Bi-directional event forwarder: OpenWrt routers → Go server → Redpanda (Kafka) → LuIS backend.
| 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 |
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 | 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 |
| Command | Args |
|---|---|
uci_set |
config, section, option, value |
shell |
command |
reboot |
— |
wifi_restart |
— |
status |
— |
| 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 |
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 |
# 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
The router has two parallel event paths to the server:
Hotplug path (instant) — kernel fires, shell runs, curl POSTs
01-wifi for WiFi connect/disconnect02-dhcp for DHCP lease add/delLua state-diff path (≤1s) — luv async loop polls state, sends diffs
wan_link_up/downwan_dhcp_new/changeddhcp_lease_new/expire (fallback / redundancy with hotplug)Both paths post to the same POST /api/events endpoint.
init.d/client2server reads UCI on start() and writes /var/run/client2server.envSERVER_URL, TOKEN, ROUTER_ID to Lua's environmentuci get directly as fallback (in case called outside init.d context)stop()main.go (~600 lines) — WS handler, HTTP handlers, Redpanda producerrouters, routerQueues (per-router offline queue), pendingCmds (awaiting result), executedCmds (idempotency)executedCmds after idempotencyTTL (5 min)kcl.ProduceSync() call — if Redpanda is down, HTTP returns 502 instead of hanging?token= query param or Authorization: Bearer headerAuthorization: Bearer or raw token/ws to WS handler, /api/* to REST, /health to livenessThe 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)auto_httpswss://your-server.com/ws is a placeholder — must be edited per-deployment*** — 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-isAUTO_CREATE_TOPICS=true in dev); production should manage topics explicitlycommand_id idempotency is server-side only — clients should pass id in RouterCommand for replay safetyLuis Rosales — MIT License 2026