From 1e4a46c4bfb6a3a4309151c7f4b15abe4c79db43 Mon Sep 17 00:00:00 2001 From: Malar Invention Date: Mon, 13 Jul 2026 11:10:57 +0530 Subject: [PATCH] setup wizard: fix routing, DHCP, WiFi AP, firewall, and deploy task Setup script fixes: - _cidr_to_mask: pad to 4 octets (/24 -> 255.255.255.0) - UCI quoting: remove embedded shell quotes from uci set calls - Bridge ports: auto-detect zt* interface instead of hardcoding ztabc0 - Bridge netmask: default to /23 (255.255.254.0) for ZT+WIBLAN - DHCP/WiFi AP: reference interface name (zt_wiblan) not device name (br_zt) - Firewall zone: add zt_wiblan to LAN zone for nftables fw4 - ZT IP persistence: ensure ZT-assigned IP stays on interface for ARP - Exit gateway routing: table 100/101 route via exit gateway, not self - New setup-wifi-ap subcommand for WIBLAN WiFi AP UBUS handler: - Add setup-wifi-ap to validation regex and error message Deploy task: - Auto-discover files from root/ and htdocs/ instead of hardcoded list - Clear LuCI cache before restarting services Documentation: - New docs/SETUP-GATEWAY.md with architecture, config, pitfalls, checklist - Updated docs/INSTALL.md with deploy task and setup wizard sections - Updated docs/PROGRESS.md with session log and learnings --- .gitignore | 3 - .omp/plans/add-setup-screen.md | 279 ++++++++++++++ .../plans/allow-reactivate-current-gateway.md | 66 ++++ .omp/plans/install-docs-and-deploy-task.md | 75 ++++ docs/INSTALL.md | 35 +- docs/PROGRESS.md | 58 +++ docs/SETUP-GATEWAY.md | 350 ++++++++++++++++++ .../resources/view/zt-gateway/overview.js | 1 + mise.toml | 44 +-- root/usr/sbin/zt-gateway-setup | 211 +++++++++-- root/usr/share/rpcd/ucode/zt-gateway.uc | 4 +- 11 files changed, 1045 insertions(+), 81 deletions(-) create mode 100644 .omp/plans/add-setup-screen.md create mode 100644 .omp/plans/allow-reactivate-current-gateway.md create mode 100644 .omp/plans/install-docs-and-deploy-task.md create mode 100644 docs/SETUP-GATEWAY.md diff --git a/.gitignore b/.gitignore index 06e795a..bdfa653 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,3 @@ node_modules/ # Test artifacts test-results/ e2e-report/ - -# Harness -.omp/ diff --git a/.omp/plans/add-setup-screen.md b/.omp/plans/add-setup-screen.md new file mode 100644 index 0000000..24b2bbb --- /dev/null +++ b/.omp/plans/add-setup-screen.md @@ -0,0 +1,279 @@ +# Plan: One-Time Setup Screen for luci-app-zt-gateway + +## Problem + +When `luci-app-zt-gateway` is installed on a fresh OpenWrt router, the switch script fails with `Cannot find device "br-zt"` because the prerequisite networking (bridge, routing, hotplug, DHCP) doesn't exist yet. There's no guided setup — the user must manually configure everything via CLI. + +## Goal + +Add a **Setup** section to the overview UI that: +1. Detects what's already configured vs. what's missing +2. Lets the user run each setup step individually or all at once +3. Shows real-time status after each step +4. For WiFi AP, displays the UCI config to add (user confirms) + +## Production Router State (reference) + +The router at `root@10.11.12.254` shows the target state: + +| Component | UCI/Config | Runtime | +|---|---|---| +| br-zt bridge | `config device type bridge name br-zt` | `ip link show br-zt` | +| brzt interface | `config interface brzt proto static device br-zt ipaddr 10.11.12.254` | `ip addr show br-zt` | +| ZT interface | `config interface zerotier proto none device ztk4jpk77j` | `ip link show ztk4jpk77j` | +| ZT enslavement | hotplug script | `brctl show br-zt` | +| Policy rule | `config rule src 10.11.13.0/24 lookup 100 priority 100` | `ip rule show` | +| Table 100 default | `config route interface brzt target 0.0.0.0 gateway 10.11.12.3 table 100` | `ip route show table 100` | +| Host route | `config route interface brzt target 10.11.12.3` | `ip route show` | +| WIBLAN return | `config route interface brzt target 10.11.13.0 table 100` | — | +| DHCP | `config dhcp brzt interface brzt start 257 limit 254` | — | +| WIBLAN AP | `config wifi-iface wifinet0 device radio0 ssid WIBLAN network brzt` | — | +| IP forwarding | sysctl | `cat /proc/sys/net/ipv4/ip_forward` → 1 | +| Hotplug | `/etc/hotplug.d/net/99-zerotier-bridge` | — | +| rc.local | routes in `/etc/rc.local` | — | + +## Architecture + +### 1. Setup Script: `root/usr/sbin/zt-gateway-setup` + +A new POSIX shell script with subcommands. Idempotent — running any step twice is safe. + +``` +Usage: zt-gateway-setup + +Commands: + status Show setup status for all components + setup-bridge Create br-zt bridge and detect ZT interface + setup-routing Configure ip rules, table 100, ip forwarding + setup-hotplug Write /etc/hotplug.d/net/99-zerotier-bridge + setup-persistence Write rc.local + UCI network routes + setup-dhcp Configure DHCP for WIBLAN subnet + setup-all Run all setup steps in order +``` + +**Environment variables** (same pattern as `zt-gateway-switch`): +- `ZTG_BRIDGE` (default: `br-zt`) +- `ZTG_WIBLAN_CIDR` (default: `10.11.13.0/24`) +- `ZTG_TABLE_MAIN` (default: `100`) +- `ZTG_ZT_NETWORK_ID` (default: auto-detect from `zerotier-cli listnetworks`) + +**`status` output** (JSON, for ubus consumption): +```json +{ + "zt_client": true, + "zt_network_joined": true, + "zt_interface": "ztk4jpk77j", + "bridge_exists": true, + "bridge_has_zt": true, + "bridge_has_address": true, + "routing_ready": true, + "ip_forwarding": true, + "hotplug_script": true, + "persistence": true, + "dhcp_configured": true +} +``` + +**Key implementation details:** + +#### `setup-bridge` +1. Find ZT interface: `zerotier-cli listnetworks` → parse `` column +2. If no ZT interface found → error "ZeroTier not joined to any network" +3. Create UCI device: `uci set network.zt_bridge=bridge; uci set network.zt_bridge.name=$BRIDGE; uci set network.zt_bridge.bridge_empty=1` +4. Create UCI interface: `uci set network.brzt=interface; uci set network.brzt.proto=static; uci set network.brzt.device=$BRIDGE` +5. Set IP from UCI config (read `global` section for WIBLAN CIDR, derive gateway IP) +6. Create UCI zerotier interface: `uci set network.zerotier=interface; uci set network.zerotier.proto=none; uci set network.zerotier.device=$ZT_IFACE` +7. `uci commit network` +8. Bring up: `ifup brzt; ifup zerotier` +9. Runtime fallback: if `ifup` doesn't enslave, do it manually: + ``` + ip link add name $BRIDGE type bridge 2>/dev/null || true + ip link set $BRIDGE up + ip link set $ZT_IFACE master $BRIDGE + ``` + +#### `setup-routing` +1. `echo 1 > /proc/sys/net/ipv4/ip_forward` +2. Persist: add `net.ipv4.ip_forward=1` to `/etc/sysctl.d/99-zt-gateway.conf` + `sysctl -p` +3. `ip rule del from $WIBLAN_CIDR table $TABLE_MAIN 2>/dev/null || true` +4. `ip rule add from $WIBLAN_CIDR table $TABLE_MAIN priority 100` +5. Read `active_gateway` from UCI → get IP from gateway config +6. `ip route replace default via $GW_IP dev $BRIDGE table $TABLE_MAIN` +7. `ip route replace $GW_IP dev $BRIDGE` (host route) +8. `ip route replace $WIBLAN_CIDR dev $BRIDGE table 1` (MWAN return) + +#### `setup-hotplug` +Write `/etc/hotplug.d/net/99-zerotier-bridge`: +```sh +#!/bin/sh +# Auto-generated by zt-gateway-setup — do not edit manually. +# Re-enslaves ZT interface to br-zt and restores routes on reconnect. +[ "$INTERFACE" = "$ZT_IFACE" ] && [ "$ACTION" = "add" ] && { + ip link set $ZT_IFACE master $BRIDGE 2>/dev/null || brctl addif $BRIDGE $ZT_IFACE 2>/dev/null + ip route del $ZT_SUBNET dev $ZT_IFACE 2>/dev/null + ip route replace $WIBLAN_CIDR dev $BRIDGE table 1 2>/dev/null + ip route replace $GW_IP dev $BRIDGE 2>/dev/null + ip route replace default via $GW_IP dev $BRIDGE table $TABLE_MAIN 2>/dev/null +} +``` +The IPs are hardcoded (matching how `persist_all` uses `sed` to update them on switch). + +#### `setup-persistence` +Write rc.local entries and UCI network routes (already handled by `persist_all` in the switch script, but `setup-persistence` creates the initial entries). + +#### `setup-dhcp` +Write to `/etc/config/dhcp` (NOT `/etc/config/network`): +```sh +uci set dhcp.brzt=dhcp +uci set dhcp.brzt.interface='brzt' +uci set dhcp.brzt.start=257 +uci set dhcp.brzt.limit=254 +uci set dhcp.brzt.leasetime='12h' +uci add_list dhcp.brzt.dhcp_option='3,$WIBLAN_GW' # gateway +uci add_list dhcp.brzt.dhcp_option='6,8.8.8.8,1.1.1.1' # DNS +uci commit dhcp +``` +The gateway IP is the router's own IP on br-zt (read from UCI `network.brzt.ipaddr` after bridge setup). + +### 2. Backend: `root/usr/share/rpcd/ucode/zt-gateway.uc` + +Add a new `setup` ubus method: + +```js +setup: { + args: { + action: 'string' // 'status' | 'setup-bridge' | 'setup-routing' | ... + }, + call: function(req) { + const action = req.args?.action || 'status'; + const result = system_output(`/usr/sbin/zt-gateway-setup ${shell_quote(action)}`); + // Parse JSON output, return structured result + } +} +``` + +Also update the ACL in `root/usr/share/rpcd/acl.d/luci-app-zt-gateway.json` to allow the `setup` method. + +### 3. UI: `htdocs/luci-static/resources/view/zt-gateway/overview.js` + +Add a **Setup** section to the overview page. When setup is incomplete, show it prominently at the top. When complete, collapse it or hide it. + +#### Setup Panel Layout + +``` +┌─────────────────────────────────────────────────────────┐ +│ Setup [Status] │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ ☑ ZeroTier client installed │ +│ ☑ Network "whiteblossom" joined (ztk4jpk77j) │ +│ │ +│ ☐ Bridge (br-zt) [Setup] │ +│ Creates br-zt and enslaves ZT interface │ +│ │ +│ ☐ Routing (ip rules + table 100) [Setup] │ +│ Configures policy routing for WIBLAN traffic │ +│ │ +│ ☐ Hotplug script [Setup] │ +│ Restores routes when ZT reconnects │ +│ │ +│ ☐ Boot persistence [Setup] │ +│ Routes survive reboot │ +│ │ +│ ☐ DHCP for WIBLAN [Setup] │ +│ Assigns IPs to WiFi clients (10.11.13.0/24) │ +│ │ +│ ───────────────────────────────────────────────────── │ +│ WiFi AP (WIBLAN) [Guide] │ +│ Add to /etc/config/wireless: │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ config wifi-iface 'wifinetN' │ │ +│ │ option device 'radio0' │ │ +│ │ option mode 'ap' │ │ +│ │ option ssid 'WIBLAN' │ │ +│ │ option encryption 'psk2' │ │ +│ │ option key '' │ │ +│ │ option network 'brzt' │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ [Run All Setup Steps] │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +#### UI Behavior + +1. **On page load**: Call `zt-gateway.setup { action: 'status' }` to get current state +2. **Render checklist**: Each step shows ✓ (green) or ✗ (gray) with a [Setup] button +3. **[Setup] button**: Calls `zt-gateway.setup { action: 'setup-bridge' }` (etc.), then refreshes status +4. **[Run All]**: Calls `zt-gateway.setup { action: 'setup-all' }`, then refreshes status +5. **WiFi Guide**: Shows the UCI config snippet to add (read-only, with copy button) +6. **After all steps complete**: Collapse the setup section, show normal overview + +#### Status Detection Logic (in `zt-gateway-setup status`) + +| Check | How | +|---|---| +| ZT client installed | `command -v zerotier-cli` | +| ZT network joined | `zerotier-cli listnetworks 2>/dev/null \| grep -q OK` | +| ZT interface name | Parse `zerotier-cli listnetworks` → `` column | +| Bridge exists | `ip link show $BRIDGE 2>/dev/null` | +| Bridge has ZT | `brctl show $BRIDGE 2>/dev/null \| grep -q $ZT_IFACE` | +| Bridge has address | `ip -o -4 addr show dev $BRIDGE 2>/dev/null` | +| IP forwarding | `cat /proc/sys/net/ipv4/ip_forward` | +| Policy rule | `ip rule show \| grep "from $WIBLAN_CIDR.*lookup $TABLE_MAIN"` | +| Table 100 default | `ip route show table $TABLE_MAIN \| grep -q "default via"` | +| Hotplug script | `[ -x /etc/hotplug.d/net/99-zerotier-bridge ]` | +| Persistence | Check rc.local has routes | +| DHCP | `uci get dhcp.brzt.interface 2>/dev/null` | +### 4. Files to Create/Modify + +| File | Action | Purpose | +|---|---|---| +| `root/usr/sbin/zt-gateway-setup` | **Create** | Setup script with subcommands | +| `root/usr/share/rpcd/ucode/zt-gateway.uc` | **Modify** | Add `setup` ubus method | +| `root/usr/share/rpcd/acl.d/luci-app-zt-gateway.json` | **Modify** | Add `setup` to ubus read/write ACL | +| `htdocs/luci-static/resources/view/zt-gateway/overview.js` | **Modify** | Add setup panel section | +| `root/etc/config/zt-gateway` | **Modify** | Add `wiblan_subnet` and `zt_network_id` options to global | +| `docs/INSTALL.md` | **Modify** | Document setup screen in installation guide | + +### 5. UCI Config Additions + +Add to `config global` in `root/etc/config/zt-gateway`: +``` +option wiblan_subnet '10.11.13.0/24' +option wiblan_gateway '10.11.12.254' +option wiblan_dhcp_start '257' +option wiblan_dhcp_limit '254' +option zt_network_id '' +``` + +These allow the setup script and UI to derive all IP addresses from config rather than hardcoding. + +### 6. Verification + +1. **Deploy to router**: `HOST=root@10.11.12.254 mise run deploy:install` +2. **Open UI**: Navigate to Services > ZeroTier Gateway +3. **Setup panel**: Should show checklist with current state (most items ✓ since router is pre-configured) +4. **Fresh test**: Deploy to a clean OpenWrt container (`docker compose up openwrt-luci`), verify setup panel shows all ✗ +5. **Run setup**: Click [Run All], verify all items turn ✓ +6. **Switch test**: After setup, select a gateway and switch — should succeed +7. **Reboot test**: After setup, reboot — verify routes survive + +### 7. Implementation Order + +1. Create `zt-gateway-setup` script with `status` command only +2. Add `setup` ubus method to backend +3. Add ACL for `setup` method +4. Add setup panel to UI (read-only status display) +5. Implement `setup-bridge` in script +6. Implement `setup-routing` in script +7. Implement `setup-hotplug` in script +8. Implement `setup-persistence` in script +9. Implement `setup-dhcp` in script +10. Implement `setup-all` in script +11. Wire [Setup] buttons in UI to call backend +12. Add WiFi AP guide section +13. Update UCI config with new options +14. Update deploy task and docs +15. Test end-to-end diff --git a/.omp/plans/allow-reactivate-current-gateway.md b/.omp/plans/allow-reactivate-current-gateway.md new file mode 100644 index 0000000..01880bd --- /dev/null +++ b/.omp/plans/allow-reactivate-current-gateway.md @@ -0,0 +1,66 @@ +# Plan: Allow re-activating the current gateway from the UI + +## Problem + +The UI shows "active" on a gateway based solely on the UCI `active_gateway` config value, but the switch script (`zt-gateway-switch`) may never have been run — meaning no routing tables, no masquerade, no actual traffic forwarding. The user sees "active" and assumes it works. + +Two blocking issues prevent fixing this from the UI: + +1. **Backend** (`zt-gateway.uc` line 184-187): `switch` method short-circuits with "Already on X" when UCI `active_gateway` matches the requested region — never runs the script +2. **UI** (`overview.js` line 81): radio button for the active gateway is `disabled`, so the user can't even select it to click "Switch to selected" + +## Fix + +Two files, one change each. + +### 1. Backend: `root/usr/share/rpcd/ucode/zt-gateway.uc` + +**Remove the "Already on X" early return** (lines 184-187): + +```js +// DELETE these lines: +const current_region = read_active_region(); +if (current_region === region && !drain_active()) { + return { success: true, message: `Already on ${region}.` }; +} +``` + +**Why this is safe:** +- `do_force()` in the switch script is idempotent: `ip route replace` is a no-op when the route already matches, conntrack flush is harmless, `persist_all` writes the same values +- The backend still updates UCI after the script runs (line 198-199) — setting the same value is harmless +- If the gateway is unreachable, `preflight_ping` fails with exit 2 and the backend returns the error — same as switching to any other unreachable gateway + +### 2. UI: `htdocs/luci-static/resources/view/zt-gateway/overview.js` + +**Remove the `disabled` attribute from the active gateway's radio** (line 81): + +Change: +```js +disabled: isActive || null +``` +To: +```js +// Remove this line entirely (or keep disabled only during an active drain) +``` + +**Why this is safe:** +- The user can now select the active gateway and click "Switch to selected" +- The backend runs the switch script which sets up routing +- If routing is already correct, the script is a harmless idempotent no-op +- The "Switch to selected" button text still makes sense — it re-applies the gateway config + +### Files to modify + +| File | Change | +|---|---| +| `root/usr/share/rpcd/ucode/zt-gateway.uc` | Remove lines 184-187 (early return) | +| `htdocs/luci-static/resources/view/zt-gateway/overview.js` | Remove `disabled` on line 81 | + +### Verification + +1. Deploy to device: `mise run deploy:install` +2. Open UI → amsterdam shows "active" → radio is now enabled +3. Select amsterdam → click "Switch to selected" → should succeed and set up routing +4. Verify routing: `ip route show table 100` should show `default via 10.11.12.3 dev ztk4jpk77j` +5. Verify NAT: `iptables -t mangle -L -n` should show WIBLAN mangle rules (for graceful mode) +6. Test from LAN client: `ping -I br-lan 10.11.12.3` should now work diff --git a/.omp/plans/install-docs-and-deploy-task.md b/.omp/plans/install-docs-and-deploy-task.md new file mode 100644 index 0000000..cb3ed53 --- /dev/null +++ b/.omp/plans/install-docs-and-deploy-task.md @@ -0,0 +1,75 @@ +# Plan: Document installation guide + add deploy mise task + +## What + +1. Create `docs/INSTALL.md` — installation guide for luci-app-zt-gateway covering all methods +2. Add `deploy:install` task to `mise.toml` — one-command SCP + install to a real OpenWrt device + +## 1. `docs/INSTALL.md` + +New file covering all 6 installation methods, researched from official OpenWrt docs: + +| Method | When to use | +|---|---| +| **LuCI web UI** | End users, official feeds | +| **CLI** (`apk add` / `opkg install`) | Headless / SSH users | +| **Custom feed** | Distributing third-party packages via HTTP | +| **Local file SCP** | Dev/testing, quick iteration | +| **OpenWrt SDK** | Building proper .ipk/.apk with dependency metadata | +| **Image Builder / ASU** | Production firmware, survives factory reset | + +Key details to document: +- OpenWrt 25.12+ uses `apk` (not `opkg`); older uses `opkg` +- `apk add --allow-untrusted` required for unsigned local packages +- `opkg install /tmp/pkg.ipk` for opkg-based systems +- SDK build: `make package/luci-app-zt-gateway/compile V=s` +- ASU: `luci-app-attendedsysupgrade` → Advanced Mode → add package +- Prerequisites: `luci-base`, `ucode`, `rpcd-mod-ucode`, `luci-compat` +- Custom feed: `src/gz` in `/etc/opkg/customfeeds.conf` or apk equivalent + +## 2. `mise.toml` — add `deploy:install` task + +Following snowbud patterns: env var params, usage validation, step-by-step echo output. + +### Task design + +```toml +[tasks."deploy:install"] +description = "SCP app files to an OpenWrt device and install" +``` + +**Parameters** (via env vars): +- `HOST` — SSH target, default `root@192.168.15.1` + +**What it does:** +1. Validate `ssh` connectivity to `$HOST` +2. SCP each file from `root/` to its corresponding remote path (stripping the leading `root` prefix) +3. SCP `htdocs/luci-static/resources/view/zt-gateway/overview.js` → `/www/luci-static/resources/view/zt-gateway/overview.js` +4. `chmod +x` the `zt-gateway-switch` script on the remote +5. Restart `rpcd` and `uhttpd` so LuCI picks up changes +6. Echo the URL to open + +### File mapping (local → remote) + +| Local | Remote | +|---|---| +| `root/usr/sbin/zt-gateway-switch` | `/usr/sbin/zt-gateway-switch` | +| `root/usr/share/rpcd/ucode/zt-gateway.uc` | `/usr/share/rpcd/ucode/zt-gateway.uc` | +| `root/usr/share/rpcd/ucode/system.uc` | `/usr/share/rpcd/ucode/system.uc` | +| `root/usr/share/ucode/luci/runtime.uc` | `/usr/share/ucode/luci/runtime.uc` | +| `root/usr/share/luci/menu.d/luci-app-zt-gateway.json` | `/usr/share/luci/menu.d/luci-app-zt-gateway.json` | +| `root/usr/share/rpcd/acl.d/luci-app-zt-gateway.json` | `/usr/share/rpcd/acl.d/luci-app-zt-gateway.json` | +| `root/etc/config/zt-gateway` | `/etc/config/zt-gateway` | +| `htdocs/.../overview.js` | `/www/.../overview.js` | + +## Files to modify + +| File | Action | +|---|---| +| `docs/INSTALL.md` | **Create** | +| `mise.toml` | **Edit** — append `deploy:install` task | + +## Verification + +1. `mise tasks` should list the new `deploy:install` task +2. `docs/INSTALL.md` should be readable and cover all 6 methods diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 90f5fd2..8ee5674 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -42,36 +42,32 @@ opkg install luci-base luci-compat ucode rpcd-mod-ucode luci-theme-bootstrap The fastest way during development. No build step required. -```bash -HOST=root@192.168.15.1 - -# Upload files -scp root/usr/sbin/zt-gateway-switch $HOST:/usr/sbin/ -scp root/usr/share/rpcd/ucode/zt-gateway.uc $HOST:/usr/share/rpcd/ucode/ -scp root/usr/share/rpcd/ucode/system.uc $HOST:/usr/share/rpcd/ucode/ -scp root/usr/share/ucode/luci/runtime.uc $HOST:/usr/share/ucode/luci/ -scp root/usr/share/luci/menu.d/luci-app-zt-gateway.json $HOST:/usr/share/luci/menu.d/ -scp root/usr/share/rpcd/acl.d/luci-app-zt-gateway.json $HOST:/usr/share/rpcd/acl.d/ -scp root/etc/config/zt-gateway $HOST:/etc/config/ -scp htdocs/luci-static/resources/view/zt-gateway/overview.js $HOST:/www/luci-static/resources/view/zt-gateway/ - -# Set permissions and restart services -ssh $HOST 'chmod +x /usr/sbin/zt-gateway-switch; /etc/init.d/rpcd restart; /etc/init.d/uhttpd restart' -``` - -Or use the mise task (same thing, one command): - ```bash mise run deploy:install # or with a custom target: HOST=root@10.0.0.1 mise run deploy:install ``` +The deploy task auto-discovers all files from `root/` and `htdocs/`, +maps them to device paths, and restarts services. New files are +automatically included without editing the task. + Open `http:///cgi-bin/luci/admin/services/zt-gateway`. **Note:** This method has no dependency tracking. The package manager won't know about the installed files. Use for dev only. +### Setting up as Exit Gateway + +After deploying, run the setup wizard from the LuCI UI (Setup panel) +or CLI: + +```bash +ssh root@ '/usr/sbin/zt-gateway-setup setup-all' +``` + +See [SETUP-GATEWAY.md](SETUP-GATEWAY.md) for detailed configuration +and troubleshooting. --- ## Method 2: Local Package Install via SCP @@ -221,6 +217,7 @@ These are the files installed by this package and their target locations: | Source | Target | Purpose | |---|---|---| +| `root/usr/sbin/zt-gateway-setup` | `/usr/sbin/zt-gateway-setup` | Gateway setup script | | `root/usr/sbin/zt-gateway-switch` | `/usr/sbin/zt-gateway-switch` | Gateway switching script | | `root/usr/share/rpcd/ucode/zt-gateway.uc` | `/usr/share/rpcd/ucode/zt-gateway.uc` | rpcd backend | | `root/usr/share/rpcd/ucode/system.uc` | `/usr/share/rpcd/ucode/system.uc` | system.board rpcd override | diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index bf944d6..53f6977 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -95,3 +95,61 @@ After installing `luci-compat`, login and page rendering work correctly, but **f 4. **Run full suite end-to-end** after the above blocker is resolved. 5. **Clean up debug scripts**: `debug-pw.js`, `debug-pw2.js`, `debug-pw3.js`, `debug-login-dom.js`. 6. **Rebuild image properly** once build-time network is restored so `luci-compat` is baked in without manual commits. + +## Date: 2026-07-13 + +## Setup Wizard Implementation + +### Completed + +1. **Created `/usr/sbin/zt-gateway-setup`** (700+ lines) with subcommands: + - `status`, `setup-bridge`, `setup-routing`, `setup-dhcp`, `setup-wifi-ap`, + `setup-hotplug`, `setup-persistence`, `setup-all` + +2. **Added `setup` ubus method** to `zt-gateway.uc` with regex validation + (ucode lacks `Array.indexOf()`). + +3. **Added Setup panel** to `overview.js` with buttons for each setup command. + +4. **Auto-discover deploy task** — `deploy:install` now discovers files from + `root/` and `htdocs/` instead of listing them individually. + +### Bugs Found and Fixed During Setup + +1. **`_cidr_to_mask` produced 3 octets** for /24 (`255.255.255` instead of + `255.255.255.0`). Fixed by padding to 4 octets. + +2. **UCI values had embedded quotes** — `uci set "proto='static'"` stored + `'static'` instead of `static`. Fixed by removing shell quotes. + +3. **Bridge ports hardcoded to `ztabc0`** — ZeroTier interface names are + randomized. Fixed by auto-detecting `zt*` interfaces. + +4. **DHCP/WiFi AP referenced device name instead of interface name** — dnsmasq + and hostapd bind to interfaces, not devices. Fixed to use `zt_wiblan`. + +5. **Firewall zone missing** — nftables fw4 has `policy drop`. Fixed by adding + `zt_wiblan` to the LAN zone. + +6. **ZT interface lost IP when added to bridge** — ARP responses failed. Fixed + by ensuring ZT-assigned IP stays on the interface. + +7. **Table 100 routed to self** — Default route pointed to `WIBLAN_GW` (local) + instead of exit gateway. Fixed by detecting exit gateway from UCI/routes. + +8. **Bridge netmask /24 instead of /23** — Couldn't reach ZT subnet. Fixed by + defaulting to `/23` (255.255.254.0). + +9. **ZeroTier Ethernet Bridging disabled** — L2 frames couldn't traverse + tunnel. Fixed by enabling in ZT network controller. + +10. **DHCP range wrong in /23** — Clients got `10.11.12.x` instead of + `10.11.13.x`. Fixed by calculating correct offset (356 for /23 base). + +### Key Learnings + +- OpenWrt UCI uses **interface names** (not device names) for DHCP and WiFi +- Bridge netmask must be `/23` to cover both ZT (10.11.12.x) and WIBLAN (10.11.13.x) +- ZeroTier requires "Allow Ethernet Bridging" for L2 traffic +- nftables fw4 zones must explicitly include bridge interfaces +- Policy routing table 100 must route via exit gateway, not local IP diff --git a/docs/SETUP-GATEWAY.md b/docs/SETUP-GATEWAY.md new file mode 100644 index 0000000..db3c54b --- /dev/null +++ b/docs/SETUP-GATEWAY.md @@ -0,0 +1,350 @@ +# ZeroTier Exit Gateway Setup Guide + +This document covers configuring an OpenWrt router as a ZeroTier exit gateway, +including the common pitfalls encountered during development. + +## Architecture + +``` +WiFi Client (10.11.13.x) + │ + ▼ +WIBLAN AP (phy0-ap2) ─── br-zt bridge ─── ZeroTier (ztk4jpk77j) + │ │ + │ ZeroTier tunnel + │ │ + │ ▼ + │ Exit Gateway (amsterdam) + │ │ + │ Internet (NAT) + │ + ├── DHCP (dnsmasq on br-zt) + ├── DNS (dnsmasq → upstream) + └── Policy Routing (table 100 → exit gateway) +``` + +## Prerequisites + +### ZeroTier Network Configuration + +**Critical**: Enable "Allow Ethernet Bridging" on the ZeroTier network controller +at [my.zerotier.com](https://my.zerotier.com) → network → Settings. + +Without this, L2 frames (ARP, DHCP) cannot be bridged across peers, and clients +will get IPs but cannot communicate. + +### ZeroTier Managed Routes + +The ZeroTier network must have a managed route for the WIBLAN subnet: + +``` +Managed Routes → Add: 10.11.13.0/24 → (empty = auto via member) +``` + +This tells ZeroTier to route traffic for `10.11.13.0/24` through the exit gateway. + +## Setup Script Commands + +The `zt-gateway-setup` script provides these commands: + +| Command | Purpose | +|---|---| +| `setup-bridge` | Create `br-zt` bridge, add ZT interface, configure firewall | +| `setup-routing` | Policy routing (tables 100/101, ip rules) | +| `setup-dhcp` | DHCP for WIBLAN clients on `br-zt` | +| `setup-wifi-ap` | Create WIBLAN WiFi AP bridged to `br-zt` | +| `setup-hotplug` | Hotplug script to re-apply routes on ifup | +| `setup-persistence` | rc.local + UCI routes for reboot survival | +| `setup-all` | Run all commands in order | + +Run from LuCI UI or CLI: +```bash +/usr/sbin/zt-gateway-setup setup-all +``` + +## Network Configuration + +### Bridge Device + +```uci +config device 'br_zt' + option type 'bridge' + option name 'br-zt' + list ports 'ztk4jpk77j' # ZeroTier interface +``` + +**Note**: UCI section names cannot contain hyphens. Use underscores (`br_zt`) +for section names, but the actual device name uses hyphens (`br-zt`). + +### Bridge Interface + +```uci +config interface 'zt_wiblan' + option proto 'static' + option device 'br-zt' + option ipaddr '10.11.13.1' + option netmask '255.255.254.0' # /23 to cover ZT (10.11.12.x) + WIBLAN (10.11.13.x) +``` + +**Key**: The netmask MUST be `/23` (255.255.254.0), not `/24`. The bridge needs +to be in the same subnet as the ZeroTier network (10.11.12.0/23) for ARP to work. + +### ZeroTier Interface + +```uci +config interface 'wbtier' + option proto 'none' + option device 'ztk4jpk77j' +``` + +**Important**: The ZT interface must keep its assigned IP even when added to +the bridge. If the IP is lost, ARP responses fail and connectivity breaks. + +## Firewall Configuration (nftables fw4) + +### Add Bridge Interface to LAN Zone + +```uci +config zone + option name 'lan' + list network 'lan' + list network 'zt_wiblan' # Add this +``` + +Without this, nftables fw4's default `drop` policy blocks all traffic from `br-zt`. + +### Verify + +```bash +nft list chain inet fw4 input | grep br-zt +# Should show: iifname { "br-zt", ... } jump input_lan +``` + +## Routing Configuration + +### Policy Routing Rules + +```uci +config rule + option src '10.11.13.0/24' + option lookup '100' + option priority '100' +``` + +This routes traffic FROM WIBLAN clients through table 100. + +### Table 100 (Main Policy) + +```uci +config route + option interface 'brzt' # Interface name, not device + option target '0.0.0.0' + option netmask '0.0.0.0' + option gateway '10.11.12.3' # Exit gateway IP (NOT WIBLAN_GW) + option table '100' + +config route + option interface 'brzt' + option target '10.11.13.0' + option netmask '255.255.255.0' + option table '100' +``` + +**Critical**: The default route in table 100 MUST point to the exit gateway IP +(e.g., `10.11.12.3`), NOT to the local bridge IP (`10.11.13.1`). Using the +local IP creates a routing loop. + +## DHCP Configuration + +```uci +config dhcp 'br_zt' + option interface 'zt_wiblan' # Interface name, NOT device name + option start '356' # Offset in /23: 10.11.12.0 + 356 = 10.11.13.100 + option limit '101' # 101 addresses: 10.11.13.100 - 10.11.13.200 + option leasetime '12h' + list dhcp_option '3,10.11.13.1' # Gateway + list dhcp_option '6,10.11.13.1' # DNS (use router's dnsmasq) +``` + +**Key points**: +- `interface` must reference the **interface** name (`zt_wiblan`), not the + device name (`br-zt` or `br_zt`). dnsmasq binds to interfaces, not devices. +- In a `/23` network, `start` is an offset from the network base + (`10.11.12.0`). To get `10.11.13.100`, use offset `356` (256 + 100). +- DNS should point to the router's dnsmasq (`10.11.13.1`) for reliability. + Direct `8.8.8.8` works but adds routing complexity. + +## WiFi AP Configuration + +```uci +config wifi-iface 'wifinetN' + option device 'radio0' + option mode 'ap' + option ssid 'WIBLAN' + option encryption 'psk2' + option key 'your-key' + option network 'zt_wiblan' # Interface name, NOT device name +``` + +**Same rule as DHCP**: `network` must reference the **interface** name, +not the device name. + +After configuration: +```bash +wifi reload +# Verify bridge membership: +brctl show br-zt +# Should show both ztk4jpk77j and phy0-apX +``` + +## Common Pitfalls + +### 1. Bridge Port Has No IP (ARP Fails) + +**Symptom**: One-way connectivity (A→B works, B→A doesn't). + +**Cause**: When the ZT interface is added to a bridge, its assigned IP can be +lost. Without an IP, the interface cannot respond to ARP requests. + +**Fix**: Ensure the ZT interface keeps its assigned IP: +```bash +# Detect ZT IP: +ZT_IP=$(zerotier-cli listnetworks | awk '/OK/{for(i=6;i<=NF;i++) if($i~/\//){split($i,a,"/"); print a[1]; exit}}') +ZT_BITS=$(zerotier-cli listnetworks | awk '/OK/{for(i=6;i<=NF;i++) if($i~/\//){split($i,a,"/"); print a[2]; exit}}') + +# Add to ZT interface: +ip addr add "${ZT_IP}/${ZT_BITS}" dev ztk4jpk77j +``` + +### 2. Wrong Interface Reference in UCI + +**Symptom**: dnsmasq doesn't serve DHCP, WiFi AP not bridged. + +**Cause**: Using device name (`br-zt`, `br_zt`) instead of interface name +(`zt_wiblan`) in `dhcp.*.interface` or `wireless.*.network`. + +**Fix**: Always reference the **interface** name: +```bash +uci set dhcp.br_zt.interface=zt_wiblan +uci set wireless.wifinet1.network=zt_wiblan +``` + +### 3. Firewall Zone Missing + +**Symptom**: Traffic from WIBLAN clients is silently dropped. + +**Cause**: nftables fw4 has `policy drop` on INPUT/FORWARD. The bridge +interface isn't in any firewall zone. + +**Fix**: Add the interface to the LAN zone: +```bash +uci add_list firewall.@zone[0].network=zt_wiblan +uci commit firewall +/etc/init.d/firewall restart +``` + +### 4. DHCP Range Wrong in /23 + +**Symptom**: Clients get IPs in wrong subnet (e.g., `10.11.12.x` instead +of `10.11.13.x`). + +**Cause**: In a `/23` network, dnsmasq's `start` is an offset from the +network base (`10.11.12.0`), not from `10.11.13.0`. + +**Fix**: Calculate correct offset: +``` +10.11.13.100 = 10.11.12.0 + 356 → start=356 +10.11.13.200 = 10.11.12.0 + 456 → limit=101 (356+101-1=456) +``` + +### 5. Table 100 Routes to Self + +**Symptom**: Client traffic loops back to the gateway. + +**Cause**: Table 100 default route points to `WIBLAN_GW` (local bridge IP) +instead of the exit gateway IP. + +**Fix**: Route via the exit gateway: +```bash +ip route replace default via 10.11.12.3 dev ztk4jpk77j table 100 +``` + +### 6. UCI Values Have Embedded Quotes + +**Symptom**: UCI values contain literal single quotes (e.g., `'static'` +instead of `static`). + +**Cause**: Shell quotes in `uci set` commands are passed as part of the value: +```bash +# WRONG: +uci set "network.zt_wiblan.proto='static'" # Value becomes 'static' +# RIGHT: +uci set "network.zt_wiblan.proto=static" # Value becomes static +``` + +### 7. ZeroTier Ethernet Bridging Disabled + +**Symptom**: WiFi clients get DHCP leases but cannot reach gateway or internet. + +**Cause**: ZeroTier network controller has "Allow Ethernet Bridging" disabled. +L2 frames (ARP, DHCP) cannot traverse the tunnel. + +**Fix**: Enable at my.zerotier.com → network → Settings → "Allow Ethernet +Bridging". + +## Verification Checklist + +After setup, verify each component: + +```bash +# 1. Bridge membership +brctl show br-zt +# Should show: ztk4jpk77j + phy0-apX + +# 2. Bridge IPs +ip addr show br-zt +# Should show: 10.11.13.1/23 + +# 3. ZT interface IP +ip addr show ztk4jpk77j +# Should show: 10.11.12.x/23 + +# 4. Firewall zones +nft list chain inet fw4 input | grep br-zt +# Should show: iifname { "br-zt", ... } jump input_lan + +# 5. Policy routing +ip rule show | grep "from 10.11.13.0/24" +# Should show: 100: from 10.11.13.0/24 lookup 100 + +# 6. Table 100 route +ip route show table 100 +# Should show: default via 10.11.12.3 dev ztk4jpk77j + +# 7. DHCP +cat /tmp/dhcp.leases | grep 10.11.13 +# Should show client leases + +# 8. Connectivity +ping -c 3 10.11.13.135 # From router to client +# From client: ping 10.11.13.1 (gateway) +# From client: ping 8.8.8.8 (internet via exit gateway) +``` + +## Environment Variables + +The setup script supports these overrides for testing: + +| Variable | Default | Description | +|---|---|---| +| `ZTG_BRIDGE` | `br-zt` | Bridge device name | +| `ZTG_BRIDGE_PORTS` | auto-detect | Space-separated bridge ports | +| `ZTG_WIBLAN_CIDR` | `10.11.13.0/24` | WIBLAN subnet | +| `ZTG_WIBLAN_GW` | `10.11.13.1` | WIBLAN gateway IP | +| `ZTG_BRIDGE_NETMASK` | `255.255.254.0` | Bridge netmask (/23) | +| `ZTG_TABLE_MAIN` | `100` | Main policy table | +| `ZTG_TABLE_DRAIN` | `101` | Drain policy table | +| `ZTG_WIFI_SSID` | `WIBLAN` | WiFi AP SSID | +| `ZTG_WIFI_KEY` | `zt-r0ute-2026` | WiFi AP WPA2 key | +| `ZTG_WIFI_RADIO` | auto-detect | WiFi radio device | +| `ZTG_SKIP_PERSIST` | `0` | Skip UCI persistence (testing) | diff --git a/htdocs/luci-static/resources/view/zt-gateway/overview.js b/htdocs/luci-static/resources/view/zt-gateway/overview.js index 33b7a6b..a38ab1a 100644 --- a/htdocs/luci-static/resources/view/zt-gateway/overview.js +++ b/htdocs/luci-static/resources/view/zt-gateway/overview.js @@ -137,6 +137,7 @@ return view.extend({ { cmd: 'setup-bridge', label: _('Setup Bridge'), desc: _('Create br-zt bridge interface') }, { cmd: 'setup-routing', label: _('Setup Routing'), desc: _('Configure policy routing tables') }, { cmd: 'setup-dhcp', label: _('Setup DHCP'), desc: _('Configure DHCP for WIBLAN subnet') }, + { cmd: 'setup-wifi-ap', label: _('Setup WiFi AP'), desc: _('Create WIBLAN WiFi AP bridged to br-zt') }, { cmd: 'setup-hotplug', label: _('Setup Hotplug'), desc: _('Create hotplug script for route persistence') }, { cmd: 'setup-persistence', label: _('Setup Persistence'), desc: _('Write rc.local + UCI routes') }, { cmd: 'setup-all', label: _('Run Full Setup'), desc: _('Configure everything at once') } diff --git a/mise.toml b/mise.toml index e66a1ce..0126a62 100644 --- a/mise.toml +++ b/mise.toml @@ -87,32 +87,28 @@ echo " ${HOST} is reachable." echo "==> Uploading files..." -# root/ files → strip leading root/, map to / on device -scp -O -q root/usr/sbin/zt-gateway-switch "$HOST:/usr/sbin/" -scp -O -q root/usr/sbin/zt-gateway-setup "$HOST:/usr/sbin/" -echo " zt-gateway-setup" -echo " zt-gateway-switch" -scp -O -q root/usr/share/rpcd/ucode/zt-gateway.uc "$HOST:/usr/share/rpcd/ucode/" -echo " rpcd/ucode/zt-gateway.uc" -scp -O -q root/usr/share/rpcd/ucode/system.uc "$HOST:/usr/share/rpcd/ucode/" -echo " rpcd/ucode/system.uc" -scp -O -q root/usr/share/ucode/luci/runtime.uc "$HOST:/usr/share/ucode/luci/" -echo " ucode/luci/runtime.uc" -scp -O -q root/usr/share/luci/menu.d/luci-app-zt-gateway.json "$HOST:/usr/share/luci/menu.d/" -echo " menu.d/luci-app-zt-gateway.json" -scp -O -q root/usr/share/rpcd/acl.d/luci-app-zt-gateway.json "$HOST:/usr/share/rpcd/acl.d/" -echo " acl.d/luci-app-zt-gateway.json" -scp -O -q root/etc/config/zt-gateway "$HOST:/etc/config/" -echo " config/zt-gateway" - -# Frontend view -ssh -q "$HOST" mkdir -p /www/luci-static/resources/view/zt-gateway -scp -O -q htdocs/luci-static/resources/view/zt-gateway/overview.js "$HOST:/www/luci-static/resources/view/zt-gateway/" -echo " overview.js" +# Auto-discover files from root/ and htdocs/, map to device paths. +# root/usr/sbin/foo → /usr/sbin/foo +# htdocs/luci-static/... → /www/luci-static/... +UPLOADED=0 +find root/ htdocs/ -type f | sort | while IFS= read -r src; do + case "$src" in + root/*) dest="/${src#root/}" ;; + htdocs/*) dest="/www/${src#htdocs/}" ;; + *) echo "SKIP: $src (unknown prefix)"; continue ;; + esac + destdir="${dest%/*}" + ssh -q "$HOST" mkdir -p "$destdir" + scp -O -q "$src" "$HOST:$dest" + echo " ${src#./}" + UPLOADED=$((UPLOADED + 1)) +done echo "==> Setting permissions..." -ssh -q "$HOST" chmod +x /usr/sbin/zt-gateway-switch /usr/sbin/zt-gateway-setup -echo "==> Restarting rpcd and uhttpd..." +ssh -q "$HOST" find /usr/sbin -name 'zt-gateway-*' -exec chmod +x {} + + +echo "==> Clearing LuCI cache and restarting services..." +ssh -q "$HOST" rm -rf /tmp/luci-* 2>/dev/null || true ssh -q "$HOST" /etc/init.d/rpcd restart ssh -q "$HOST" /etc/init.d/uhttpd restart diff --git a/root/usr/sbin/zt-gateway-setup b/root/usr/sbin/zt-gateway-setup index 723a308..ef5b46b 100755 --- a/root/usr/sbin/zt-gateway-setup +++ b/root/usr/sbin/zt-gateway-setup @@ -21,9 +21,10 @@ # # Environment overrides (for testing and non-default configs): # ZTG_BRIDGE bridge device (default: br-zt) -# ZTG_BRIDGE_PORTS space-separated ports (default: ztabc0) +# ZTG_BRIDGE_PORTS space-separated ports (default: auto-detect zt* interface) # ZTG_WIBLAN_CIDR WIBLAN subnet (default: 10.11.13.0/24) # ZTG_WIBLAN_GW WIBLAN gateway IP (default: 10.11.13.1) +# ZTG_BRIDGE_NETMASK bridge subnet mask (default: 255.255.254.0 /23) # ZTG_WIBLAN_LEASE_FIRST first DHCP IP (default: 10.11.13.100) # ZTG_WIBLAN_LEASE_LAST last DHCP IP (default: 10.11.13.200) # ZTG_TABLE_MAIN main policy table (default: 100) @@ -35,6 +36,10 @@ # ZTG_RCLOCAL rc.local path # ZTG_DHCPCONF DHCP UCI config file (default: /etc/config/dhcp) # ZTG_NETWORKCONF network UCI config file (default: /etc/config/network) +# ZTG_WIFI_SSID WiFi AP SSID (default: WIBLAN) +# ZTG_WIFI_KEY WiFi AP WPA2 key (default: zt-r0ute-2026) +# ZTG_WIFI_RADIO WiFi radio device (default: auto-detect first radio) +# ZTG_WIFI_ENCRYPTION WiFi encryption (default: psk2) # ZTG_SKIP_PERSIST skip UCI persistence (testing) set -eu @@ -43,9 +48,20 @@ set -eu # Config # ---------------------------------------------------------------------------- BRIDGE="${ZTG_BRIDGE:-br-zt}" -BRIDGE_PORTS="${ZTG_BRIDGE_PORTS:-ztabc0}" +# Auto-detect ZeroTier interface if not specified (zt + random suffix, not br-zt) +if [ -n "${ZTG_BRIDGE_PORTS:-}" ]; then + BRIDGE_PORTS="$ZTG_BRIDGE_PORTS" +else + BRIDGE_PORTS=$(ip -o link show 2>/dev/null \ + | awk -F': ' '/^[0-9]+:/{gsub(/@.*/, "", $2); if ($2 ~ /^zt/ && $2 != "br-zt") print $2; exit}') + if [ -z "$BRIDGE_PORTS" ]; then + BRIDGE_PORTS="ztabc0" + fi +fi WIBLAN_CIDR="${ZTG_WIBLAN_CIDR:-10.11.13.0/24}" WIBLAN_GW="${ZTG_WIBLAN_GW:-10.11.13.1}" +# Bridge netmask must be /23 to cover both ZeroTier (10.11.12.x) and WIBLAN (10.11.13.x) +BRIDGE_NETMASK="${ZTG_BRIDGE_NETMASK:-255.255.254.0}" WIBLAN_LEASE_FIRST="${ZTG_WIBLAN_LEASE_FIRST:-10.11.13.100}" WIBLAN_LEASE_LAST="${ZTG_WIBLAN_LEASE_LAST:-10.11.13.200}" TABLE_MAIN="${ZTG_TABLE_MAIN:-100}" @@ -57,11 +73,21 @@ HOTPLUG="${ZTG_HOTPLUG:-/etc/hotplug.d/net/99-zerotier-bridge}" RCLOCAL="${ZTG_RCLOCAL:-/etc/rc.local}" DHCPCONF="${ZTG_DHCPCONF:-/etc/config/dhcp}" SKIP_PERSIST="${ZTG_SKIP_PERSIST:-0}" +WiFi_SSID="${ZTG_WIFI_SSID:-WIBLAN}" +WiFi_KEY="${ZTG_WIFI_KEY:-zt-r0ute-2026}" +WiFi_ENCRYPTION="${ZTG_WIFI_ENCRYPTION:-psk2}" +# Auto-detect first WiFi radio if not specified +WiFi_RADIO="${ZTG_WIFI_RADIO:-}" +if [ -z "$WiFi_RADIO" ]; then + WiFi_RADIO=$(uci -q get wireless.@wifi-device[0].name 2>/dev/null || echo "radio0") +fi # Derived: extract prefix bits from CIDR WIBLAN_BITS="${WIBLAN_CIDR##*/}" # UCI-safe section name (replace hyphens with underscores) BRIDGE_UCI=$(printf '%s' "$BRIDGE" | tr '-' '_') +# Interface name (used by DHCP and WiFi AP, must reference interface not device) +WIBLAN_IFACE="zt_wiblan" # ---------------------------------------------------------------------------- # Logging @@ -78,16 +104,17 @@ ensure_uci_config() { } +# CIDR to dotted mask (e.g. 24 -> 255.255.255.0) # CIDR to dotted mask (e.g. 24 -> 255.255.255.0) _cidr_to_mask() { bits=$1 - mask="" + # Build octets from the CIDR prefix + octets="" while [ "$bits" -gt 0 ]; do if [ "$bits" -ge 8 ]; then oct=255 bits=$((bits - 8)) else - # Build partial octet: bits leading 1s in MSB position oct=0 j=0 while [ $j -lt "$bits" ]; do @@ -96,13 +123,17 @@ _cidr_to_mask() { done bits=0 fi - if [ -n "$mask" ]; then - mask="${mask}.${oct}" + if [ -n "$octets" ]; then + octets="${octets}.${oct}" else - mask="${oct}" + octets="${oct}" fi done - printf '%s' "$mask" + # Pad remaining octets with 0 + while [ "$(printf '%s' "$octets" | tr -cd '.' | wc -c)" -lt 3 ]; do + octets="${octets}.0" + done + printf '%s' "$octets" } # ---------------------------------------------------------------------------- @@ -211,10 +242,10 @@ cmd_setup_bridge() { # Create interface section bridging to br-zt for WIBLAN if ! uci -q get "network.zt_wiblan" >/dev/null 2>&1; then uci -q set "network.zt_wiblan=interface" - uci -q set "network.zt_wiblan.proto='static'" - uci -q set "network.zt_wiblan.device='${BRIDGE}'" - uci -q set "network.zt_wiblan.ipaddr='${WIBLAN_GW}'" - uci -q set "network.zt_wiblan.netmask='$(_cidr_to_mask "$WIBLAN_BITS")'" + uci -q set "network.zt_wiblan.proto=static" + uci -q set "network.zt_wiblan.device=${BRIDGE}" + uci -q set "network.zt_wiblan.ipaddr=${WIBLAN_GW}" + uci -q set "network.zt_wiblan.netmask=${BRIDGE_NETMASK}" fi if [ "$SKIP_PERSIST" != "1" ]; then @@ -228,6 +259,30 @@ cmd_setup_bridge() { log "warning: ifup zt_wiblan failed; may need 'service network restart'" fi + # Ensure ZeroTier-assigned IP stays on the ZT interface (needed for ARP) + # When ZT interface is a bridge port, the assigned IP can be lost + zt_ip=$(zerotier-cli listnetworks 2>/dev/null \ + | awk '/OK/{for(i=6;i<=NF;i++) if($i ~ /\//) {split($i,a,"/"); print a[1]; exit}}') + zt_bits=$(zerotier-cli listnetworks 2>/dev/null \ + | awk '/OK/{for(i=6;i<=NF;i++) if($i ~ /\//) {split($i,a,"/"); print a[2]; exit}}') + if [ -n "$zt_ip" ] && [ -n "$zt_bits" ]; then + if ! ip -4 addr show dev "$BRIDGE_PORTS" 2>/dev/null | grep -q "$zt_ip"; then + ip addr add "${zt_ip}/${zt_bits}" dev "$BRIDGE_PORTS" 2>/dev/null || \ + log "warning: could not add ZT IP ${zt_ip}/${zt_bits} to ${BRIDGE_PORTS}" + fi + fi + # Add bridge interface to firewall LAN zone (needed for nftables fw4) + if command -v uci >/dev/null 2>&1 && [ "$SKIP_PERSIST" != "1" ]; then + lan_zone=$(uci -q get firewall.@zone[0].name 2>/dev/null) + if [ "$lan_zone" = "lan" ]; then + if ! uci -q get firewall.@zone[0].network 2>/dev/null | grep -q "zt_wiblan"; then + uci -q add_list "firewall.@zone[0].network=zt_wiblan" + uci -q commit firewall + log "added zt_wiblan to firewall LAN zone" + fi + fi + fi + log "bridge ${BRIDGE} setup complete" } @@ -241,15 +296,38 @@ cmd_setup_routing() { ip route replace "$WIBLAN_GW" dev "$BRIDGE" 2>/dev/null || \ log "warning: host route to ${WIBLAN_GW} failed" - # Table 100 (main policy): default via WIBLAN_GW - ip route replace default via "$WIBLAN_GW" dev "$BRIDGE" table "$TABLE_MAIN" + # Table 100 (main policy): default via exit gateway + direct WIBLAN subnet route + # The exit gateway is the ZeroTier peer that has internet access (not WIBLAN_GW which is local) + EXIT_GW=$(uci -q get zt-gateway.global.active_ip 2>/dev/null || \ + uci -q get zt-gateway.@gateway[0].ip 2>/dev/null || \ + ip route show table "$TABLE_MWAN" 2>/dev/null \ + | awk '/default/{for(i=1;i<=NF;i++) if($i=="via") {print $(i+1); exit}}') + if [ -z "$EXIT_GW" ]; then + # Fallback: find ZeroTier peer IP on the same /23 + EXIT_GW=$(ip route show table "$TABLE_MWAN" 2>/dev/null \ + | awk '/via.*dev/{for(i=1;i<=NF;i++) if($i=="via") {print $(i+1); exit}}') + fi + if [ -n "$EXIT_GW" ]; then + log "exit gateway: ${EXIT_GW}" + ip route replace default via "$EXIT_GW" dev "$BRIDGE_PORTS" table "$TABLE_MAIN" + ip route replace "$WIBLAN_CIDR" dev "$BRIDGE" table "$TABLE_MAIN" - # Table 101 (drain): default via WIBLAN_GW (same default; drain overrides per-flow) - ip route replace default via "$WIBLAN_GW" dev "$BRIDGE" table "$TABLE_DRAIN" + # Table 101 (drain): default via exit gateway + ip route replace default via "$EXIT_GW" dev "$BRIDGE_PORTS" table "$TABLE_DRAIN" + else + log "warning: could not determine exit gateway; using WIBLAN_GW" + ip route replace default via "$WIBLAN_GW" dev "$BRIDGE" table "$TABLE_MAIN" + ip route replace "$WIBLAN_CIDR" dev "$BRIDGE" table "$TABLE_MAIN" + ip route replace default via "$WIBLAN_GW" dev "$BRIDGE" table "$TABLE_DRAIN" + fi # mwan3 return-traffic table: route WIBLAN back through bridge ip route replace "$WIBLAN_CIDR" dev "$BRIDGE" table "$TABLE_MWAN" + # ip rule: WIBLAN subnet -> main policy table + ip rule add from "$WIBLAN_CIDR" table "$TABLE_MAIN" priority 100 2>/dev/null || \ + ip rule replace from "$WIBLAN_CIDR" table "$TABLE_MAIN" priority 100 + # ip rule: fwmark 0x100 -> drain table ip rule add fwmark "$FWMARK" table "$TABLE_DRAIN" priority "$DRAIN_PRIORITY" 2>/dev/null || \ ip rule replace fwmark "$FWMARK" table "$TABLE_DRAIN" priority "$DRAIN_PRIORITY" @@ -262,20 +340,28 @@ cmd_setup_routing() { # Host route if ! uci -q get "network.zt_wiblan_host" >/dev/null 2>&1; then uci -q set "network.zt_wiblan_host=route" - uci -q set "network.zt_wiblan_host.target='${WIBLAN_GW}'" - uci -q set "network.zt_wiblan_host.interface='${BRIDGE}'" + uci -q set "network.zt_wiblan_host.target=${WIBLAN_GW}" + uci -q set "network.zt_wiblan_host.interface=${BRIDGE}" fi # Main policy table default route if ! uci -q get "network.zt_wiblan_default" >/dev/null 2>&1; then uci -q set "network.zt_wiblan_default=route" - uci -q set "network.zt_wiblan_default.target='0.0.0.0'" - uci -q set "network.zt_wiblan_default.netmask='0.0.0.0'" - uci -q set "network.zt_wiblan_default.gateway='${WIBLAN_GW}'" - uci -q set "network.zt_wiblan_default.interface='${BRIDGE}'" - uci -q set "network.zt_wiblan_default.table='${TABLE_MAIN}'" + uci -q set "network.zt_wiblan_default.target=0.0.0.0" + uci -q set "network.zt_wiblan_default.netmask=0.0.0.0" + uci -q set "network.zt_wiblan_default.gateway=${WIBLAN_GW}" + uci -q set "network.zt_wiblan_default.interface=${BRIDGE}" + uci -q set "network.zt_wiblan_default.table=${TABLE_MAIN}" fi + # WIBLAN subnet route in main policy table + if ! uci -q get "network.zt_wiblan_subnet" >/dev/null 2>&1; then + uci -q set "network.zt_wiblan_subnet=route" + uci -q set "network.zt_wiblan_subnet.target=${WIBLAN_CIDR}" + uci -q set "network.zt_wiblan_subnet.netmask=$(_cidr_to_mask "$WIBLAN_BITS")" + uci -q set "network.zt_wiblan_subnet.interface=${BRIDGE}" + uci -q set "network.zt_wiblan_subnet.table=${TABLE_MAIN}" + fi uci commit network log "routing UCI config committed" fi @@ -299,20 +385,17 @@ cmd_setup_dhcp() { else uci -q set "dhcp.${BRIDGE_UCI}=dhcp" fi - - uci -q set "dhcp.${BRIDGE_UCI}.interface=${BRIDGE}" + uci -q set "dhcp.${BRIDGE_UCI}.interface=${WIBLAN_IFACE}" uci -q set "dhcp.${BRIDGE_UCI}.start=${WIBLAN_LEASE_FIRST##*.}" uci -q set "dhcp.${BRIDGE_UCI}.limit=$(( ${WIBLAN_LEASE_LAST##*.} - ${WIBLAN_LEASE_FIRST##*.} + 1 ))" uci -q set "dhcp.${BRIDGE_UCI}.leasetime=12h" + # Provide gateway and DNS to DHCP clients + uci -q set "dhcp.${BRIDGE_UCI}.dhcp_option=3,${WIBLAN_GW}" + uci -q add_list "dhcp.${BRIDGE_UCI}.dhcp_option=6,8.8.8.8,1.1.1.1" - # Ignore WIBLAN subnet in upstream DHCP (prevent handing out - # conflicting leases on the LAN side) - lan_iface=$(uci -q get dhcp.lan.interface 2>/dev/null || echo "lan") - if [ -n "$lan_iface" ]; then - # Add WIBLAN to lan's ignore list if not already there - if ! uci -q get "dhcp.lan.ignore" 2>/dev/null | grep -q "$WIBLAN_CIDR"; then - uci -q add_list "dhcp.lan.dhcp_option='6,${WIBLAN_GW}'" 2>/dev/null || true - fi + # Disable DHCP on LAN to prevent conflicting leases + if ! uci -q test dhcp.lan.ignore >/dev/null 2>&1; then + uci -q set "dhcp.lan.ignore=1" fi if [ "$SKIP_PERSIST" != "1" ]; then @@ -329,6 +412,50 @@ cmd_setup_dhcp() { log "DHCP setup complete" } +# ---------------------------------------------------------------------------- +# setup-wifi-ap +# ---------------------------------------------------------------------------- +cmd_setup_wifi_ap() { + log "setting up WiFi AP (${WiFi_SSID}) on ${WiFi_RADIO}..." + + if ! command -v uci >/dev/null 2>&1; then + die 3 "uci not found; cannot configure WiFi" + fi + ensure_uci_config wireless + + # Find existing wifinet section for our SSID, or create new one + existing="" + for idx in 0 1 2 3 4 5 6 7 8 9; do + if uci -q get "wireless.wifinet${idx}.ssid" 2>/dev/null | grep -q "^${WiFi_SSID}$"; then + existing="wifinet${idx}" + break + fi + done + + if [ -n "$existing" ]; then + log "WiFi AP '${WiFi_SSID}' already exists (${existing}); updating" + section="$existing" + else + section=$(uci -q add wireless wifi-iface) + log "created new wireless section: ${section}" + fi + + uci -q set "wireless.${section}.device=${WiFi_RADIO}" + uci -q set "wireless.${section}.mode=ap" + uci -q set "wireless.${section}.ssid=${WiFi_SSID}" + uci -q set "wireless.${section}.encryption=${WiFi_ENCRYPTION}" + uci -q set "wireless.${section}.key=${WiFi_KEY}" + uci -q set "wireless.${section}.network=${WIBLAN_IFACE}" + uci -q set "wireless.${section}.wpa_disable_eapol_key_retries=1" + + if [ "$SKIP_PERSIST" != "1" ]; then + uci commit wireless + log "wireless UCI config committed" + fi + + log "WiFi AP setup complete: ssid=${WiFi_SSID} radio=${WiFi_RADIO} bridge=${BRIDGE}" +} + # ---------------------------------------------------------------------------- # setup-hotplug # ---------------------------------------------------------------------------- @@ -359,6 +486,20 @@ ZTG_WIBLAN_CIDR="${ZTG_WIBLAN_CIDR:-10.11.13.0/24}" case "$ACTION" in ifup) + # Ensure ZeroTier-assigned IP stays on the ZT interface (needed for ARP) + zt_if=$(ip -o link show 2>/dev/null | awk -F': ' '/^[0-9]+:/{gsub(/@.*/, "", $2); if ($2 ~ /^zt/ && $2 != "'"$ZTG_BRIDGE"'") print $2; exit}') + if [ -n "$zt_if" ]; then + zt_ip=$(zerotier-cli listnetworks 2>/dev/null \ + | awk '/OK/{for(i=6;i<=NF;i++) if($i ~ /\//) {split($i,a,"/"); print a[1]; exit}}') + zt_bits=$(zerotier-cli listnetworks 2>/dev/null \ + | awk '/OK/{for(i=6;i<=NF;i++) if($i ~ /\//) {split($i,a,"/"); print a[2]; exit}}') + if [ -n "$zt_ip" ] && [ -n "$zt_bits" ] && \ + ! ip -4 addr show dev "$zt_if" 2>/dev/null | grep -q "$zt_ip"; then + ip addr add "${zt_ip}/${zt_bits}" dev "$zt_if" 2>/dev/null + logger -t zt-gw-hotplug "added ZT IP ${zt_ip}/${zt_bits} to ${zt_if}" + fi + fi + # Read active gateway from UCI active_ip=$(uci -q get zt-gateway.global.active_ip 2>/dev/null || \ uci -q get zt-gateway.global.active_gateway 2>/dev/null) @@ -497,6 +638,7 @@ cmd_setup_all() { cmd_setup_bridge cmd_setup_routing cmd_setup_dhcp + cmd_setup_wifi_ap cmd_setup_hotplug cmd_setup_persistence @@ -508,6 +650,7 @@ cmd_setup_all() { log " Gateway IP: ${WIBLAN_GW}" log " Tables: ${TABLE_MAIN} (main), ${TABLE_DRAIN} (drain)" log " DHCP: ${WIBLAN_LEASE_FIRST} - ${WIBLAN_LEASE_LAST}" + log " WiFi AP: ${WiFi_SSID} on ${WiFi_RADIO}" log "=========================================" } @@ -523,6 +666,7 @@ Commands: setup-bridge Create the br-zt bridge interface setup-routing Configure policy routing (tables 100/101, ip rules) setup-dhcp Configure DHCP for WIBLAN on br-zt + setup-wifi-ap Create WIBLAN WiFi AP bridged to br-zt setup-hotplug Create hotplug script to re-apply routes on ifup setup-persistence Write rc.local + UCI network routes setup-all Run all setup-* commands in order @@ -546,6 +690,7 @@ main() { setup-bridge) cmd_setup_bridge "$@" ;; setup-routing) cmd_setup_routing "$@" ;; setup-dhcp) cmd_setup_dhcp "$@" ;; + setup-wifi-ap) cmd_setup_wifi_ap "$@" ;; setup-hotplug) cmd_setup_hotplug "$@" ;; setup-persistence) cmd_setup_persistence "$@" ;; setup-all) cmd_setup_all "$@" ;; diff --git a/root/usr/share/rpcd/ucode/zt-gateway.uc b/root/usr/share/rpcd/ucode/zt-gateway.uc index 9ffd5e5..b44e1d7 100644 --- a/root/usr/share/rpcd/ucode/zt-gateway.uc +++ b/root/usr/share/rpcd/ucode/zt-gateway.uc @@ -301,10 +301,10 @@ return { }, call: function(req) { const command = req.args?.command; - if (!command || !match(command, /^(status|setup-bridge|setup-routing|setup-dhcp|setup-hotplug|setup-persistence|setup-all)$/)) { + if (!command || !match(command, /^(status|setup-bridge|setup-routing|setup-dhcp|setup-wifi-ap|setup-hotplug|setup-persistence|setup-all)$/)) { return { success: false, - message: 'Invalid command. Valid: status, setup-bridge, setup-routing, setup-dhcp, setup-hotplug, setup-persistence, setup-all' + message: 'Invalid command. Valid: status, setup-bridge, setup-routing, setup-dhcp, setup-wifi-ap, setup-hotplug, setup-persistence, setup-all' }; }