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

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

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

```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)")]
```

```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 <<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,_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
```
