Forráskód Böngészése

Migrate Go server: coder/websocket + franz-go, unify port 3843

The Go server had a broken go.mod that prevented building:
- nhooyr.io/websocket was archived (version no longer resolvable)
- github.com/redpanda-data/redpanda-sdk-go never existed as a public
  module (placeholder version v0.0.0-20240601023312-1234567890ab)

Replaced with maintained, real packages:
- nhooyr.io/websocket   -> github.com/coder/websocket v1.8.13
                            (drop-in fork, maintained by coder)
- redpanda-sdk-go       -> github.com/twmb/franz-go v1.18.0
                            + pkg/kadm v1.14.0
                            (pure Go, fast, real Kafka client;
                             Redpanda speaks the Kafka wire protocol)

Unified WebSocket and HTTP API on a single port (3843). Caddy now does
all the LB and route dispatching; the Go server just serves /ws + /api/*
on the same listener. Drops the legacy separate :3844 port.

Other improvements:
- main.go rewritten cleanly: ~600 lines, mutex-guarded state, better
  error handling, graceful shutdown that closes all WS connections
- 3-second timeout on every Redpanda publish so HTTP handlers can't
  hang forever if the broker is down (returns 502 instead)
- Background janitor to expire idempotency cache (no unbounded growth)
- Dockerfile: Go 1.22 builder, single EXPOSE 3843
- Caddyfile: rewritten with proper reverse_proxy block, no more
  placeholder 'ws://' + 'to' mix that didn't actually work
- docker-compose: dropped obsolete 3844:3844 port mapping
- README + ARCHITECTURE: port references updated to 3843 unified
- MEMORY: tech stack, ports, HTTP API table, server architecture,
  evolution history, known issues all updated

Smoke tested: build clean, go vet clean, all endpoints respond
correctly (health 200, events 401/502, command queues while offline,
shutdown clean).
Gogs 2 hónapja
szülő
commit
9f2269a935
9 módosított fájl, 567 hozzáadás és 362 törlés
  1. 4 4
      ARCHITECTURE.md
  2. 26 58
      Caddyfile
  3. 45 20
      MEMORY.md
  4. 7 8
      README.md
  5. 1 2
      docker-compose.yml
  6. 4 2
      server/Dockerfile
  7. 9 6
      server/go.mod
  8. 16 0
      server/go.sum
  9. 455 262
      server/main.go

+ 4 - 4
ARCHITECTURE.md

@@ -18,7 +18,7 @@ flowchart TB
     subgraph Cloud["Cloud"]
         subgraph CaddyLB["Caddy Load Balancer :3843"]
             WS[("WebSocket")]
-            API[("HTTP :3844")]
+            API[("HTTP/WS :3843")]
         end
         
         subgraph Server["Go Servers (x2)"]
@@ -147,7 +147,7 @@ flowchart LR
 | Service | Port | Protocol | Purpose |
 |---------|------|----------|--------||
 | Caddy WS | 3843 | WebSocket | Router connections |
-| Caddy HTTP | 3844 | HTTP | REST API |
+| Caddy HTTP | 3843 | HTTP | Same port as WS; serves `/api/*` + `/health` |
 | Redpanda | 9092 | Kafka | Event storage |
 | Redpanda REST | 8082 | HTTP | Schema registry |
 
@@ -217,7 +217,7 @@ flowchart TB
     
     subgraph CaddyLB["Caddy Load Balancer"]
         WS["WebSocket :3843"]
-        API["HTTP API :3844"]
+        API["HTTP API :3843"]
     end
     
     subgraph Server["Go Servers"]
@@ -305,7 +305,7 @@ flowchart LR
 | Service | Port | Protocol |
 |---------|------|----------|
 | WebSocket LB | 3843 | WS |
-| HTTP API | 3844 | HTTP |
+| HTTP API + WS | 3843 | HTTP + WS | Unified port |
 | Redpanda Kafka | 9092 | Kafka |
 | Redpanda REST | 8082 | HTTP |
 

+ 26 - 58
Caddyfile

@@ -1,5 +1,6 @@
 # Caddyfile for client2server
-# Supports multiple server instances via Docker Compose
+# Single port (:3843) serves WebSocket and HTTP API; Caddy load-balances
+# across server1 and server2 in the Docker network.
 
 {
     # Global options
@@ -9,85 +10,52 @@
 }
 
 # ========================================
-# WebSocket Load Balancer (port 3843)
+# Unified WebSocket + HTTP load balancer (:3843)
 # ========================================
-ws://:3843 {
+:3843 {
     # Backend servers (Docker network DNS)
-    to server1:3843 server2:3843
-    
-    # Load balancing
-    lb_try_duration 2s
-    lb_try_interval 500ms
-    
-    # Keep WebSocket connections alive
-    websocket
-    
-    # Health check
-    health_check /health {
-        interval 10s
-        timeout 5s
-        fails 2
-        passes 1
+    reverse_proxy server1:3843 server2:3843 {
+        # Health check each backend
+        health_uri /health
+        health_interval 10s
+        health_timeout 5s
+        health_status 2xx
+
+        # Load balancing policy
+        lb_policy round_robin
+
+        # WebSocket support (long-lived connections)
+        # Caddy auto-detects Upgrade headers, no explicit directive needed
     }
-    
+
     # Preserve client info
     header_up X-Real-IP {remote}
     header_up X-Forwarded-For {remote}
     header_up X-Forwarded-Proto {scheme}
-    header_up X-Client-ID {client_ip}
-    
-    # Timeouts for long-lived connections
+
+    # Timeouts for long-lived WebSocket connections
     timeouts {
-        read 300s
+        read  300s
         write 300s
-        idle 300s
+        idle  300s
     }
 }
 
 # ========================================
-# HTTP API (port 3844)
-# ========================================
-:3844 {
-    # Health endpoint for each server
-    handle /health* {
-        respond "OK" 200
-    }
-    
-    # Events webhook (HTTP fallback)
-    handle /api/events* {
-        reverse_proxy server1:3843
-    }
-    
-    # Router management
-    handle /api/routers* {
-        reverse_proxy server1:3843
-    }
-    
-    # Command endpoint
-    handle /api/command* {
-        reverse_proxy server1:3843
-    }
-    
-    # Default
-    respond "client2server API" 200
-}
-
-# ========================================
-# Dashboard (port 80/443)
+# Dashboard (port 80/443) - placeholder
 # ========================================
 :80, :443 {
-    # Redirect to HTTPS
-    respond / https://{$host}/dashboard {302}
+    respond / "client2server dashboard - not yet implemented" 200
 }
 
 # Dashboard placeholder (enable if you have a web UI)
-# localhost/dashboard {
+# :80 {
 #     reverse_proxy dashboard:3000
 # }
 
 # ========================================
-# TLS for HTTPS (optional)
+# TLS for HTTPS (optional - requires auto_https on)
 # ========================================
 # tls {
 #     # Let's Encrypt (automatic)
-# }
+# }

+ 45 - 20
MEMORY.md

@@ -1,6 +1,6 @@
 # client2server — Project Memory
 
-> Bi-directional event forwarder: OpenWrt routers → Go server → Redpanda → LuIS backend.
+> Bi-directional event forwarder: OpenWrt routers → Go server → Redpanda (Kafka) → LuIS backend.
 
 ## Purpose
 - Routers push events (DHCP leases, WiFi connects/disconnects, WAN state) to a central server.
@@ -13,10 +13,11 @@
 |-------|------|
 | Router client | Lua (OpenWrt), `client2server-unified.lua` |
 | Hotplug paths | Shell + curl, `/etc/hotplug.d/{wireless,dhcp}/*` |
-| Transport | WebSocket :3843 (Caddy LB → 2× Go servers) |
-| API | HTTP :3844 (Caddy → server1) |
+| Transport | WebSocket + HTTP (unified port) via Caddy LB |
+| WebSocket lib | `github.com/coder/websocket` v1.8.13 (fork of nhooyr.io) |
 | Event bus | Redpanda (Kafka-compatible) :9092 |
-| Server | Go (`nhooyr.io/websocket`, `redpanda-data/redpanda-sdk-go`) |
+| Kafka client | `github.com/twmb/franz-go` v1.18.0 + `pkg/kadm` v1.14.0 |
+| Server | Go 1.22 |
 
 ## Repo Layout (current)
 ```
@@ -24,7 +25,7 @@ client2server/
 ├── ARCHITECTURE.md      # Mermaid diagrams, full spec
 ├── README.md            # User-facing docs
 ├── MEMORY.md            # ← you are here
-├── Caddyfile            # LB + reverse proxy
+├── Caddyfile            # LB + reverse proxy (single port :3843)
 ├── docker-compose.yml   # redpanda + 2× server + caddy
 ├── package/
 │   ├── Makefile         # IPK build (includes hotplug)
@@ -37,13 +38,12 @@ client2server/
 │       ├── 01-wifi      # wireless hotplug → wifi_connected/disconnected
 │       └── 02-dhcp      # dhcp hotplug → dhcp_lease_new/expire
 └── server/
-    ├── main.go          # WS handler + Redpanda producer/consumer
+    ├── main.go          # WS handler + HTTP API + Redpanda producer
     ├── go.mod
-    └── Dockerfile
+    ├── go.sum
+    └── Dockerfile       # Go 1.22 builder + alpine runtime
 ```
 
-**Removed in 2026-06-09 cleanup:** `package/src/{minimal,ws}.lua`, legacy `etc/` and `usr/` (predecessor `event-forwarder`), `server/{index,server-ws}.js` (Node fallback). All in git history if needed.
-
 ## Event Types (router → server)
 | Event | Source | Payload |
 |-------|--------|---------|
@@ -56,8 +56,6 @@ client2server/
 | `wifi_connected` | hostapd hotplug | mac, interface |
 | `wifi_disconnected` | hostapd hotplug | mac, interface |
 
-✅ **All event names are now consistent** (hotplug scripts renamed to match unified.lua vocabulary on 2026-06-09).
-
 ## Commands (server → router)
 | Command | Args |
 |---------|------|
@@ -70,21 +68,32 @@ client2server/
 ## Ports
 | Service | Port | Notes |
 |---------|------|-------|
-| WebSocket LB | 3843 | Routers connect here |
-| HTTP API | 3844 | REST + health |
-| Redpanda Kafka | 9092 | Internal |
+| Caddy LB (WS + HTTP) | 3843 | Routers connect here, API served here too |
+| Go server (×2) | 3843 (internal) | Behind Caddy, not directly exposed |
+| Redpanda Kafka | 9092 | Internal Docker network |
 | Redpanda REST | 8082 | Schema/management |
 | Redpanda Schema | 8081 | Schema registry |
 
+## HTTP API
+All endpoints on `:3843`, behind Caddy LB.
+
+| Method | Path | Auth | Purpose |
+|--------|------|------|---------|
+| `GET`  | `/health` | none | Liveness + router stats |
+| `POST` | `/api/events` | Bearer | Event ingestion (hotplug + lua) |
+| `GET`  | `/api/routers` | none (recommended: add Bearer) | List known routers |
+| `POST` | `/api/command` | Bearer | Send command to router; awaits result |
+| `GET`  | `/ws` | query `?token=` or `Authorization: Bearer` | WebSocket upgrade |
+
 ## Quick Run
 ```bash
 # Full stack
 cd /root/.openclaw/workspace/client2server
 TOKEN=*** docker-compose up -d
 
-# Server only
+# Server only (Go 1.22+ required)
 cd server && go build -o server . && \
-  REDPANDA_BROKERS=localhost:9092 TOKEN=*** ./server
+  REDPANDA_BROKERS=localhost:9092 TOKEN=*** PORT=3843 ./server
 
 # Install on router (manual)
 scp package/src/client2server-unified.lua root@router:/usr/sbin/
@@ -113,7 +122,7 @@ The router has **two parallel event paths** to the server:
    - `wan_dhcp_new/changed`
    - `dhcp_lease_new/expire` (fallback / redundancy with hotplug)
 
-Both paths post to the same `POST /api/events` endpoint, so server-side sees one event stream.
+Both paths post to the same `POST /api/events` endpoint.
 
 ## Environment Wiring (init.d → hotplug)
 - `init.d/client2server` reads UCI on `start()` and writes `/var/run/client2server.env`
@@ -121,16 +130,32 @@ Both paths post to the same `POST /api/events` endpoint, so server-side sees one
 - Hotplug scripts also `uci get` directly as fallback (in case called outside init.d context)
 - Env file removed on `stop()`
 
+## Server Architecture (Go)
+- Single `main.go` (~600 lines) — WS handler, HTTP handlers, Redpanda producer
+- In-memory state: `routers`, `routerQueues` (per-router offline queue), `pendingCmds` (awaiting result), `executedCmds` (idempotency)
+- Background janitor: cleans `executedCmds` after `idempotencyTTL` (5 min)
+- **Publish timeout**: 3s per `kcl.ProduceSync()` call — if Redpanda is down, HTTP returns 502 instead of hanging
+- WebSocket auth: `?token=` query param or `Authorization: Bearer` header
+- HTTP API auth: `Authorization: Bearer` or raw token
+- Single port (3843) — mux routes `/ws` to WS handler, `/api/*` to REST, `/health` to liveness
+
 ## Client Evolution
-The Lua client went through ~6 architectural rewrites in 2 days (Jun 7 2026) trying to kill polling. Final settled state = hybrid hotplug + luv state-diff. See git log `9e248cd..0b6d8ff` for the iteration history.
+The Lua client went through ~6 architectural rewrites in 2 days (Jun 7 2026) trying to kill polling. Final settled state = hybrid hotplug + luv state-diff.
+
+The Go server went through a dependency overhaul on 2026-06-09:
+- `nhooyr.io/websocket` → `github.com/coder/websocket` (nhooyr archived, coder is the maintained fork; same API)
+- `github.com/redpanda-data/redpanda-sdk-go` (which never existed as a public module) → `github.com/twmb/franz-go` + `kadm` (real, fast, pure Go Kafka client — Redpanda speaks Kafka wire protocol natively)
+- Unified WebSocket and HTTP on port 3843 (Caddy handles routing)
 
 ## Known Issues / TODO
 - ⚠️ Caddyfile email is commented out — set real value if enabling `auto_https`
 - ⚠️ UCI default `wss://your-server.com/ws` is a placeholder — must be edited per-deployment
 - ⚠️ Docker-compose TOKEN is the literal string `***` — override via env or `.env` file
+- ⚠️ `/api/routers` currently has no auth — should require Bearer (intentional for monitoring, but flag it)
 - `unified.lua` is 759 lines — worth splitting into modules (DHCP/WiFi/WAN/WS/CMD) but functional as-is
-- No tests for the Go server; would benefit from integration tests using a mock WebSocket client
-- `command_id` idempotency + per-router command queue are server-side features (per commits 4134845, 7c85b33) — verify they work end-to-end
+- No automated tests for the Go server; manual smoke test confirms endpoints work (build, /health 200, /api/events 401/502, /api/command queues while offline)
+- Redpanda topic creation is best-effort (relies on `AUTO_CREATE_TOPICS=true` in dev); production should manage topics explicitly
+- `command_id` idempotency is server-side only — clients should pass `id` in `RouterCommand` for replay safety
 
 ## Author
 Luis Rosales — MIT License 2026

+ 7 - 8
README.md

@@ -88,9 +88,8 @@ ssh root@router "/etc/init.d/client2server enable && /etc/init.d/client2server s
 
 | Service | Port | Protocol |
 |----------|------|----------|
-| WebSocket | 3843 | WS |
-| HTTP API | 3844 | HTTP |
-| Redpanda | 9092 | Kafka |
+| WebSocket + HTTP | 3843 | WS + HTTP (unified) |
+| Redpanda | 9092 | Kafka (Redpanda) |
 
 ## Configuration
 
@@ -124,17 +123,17 @@ Send from server to router via WebSocket or HTTP API:
 
 ```bash
 # UCI set
-curl -X POST http://localhost:3844/api/command \
+curl -X POST http://localhost:3843/api/command \
   -H "Authorization: Bearer TOKEN" \
   -d '{"router_id":"router1","command":"uci_set","args":{"config":"network","section":"lan","option":"ipaddr","value":"192.168.1.1"}}'
 
 # Shell
-curl -X POST http://localhost:3844/api/command \
+curl -X POST http://localhost:3843/api/command \
   -H "Authorization: Bearer TOKEN" \
   -d '{"router_id":"router1","command":"shell","args":{"command":"reboot"}}'
 
 # Reboot
-curl -X POST http://localhost:3844/api/command \
+curl -X POST http://localhost:3843/api/command \
   -H "Authorization: Bearer TOKEN" \
   -d '{"router_id":"router1","command":"reboot"}'
 ```
@@ -215,8 +214,8 @@ logread -f -e client2server
 docker-compose logs -f redpanda
 
 # API
-curl http://localhost:3844/api/routers
-curl http://localhost:3844/api/events
+curl http://localhost:3843/api/routers
+curl http://localhost:3843/api/events
 ```
 
 ## Security

+ 1 - 2
docker-compose.yml

@@ -92,8 +92,7 @@ services:
     ports:
       - "80:80"
       - "443:443"
-      - "3843:3843"  # WebSocket
-      - "3844:3844"  # HTTP API
+      - "3843:3843"  # Unified WebSocket + HTTP API
     volumes:
       - ./Caddyfile:/etc/caddy/Caddyfile:ro
       - caddy_data:/data

+ 4 - 2
server/Dockerfile

@@ -1,4 +1,4 @@
-FROM golang:1.21-alpine AS builder
+FROM golang:1.22-alpine AS builder
 
 WORKDIR /app
 
@@ -26,9 +26,11 @@ RUN apk add --no-cache ca-certificates tzdata
 # Copy binary
 COPY --from=builder /client2server-server .
 
-EXPOSE 3843 3844
+# Single port serves both WebSocket and HTTP API (Caddy fronts everything)
+EXPOSE 3843
 
 ENV TOKEN=change_me_in_production
 ENV REDPANDA_BROKERS=redpanda:9092
+ENV PORT=3843
 
 CMD ["/client2server-server"]

+ 9 - 6
server/go.mod

@@ -1,14 +1,17 @@
 module github.com/lrosales/client2server
 
-go 1.21
+go 1.22
 
 require (
+	github.com/coder/websocket v1.8.13
 	github.com/google/uuid v1.6.0
-	github.com/redpanda-data/redpanda-sdk-go v0.0.0-20240601023312-1234567890ab
-	nhooyr.io/websocket v0.0.0-20231004141808-1d700588fda5
+	github.com/twmb/franz-go v1.18.0
+	github.com/twmb/franz-go/pkg/kadm v1.14.0
 )
 
 require (
-	github.com/google/uuid v1.6.0 // indirect
-	golang.org/x/net v0.21.0 // indirect
-)
+	github.com/klauspost/compress v1.17.11 // indirect
+	github.com/pierrec/lz4/v4 v4.1.21 // indirect
+	github.com/twmb/franz-go/pkg/kmsg v1.9.0 // indirect
+	golang.org/x/crypto v0.28.0 // indirect
+)

+ 16 - 0
server/go.sum

@@ -0,0 +1,16 @@
+github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
+github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
+github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
+github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
+github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/twmb/franz-go v1.18.0 h1:25FjMZfdozBywVX+5xrWC2W+W76i0xykKjTdEeD2ejw=
+github.com/twmb/franz-go v1.18.0/go.mod h1:zXCGy74M0p5FbXsLeASdyvfLFsBvTubVqctIaa5wQ+I=
+github.com/twmb/franz-go/pkg/kadm v1.14.0 h1:nAn1co1lXzJQocpzyIyOFOjUBf4WHWs5/fTprXy2IZs=
+github.com/twmb/franz-go/pkg/kadm v1.14.0/go.mod h1:XjOPz6ZaXXjrW2jVCfLuucP8H1w2TvD6y3PT2M+aAM4=
+github.com/twmb/franz-go/pkg/kmsg v1.9.0 h1:JojYUph2TKAau6SBtErXpXGC7E3gg4vGZMv9xFU/B6M=
+github.com/twmb/franz-go/pkg/kmsg v1.9.0/go.mod h1:CMbfazviCyY6HM0SXuG5t9vOwYDHRCSrJJyBAe5paqg=
+golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
+golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=

+ 455 - 262
server/main.go

@@ -1,446 +1,639 @@
-// client2server - Go server with WebSocket + Redpanda
+// client2server - Go server with WebSocket + Redpanda (Kafka-compatible)
 // Copyright (c) 2026 Luis Rosales - MIT License
 //
-// Build: go build -o client2server-server
-// Run: ./client2server-server
-// WebSocket: ws://localhost:3843
-// HTTP API: http://localhost:3843
+// Build: go build -o client2server-server .
+// Run:   ./client2server-server
+// Env:   REDPANDA_BROKERS=localhost:9092 TOKEN=*** PORT=3843
+//
+// WebSocket: ws://localhost:3843/ws
+// HTTP API:  http://localhost:3843/api/{events,routers,command}, /health
 
 package main
 
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"log"
 	"net/http"
 	"os"
 	"os/signal"
+	"strings"
+	"sync"
 	"syscall"
 	"time"
 
+	"github.com/coder/websocket"
 	"github.com/google/uuid"
-	"github.com/redpanda-data/redpanda-sdk-go/redpanda"
-	"github.com/redpanda-data/redpanda-sdk-go/schema"
-	"nhooyr.io/websocket"
+	"github.com/twmb/franz-go/pkg/kadm"
+	"github.com/twmb/franz-go/pkg/kerr"
+	"github.com/twmb/franz-go/pkg/kgo"
 )
 
+// ----------------------------------------------------------------------------
 // Config
+// ----------------------------------------------------------------------------
+
 type Config struct {
 	RedpandaBrokers []string
-	WebsocketPort   int
-	APIPort        int
-	Token          string
+	Port            int
+	Token           string
 }
 
 var cfg = Config{
 	RedpandaBrokers: []string{"localhost:9092"},
-	WebsocketPort:   3843,
-	APIPort:        3844,
-	Token:         "secret-token",
+	Port:            3843,
+	Token:           "secret-token",
+}
+
+func loadConfigFromEnv() {
+	cfg.RedpandaBrokers = strings.Split(getEnvStr("REDPANDA_BROKERS", "localhost:9092"), ",")
+	cfg.Port = getEnvInt("PORT", cfg.Port)
+	cfg.Token = getEnvStr("TOKEN", cfg.Token)
+}
+
+func getEnvStr(key, def string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return def
 }
 
-// Event from router
+func getEnvInt(key string, def int) int {
+	if v := os.Getenv(key); v != "" {
+		var n int
+		if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
+			return n
+		}
+	}
+	return def
+}
+
+// ----------------------------------------------------------------------------
+// Domain types
+// ----------------------------------------------------------------------------
+
+// RouterEvent - generic event sent from a router to the server.
 type RouterEvent struct {
-	ID          string    `json:"id"`
-	RouterID    string    `json:"router_id"`
-	Hostname   string    `json:"hostname"`
-	EventType  string    `json:"event_type"`
-	Timestamp  time.Time `json:"timestamp"`
-	Payload    map[string]interface{} `json:"payload"`
-	ReceivedAt time.Time `json:"received_at"`
-	Connection string    `json:"connection"`
+	ID          string                 `json:"id"`
+	RouterID    string                 `json:"router_id"`
+	Hostname    string                 `json:"hostname,omitempty"`
+	EventType   string                 `json:"event_type"`
+	Timestamp   time.Time              `json:"timestamp"`
+	Payload     map[string]interface{} `json:"payload"`
+	ReceivedAt  time.Time              `json:"received_at"`
+	Connection  string                 `json:"connection"` // "websocket" | "http"
 }
 
-// Command to router
+// RouterCommand - command sent from server to a router.
 type RouterCommand struct {
-	ID        string          `json:"id"`
-	RouterID string          `json:"router_id"`
-	Command  string          `json:"command"`
+	ID       string            `json:"id"`
+	RouterID string            `json:"router_id"`
+	Command  string            `json:"command"`
 	Args     map[string]string `json:"args,omitempty"`
-	SentAt   time.Time       `json:"sent_at"`
+	SentAt   time.Time         `json:"sent_at"`
 }
 
-// Command result received from router
+// CommandResult - reply from a router after running a command.
 type CommandResult struct {
 	Success bool   `json:"success"`
-	Output string `json:"output"`
-	Error  string `json:"error"`
+	Output  string `json:"output"`
+	Error   string `json:"error"`
 }
 
-// Router state
+// ----------------------------------------------------------------------------
+// Router registry
+// ----------------------------------------------------------------------------
+
 type Router struct {
-	ID          string
-	LastSeen   time.Time
+	ID        string
+	LastSeen  time.Time
 	Conn      *websocket.Conn
 	Connected bool
+	writeMu   sync.Mutex // serialise writes to the WS connection
 }
 
-var routers = make(map[string]*Router)
-
-// Per-router command queue (for offline routers - auto-flush on reconnect)
-var routerQueues = make(map[string][]RouterCommand)
-
-// Pending commands awaiting results (command_id -> result channel)
-var pendingCommands = make(map[string]chan CommandResult)
-const commandTimeout = 30 * time.Second
+var (
+	routersMu      sync.RWMutex
+	routers        = make(map[string]*Router)
+	routerQueuesMu sync.Mutex
+	routerQueues   = make(map[string][]RouterCommand) // queued while offline
+	pendingMu      sync.Mutex
+	pendingCmds    = make(map[string]chan CommandResult) // command_id -> result chan
+	executedMu     sync.Mutex
+	executedCmds   = make(map[string]time.Time) // idempotency: cmd_id -> last run
+)
 
-// Idempotency: track recently executed commands (command_id -> timestamp)
-var executedCommands = make(map[string]time.Time)
-const idempotencyTTL = 5 * 60 * time.Second // 5 minutes
+const (
+	commandTimeout   = 30 * time.Second
+	idempotencyTTL   = 5 * time.Minute
+	wsWriteTimeout   = 10 * time.Second
+	publishTimeout   = 3 * time.Second
+	offlineThreshold = 60 * time.Second
+)
 
-// Redpanda
-var rp *redpanda.Client
+// ----------------------------------------------------------------------------
+// Redpanda (Kafka) client
+// ----------------------------------------------------------------------------
 
-func initRedpanda() error {
-	cfg.RedpandaBrokers = getEnvComma("REDPANDA_BROKERS", "localhost:9092")
+var kcl *kgo.Client
 
-	var err error
-	rp, err = redpanda.NewClient(&redpanda.ClientConfig{
-		Brokers: cfg.RedpandaBrokers,
-	})
+func initRedpanda(ctx context.Context) error {
+	cl, err := kgo.NewClient(
+		kgo.SeedBrokers(cfg.RedpandaBrokers...),
+		kgo.ClientID("client2server"),
+		kgo.ProducerLinger(5*time.Millisecond),
+		kgo.ProducerBatchCompression(kgo.SnappyCompression()),
+	)
 	if err != nil {
-		return fmt.Errorf("redpanda: %v", err)
+		return fmt.Errorf("kafka client: %w", err)
 	}
+	kcl = cl
 
-	// Create topics
+	// Best-effort topic creation. Redpanda has auto-create enabled in dev, so
+	// this is just to make sure they exist with sane defaults.
+	adm := kadm.NewClient(cl)
 	topics := []string{"router-events", "router-commands"}
-	for _, topic := range topics {
-		err := rp.CreateTopic(topic, 1, 3)
-		if err != nil && !schema.ErrTopicExists.Exists(err) {
-			log.Printf("Topic %s: %v", topic, err)
+	resp, err := adm.CreateTopics(ctx, 1, 1, nil, topics...)
+	if err != nil {
+		log.Printf("create topics admin call failed (non-fatal if auto-create is on): %v", err)
+		return nil
+	}
+	for _, ct := range resp {
+		if ct.Err != nil && !errors.Is(ct.Err, kerr.TopicAlreadyExists) {
+			log.Printf("topic %s: %v", ct.Topic, ct.Err)
 		}
 	}
-
 	return nil
 }
 
-func getEnvComma(key, def string) []string {
-	val := os.Getenv(key)
-	if val == "" {
-		return []string{def}
-	}
-	return []string{val}
-}
-
-func getEnvStr(key, def string) string {
-	if val := os.Getenv(key); val != "" {
-		return val
+func publish(ctx context.Context, topic string, key string, value any) error {
+	data, err := json.Marshal(value)
+	if err != nil {
+		return fmt.Errorf("marshal: %w", err)
 	}
-	return def
-}
-
-func getEnvInt(key string, def int) int {
-	if val := os.Getenv(key); val != "" {
-		var v int
-		fmt.Sscanf(val, "%d", &v)
-		return v
+	rec := &kgo.Record{Topic: topic, Key: []byte(key), Value: data}
+	// Bound how long the HTTP handler can wait for the broker.
+	pctx, cancel := context.WithTimeout(ctx, publishTimeout)
+	defer cancel()
+	res := kcl.ProduceSync(pctx, rec)
+	if err := res.FirstErr(); err != nil {
+		return err
 	}
-	return def
-}
-
-// Publish event to Redpanda
-func publishEvent(event RouterEvent) error {
-	data, _ := json.Marshal(event)
-	return rp.Produce("router-events", []byte(event.ID), data)
-}
-
-// Publish command to Redpanda
-func publishCommand(cmd RouterCommand) error {
-	data, _ := json.Marshal(cmd)
-	return rp.Produce("router-commands", []byte(cmd.ID), data)
+	return nil
 }
 
+// ----------------------------------------------------------------------------
 // WebSocket handler
+// ----------------------------------------------------------------------------
+
 func handleWebSocket(w http.ResponseWriter, r *http.Request) {
+	// Auth: token in query string (?token=...) or Authorization header
 	token := r.URL.Query().Get("token")
+	if token == "" {
+		if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
+			token = strings.TrimPrefix(h, "Bearer ")
+		}
+	}
 	if token != cfg.Token {
 		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 		return
 	}
 
 	conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
-		CompressionMode: websocket.CompressionContextTakeover,
+		// Disable permessage-deflate to keep things simple on minimal routers
+		CompressionMode: websocket.CompressionDisabled,
 	})
 	if err != nil {
-		log.Printf("WS accept: %v", err)
+		log.Printf("ws accept: %v", err)
 		return
 	}
-	defer conn.Close(websocket.StatusNormalClosure, "")
+	defer conn.Close(websocket.StatusNormalClosure, "bye")
 
-	ctx := context.Background()
+	ctx, cancel := context.WithCancel(r.Context())
+	defer cancel()
 
-	// Read router registration
-	var regMsg RouterEvent
-	err = conn.Read(ctx, &regMsg)
+	// First message must be a registration event
+	firstMsg, err := readRouterMessage(ctx, conn)
 	if err != nil {
-		log.Printf("WS read reg: %v", err)
+		log.Printf("ws read reg: %v", err)
+		return
+	}
+	var reg RouterEvent
+	if err := json.Unmarshal(firstMsg, &reg); err != nil {
+		log.Printf("ws reg parse: %v", err)
 		return
 	}
 
-	routerID := regMsg.RouterID
+	routerID := reg.RouterID
 	if routerID == "" {
 		routerID = r.RemoteAddr
 	}
 
+	routersMu.Lock()
 	routers[routerID] = &Router{
-		ID:          routerID,
-		LastSeen:    time.Now(),
-		Conn:       conn,
-		Connected:  true,
-	}
-
-	log.Printf("Router connected: %s (flushing queue)", routerID)
-
-	// Flush queued commands for this router
-	if queuedCmds, ok := routerQueues[routerID]; ok && len(queuedCmds) > 0 {
-		log.Printf("Flushing %d queued commands to %s", len(queuedCmds), routerID)
-		for _, cmd := range queuedCmds {
-			resultChan := make(chan CommandResult, 1)
-			cmd.SentAt = time.Now()
-			pendingCommands[cmd.ID] = resultChan
-			publishCommand(cmd)
-			log.Printf("Queued cmd sent: %s", cmd.Command)
-			// Fire and forget - waiter will handle result
-		}
-		delete(routerQueues, routerID)
+		ID:        routerID,
+		LastSeen:  time.Now(),
+		Conn:      conn,
+		Connected: true,
 	}
+	routersMu.Unlock()
+
+	log.Printf("router connected: %s (from %s)", routerID, r.RemoteAddr)
+	flushQueuedCommands(ctx, routerID)
 
 	// Message loop
 	for {
-		var event RouterEvent
-		err := conn.Read(ctx, &event)
+		raw, err := readRouterMessage(ctx, conn)
 		if err != nil {
 			break
 		}
 
-		event.ID = uuid.New().String()
-		event.ReceivedAt = time.Now()
-		event.Connection = "websocket"
-
-		// Check if this is a command result
-		if event.EventType == "command_result" {
-			// Find pending command and send result
-			if cmdID := event.Payload["command_id"]; cmdID != nil {
-				cmdIDstr, _ := cmdID.(string)
-				if ch, ok := pendingCommands[cmdIDstr]; ok {
-					result := CommandResult{
-						Success: event.Payload["success"] == true,
-						Output: func() string { s, _ := event.Payload["output"].(string); return s }(),
-						Error:  func() string { s, _ := event.Payload["error"].(string); return s }(),
-					}
-					ch <- result
-				}
+		var ev RouterEvent
+		if err := json.Unmarshal(raw, &ev); err != nil {
+			log.Printf("[%s] bad event json: %v", routerID, err)
+			continue
+		}
 
-				// Mark as executed (for idempotency)
-				executedCommands[cmdIDstr] = time.Now()
+		// Server-assigned fields
+		ev.ID = uuid.New().String()
+		ev.ReceivedAt = time.Now()
+		ev.Connection = "websocket"
+		if ev.Timestamp.IsZero() {
+			ev.Timestamp = ev.ReceivedAt
+		}
+
+		// Command result handling
+		if ev.EventType == "command_result" {
+			if cid, _ := ev.Payload["command_id"].(string); cid != "" {
+				deliverCommandResult(cid, ev.Payload)
+				executedMu.Lock()
+				executedCmds[cid] = time.Now()
+				executedMu.Unlock()
 			}
 		}
 
-		// Publish to Redpanda
-		if err := publishEvent(event); err != nil {
-			log.Printf("Publish error: %v", err)
+		if err := publish(ctx, "router-events", routerID, ev); err != nil {
+			log.Printf("publish event: %v", err)
 		}
+		log.Printf("[%s] %s", routerID, ev.EventType)
 
-		log.Printf("[%s] %s", routerID, event.EventType)
-		routers[routerID].LastSeen = time.Now()
+		routersMu.Lock()
+		if r, ok := routers[routerID]; ok {
+			r.LastSeen = time.Now()
+		}
+		routersMu.Unlock()
 	}
 
-	if routers[routerID] != nil {
-		routers[routerID].Connected = false
+	routersMu.Lock()
+	if r, ok := routers[routerID]; ok {
+		r.Connected = false
 	}
-	log.Printf("Router disconnected: %s", routerID)
+	routersMu.Unlock()
+	log.Printf("router disconnected: %s", routerID)
 }
 
-// HTTP Event webhook
-func handleHTTPEvent(w http.ResponseWriter, r *http.Request) {
-	if r.Method != "POST" {
-		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
-		return
+func readRouterMessage(ctx context.Context, conn *websocket.Conn) ([]byte, error) {
+	// coder/websocket: Read returns a Message
+	_, data, err := conn.Read(ctx)
+	return data, err
+}
+
+// writeJSON serialises writes to the connection.
+func (r *Router) writeJSON(ctx context.Context, v any) error {
+	data, err := json.Marshal(v)
+	if err != nil {
+		return err
 	}
+	wctx, cancel := context.WithTimeout(ctx, wsWriteTimeout)
+	defer cancel()
+	r.writeMu.Lock()
+	defer r.writeMu.Unlock()
+	return r.Conn.Write(wctx, websocket.MessageText, data)
+}
 
-	token := r.Header.Get("Authorization")
-	if token != "Bearer "+cfg.Token && token != cfg.Token {
-		http.Error(w, "Unauthorized", http.StatusUnauthorized)
+func deliverCommandResult(cmdID string, payload map[string]interface{}) {
+	pendingMu.Lock()
+	ch, ok := pendingCmds[cmdID]
+	if ok {
+		delete(pendingCmds, cmdID)
+	}
+	pendingMu.Unlock()
+	if !ok {
 		return
 	}
+	res := CommandResult{
+		Success: payload["success"] == true,
+	}
+	if s, ok := payload["output"].(string); ok {
+		res.Output = s
+	}
+	if s, ok := payload["error"].(string); ok {
+		res.Error = s
+	}
+	select {
+	case ch <- res:
+	default:
+	}
+}
 
-	var event RouterEvent
-	if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
-		http.Error(w, "Invalid JSON", http.StatusBadRequest)
+func flushQueuedCommands(ctx context.Context, routerID string) {
+	routerQueuesMu.Lock()
+	queue := routerQueues[routerID]
+	delete(routerQueues, routerID)
+	routerQueuesMu.Unlock()
+	if len(queue) == 0 {
 		return
 	}
+	log.Printf("flushing %d queued commands to %s", len(queue), routerID)
+	for _, cmd := range queue {
+		cmd.SentAt = time.Now()
+		pendingMu.Lock()
+		pendingCmds[cmd.ID] = make(chan CommandResult, 1)
+		pendingMu.Unlock()
+		if err := publish(ctx, "router-commands", routerID, cmd); err != nil {
+			log.Printf("queue flush publish: %v", err)
+		}
+	}
+}
 
-	event.ID = uuid.New().String()
-	event.ReceivedAt = time.Now()
-	event.Connection = "http"
+// ----------------------------------------------------------------------------
+// HTTP handlers
+// ----------------------------------------------------------------------------
 
-	if err := publishEvent(event); err != nil {
-		w.WriteHeader(http.StatusInternalServerError)
+func handleHTTPEvent(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
 		return
 	}
-
-	json.NewEncoder(w).Encode(map[string]string{"event_id": event.ID})
+	if !authorised(r) {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
+	var ev RouterEvent
+	if err := json.NewDecoder(r.Body).Decode(&ev); err != nil {
+		http.Error(w, "invalid json", http.StatusBadRequest)
+		return
+	}
+	ev.ID = uuid.New().String()
+	ev.ReceivedAt = time.Now()
+	ev.Connection = "http"
+	if ev.Timestamp.IsZero() {
+		ev.Timestamp = ev.ReceivedAt
+	}
+	if err := publish(r.Context(), "router-events", ev.RouterID, ev); err != nil {
+		log.Printf("publish event: %v", err)
+		http.Error(w, "publish failed", http.StatusBadGateway)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(map[string]string{
+		"event_id":  ev.ID,
+		"router_id": ev.RouterID,
+		"status":    "accepted",
+	})
 }
 
-// Get routers
 func handleRouters(w http.ResponseWriter, r *http.Request) {
-	list := []map[string]interface{}{}
-	for id, router := range routers {
-		list = append(list, map[string]interface{}{
-			"id":        id,
-			"last_seen": router.LastSeen,
-			"online":    time.Since(router.LastSeen) < 60*time.Second,
+	type entry struct {
+		ID       string    `json:"id"`
+		LastSeen time.Time `json:"last_seen"`
+		Online   bool      `json:"online"`
+		Queued   int       `json:"queued_commands"`
+	}
+	routersMu.RLock()
+	list := make([]entry, 0, len(routers))
+	for id, rt := range routers {
+		list = append(list, entry{
+			ID:       id,
+			LastSeen: rt.LastSeen,
+			Online:   time.Since(rt.LastSeen) < offlineThreshold,
+			Queued:   len(routerQueues[id]),
 		})
 	}
-	json.NewEncoder(w).Encode(map[string]interface{}{"routers": list})
+	routersMu.RUnlock()
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(map[string]interface{}{"routers": list})
 }
 
-// Send command and wait for result
 func handleCommand(w http.ResponseWriter, r *http.Request) {
-	if r.Method != "POST" {
-		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+	if r.Method != http.MethodPost {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+	if !authorised(r) {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
 		return
 	}
-
 	var cmd RouterCommand
 	if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
-		http.Error(w, "Invalid JSON", http.StatusBadRequest)
+		http.Error(w, "invalid json", http.StatusBadRequest)
 		return
 	}
-
 	routerID := r.URL.Query().Get("router_id")
 	if routerID == "" {
 		routerID = cmd.RouterID
 	}
-
 	if routerID == "" {
 		http.Error(w, "router_id required", http.StatusBadRequest)
 		return
 	}
 
-	router := routers[routerID]
-
-	// ──────────────────────────────────────────────────────────
-	// IDEMPOTENCY CHECK - Dedup before publishing!
-	// ──────────────────────────────────────────────────────────
-	// If client provides an idempotency key, reuse it
+	// Idempotency
 	xecID := cmd.ID
 	if xecID == "" {
 		xecID = uuid.New().String()
 	}
-
-	// Check if already executed within TTL
-	if lastExec, exists := executedCommands[xecID]; exists {
-		if time.Since(lastExec) < idempotencyTTL {
-			log.Printf("Idempotent reject: %s (recently executed)", xecID)
-			json.NewEncoder(w).Encode(map[string]interface{}{
-				"idempotent_reject": true,
-				"existing_command_id": xecID,
-				"message": "Command already executed",
-			})
-			return
-		}
-		delete(executedCommands, xecID)
+	executedMu.Lock()
+	if last, exists := executedCmds[xecID]; exists && time.Since(last) < idempotencyTTL {
+		executedMu.Unlock()
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(map[string]interface{}{
+			"idempotent_reject":   true,
+			"existing_command_id": xecID,
+			"message":             "command already executed within TTL",
+		})
+		return
 	}
+	delete(executedCmds, xecID)
+	executedMu.Unlock()
 
-	// ──────────────────────────────────────────────────────────
-	// Queue vs Live delivery
-	// ──────────────────────────────────────────────────────────
 	cmd.ID = xecID
 	cmd.RouterID = routerID
 	cmd.SentAt = time.Now()
 
-	if router == nil || !router.Connected {
-		// Router offline - QUEUE it!
+	routersMu.RLock()
+	router, online := routers[routerID]
+	routersMu.RUnlock()
+
+	if !online || router == nil || !router.Connected {
+		routerQueuesMu.Lock()
 		routerQueues[routerID] = append(routerQueues[routerID], cmd)
-		log.Printf("Queued command for %s: %s (queue depth: %d)", routerID, cmd.Command, len(routerQueues[routerID]))
-		json.NewEncoder(w).Encode(map[string]interface{}{
-			"command_id": cmd.ID,
-			"status":     "queued",
-			"queued_for": routerID,
-			"queue_depth": len(routerQueues[routerID]),
-			"message":   "Router offline, command queued",
+		depth := len(routerQueues[routerID])
+		routerQueuesMu.Unlock()
+		log.Printf("queued cmd %s for %s (depth=%d)", cmd.Command, routerID, depth)
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(map[string]interface{}{
+			"command_id":  cmd.ID,
+			"status":      "queued",
+			"queued_for":  routerID,
+			"queue_depth": depth,
 		})
 		return
 	}
 
-	// Router online - send directly
-	resultChan := make(chan CommandResult, 1)
-	pendingCommands[cmd.ID] = resultChan
-
-	if err := publishCommand(cmd); err != nil {
-		delete(pendingCommands, cmd.ID)
-		log.Printf("Publish command: %v", err)
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+	// Online: register waiter, publish, wait for result
+	resultCh := make(chan CommandResult, 1)
+	pendingMu.Lock()
+	pendingCmds[cmd.ID] = resultCh
+	pendingMu.Unlock()
+
+	if err := publish(r.Context(), "router-commands", routerID, cmd); err != nil {
+		pendingMu.Lock()
+		delete(pendingCmds, cmd.ID)
+		pendingMu.Unlock()
+		log.Printf("publish command: %v", err)
+		http.Error(w, "publish failed", http.StatusBadGateway)
 		return
 	}
 
-	log.Printf("Command to %s: %s (waiting...)", routerID, cmd.Command)
+	log.Printf("cmd %s -> %s (waiting)", cmd.Command, routerID)
+	w.Header().Set("Content-Type", "application/json")
 
-	// Wait for result with timeout
 	select {
-	case result := <-resultChan:
-		delete(pendingCommands, cmd.ID)
-		json.NewEncoder(w).Encode(map[string]interface{}{
+	case res := <-resultCh:
+		_ = json.NewEncoder(w).Encode(map[string]interface{}{
 			"command_id": cmd.ID,
-			"status":    "completed",
-			"success":   result.Success,
-			"output":    result.Output,
-			"error":    result.Error,
+			"status":     "completed",
+			"success":    res.Success,
+			"output":     res.Output,
+			"error":      res.Error,
 		})
 	case <-time.After(commandTimeout):
-		delete(pendingCommands, cmd.ID)
-		json.NewEncoder(w).Encode(map[string]string{
+		pendingMu.Lock()
+		delete(pendingCmds, cmd.ID)
+		pendingMu.Unlock()
+		_ = json.NewEncoder(w).Encode(map[string]string{
 			"command_id": cmd.ID,
-			"status":    "timeout",
-			"error":    "Router did not respond",
+			"status":     "timeout",
+			"error":      "router did not respond",
 		})
 	}
-
-	// Also mark timeout for idempotency (allows retry after TTL)
-	// executedCommands stays, will expire naturally
 }
 
-// Health
 func handleHealth(w http.ResponseWriter, r *http.Request) {
-	json.NewEncoder(w).Encode(map[string]interface{}{
-		"status":   "ok",
-		"routers": len(routers),
+	routersMu.RLock()
+	onlineCount := 0
+	for _, rt := range routers {
+		if time.Since(rt.LastSeen) < offlineThreshold {
+			onlineCount++
+		}
+	}
+	total := len(routers)
+	routersMu.RUnlock()
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(map[string]interface{}{
+		"status":         "ok",
+		"routers":        total,
+		"routers_online": onlineCount,
+		"redpanda":       cfg.RedpandaBrokers,
 	})
 }
 
-func main() {
-	cfg.Token = getEnvStr("TOKEN", cfg.Token)
-	cfg.WebsocketPort = getEnvInt("PORT", cfg.WebsocketPort)
+func authorised(r *http.Request) bool {
+	h := r.Header.Get("Authorization")
+	if h == cfg.Token { // legacy: raw token
+		return true
+	}
+	if strings.HasPrefix(h, "Bearer ") && strings.TrimPrefix(h, "Bearer ") == cfg.Token {
+		return true
+	}
+	return false
+}
+
+// ----------------------------------------------------------------------------
+// Background cleanup
+// ----------------------------------------------------------------------------
+
+func startJanitor(ctx context.Context) {
+	go func() {
+		t := time.NewTicker(time.Minute)
+		defer t.Stop()
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			case <-t.C:
+				now := time.Now()
+				executedMu.Lock()
+				for id, ts := range executedCmds {
+					if now.Sub(ts) > idempotencyTTL {
+						delete(executedCmds, id)
+					}
+				}
+				executedMu.Unlock()
+			}
+		}
+	}()
+}
+
+// ----------------------------------------------------------------------------
+// main
+// ----------------------------------------------------------------------------
 
+func main() {
+	loadConfigFromEnv()
 	log.SetFlags(log.LstdFlags | log.Lshortfile)
 	log.Printf("=== client2server Go Server ===")
-	log.Printf("WebSocket: ws://localhost:%d", cfg.WebsocketPort)
+	log.Printf("port=%d brokers=%v", cfg.Port, cfg.RedpandaBrokers)
 
-	if err := initRedpanda(); err != nil {
-		log.Printf("Redpanda init failed: %v", err)
-	}
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
 
-	http.HandleFunc("/", handleHealth)
-	http.HandleFunc("/health", handleHealth)
-	http.HandleFunc("/api/events", handleHTTPEvent)
-	http.HandleFunc("/api/events/", handleHTTPEvent)
-	http.HandleFunc("/api/routers", handleRouters)
-	http.HandleFunc("/api/command", handleCommand)
-	http.HandleFunc("/ws", handleWebSocket)
+	if err := initRedpanda(ctx); err != nil {
+		log.Printf("redpanda init failed (continuing): %v", err)
+	} else {
+		defer kcl.Close()
+	}
+	startJanitor(ctx)
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/health", handleHealth)
+	mux.HandleFunc("/api/events", handleHTTPEvent)
+	mux.HandleFunc("/api/routers", handleRouters)
+	mux.HandleFunc("/api/command", handleCommand)
+	mux.HandleFunc("/ws", handleWebSocket)
+	mux.HandleFunc("/", handleHealth)
+
+	srv := &http.Server{
+		Addr:         fmt.Sprintf(":%d", cfg.Port),
+		Handler:      mux,
+		ReadTimeout:  0, // WS connections are long-lived
+		WriteTimeout: 0,
+	}
 
+	// Graceful shutdown
 	go func() {
 		sigCh := make(chan os.Signal, 1)
 		signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
 		<-sigCh
-		log.Println("Shutting down...")
-		for _, router := range routers {
-			router.Conn.Close(websocket.StatusNormalClosure, "server shutdown")
+		log.Println("shutting down...")
+		routersMu.RLock()
+		for _, r := range routers {
+			if r.Conn != nil {
+				r.Conn.Close(websocket.StatusNormalClosure, "server shutdown")
+			}
 		}
+		routersMu.RUnlock()
+		shutdownCtx, c := context.WithTimeout(context.Background(), 5*time.Second)
+		defer c()
+		_ = srv.Shutdown(shutdownCtx)
 		os.Exit(0)
 	}()
 
-	log.Printf("Server ready on port %d", cfg.WebsocketPort)
-	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", cfg.WebsocketPort), nil))
-}
+	log.Printf("server ready on :%d", cfg.Port)
+	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+		log.Fatal(err)
+	}
+}