# client2server — Project Memory

> Bi-directional event forwarder: OpenWrt routers → Go server → Redpanda (Kafka) → LuIS backend.
> + Apple-style React dashboard for monitoring and control.

## Purpose
- Routers push events (DHCP leases, WiFi connects/disconnects, WAN state) to a central server.
- Two delivery paths (router → server): WebSocket long-lived connection for command roundtrips, and hotplug scripts that spool events to a shared on-disk buffer for resilience.
- Both paths share `/var/run/client2server/buffer` (NDJSON) so events survive internet outages; the Lua agent flushes the buffer on reconnect.
- Server fans out via Redpanda topics; LuIS backend consumes.
- Server can also push commands back to routers (uci_set, shell, reboot, wifi_restart, status).
- Dashboard (new in 2.1) provides Apple-style web UI for monitoring and control.

## Conventions

### ⛔ All Lua code targets Lua 5.1 (OpenWrt default)

OpenWrt ships **Lua 5.1** (with `compat-5.3` in some builds, but not all
packages). Every `.lua` file under `package/` must parse and run cleanly
on plain Lua 5.1. **Never use 5.2+/5.3+/5.4/LuaJIT-only features.**

**Forbidden (will silently break on the router):**
- `goto` / `::label::` statements (5.2+)
- `continue` statement (5.2+)
- `<close>` attribute on to-be-closed locals (5.4)
- `integer` type, hex floats (`0x1.8p3`), `\xNN` escapes (5.3+)
- `//` floor division, `&` `|` `~` `<<` `>>` bitwise operators (5.3+, LuaJIT)
- `utf8.*` standard library (5.3+)
- `table.move` / `table.pack` (5.2+/5.3); use `table.insert` + `table.remove`
- `string.pack` / `string.unpack` / `string.dump` (3rd-arg signature 5.3+)
- `<toclose>` metamethod pattern

**Avoid (work in 5.1 but trap you later):**
- Global `unpack` (use `table.unpack` if you must, or just `for i,v in ipairs`)
- `setfenv` / `getfenv` (removed in 5.2)
- `module(...)` (5.2 deprecates the implicit module function)

**Safe to use (all in 5.1):**
- `string.byte(s, i, j)` multi-return, `string.char(...)` with numeric args
- `string.find` with 4th-arg `init`, `string.match`, `string.gmatch`,
  `string.gsub` (string or function replacement)
- `io.popen(cmd)` — always guard the result with `if f then`
- `coroutine.create / resume / yield / status / wrap`
- `pcall` / `xpcall` (single-arg or with custom message handler)
- `math.floor / random / randomseed / min / max / huge` etc.
- `pairs` / `ipairs` / `next`
- Pattern escapes: `%a %A %d %D %s %S %w %W %l %u %c %C %p %P %x %X`
- `table.insert(t, [pos,] v)`, `table.remove(t, [pos])`, `table.concat(t, sep)`
- `debug` library (5.1+) — avoid in production; only for diagnostics

**Validation gate (run before every commit that touches `.lua` files):**
```bash
luac5.1 -p package/src/client2server-unified.lua   # must exit 0
```
If `luac5.1` is unavailable locally, do a manual review against the
forbidden-list above. CI on the router will surface real syntax errors
at agent startup, but those are silent and look like a dead daemon.

**Real-world example of a bug this rule would have caught:**
Commit `466fddc` ("Print at start", 5 Jun 2026) introduced a literal
newline inside a Lua string literal:
```lua
print("START
")--[[
```
That `LF` should have been the two-character escape `\n`. The result
was an unparseable file — the Lua agent was dead on every router for
~5 days before commit `ff7bf22` fixed it. **Do not let this happen
again.** A single `luac5.1 -p` before commit would have caught it.

## Stack
| Layer | Tech |
|-------|------|
| Router client | Lua (OpenWrt), `client2server-unified.lua` |
| Hotplug paths | Shell + shared `hotplug-lib.sh`, `/etc/hotplug.d/{wireless,dhcp}/*` |
| Server runtime | Go 1.22 |
| WebSocket lib | `github.com/coder/websocket` v1.8.13 |
| Event bus | Redpanda (Kafka-compatible) :9092 |
| Kafka client | `github.com/twmb/franz-go` v1.18.0 + `pkg/kadm` v1.14.0 |
| Storage | SQLite (`modernc.org/sqlite`, pure Go, no cgo) |
| Auth | JWT (HS256) with scrypt password hashing |
| Live feed | Server-Sent Events (`/api/events/stream`) |
| Frontend | React 19 + TypeScript + Vite + Tailwind v3 + Framer Motion + Recharts + TanStack Query + Wouter |
| Dashboard hosting | Caddy (serves SPA + proxies /api, /ws) |

## Repo Layout (current)
```
client2server/
├── ARCHITECTURE.md      # Mermaid diagrams, full spec
├── README.md            # User-facing docs
├── MEMORY.md            # ← you are here
├── Caddyfile            # LB: 3843 → servers, 80/443 → dashboard
├── docker-compose.yml   # redpanda + 2× server + dashboard + caddy
├── package/             # OpenWrt IPK build
│   ├── Makefile
│   ├── src/client2server-unified.lua
│   ├── files/etc/{config,init.d}/client2server
│   └── hotplug/{01-wifi,02-dhcp,_lib.sh}
├── server/              # Go server
│   ├── main.go          # WS handler + HTTP API + ingestEvent
│   ├── auth.go          # JWT + scrypt + login + middleware
│   ├── consumer.go      # Redpanda → SQLite consumer
│   ├── metrics.go       # 1-min buckets, 24h retention
│   ├── sse.go           # SSE broadcaster
│   ├── store.go         # SQLite (users, events, commands, alerts)
│   ├── go.mod
│   ├── go.sum
│   └── Dockerfile
└── dashboard/           # React SPA
    ├── src/
    │   ├── App.tsx              # Routes + auth gate
    │   ├── main.tsx
    │   ├── styles.css           # Apple design tokens
    │   ├── lib/{api,types,sse}.ts
    │   ├── components/{Shell,ui}.tsx
    │   └── pages/{Login,Overview,Routers,Events,Commands,Alerts}.tsx
    ├── screenshots/             # 6 retina PNGs (visual reference)
    ├── package.json
    ├── vite.config.ts           # VITE_API_TARGET proxy
    ├── tailwind.config.js       # Apple palette + display sizes
    ├── Dockerfile               # Node builder + Caddy runtime
    └── Caddyfile.production     # /api + /ws proxy, SPA fallback
```

## Endpoints (Go server, port 3843)

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET  | `/health` | none | Liveness + router stats |
| POST | `/api/auth/login` | none | `{username,password}` → `{token,role}` |
| GET  | `/api/auth/me` | JWT | Current user info |
| POST | `/api/events` | JWT or legacy token | Ingest event from router/hotplug |
| GET  | `/api/events/list` | JWT | Historical events (filter by router_id, event_type) |
| GET  | `/api/events/stream` | JWT or legacy token | Server-Sent Events live feed |
| GET  | `/api/routers` | none | Known routers (online, last_seen, queued) |
| POST | `/api/command` | JWT | Send command, awaits result |
| GET  | `/api/commands` | JWT | Command history |
| GET  | `/api/metrics?since=1h` | JWT | Time-series metrics (1-min buckets) |
| GET  | `/api/alerts?unack=1` | JWT | Alerts list |
| POST | `/api/alerts/{id}/ack` | JWT | Acknowledge alert |
| GET  | `/ws` | legacy token | WebSocket from router |

## Event Types (router → server)
| Event | Source | Payload |
|-------|--------|---------|
| `dhcp_lease_new` | dnsmasq (luv + hotplug) | mac, ip, hostname |
| `dhcp_lease_expire` | dnsmasq (luv + 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 |

## Offline Buffering

Both event paths share a single on-disk buffer so events survive internet outages.

- **State dir:** `/var/run/client2server/` (created by init.d at boot)
- **Buffer file:** `/var/run/client2server/buffer` (NDJSON, one event per line)
- **PID file:** `/var/run/client2server/pid` (written by the Lua agent)
- **Wake file:** `/var/run/client2server/wake` (touched by hotplug scripts to nudge the agent)
- **Env file:** `/var/run/client2server/env` (UCI exports for child processes)

**Flow (hotplug script → server):**
1. Hotplug event fires (WiFi/DHCP).
2. `hotplug-lib.sh` appends JSON event to `/var/run/client2server/buffer`.
3. Hotplug touches `wake` file (and SIGHUPs agent as fast path).
4. Lua agent's main loop sees the wake file → flushes buffer via WebSocket.
5. On reconnect, agent drains everything; on overflow, drops oldest (FIFO, `max_buffer=1000`).

**Flow (Lua agent → server):**
1. Event generated internally (DHCP lease, WAN link, command result).
2. `ws.send()` fails → `buffer.add()` writes to the same buffer file.
3. Reconnect logic (exponential 30s→5min backoff, 10s when buffer >80% full) retries; on success, `buffer.flush()` drains in order.

**Survives:** agent restarts, internet outages, server downtime.
**Doesn't survive:** router reboot (buffer is in tmpfs). Acceptable: network events from a rebooting router are stale anyway.

## Commands (server → router)
| Command | Args | Notes |
|---------|------|-------|
| `reboot` | — | dangerous |
| `wifi_restart` | — | |
| `status` | — | |
| `shell` | `command` | dangerous |
| `uci_set` | `config,section,option,value` | dangerous |

## Ports
| Service | Port | Notes |
|---------|------|-------|
| Caddy (WS+HTTP API) | 3843 | Routers + API clients |
| Caddy (Dashboard) | 80/443 | Web UI |
| Go server (×2) | 3843 (internal) | Behind Caddy |
| Redpanda Kafka | 9092 | Internal |
| Redpanda REST | 8082 | Schema/management |
| Dashboard dev (Vite) | 5173 | Local dev only |

## Quick Run
```bash
# Full stack
cd /root/.openclaw/workspace/client2server
# Set in .env or export:
#   TOKEN=***        (legacy router/hotplug shared token)
#   JWT_SECRET=*** (>= 32 bytes for dashboard)
TOKEN=*** JWT_SECRET=$(openssl rand -hex 32) docker-compose up -d

# Local dev: server + Vite dashboard
cd server && go build -o server . && \
  REDPANDA_BROKERS=localhost:9092 TOKEN=*** JWT_SECRET=dev PORT=3843 ./server &
cd ../dashboard && VITE_API_TARGET=http://localhost:3843 npm run dev

# Install on router
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/
scp package/hotplug/_lib.sh root@router:/usr/share/client2server/hotplug-lib.sh
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 /usr/share/client2server/hotplug-lib.sh"
ssh root@router "/etc/init.d/client2server enable && /etc/init.d/client2server start"
```

## Auth Flow
1. User visits dashboard → redirected to /login
2. POSTs username/password to /api/auth/login → gets JWT
3. JWT stored in localStorage as `c2s_token`
4. Every API call attaches `Authorization: Bearer <jwt>`
5. SSE uses `?token=<jwt>` query param (EventSource doesn't support headers)
6. Routers continue to use legacy shared TOKEN (not JWT)

## Default Credentials
- Username: `admin`
- Password: `admin` — **CHANGE IN PRODUCTION**
- Role: `system_admin` (can do everything)

To add users: connect to SQLite, INSERT into `users` table with scrypt hash from `HashPassword()`.

## Architecture: Hybrid Event Delivery (router side)
The router has two parallel event paths, **both** writing to a shared on-disk buffer:
1. **Hotplug path (instant)** — kernel fires, shell runs, `hotplug-lib.sh` appends NDJSON to `/var/run/client2server/buffer` + touches wake file
2. **Lua state-diff path (≤1s)** — luv async loop polls state, generates events, writes to the same buffer on send failure

The Lua agent's main loop polls the wake file each iteration and flushes the buffer over WebSocket. Both paths are now resilient to internet outages; hotplug events are no longer dropped when the server is unreachable.

## Evolution (commit history)
- `6d6780c` — Removed legacy event-forwarder + unused Lua clients, fixed event names, wired UCI to hotplug
- `9f2269a` — Migrated Go server to coder/websocket + franz-go (the legacy deps didn't exist/weren't archived), unified port 3843
- `6af966d` — Added SQLite, JWT auth, SSE live feed, metrics, alerts, Redpanda consumer
- `5b57be3` — Built the Apple-style React dashboard SPA + fixed publish() to be fire-and-forget (was blocking HTTP for 3s when Redpanda down)
- *(pending)* — Unified offline buffering: hotplug scripts + Lua agent share `/var/run/client2server/buffer` (NDJSON), wake-file signaling, fixed broken PID file write, raised `max_buffer` to 1000

## Known Issues / TODO
- ⚠️ Default admin/admin must be changed in production
- ⚠️ JWT_SECRET needs to be set in env (32+ bytes) — server falls back to insecure dev key
- ⚠️ `/api/routers` has no auth (intentional for monitoring but flag for production)
- ⚠️ Caddyfile email is commented out — needed if `auto_https on`
- ⚠️ UCI default `wss://your-server.com/ws` is a placeholder
- ⚠️ `unified.lua` is 759 lines — could be split into modules (DHCP/WiFi/WAN/WS/CMD)
- ⚠️ No automated tests for the Go server (smoke tests done manually, all endpoints work)
- ⚠️ Redpanda topic creation is best-effort; production should manage topics explicitly
- ⚠️ Dashboard bundle is 742KB (219KB gzip) — could be code-split with manualChunks
- ⚠️ `franz-go` retries silently in background; failed publishes only log, no metric for publish failures
- ⚠️ Alerts only fire from offline watcher (30s tick); no flap detection, no recovery notifications beyond clearing on reconnect

## Author
Luis Rosales — MIT License 2026
