Files
tiny-forge/internal/api/event_triggers.go
T
alexei.dolgolyov 7a9ff7ad54 feat(observability): event triggers + log scanner backend
Two paired backends sharing the events.Bus seam:

Event triggers (consumer-side):
- internal/store/event_triggers.go — CRUD with action_secret
  redaction on read (placeholder echo treated as "no change" on
  PATCH so secrets aren't accidentally wiped).
- internal/events/dispatcher.go — bus subscriber, AND-composed
  filters (severity CSV, source CSV, message regex with memoized
  compile cache). Structural loop-prevention: never writes to
  event_log. Sends via notifier.SendPayload.
- internal/notify: SendPayload + SendSyncForTestPayload methods,
  TierEventTrigger constant, doSendRaw shared with the legacy
  Event-shaped path.
- internal/api/event_triggers.go — admin-gated CRUD + /test
  sending the real TriggerWebhookPayload shape. SSRF guard
  rejects loopback / link-local / unspecified targets. PATCH
  uses pointer-typed DTO for partial updates.

Log scanner (producer-side):
- internal/logscanner/ — engine (per-rule cooldown +
  per-container token bucket, atomic drop counters), tail
  (multiplexed docker frame demuxer with TTY fallback + 16 MiB
  payload cap + 1 MiB reassembly cap + RFC3339Nano-validated
  timestamp strip + UTF-8-safe message truncation), manager
  (5s container polling, atomic.Pointer[Snapshot] hot-reload,
  HitEmitter writes event_log + publishes EventLog so the
  trigger dispatcher picks them up immediately).
- internal/docker/container.go — ContainerLogsOpts exposes
  stream selection for stderr-only / stdout-only rules.
- internal/store: log_scan_rules table + CRUD with
  EffectiveLogScanRules resolver (globals minus per-workload
  overrides plus workload-only additions). Transactional
  cascade-delete of overrides when a global rule is removed.
- internal/api/log_scan_rules.go — admin-gated CRUD + /test
  (sample_line → matched/captures) + /stats (drop counters +
  active tail count + last-snapshot compile errors) +
  GET /api/workloads/{id}/effective-rules.

cmd/server/main.go wires both subsystems next to the existing
RegisterPersistentLogger. Coverage spans engine cooldown / bucket
counter tests, snapshot effective-set semantics, manager compile-
error capture, dispatcher matching, store validation +
cascade-delete, API URL validator + secret redaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:18:11 +03:00

326 lines
10 KiB
Go

// Package api: event-trigger HTTP handlers. The dispatcher itself
// lives in internal/events; this file is the REST surface that lets
// operators create, edit, and test triggers from the UI.
package api
import (
"context"
"errors"
"net"
"net/http"
"net/url"
"regexp"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/store"
)
// triggerInput is the JSON shape accepted by POST + PATCH. Pointers
// distinguish "absent" from a zero/empty value so PATCH can leave a
// field unchanged. Required fields on POST are validated explicitly.
type triggerInput struct {
Name *string `json:"name"`
FilterSeverity *string `json:"filter_severity"`
FilterSource *string `json:"filter_source"`
FilterMessageRegex *string `json:"filter_message_regex"`
ActionType *string `json:"action_type"`
ActionTarget *string `json:"action_target"`
ActionSecret *string `json:"action_secret"` // omit = leave unchanged; "" = clear
Enabled *bool `json:"enabled"`
}
// actionSecretPlaceholder is what we return on read to signal "a secret
// is configured" without exposing the actual value. The edit page
// preserves this placeholder verbatim (or replaces it with a new value)
// — the API treats the placeholder as "no change" on PATCH. This is
// the same shape Stripe / GitHub use for their secret read APIs.
const actionSecretPlaceholder = "********"
// listEventTriggers handles GET /api/event-triggers. Secrets are
// redacted to avoid exposing them on read; the edit page shows a
// "configured" indicator when a placeholder is present.
func (s *Server) listEventTriggers(w http.ResponseWriter, r *http.Request) {
out, err := s.store.ListEventTriggers()
if err != nil {
respondError(w, http.StatusInternalServerError, "list event triggers")
return
}
for i := range out {
out[i] = redactTriggerSecret(out[i])
}
respondJSON(w, http.StatusOK, out)
}
// getEventTrigger handles GET /api/event-triggers/{id}.
func (s *Server) getEventTrigger(w http.ResponseWriter, r *http.Request) {
id, ok := parseTriggerID(w, r)
if !ok {
return
}
t, err := s.store.GetEventTrigger(id)
if err != nil {
mapStoreError(w, err, "event trigger")
return
}
respondJSON(w, http.StatusOK, redactTriggerSecret(t))
}
// createEventTrigger handles POST /api/event-triggers.
func (s *Server) createEventTrigger(w http.ResponseWriter, r *http.Request) {
var in triggerInput
if !decodeJSON(w, r, &in) {
return
}
t := store.EventTrigger{
Name: derefString(in.Name),
FilterSeverity: derefString(in.FilterSeverity),
FilterSource: derefString(in.FilterSource),
FilterMessageRegex: derefString(in.FilterMessageRegex),
ActionType: firstNonEmpty(derefString(in.ActionType), store.EventTriggerActionWebhook),
ActionTarget: derefString(in.ActionTarget),
ActionSecret: derefString(in.ActionSecret),
Enabled: in.Enabled == nil || *in.Enabled,
}
if msg := validateTrigger(t); msg != "" {
respondError(w, http.StatusBadRequest, msg)
return
}
out, err := s.store.CreateEventTrigger(t)
if err != nil {
// CreateEventTrigger returns validation-shaped errors plus
// raw DB errors. Validation already ran above, so anything
// here is a server-side problem — surface as 500 and avoid
// echoing driver text to the client.
respondError(w, http.StatusInternalServerError, "create event trigger")
return
}
respondJSON(w, http.StatusCreated, redactTriggerSecret(out))
}
// updateEventTrigger handles PATCH /api/event-triggers/{id}. Each
// field on the input is optional (pointer); absent fields are left
// unchanged. ActionSecret receives special treatment so the read-side
// placeholder round-trips safely.
func (s *Server) updateEventTrigger(w http.ResponseWriter, r *http.Request) {
id, ok := parseTriggerID(w, r)
if !ok {
return
}
existing, err := s.store.GetEventTrigger(id)
if err != nil {
mapStoreError(w, err, "event trigger")
return
}
var in triggerInput
if !decodeJSON(w, r, &in) {
return
}
if in.Name != nil {
existing.Name = *in.Name
}
if in.FilterSeverity != nil {
existing.FilterSeverity = *in.FilterSeverity
}
if in.FilterSource != nil {
existing.FilterSource = *in.FilterSource
}
if in.FilterMessageRegex != nil {
existing.FilterMessageRegex = *in.FilterMessageRegex
}
if in.ActionType != nil && *in.ActionType != "" {
existing.ActionType = *in.ActionType
}
if in.ActionTarget != nil {
existing.ActionTarget = *in.ActionTarget
}
// Secret round-trip: the read API returns a placeholder when a
// secret is configured. If the client echoes the placeholder back
// unchanged we leave the stored secret alone; any other value
// (including the empty string) is treated as a deliberate update.
if in.ActionSecret != nil && *in.ActionSecret != actionSecretPlaceholder {
existing.ActionSecret = *in.ActionSecret
}
if in.Enabled != nil {
existing.Enabled = *in.Enabled
}
if msg := validateTrigger(existing); msg != "" {
respondError(w, http.StatusBadRequest, msg)
return
}
out, err := s.store.UpdateEventTrigger(existing)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "event trigger")
return
}
respondError(w, http.StatusInternalServerError, "update event trigger")
return
}
respondJSON(w, http.StatusOK, redactTriggerSecret(out))
}
// deleteEventTrigger handles DELETE /api/event-triggers/{id}.
func (s *Server) deleteEventTrigger(w http.ResponseWriter, r *http.Request) {
id, ok := parseTriggerID(w, r)
if !ok {
return
}
if err := s.store.DeleteEventTrigger(id); err != nil {
mapStoreError(w, err, "event trigger")
return
}
w.WriteHeader(http.StatusNoContent)
}
// testEventTrigger handles POST /api/event-triggers/{id}/test. Sends
// a real TriggerWebhookPayload to the action target so receivers see
// the same shape they'll see at runtime. Routes through the dedicated
// SendSyncForTestPayload path that preserves the payload through the
// HMAC+HTTP core unchanged.
func (s *Server) testEventTrigger(w http.ResponseWriter, r *http.Request) {
id, ok := parseTriggerID(w, r)
if !ok {
return
}
t, err := s.store.GetEventTrigger(id)
if err != nil {
mapStoreError(w, err, "event trigger")
return
}
if t.ActionType != store.EventTriggerActionWebhook {
respondError(w, http.StatusBadRequest, "action_type not testable")
return
}
now := time.Now().UTC().Format(time.RFC3339)
payload := events.TriggerWebhookPayload{
Type: "event_trigger",
TriggerID: t.ID,
Trigger: t.Name,
Event: events.EventLogPayload{
ID: -1,
Source: "test",
Severity: "info",
Message: "Test event from Tinyforge — trigger=" + t.Name,
Metadata: `{"synthetic":true}`,
CreatedAt: now,
},
Timestamp: now,
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
result := s.notifier.SendSyncForTestPayload(ctx, t.ActionTarget, t.ActionSecret,
notify.TierEventTrigger, "event_trigger", payload)
respondJSON(w, http.StatusOK, result)
}
// validateTrigger runs the full set of invariants over a fully-merged
// trigger row. Called by both create and update so the contract is
// enforced once. Returns an empty string for a valid trigger.
func validateTrigger(t store.EventTrigger) string {
if t.Name == "" {
return "name is required"
}
if t.ActionType != "" && t.ActionType != store.EventTriggerActionWebhook {
return "action_type must be 'webhook'"
}
if t.ActionTarget == "" {
return "action_target is required"
}
if msg := validateWebhookURL(t.ActionTarget); msg != "" {
return msg
}
if t.FilterMessageRegex != "" {
if _, err := regexp.Compile(t.FilterMessageRegex); err != nil {
return "filter_message_regex invalid: " + err.Error()
}
}
return ""
}
// validateWebhookURL guards against the most common SSRF vectors that
// admin-controlled webhook URLs enable: non-http(s) schemes, missing
// host, and internal-network targets (loopback / link-local / RFC1918
// when the hostname resolves to a literal). Hostname-based lookups
// are NOT resolved here — DNS rebinding is out of scope and would
// require enforcement at dispatch time too. Admin gating remains the
// primary control; this is defense-in-depth.
func validateWebhookURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return "action_target invalid URL: " + err.Error()
}
if u.Scheme != "http" && u.Scheme != "https" {
return "action_target must be http:// or https://"
}
host := u.Hostname()
if host == "" {
return "action_target missing host"
}
// Literal-IP guard: block loopback / link-local / unspecified
// addresses outright. RFC1918 private ranges are intentionally
// allowed since same-LAN receivers are a legitimate Tinyforge
// deployment pattern.
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return "action_target points at a reserved/loopback address"
}
}
return ""
}
// redactTriggerSecret returns a copy of t with ActionSecret replaced
// by the placeholder string when a secret is configured. Empty secret
// stays empty so the UI can distinguish "no signing" from "signing
// configured."
func redactTriggerSecret(t store.EventTrigger) store.EventTrigger {
if t.ActionSecret != "" {
t.ActionSecret = actionSecretPlaceholder
}
return t
}
// mapStoreError translates a store-layer error into an HTTP status +
// generic message. ErrNotFound → 404; everything else → 500 without
// echoing driver text to the client (avoids leaking schema details
// or transient error states to API consumers).
func mapStoreError(w http.ResponseWriter, err error, resource string) {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, resource)
return
}
respondError(w, http.StatusInternalServerError, "get "+resource)
}
func parseTriggerID(w http.ResponseWriter, r *http.Request) (int64, bool) {
raw := chi.URLParam(r, "id")
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
respondError(w, http.StatusBadRequest, "invalid event trigger id")
return 0, false
}
return id, true
}
func derefString(p *string) string {
if p == nil {
return ""
}
return *p
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}