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