feat: 实现可靠事件与证据 connector (#153)
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
)
|
||||
|
||||
type EvidenceClient struct {
|
||||
Endpoint string
|
||||
Signer machine_identity.Signer
|
||||
HTTP interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
}
|
||||
|
||||
func NewEvidenceClient(endpoint string, signer machine_identity.Signer) (*EvidenceClient, error) {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, errors.New("Sense evidence endpoint must be an HTTPS origin without userinfo")
|
||||
}
|
||||
transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second}
|
||||
return &EvidenceClient{Endpoint: strings.TrimRight(endpoint, "/"), Signer: signer, HTTP: &http.Client{Transport: transport, Timeout: 8 * time.Second}}, nil
|
||||
}
|
||||
|
||||
func (c EvidenceClient) Refresh(ctx context.Context, db *gorm.DB, status EvidenceStatus) error {
|
||||
path := "/v1/evidence/" + status.EvidenceID
|
||||
token, err := c.Signer.Mint("yovision-sense", []string{"evidence:read"}, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "machine_identity_error")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Endpoint+path, nil)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_request")
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("X-Request-ID", newCorrelationID())
|
||||
response, err := c.HTTP.Do(request)
|
||||
if err != nil {
|
||||
code := "evidence_unavailable"
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
code = "evidence_timeout"
|
||||
}
|
||||
return c.degrade(db, status, "unavailable", code)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64*1024+1))
|
||||
if readErr != nil || len(body) > 64*1024 {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return c.degrade(db, status, "unavailable", "evidence_not_found")
|
||||
}
|
||||
if response.StatusCode == http.StatusGone {
|
||||
return c.degrade(db, status, "expired", "evidence_expired")
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return c.degrade(db, status, "unavailable", "evidence_unavailable")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
var evidence Evidence
|
||||
if err = decoder.Decode(&evidence); err != nil || evidence.EvidenceID != status.EvidenceID || evidence.OwnerID != status.OwnerID || validateEvidence(evidence) != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
canonical, err := canonicalJSON(body)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var expiresAt *time.Time
|
||||
if evidence.ExpiresAt != "" {
|
||||
parsedExpiry, parseErr := time.Parse(time.RFC3339Nano, evidence.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
parsedExpiry = parsedExpiry.UTC()
|
||||
expiresAt = &parsedExpiry
|
||||
}
|
||||
return db.Model(&EvidenceStatus{}).Where("event_id = ? AND evidence_id = ?", status.EventID, status.EvidenceID).Updates(map[string]any{"status": evidence.Status, "resolution": "current", "current_payload": canonical, "last_error": "", "expires_at": expiresAt, "checked_at": now, "updated_at": now}).Error
|
||||
}
|
||||
|
||||
func (c EvidenceClient) degrade(db *gorm.DB, status EvidenceStatus, resolution, code string) error {
|
||||
now := time.Now().UTC()
|
||||
return db.Model(&EvidenceStatus{}).Where("event_id = ? AND evidence_id = ?", status.EventID, status.EvidenceID).Updates(map[string]any{"resolution": resolution, "last_error": code, "checked_at": now, "updated_at": now}).Error
|
||||
}
|
||||
|
||||
func newCorrelationID() string {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "request-id-fallback"
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func LoadEvidenceClient(getenv func(string) string) (*EvidenceClient, error) {
|
||||
key, err := machine_identity.LoadPrivateKey(getenv("BELL_SENSE_PRIVATE_KEY_PATH"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Bell evidence key: %w", err)
|
||||
}
|
||||
signer := machine_identity.Signer{Principal: strings.TrimSpace(getenv("BELL_SENSE_PRINCIPAL_ID")), KeyID: strings.TrimSpace(getenv("BELL_SENSE_KEY_ID")), PrivateKey: key}
|
||||
return NewEvidenceClient(strings.TrimSpace(getenv("BELL_SENSE_EVIDENCE_ENDPOINT")), signer)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
)
|
||||
|
||||
const MaxRequestBytes = 64 * 1024
|
||||
|
||||
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
|
||||
|
||||
type Handler struct {
|
||||
DB *gorm.DB
|
||||
Verifier machine_identity.Verifier
|
||||
Enabled bool
|
||||
Resolver EvidenceRefresher
|
||||
}
|
||||
|
||||
func (h Handler) Post(c *gin.Context) {
|
||||
if !h.Enabled {
|
||||
writeProblem(c, http.StatusServiceUnavailable, "connector_disabled", "event connector is disabled", "")
|
||||
return
|
||||
}
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
} else if !requestIDPattern.MatchString(requestID) {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_request_id", "X-Request-ID must be an opaque 16-128 character value", "")
|
||||
return
|
||||
}
|
||||
c.Header("X-Request-ID", requestID)
|
||||
if c.Request.URL.RawQuery != "" || c.Request.URL.Fragment != "" || c.Request.URL.EscapedPath() != "/v1/events" {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_request_target", "event request target must be the normalized /v1/events path", "")
|
||||
return
|
||||
}
|
||||
if relayHeader := c.GetHeader("X-YoVision-Relay-ID"); relayHeader != "" {
|
||||
relayID := strings.TrimSpace(relayHeader)
|
||||
if relayID != relayHeader || !validID(relayID) {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_event", "relay identity header is invalid", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, MaxRequestBytes))
|
||||
if err != nil {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_event", "event payload is invalid or too large", "")
|
||||
return
|
||||
}
|
||||
token, err := machine_identity.BearerToken(c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
writeProblem(c, http.StatusUnauthorized, machineErrorCode(err), "machine identity was rejected", "")
|
||||
return
|
||||
}
|
||||
if _, err = h.Verifier.Verify(token, "yovision-bell", "events:ingest", c.Request.Method, c.Request.URL.EscapedPath(), body); err != nil {
|
||||
status := http.StatusUnauthorized
|
||||
code := machineErrorCode(err)
|
||||
if code == "machine_scope_denied" || code == "machine_audience_denied" {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
writeProblem(c, status, code, "machine identity was rejected", "")
|
||||
return
|
||||
}
|
||||
parsed, err := ParseEvent(body)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnsupportedSchema) {
|
||||
writeProblem(c, http.StatusUnprocessableEntity, "unsupported_schema_version", "event schema version is unsupported", "")
|
||||
return
|
||||
}
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_event", "event payload failed validation", "")
|
||||
return
|
||||
}
|
||||
result, err := (Service{DB: h.DB, Resolver: h.Resolver}).Ingest(c.Request.Context(), parsed)
|
||||
if errors.Is(err, ErrIdempotencyConflict) {
|
||||
writeProblem(c, http.StatusConflict, "idempotency_conflict", "idempotency key is already bound to another payload", result.EventID)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeProblem(c, http.StatusServiceUnavailable, "ingest_unavailable", "event ingest is temporarily unavailable", "")
|
||||
return
|
||||
}
|
||||
status := http.StatusCreated
|
||||
if result.Disposition == "duplicate" {
|
||||
status = http.StatusOK
|
||||
}
|
||||
c.JSON(status, result)
|
||||
}
|
||||
|
||||
func writeProblem(c *gin.Context, status int, code, message, existing string) {
|
||||
c.Header("Content-Type", "application/problem+json")
|
||||
c.JSON(status, Problem{Code: code, Message: message, ExistingEventID: existing})
|
||||
}
|
||||
|
||||
func machineErrorCode(err error) string {
|
||||
var machineErr *machine_identity.Error
|
||||
if errors.As(err, &machineErr) {
|
||||
return machineErr.Code
|
||||
}
|
||||
return "machine_token_invalid"
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
EventSchemaVersion = "yovision.event/v1"
|
||||
EvidenceSchemaVersion = "yovision.evidence-reference/v1"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
SiteRef string `json:"site_ref"`
|
||||
DeviceRef string `json:"device_ref"`
|
||||
ProfileRef string `json:"profile_ref"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Severity string `json:"severity"`
|
||||
Rule Rule `json:"rule"`
|
||||
Model Model `json:"model"`
|
||||
Observation Observation `json:"observation"`
|
||||
Region Region `json:"region"`
|
||||
Evidence []Evidence `json:"evidence"`
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type Observation struct {
|
||||
TrackID string `json:"track_id"`
|
||||
Category string `json:"category"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
BBoxNormalized []float64 `json:"bbox_normalized,omitempty"`
|
||||
}
|
||||
|
||||
type Region struct {
|
||||
RegionID string `json:"region_id"`
|
||||
Kind string `json:"kind"`
|
||||
CrossingDirection string `json:"crossing_direction,omitempty"`
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
EvidenceID string `json:"evidence_id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
CapturedAt string `json:"captured_at"`
|
||||
StatusUpdatedAt string `json:"status_updated_at"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Integrity *EvidenceIntegrity `json:"integrity,omitempty"`
|
||||
Failure *EvidenceFailure `json:"failure,omitempty"`
|
||||
}
|
||||
|
||||
type EvidenceIntegrity struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Digest string `json:"digest"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
type EvidenceFailure struct {
|
||||
Code string `json:"code"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
|
||||
type IngestResult struct {
|
||||
EventID string `json:"event_id"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
Disposition string `json:"disposition"`
|
||||
PayloadSHA256 string `json:"payload_sha256"`
|
||||
}
|
||||
|
||||
type Problem struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Field string `json:"field,omitempty"`
|
||||
ExistingEventID string `json:"existing_event_id,omitempty"`
|
||||
}
|
||||
|
||||
type ParsedEvent struct {
|
||||
Event Event
|
||||
Canonical json.RawMessage
|
||||
Digest string
|
||||
Occurred time.Time
|
||||
}
|
||||
|
||||
// EvidenceStatus is mutable Bell-owned resolution metadata kept separately
|
||||
// from the immutable Event and from Alert acknowledgement/close facts.
|
||||
type EvidenceStatus struct {
|
||||
EventID string `gorm:"type:uuid;primaryKey"`
|
||||
EvidenceID string `gorm:"size:128;primaryKey"`
|
||||
OwnerID string `gorm:"size:128;not null;index"`
|
||||
Status string `gorm:"size:16;not null"`
|
||||
Resolution string `gorm:"size:16;not null;index"`
|
||||
CurrentPayload json.RawMessage `gorm:"column:current_payload;type:jsonb;not null"`
|
||||
LastError string `gorm:"size:64;not null;default:''"`
|
||||
ExpiresAt *time.Time `gorm:"index"`
|
||||
CheckedAt *time.Time
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (EvidenceStatus) TableName() string { return "bell_evidence_status" }
|
||||
@@ -0,0 +1,42 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ReplayToken is Bell-owned security state. It is intentionally separate from
|
||||
// business Receipt idempotency and remains effective across process restarts.
|
||||
type ReplayToken struct {
|
||||
Principal string `gorm:"size:128;primaryKey"`
|
||||
TokenID string `gorm:"size:64;primaryKey"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (ReplayToken) TableName() string { return "bell_machine_token_replays" }
|
||||
|
||||
type PersistentReplayStore struct{ DB *gorm.DB }
|
||||
|
||||
func (s PersistentReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
if s.DB == nil {
|
||||
return false
|
||||
}
|
||||
accepted := false
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("expires_at <= ?", now.UTC()).Delete(&ReplayToken{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ReplayToken{
|
||||
Principal: principal, TokenID: tokenID, ExpiresAt: expiresAt.UTC(), CreatedAt: now.UTC(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
accepted = result.RowsAffected == 1
|
||||
return nil
|
||||
})
|
||||
return err == nil && accepted
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
)
|
||||
|
||||
func RegisterRuntime(engine *gin.Engine) error {
|
||||
enabled := strings.EqualFold(strings.TrimSpace(os.Getenv("BELL_EVENT_INGRESS_ENABLED")), "true")
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
db := sdk.Runtime.GetDbByKey("")
|
||||
if db == nil {
|
||||
return fmt.Errorf("Bell event ingress database is unavailable")
|
||||
}
|
||||
if !db.Migrator().HasTable(&ReplayToken{}) || !db.Migrator().HasTable(&EvidenceStatus{}) {
|
||||
return fmt.Errorf("Bell event ingress migration is required")
|
||||
}
|
||||
registry, err := machine_identity.LoadRegistry(os.Getenv("BELL_MACHINE_PRINCIPAL_REGISTRY"), "yovision-bell")
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Bell machine identity registry: %w", err)
|
||||
}
|
||||
var resolver EvidenceRefresher
|
||||
if strings.EqualFold(strings.TrimSpace(os.Getenv("BELL_EVIDENCE_RESOLVER_ENABLED")), "true") {
|
||||
resolver, err = LoadEvidenceClient(os.Getenv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Bell evidence resolver: %w", err)
|
||||
}
|
||||
}
|
||||
handler := Handler{DB: db, Enabled: true, Resolver: resolver, Verifier: machine_identity.Verifier{Registry: registry, Replay: PersistentReplayStore{DB: db}}}
|
||||
engine.POST("/v1/events", handler.Post)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/receipt"
|
||||
)
|
||||
|
||||
var ErrIdempotencyConflict = errors.New("idempotency_conflict")
|
||||
|
||||
type EvidenceRefresher interface {
|
||||
Refresh(context.Context, *gorm.DB, EvidenceStatus) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Resolver EvidenceRefresher
|
||||
}
|
||||
|
||||
func (s Service) Ingest(ctx context.Context, parsed ParsedEvent) (IngestResult, error) {
|
||||
if s.DB == nil {
|
||||
return IngestResult{}, errors.New("event database is unavailable")
|
||||
}
|
||||
var output IngestResult
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
key := fmt.Sprintf("%d:%s:%s", len(parsed.Event.ProducerID), parsed.Event.ProducerID, parsed.Event.SourceEventID)
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var existing struct {
|
||||
EventID string
|
||||
PayloadSHA256 string
|
||||
}
|
||||
err := tx.Model(&receipt.Receipt{}).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Select("event_id", "payload_sha256").Where("producer_id = ? AND source_event_id = ?", parsed.Event.ProducerID, parsed.Event.SourceEventID).First(&existing).Error
|
||||
if err == nil {
|
||||
if existing.PayloadSHA256 != parsed.Digest {
|
||||
output.EventID = existing.EventID
|
||||
return ErrIdempotencyConflict
|
||||
}
|
||||
output = IngestResult{EventID: existing.EventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, Disposition: "duplicate", PayloadSHA256: parsed.Digest}
|
||||
return tx.Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeReplay, ActorID: 0, CreatedAt: time.Now().UTC()}).Error
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
eventID := uuid.NewString()
|
||||
var evidenceRef *string
|
||||
if len(parsed.Event.Evidence) > 0 {
|
||||
value := parsed.Event.Evidence[0].EvidenceID
|
||||
evidenceRef = &value
|
||||
}
|
||||
item := event.Event{ID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID,
|
||||
EventType: parsed.Event.EventType, OccurredAt: parsed.Occurred, Location: parsed.Event.SiteRef + "/" + parsed.Event.DeviceRef,
|
||||
Severity: parsed.Event.Severity, EvidenceRef: evidenceRef, NormalizedPayload: parsed.Canonical, PayloadSHA256: parsed.Digest, ReceivedAt: now}
|
||||
receiptItem := receipt.Receipt{ID: uuid.NewString(), EventID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, AcceptedAt: now}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&receiptItem).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, evidence := range parsed.Event.Evidence {
|
||||
payload, marshalErr := json.Marshal(evidence)
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
canonical, canonicalErr := canonicalJSON(payload)
|
||||
if canonicalErr != nil {
|
||||
return canonicalErr
|
||||
}
|
||||
var expiresAt *time.Time
|
||||
if evidence.ExpiresAt != "" {
|
||||
parsedExpiry, parseErr := time.Parse(time.RFC3339Nano, evidence.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
parsedExpiry = parsedExpiry.UTC()
|
||||
expiresAt = &parsedExpiry
|
||||
}
|
||||
status := EvidenceStatus{EventID: eventID, EvidenceID: evidence.EvidenceID, OwnerID: evidence.OwnerID,
|
||||
Status: evidence.Status, Resolution: "snapshot", CurrentPayload: canonical, ExpiresAt: expiresAt, CreatedAt: now, UpdatedAt: now}
|
||||
if err := tx.Create(&status).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeAccepted, ActorID: 0, CreatedAt: now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
output = IngestResult{EventID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, Disposition: "created", PayloadSHA256: parsed.Digest}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, ErrIdempotencyConflict) {
|
||||
auditErr := s.DB.WithContext(ctx).Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeConflict, ActorID: 0, CreatedAt: time.Now().UTC()}).Error
|
||||
if auditErr != nil {
|
||||
return IngestResult{}, fmt.Errorf("record conflict audit: %w", auditErr)
|
||||
}
|
||||
return output, ErrIdempotencyConflict
|
||||
}
|
||||
if err != nil || s.Resolver == nil {
|
||||
return output, err
|
||||
}
|
||||
var statuses []EvidenceStatus
|
||||
if err = s.DB.WithContext(ctx).Where("event_id = ?", output.EventID).Find(&statuses).Error; err != nil {
|
||||
return IngestResult{}, err
|
||||
}
|
||||
for _, status := range statuses {
|
||||
// Evidence lookup is supplementary. The immutable Event/Receipt boundary
|
||||
// remains accepted even when Sense is unavailable.
|
||||
_ = s.Resolver.Refresh(ctx, s.DB.WithContext(ctx), status)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidEvent = errors.New("invalid_event")
|
||||
ErrUnsupportedSchema = errors.New("unsupported_schema_version")
|
||||
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
||||
hexDigestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
)
|
||||
|
||||
func ParseEvent(raw []byte) (ParsedEvent, error) {
|
||||
var event Event
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: malformed or unknown member", ErrInvalidEvent)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: trailing JSON value", ErrInvalidEvent)
|
||||
}
|
||||
if event.SchemaVersion == "" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.SchemaVersion != EventSchemaVersion {
|
||||
return ParsedEvent{}, ErrUnsupportedSchema
|
||||
}
|
||||
occurred, err := time.Parse("2006-01-02T15:04:05.000Z", event.OccurredAt)
|
||||
if err != nil || !validID(event.ProducerID) || !validID(event.SourceEventID) || !validID(event.SiteRef) ||
|
||||
!validID(event.DeviceRef) || !validID(event.ProfileRef) || !validID(event.Rule.RuleID) ||
|
||||
!validID(event.Observation.TrackID) || !validID(event.Region.RegionID) || event.Rule.Version == "" ||
|
||||
len(event.Rule.Version) > 64 || event.Model.Name == "" || len(event.Model.Name) > 128 ||
|
||||
event.Model.Version == "" || len(event.Model.Version) > 64 || event.Observation.Confidence < 0 ||
|
||||
event.Observation.Confidence > 1 || math.IsNaN(event.Observation.Confidence) || math.IsInf(event.Observation.Confidence, 0) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.EventType != "dangerous_area_entered" && event.EventType != "directional_line_crossed" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Severity != "low" && event.Severity != "medium" && event.Severity != "high" && event.Severity != "critical" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Observation.Category != "person" && event.Observation.Category != "vehicle" && event.Observation.Category != "other" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if len(event.Observation.BBoxNormalized) != 0 && len(event.Observation.BBoxNormalized) != 4 {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
for _, value := range event.Observation.BBoxNormalized {
|
||||
if value < 0 || value > 1 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
}
|
||||
if (event.EventType == "dangerous_area_entered" && (event.Region.Kind != "area" || event.Region.CrossingDirection != "")) ||
|
||||
(event.EventType == "directional_line_crossed" && (event.Region.Kind != "line" || (event.Region.CrossingDirection != "a_to_b" && event.Region.CrossingDirection != "b_to_a"))) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Evidence == nil || len(event.Evidence) > 8 {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
seenEvidence := map[string]bool{}
|
||||
for _, evidence := range event.Evidence {
|
||||
evidenceJSON, marshalErr := json.Marshal(evidence)
|
||||
canonicalEvidence, canonicalErr := canonicalJSON(evidenceJSON)
|
||||
if err := validateEvidence(evidence); err != nil || marshalErr != nil || canonicalErr != nil || seenEvidence[string(canonicalEvidence)] {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
seenEvidence[string(canonicalEvidence)] = true
|
||||
}
|
||||
canonical, err := canonicalJSON(raw)
|
||||
if err != nil {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if containsExplicitNull(raw) {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: optional members must be omitted", ErrInvalidEvent)
|
||||
}
|
||||
digest := sha256.Sum256(canonical)
|
||||
return ParsedEvent{Event: event, Canonical: canonical, Digest: hex.EncodeToString(digest[:]), Occurred: occurred}, nil
|
||||
}
|
||||
|
||||
func validateEvidence(value Evidence) error {
|
||||
if value.SchemaVersion != EvidenceSchemaVersion || !validID(value.EvidenceID) || !validID(value.OwnerID) ||
|
||||
(value.Type != "snapshot" && value.Type != "clip") {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.CapturedAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.StatusUpdatedAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if value.ExpiresAt != "" {
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.ExpiresAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
}
|
||||
switch value.Status {
|
||||
case "pending", "processing":
|
||||
if value.ContentType != "" || value.Integrity != nil || value.Failure != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
case "success":
|
||||
if value.Integrity == nil || value.Failure != nil || (value.ContentType != "image/jpeg" && value.ContentType != "image/png" && value.ContentType != "video/mp4") ||
|
||||
value.Integrity.Algorithm != "sha256" || !hexDigestPattern.MatchString(value.Integrity.Digest) || value.Integrity.SizeBytes < 0 {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
case "failed":
|
||||
if value.Failure == nil || value.ContentType != "" || value.Integrity != nil ||
|
||||
(value.Failure.Code != "capture_failed" && value.Failure.Code != "processing_failed" && value.Failure.Code != "expired" && value.Failure.Code != "unavailable") {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
default:
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalJSON(raw []byte) ([]byte, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := normalizeJCSNumbers(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
encoder := json.NewEncoder(&buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonical := bytes.TrimSuffix(buffer.Bytes(), []byte("\n"))
|
||||
canonical = bytes.ReplaceAll(canonical, []byte(`\u2028`), []byte("\u2028"))
|
||||
canonical = bytes.ReplaceAll(canonical, []byte(`\u2029`), []byte("\u2029"))
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func normalizeJCSNumbers(value any) (any, error) {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
number, err := strconv.ParseFloat(string(typed), 64)
|
||||
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
|
||||
return nil, errors.New("JSON number is outside the RFC 8785 domain")
|
||||
}
|
||||
if number == 0 {
|
||||
return float64(0), nil
|
||||
}
|
||||
return number, nil
|
||||
case []any:
|
||||
for index, item := range typed {
|
||||
normalized, err := normalizeJCSNumbers(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed[index] = normalized
|
||||
}
|
||||
case map[string]any:
|
||||
for key, item := range typed {
|
||||
normalized, err := normalizeJCSNumbers(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed[key] = normalized
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func containsExplicitNull(raw []byte) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if decoder.Decode(&value) != nil {
|
||||
return true
|
||||
}
|
||||
return hasNull(value)
|
||||
}
|
||||
|
||||
func hasNull(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if hasNull(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
for _, item := range typed {
|
||||
if hasNull(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validID(value string) bool {
|
||||
return identifierPattern.MatchString(value) && !strings.ContainsAny(strings.ToLower(value), "\\/@")
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
"go-admin/app/bell/synthetic"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
@@ -32,6 +33,9 @@ func InitRouter() {
|
||||
for _, register := range registrars {
|
||||
register(v1, authMiddleware)
|
||||
}
|
||||
if err := event_ingress.RegisterRuntime(engine); err != nil {
|
||||
log.Errorf("Bell event ingress init error: %v", err)
|
||||
}
|
||||
if synthetic.Enabled(config.ApplicationConfig.Mode, os.Getenv) {
|
||||
registerSyntheticRouter(v1, authMiddleware)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user