| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639 |
- // client2server - Go server with WebSocket + Redpanda (Kafka-compatible)
- // Copyright (c) 2026 Luis Rosales - MIT License
- //
- // 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/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
- Port int
- Token string
- }
- var cfg = Config{
- RedpandaBrokers: []string{"localhost:9092"},
- 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
- }
- 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,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"
- }
- // RouterCommand - command sent from server to a router.
- type RouterCommand struct {
- 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"`
- }
- // CommandResult - reply from a router after running a command.
- type CommandResult struct {
- Success bool `json:"success"`
- Output string `json:"output"`
- Error string `json:"error"`
- }
- // ----------------------------------------------------------------------------
- // Router registry
- // ----------------------------------------------------------------------------
- type Router struct {
- ID string
- LastSeen time.Time
- Conn *websocket.Conn
- Connected bool
- writeMu sync.Mutex // serialise writes to the WS connection
- }
- 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
- )
- const (
- commandTimeout = 30 * time.Second
- idempotencyTTL = 5 * time.Minute
- wsWriteTimeout = 10 * time.Second
- publishTimeout = 3 * time.Second
- offlineThreshold = 60 * time.Second
- )
- // ----------------------------------------------------------------------------
- // Redpanda (Kafka) client
- // ----------------------------------------------------------------------------
- var kcl *kgo.Client
- 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("kafka client: %w", err)
- }
- kcl = cl
- // 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"}
- 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 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)
- }
- 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 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{
- // Disable permessage-deflate to keep things simple on minimal routers
- CompressionMode: websocket.CompressionDisabled,
- })
- if err != nil {
- log.Printf("ws accept: %v", err)
- return
- }
- defer conn.Close(websocket.StatusNormalClosure, "bye")
- ctx, cancel := context.WithCancel(r.Context())
- defer cancel()
- // First message must be a registration event
- firstMsg, err := readRouterMessage(ctx, conn)
- if err != nil {
- log.Printf("ws read reg: %v", err)
- return
- }
- var reg RouterEvent
- if err := json.Unmarshal(firstMsg, ®); err != nil {
- log.Printf("ws reg parse: %v", err)
- return
- }
- routerID := reg.RouterID
- if routerID == "" {
- routerID = r.RemoteAddr
- }
- routersMu.Lock()
- routers[routerID] = &Router{
- 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 {
- raw, err := readRouterMessage(ctx, conn)
- if err != nil {
- break
- }
- var ev RouterEvent
- if err := json.Unmarshal(raw, &ev); err != nil {
- log.Printf("[%s] bad event json: %v", routerID, err)
- continue
- }
- // 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()
- }
- }
- if err := publish(ctx, "router-events", routerID, ev); err != nil {
- log.Printf("publish event: %v", err)
- }
- log.Printf("[%s] %s", routerID, ev.EventType)
- routersMu.Lock()
- if r, ok := routers[routerID]; ok {
- r.LastSeen = time.Now()
- }
- routersMu.Unlock()
- }
- routersMu.Lock()
- if r, ok := routers[routerID]; ok {
- r.Connected = false
- }
- routersMu.Unlock()
- log.Printf("router disconnected: %s", routerID)
- }
- 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)
- }
- 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:
- }
- }
- 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)
- }
- }
- }
- // ----------------------------------------------------------------------------
- // HTTP handlers
- // ----------------------------------------------------------------------------
- func handleHTTPEvent(w http.ResponseWriter, r *http.Request) {
- 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 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",
- })
- }
- func handleRouters(w http.ResponseWriter, r *http.Request) {
- 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]),
- })
- }
- routersMu.RUnlock()
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]interface{}{"routers": list})
- }
- func handleCommand(w http.ResponseWriter, r *http.Request) {
- 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)
- return
- }
- routerID := r.URL.Query().Get("router_id")
- if routerID == "" {
- routerID = cmd.RouterID
- }
- if routerID == "" {
- http.Error(w, "router_id required", http.StatusBadRequest)
- return
- }
- // Idempotency
- xecID := cmd.ID
- if xecID == "" {
- xecID = uuid.New().String()
- }
- 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()
- cmd.ID = xecID
- cmd.RouterID = routerID
- cmd.SentAt = time.Now()
- routersMu.RLock()
- router, online := routers[routerID]
- routersMu.RUnlock()
- if !online || router == nil || !router.Connected {
- routerQueuesMu.Lock()
- routerQueues[routerID] = append(routerQueues[routerID], cmd)
- 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
- }
- // 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("cmd %s -> %s (waiting)", cmd.Command, routerID)
- w.Header().Set("Content-Type", "application/json")
- select {
- case res := <-resultCh:
- _ = json.NewEncoder(w).Encode(map[string]interface{}{
- "command_id": cmd.ID,
- "status": "completed",
- "success": res.Success,
- "output": res.Output,
- "error": res.Error,
- })
- case <-time.After(commandTimeout):
- 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",
- })
- }
- }
- func handleHealth(w http.ResponseWriter, r *http.Request) {
- 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 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("port=%d brokers=%v", cfg.Port, cfg.RedpandaBrokers)
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- 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...")
- 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 :%d", cfg.Port)
- if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
- log.Fatal(err)
- }
- }
|