gomcp/internal/application/sidecar/client.go

163 lines
4.3 KiB
Go

// Copyright 2026 Syntrex Lab. All rights reserved.
// Use of this source code is governed by an Apache-2.0 license
// that can be found in the LICENSE file.
package sidecar
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
domsoc "github.com/syntrex-lab/gomcp/internal/domain/soc"
)
// 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
}