Files

149 lines
4.5 KiB
Go
Raw Permalink Normal View History

2026-08-26 06:41:07 +08:00
package mockalertreceiver
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const maxBodyBytes = 64 * 1024
type Options struct {
Secret string
ClockSkew time.Duration
}
type Event struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
TestRef string `json:"test_ref,omitempty"`
Data json.RawMessage `json:"data"`
ReceivedAt time.Time `json:"received_at"`
}
type envelope struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
Data json.RawMessage `json:"data"`
}
type Server struct {
secret []byte
clockSkew time.Duration
logger *slog.Logger
now func() time.Time
mu sync.RWMutex
events []Event
}
func New(options Options, logger *slog.Logger) (*Server, error) {
options.Secret = strings.TrimSpace(options.Secret)
if len(options.Secret) < 32 {
return nil, errors.New("mock alert receiver secret must contain at least 32 characters")
}
if options.ClockSkew <= 0 {
return nil, errors.New("mock alert receiver clock skew must be positive")
}
if logger == nil {
return nil, errors.New("mock alert receiver logger is required")
}
return &Server{secret: []byte(options.Secret), clockSkew: options.ClockSkew, logger: logger, now: time.Now, events: []Event{}}, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health/live", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "alive", "service": "mock-alert-receiver"})
})
mux.HandleFunc("POST /webhooks/alerts", s.receive)
mux.HandleFunc("GET /events", s.list)
return mux
}
func (s *Server) receive(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
if err != nil || len(body) > maxBodyBytes {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
timestamp := strings.TrimSpace(r.Header.Get("X-Voice-Timestamp"))
eventID := strings.TrimSpace(r.Header.Get("X-Voice-Event-ID"))
unixSeconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || eventID == "" || s.now().UTC().Sub(time.Unix(unixSeconds, 0).UTC()).Abs() > s.clockSkew || !validSignature(s.secret, timestamp, body, r.Header.Get("X-Voice-Signature")) {
http.Error(w, "invalid webhook authentication", http.StatusUnauthorized)
return
}
var incoming envelope
if err = json.Unmarshal(body, &incoming); err != nil || incoming.EventID != eventID || incoming.EventType == "" || !json.Valid(incoming.Data) {
http.Error(w, "invalid webhook envelope", http.StatusBadRequest)
return
}
var metadata struct {
TestRef string `json:"test_ref"`
}
_ = json.Unmarshal(incoming.Data, &metadata)
received := Event{EventID: incoming.EventID, EventType: incoming.EventType, TestRef: metadata.TestRef, Data: incoming.Data, ReceivedAt: s.now().UTC()}
s.mu.Lock()
found := false
for index := range s.events {
if s.events[index].EventID == received.EventID {
s.events[index] = received
found = true
break
}
}
if !found {
s.events = append([]Event{received}, s.events...)
if len(s.events) > 100 {
s.events = s.events[:100]
}
}
s.mu.Unlock()
s.logger.InfoContext(r.Context(), "mock alert webhook received", "event_id", received.EventID, "event_type", received.EventType, "test_ref", received.TestRef)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) list(w http.ResponseWriter, r *http.Request) {
testRef := strings.TrimSpace(r.URL.Query().Get("test_ref"))
s.mu.RLock()
items := make([]Event, 0, len(s.events))
for _, item := range s.events {
if testRef == "" || item.TestRef == testRef {
items = append(items, item)
}
}
s.mu.RUnlock()
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func validSignature(secret []byte, timestamp string, body []byte, provided string) bool {
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(timestamp))
_, _ = mac.Write([]byte("."))
_, _ = mac.Write(body)
want := mac.Sum(nil)
provided = strings.TrimSpace(provided)
if !strings.HasPrefix(provided, "v1=") {
return false
}
provided = strings.TrimPrefix(provided, "v1=")
got, err := hex.DecodeString(provided)
return err == nil && hmac.Equal(want, got)
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}