package emission import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" ) const HeartbeatType = "audit-core.heartbeat" // RareClasses is the source-owned inventory in cadence.yaml. func RareClasses() []string { return []string{ClassPrefix + "deny", ClassPrefix + "redact", ClassPrefix + "not_applicable", ClassPrefix + "audit_only"} } type heartbeatData struct { Class string `json:"class"` Assertion string `json:"assertion"` Committed uint64 `json:"committed"` ReleasedUncommitted uint64 `json:"released_uncommitted"` } func (o *Outbox) noteHeartbeat(event Event) { if event.Type != HeartbeatType { return } b, err := json.Marshal(event.Data) if err != nil { return } var data heartbeatData if json.Unmarshal(b, &data) != nil { return } at, err := time.Parse(time.RFC3339Nano, event.OccurredAt) if err == nil { o.heartbeats[data.Class] = at } } // AppendDueHeartbeats commits one heartbeat per rare class at most every 24h. // The timestamp is rebuilt from the log on restart, never from a process timer. func (o *Outbox) AppendDueHeartbeats() error { o.mu.Lock() defer o.mu.Unlock() now := o.clock().UTC() var events []Event for _, class := range RareClasses() { if at, ok := o.heartbeats[class]; ok && now.Sub(at) < 24*time.Hour { continue } id, err := eventID() if err != nil { return err } assertion := "nothing-to-report" if o.uncommitted[class] > 0 { assertion = "released-uncommitted-finding" } events = append(events, Event{ID: id, Type: HeartbeatType, Source: o.source, Subject: o.source, Tenant: PlatformTenant, CorrelationID: id, OccurredAt: now.Format(time.RFC3339Nano), Data: heartbeatData{Class: class, Assertion: assertion, Committed: o.committed[class], ReleasedUncommitted: o.uncommitted[class]}}) } if len(events) == 0 { return nil } return o.appendEvents(now.Format(time.RFC3339Nano), events) } type drainCursor struct { Seq uint64 `json:"seq"` Offset int64 `json:"offset"` } func (o *Outbox) loadCursor() error { file, err := openOwnedFile(filepath.Join(filepath.Dir(o.path), "drain.json"), os.O_RDONLY, 0) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return fmt.Errorf("read drain cursor: %w", err) } defer file.Close() info, err := file.Stat() if err != nil || !info.Mode().IsRegular() { return errors.New("drain cursor must be a regular file") } b, err := io.ReadAll(io.LimitReader(file, 4097)) if err != nil { return err } if len(b) > 4096 { return errors.New("drain cursor exceeds 4096 bytes") } if err := json.Unmarshal(b, &o.cursor); err != nil { return fmt.Errorf("decode drain cursor: %w", err) } if o.cursor.Offset < 0 || (o.cursor.Seq == 0) != (o.cursor.Offset == 0) { return errors.New("invalid drain cursor") } return nil } // nextEntry reads only the committed prefix; mu protects the size snapshot. func (o *Outbox) nextEntry() (entry, int64, error) { f, err := openOwnedFile(o.path, os.O_RDONLY, 0) if err != nil { return entry{}, 0, err } defer f.Close() r := bufio.NewReader(io.NewSectionReader(f, o.cursor.Offset, o.size-o.cursor.Offset)) line, err := boundedLine(r) if err != nil { return entry{}, 0, err } var e entry decoder := json.NewDecoder(bytes.NewReader(line)) decoder.UseNumber() if err := decoder.Decode(&e); err != nil { return entry{}, 0, err } if e.Seq != o.cursor.Seq+1 || e.Event.Source != o.source { return entry{}, 0, errors.New("inconsistent next outbox entry") } return e, o.cursor.Offset + int64(len(line)), nil } func (o *Outbox) saveCursor(cursor drainCursor) error { dir := filepath.Dir(o.path) f, err := os.CreateTemp(dir, ".drain-*") if err != nil { return err } defer os.Remove(f.Name()) b, _ := json.Marshal(cursor) if _, err = f.Write(b); err == nil { err = f.Sync() } err = errors.Join(err, f.Close()) if err != nil { return err } if err = os.Rename(f.Name(), filepath.Join(dir, "drain.json")); err != nil { return err } d, err := os.Open(dir) if err != nil { return err } defer d.Close() return d.Sync() } // DeliveryConfig is supplied only after sender admission. Tokens are read from // a mounted file for each event to support rotation; they never enter logs. type DeliveryConfig struct { URL string TokenFile string } func (c DeliveryConfig) Validate() error { u, err := url.Parse(c.URL) if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "/v1/events" { return errors.New("audit URL must be an explicit http(s) /v1/events endpoint without credentials, query or fragment") } if c.TokenFile == "" { return errors.New("audit delivery requires a token file") } return nil } // Drain sends a bounded batch sequentially. Only 200/202 advance the durable // cursor. Failed or ambiguous requests replay the same event/idempotency key. // No receiver error payload or token is included in an error. func (o *Outbox) Drain(ctx context.Context, config DeliveryConfig, limit int) (int, error) { if err := config.Validate(); err != nil { return 0, err } if limit < 1 { return 0, errors.New("drain limit must be positive") } o.drainMu.Lock() defer o.drainMu.Unlock() client := &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} delivered := 0 for delivered < limit { o.mu.Lock() if o.closed { o.mu.Unlock() return delivered, os.ErrClosed } if o.cursor.Seq == o.seq { o.mu.Unlock() return delivered, nil } e, offset, err := o.nextEntry() o.mu.Unlock() if err != nil { return delivered, fmt.Errorf("read pending event: %w", err) } token, err := os.ReadFile(config.TokenFile) if err != nil { return delivered, errors.New("audit token file unavailable") } value := strings.TrimSpace(string(token)) if value == "" || strings.ContainsAny(value, "\r\n") { return delivered, errors.New("audit token file is empty or invalid") } body, err := json.Marshal(e.Event) if err != nil { return delivered, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.URL, bytes.NewReader(body)) if err != nil { return delivered, errors.New("construct audit request failed") } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+value) req.Header.Set("Idempotency-Key", e.Event.ID) response, err := client.Do(req) if err != nil { return delivered, errors.New("audit delivery failed; event retained") } response.Body.Close() if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusAccepted { return delivered, fmt.Errorf("audit delivery HTTP %d; event retained", response.StatusCode) } o.mu.Lock() cursor := drainCursor{Seq: e.Seq, Offset: offset} err = o.saveCursor(cursor) if err == nil { o.cursor = cursor } o.mu.Unlock() if err != nil { return delivered, fmt.Errorf("persist drain cursor: %w", err) } delivered++ } return delivered, nil }