# 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
