| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446 |
- // client2server - Go server with WebSocket + Redpanda
- // 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
- package main
- import (
- "context"
- "encoding/json"
- "fmt"
- "log"
- "net/http"
- "os"
- "os/signal"
- "syscall"
- "time"
- "github.com/google/uuid"
- "github.com/redpanda-data/redpanda-sdk-go/redpanda"
- "github.com/redpanda-data/redpanda-sdk-go/schema"
- "nhooyr.io/websocket"
- )
- // Config
- type Config struct {
- RedpandaBrokers []string
- WebsocketPort int
- APIPort int
- Token string
- }
- var cfg = Config{
- RedpandaBrokers: []string{"localhost:9092"},
- WebsocketPort: 3843,
- APIPort: 3844,
- Token: "secret-token",
- }
- // Event from router
- 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"`
- }
- // Command to 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"`
- }
- // Command result received from router
- type CommandResult struct {
- Success bool `json:"success"`
- Output string `json:"output"`
- Error string `json:"error"`
- }
- // Router state
- type Router struct {
- ID string
- LastSeen time.Time
- Conn *websocket.Conn
- Connected bool
- }
- 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
- // Idempotency: track recently executed commands (command_id -> timestamp)
- var executedCommands = make(map[string]time.Time)
- const idempotencyTTL = 5 * 60 * time.Second // 5 minutes
- // Redpanda
- var rp *redpanda.Client
- func initRedpanda() error {
- cfg.RedpandaBrokers = getEnvComma("REDPANDA_BROKERS", "localhost:9092")
- var err error
- rp, err = redpanda.NewClient(&redpanda.ClientConfig{
- Brokers: cfg.RedpandaBrokers,
- })
- if err != nil {
- return fmt.Errorf("redpanda: %v", err)
- }
- // Create topics
- 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)
- }
- }
- 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
- }
- 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
- }
- 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)
- }
- // WebSocket handler
- func handleWebSocket(w http.ResponseWriter, r *http.Request) {
- token := r.URL.Query().Get("token")
- if token != cfg.Token {
- http.Error(w, "Unauthorized", http.StatusUnauthorized)
- return
- }
- conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
- CompressionMode: websocket.CompressionContextTakeover,
- })
- if err != nil {
- log.Printf("WS accept: %v", err)
- return
- }
- defer conn.Close(websocket.StatusNormalClosure, "")
- ctx := context.Background()
- // Read router registration
- var regMsg RouterEvent
- err = conn.Read(ctx, ®Msg)
- if err != nil {
- log.Printf("WS read reg: %v", err)
- return
- }
- routerID := regMsg.RouterID
- if routerID == "" {
- routerID = r.RemoteAddr
- }
- 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)
- }
- // Message loop
- for {
- var event RouterEvent
- err := conn.Read(ctx, &event)
- 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
- }
- // Mark as executed (for idempotency)
- executedCommands[cmdIDstr] = time.Now()
- }
- }
- // Publish to Redpanda
- if err := publishEvent(event); err != nil {
- log.Printf("Publish error: %v", err)
- }
- log.Printf("[%s] %s", routerID, event.EventType)
- routers[routerID].LastSeen = time.Now()
- }
- if routers[routerID] != nil {
- routers[routerID].Connected = false
- }
- 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
- }
- token := r.Header.Get("Authorization")
- if token != "Bearer "+cfg.Token && token != cfg.Token {
- http.Error(w, "Unauthorized", http.StatusUnauthorized)
- return
- }
- var event RouterEvent
- if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
- http.Error(w, "Invalid JSON", http.StatusBadRequest)
- return
- }
- event.ID = uuid.New().String()
- event.ReceivedAt = time.Now()
- event.Connection = "http"
- if err := publishEvent(event); err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- return
- }
- json.NewEncoder(w).Encode(map[string]string{"event_id": event.ID})
- }
- // 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,
- })
- }
- 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)
- 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
- }
- router := routers[routerID]
- // ──────────────────────────────────────────────────────────
- // IDEMPOTENCY CHECK - Dedup before publishing!
- // ──────────────────────────────────────────────────────────
- // If client provides an idempotency key, reuse it
- 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)
- }
- // ──────────────────────────────────────────────────────────
- // Queue vs Live delivery
- // ──────────────────────────────────────────────────────────
- cmd.ID = xecID
- cmd.RouterID = routerID
- cmd.SentAt = time.Now()
- if router == nil || !router.Connected {
- // Router offline - QUEUE it!
- 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",
- })
- 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)
- return
- }
- log.Printf("Command to %s: %s (waiting...)", routerID, cmd.Command)
- // Wait for result with timeout
- select {
- case result := <-resultChan:
- delete(pendingCommands, cmd.ID)
- json.NewEncoder(w).Encode(map[string]interface{}{
- "command_id": cmd.ID,
- "status": "completed",
- "success": result.Success,
- "output": result.Output,
- "error": result.Error,
- })
- case <-time.After(commandTimeout):
- delete(pendingCommands, cmd.ID)
- json.NewEncoder(w).Encode(map[string]string{
- "command_id": cmd.ID,
- "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),
- })
- }
- func main() {
- cfg.Token = getEnvStr("TOKEN", cfg.Token)
- cfg.WebsocketPort = getEnvInt("PORT", cfg.WebsocketPort)
- log.SetFlags(log.LstdFlags | log.Lshortfile)
- log.Printf("=== client2server Go Server ===")
- log.Printf("WebSocket: ws://localhost:%d", cfg.WebsocketPort)
- if err := initRedpanda(); err != nil {
- log.Printf("Redpanda init failed: %v", err)
- }
- 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)
- 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")
- }
- os.Exit(0)
- }()
- log.Printf("Server ready on port %d", cfg.WebsocketPort)
- log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", cfg.WebsocketPort), nil))
- }
|