MEMORY.md 13 KB

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

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:

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

# 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: adminCHANGE 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