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
This commit is contained in:
2026-07-13 11:10:57 +05:30
parent cd429291ef
commit 1e4a46c4bf
11 changed files with 1045 additions and 81 deletions

3
.gitignore vendored
View File

@@ -33,6 +33,3 @@ node_modules/
# Test artifacts # Test artifacts
test-results/ test-results/
e2e-report/ e2e-report/
# Harness
.omp/

View File

@@ -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 <command>
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 `<dev>` 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 '<your-password>' │ │
│ │ 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` → `<dev>` 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

View File

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

View File

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

View File

@@ -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. 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 ```bash
mise run deploy:install mise run deploy:install
# or with a custom target: # or with a custom target:
HOST=root@10.0.0.1 mise run deploy:install 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://<device-ip>/cgi-bin/luci/admin/services/zt-gateway`. Open `http://<device-ip>/cgi-bin/luci/admin/services/zt-gateway`.
**Note:** This method has no dependency tracking. The package manager won't know **Note:** This method has no dependency tracking. The package manager won't know
about the installed files. Use for dev only. 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@<device-ip> '/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 ## 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 | | 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/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/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 | | `root/usr/share/rpcd/ucode/system.uc` | `/usr/share/rpcd/ucode/system.uc` | system.board rpcd override |

View File

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

350
docs/SETUP-GATEWAY.md Normal file
View File

@@ -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) |

View File

@@ -137,6 +137,7 @@ return view.extend({
{ cmd: 'setup-bridge', label: _('Setup Bridge'), desc: _('Create br-zt bridge interface') }, { 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-routing', label: _('Setup Routing'), desc: _('Configure policy routing tables') },
{ cmd: 'setup-dhcp', label: _('Setup DHCP'), desc: _('Configure DHCP for WIBLAN subnet') }, { 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-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-persistence', label: _('Setup Persistence'), desc: _('Write rc.local + UCI routes') },
{ cmd: 'setup-all', label: _('Run Full Setup'), desc: _('Configure everything at once') } { cmd: 'setup-all', label: _('Run Full Setup'), desc: _('Configure everything at once') }

View File

@@ -87,32 +87,28 @@ echo " ${HOST} is reachable."
echo "==> Uploading files..." echo "==> Uploading files..."
# root/ files → strip leading root/, map to / on device # Auto-discover files from root/ and htdocs/, map to device paths.
scp -O -q root/usr/sbin/zt-gateway-switch "$HOST:/usr/sbin/" # root/usr/sbin/foo → /usr/sbin/foo
scp -O -q root/usr/sbin/zt-gateway-setup "$HOST:/usr/sbin/" # htdocs/luci-static/... /www/luci-static/...
echo " zt-gateway-setup" UPLOADED=0
echo " zt-gateway-switch" find root/ htdocs/ -type f | sort | while IFS= read -r src; do
scp -O -q root/usr/share/rpcd/ucode/zt-gateway.uc "$HOST:/usr/share/rpcd/ucode/" case "$src" in
echo " rpcd/ucode/zt-gateway.uc" root/*) dest="/${src#root/}" ;;
scp -O -q root/usr/share/rpcd/ucode/system.uc "$HOST:/usr/share/rpcd/ucode/" htdocs/*) dest="/www/${src#htdocs/}" ;;
echo " rpcd/ucode/system.uc" *) echo "SKIP: $src (unknown prefix)"; continue ;;
scp -O -q root/usr/share/ucode/luci/runtime.uc "$HOST:/usr/share/ucode/luci/" esac
echo " ucode/luci/runtime.uc" destdir="${dest%/*}"
scp -O -q root/usr/share/luci/menu.d/luci-app-zt-gateway.json "$HOST:/usr/share/luci/menu.d/" ssh -q "$HOST" mkdir -p "$destdir"
echo " menu.d/luci-app-zt-gateway.json" scp -O -q "$src" "$HOST:$dest"
scp -O -q root/usr/share/rpcd/acl.d/luci-app-zt-gateway.json "$HOST:/usr/share/rpcd/acl.d/" echo " ${src#./}"
echo " acl.d/luci-app-zt-gateway.json" UPLOADED=$((UPLOADED + 1))
scp -O -q root/etc/config/zt-gateway "$HOST:/etc/config/" done
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"
echo "==> Setting permissions..." echo "==> Setting permissions..."
ssh -q "$HOST" chmod +x /usr/sbin/zt-gateway-switch /usr/sbin/zt-gateway-setup ssh -q "$HOST" find /usr/sbin -name 'zt-gateway-*' -exec chmod +x {} +
echo "==> Restarting rpcd and uhttpd..."
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/rpcd restart
ssh -q "$HOST" /etc/init.d/uhttpd restart ssh -q "$HOST" /etc/init.d/uhttpd restart

View File

@@ -21,9 +21,10 @@
# #
# Environment overrides (for testing and non-default configs): # Environment overrides (for testing and non-default configs):
# ZTG_BRIDGE bridge device (default: br-zt) # 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_CIDR WIBLAN subnet (default: 10.11.13.0/24)
# ZTG_WIBLAN_GW WIBLAN gateway IP (default: 10.11.13.1) # 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_FIRST first DHCP IP (default: 10.11.13.100)
# ZTG_WIBLAN_LEASE_LAST last DHCP IP (default: 10.11.13.200) # ZTG_WIBLAN_LEASE_LAST last DHCP IP (default: 10.11.13.200)
# ZTG_TABLE_MAIN main policy table (default: 100) # ZTG_TABLE_MAIN main policy table (default: 100)
@@ -35,6 +36,10 @@
# ZTG_RCLOCAL rc.local path # ZTG_RCLOCAL rc.local path
# ZTG_DHCPCONF DHCP UCI config file (default: /etc/config/dhcp) # ZTG_DHCPCONF DHCP UCI config file (default: /etc/config/dhcp)
# ZTG_NETWORKCONF network UCI config file (default: /etc/config/network) # 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) # ZTG_SKIP_PERSIST skip UCI persistence (testing)
set -eu set -eu
@@ -43,9 +48,20 @@ set -eu
# Config # Config
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
BRIDGE="${ZTG_BRIDGE:-br-zt}" 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_CIDR="${ZTG_WIBLAN_CIDR:-10.11.13.0/24}"
WIBLAN_GW="${ZTG_WIBLAN_GW:-10.11.13.1}" 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_FIRST="${ZTG_WIBLAN_LEASE_FIRST:-10.11.13.100}"
WIBLAN_LEASE_LAST="${ZTG_WIBLAN_LEASE_LAST:-10.11.13.200}" WIBLAN_LEASE_LAST="${ZTG_WIBLAN_LEASE_LAST:-10.11.13.200}"
TABLE_MAIN="${ZTG_TABLE_MAIN:-100}" 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}" RCLOCAL="${ZTG_RCLOCAL:-/etc/rc.local}"
DHCPCONF="${ZTG_DHCPCONF:-/etc/config/dhcp}" DHCPCONF="${ZTG_DHCPCONF:-/etc/config/dhcp}"
SKIP_PERSIST="${ZTG_SKIP_PERSIST:-0}" 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 # Derived: extract prefix bits from CIDR
WIBLAN_BITS="${WIBLAN_CIDR##*/}" WIBLAN_BITS="${WIBLAN_CIDR##*/}"
# UCI-safe section name (replace hyphens with underscores) # UCI-safe section name (replace hyphens with underscores)
BRIDGE_UCI=$(printf '%s' "$BRIDGE" | tr '-' '_') BRIDGE_UCI=$(printf '%s' "$BRIDGE" | tr '-' '_')
# Interface name (used by DHCP and WiFi AP, must reference interface not device)
WIBLAN_IFACE="zt_wiblan"
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Logging # 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 dotted mask (e.g. 24 -> 255.255.255.0)
_cidr_to_mask() { _cidr_to_mask() {
bits=$1 bits=$1
mask="" # Build octets from the CIDR prefix
octets=""
while [ "$bits" -gt 0 ]; do while [ "$bits" -gt 0 ]; do
if [ "$bits" -ge 8 ]; then if [ "$bits" -ge 8 ]; then
oct=255 oct=255
bits=$((bits - 8)) bits=$((bits - 8))
else else
# Build partial octet: bits leading 1s in MSB position
oct=0 oct=0
j=0 j=0
while [ $j -lt "$bits" ]; do while [ $j -lt "$bits" ]; do
@@ -96,13 +123,17 @@ _cidr_to_mask() {
done done
bits=0 bits=0
fi fi
if [ -n "$mask" ]; then if [ -n "$octets" ]; then
mask="${mask}.${oct}" octets="${octets}.${oct}"
else else
mask="${oct}" octets="${oct}"
fi fi
done 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 # Create interface section bridging to br-zt for WIBLAN
if ! uci -q get "network.zt_wiblan" >/dev/null 2>&1; then 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=interface"
uci -q set "network.zt_wiblan.proto='static'" uci -q set "network.zt_wiblan.proto=static"
uci -q set "network.zt_wiblan.device='${BRIDGE}'" uci -q set "network.zt_wiblan.device=${BRIDGE}"
uci -q set "network.zt_wiblan.ipaddr='${WIBLAN_GW}'" 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.netmask=${BRIDGE_NETMASK}"
fi fi
if [ "$SKIP_PERSIST" != "1" ]; then if [ "$SKIP_PERSIST" != "1" ]; then
@@ -228,6 +259,30 @@ cmd_setup_bridge() {
log "warning: ifup zt_wiblan failed; may need 'service network restart'" log "warning: ifup zt_wiblan failed; may need 'service network restart'"
fi 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" log "bridge ${BRIDGE} setup complete"
} }
@@ -241,15 +296,38 @@ cmd_setup_routing() {
ip route replace "$WIBLAN_GW" dev "$BRIDGE" 2>/dev/null || \ ip route replace "$WIBLAN_GW" dev "$BRIDGE" 2>/dev/null || \
log "warning: host route to ${WIBLAN_GW} failed" log "warning: host route to ${WIBLAN_GW} failed"
# Table 100 (main policy): default via WIBLAN_GW # Table 100 (main policy): default via exit gateway + direct WIBLAN subnet route
ip route replace default via "$WIBLAN_GW" dev "$BRIDGE" table "$TABLE_MAIN" # 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) # Table 101 (drain): default via exit gateway
ip route replace default via "$WIBLAN_GW" dev "$BRIDGE" table "$TABLE_DRAIN" 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 # mwan3 return-traffic table: route WIBLAN back through bridge
ip route replace "$WIBLAN_CIDR" dev "$BRIDGE" table "$TABLE_MWAN" 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: fwmark 0x100 -> drain table
ip rule add fwmark "$FWMARK" table "$TABLE_DRAIN" priority "$DRAIN_PRIORITY" 2>/dev/null || \ 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" ip rule replace fwmark "$FWMARK" table "$TABLE_DRAIN" priority "$DRAIN_PRIORITY"
@@ -262,20 +340,28 @@ cmd_setup_routing() {
# Host route # Host route
if ! uci -q get "network.zt_wiblan_host" >/dev/null 2>&1; then 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=route"
uci -q set "network.zt_wiblan_host.target='${WIBLAN_GW}'" 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.interface=${BRIDGE}"
fi fi
# Main policy table default route # Main policy table default route
if ! uci -q get "network.zt_wiblan_default" >/dev/null 2>&1; then 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=route"
uci -q set "network.zt_wiblan_default.target='0.0.0.0'" 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.netmask=0.0.0.0"
uci -q set "network.zt_wiblan_default.gateway='${WIBLAN_GW}'" 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.interface=${BRIDGE}"
uci -q set "network.zt_wiblan_default.table='${TABLE_MAIN}'" uci -q set "network.zt_wiblan_default.table=${TABLE_MAIN}"
fi 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 uci commit network
log "routing UCI config committed" log "routing UCI config committed"
fi fi
@@ -299,20 +385,17 @@ cmd_setup_dhcp() {
else else
uci -q set "dhcp.${BRIDGE_UCI}=dhcp" uci -q set "dhcp.${BRIDGE_UCI}=dhcp"
fi fi
uci -q set "dhcp.${BRIDGE_UCI}.interface=${WIBLAN_IFACE}"
uci -q set "dhcp.${BRIDGE_UCI}.interface=${BRIDGE}"
uci -q set "dhcp.${BRIDGE_UCI}.start=${WIBLAN_LEASE_FIRST##*.}" 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}.limit=$(( ${WIBLAN_LEASE_LAST##*.} - ${WIBLAN_LEASE_FIRST##*.} + 1 ))"
uci -q set "dhcp.${BRIDGE_UCI}.leasetime=12h" 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 # Disable DHCP on LAN to prevent conflicting leases
# conflicting leases on the LAN side) if ! uci -q test dhcp.lan.ignore >/dev/null 2>&1; then
lan_iface=$(uci -q get dhcp.lan.interface 2>/dev/null || echo "lan") uci -q set "dhcp.lan.ignore=1"
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
fi fi
if [ "$SKIP_PERSIST" != "1" ]; then if [ "$SKIP_PERSIST" != "1" ]; then
@@ -329,6 +412,50 @@ cmd_setup_dhcp() {
log "DHCP setup complete" 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 # setup-hotplug
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
@@ -359,6 +486,20 @@ ZTG_WIBLAN_CIDR="${ZTG_WIBLAN_CIDR:-10.11.13.0/24}"
case "$ACTION" in case "$ACTION" in
ifup) 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 # Read active gateway from UCI
active_ip=$(uci -q get zt-gateway.global.active_ip 2>/dev/null || \ active_ip=$(uci -q get zt-gateway.global.active_ip 2>/dev/null || \
uci -q get zt-gateway.global.active_gateway 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_bridge
cmd_setup_routing cmd_setup_routing
cmd_setup_dhcp cmd_setup_dhcp
cmd_setup_wifi_ap
cmd_setup_hotplug cmd_setup_hotplug
cmd_setup_persistence cmd_setup_persistence
@@ -508,6 +650,7 @@ cmd_setup_all() {
log " Gateway IP: ${WIBLAN_GW}" log " Gateway IP: ${WIBLAN_GW}"
log " Tables: ${TABLE_MAIN} (main), ${TABLE_DRAIN} (drain)" log " Tables: ${TABLE_MAIN} (main), ${TABLE_DRAIN} (drain)"
log " DHCP: ${WIBLAN_LEASE_FIRST} - ${WIBLAN_LEASE_LAST}" log " DHCP: ${WIBLAN_LEASE_FIRST} - ${WIBLAN_LEASE_LAST}"
log " WiFi AP: ${WiFi_SSID} on ${WiFi_RADIO}"
log "=========================================" log "========================================="
} }
@@ -523,6 +666,7 @@ Commands:
setup-bridge Create the br-zt bridge interface setup-bridge Create the br-zt bridge interface
setup-routing Configure policy routing (tables 100/101, ip rules) setup-routing Configure policy routing (tables 100/101, ip rules)
setup-dhcp Configure DHCP for WIBLAN on br-zt 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-hotplug Create hotplug script to re-apply routes on ifup
setup-persistence Write rc.local + UCI network routes setup-persistence Write rc.local + UCI network routes
setup-all Run all setup-* commands in order setup-all Run all setup-* commands in order
@@ -546,6 +690,7 @@ main() {
setup-bridge) cmd_setup_bridge "$@" ;; setup-bridge) cmd_setup_bridge "$@" ;;
setup-routing) cmd_setup_routing "$@" ;; setup-routing) cmd_setup_routing "$@" ;;
setup-dhcp) cmd_setup_dhcp "$@" ;; setup-dhcp) cmd_setup_dhcp "$@" ;;
setup-wifi-ap) cmd_setup_wifi_ap "$@" ;;
setup-hotplug) cmd_setup_hotplug "$@" ;; setup-hotplug) cmd_setup_hotplug "$@" ;;
setup-persistence) cmd_setup_persistence "$@" ;; setup-persistence) cmd_setup_persistence "$@" ;;
setup-all) cmd_setup_all "$@" ;; setup-all) cmd_setup_all "$@" ;;

View File

@@ -301,10 +301,10 @@ return {
}, },
call: function(req) { call: function(req) {
const command = req.args?.command; 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 { return {
success: false, 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'
}; };
} }