ARCHITECTURE.md 19 KB

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
  2. Data Flows
  3. Component Architecture
  4. Auth & Persistence
  5. Error Handling & Resilience
  6. Topics & Ports
  7. Commands
  8. Events
  9. Build & Deploy

System Overview

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

sequenceDiagram
    participant R as Router
    participant H as Hotplug
    participant Buf as Shared buffer<br/>(/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<br/>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)

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

5. Offline Buffering (router → server, with outages)

sequenceDiagram
    participant Ev as Event source
    participant H as Hotplug script
    participant L as Lua agent
    participant Buf as Shared buffer<br/>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)

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")]
        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 <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,_lib.sh}
├── 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