|
|
@@ -1,6 +1,21 @@
|
|
|
# client2server Architecture
|
|
|
|
|
|
-> Complete system architecture for bidirectional event forwarding between OpenWrt routers and central server.
|
|
|
+> 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
|
|
|
|
|
|
@@ -9,346 +24,571 @@ flowchart TB
|
|
|
subgraph Router["OpenWrt Router"]
|
|
|
direction TB
|
|
|
Lua[("client2server-unified.lua")]
|
|
|
- DHCP[("DHCP Monitor")]
|
|
|
- WAN[("WAN Monitor")]
|
|
|
+ Hotplug[("/etc/hotplug.d/* scripts")]
|
|
|
+ Wifi[("wifi_connected / disconnected")]
|
|
|
+ DHCP[("dhcp_lease_new / expire")]
|
|
|
+ WAN[("wan_link / dhcp events")]
|
|
|
CMD[("Command Executor")]
|
|
|
- Link[("Link Monitor")]
|
|
|
+ Buf[("Event buffer
|
|
|
+/tmp/event_buffer")]
|
|
|
end
|
|
|
-
|
|
|
- subgraph Cloud["Cloud"]
|
|
|
- subgraph CaddyLB["Caddy Load Balancer :3843"]
|
|
|
- WS[("WebSocket")]
|
|
|
- API[("HTTP/WS :3843")]
|
|
|
- end
|
|
|
-
|
|
|
- subgraph Server["Go Servers (x2)"]
|
|
|
- S1[("server1")]
|
|
|
- S2[("server2")]
|
|
|
- end
|
|
|
-
|
|
|
- subgraph Redpanda["Redpanda"]
|
|
|
- Events[("router-events")]
|
|
|
- Commands[("router-commands")]
|
|
|
- end
|
|
|
-
|
|
|
- subgraph Backend["LuIS Backend"]
|
|
|
- API2[("API Server")]
|
|
|
- DB[(Database)]
|
|
|
- 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 -->|WS| CaddyLB
|
|
|
- CaddyLB --> WS
|
|
|
- WS <-->|lb| S1
|
|
|
- WS <-->|lb| S2
|
|
|
- S1 -->|publish| Events
|
|
|
- S2 -->|publish| Commands
|
|
|
- Events --> Redpanda
|
|
|
- Commands --> Redpanda
|
|
|
- Redpanda --> API2
|
|
|
- API2 --> DB
|
|
|
+
|
|
|
+ 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
|
|
|
|
|
|
-### Event Flow (Router → Server)
|
|
|
+### 1. Event Flow (Router → Dashboard, real-time)
|
|
|
|
|
|
```mermaid
|
|
|
sequenceDiagram
|
|
|
participant R as Router
|
|
|
- participant C as Caddy
|
|
|
+ participant H as Hotplug
|
|
|
participant S as Go Server
|
|
|
- participant K as Redpanda
|
|
|
- participant L as LuIS
|
|
|
-
|
|
|
- Note over R,L: DHCP Event Example
|
|
|
-
|
|
|
- R->>C: WebSocket connect → :3843
|
|
|
- activate C
|
|
|
- C->>S: Proxy to server1
|
|
|
- activate S
|
|
|
- S->>K: Produce → router-events
|
|
|
- activate K
|
|
|
- K-->>S: ACK
|
|
|
- deactivate K
|
|
|
- S-->>C: ACK
|
|
|
- deactivate S
|
|
|
- C-->>R: Connected!
|
|
|
- deactivate C
|
|
|
-
|
|
|
- Note over R,L: Later - New DHCP Lease
|
|
|
- R->>C: Event: dhcp_lease_new
|
|
|
- C->>S: Forward event
|
|
|
- S->>K: Publish event
|
|
|
- S->>R: ACK
|
|
|
-
|
|
|
- Note over R,L: LuIS Dashboard consumes
|
|
|
- L->>K: Consume router-events
|
|
|
- K-->>L: Event data
|
|
|
+ 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
|
|
|
```
|
|
|
|
|
|
-### Command Flow (Server → Router)
|
|
|
+### 2. Command Flow (Dashboard → Router)
|
|
|
|
|
|
```mermaid
|
|
|
sequenceDiagram
|
|
|
- participant L as LuIS
|
|
|
+ participant D as Dashboard
|
|
|
participant S as Go Server
|
|
|
- participant K as Redpanda
|
|
|
+ participant DB as SQLite
|
|
|
participant R as Router
|
|
|
-
|
|
|
- Note over L,R: Server sends command to router
|
|
|
- L->>S: POST /api/command
|
|
|
- S->>K: Publish → router-commands
|
|
|
- K-->>S: ACK
|
|
|
- S-->>L: command_id
|
|
|
-
|
|
|
- Note over R,R: Router receives via WebSocket
|
|
|
- R->>R: Listen for commands
|
|
|
- R->>R: Execute: uci set network.lan.ipaddr='192.168.1.1'
|
|
|
- R->>R: Commit: uci commit network
|
|
|
- R->>S: Return result
|
|
|
- S->>K: Publish result (optional)
|
|
|
+ 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
|
|
|
```
|
|
|
|
|
|
-## Component Architecture
|
|
|
+### 3. Login Flow (Dashboard only)
|
|
|
|
|
|
```mermaid
|
|
|
-flowchart LR
|
|
|
- subgraph Router["Router (OpenWrt)"]
|
|
|
- WSCli[("WebSocket Client")]
|
|
|
- Buf[("Buffer File
|
|
|
-/tmp/event_buffer")]
|
|
|
- Events[("Event Sources")]
|
|
|
- CMD[("Command Executor")]
|
|
|
-
|
|
|
- Events -->|new| WSCli
|
|
|
- WSCli -->|offline| Buf
|
|
|
- Buf -->|flush| WSCli
|
|
|
- end
|
|
|
-
|
|
|
- subgraph Server["Go Server"]
|
|
|
- WSHan[("WS Handler")]
|
|
|
- RedProd[("Producer")]
|
|
|
- RedCons[("Consumer")]
|
|
|
- Routers[("Router State")]
|
|
|
- CMDServ[("Command Service")]
|
|
|
+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
|
|
|
-
|
|
|
- WSCli -->|connect| WSHan
|
|
|
- WSHan --> RedProd
|
|
|
- RedProd -->|topics| Redpanda["Redpanda"]
|
|
|
- Redpanda --> RedCons
|
|
|
- RedCons --> CMDServ
|
|
|
- CMDServ --> Routers
|
|
|
```
|
|
|
|
|
|
-## Port Mappings
|
|
|
-
|
|
|
-| Service | Port | Protocol | Purpose |
|
|
|
-|---------|------|----------|--------||
|
|
|
-| Caddy WS | 3843 | WebSocket | Router connections |
|
|
|
-| Caddy HTTP | 3843 | HTTP | Same port as WS; serves `/api/*` + `/health` |
|
|
|
-| Redpanda | 9092 | Kafka | Event storage |
|
|
|
-| Redpanda REST | 8082 | HTTP | Schema registry |
|
|
|
+### 4. Offline Watcher (alerts)
|
|
|
|
|
|
-## Configuration
|
|
|
+```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
|
|
|
|
|
|
-```yaml
|
|
|
-# docker-compose.yml
|
|
|
-environment:
|
|
|
- REDPANDA_BROKERS: redpanda:9092
|
|
|
- TOKEN: ***
|
|
|
- PORT: 3843
|
|
|
+ Note over S,D: On reconnect: alert auto-cleared
|
|
|
```
|
|
|
|
|
|
-## Build Targets
|
|
|
+---
|
|
|
+
|
|
|
+## Component Architecture
|
|
|
+
|
|
|
+### Go server (single process, 6 files)
|
|
|
|
|
|
```mermaid
|
|
|
flowchart LR
|
|
|
- A[("Source")] --> B[("Build")]
|
|
|
- B --> C[("Package")]
|
|
|
- B --> D[("Docker")]
|
|
|
- B --> E[("IPK")]
|
|
|
-
|
|
|
- C -->|Go| D[("server binary")]
|
|
|
- C -->|Lua| E[("OpenWrt ipk")]
|
|
|
-
|
|
|
- subgraph Build["Builds"]
|
|
|
- direction TB
|
|
|
- F[("docker-compose up")]
|
|
|
- G[("./scripts/feeds")]
|
|
|
+ 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
|
|
|
-```
|
|
|
|
|
|
-## Topics (Redpanda)
|
|
|
+ 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
|
|
|
|
|
|
-| Topic | Partitions | Retention | Purpose |
|
|
|
-|-------|-----------|-----------|---------|
|
|
|
-| router-events | 3 | 7 days | All router events |
|
|
|
-| router-commands | 3 | 1 hour | Commands to routers |
|
|
|
+ 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
|
|
|
+```
|
|
|
|
|
|
-## Error Handling
|
|
|
+### Dashboard (React SPA)
|
|
|
|
|
|
```mermaid
|
|
|
-flowchart TD
|
|
|
- A[("Connection Lost")] --> B{Router Online?}
|
|
|
- B -->|Yes| C[("Buffer Events")]
|
|
|
- C --> D[("Retry Every 5s")]
|
|
|
- D --> E{Connected?}
|
|
|
- E -->|Yes| F[("Flush Buffer")]
|
|
|
- E -->|No| D
|
|
|
- B -->|No| G[("Wait")]
|
|
|
- G --> H[("Timer Reset")]
|
|
|
- H --> A
|
|
|
+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
|
|
|
```
|
|
|
|
|
|
-## System Overview
|
|
|
+### Router (OpenWrt)
|
|
|
|
|
|
```mermaid
|
|
|
-flowchart TB
|
|
|
- subgraph Router["OpenWrt Router"]
|
|
|
- direction TB
|
|
|
+flowchart LR
|
|
|
+ subgraph Router["OpenWrt"]
|
|
|
+ Init[("/etc/init.d/client2server")]
|
|
|
Lua[("client2server-unified.lua")]
|
|
|
- DHCP[("DHCP Monitor")]
|
|
|
- WAN[("WAN Monitor")]
|
|
|
- CMD[("Command Executor")]
|
|
|
- end
|
|
|
-
|
|
|
- subgraph CaddyLB["Caddy Load Balancer"]
|
|
|
- WS["WebSocket :3843"]
|
|
|
- API["HTTP API :3843"]
|
|
|
- end
|
|
|
-
|
|
|
- subgraph Server["Go Servers"]
|
|
|
- S1["server1"]
|
|
|
- S2["server2"]
|
|
|
+ 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
|
|
|
-
|
|
|
- subgraph Redpanda["Redpanda Cluster"]
|
|
|
- Events[("router-events")]
|
|
|
- Commands[("router-commands")]
|
|
|
- end
|
|
|
-
|
|
|
- subgraph Backend["LuIS Backend"]
|
|
|
- API2[("API Server")]
|
|
|
- DB[(Database)]
|
|
|
- end
|
|
|
-
|
|
|
- Router -->|WebSocket| CaddyLB
|
|
|
- CaddyLB --> WS
|
|
|
- WS --> S1
|
|
|
- WS --> S2
|
|
|
- S1 --> Events
|
|
|
- S2 --> Commands
|
|
|
- Events --> Redpanda
|
|
|
- Commands --> Redpanda
|
|
|
- Redpanda --> API2
|
|
|
- API2 --> DB
|
|
|
+
|
|
|
+ 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")]
|
|
|
```
|
|
|
|
|
|
-## Data Flow
|
|
|
+---
|
|
|
+
|
|
|
+## 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
|
|
|
|
|
|
```mermaid
|
|
|
-sequenceDiagram
|
|
|
- participant R as Router
|
|
|
- participant C as Caddy
|
|
|
- participant S as Go Server
|
|
|
- participant K as Redpanda
|
|
|
- participant L as LuIS
|
|
|
-
|
|
|
- Note over R,L: Event Flow
|
|
|
-
|
|
|
- R->>C: WebSocket connect :3843
|
|
|
- C->>S: Proxy to server1
|
|
|
- S->>K: Publish to router-events
|
|
|
-
|
|
|
- Note over R,L: Command Flow
|
|
|
-
|
|
|
- L->>K: Publish command
|
|
|
- K->>S: Consume from router-commands
|
|
|
- S->>R: WebSocket send command
|
|
|
- R->>R: Execute (uci_set, shell, etc)
|
|
|
- R->>S: Ack result
|
|
|
- S->>K: Publish result
|
|
|
- K->>L: Result stored
|
|
|
+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
|
|
|
+ }
|
|
|
```
|
|
|
|
|
|
-## Component Details
|
|
|
+### 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 LR
|
|
|
- subgraph Lua["Lua Client"]
|
|
|
- WS[("WebSocket")]
|
|
|
- Buf[("Buffer")]
|
|
|
- DHCP[("DHCP")]
|
|
|
- WAN[("WAN")]
|
|
|
- CMD[("Commands")]
|
|
|
- end
|
|
|
-
|
|
|
- subgraph GoServer["Go Server"]
|
|
|
- Handler[("Handler")]
|
|
|
- Redpanda[("Redpanda")]
|
|
|
- RouterMgr[("Router State")]
|
|
|
- end
|
|
|
-
|
|
|
- WS -->|buffer when offline| Buf
|
|
|
- Buf -->|flush on reconnect| Handler
|
|
|
- DHCP -->|events| Handler
|
|
|
- WAN -->|events| Handler
|
|
|
- Handler --> Redpanda
|
|
|
- Redpanda --> RouterMgr
|
|
|
+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
|
|
|
```
|
|
|
|
|
|
-## Ports
|
|
|
+```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<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)")]
|
|
|
+```
|
|
|
|
|
|
-| Service | Port | Protocol |
|
|
|
-|---------|------|----------|
|
|
|
-| WebSocket LB | 3843 | WS |
|
|
|
-| HTTP API + WS | 3843 | HTTP + WS | Unified port |
|
|
|
-| Redpanda Kafka | 9092 | Kafka |
|
|
|
-| Redpanda REST | 8082 | HTTP |
|
|
|
+```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")]
|
|
|
+```
|
|
|
|
|
|
-## Config
|
|
|
+---
|
|
|
|
|
|
-```yaml
|
|
|
-# Environment
|
|
|
-TOKEN: *** # Auth token
|
|
|
-REDPANDA_BROKERS: redpanda:9092 # Redpanda address
|
|
|
-PORT: 3843 # Listening port
|
|
|
-```
|
|
|
+## 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
|
|
|
|
|
|
-| Command | Example | Response |
|
|
|
-|---------|---------|-----------|
|
|
|
-| `uci_set` | `{"config":"network", "section":"lan", "option":"ipaddr", "value":"192.168.1.1"}` | Success |
|
|
|
-| `shell` | `{"command":"reboot"}` | Output |
|
|
|
-| `reboot` | `{}` | Scheduled |
|
|
|
-| `wifi_restart` | `{}` | Network restart |
|
|
|
-| `status` | `{}` | JSON status |
|
|
|
+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 | mac, ip, hostname |
|
|
|
-| `dhcp_lease_expire` | dnsmasq | 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 |
|
|
|
+| `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` |
|
|
|
|
|
|
-## Docker Run
|
|
|
+---
|
|
|
+
|
|
|
+## Build & Deploy
|
|
|
+
|
|
|
+### Go server
|
|
|
|
|
|
```bash
|
|
|
-TOKEN=*** docker-compose up -d
|
|
|
+cd server
|
|
|
+go build -o server . # local
|
|
|
+docker build -t client2server:dev . # container
|
|
|
```
|
|
|
|
|
|
-## IPK Build
|
|
|
+### Dashboard
|
|
|
|
|
|
```bash
|
|
|
-# With OpenWrt SDK
|
|
|
+cd dashboard
|
|
|
+npm install
|
|
|
+npm run build # → dist/ (static)
|
|
|
+docker build -t client2server-dashboard:dev .
|
|
|
+```
|
|
|
+
|
|
|
+### Full stack
|
|
|
+
|
|
|
+```bash
|
|
|
+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
|
|
|
+
|
|
|
+```bash
|
|
|
+./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
|
|
|
+```
|