2026-03-23 16:45:40 +10:00
|
|
|
package sidecar
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-03-31 22:01:51 +10:00
|
|
|
domsoc "github.com/syntrex-lab/gomcp/internal/domain/soc"
|
2026-03-23 16:45:40 +10:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// BusClient sends security events to the SOC Event Bus via HTTP POST.
|
|
|
|
|
type BusClient struct {
|
|
|
|
|
baseURL string
|
|
|
|
|
sensorID string
|
|
|
|
|
apiKey string
|
|
|
|
|
httpClient *http.Client
|
|
|
|
|
maxRetries int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewBusClient creates a client for the SOC Event Bus.
|
|
|
|
|
func NewBusClient(baseURL, sensorID, apiKey string) *BusClient {
|
|
|
|
|
return &BusClient{
|
|
|
|
|
baseURL: baseURL,
|
|
|
|
|
sensorID: sensorID,
|
|
|
|
|
apiKey: apiKey,
|
|
|
|
|
httpClient: &http.Client{
|
|
|
|
|
Timeout: 10 * time.Second,
|
|
|
|
|
Transport: &http.Transport{
|
|
|
|
|
MaxIdleConns: 10,
|
|
|
|
|
IdleConnTimeout: 90 * time.Second,
|
|
|
|
|
MaxIdleConnsPerHost: 5,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
maxRetries: 3,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ingestPayload matches the SOC ingest API expected JSON.
|
|
|
|
|
type ingestPayload struct {
|
|
|
|
|
Source string `json:"source"`
|
|
|
|
|
SensorID string `json:"sensor_id"`
|
|
|
|
|
SensorKey string `json:"sensor_key,omitempty"`
|
|
|
|
|
Severity string `json:"severity"`
|
|
|
|
|
Category string `json:"category"`
|
|
|
|
|
Subcategory string `json:"subcategory,omitempty"`
|
|
|
|
|
Confidence float64 `json:"confidence"`
|
|
|
|
|
Description string `json:"description"`
|
|
|
|
|
SessionID string `json:"session_id,omitempty"`
|
|
|
|
|
Metadata map[string]string `json:"metadata,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SendEvent posts a SOCEvent to the Event Bus.
|
|
|
|
|
// Accepts context for graceful cancellation during retries (L-2 fix).
|
|
|
|
|
func (c *BusClient) SendEvent(ctx context.Context, evt *domsoc.SOCEvent) error {
|
|
|
|
|
payload := ingestPayload{
|
|
|
|
|
Source: string(evt.Source),
|
|
|
|
|
SensorID: c.sensorID,
|
|
|
|
|
SensorKey: c.apiKey,
|
|
|
|
|
Severity: string(evt.Severity),
|
|
|
|
|
Category: evt.Category,
|
|
|
|
|
Subcategory: evt.Subcategory,
|
|
|
|
|
Confidence: evt.Confidence,
|
|
|
|
|
Description: evt.Description,
|
|
|
|
|
SessionID: evt.SessionID,
|
|
|
|
|
Metadata: evt.Metadata,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body, err := json.Marshal(payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("sidecar: marshal event: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := c.baseURL + "/api/v1/soc/events"
|
|
|
|
|
|
|
|
|
|
var lastErr error
|
|
|
|
|
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
|
|
|
|
if attempt > 0 {
|
|
|
|
|
// Context-aware backoff: cancellable during shutdown (H-1 fix).
|
|
|
|
|
backoff := time.Duration(attempt*attempt) * 500 * time.Millisecond
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return fmt.Errorf("sidecar: send cancelled during retry: %w", ctx.Err())
|
|
|
|
|
case <-time.After(backoff):
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("sidecar: create request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
lastErr = err
|
|
|
|
|
slog.Warn("sidecar: bus POST failed, retrying",
|
|
|
|
|
"attempt", attempt+1, "error", err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastErr = fmt.Errorf("bus returned %d", resp.StatusCode)
|
|
|
|
|
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
|
|
|
|
// Client error — don't retry.
|
|
|
|
|
return lastErr
|
|
|
|
|
}
|
|
|
|
|
slog.Warn("sidecar: bus returned server error, retrying",
|
|
|
|
|
"attempt", attempt+1, "status", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fmt.Errorf("sidecar: exhausted retries: %w", lastErr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Heartbeat sends a sensor heartbeat to the Event Bus.
|
|
|
|
|
func (c *BusClient) Heartbeat() error {
|
|
|
|
|
payload := map[string]string{
|
|
|
|
|
"sensor_id": c.sensorID,
|
|
|
|
|
}
|
|
|
|
|
body, err := json.Marshal(payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("sidecar: marshal heartbeat: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := c.baseURL + "/api/soc/sensors/heartbeat"
|
|
|
|
|
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("heartbeat returned %d", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Healthy checks if the bus is reachable (M-4 fix: /healthz not /health).
|
|
|
|
|
func (c *BusClient) Healthy() bool {
|
|
|
|
|
resp, err := c.httpClient.Get(c.baseURL + "/healthz")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
resp.Body.Close()
|
|
|
|
|
return resp.StatusCode == http.StatusOK
|
|
|
|
|
}
|