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
- System Overview
- Data Flows
- Component Architecture
- Auth & Persistence
- Error Handling & Resilience
- Topics & Ports
- Commands
- Events
- Build & Deploy
System Overview
flowchart TB
subgraph Router["OpenWrt Router"]
direction TB
Lua[("client2server-unified.lua")]
Hotplug[("/etc/hotplug.d/* scripts")]
Wifi[("wifi_connected / disconnected")]
DHCP[("dhcp_lease_new / expire")]
WAN[("wan_link / dhcp events")]
CMD[("Command Executor")]
Buf[("Event buffer
/tmp/event_buffer")]
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| Buf
Buf -->|reconnect| Lua
Hotplug -->|HTTP POST /api/events| S1
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
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)
sequenceDiagram
participant R as Router
participant H as Hotplug
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
par Hotplug path (instant)
H->>S: POST /api/events (curl)
and Lua state-diff (≤1s)
R->>S: WS frame: {type: event, ...}
end
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-->>R: ACK
S-->>H: 200 OK
2. Command Flow (Dashboard → Router)
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)
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)
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
Component Architecture
Go server (single process, 6 files)
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)
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)
flowchart LR
subgraph Router["OpenWrt"]
Init[("/etc/init.d/client2server")]
Lua[("client2server-unified.lua")]
HP1[("hotplug.d/wireless/01-wifi")]
HP2[("hotplug.d/dhcp/02-dhcp")]
UCI[("/etc/config/client2server")]
UB[("UCI store")]
Buf[("/tmp/event_buffer")]
WScli[("WebSocket client
(mosquitto/lua-websockets)")]
CMDex[("Command executor")]
end
Init --> Lua
UCI --> Lua
HP1 -->|curl POST /api/events| Server[("Go server :3843")]
HP2 -->|curl POST /api/events| Server
Lua -->|WS| Server
Lua --> Buf
Buf --> Lua
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 <token> on HTTP, query on WS |
| Hotplug scripts |
legacy TOKEN shared secret |
-H "Authorization: Bearer $TOKEN" |
| Dashboard |
JWT (HS256, 24h) |
Authorization: Bearer <jwt> for REST, ?token=<jwt> 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
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
flowchart TD
Start[("Event arrives")]
Auth{Auth OK?}
Auth -->|No| A401[401]
Auth -->|Yes| DBWrite[("INSERT INTO events")]
DBWrite -->|ok| Publish[("Kafka publish<br/>fire-and-forget<br/>100ms local queue")]
DBWrite -->|error| A500[500 + log]
Publish -->|queued ok| SSE[("Broadcast to SSE subscribers")]
Publish -->|broker down| LogErr[("log error, continue<br/>(local ack to client)")]
SSE --> End[("done")]
LogErr --> End
flowchart TD
Cmd[("POST /api/command")]
Cmd --> Check{Is router online?}
Check -->|Yes| Send[("Send via WS")]
Check -->|No| Queue[("Persist to commands table<br/>(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<br/>(next reconnect)")]
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
cd server
go build -o server . # local
docker build -t client2server:dev . # container
Dashboard
cd dashboard
npm install
npm run build # → dist/ (static)
docker build -t client2server-dashboard:dev .
Full stack
cd /path/to/client2server
cat > .env <<EOF
TOKEN=***
JWT_SECRET=*** rand -hex 32)
EOF
docker-compose up -d
# Dashboard at http://localhost/ (admin/admin)
# API at http://localhost:3843
OpenWrt IPK
./scripts/feeds update -a
./scripts/feeds install client2server-unified
make package/client2server-unified/compile
make package/client2server-unified/ipk
Source Layout
client2server/
├── ARCHITECTURE.md # this file
├── README.md # user-facing docs
├── MEMORY.md # project memory (AI/agent context)
├── Caddyfile # routes :80,:443 → dashboard, :3843 → go servers
├── docker-compose.yml # redpanda, caddy, server1, server2, dashboard
├── package/ # OpenWrt IPK build
│ ├── src/client2server-unified.lua
│ ├── files/etc/{init.d,config}/client2server
│ └── hotplug/{01-wifi,02-dhcp}
├── server/ # Go server (single binary, 6 files)
│ ├── main.go # HTTP/WS routes + ingest
│ ├── auth.go # JWT + scrypt
│ ├── store.go # SQLite layer
│ ├── consumer.go # Redpanda → SQLite
│ ├── metrics.go # time-series
│ ├── sse.go # live feed
│ └── Dockerfile
└── dashboard/ # React SPA
├── src/
│ ├── App.tsx
│ ├── lib/{api,types,sse}.ts
│ ├── components/{Shell,ui}.tsx
│ └── pages/{Login,Overview,Routers,Events,Commands,Alerts}.tsx
├── Dockerfile
└── Caddyfile.production