main.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. // client2server - Go server with WebSocket + Redpanda
  2. // Copyright (c) 2026 Luis Rosales - MIT License
  3. //
  4. // Build: go build -o client2server-server
  5. // Run: ./client2server-server
  6. // WebSocket: ws://localhost:3843
  7. // HTTP API: http://localhost:3843
  8. package main
  9. import (
  10. "context"
  11. "encoding/json"
  12. "fmt"
  13. "log"
  14. "net/http"
  15. "os"
  16. "os/signal"
  17. "syscall"
  18. "time"
  19. "github.com/google/uuid"
  20. "github.com/redpanda-data/redpanda-sdk-go/redpanda"
  21. "github.com/redpanda-data/redpanda-sdk-go/schema"
  22. "nhooyr.io/websocket"
  23. )
  24. // Config
  25. type Config struct {
  26. RedpandaBrokers []string
  27. WebsocketPort int
  28. APIPort int
  29. Token string
  30. }
  31. var cfg = Config{
  32. RedpandaBrokers: []string{"localhost:9092"},
  33. WebsocketPort: 3843,
  34. APIPort: 3844,
  35. Token: "secret-token",
  36. }
  37. // Event from router
  38. type RouterEvent struct {
  39. ID string `json:"id"`
  40. RouterID string `json:"router_id"`
  41. Hostname string `json:"hostname"`
  42. EventType string `json:"event_type"`
  43. Timestamp time.Time `json:"timestamp"`
  44. Payload map[string]interface{} `json:"payload"`
  45. ReceivedAt time.Time `json:"received_at"`
  46. Connection string `json:"connection"`
  47. }
  48. // Command to router
  49. type RouterCommand struct {
  50. ID string `json:"id"`
  51. RouterID string `json:"router_id"`
  52. Command string `json:"command"`
  53. Args map[string]string `json:"args,omitempty"`
  54. SentAt time.Time `json:"sent_at"`
  55. }
  56. // Command result received from router
  57. type CommandResult struct {
  58. Success bool `json:"success"`
  59. Output string `json:"output"`
  60. Error string `json:"error"`
  61. }
  62. // Router state
  63. type Router struct {
  64. ID string
  65. LastSeen time.Time
  66. Conn *websocket.Conn
  67. Connected bool
  68. }
  69. var routers = make(map[string]*Router)
  70. // Per-router command queue (for offline routers - auto-flush on reconnect)
  71. var routerQueues = make(map[string][]RouterCommand)
  72. // Pending commands awaiting results (command_id -> result channel)
  73. var pendingCommands = make(map[string]chan CommandResult)
  74. const commandTimeout = 30 * time.Second
  75. // Idempotency: track recently executed commands (command_id -> timestamp)
  76. var executedCommands = make(map[string]time.Time)
  77. const idempotencyTTL = 5 * 60 * time.Second // 5 minutes
  78. // Redpanda
  79. var rp *redpanda.Client
  80. func initRedpanda() error {
  81. cfg.RedpandaBrokers = getEnvComma("REDPANDA_BROKERS", "localhost:9092")
  82. var err error
  83. rp, err = redpanda.NewClient(&redpanda.ClientConfig{
  84. Brokers: cfg.RedpandaBrokers,
  85. })
  86. if err != nil {
  87. return fmt.Errorf("redpanda: %v", err)
  88. }
  89. // Create topics
  90. topics := []string{"router-events", "router-commands"}
  91. for _, topic := range topics {
  92. err := rp.CreateTopic(topic, 1, 3)
  93. if err != nil && !schema.ErrTopicExists.Exists(err) {
  94. log.Printf("Topic %s: %v", topic, err)
  95. }
  96. }
  97. return nil
  98. }
  99. func getEnvComma(key, def string) []string {
  100. val := os.Getenv(key)
  101. if val == "" {
  102. return []string{def}
  103. }
  104. return []string{val}
  105. }
  106. func getEnvStr(key, def string) string {
  107. if val := os.Getenv(key); val != "" {
  108. return val
  109. }
  110. return def
  111. }
  112. func getEnvInt(key string, def int) int {
  113. if val := os.Getenv(key); val != "" {
  114. var v int
  115. fmt.Sscanf(val, "%d", &v)
  116. return v
  117. }
  118. return def
  119. }
  120. // Publish event to Redpanda
  121. func publishEvent(event RouterEvent) error {
  122. data, _ := json.Marshal(event)
  123. return rp.Produce("router-events", []byte(event.ID), data)
  124. }
  125. // Publish command to Redpanda
  126. func publishCommand(cmd RouterCommand) error {
  127. data, _ := json.Marshal(cmd)
  128. return rp.Produce("router-commands", []byte(cmd.ID), data)
  129. }
  130. // WebSocket handler
  131. func handleWebSocket(w http.ResponseWriter, r *http.Request) {
  132. token := r.URL.Query().Get("token")
  133. if token != cfg.Token {
  134. http.Error(w, "Unauthorized", http.StatusUnauthorized)
  135. return
  136. }
  137. conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
  138. CompressionMode: websocket.CompressionContextTakeover,
  139. })
  140. if err != nil {
  141. log.Printf("WS accept: %v", err)
  142. return
  143. }
  144. defer conn.Close(websocket.StatusNormalClosure, "")
  145. ctx := context.Background()
  146. // Read router registration
  147. var regMsg RouterEvent
  148. err = conn.Read(ctx, &regMsg)
  149. if err != nil {
  150. log.Printf("WS read reg: %v", err)
  151. return
  152. }
  153. routerID := regMsg.RouterID
  154. if routerID == "" {
  155. routerID = r.RemoteAddr
  156. }
  157. routers[routerID] = &Router{
  158. ID: routerID,
  159. LastSeen: time.Now(),
  160. Conn: conn,
  161. Connected: true,
  162. }
  163. log.Printf("Router connected: %s (flushing queue)", routerID)
  164. // Flush queued commands for this router
  165. if queuedCmds, ok := routerQueues[routerID]; ok && len(queuedCmds) > 0 {
  166. log.Printf("Flushing %d queued commands to %s", len(queuedCmds), routerID)
  167. for _, cmd := range queuedCmds {
  168. resultChan := make(chan CommandResult, 1)
  169. cmd.SentAt = time.Now()
  170. pendingCommands[cmd.ID] = resultChan
  171. publishCommand(cmd)
  172. log.Printf("Queued cmd sent: %s", cmd.Command)
  173. // Fire and forget - waiter will handle result
  174. }
  175. delete(routerQueues, routerID)
  176. }
  177. // Message loop
  178. for {
  179. var event RouterEvent
  180. err := conn.Read(ctx, &event)
  181. if err != nil {
  182. break
  183. }
  184. event.ID = uuid.New().String()
  185. event.ReceivedAt = time.Now()
  186. event.Connection = "websocket"
  187. // Check if this is a command result
  188. if event.EventType == "command_result" {
  189. // Find pending command and send result
  190. if cmdID := event.Payload["command_id"]; cmdID != nil {
  191. cmdIDstr, _ := cmdID.(string)
  192. if ch, ok := pendingCommands[cmdIDstr]; ok {
  193. result := CommandResult{
  194. Success: event.Payload["success"] == true,
  195. Output: func() string { s, _ := event.Payload["output"].(string); return s }(),
  196. Error: func() string { s, _ := event.Payload["error"].(string); return s }(),
  197. }
  198. ch <- result
  199. }
  200. // Mark as executed (for idempotency)
  201. executedCommands[cmdIDstr] = time.Now()
  202. }
  203. }
  204. // Publish to Redpanda
  205. if err := publishEvent(event); err != nil {
  206. log.Printf("Publish error: %v", err)
  207. }
  208. log.Printf("[%s] %s", routerID, event.EventType)
  209. routers[routerID].LastSeen = time.Now()
  210. }
  211. if routers[routerID] != nil {
  212. routers[routerID].Connected = false
  213. }
  214. log.Printf("Router disconnected: %s", routerID)
  215. }
  216. // HTTP Event webhook
  217. func handleHTTPEvent(w http.ResponseWriter, r *http.Request) {
  218. if r.Method != "POST" {
  219. http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
  220. return
  221. }
  222. token := r.Header.Get("Authorization")
  223. if token != "Bearer "+cfg.Token && token != cfg.Token {
  224. http.Error(w, "Unauthorized", http.StatusUnauthorized)
  225. return
  226. }
  227. var event RouterEvent
  228. if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
  229. http.Error(w, "Invalid JSON", http.StatusBadRequest)
  230. return
  231. }
  232. event.ID = uuid.New().String()
  233. event.ReceivedAt = time.Now()
  234. event.Connection = "http"
  235. if err := publishEvent(event); err != nil {
  236. w.WriteHeader(http.StatusInternalServerError)
  237. return
  238. }
  239. json.NewEncoder(w).Encode(map[string]string{"event_id": event.ID})
  240. }
  241. // Get routers
  242. func handleRouters(w http.ResponseWriter, r *http.Request) {
  243. list := []map[string]interface{}{}
  244. for id, router := range routers {
  245. list = append(list, map[string]interface{}{
  246. "id": id,
  247. "last_seen": router.LastSeen,
  248. "online": time.Since(router.LastSeen) < 60*time.Second,
  249. })
  250. }
  251. json.NewEncoder(w).Encode(map[string]interface{}{"routers": list})
  252. }
  253. // Send command and wait for result
  254. func handleCommand(w http.ResponseWriter, r *http.Request) {
  255. if r.Method != "POST" {
  256. http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
  257. return
  258. }
  259. var cmd RouterCommand
  260. if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
  261. http.Error(w, "Invalid JSON", http.StatusBadRequest)
  262. return
  263. }
  264. routerID := r.URL.Query().Get("router_id")
  265. if routerID == "" {
  266. routerID = cmd.RouterID
  267. }
  268. if routerID == "" {
  269. http.Error(w, "router_id required", http.StatusBadRequest)
  270. return
  271. }
  272. router := routers[routerID]
  273. // ──────────────────────────────────────────────────────────
  274. // IDEMPOTENCY CHECK - Dedup before publishing!
  275. // ──────────────────────────────────────────────────────────
  276. // If client provides an idempotency key, reuse it
  277. xecID := cmd.ID
  278. if xecID == "" {
  279. xecID = uuid.New().String()
  280. }
  281. // Check if already executed within TTL
  282. if lastExec, exists := executedCommands[xecID]; exists {
  283. if time.Since(lastExec) < idempotencyTTL {
  284. log.Printf("Idempotent reject: %s (recently executed)", xecID)
  285. json.NewEncoder(w).Encode(map[string]interface{}{
  286. "idempotent_reject": true,
  287. "existing_command_id": xecID,
  288. "message": "Command already executed",
  289. })
  290. return
  291. }
  292. delete(executedCommands, xecID)
  293. }
  294. // ──────────────────────────────────────────────────────────
  295. // Queue vs Live delivery
  296. // ──────────────────────────────────────────────────────────
  297. cmd.ID = xecID
  298. cmd.RouterID = routerID
  299. cmd.SentAt = time.Now()
  300. if router == nil || !router.Connected {
  301. // Router offline - QUEUE it!
  302. routerQueues[routerID] = append(routerQueues[routerID], cmd)
  303. log.Printf("Queued command for %s: %s (queue depth: %d)", routerID, cmd.Command, len(routerQueues[routerID]))
  304. json.NewEncoder(w).Encode(map[string]interface{}{
  305. "command_id": cmd.ID,
  306. "status": "queued",
  307. "queued_for": routerID,
  308. "queue_depth": len(routerQueues[routerID]),
  309. "message": "Router offline, command queued",
  310. })
  311. return
  312. }
  313. // Router online - send directly
  314. resultChan := make(chan CommandResult, 1)
  315. pendingCommands[cmd.ID] = resultChan
  316. if err := publishCommand(cmd); err != nil {
  317. delete(pendingCommands, cmd.ID)
  318. log.Printf("Publish command: %v", err)
  319. http.Error(w, err.Error(), http.StatusInternalServerError)
  320. return
  321. }
  322. log.Printf("Command to %s: %s (waiting...)", routerID, cmd.Command)
  323. // Wait for result with timeout
  324. select {
  325. case result := <-resultChan:
  326. delete(pendingCommands, cmd.ID)
  327. json.NewEncoder(w).Encode(map[string]interface{}{
  328. "command_id": cmd.ID,
  329. "status": "completed",
  330. "success": result.Success,
  331. "output": result.Output,
  332. "error": result.Error,
  333. })
  334. case <-time.After(commandTimeout):
  335. delete(pendingCommands, cmd.ID)
  336. json.NewEncoder(w).Encode(map[string]string{
  337. "command_id": cmd.ID,
  338. "status": "timeout",
  339. "error": "Router did not respond",
  340. })
  341. }
  342. // Also mark timeout for idempotency (allows retry after TTL)
  343. // executedCommands stays, will expire naturally
  344. }
  345. // Health
  346. func handleHealth(w http.ResponseWriter, r *http.Request) {
  347. json.NewEncoder(w).Encode(map[string]interface{}{
  348. "status": "ok",
  349. "routers": len(routers),
  350. })
  351. }
  352. func main() {
  353. cfg.Token = getEnvStr("TOKEN", cfg.Token)
  354. cfg.WebsocketPort = getEnvInt("PORT", cfg.WebsocketPort)
  355. log.SetFlags(log.LstdFlags | log.Lshortfile)
  356. log.Printf("=== client2server Go Server ===")
  357. log.Printf("WebSocket: ws://localhost:%d", cfg.WebsocketPort)
  358. if err := initRedpanda(); err != nil {
  359. log.Printf("Redpanda init failed: %v", err)
  360. }
  361. http.HandleFunc("/", handleHealth)
  362. http.HandleFunc("/health", handleHealth)
  363. http.HandleFunc("/api/events", handleHTTPEvent)
  364. http.HandleFunc("/api/events/", handleHTTPEvent)
  365. http.HandleFunc("/api/routers", handleRouters)
  366. http.HandleFunc("/api/command", handleCommand)
  367. http.HandleFunc("/ws", handleWebSocket)
  368. go func() {
  369. sigCh := make(chan os.Signal, 1)
  370. signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
  371. <-sigCh
  372. log.Println("Shutting down...")
  373. for _, router := range routers {
  374. router.Conn.Close(websocket.StatusNormalClosure, "server shutdown")
  375. }
  376. os.Exit(0)
  377. }()
  378. log.Printf("Server ready on port %d", cfg.WebsocketPort)
  379. log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", cfg.WebsocketPort), nil))
  380. }