main.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887
  1. // client2server - Go server with WebSocket + Redpanda + Dashboard API
  2. // Copyright (c) 2026 Luis Rosales - MIT License
  3. //
  4. // Stack:
  5. // - WebSocket: github.com/coder/websocket
  6. // - Kafka: github.com/twmb/franz-go (talks to Redpanda)
  7. // - Auth: JWT (HS256) with role-based access
  8. // - Storage: SQLite (modernc.org/sqlite, pure Go, no cgo)
  9. // - Live feed: Server-Sent Events
  10. //
  11. // Endpoints:
  12. // POST /api/auth/login - username/password -> JWT
  13. // GET /api/auth/me - current user
  14. // GET /health - liveness
  15. // GET /api/routers - known routers (legacy shared token OK)
  16. // GET /api/events?limit=&router_id= - event history (auth)
  17. // POST /api/events - ingest event (router or hotplug)
  18. // POST /api/command - send command to router (auth)
  19. // GET /api/commands?router_id= - command history
  20. // GET /api/metrics?since=1h - time-series metrics
  21. // GET /api/alerts?unack=1 - alerts
  22. // POST /api/alerts/:id/ack - acknowledge alert
  23. // GET /api/events/stream - SSE live feed
  24. // GET /ws - WebSocket from routers (legacy token)
  25. //
  26. // Env: REDPANDA_BROKERS, TOKEN (legacy shared), JWT_SECRET, DB_PATH, PORT
  27. package main
  28. import (
  29. "context"
  30. "encoding/json"
  31. "errors"
  32. "fmt"
  33. "log"
  34. "net/http"
  35. "os"
  36. "os/signal"
  37. "strconv"
  38. "strings"
  39. "sync"
  40. "syscall"
  41. "time"
  42. "github.com/coder/websocket"
  43. "github.com/google/uuid"
  44. "github.com/twmb/franz-go/pkg/kgo"
  45. )
  46. // ----------------------------------------------------------------------------
  47. // Config
  48. // ----------------------------------------------------------------------------
  49. type Config struct {
  50. RedpandaBrokers []string
  51. Port int
  52. Token string // legacy shared token for routers/hotplug
  53. DBPath string
  54. }
  55. var cfg = Config{
  56. RedpandaBrokers: []string{"localhost:9092"},
  57. Port: 3843,
  58. Token: "secret-token",
  59. DBPath: "data/client2server.db",
  60. }
  61. func loadConfigFromEnv() {
  62. cfg.RedpandaBrokers = strings.Split(getEnvStr("REDPANDA_BROKERS", "localhost:9092"), ",")
  63. cfg.Port = getEnvInt("PORT", cfg.Port)
  64. cfg.Token = getEnvStr("TOKEN", cfg.Token)
  65. cfg.DBPath = getEnvStr("DB_PATH", cfg.DBPath)
  66. }
  67. func getEnvStr(key, def string) string {
  68. if v := os.Getenv(key); v != "" {
  69. return v
  70. }
  71. return def
  72. }
  73. func getEnvInt(key string, def int) int {
  74. if v := os.Getenv(key); v != "" {
  75. var n int
  76. if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
  77. return n
  78. }
  79. }
  80. return def
  81. }
  82. // ----------------------------------------------------------------------------
  83. // Domain types
  84. // ----------------------------------------------------------------------------
  85. type RouterEvent struct {
  86. ID string `json:"id"`
  87. RouterID string `json:"router_id"`
  88. Hostname string `json:"hostname,omitempty"`
  89. EventType string `json:"event_type"`
  90. Timestamp time.Time `json:"timestamp"`
  91. Payload map[string]interface{} `json:"payload"`
  92. ReceivedAt time.Time `json:"received_at"`
  93. Connection string `json:"connection"`
  94. }
  95. type RouterCommand struct {
  96. ID string `json:"id"`
  97. RouterID string `json:"router_id"`
  98. Command string `json:"command"`
  99. Args map[string]string `json:"args,omitempty"`
  100. SentAt time.Time `json:"sent_at"`
  101. }
  102. type CommandResult struct {
  103. Success bool `json:"success"`
  104. Output string `json:"output"`
  105. Error string `json:"error"`
  106. }
  107. // ----------------------------------------------------------------------------
  108. // Router registry
  109. // ----------------------------------------------------------------------------
  110. type Router struct {
  111. ID string
  112. LastSeen time.Time
  113. Conn *websocket.Conn
  114. Connected bool
  115. writeMu sync.Mutex
  116. }
  117. var (
  118. routersMu sync.RWMutex
  119. routers = make(map[string]*Router)
  120. routerQueuesMu sync.Mutex
  121. routerQueues = make(map[string][]RouterCommand)
  122. pendingMu sync.Mutex
  123. pendingCmds = make(map[string]chan CommandResult)
  124. executedMu sync.Mutex
  125. executedCmds = make(map[string]time.Time)
  126. )
  127. const (
  128. commandTimeout = 30 * time.Second
  129. idempotencyTTL = 5 * time.Minute
  130. wsWriteTimeout = 10 * time.Second
  131. publishTimeout = 3 * time.Second
  132. offlineThreshold = 60 * time.Second
  133. )
  134. // ----------------------------------------------------------------------------
  135. // Redpanda (Kafka) producer
  136. // ----------------------------------------------------------------------------
  137. var kcl *kgo.Client
  138. func initRedpanda(ctx context.Context) error {
  139. cl, err := kgo.NewClient(
  140. kgo.SeedBrokers(cfg.RedpandaBrokers...),
  141. kgo.ClientID("client2server"),
  142. kgo.ProducerLinger(5*time.Millisecond),
  143. kgo.ProducerBatchCompression(kgo.SnappyCompression()),
  144. )
  145. if err != nil {
  146. return fmt.Errorf("kafka client: %w", err)
  147. }
  148. kcl = cl
  149. return nil
  150. }
  151. func publish(ctx context.Context, topic string, key string, value any) error {
  152. data, err := json.Marshal(value)
  153. if err != nil {
  154. return fmt.Errorf("marshal: %w", err)
  155. }
  156. rec := &kgo.Record{Topic: topic, Key: []byte(key), Value: data}
  157. pctx, cancel := context.WithTimeout(ctx, publishTimeout)
  158. defer cancel()
  159. res := kcl.ProduceSync(pctx, rec)
  160. if err := res.FirstErr(); err != nil {
  161. return err
  162. }
  163. return nil
  164. }
  165. // ----------------------------------------------------------------------------
  166. // Event ingestion (called by both WS and HTTP)
  167. // ----------------------------------------------------------------------------
  168. func ingestEvent(ev RouterEvent) {
  169. if ev.ID == "" {
  170. ev.ID = uuid.New().String()
  171. }
  172. if ev.ReceivedAt.IsZero() {
  173. ev.ReceivedAt = time.Now()
  174. }
  175. if ev.Timestamp.IsZero() {
  176. ev.Timestamp = ev.ReceivedAt
  177. }
  178. // Metrics
  179. globalMetrics.recordEvent(ev.EventType)
  180. // Persist (best-effort)
  181. if err := saveEvent(ev); err != nil {
  182. log.Printf("save event: %v", err)
  183. }
  184. // Publish to Redpanda
  185. if err := publish(context.Background(), "router-events", ev.RouterID, ev); err != nil {
  186. log.Printf("publish event: %v", err)
  187. }
  188. // Broadcast to SSE clients
  189. hub.broadcast("event", ev)
  190. }
  191. // ----------------------------------------------------------------------------
  192. // WebSocket handler (routers connect here)
  193. // ----------------------------------------------------------------------------
  194. func handleWebSocket(w http.ResponseWriter, r *http.Request) {
  195. // Auth: legacy TOKEN only (routers use shared secret, not JWT)
  196. token := r.URL.Query().Get("token")
  197. if token == "" {
  198. if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
  199. token = strings.TrimPrefix(h, "Bearer ")
  200. }
  201. }
  202. if token != cfg.Token {
  203. http.Error(w, "Unauthorized", http.StatusUnauthorized)
  204. return
  205. }
  206. conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
  207. CompressionMode: websocket.CompressionDisabled,
  208. })
  209. if err != nil {
  210. log.Printf("ws accept: %v", err)
  211. return
  212. }
  213. defer conn.Close(websocket.StatusNormalClosure, "bye")
  214. ctx, cancel := context.WithCancel(r.Context())
  215. defer cancel()
  216. // First message is registration
  217. raw, err := readRouterMessage(ctx, conn)
  218. if err != nil {
  219. log.Printf("ws read reg: %v", err)
  220. return
  221. }
  222. var reg RouterEvent
  223. if err := json.Unmarshal(raw, &reg); err != nil {
  224. log.Printf("ws reg parse: %v", err)
  225. return
  226. }
  227. routerID := reg.RouterID
  228. if routerID == "" {
  229. routerID = r.RemoteAddr
  230. }
  231. routersMu.Lock()
  232. routers[routerID] = &Router{
  233. ID: routerID,
  234. LastSeen: time.Now(),
  235. Conn: conn,
  236. Connected: true,
  237. }
  238. routersMu.Unlock()
  239. // Mark as back online (clears any offline alert)
  240. acknowledgeRouterAlerts(routerID)
  241. log.Printf("router connected: %s (from %s)", routerID, r.RemoteAddr)
  242. flushQueuedCommands(ctx, routerID)
  243. for {
  244. raw, err := readRouterMessage(ctx, conn)
  245. if err != nil {
  246. break
  247. }
  248. var ev RouterEvent
  249. if err := json.Unmarshal(raw, &ev); err != nil {
  250. log.Printf("[%s] bad event json: %v", routerID, err)
  251. continue
  252. }
  253. // Inherit router_id if missing
  254. if ev.RouterID == "" {
  255. ev.RouterID = routerID
  256. }
  257. ev.Connection = "websocket"
  258. // Command result handling
  259. if ev.EventType == "command_result" {
  260. if cid, _ := ev.Payload["command_id"].(string); cid != "" {
  261. deliverCommandResult(cid, ev.Payload)
  262. executedMu.Lock()
  263. executedCmds[cid] = time.Now()
  264. executedMu.Unlock()
  265. }
  266. }
  267. ingestEvent(ev)
  268. log.Printf("[%s] %s", routerID, ev.EventType)
  269. routersMu.Lock()
  270. if r, ok := routers[routerID]; ok {
  271. r.LastSeen = time.Now()
  272. }
  273. routersMu.Unlock()
  274. }
  275. routersMu.Lock()
  276. if r, ok := routers[routerID]; ok {
  277. r.Connected = false
  278. }
  279. routersMu.Unlock()
  280. log.Printf("router disconnected: %s", routerID)
  281. }
  282. func readRouterMessage(ctx context.Context, conn *websocket.Conn) ([]byte, error) {
  283. _, data, err := conn.Read(ctx)
  284. return data, err
  285. }
  286. func deliverCommandResult(cmdID string, payload map[string]interface{}) {
  287. pendingMu.Lock()
  288. ch, ok := pendingCmds[cmdID]
  289. if ok {
  290. delete(pendingCmds, cmdID)
  291. }
  292. pendingMu.Unlock()
  293. if !ok {
  294. return
  295. }
  296. res := CommandResult{Success: payload["success"] == true}
  297. if s, ok := payload["output"].(string); ok {
  298. res.Output = s
  299. }
  300. if s, ok := payload["error"].(string); ok {
  301. res.Error = s
  302. }
  303. // Persist result to SQLite
  304. _ = updateCommandResult(cmdID, "completed", &res)
  305. globalMetrics.recordCommand(res.Success)
  306. select {
  307. case ch <- res:
  308. default:
  309. }
  310. }
  311. func flushQueuedCommands(ctx context.Context, routerID string) {
  312. routerQueuesMu.Lock()
  313. queue := routerQueues[routerID]
  314. delete(routerQueues, routerID)
  315. routerQueuesMu.Unlock()
  316. if len(queue) == 0 {
  317. return
  318. }
  319. log.Printf("flushing %d queued commands to %s", len(queue), routerID)
  320. for _, cmd := range queue {
  321. cmd.SentAt = time.Now()
  322. pendingMu.Lock()
  323. pendingCmds[cmd.ID] = make(chan CommandResult, 1)
  324. pendingMu.Unlock()
  325. _ = updateCommandResult(cmd.ID, "delivered", nil)
  326. if err := publish(ctx, "router-commands", routerID, cmd); err != nil {
  327. log.Printf("queue flush publish: %v", err)
  328. }
  329. }
  330. }
  331. func acknowledgeRouterAlerts(routerID string) {
  332. // Best-effort: mark all unacked offline alerts for this router as resolved
  333. if db == nil {
  334. return
  335. }
  336. _, _ = db.Exec("UPDATE alerts SET acknowledged_at = CURRENT_TIMESTAMP WHERE router_id = ? AND kind = 'router_offline' AND acknowledged_at IS NULL", routerID)
  337. }
  338. // ----------------------------------------------------------------------------
  339. // HTTP API
  340. // ----------------------------------------------------------------------------
  341. func handleHealth(w http.ResponseWriter, r *http.Request) {
  342. routersMu.RLock()
  343. onlineCount := 0
  344. total := len(routers)
  345. for _, rt := range routers {
  346. if time.Since(rt.LastSeen) < offlineThreshold {
  347. onlineCount++
  348. }
  349. }
  350. routersMu.RUnlock()
  351. w.Header().Set("Content-Type", "application/json")
  352. _ = json.NewEncoder(w).Encode(map[string]interface{}{
  353. "status": "ok",
  354. "routers": total,
  355. "routers_online": onlineCount,
  356. "redpanda": cfg.RedpandaBrokers,
  357. "version": "2.1.0",
  358. })
  359. }
  360. func handleRouters(w http.ResponseWriter, r *http.Request) {
  361. type entry struct {
  362. ID string `json:"id"`
  363. LastSeen time.Time `json:"last_seen"`
  364. Online bool `json:"online"`
  365. Queued int `json:"queued_commands"`
  366. }
  367. routersMu.RLock()
  368. list := make([]entry, 0, len(routers))
  369. for id, rt := range routers {
  370. list = append(list, entry{
  371. ID: id,
  372. LastSeen: rt.LastSeen,
  373. Online: time.Since(rt.LastSeen) < offlineThreshold,
  374. Queued: len(routerQueues[id]),
  375. })
  376. }
  377. routersMu.RUnlock()
  378. w.Header().Set("Content-Type", "application/json")
  379. _ = json.NewEncoder(w).Encode(map[string]interface{}{"routers": list})
  380. }
  381. func handleHTTPEvent(w http.ResponseWriter, r *http.Request) {
  382. if r.Method != http.MethodPost {
  383. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  384. return
  385. }
  386. // Accept either JWT or legacy shared TOKEN
  387. if !authorisedRouter(r) {
  388. http.Error(w, "unauthorized", http.StatusUnauthorized)
  389. return
  390. }
  391. var ev RouterEvent
  392. if err := json.NewDecoder(r.Body).Decode(&ev); err != nil {
  393. http.Error(w, "invalid json", http.StatusBadRequest)
  394. return
  395. }
  396. ev.Connection = "http"
  397. ingestEvent(ev)
  398. w.Header().Set("Content-Type", "application/json")
  399. _ = json.NewEncoder(w).Encode(map[string]string{
  400. "event_id": ev.ID,
  401. "router_id": ev.RouterID,
  402. "status": "accepted",
  403. })
  404. _ = ev
  405. }
  406. func handleListEvents(w http.ResponseWriter, r *http.Request) {
  407. limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
  408. if limit <= 0 || limit > 1000 {
  409. limit = 100
  410. }
  411. routerID := r.URL.Query().Get("router_id")
  412. eventType := r.URL.Query().Get("event_type")
  413. events, err := listEvents(limit, routerID, eventType)
  414. if err != nil {
  415. http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
  416. return
  417. }
  418. w.Header().Set("Content-Type", "application/json")
  419. _ = json.NewEncoder(w).Encode(map[string]interface{}{"events": events})
  420. }
  421. func handleCommand(w http.ResponseWriter, r *http.Request) {
  422. if r.Method != http.MethodPost {
  423. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  424. return
  425. }
  426. // JWT or legacy TOKEN
  427. if !authorisedAny(r) {
  428. http.Error(w, "unauthorized", http.StatusUnauthorized)
  429. return
  430. }
  431. var cmd RouterCommand
  432. if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
  433. http.Error(w, "invalid json", http.StatusBadRequest)
  434. return
  435. }
  436. routerID := r.URL.Query().Get("router_id")
  437. if routerID == "" {
  438. routerID = cmd.RouterID
  439. }
  440. if routerID == "" {
  441. http.Error(w, "router_id required", http.StatusBadRequest)
  442. return
  443. }
  444. // Idempotency
  445. xecID := cmd.ID
  446. if xecID == "" {
  447. xecID = uuid.New().String()
  448. }
  449. executedMu.Lock()
  450. if last, exists := executedCmds[xecID]; exists && time.Since(last) < idempotencyTTL {
  451. executedMu.Unlock()
  452. w.Header().Set("Content-Type", "application/json")
  453. _ = json.NewEncoder(w).Encode(map[string]interface{}{
  454. "idempotent_reject": true,
  455. "existing_command_id": xecID,
  456. "message": "command already executed within TTL",
  457. })
  458. return
  459. }
  460. delete(executedCmds, xecID)
  461. executedMu.Unlock()
  462. cmd.ID = xecID
  463. cmd.RouterID = routerID
  464. cmd.SentAt = time.Now()
  465. // Identify the issuer (JWT username) if present
  466. issuedBy := "token"
  467. if claims := claimsFromHeader(r); claims != nil {
  468. issuedBy = claims.Username
  469. }
  470. // Persist
  471. _ = saveCommand(cmd, issuedBy, "pending")
  472. routersMu.RLock()
  473. router, online := routers[routerID]
  474. routersMu.RUnlock()
  475. if !online || router == nil || !router.Connected {
  476. routerQueuesMu.Lock()
  477. routerQueues[routerID] = append(routerQueues[routerID], cmd)
  478. depth := len(routerQueues[routerID])
  479. routerQueuesMu.Unlock()
  480. log.Printf("queued cmd %s for %s (depth=%d)", cmd.Command, routerID, depth)
  481. _ = updateCommandResult(cmd.ID, "queued", nil)
  482. w.Header().Set("Content-Type", "application/json")
  483. _ = json.NewEncoder(w).Encode(map[string]interface{}{
  484. "command_id": cmd.ID,
  485. "status": "queued",
  486. "queued_for": routerID,
  487. "queue_depth": depth,
  488. })
  489. return
  490. }
  491. resultCh := make(chan CommandResult, 1)
  492. pendingMu.Lock()
  493. pendingCmds[cmd.ID] = resultCh
  494. pendingMu.Unlock()
  495. if err := publish(r.Context(), "router-commands", routerID, cmd); err != nil {
  496. pendingMu.Lock()
  497. delete(pendingCmds, cmd.ID)
  498. pendingMu.Unlock()
  499. _ = updateCommandResult(cmd.ID, "publish_failed", nil)
  500. http.Error(w, "publish failed: "+err.Error(), http.StatusBadGateway)
  501. return
  502. }
  503. _ = updateCommandResult(cmd.ID, "delivered", nil)
  504. log.Printf("cmd %s -> %s (waiting)", cmd.Command, routerID)
  505. w.Header().Set("Content-Type", "application/json")
  506. select {
  507. case res := <-resultCh:
  508. _ = json.NewEncoder(w).Encode(map[string]interface{}{
  509. "command_id": cmd.ID,
  510. "status": "completed",
  511. "success": res.Success,
  512. "output": res.Output,
  513. "error": res.Error,
  514. })
  515. case <-time.After(commandTimeout):
  516. pendingMu.Lock()
  517. delete(pendingCmds, cmd.ID)
  518. pendingMu.Unlock()
  519. _ = updateCommandResult(cmd.ID, "timeout", nil)
  520. _ = json.NewEncoder(w).Encode(map[string]string{
  521. "command_id": cmd.ID,
  522. "status": "timeout",
  523. "error": "router did not respond",
  524. })
  525. }
  526. }
  527. func handleListCommands(w http.ResponseWriter, r *http.Request) {
  528. limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
  529. if limit <= 0 || limit > 500 {
  530. limit = 50
  531. }
  532. routerID := r.URL.Query().Get("router_id")
  533. cmds, err := listCommands(limit, routerID)
  534. if err != nil {
  535. http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
  536. return
  537. }
  538. w.Header().Set("Content-Type", "application/json")
  539. _ = json.NewEncoder(w).Encode(map[string]interface{}{"commands": cmds})
  540. }
  541. func handleMetrics(w http.ResponseWriter, r *http.Request) {
  542. sinceStr := r.URL.Query().Get("since")
  543. since := 1 * time.Hour
  544. if sinceStr != "" {
  545. if d, err := time.ParseDuration(sinceStr); err == nil {
  546. since = d
  547. }
  548. }
  549. w.Header().Set("Content-Type", "application/json")
  550. _ = json.NewEncoder(w).Encode(map[string]interface{}{
  551. "summary": globalMetrics.summary(),
  552. "buckets": globalMetrics.snapshot(since),
  553. "since": since.String(),
  554. })
  555. }
  556. func handleAlerts(w http.ResponseWriter, r *http.Request) {
  557. if r.Method == http.MethodPost {
  558. // Acknowledge: POST /api/alerts/{id}/ack
  559. // Path is set up by the mux (see main)
  560. http.Error(w, "use POST /api/alerts/{id}/ack", http.StatusMethodNotAllowed)
  561. return
  562. }
  563. unack := r.URL.Query().Get("unack") == "1"
  564. limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
  565. if limit <= 0 || limit > 500 {
  566. limit = 100
  567. }
  568. alerts, err := listAlerts(unack, limit)
  569. if err != nil {
  570. http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
  571. return
  572. }
  573. w.Header().Set("Content-Type", "application/json")
  574. _ = json.NewEncoder(w).Encode(map[string]interface{}{"alerts": alerts})
  575. }
  576. func handleAckAlert(w http.ResponseWriter, r *http.Request) {
  577. if r.Method != http.MethodPost {
  578. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  579. return
  580. }
  581. // Extract id from /api/alerts/{id}/ack
  582. parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
  583. if len(parts) < 3 {
  584. http.Error(w, "bad path", http.StatusBadRequest)
  585. return
  586. }
  587. id, err := strconv.ParseInt(parts[2], 10, 64)
  588. if err != nil {
  589. http.Error(w, "bad id", http.StatusBadRequest)
  590. return
  591. }
  592. if err := acknowledgeAlert(id); err != nil {
  593. http.Error(w, err.Error(), http.StatusInternalServerError)
  594. return
  595. }
  596. w.Header().Set("Content-Type", "application/json")
  597. _ = json.NewEncoder(w).Encode(map[string]string{"status": "acknowledged"})
  598. }
  599. func handleMe(w http.ResponseWriter, r *http.Request) {
  600. claims := claimsFromHeader(r)
  601. if claims == nil {
  602. http.Error(w, "unauthorized", http.StatusUnauthorized)
  603. return
  604. }
  605. w.Header().Set("Content-Type", "application/json")
  606. _ = json.NewEncoder(w).Encode(map[string]interface{}{
  607. "user_id": claims.UserID,
  608. "username": claims.Username,
  609. "role": claims.Role,
  610. "expires": claims.ExpiresAt,
  611. })
  612. }
  613. // ----------------------------------------------------------------------------
  614. // Authorisation helpers
  615. // ----------------------------------------------------------------------------
  616. func claimsFromHeader(r *http.Request) *Claims {
  617. auth := r.Header.Get("Authorization")
  618. token := trimBearer(auth)
  619. if token == "" || token == cfg.Token {
  620. return nil
  621. }
  622. claims, err := ParseJWT(token)
  623. if err != nil {
  624. return nil
  625. }
  626. return claims
  627. }
  628. func authorisedAny(r *http.Request) bool {
  629. auth := r.Header.Get("Authorization")
  630. token := trimBearer(auth)
  631. if token == "" {
  632. return false
  633. }
  634. if token == cfg.Token {
  635. return true
  636. }
  637. _, err := ParseJWT(token)
  638. return err == nil
  639. }
  640. func authorisedRouter(r *http.Request) bool {
  641. auth := r.Header.Get("Authorization")
  642. token := trimBearer(auth)
  643. if token == "" {
  644. return false
  645. }
  646. // Routers use the legacy shared TOKEN. JWT users are valid too (in case
  647. // someone scripts an event submission from the dashboard).
  648. if token == cfg.Token {
  649. return true
  650. }
  651. _, err := ParseJWT(token)
  652. return err == nil
  653. }
  654. // ----------------------------------------------------------------------------
  655. // Background jobs
  656. // ----------------------------------------------------------------------------
  657. func startJanitor(ctx context.Context) {
  658. go func() {
  659. t := time.NewTicker(time.Minute)
  660. defer t.Stop()
  661. for {
  662. select {
  663. case <-ctx.Done():
  664. return
  665. case <-t.C:
  666. now := time.Now()
  667. executedMu.Lock()
  668. for id, ts := range executedCmds {
  669. if now.Sub(ts) > idempotencyTTL {
  670. delete(executedCmds, id)
  671. }
  672. }
  673. executedMu.Unlock()
  674. }
  675. }
  676. }()
  677. }
  678. func startOfflineWatcher(ctx context.Context) {
  679. go func() {
  680. t := time.NewTicker(30 * time.Second)
  681. defer t.Stop()
  682. known := map[string]bool{}
  683. for {
  684. select {
  685. case <-ctx.Done():
  686. return
  687. case <-t.C:
  688. routersMu.RLock()
  689. current := map[string]bool{}
  690. for id, rt := range routers {
  691. online := time.Since(rt.LastSeen) < offlineThreshold
  692. current[id] = online
  693. if !online && !known[id] {
  694. createAlert(id, "router_offline", fmt.Sprintf("Router %s has been offline for %s", id, time.Since(rt.LastSeen).Round(time.Second)))
  695. }
  696. }
  697. routersMu.RUnlock()
  698. known = current
  699. }
  700. }
  701. }()
  702. }
  703. // ----------------------------------------------------------------------------
  704. // main
  705. // ----------------------------------------------------------------------------
  706. func main() {
  707. loadConfigFromEnv()
  708. log.SetFlags(log.LstdFlags | log.Lshortfile)
  709. log.Printf("=== client2server v2.1 ===")
  710. log.Printf("port=%d brokers=%v db=%s", cfg.Port, cfg.RedpandaBrokers, cfg.DBPath)
  711. // Ensure DB directory exists
  712. if err := os.MkdirAll(strings.TrimSuffix(cfg.DBPath, "/"+pathBase(cfg.DBPath)), 0755); err != nil {
  713. log.Printf("mkdir db: %v", err)
  714. }
  715. if err := initStore(cfg.DBPath); err != nil {
  716. log.Fatalf("init store: %v", err)
  717. }
  718. ctx, cancel := context.WithCancel(context.Background())
  719. defer cancel()
  720. if err := initRedpanda(ctx); err != nil {
  721. log.Printf("redpanda init failed (continuing): %v", err)
  722. } else {
  723. defer kcl.Close()
  724. // Try to ensure topics exist (best effort)
  725. if err := ensureTopics(ctx); err != nil {
  726. log.Printf("ensure topics: %v", err)
  727. }
  728. // Start the consumer that persists to SQLite
  729. go startConsumer(ctx)
  730. }
  731. startJanitor(ctx)
  732. startOfflineWatcher(ctx)
  733. mux := http.NewServeMux()
  734. // Public
  735. mux.HandleFunc("/health", handleHealth)
  736. mux.HandleFunc("/", handleHealth)
  737. // Auth
  738. mux.HandleFunc("/api/auth/login", handleLogin)
  739. // Router-facing (legacy token OR JWT)
  740. mux.HandleFunc("/api/events", handleHTTPEvent)
  741. mux.HandleFunc("/api/events/", handleHTTPEvent)
  742. mux.HandleFunc("/ws", handleWebSocket)
  743. // Dashboard
  744. mux.HandleFunc("/api/routers", handleRouters)
  745. mux.HandleFunc("/api/events/list", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleListEvents))
  746. mux.HandleFunc("/api/command", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleCommand))
  747. mux.HandleFunc("/api/commands", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleListCommands))
  748. mux.HandleFunc("/api/metrics", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleMetrics))
  749. mux.HandleFunc("/api/alerts", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleAlerts))
  750. mux.HandleFunc("/api/alerts/", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleAckAlert))
  751. mux.HandleFunc("/api/auth/me", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleMe))
  752. mux.HandleFunc("/api/events/stream", handleSSEStream)
  753. srv := &http.Server{
  754. Addr: fmt.Sprintf(":%d", cfg.Port),
  755. Handler: mux,
  756. ReadTimeout: 0,
  757. WriteTimeout: 0,
  758. }
  759. go func() {
  760. sigCh := make(chan os.Signal, 1)
  761. signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
  762. <-sigCh
  763. log.Println("shutting down...")
  764. routersMu.RLock()
  765. for _, r := range routers {
  766. if r.Conn != nil {
  767. r.Conn.Close(websocket.StatusNormalClosure, "server shutdown")
  768. }
  769. }
  770. routersMu.RUnlock()
  771. shutdownCtx, c := context.WithTimeout(context.Background(), 5*time.Second)
  772. defer c()
  773. _ = srv.Shutdown(shutdownCtx)
  774. os.Exit(0)
  775. }()
  776. log.Printf("server ready on :%d", cfg.Port)
  777. if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
  778. log.Fatal(err)
  779. }
  780. }
  781. func pathBase(p string) string {
  782. i := strings.LastIndex(p, "/")
  783. if i < 0 {
  784. return p
  785. }
  786. return p[i+1:]
  787. }
  788. func ensureTopics(ctx context.Context) error {
  789. if kcl == nil {
  790. return nil
  791. }
  792. // Best-effort, log only
  793. log.Println("redpanda: topics will be auto-created on first publish")
  794. return nil
  795. }