|
@@ -1,12 +1,29 @@
|
|
|
-// client2server - Go server with WebSocket + Redpanda (Kafka-compatible)
|
|
|
|
|
|
|
+// client2server - Go server with WebSocket + Redpanda + Dashboard API
|
|
|
// Copyright (c) 2026 Luis Rosales - MIT License
|
|
// Copyright (c) 2026 Luis Rosales - MIT License
|
|
|
//
|
|
//
|
|
|
-// Build: go build -o client2server-server .
|
|
|
|
|
-// Run: ./client2server-server
|
|
|
|
|
-// Env: REDPANDA_BROKERS=localhost:9092 TOKEN=*** PORT=3843
|
|
|
|
|
|
|
+// Stack:
|
|
|
|
|
+// - WebSocket: github.com/coder/websocket
|
|
|
|
|
+// - Kafka: github.com/twmb/franz-go (talks to Redpanda)
|
|
|
|
|
+// - Auth: JWT (HS256) with role-based access
|
|
|
|
|
+// - Storage: SQLite (modernc.org/sqlite, pure Go, no cgo)
|
|
|
|
|
+// - Live feed: Server-Sent Events
|
|
|
//
|
|
//
|
|
|
-// WebSocket: ws://localhost:3843/ws
|
|
|
|
|
-// HTTP API: http://localhost:3843/api/{events,routers,command}, /health
|
|
|
|
|
|
|
+// Endpoints:
|
|
|
|
|
+// POST /api/auth/login - username/password -> JWT
|
|
|
|
|
+// GET /api/auth/me - current user
|
|
|
|
|
+// GET /health - liveness
|
|
|
|
|
+// GET /api/routers - known routers (legacy shared token OK)
|
|
|
|
|
+// GET /api/events?limit=&router_id= - event history (auth)
|
|
|
|
|
+// POST /api/events - ingest event (router or hotplug)
|
|
|
|
|
+// POST /api/command - send command to router (auth)
|
|
|
|
|
+// GET /api/commands?router_id= - command history
|
|
|
|
|
+// GET /api/metrics?since=1h - time-series metrics
|
|
|
|
|
+// GET /api/alerts?unack=1 - alerts
|
|
|
|
|
+// POST /api/alerts/:id/ack - acknowledge alert
|
|
|
|
|
+// GET /api/events/stream - SSE live feed
|
|
|
|
|
+// GET /ws - WebSocket from routers (legacy token)
|
|
|
|
|
+//
|
|
|
|
|
+// Env: REDPANDA_BROKERS, TOKEN (legacy shared), JWT_SECRET, DB_PATH, PORT
|
|
|
|
|
|
|
|
package main
|
|
package main
|
|
|
|
|
|
|
@@ -19,6 +36,7 @@ import (
|
|
|
"net/http"
|
|
"net/http"
|
|
|
"os"
|
|
"os"
|
|
|
"os/signal"
|
|
"os/signal"
|
|
|
|
|
+ "strconv"
|
|
|
"strings"
|
|
"strings"
|
|
|
"sync"
|
|
"sync"
|
|
|
"syscall"
|
|
"syscall"
|
|
@@ -26,8 +44,6 @@ import (
|
|
|
|
|
|
|
|
"github.com/coder/websocket"
|
|
"github.com/coder/websocket"
|
|
|
"github.com/google/uuid"
|
|
"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"
|
|
"github.com/twmb/franz-go/pkg/kgo"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
@@ -38,19 +54,22 @@ import (
|
|
|
type Config struct {
|
|
type Config struct {
|
|
|
RedpandaBrokers []string
|
|
RedpandaBrokers []string
|
|
|
Port int
|
|
Port int
|
|
|
- Token string
|
|
|
|
|
|
|
+ Token string // legacy shared token for routers/hotplug
|
|
|
|
|
+ DBPath string
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
var cfg = Config{
|
|
var cfg = Config{
|
|
|
RedpandaBrokers: []string{"localhost:9092"},
|
|
RedpandaBrokers: []string{"localhost:9092"},
|
|
|
Port: 3843,
|
|
Port: 3843,
|
|
|
Token: "secret-token",
|
|
Token: "secret-token",
|
|
|
|
|
+ DBPath: "data/client2server.db",
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func loadConfigFromEnv() {
|
|
func loadConfigFromEnv() {
|
|
|
cfg.RedpandaBrokers = strings.Split(getEnvStr("REDPANDA_BROKERS", "localhost:9092"), ",")
|
|
cfg.RedpandaBrokers = strings.Split(getEnvStr("REDPANDA_BROKERS", "localhost:9092"), ",")
|
|
|
cfg.Port = getEnvInt("PORT", cfg.Port)
|
|
cfg.Port = getEnvInt("PORT", cfg.Port)
|
|
|
cfg.Token = getEnvStr("TOKEN", cfg.Token)
|
|
cfg.Token = getEnvStr("TOKEN", cfg.Token)
|
|
|
|
|
+ cfg.DBPath = getEnvStr("DB_PATH", cfg.DBPath)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func getEnvStr(key, def string) string {
|
|
func getEnvStr(key, def string) string {
|
|
@@ -74,19 +93,17 @@ func getEnvInt(key string, def int) int {
|
|
|
// Domain types
|
|
// Domain types
|
|
|
// ----------------------------------------------------------------------------
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
-// RouterEvent - generic event sent from a router to the server.
|
|
|
|
|
type RouterEvent struct {
|
|
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"
|
|
|
|
|
|
|
+ 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"`
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// RouterCommand - command sent from server to a router.
|
|
|
|
|
type RouterCommand struct {
|
|
type RouterCommand struct {
|
|
|
ID string `json:"id"`
|
|
ID string `json:"id"`
|
|
|
RouterID string `json:"router_id"`
|
|
RouterID string `json:"router_id"`
|
|
@@ -95,7 +112,6 @@ type RouterCommand struct {
|
|
|
SentAt time.Time `json:"sent_at"`
|
|
SentAt time.Time `json:"sent_at"`
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// CommandResult - reply from a router after running a command.
|
|
|
|
|
type CommandResult struct {
|
|
type CommandResult struct {
|
|
|
Success bool `json:"success"`
|
|
Success bool `json:"success"`
|
|
|
Output string `json:"output"`
|
|
Output string `json:"output"`
|
|
@@ -111,18 +127,18 @@ type Router struct {
|
|
|
LastSeen time.Time
|
|
LastSeen time.Time
|
|
|
Conn *websocket.Conn
|
|
Conn *websocket.Conn
|
|
|
Connected bool
|
|
Connected bool
|
|
|
- writeMu sync.Mutex // serialise writes to the WS connection
|
|
|
|
|
|
|
+ writeMu sync.Mutex
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
var (
|
|
var (
|
|
|
routersMu sync.RWMutex
|
|
routersMu sync.RWMutex
|
|
|
routers = make(map[string]*Router)
|
|
routers = make(map[string]*Router)
|
|
|
routerQueuesMu sync.Mutex
|
|
routerQueuesMu sync.Mutex
|
|
|
- routerQueues = make(map[string][]RouterCommand) // queued while offline
|
|
|
|
|
|
|
+ routerQueues = make(map[string][]RouterCommand)
|
|
|
pendingMu sync.Mutex
|
|
pendingMu sync.Mutex
|
|
|
- pendingCmds = make(map[string]chan CommandResult) // command_id -> result chan
|
|
|
|
|
|
|
+ pendingCmds = make(map[string]chan CommandResult)
|
|
|
executedMu sync.Mutex
|
|
executedMu sync.Mutex
|
|
|
- executedCmds = make(map[string]time.Time) // idempotency: cmd_id -> last run
|
|
|
|
|
|
|
+ executedCmds = make(map[string]time.Time)
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
const (
|
|
@@ -134,7 +150,7 @@ const (
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// ----------------------------------------------------------------------------
|
|
|
-// Redpanda (Kafka) client
|
|
|
|
|
|
|
+// Redpanda (Kafka) producer
|
|
|
// ----------------------------------------------------------------------------
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
var kcl *kgo.Client
|
|
var kcl *kgo.Client
|
|
@@ -150,21 +166,6 @@ func initRedpanda(ctx context.Context) error {
|
|
|
return fmt.Errorf("kafka client: %w", err)
|
|
return fmt.Errorf("kafka client: %w", err)
|
|
|
}
|
|
}
|
|
|
kcl = cl
|
|
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
|
|
return nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -174,7 +175,6 @@ func publish(ctx context.Context, topic string, key string, value any) error {
|
|
|
return fmt.Errorf("marshal: %w", err)
|
|
return fmt.Errorf("marshal: %w", err)
|
|
|
}
|
|
}
|
|
|
rec := &kgo.Record{Topic: topic, Key: []byte(key), Value: data}
|
|
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)
|
|
pctx, cancel := context.WithTimeout(ctx, publishTimeout)
|
|
|
defer cancel()
|
|
defer cancel()
|
|
|
res := kcl.ProduceSync(pctx, rec)
|
|
res := kcl.ProduceSync(pctx, rec)
|
|
@@ -185,11 +185,43 @@ func publish(ctx context.Context, topic string, key string, value any) error {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// ----------------------------------------------------------------------------
|
|
|
-// WebSocket handler
|
|
|
|
|
|
|
+// Event ingestion (called by both WS and HTTP)
|
|
|
|
|
+// ----------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+func ingestEvent(ev RouterEvent) {
|
|
|
|
|
+ if ev.ID == "" {
|
|
|
|
|
+ ev.ID = uuid.New().String()
|
|
|
|
|
+ }
|
|
|
|
|
+ if ev.ReceivedAt.IsZero() {
|
|
|
|
|
+ ev.ReceivedAt = time.Now()
|
|
|
|
|
+ }
|
|
|
|
|
+ if ev.Timestamp.IsZero() {
|
|
|
|
|
+ ev.Timestamp = ev.ReceivedAt
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Metrics
|
|
|
|
|
+ globalMetrics.recordEvent(ev.EventType)
|
|
|
|
|
+
|
|
|
|
|
+ // Persist (best-effort)
|
|
|
|
|
+ if err := saveEvent(ev); err != nil {
|
|
|
|
|
+ log.Printf("save event: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Publish to Redpanda
|
|
|
|
|
+ if err := publish(context.Background(), "router-events", ev.RouterID, ev); err != nil {
|
|
|
|
|
+ log.Printf("publish event: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Broadcast to SSE clients
|
|
|
|
|
+ hub.broadcast("event", ev)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ----------------------------------------------------------------------------
|
|
|
|
|
+// WebSocket handler (routers connect here)
|
|
|
// ----------------------------------------------------------------------------
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
|
- // Auth: token in query string (?token=...) or Authorization header
|
|
|
|
|
|
|
+ // Auth: legacy TOKEN only (routers use shared secret, not JWT)
|
|
|
token := r.URL.Query().Get("token")
|
|
token := r.URL.Query().Get("token")
|
|
|
if token == "" {
|
|
if token == "" {
|
|
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
|
@@ -202,7 +234,6 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
|
|
- // Disable permessage-deflate to keep things simple on minimal routers
|
|
|
|
|
CompressionMode: websocket.CompressionDisabled,
|
|
CompressionMode: websocket.CompressionDisabled,
|
|
|
})
|
|
})
|
|
|
if err != nil {
|
|
if err != nil {
|
|
@@ -214,14 +245,14 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
|
ctx, cancel := context.WithCancel(r.Context())
|
|
ctx, cancel := context.WithCancel(r.Context())
|
|
|
defer cancel()
|
|
defer cancel()
|
|
|
|
|
|
|
|
- // First message must be a registration event
|
|
|
|
|
- firstMsg, err := readRouterMessage(ctx, conn)
|
|
|
|
|
|
|
+ // First message is registration
|
|
|
|
|
+ raw, err := readRouterMessage(ctx, conn)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
log.Printf("ws read reg: %v", err)
|
|
log.Printf("ws read reg: %v", err)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
var reg RouterEvent
|
|
var reg RouterEvent
|
|
|
- if err := json.Unmarshal(firstMsg, ®); err != nil {
|
|
|
|
|
|
|
+ if err := json.Unmarshal(raw, ®); err != nil {
|
|
|
log.Printf("ws reg parse: %v", err)
|
|
log.Printf("ws reg parse: %v", err)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -240,10 +271,12 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
|
}
|
|
}
|
|
|
routersMu.Unlock()
|
|
routersMu.Unlock()
|
|
|
|
|
|
|
|
|
|
+ // Mark as back online (clears any offline alert)
|
|
|
|
|
+ acknowledgeRouterAlerts(routerID)
|
|
|
|
|
+
|
|
|
log.Printf("router connected: %s (from %s)", routerID, r.RemoteAddr)
|
|
log.Printf("router connected: %s (from %s)", routerID, r.RemoteAddr)
|
|
|
flushQueuedCommands(ctx, routerID)
|
|
flushQueuedCommands(ctx, routerID)
|
|
|
|
|
|
|
|
- // Message loop
|
|
|
|
|
for {
|
|
for {
|
|
|
raw, err := readRouterMessage(ctx, conn)
|
|
raw, err := readRouterMessage(ctx, conn)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
@@ -256,13 +289,11 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Server-assigned fields
|
|
|
|
|
- ev.ID = uuid.New().String()
|
|
|
|
|
- ev.ReceivedAt = time.Now()
|
|
|
|
|
- ev.Connection = "websocket"
|
|
|
|
|
- if ev.Timestamp.IsZero() {
|
|
|
|
|
- ev.Timestamp = ev.ReceivedAt
|
|
|
|
|
|
|
+ // Inherit router_id if missing
|
|
|
|
|
+ if ev.RouterID == "" {
|
|
|
|
|
+ ev.RouterID = routerID
|
|
|
}
|
|
}
|
|
|
|
|
+ ev.Connection = "websocket"
|
|
|
|
|
|
|
|
// Command result handling
|
|
// Command result handling
|
|
|
if ev.EventType == "command_result" {
|
|
if ev.EventType == "command_result" {
|
|
@@ -274,9 +305,7 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if err := publish(ctx, "router-events", routerID, ev); err != nil {
|
|
|
|
|
- log.Printf("publish event: %v", err)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ ingestEvent(ev)
|
|
|
log.Printf("[%s] %s", routerID, ev.EventType)
|
|
log.Printf("[%s] %s", routerID, ev.EventType)
|
|
|
|
|
|
|
|
routersMu.Lock()
|
|
routersMu.Lock()
|
|
@@ -295,24 +324,10 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func readRouterMessage(ctx context.Context, conn *websocket.Conn) ([]byte, error) {
|
|
func readRouterMessage(ctx context.Context, conn *websocket.Conn) ([]byte, error) {
|
|
|
- // coder/websocket: Read returns a Message
|
|
|
|
|
_, data, err := conn.Read(ctx)
|
|
_, data, err := conn.Read(ctx)
|
|
|
return data, err
|
|
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{}) {
|
|
func deliverCommandResult(cmdID string, payload map[string]interface{}) {
|
|
|
pendingMu.Lock()
|
|
pendingMu.Lock()
|
|
|
ch, ok := pendingCmds[cmdID]
|
|
ch, ok := pendingCmds[cmdID]
|
|
@@ -323,15 +338,17 @@ func deliverCommandResult(cmdID string, payload map[string]interface{}) {
|
|
|
if !ok {
|
|
if !ok {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
- res := CommandResult{
|
|
|
|
|
- Success: payload["success"] == true,
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ res := CommandResult{Success: payload["success"] == true}
|
|
|
if s, ok := payload["output"].(string); ok {
|
|
if s, ok := payload["output"].(string); ok {
|
|
|
res.Output = s
|
|
res.Output = s
|
|
|
}
|
|
}
|
|
|
if s, ok := payload["error"].(string); ok {
|
|
if s, ok := payload["error"].(string); ok {
|
|
|
res.Error = s
|
|
res.Error = s
|
|
|
}
|
|
}
|
|
|
|
|
+ // Persist result to SQLite
|
|
|
|
|
+ _ = updateCommandResult(cmdID, "completed", &res)
|
|
|
|
|
+ globalMetrics.recordCommand(res.Success)
|
|
|
|
|
+
|
|
|
select {
|
|
select {
|
|
|
case ch <- res:
|
|
case ch <- res:
|
|
|
default:
|
|
default:
|
|
@@ -352,46 +369,42 @@ func flushQueuedCommands(ctx context.Context, routerID string) {
|
|
|
pendingMu.Lock()
|
|
pendingMu.Lock()
|
|
|
pendingCmds[cmd.ID] = make(chan CommandResult, 1)
|
|
pendingCmds[cmd.ID] = make(chan CommandResult, 1)
|
|
|
pendingMu.Unlock()
|
|
pendingMu.Unlock()
|
|
|
|
|
+ _ = updateCommandResult(cmd.ID, "delivered", nil)
|
|
|
if err := publish(ctx, "router-commands", routerID, cmd); err != nil {
|
|
if err := publish(ctx, "router-commands", routerID, cmd); err != nil {
|
|
|
log.Printf("queue flush publish: %v", err)
|
|
log.Printf("queue flush publish: %v", err)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+func acknowledgeRouterAlerts(routerID string) {
|
|
|
|
|
+ // Best-effort: mark all unacked offline alerts for this router as resolved
|
|
|
|
|
+ if db == nil {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ _, _ = db.Exec("UPDATE alerts SET acknowledged_at = CURRENT_TIMESTAMP WHERE router_id = ? AND kind = 'router_offline' AND acknowledged_at IS NULL", routerID)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// ----------------------------------------------------------------------------
|
|
// ----------------------------------------------------------------------------
|
|
|
-// HTTP handlers
|
|
|
|
|
|
|
+// HTTP API
|
|
|
// ----------------------------------------------------------------------------
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
-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
|
|
|
|
|
|
|
+func handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ routersMu.RLock()
|
|
|
|
|
+ onlineCount := 0
|
|
|
|
|
+ total := len(routers)
|
|
|
|
|
+ for _, rt := range routers {
|
|
|
|
|
+ if time.Since(rt.LastSeen) < offlineThreshold {
|
|
|
|
|
+ onlineCount++
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
+ routersMu.RUnlock()
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
- _ = json.NewEncoder(w).Encode(map[string]string{
|
|
|
|
|
- "event_id": ev.ID,
|
|
|
|
|
- "router_id": ev.RouterID,
|
|
|
|
|
- "status": "accepted",
|
|
|
|
|
|
|
+ _ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
|
|
|
+ "status": "ok",
|
|
|
|
|
+ "routers": total,
|
|
|
|
|
+ "routers_online": onlineCount,
|
|
|
|
|
+ "redpanda": cfg.RedpandaBrokers,
|
|
|
|
|
+ "version": "2.1.0",
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -417,12 +430,55 @@ func handleRouters(w http.ResponseWriter, r *http.Request) {
|
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"routers": list})
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"routers": list})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+func handleHTTPEvent(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ if r.Method != http.MethodPost {
|
|
|
|
|
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // Accept either JWT or legacy shared TOKEN
|
|
|
|
|
+ if !authorisedRouter(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.Connection = "http"
|
|
|
|
|
+ ingestEvent(ev)
|
|
|
|
|
+ w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
+ _ = json.NewEncoder(w).Encode(map[string]string{
|
|
|
|
|
+ "event_id": ev.ID,
|
|
|
|
|
+ "router_id": ev.RouterID,
|
|
|
|
|
+ "status": "accepted",
|
|
|
|
|
+ })
|
|
|
|
|
+ _ = ev
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func handleListEvents(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
|
|
|
+ if limit <= 0 || limit > 1000 {
|
|
|
|
|
+ limit = 100
|
|
|
|
|
+ }
|
|
|
|
|
+ routerID := r.URL.Query().Get("router_id")
|
|
|
|
|
+ eventType := r.URL.Query().Get("event_type")
|
|
|
|
|
+ events, err := listEvents(limit, routerID, eventType)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
+ _ = json.NewEncoder(w).Encode(map[string]interface{}{"events": events})
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
func handleCommand(w http.ResponseWriter, r *http.Request) {
|
|
func handleCommand(w http.ResponseWriter, r *http.Request) {
|
|
|
if r.Method != http.MethodPost {
|
|
if r.Method != http.MethodPost {
|
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
- if !authorised(r) {
|
|
|
|
|
|
|
+ // JWT or legacy TOKEN
|
|
|
|
|
+ if !authorisedAny(r) {
|
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -463,6 +519,15 @@ func handleCommand(w http.ResponseWriter, r *http.Request) {
|
|
|
cmd.RouterID = routerID
|
|
cmd.RouterID = routerID
|
|
|
cmd.SentAt = time.Now()
|
|
cmd.SentAt = time.Now()
|
|
|
|
|
|
|
|
|
|
+ // Identify the issuer (JWT username) if present
|
|
|
|
|
+ issuedBy := "token"
|
|
|
|
|
+ if claims := claimsFromHeader(r); claims != nil {
|
|
|
|
|
+ issuedBy = claims.Username
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Persist
|
|
|
|
|
+ _ = saveCommand(cmd, issuedBy, "pending")
|
|
|
|
|
+
|
|
|
routersMu.RLock()
|
|
routersMu.RLock()
|
|
|
router, online := routers[routerID]
|
|
router, online := routers[routerID]
|
|
|
routersMu.RUnlock()
|
|
routersMu.RUnlock()
|
|
@@ -473,6 +538,7 @@ func handleCommand(w http.ResponseWriter, r *http.Request) {
|
|
|
depth := len(routerQueues[routerID])
|
|
depth := len(routerQueues[routerID])
|
|
|
routerQueuesMu.Unlock()
|
|
routerQueuesMu.Unlock()
|
|
|
log.Printf("queued cmd %s for %s (depth=%d)", cmd.Command, routerID, depth)
|
|
log.Printf("queued cmd %s for %s (depth=%d)", cmd.Command, routerID, depth)
|
|
|
|
|
+ _ = updateCommandResult(cmd.ID, "queued", nil)
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
|
"command_id": cmd.ID,
|
|
"command_id": cmd.ID,
|
|
@@ -483,7 +549,6 @@ func handleCommand(w http.ResponseWriter, r *http.Request) {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Online: register waiter, publish, wait for result
|
|
|
|
|
resultCh := make(chan CommandResult, 1)
|
|
resultCh := make(chan CommandResult, 1)
|
|
|
pendingMu.Lock()
|
|
pendingMu.Lock()
|
|
|
pendingCmds[cmd.ID] = resultCh
|
|
pendingCmds[cmd.ID] = resultCh
|
|
@@ -493,10 +558,11 @@ func handleCommand(w http.ResponseWriter, r *http.Request) {
|
|
|
pendingMu.Lock()
|
|
pendingMu.Lock()
|
|
|
delete(pendingCmds, cmd.ID)
|
|
delete(pendingCmds, cmd.ID)
|
|
|
pendingMu.Unlock()
|
|
pendingMu.Unlock()
|
|
|
- log.Printf("publish command: %v", err)
|
|
|
|
|
- http.Error(w, "publish failed", http.StatusBadGateway)
|
|
|
|
|
|
|
+ _ = updateCommandResult(cmd.ID, "publish_failed", nil)
|
|
|
|
|
+ http.Error(w, "publish failed: "+err.Error(), http.StatusBadGateway)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
+ _ = updateCommandResult(cmd.ID, "delivered", nil)
|
|
|
|
|
|
|
|
log.Printf("cmd %s -> %s (waiting)", cmd.Command, routerID)
|
|
log.Printf("cmd %s -> %s (waiting)", cmd.Command, routerID)
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
@@ -514,6 +580,7 @@ func handleCommand(w http.ResponseWriter, r *http.Request) {
|
|
|
pendingMu.Lock()
|
|
pendingMu.Lock()
|
|
|
delete(pendingCmds, cmd.ID)
|
|
delete(pendingCmds, cmd.ID)
|
|
|
pendingMu.Unlock()
|
|
pendingMu.Unlock()
|
|
|
|
|
+ _ = updateCommandResult(cmd.ID, "timeout", nil)
|
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
|
"command_id": cmd.ID,
|
|
"command_id": cmd.ID,
|
|
|
"status": "timeout",
|
|
"status": "timeout",
|
|
@@ -522,38 +589,144 @@ func handleCommand(w http.ResponseWriter, r *http.Request) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-func handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
- routersMu.RLock()
|
|
|
|
|
- onlineCount := 0
|
|
|
|
|
- for _, rt := range routers {
|
|
|
|
|
- if time.Since(rt.LastSeen) < offlineThreshold {
|
|
|
|
|
- onlineCount++
|
|
|
|
|
|
|
+func handleListCommands(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
|
|
|
+ if limit <= 0 || limit > 500 {
|
|
|
|
|
+ limit = 50
|
|
|
|
|
+ }
|
|
|
|
|
+ routerID := r.URL.Query().Get("router_id")
|
|
|
|
|
+ cmds, err := listCommands(limit, routerID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
+ _ = json.NewEncoder(w).Encode(map[string]interface{}{"commands": cmds})
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func handleMetrics(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ sinceStr := r.URL.Query().Get("since")
|
|
|
|
|
+ since := 1 * time.Hour
|
|
|
|
|
+ if sinceStr != "" {
|
|
|
|
|
+ if d, err := time.ParseDuration(sinceStr); err == nil {
|
|
|
|
|
+ since = d
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- total := len(routers)
|
|
|
|
|
- routersMu.RUnlock()
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
|
- "status": "ok",
|
|
|
|
|
- "routers": total,
|
|
|
|
|
- "routers_online": onlineCount,
|
|
|
|
|
- "redpanda": cfg.RedpandaBrokers,
|
|
|
|
|
|
|
+ "summary": globalMetrics.summary(),
|
|
|
|
|
+ "buckets": globalMetrics.snapshot(since),
|
|
|
|
|
+ "since": since.String(),
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func handleAlerts(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ if r.Method == http.MethodPost {
|
|
|
|
|
+ // Acknowledge: POST /api/alerts/{id}/ack
|
|
|
|
|
+ // Path is set up by the mux (see main)
|
|
|
|
|
+ http.Error(w, "use POST /api/alerts/{id}/ack", http.StatusMethodNotAllowed)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ unack := r.URL.Query().Get("unack") == "1"
|
|
|
|
|
+ limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
|
|
|
+ if limit <= 0 || limit > 500 {
|
|
|
|
|
+ limit = 100
|
|
|
|
|
+ }
|
|
|
|
|
+ alerts, err := listAlerts(unack, limit)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
+ _ = json.NewEncoder(w).Encode(map[string]interface{}{"alerts": alerts})
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func handleAckAlert(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ if r.Method != http.MethodPost {
|
|
|
|
|
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // Extract id from /api/alerts/{id}/ack
|
|
|
|
|
+ parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
|
|
|
|
+ if len(parts) < 3 {
|
|
|
|
|
+ http.Error(w, "bad path", http.StatusBadRequest)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ id, err := strconv.ParseInt(parts[2], 10, 64)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ http.Error(w, "bad id", http.StatusBadRequest)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := acknowledgeAlert(id); err != nil {
|
|
|
|
|
+ http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
+ _ = json.NewEncoder(w).Encode(map[string]string{"status": "acknowledged"})
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func handleMe(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ claims := claimsFromHeader(r)
|
|
|
|
|
+ if claims == nil {
|
|
|
|
|
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
+ _ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
|
|
|
+ "user_id": claims.UserID,
|
|
|
|
|
+ "username": claims.Username,
|
|
|
|
|
+ "role": claims.Role,
|
|
|
|
|
+ "expires": claims.ExpiresAt,
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-func authorised(r *http.Request) bool {
|
|
|
|
|
- h := r.Header.Get("Authorization")
|
|
|
|
|
- if h == cfg.Token { // legacy: raw token
|
|
|
|
|
|
|
+// ----------------------------------------------------------------------------
|
|
|
|
|
+// Authorisation helpers
|
|
|
|
|
+// ----------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+func claimsFromHeader(r *http.Request) *Claims {
|
|
|
|
|
+ auth := r.Header.Get("Authorization")
|
|
|
|
|
+ token := trimBearer(auth)
|
|
|
|
|
+ if token == "" || token == cfg.Token {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+ claims, err := ParseJWT(token)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+ return claims
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func authorisedAny(r *http.Request) bool {
|
|
|
|
|
+ auth := r.Header.Get("Authorization")
|
|
|
|
|
+ token := trimBearer(auth)
|
|
|
|
|
+ if token == "" {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ if token == cfg.Token {
|
|
|
return true
|
|
return true
|
|
|
}
|
|
}
|
|
|
- if strings.HasPrefix(h, "Bearer ") && strings.TrimPrefix(h, "Bearer ") == cfg.Token {
|
|
|
|
|
|
|
+ _, err := ParseJWT(token)
|
|
|
|
|
+ return err == nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func authorisedRouter(r *http.Request) bool {
|
|
|
|
|
+ auth := r.Header.Get("Authorization")
|
|
|
|
|
+ token := trimBearer(auth)
|
|
|
|
|
+ if token == "" {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ // Routers use the legacy shared TOKEN. JWT users are valid too (in case
|
|
|
|
|
+ // someone scripts an event submission from the dashboard).
|
|
|
|
|
+ if token == cfg.Token {
|
|
|
return true
|
|
return true
|
|
|
}
|
|
}
|
|
|
- return false
|
|
|
|
|
|
|
+ _, err := ParseJWT(token)
|
|
|
|
|
+ return err == nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// ----------------------------------------------------------------------------
|
|
|
-// Background cleanup
|
|
|
|
|
|
|
+// Background jobs
|
|
|
// ----------------------------------------------------------------------------
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
func startJanitor(ctx context.Context) {
|
|
func startJanitor(ctx context.Context) {
|
|
@@ -578,6 +751,32 @@ func startJanitor(ctx context.Context) {
|
|
|
}()
|
|
}()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+func startOfflineWatcher(ctx context.Context) {
|
|
|
|
|
+ go func() {
|
|
|
|
|
+ t := time.NewTicker(30 * time.Second)
|
|
|
|
|
+ defer t.Stop()
|
|
|
|
|
+ known := map[string]bool{}
|
|
|
|
|
+ for {
|
|
|
|
|
+ select {
|
|
|
|
|
+ case <-ctx.Done():
|
|
|
|
|
+ return
|
|
|
|
|
+ case <-t.C:
|
|
|
|
|
+ routersMu.RLock()
|
|
|
|
|
+ current := map[string]bool{}
|
|
|
|
|
+ for id, rt := range routers {
|
|
|
|
|
+ online := time.Since(rt.LastSeen) < offlineThreshold
|
|
|
|
|
+ current[id] = online
|
|
|
|
|
+ if !online && !known[id] {
|
|
|
|
|
+ createAlert(id, "router_offline", fmt.Sprintf("Router %s has been offline for %s", id, time.Since(rt.LastSeen).Round(time.Second)))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ routersMu.RUnlock()
|
|
|
|
|
+ known = current
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// ----------------------------------------------------------------------------
|
|
// ----------------------------------------------------------------------------
|
|
|
// main
|
|
// main
|
|
|
// ----------------------------------------------------------------------------
|
|
// ----------------------------------------------------------------------------
|
|
@@ -585,8 +784,16 @@ func startJanitor(ctx context.Context) {
|
|
|
func main() {
|
|
func main() {
|
|
|
loadConfigFromEnv()
|
|
loadConfigFromEnv()
|
|
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
|
- log.Printf("=== client2server Go Server ===")
|
|
|
|
|
- log.Printf("port=%d brokers=%v", cfg.Port, cfg.RedpandaBrokers)
|
|
|
|
|
|
|
+ log.Printf("=== client2server v2.1 ===")
|
|
|
|
|
+ log.Printf("port=%d brokers=%v db=%s", cfg.Port, cfg.RedpandaBrokers, cfg.DBPath)
|
|
|
|
|
+
|
|
|
|
|
+ // Ensure DB directory exists
|
|
|
|
|
+ if err := os.MkdirAll(strings.TrimSuffix(cfg.DBPath, "/"+pathBase(cfg.DBPath)), 0755); err != nil {
|
|
|
|
|
+ log.Printf("mkdir db: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := initStore(cfg.DBPath); err != nil {
|
|
|
|
|
+ log.Fatalf("init store: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
defer cancel()
|
|
defer cancel()
|
|
@@ -595,25 +802,49 @@ func main() {
|
|
|
log.Printf("redpanda init failed (continuing): %v", err)
|
|
log.Printf("redpanda init failed (continuing): %v", err)
|
|
|
} else {
|
|
} else {
|
|
|
defer kcl.Close()
|
|
defer kcl.Close()
|
|
|
|
|
+ // Try to ensure topics exist (best effort)
|
|
|
|
|
+ if err := ensureTopics(ctx); err != nil {
|
|
|
|
|
+ log.Printf("ensure topics: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ // Start the consumer that persists to SQLite
|
|
|
|
|
+ go startConsumer(ctx)
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
startJanitor(ctx)
|
|
startJanitor(ctx)
|
|
|
|
|
+ startOfflineWatcher(ctx)
|
|
|
|
|
|
|
|
mux := http.NewServeMux()
|
|
mux := http.NewServeMux()
|
|
|
|
|
+
|
|
|
|
|
+ // Public
|
|
|
mux.HandleFunc("/health", handleHealth)
|
|
mux.HandleFunc("/health", handleHealth)
|
|
|
|
|
+ mux.HandleFunc("/", handleHealth)
|
|
|
|
|
+
|
|
|
|
|
+ // Auth
|
|
|
|
|
+ mux.HandleFunc("/api/auth/login", handleLogin)
|
|
|
|
|
+
|
|
|
|
|
+ // Router-facing (legacy token OR JWT)
|
|
|
mux.HandleFunc("/api/events", handleHTTPEvent)
|
|
mux.HandleFunc("/api/events", handleHTTPEvent)
|
|
|
- mux.HandleFunc("/api/routers", handleRouters)
|
|
|
|
|
- mux.HandleFunc("/api/command", handleCommand)
|
|
|
|
|
|
|
+ mux.HandleFunc("/api/events/", handleHTTPEvent)
|
|
|
mux.HandleFunc("/ws", handleWebSocket)
|
|
mux.HandleFunc("/ws", handleWebSocket)
|
|
|
- mux.HandleFunc("/", handleHealth)
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // Dashboard
|
|
|
|
|
+ mux.HandleFunc("/api/routers", handleRouters)
|
|
|
|
|
+ mux.HandleFunc("/api/events/list", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleListEvents))
|
|
|
|
|
+ mux.HandleFunc("/api/command", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleCommand))
|
|
|
|
|
+ mux.HandleFunc("/api/commands", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleListCommands))
|
|
|
|
|
+ mux.HandleFunc("/api/metrics", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleMetrics))
|
|
|
|
|
+ mux.HandleFunc("/api/alerts", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleAlerts))
|
|
|
|
|
+ mux.HandleFunc("/api/alerts/", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleAckAlert))
|
|
|
|
|
+ mux.HandleFunc("/api/auth/me", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleMe))
|
|
|
|
|
+ mux.HandleFunc("/api/events/stream", handleSSEStream)
|
|
|
|
|
|
|
|
srv := &http.Server{
|
|
srv := &http.Server{
|
|
|
Addr: fmt.Sprintf(":%d", cfg.Port),
|
|
Addr: fmt.Sprintf(":%d", cfg.Port),
|
|
|
Handler: mux,
|
|
Handler: mux,
|
|
|
- ReadTimeout: 0, // WS connections are long-lived
|
|
|
|
|
|
|
+ ReadTimeout: 0,
|
|
|
WriteTimeout: 0,
|
|
WriteTimeout: 0,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Graceful shutdown
|
|
|
|
|
go func() {
|
|
go func() {
|
|
|
sigCh := make(chan os.Signal, 1)
|
|
sigCh := make(chan os.Signal, 1)
|
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
@@ -637,3 +868,20 @@ func main() {
|
|
|
log.Fatal(err)
|
|
log.Fatal(err)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+func pathBase(p string) string {
|
|
|
|
|
+ i := strings.LastIndex(p, "/")
|
|
|
|
|
+ if i < 0 {
|
|
|
|
|
+ return p
|
|
|
|
|
+ }
|
|
|
|
|
+ return p[i+1:]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func ensureTopics(ctx context.Context) error {
|
|
|
|
|
+ if kcl == nil {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+ // Best-effort, log only
|
|
|
|
|
+ log.Println("redpanda: topics will be auto-created on first publish")
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|