# client2server — Project Memory > Bi-directional event forwarder: OpenWrt routers → Go server → Redpanda (Kafka) → LuIS backend. ## Purpose - Routers push events (DHCP leases, WiFi connects/disconnects, WAN state) to a central server. - Two delivery paths: WebSocket long-lived connection for command roundtrips, and direct HTTP POST for hotplug-triggered events. - Server fans out via Redpanda topics; LuIS backend consumes. - Server can also push commands back to routers (uci_set, shell, reboot, wifi_restart, status). ## Stack | Layer | Tech | |-------|------| | Router client | Lua (OpenWrt), `client2server-unified.lua` | | Hotplug paths | Shell + curl, `/etc/hotplug.d/{wireless,dhcp}/*` | | Transport | WebSocket + HTTP (unified port) via Caddy LB | | WebSocket lib | `github.com/coder/websocket` v1.8.13 (fork of nhooyr.io) | | Event bus | Redpanda (Kafka-compatible) :9092 | | Kafka client | `github.com/twmb/franz-go` v1.18.0 + `pkg/kadm` v1.14.0 | | Server | Go 1.22 | ## Repo Layout (current) ``` client2server/ ├── ARCHITECTURE.md # Mermaid diagrams, full spec ├── README.md # User-facing docs ├── MEMORY.md # ← you are here ├── Caddyfile # LB + reverse proxy (single port :3843) ├── docker-compose.yml # redpanda + 2× server + caddy ├── package/ │ ├── Makefile # IPK build (includes hotplug) │ ├── src/ │ │ └── client2server-unified.lua # CANONICAL Lua client │ ├── files/ │ │ ├── etc/config/client2server # UCI defaults │ │ └── etc/init.d/client2server # Procd init script (exports UCI → env) │ └── hotplug/ │ ├── 01-wifi # wireless hotplug → wifi_connected/disconnected │ └── 02-dhcp # dhcp hotplug → dhcp_lease_new/expire └── server/ ├── main.go # WS handler + HTTP API + Redpanda producer ├── go.mod ├── go.sum └── Dockerfile # Go 1.22 builder + alpine runtime ``` ## Event Types (router → server) | Event | Source | Payload | |-------|--------|---------| | `dhcp_lease_new` | dnsmasq (luv timer + hotplug) | mac, ip, hostname | | `dhcp_lease_expire` | dnsmasq (luv timer + hotplug) | mac, old_ip | | `wan_link_up` | /sys/class/net/* | device | | `wan_link_down` | /sys/class/net/* | device | | `wan_dhcp_new` | ubus | new_ip | | `wan_dhcp_changed` | ubus | old_ip, new_ip | | `wifi_connected` | hostapd hotplug | mac, interface | | `wifi_disconnected` | hostapd hotplug | mac, interface | ## Commands (server → router) | Command | Args | |---------|------| | `uci_set` | config, section, option, value | | `shell` | command | | `reboot` | — | | `wifi_restart` | — | | `status` | — | ## Ports | Service | Port | Notes | |---------|------|-------| | Caddy LB (WS + HTTP) | 3843 | Routers connect here, API served here too | | Go server (×2) | 3843 (internal) | Behind Caddy, not directly exposed | | Redpanda Kafka | 9092 | Internal Docker network | | Redpanda REST | 8082 | Schema/management | | Redpanda Schema | 8081 | Schema registry | ## HTTP API All endpoints on `:3843`, behind Caddy LB. | Method | Path | Auth | Purpose | |--------|------|------|---------| | `GET` | `/health` | none | Liveness + router stats | | `POST` | `/api/events` | Bearer | Event ingestion (hotplug + lua) | | `GET` | `/api/routers` | none (recommended: add Bearer) | List known routers | | `POST` | `/api/command` | Bearer | Send command to router; awaits result | | `GET` | `/ws` | query `?token=` or `Authorization: Bearer` | WebSocket upgrade | ## Quick Run ```bash # Full stack cd /root/.openclaw/workspace/client2server TOKEN=*** docker-compose up -d # Server only (Go 1.22+ required) cd server && go build -o server . && \ REDPANDA_BROKERS=localhost:9092 TOKEN=*** PORT=3843 ./server # Install on router (manual) scp package/src/client2server-unified.lua root@router:/usr/sbin/ scp package/files/etc/init.d/client2server root@router:/etc/init.d/ scp package/files/etc/config/client2server root@router:/etc/config/ scp package/hotplug/01-wifi root@router:/etc/hotplug.d/wireless/ scp package/hotplug/02-dhcp root@router:/etc/hotplug.d/dhcp/ ssh root@router "chmod +x /usr/sbin/client2server-unified.lua /etc/init.d/client2server /etc/hotplug.d/wireless/01-wifi /etc/hotplug.d/dhcp/02-dhcp" ssh root@router "/etc/init.d/client2server enable && /etc/init.d/client2server start" # Or build IPK make package/client2server-unified/ipk ``` ## Architecture: Hybrid Event Delivery The router has **two parallel event paths** to the server: 1. **Hotplug path (instant)** — kernel fires, shell runs, curl POSTs - `01-wifi` for WiFi connect/disconnect - `02-dhcp` for DHCP lease add/del - Latency: ~10ms 2. **Lua state-diff path (≤1s)** — luv async loop polls state, sends diffs - SSID name changes - `wan_link_up/down` - `wan_dhcp_new/changed` - `dhcp_lease_new/expire` (fallback / redundancy with hotplug) Both paths post to the same `POST /api/events` endpoint. ## Environment Wiring (init.d → hotplug) - `init.d/client2server` reads UCI on `start()` and writes `/var/run/client2server.env` - Exports `SERVER_URL`, `TOKEN`, `ROUTER_ID` to Lua's environment - Hotplug scripts also `uci get` directly as fallback (in case called outside init.d context) - Env file removed on `stop()` ## Server Architecture (Go) - Single `main.go` (~600 lines) — WS handler, HTTP handlers, Redpanda producer - In-memory state: `routers`, `routerQueues` (per-router offline queue), `pendingCmds` (awaiting result), `executedCmds` (idempotency) - Background janitor: cleans `executedCmds` after `idempotencyTTL` (5 min) - **Publish timeout**: 3s per `kcl.ProduceSync()` call — if Redpanda is down, HTTP returns 502 instead of hanging - WebSocket auth: `?token=` query param or `Authorization: Bearer` header - HTTP API auth: `Authorization: Bearer` or raw token - Single port (3843) — mux routes `/ws` to WS handler, `/api/*` to REST, `/health` to liveness ## Client Evolution The Lua client went through ~6 architectural rewrites in 2 days (Jun 7 2026) trying to kill polling. Final settled state = hybrid hotplug + luv state-diff. The Go server went through a dependency overhaul on 2026-06-09: - `nhooyr.io/websocket` → `github.com/coder/websocket` (nhooyr archived, coder is the maintained fork; same API) - `github.com/redpanda-data/redpanda-sdk-go` (which never existed as a public module) → `github.com/twmb/franz-go` + `kadm` (real, fast, pure Go Kafka client — Redpanda speaks Kafka wire protocol natively) - Unified WebSocket and HTTP on port 3843 (Caddy handles routing) ## Known Issues / TODO - ⚠️ Caddyfile email is commented out — set real value if enabling `auto_https` - ⚠️ UCI default `wss://your-server.com/ws` is a placeholder — must be edited per-deployment - ⚠️ Docker-compose TOKEN is the literal string `***` — override via env or `.env` file - ⚠️ `/api/routers` currently has no auth — should require Bearer (intentional for monitoring, but flag it) - `unified.lua` is 759 lines — worth splitting into modules (DHCP/WiFi/WAN/WS/CMD) but functional as-is - No automated tests for the Go server; manual smoke test confirms endpoints work (build, /health 200, /api/events 401/502, /api/command queues while offline) - Redpanda topic creation is best-effort (relies on `AUTO_CREATE_TOPICS=true` in dev); production should manage topics explicitly - `command_id` idempotency is server-side only — clients should pass `id` in `RouterCommand` for replay safety ## Author Luis Rosales — MIT License 2026