# client2server Architecture
> System architecture for bidirectional event forwarding between OpenWrt routers
> and a central Go server, with persistent storage, real-time web dashboard, and
> command queue.
## Table of Contents
1. [System Overview](#system-overview)
2. [Data Flows](#data-flows)
3. [Component Architecture](#component-architecture)
4. [Auth & Persistence](#auth--persistence)
5. [Error Handling & Resilience](#error-handling--resilience)
6. [Topics & Ports](#topics--ports)
7. [Commands](#commands)
8. [Events](#events)
9. [Build & Deploy](#build--deploy)
---
## System Overview
```mermaid
flowchart TB
subgraph Router["OpenWrt Router"]
direction TB
Lua[("client2server-unified.lua")]
Hotplug[("/etc/hotplug.d/{wireless,dhcp}/* scripts")]
Wifi[("wifi_connected / disconnected")]
DHCP[("dhcp_lease_new / expire")]
WAN[("wan_link / dhcp events")]
CMD[("Command Executor")]
Shared[("Shared buffer
/var/run/client2server/buffer")]
Wake[("wake file
/var/run/client2server/wake")]
end
subgraph Edge["Reverse Proxy / LB"]
Caddy[("Caddy :80/:443 dashboard
Caddy :3843 API/WS")]
end
subgraph App["Go Servers (x2, behind Caddy)"]
S1[("server1 :3843")]
S2[("server2 :3843")]
SQLite1[("SQLite")]
SQLite2[("SQLite")]
end
subgraph Bus["Redpanda :9092"]
REvents[("router-events")]
RCommands[("router-commands")]
end
subgraph UI["Dashboard (React SPA, :80)"]
Dash[("Login / Overview / Routers
Events / Commands / Alerts")]
end
subgraph Consumer["Downstream Consumers"]
AnyClient[("Any Kafka client
(LuIS, analytics, etc.)")]
end
Router -->|WebSocket :3843| Caddy
Caddy -->|lb| S1
Caddy -->|lb| S2
Lua -->|offline| Shared
Hotplug -->|spool| Shared
Hotplug -->|touch| Wake
Wake -->|polled by| Lua
Shared -->|flush on reconnect| Lua
Lua -->|WS frame| Caddy
S1 -->|publish| REvents
S1 -->|publish| RCommands
S2 -->|publish| REvents
S2 -->|publish| RCommands
REvents --> AnyClient
RCommands --> AnyClient
S1 --> SQLite1
S2 --> SQLite2
Caddy -->|static + /api proxy| Dash
Dash -->|SSE /api/events/stream| S1
Dash -->|REST /api/command| S1
S1 -->|queued if offline| Caddy
Caddy -->|WebSocket send| Router
Router --> CMD
```
> **Key change (v2.x):** Hotplug scripts no longer POST to the server
> directly. Both hotplug events and Lua-generated events go through a
> **single shared on-disk buffer** (`/var/run/client2server/buffer`) that
> survives internet outages. See [Offline Buffering](#offline-buffering)
> for the full flow.
**Read it as three planes:**
| Plane | What flows | Direction |
|---|---|---|
| **Telemetry** | DHCP / WiFi / WAN events | Router → Caddy → Go server → Redpanda → SQLite + SSE → Dashboard |
| **Control** | `reboot`, `uci_set`, `status`, etc. | Dashboard → REST → Go server (queues if router offline) → Caddy → Router via WS |
| **Persistence** | events, commands, alerts, users | Go server ↔ SQLite (local file per server instance) |
---
## Data Flows
### 1. Event Flow (Router → Dashboard, real-time)
```mermaid
sequenceDiagram
participant R as Router
participant H as Hotplug
participant Buf as Shared buffer
(/var/run/client2server/buffer)
participant L as Lua agent
participant S as Go Server
participant DB as SQLite
participant RP as Redpanda
participant D as Dashboard (SSE)
participant C as Consumer
Note over R,C: Hybrid delivery: instant hotplug + ≤1s luv state-diff
Both paths share one on-disk buffer for offline resilience
par Hotplug path (instant)
H->>Buf: append NDJSON event
H->>L: touch wake file
and Lua state-diff (≤1s)
L->>L: detect (WAN/DHCP/state change)
end
L->>L: main loop polls wake file
L->>Buf: read queued events
L->>S: WS frame: {type: event, ...}
S->>S: authenticate (legacy token or JWT)
S->>DB: INSERT INTO events
S->>RP: publish (fire-and-forget)
S->>D: SSE push to all subscribers
RP-->>C: consume (any Kafka client)
S-->>L: ACK
```
### 2. Command Flow (Dashboard → Router)
```mermaid
sequenceDiagram
participant D as Dashboard
participant S as Go Server
participant DB as SQLite
participant R as Router
participant RP as Redpanda (optional)
D->>S: POST /api/command {router_id, command, args}
S->>S: authenticate (JWT, role check)
S->>DB: INSERT INTO commands (id, status=pending)
alt Router is online
S->>R: WebSocket send {type: command, ...}
R->>R: execute (uci_set, shell, reboot, etc.)
R->>S: WS result {type: command_result, command_id}
S->>DB: UPDATE status=completed
S->>D: SSE push command_result
else Router is offline
S->>DB: command stays in queue
Note over S,R: ...later, router reconnects...
S->>R: WebSocket send all queued commands
end
opt Long-lived audit
S->>RP: publish (optional)
end
```
### 3. Login Flow (Dashboard only)
```mermaid
sequenceDiagram
participant D as Dashboard
participant S as Go Server
participant DB as SQLite
D->>S: POST /api/auth/login {username, password}
S->>DB: SELECT * FROM users WHERE username=?
S->>S: scrypt.CompareHashAndPassword
alt valid
S-->>D: 200 {token: JWT, role, expires}
D->>D: store c2s_token in localStorage
D->>S: GET /api/auth/me (Bearer JWT)
S-->>D: 200 {username, role}
else invalid
S-->>D: 401
end
```
### 4. Offline Watcher (alerts)
```mermaid
sequenceDiagram
participant T as Ticker (30s)
participant S as Go Server
participant DB as SQLite
participant D as Dashboard (SSE)
loop every 30s
T->>S: tick
S->>DB: SELECT routers WHERE last_seen < now - 60s
loop each offline router
S->>DB: INSERT alerts (kind=router_offline)
S->>D: SSE push {kind: alert, ...}
end
end
Note over S,D: On reconnect: alert auto-cleared
```
### 5. Offline Buffering (router → server, with outages)
```mermaid
sequenceDiagram
participant Ev as Event source
participant H as Hotplug script
participant L as Lua agent
participant Buf as Shared buffer
NDJSON file
participant W as Wake file
participant S as Go server
participant D as SQLite
Note over Ev,D: Single buffer handles BOTH hotplug and Lua events.
Note over Ev,D: Survives: internet outage, server restart, agent restart.
Note over Ev,D: Does NOT survive: router reboot (tmpfs).
Ev->>H: WiFi/DHCP event
H->>Buf: append NDJSON line
H->>W: touch wake file
H->>L: send SIGHUP (best-effort)
L->>W: poll wake file (each loop)
L->>Buf: read queued events
loop per event (in order)
L->>S: WS frame {type: event, ...}
S->>D: INSERT events
S-->>L: ACK
end
L->>Buf: remove sent lines
alt Internet down
L->>L: WS send fails
L->>Buf: append own event
Note over L: exponential backoff
L->>L: 30s → 60s → 120s → ... → 5min
Note over L: 10s retry when buffer >80% full
end
alt Buffer overflow (>1000 events)
L->>L: drop oldest (FIFO), log warning
end
```
**File locations:**
| File | Purpose | Owner |
|---|---|---|
| `/var/run/client2server/buffer` | NDJSON event queue | Both hotplug + Lua (shared) |
| `/var/run/client2server/pid` | Lua agent's PID | Lua (written at startup) |
| `/var/run/client2server/wake` | Hotplug → agent signal | Hotplug (touch), Lua (consume) |
| `/var/run/client2server/env` | UCI exports for hotplug | init.d (write), hotplug (read) |
| `/usr/share/client2server/hotplug-lib.sh` | Shared hotplug helpers | Makefile (install) |
**Wake latency:** bounded by the Lua agent's main loop (1s sleep between
iterations), so a hotplug event is typically picked up within ~1s. SIGHUP
is sent as a fast path but is not relied on (Lua 5.1 has no portable
signal API).
---
## Component Architecture
### Go server (single process, 6 files)
```mermaid
flowchart LR
subgraph HTTP["HTTP routes (port 3843)"]
Login["/api/auth/login"]
Me["/api/auth/me"]
EvIn["POST /api/events"]
EvList["GET /api/events/list"]
EvStream["GET /api/events/stream"]
Routers["GET /api/routers"]
Cmd["POST /api/command"]
CmdList["GET /api/commands"]
Metrics["GET /api/metrics"]
Alerts["GET /api/alerts"]
Ack["POST /api/alerts/:id/ack"]
Health["GET /health"]
end
subgraph WS["WebSocket /ws"]
RouterWS["router connections"]
end
subgraph Core["Server internals"]
Store[("store.go
SQLite layer")]
Auth[("auth.go
JWT + scrypt")]
SSE[("sse.go
broadcaster")]
Metrics2[("metrics.go
1-min buckets")]
Consumer[("consumer.go
Redpanda → SQLite")]
Publisher[("Kafka producer
(franz-go)")]
RouterMgr[("router state
in-memory map")]
CmdQueue[("command queue
per router")]
IdemJanitor[("idempotency
TTL janitor")]
end
Login --> Auth
Me --> Auth
Cmd --> Store
CmdList --> Store
Metrics --> Metrics2
Alerts --> Store
Ack --> Store
EvList --> Store
EvIn --> Store
EvIn --> Publisher
EvIn --> SSE
RouterWS --> RouterMgr
RouterWS --> CmdQueue
RouterWS --> Publisher
Consumer --> Store
Publisher --> RP["Redpanda :9092"]
RP --> Consumer
SSE --> EvStream
IdemJanitor --> Store
```
### Dashboard (React SPA)
```mermaid
flowchart TB
subgraph SPA["dashboard/ (Vite build → Caddy)"]
Routes[("Wouter router")]
AuthGate[("Auth gate
(localStorage c2s_token)")]
Login[("Login page")]
Overview[("Overview: stats + charts")]
Routers[("Routers: status grid")]
Events[("Events: filterable table")]
Commands[("Commands: console + history")]
Alerts[("Alerts: inbox")]
TanQ[("TanStack Query
data fetching")]
SSEClient[("EventSource
/api/events/stream")]
API[("lib/api.ts
fetch + JWT header")]
end
Routes --> AuthGate
AuthGate -->|no token| Login
AuthGate -->|has token| Overview
Overview --> TanQ
Routers --> TanQ
Events --> TanQ
Commands --> TanQ
Alerts --> TanQ
TanQ --> API
TanQ --> SSEClient
API -->|REST| GoServer[("Go server :3843")]
SSEClient -->|EventSource| GoServer
```
### Router (OpenWrt)
```mermaid
flowchart LR
subgraph Router["OpenWrt"]
Init[("/etc/init.d/client2server")]
State[("State dir
/var/run/client2server/
{buffer,pid,env,wake}")]
Lua[("client2server-unified.lua")]
Lib[("hotplug-lib.sh
/usr/share/client2server/")]
HP1[("hotplug.d/wireless/01-wifi")]
HP2[("hotplug.d/dhcp/02-dhcp")]
UCI[("/etc/config/client2server")]
UB[("UCI store")]
WScli[("WebSocket client
(luasocket)")]
CMDex[("Command executor")]
end
Init -->|create state dir| State
Init -->|export UCI env| State
Init -->|launch| Lua
UCI --> Lua
Lua -->|write pid| State
HP1 --> Lib
HP2 --> Lib
Lib -->|spool NDJSON| State
Lib -->|touch wake| State
Lua -->|poll wake file| State
Lua -->|WS| Server[("Go server :3843")]
Server -->|WS frame| WScli
WScli --> CMDex
CMDex --> UB
Lua -->|read state| Sys[("/sys/class/net/*
/usr/bin/ubus")]
Lua -->|poll dnsmasq leases| DNS[("/tmp/dhcp.leases")]
```
---
## Auth & Persistence
### Auth
| Caller | Method | Where it lives |
|---|---|---|
| Routers (Lua) | legacy `TOKEN` shared secret | `Authorization: Bearer ` on HTTP, query on WS |
| Hotplug scripts | legacy `TOKEN` shared secret | `-H "Authorization: Bearer $TOKEN"` |
| Dashboard | JWT (HS256, 24h) | `Authorization: Bearer ` for REST, `?token=` for SSE |
- Passwords: scrypt-hashed, stored in `users` table
- Default admin/admin created on first boot if `users` table empty (warning logged)
- Roles: `system_admin` (everything), `project_admin` (commands + read), `user` (read-only)
### SQLite schema
```mermaid
erDiagram
users ||--o{ sessions : "issued"
events }o--|| routers : "logical"
commands }o--|| routers : "target"
alerts }o--|| routers : "subject"
users {
int id PK
text username UK
text password_hash
text role
timestamp created_at
}
events {
int id PK
text router_id
text event_type
text payload_json
timestamp created_at
}
commands {
text id PK
text router_id
text command
text args_json
text status
text result_json
timestamp created_at
timestamp completed_at
}
alerts {
int id PK
text router_id
text kind
text message
bool acknowledged
timestamp created_at
timestamp acknowledged_at
}
```
### Files
| Concern | File | Notes |
|---|---|---|
| HTTP/WS routes | `server/main.go` | orchestration, hand-off to internal pkgs |
| SQLite layer | `server/store.go` | `SetMaxOpenConns(1)` single-writer |
| Auth | `server/auth.go` | JWT sign/verify, scrypt, role middleware |
| Metrics | `server/metrics.go` | in-memory ring of 1-min buckets, 24h retention |
| SSE | `server/sse.go` | broadcaster + per-client goroutine |
| Kafka consumer | `server/consumer.go` | consumer group `client2server-persistor` |
---
## Error Handling & Resilience
```mermaid
flowchart TD
Start[("Event arrives")]
Auth{Auth OK?}
Auth -->|No| A401[401]
Auth -->|Yes| DBWrite[("INSERT INTO events")]
DBWrite -->|ok| Publish[("Kafka publish
fire-and-forget
100ms local queue")]
DBWrite -->|error| A500[500 + log]
Publish -->|queued ok| SSE[("Broadcast to SSE subscribers")]
Publish -->|broker down| LogErr[("log error, continue
(local ack to client)")]
SSE --> End[("done")]
LogErr --> End
```
```mermaid
flowchart TD
Cmd[("POST /api/command")]
Cmd --> Check{Is router online?}
Check -->|Yes| Send[("Send via WS")]
Check -->|No| Queue[("Persist to commands table
(status=pending)")]
Queue --> Wait[("Wait for router reconnect")]
Wait --> Send
Send --> Ack{Result?}
Ack -->|success| Done[("UPDATE status=completed")]
Ack -->|timeout| Retry[("Mark for retry
(next reconnect)")]
```
```mermaid
flowchart TD
Disconnect[("WS disconnected")]
Disconnect --> Timer[("Mark router offline after 60s")]
Timer --> Alert[("Create router_offline alert")]
Alert --> Watch[("30s offline-watcher tick")]
Watch --> Recv{Online again?}
Recv -->|Yes| Clear[("Auto-clear alert")]
Recv -->|No| Watch
Clear --> Flush[("Flush queued commands")]
```
---
## Topics & Ports
### Redpanda topics
| Topic | Partitions | Retention | Purpose |
|-------|-----------|-----------|---------|
| `router-events` | 3 | 7 days | All router events (DHCP, WiFi, WAN) |
| `router-commands` | 3 | 1 hour | Commands to routers (consumed by server) |
### Ports
| Service | Port | Protocol | Notes |
|---------|------|----------|-------|
| Caddy (dashboard) | 80, 443 | HTTP(S) | Reverse-proxies `/api/*`, `/ws` to go server |
| Caddy (API/WS) | 3843 | HTTP + WS | Unified: routers + dashboard API |
| Go server (×2) | 3843 | HTTP + WS | Internal, behind Caddy |
| Redpanda Kafka | 9092 | Kafka | Internal |
| Redpanda REST | 8082 | HTTP | Schema/management (optional) |
| Dashboard dev (Vite) | 5173 | HTTP | Local dev only |
### docker-compose services
| Service | Image | Purpose |
|---------|-------|---------|
| `redpanda` | `redpandadata/redpanda` | Kafka broker |
| `caddy` | `caddy:2-alpine` | Reverse proxy + LB |
| `server1`, `server2` | local `server/Dockerfile` | Go app, behind Caddy |
| `dashboard` | local `dashboard/Dockerfile` | Static SPA behind Caddy |
---
## Commands
13 commands accepted by the router. The first 5 are the "documented" ones surfaced
in the dashboard UI; the rest are exposed for internal/advanced use via `/api/command`.
| Command | Args | Notes | Surfaced in UI |
|---------|------|-------|:-:|
| `reboot` | — | dangerous | ✅ |
| `wifi_restart` | — | | ✅ |
| `status` | — | | ✅ |
| `shell` | `{command}` | dangerous | ✅ |
| `uci_set` | `{config, section, option, value}` | dangerous | ✅ |
| `uci_commit` | `{config}` | | |
| `uci_reload` | — | | |
| `client2server_enable` | `{0\|1}` | toggle via UCI | |
| `client2server_url` | `{url}` | update server URL | |
| `client2server_token` | `{token}` | rotate token | |
| `firewall_restart` | — | | |
| `network_restart` | — | | |
| `get_config` | — | dumps UCI config as JSON | |
---
## Events
Sent by router over WS, hotplug scripts, or `POST /api/events`.
| 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` |
| `command_result` | router | `command_id`, `success`, `output` |
---
## Build & Deploy
### Go server
```bash
cd server
go build -o server . # local
docker build -t client2server:dev . # container
```
### Dashboard
```bash
cd dashboard
npm install
npm run build # → dist/ (static)
docker build -t client2server-dashboard:dev .
```
### Full stack
```bash
cd /path/to/client2server
cat > .env <