main.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. // client2server - Go server with WebSocket + Redpanda (Kafka-compatible)
  2. // Copyright (c) 2026 Luis Rosales - MIT License
  3. //
  4. // Build: go build -o client2server-server .
  5. // Run: ./client2server-server
  6. // Env: REDPANDA_BROKERS=localhost:9092 TOKEN=*** PORT=3843
  7. //
  8. // WebSocket: ws://localhost:3843/ws
  9. // HTTP API: http://localhost:3843/api/{events,routers,command}, /health
  10. package main
  11. import (
  12. "context"
  13. "encoding/json"
  14. "errors"
  15. "fmt"
  16. "log"
  17. "net/http"
  18. "os"
  19. "os/signal"
  20. "strings"
  21. "sync"
  22. "syscall"
  23. "time"
  24. "github.com/coder/websocket"
  25. "github.com/google/uuid"
  26. "github.com/twmb/franz-go/pkg/kadm"
  27. "github.com/twmb/franz-go/pkg/kerr"
  28. "github.com/twmb/franz-go/pkg/kgo"
  29. )
  30. // ----------------------------------------------------------------------------
  31. // Config
  32. // ----------------------------------------------------------------------------
  33. type Config struct {
  34. RedpandaBrokers []string
  35. Port int
  36. Token string
  37. }
  38. var cfg = Config{
  39. RedpandaBrokers: []string{"localhost:9092"},
  40. Port: 3843,
  41. Token: "secret-token",
  42. }
  43. func loadConfigFromEnv() {
  44. cfg.RedpandaBrokers = strings.Split(getEnvStr("REDPANDA_BROKERS", "localhost:9092"), ",")
  45. cfg.Port = getEnvInt("PORT", cfg.Port)
  46. cfg.Token = getEnvStr("TOKEN", cfg.Token)
  47. }
  48. func getEnvStr(key, def string) string {
  49. if v := os.Getenv(key); v != "" {
  50. return v
  51. }
  52. return def
  53. }
  54. func getEnvInt(key string, def int) int {
  55. if v := os.Getenv(key); v != "" {
  56. var n int
  57. if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
  58. return n
  59. }
  60. }
  61. return def
  62. }
  63. // ----------------------------------------------------------------------------
  64. // Domain types
  65. // ----------------------------------------------------------------------------
  66. // RouterEvent - generic event sent from a router to the server.
  67. type RouterEvent struct {
  68. ID string `json:"id"`
  69. RouterID string `json:"router_id"`
  70. Hostname string `json:"hostname,omitempty"`
  71. EventType string `json:"event_type"`
  72. Timestamp time.Time `json:"timestamp"`
  73. Payload map[string]interface{} `json:"payload"`
  74. ReceivedAt time.Time `json:"received_at"`
  75. Connection string `json:"connection"` // "websocket" | "http"
  76. }
  77. // RouterCommand - command sent from server to a router.
  78. type RouterCommand struct {
  79. ID string `json:"id"`
  80. RouterID string `json:"router_id"`
  81. Command string `json:"command"`
  82. Args map[string]string `json:"args,omitempty"`
  83. SentAt time.Time `json:"sent_at"`
  84. }
  85. // CommandResult - reply from a router after running a command.
  86. type CommandResult struct {
  87. Success bool `json:"success"`
  88. Output string `json:"output"`
  89. Error string `json:"error"`
  90. }
  91. // ----------------------------------------------------------------------------
  92. // Router registry
  93. // ----------------------------------------------------------------------------
  94. type Router struct {
  95. ID string
  96. LastSeen time.Time
  97. Conn *websocket.Conn
  98. Connected bool
  99. writeMu sync.Mutex // serialise writes to the WS connection
  100. }
  101. var (
  102. routersMu sync.RWMutex
  103. routers = make(map[string]*Router)
  104. routerQueuesMu sync.Mutex
  105. routerQueues = make(map[string][]RouterCommand) // queued while offline
  106. pendingMu sync.Mutex
  107. pendingCmds = make(map[string]chan CommandResult) // command_id -> result chan
  108. executedMu sync.Mutex
  109. executedCmds = make(map[string]time.Time) // idempotency: cmd_id -> last run
  110. )
  111. const (
  112. commandTimeout = 30 * time.Second
  113. idempotencyTTL = 5 * time.Minute
  114. wsWriteTimeout = 10 * time.Second
  115. publishTimeout = 3 * time.Second
  116. offlineThreshold = 60 * time.Second
  117. )
  118. // ----------------------------------------------------------------------------
  119. // Redpanda (Kafka) client
  120. // ----------------------------------------------------------------------------
  121. var kcl *kgo.Client
  122. func initRedpanda(ctx context.Context) error {
  123. cl, err := kgo.NewClient(
  124. kgo.SeedBrokers(cfg.RedpandaBrokers...),
  125. kgo.ClientID("client2server"),
  126. kgo.ProducerLinger(5*time.Millisecond),
  127. kgo.ProducerBatchCompression(kgo.SnappyCompression()),
  128. )
  129. if err != nil {
  130. return fmt.Errorf("kafka client: %w", err)
  131. }
  132. kcl = cl
  133. // Best-effort topic creation. Redpanda has auto-create enabled in dev, so
  134. // this is just to make sure they exist with sane defaults.
  135. adm := kadm.NewClient(cl)
  136. topics := []string{"router-events", "router-commands"}
  137. resp, err := adm.CreateTopics(ctx, 1, 1, nil, topics...)
  138. if err != nil {
  139. log.Printf("create topics admin call failed (non-fatal if auto-create is on): %v", err)
  140. return nil
  141. }
  142. for _, ct := range resp {
  143. if ct.Err != nil && !errors.Is(ct.Err, kerr.TopicAlreadyExists) {
  144. log.Printf("topic %s: %v", ct.Topic, ct.Err)
  145. }
  146. }
  147. return nil
  148. }
  149. func publish(ctx context.Context, topic string, key string, value any) error {
  150. data, err := json.Marshal(value)
  151. if err != nil {
  152. return fmt.Errorf("marshal: %w", err)
  153. }
  154. rec := &kgo.Record{Topic: topic, Key: []byte(key), Value: data}
  155. // Bound how long the HTTP handler can wait for the broker.
  156. pctx, cancel := context.WithTimeout(ctx, publishTimeout)
  157. defer cancel()
  158. res := kcl.ProduceSync(pctx, rec)
  159. if err := res.FirstErr(); err != nil {
  160. return err
  161. }
  162. return nil
  163. }
  164. // ----------------------------------------------------------------------------
  165. // WebSocket handler
  166. // ----------------------------------------------------------------------------
  167. func handleWebSocket(w http.ResponseWriter, r *http.Request) {
  168. // Auth: token in query string (?token=...) or Authorization header
  169. token := r.URL.Query().Get("token")
  170. if token == "" {
  171. if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
  172. token = strings.TrimPrefix(h, "Bearer ")
  173. }
  174. }
  175. if token != cfg.Token {
  176. http.Error(w, "Unauthorized", http.StatusUnauthorized)
  177. return
  178. }
  179. conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
  180. // Disable permessage-deflate to keep things simple on minimal routers
  181. CompressionMode: websocket.CompressionDisabled,
  182. })
  183. if err != nil {
  184. log.Printf("ws accept: %v", err)
  185. return
  186. }
  187. defer conn.Close(websocket.StatusNormalClosure, "bye")
  188. ctx, cancel := context.WithCancel(r.Context())
  189. defer cancel()
  190. // First message must be a registration event
  191. firstMsg, err := readRouterMessage(ctx, conn)
  192. if err != nil {
  193. log.Printf("ws read reg: %v", err)
  194. return
  195. }
  196. var reg RouterEvent
  197. if err := json.Unmarshal(firstMsg, &reg); err != nil {
  198. log.Printf("ws reg parse: %v", err)
  199. return
  200. }
  201. routerID := reg.RouterID
  202. if routerID == "" {
  203. routerID = r.RemoteAddr
  204. }
  205. routersMu.Lock()
  206. routers[routerID] = &Router{
  207. ID: routerID,
  208. LastSeen: time.Now(),
  209. Conn: conn,
  210. Connected: true,
  211. }
  212. routersMu.Unlock()
  213. log.Printf("router connected: %s (from %s)", routerID, r.RemoteAddr)
  214. flushQueuedCommands(ctx, routerID)
  215. // Message loop
  216. for {
  217. raw, err := readRouterMessage(ctx, conn)
  218. if err != nil {
  219. break
  220. }
  221. var ev RouterEvent
  222. if err := json.Unmarshal(raw, &ev); err != nil {
  223. log.Printf("[%s] bad event json: %v", routerID, err)
  224. continue
  225. }
  226. // Server-assigned fields
  227. ev.ID = uuid.New().String()
  228. ev.ReceivedAt = time.Now()
  229. ev.Connection = "websocket"
  230. if ev.Timestamp.IsZero() {
  231. ev.Timestamp = ev.ReceivedAt
  232. }
  233. // Command result handling
  234. if ev.EventType == "command_result" {
  235. if cid, _ := ev.Payload["command_id"].(string); cid != "" {
  236. deliverCommandResult(cid, ev.Payload)
  237. executedMu.Lock()
  238. executedCmds[cid] = time.Now()
  239. executedMu.Unlock()
  240. }
  241. }
  242. if err := publish(ctx, "router-events", routerID, ev); err != nil {
  243. log.Printf("publish event: %v", err)
  244. }
  245. log.Printf("[%s] %s", routerID, ev.EventType)
  246. routersMu.Lock()
  247. if r, ok := routers[routerID]; ok {
  248. r.LastSeen = time.Now()
  249. }
  250. routersMu.Unlock()
  251. }
  252. routersMu.Lock()
  253. if r, ok := routers[routerID]; ok {
  254. r.Connected = false
  255. }
  256. routersMu.Unlock()
  257. log.Printf("router disconnected: %s", routerID)
  258. }
  259. func readRouterMessage(ctx context.Context, conn *websocket.Conn) ([]byte, error) {
  260. // coder/websocket: Read returns a Message
  261. _, data, err := conn.Read(ctx)
  262. return data, err
  263. }
  264. // writeJSON serialises writes to the connection.
  265. func (r *Router) writeJSON(ctx context.Context, v any) error {
  266. data, err := json.Marshal(v)
  267. if err != nil {
  268. return err
  269. }
  270. wctx, cancel := context.WithTimeout(ctx, wsWriteTimeout)
  271. defer cancel()
  272. r.writeMu.Lock()
  273. defer r.writeMu.Unlock()
  274. return r.Conn.Write(wctx, websocket.MessageText, data)
  275. }
  276. func deliverCommandResult(cmdID string, payload map[string]interface{}) {
  277. pendingMu.Lock()
  278. ch, ok := pendingCmds[cmdID]
  279. if ok {
  280. delete(pendingCmds, cmdID)
  281. }
  282. pendingMu.Unlock()
  283. if !ok {
  284. return
  285. }
  286. res := CommandResult{
  287. Success: payload["success"] == true,
  288. }
  289. if s, ok := payload["output"].(string); ok {
  290. res.Output = s
  291. }
  292. if s, ok := payload["error"].(string); ok {
  293. res.Error = s
  294. }
  295. select {
  296. case ch <- res:
  297. default:
  298. }
  299. }
  300. func flushQueuedCommands(ctx context.Context, routerID string) {
  301. routerQueuesMu.Lock()
  302. queue := routerQueues[routerID]
  303. delete(routerQueues, routerID)
  304. routerQueuesMu.Unlock()
  305. if len(queue) == 0 {
  306. return
  307. }
  308. log.Printf("flushing %d queued commands to %s", len(queue), routerID)
  309. for _, cmd := range queue {
  310. cmd.SentAt = time.Now()
  311. pendingMu.Lock()
  312. pendingCmds[cmd.ID] = make(chan CommandResult, 1)
  313. pendingMu.Unlock()
  314. if err := publish(ctx, "router-commands", routerID, cmd); err != nil {
  315. log.Printf("queue flush publish: %v", err)
  316. }
  317. }
  318. }
  319. // ----------------------------------------------------------------------------
  320. // HTTP handlers
  321. // ----------------------------------------------------------------------------
  322. func handleHTTPEvent(w http.ResponseWriter, r *http.Request) {
  323. if r.Method != http.MethodPost {
  324. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  325. return
  326. }
  327. if !authorised(r) {
  328. http.Error(w, "unauthorized", http.StatusUnauthorized)
  329. return
  330. }
  331. var ev RouterEvent
  332. if err := json.NewDecoder(r.Body).Decode(&ev); err != nil {
  333. http.Error(w, "invalid json", http.StatusBadRequest)
  334. return
  335. }
  336. ev.ID = uuid.New().String()
  337. ev.ReceivedAt = time.Now()
  338. ev.Connection = "http"
  339. if ev.Timestamp.IsZero() {
  340. ev.Timestamp = ev.ReceivedAt
  341. }
  342. if err := publish(r.Context(), "router-events", ev.RouterID, ev); err != nil {
  343. log.Printf("publish event: %v", err)
  344. http.Error(w, "publish failed", http.StatusBadGateway)
  345. return
  346. }
  347. w.Header().Set("Content-Type", "application/json")
  348. _ = json.NewEncoder(w).Encode(map[string]string{
  349. "event_id": ev.ID,
  350. "router_id": ev.RouterID,
  351. "status": "accepted",
  352. })
  353. }
  354. func handleRouters(w http.ResponseWriter, r *http.Request) {
  355. type entry struct {
  356. ID string `json:"id"`
  357. LastSeen time.Time `json:"last_seen"`
  358. Online bool `json:"online"`
  359. Queued int `json:"queued_commands"`
  360. }
  361. routersMu.RLock()
  362. list := make([]entry, 0, len(routers))
  363. for id, rt := range routers {
  364. list = append(list, entry{
  365. ID: id,
  366. LastSeen: rt.LastSeen,
  367. Online: time.Since(rt.LastSeen) < offlineThreshold,
  368. Queued: len(routerQueues[id]),
  369. })
  370. }
  371. routersMu.RUnlock()
  372. w.Header().Set("Content-Type", "application/json")
  373. _ = json.NewEncoder(w).Encode(map[string]interface{}{"routers": list})
  374. }
  375. func handleCommand(w http.ResponseWriter, r *http.Request) {
  376. if r.Method != http.MethodPost {
  377. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  378. return
  379. }
  380. if !authorised(r) {
  381. http.Error(w, "unauthorized", http.StatusUnauthorized)
  382. return
  383. }
  384. var cmd RouterCommand
  385. if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
  386. http.Error(w, "invalid json", http.StatusBadRequest)
  387. return
  388. }
  389. routerID := r.URL.Query().Get("router_id")
  390. if routerID == "" {
  391. routerID = cmd.RouterID
  392. }
  393. if routerID == "" {
  394. http.Error(w, "router_id required", http.StatusBadRequest)
  395. return
  396. }
  397. // Idempotency
  398. xecID := cmd.ID
  399. if xecID == "" {
  400. xecID = uuid.New().String()
  401. }
  402. executedMu.Lock()
  403. if last, exists := executedCmds[xecID]; exists && time.Since(last) < idempotencyTTL {
  404. executedMu.Unlock()
  405. w.Header().Set("Content-Type", "application/json")
  406. _ = json.NewEncoder(w).Encode(map[string]interface{}{
  407. "idempotent_reject": true,
  408. "existing_command_id": xecID,
  409. "message": "command already executed within TTL",
  410. })
  411. return
  412. }
  413. delete(executedCmds, xecID)
  414. executedMu.Unlock()
  415. cmd.ID = xecID
  416. cmd.RouterID = routerID
  417. cmd.SentAt = time.Now()
  418. routersMu.RLock()
  419. router, online := routers[routerID]
  420. routersMu.RUnlock()
  421. if !online || router == nil || !router.Connected {
  422. routerQueuesMu.Lock()
  423. routerQueues[routerID] = append(routerQueues[routerID], cmd)
  424. depth := len(routerQueues[routerID])
  425. routerQueuesMu.Unlock()
  426. log.Printf("queued cmd %s for %s (depth=%d)", cmd.Command, routerID, depth)
  427. w.Header().Set("Content-Type", "application/json")
  428. _ = json.NewEncoder(w).Encode(map[string]interface{}{
  429. "command_id": cmd.ID,
  430. "status": "queued",
  431. "queued_for": routerID,
  432. "queue_depth": depth,
  433. })
  434. return
  435. }
  436. // Online: register waiter, publish, wait for result
  437. resultCh := make(chan CommandResult, 1)
  438. pendingMu.Lock()
  439. pendingCmds[cmd.ID] = resultCh
  440. pendingMu.Unlock()
  441. if err := publish(r.Context(), "router-commands", routerID, cmd); err != nil {
  442. pendingMu.Lock()
  443. delete(pendingCmds, cmd.ID)
  444. pendingMu.Unlock()
  445. log.Printf("publish command: %v", err)
  446. http.Error(w, "publish failed", http.StatusBadGateway)
  447. return
  448. }
  449. log.Printf("cmd %s -> %s (waiting)", cmd.Command, routerID)
  450. w.Header().Set("Content-Type", "application/json")
  451. select {
  452. case res := <-resultCh:
  453. _ = json.NewEncoder(w).Encode(map[string]interface{}{
  454. "command_id": cmd.ID,
  455. "status": "completed",
  456. "success": res.Success,
  457. "output": res.Output,
  458. "error": res.Error,
  459. })
  460. case <-time.After(commandTimeout):
  461. pendingMu.Lock()
  462. delete(pendingCmds, cmd.ID)
  463. pendingMu.Unlock()
  464. _ = json.NewEncoder(w).Encode(map[string]string{
  465. "command_id": cmd.ID,
  466. "status": "timeout",
  467. "error": "router did not respond",
  468. })
  469. }
  470. }
  471. func handleHealth(w http.ResponseWriter, r *http.Request) {
  472. routersMu.RLock()
  473. onlineCount := 0
  474. for _, rt := range routers {
  475. if time.Since(rt.LastSeen) < offlineThreshold {
  476. onlineCount++
  477. }
  478. }
  479. total := len(routers)
  480. routersMu.RUnlock()
  481. w.Header().Set("Content-Type", "application/json")
  482. _ = json.NewEncoder(w).Encode(map[string]interface{}{
  483. "status": "ok",
  484. "routers": total,
  485. "routers_online": onlineCount,
  486. "redpanda": cfg.RedpandaBrokers,
  487. })
  488. }
  489. func authorised(r *http.Request) bool {
  490. h := r.Header.Get("Authorization")
  491. if h == cfg.Token { // legacy: raw token
  492. return true
  493. }
  494. if strings.HasPrefix(h, "Bearer ") && strings.TrimPrefix(h, "Bearer ") == cfg.Token {
  495. return true
  496. }
  497. return false
  498. }
  499. // ----------------------------------------------------------------------------
  500. // Background cleanup
  501. // ----------------------------------------------------------------------------
  502. func startJanitor(ctx context.Context) {
  503. go func() {
  504. t := time.NewTicker(time.Minute)
  505. defer t.Stop()
  506. for {
  507. select {
  508. case <-ctx.Done():
  509. return
  510. case <-t.C:
  511. now := time.Now()
  512. executedMu.Lock()
  513. for id, ts := range executedCmds {
  514. if now.Sub(ts) > idempotencyTTL {
  515. delete(executedCmds, id)
  516. }
  517. }
  518. executedMu.Unlock()
  519. }
  520. }
  521. }()
  522. }
  523. // ----------------------------------------------------------------------------
  524. // main
  525. // ----------------------------------------------------------------------------
  526. func main() {
  527. loadConfigFromEnv()
  528. log.SetFlags(log.LstdFlags | log.Lshortfile)
  529. log.Printf("=== client2server Go Server ===")
  530. log.Printf("port=%d brokers=%v", cfg.Port, cfg.RedpandaBrokers)
  531. ctx, cancel := context.WithCancel(context.Background())
  532. defer cancel()
  533. if err := initRedpanda(ctx); err != nil {
  534. log.Printf("redpanda init failed (continuing): %v", err)
  535. } else {
  536. defer kcl.Close()
  537. }
  538. startJanitor(ctx)
  539. mux := http.NewServeMux()
  540. mux.HandleFunc("/health", handleHealth)
  541. mux.HandleFunc("/api/events", handleHTTPEvent)
  542. mux.HandleFunc("/api/routers", handleRouters)
  543. mux.HandleFunc("/api/command", handleCommand)
  544. mux.HandleFunc("/ws", handleWebSocket)
  545. mux.HandleFunc("/", handleHealth)
  546. srv := &http.Server{
  547. Addr: fmt.Sprintf(":%d", cfg.Port),
  548. Handler: mux,
  549. ReadTimeout: 0, // WS connections are long-lived
  550. WriteTimeout: 0,
  551. }
  552. // Graceful shutdown
  553. go func() {
  554. sigCh := make(chan os.Signal, 1)
  555. signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
  556. <-sigCh
  557. log.Println("shutting down...")
  558. routersMu.RLock()
  559. for _, r := range routers {
  560. if r.Conn != nil {
  561. r.Conn.Close(websocket.StatusNormalClosure, "server shutdown")
  562. }
  563. }
  564. routersMu.RUnlock()
  565. shutdownCtx, c := context.WithTimeout(context.Background(), 5*time.Second)
  566. defer c()
  567. _ = srv.Shutdown(shutdownCtx)
  568. os.Exit(0)
  569. }()
  570. log.Printf("server ready on :%d", cfg.Port)
  571. if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
  572. log.Fatal(err)
  573. }
  574. }