7a9ff7ad54
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>
326 lines
10 KiB
Go
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
|
|
}
|