149 lines
4.5 KiB
Go
149 lines
4.5 KiB
Go
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)
|
|
}
|