372 lines
12 KiB
Go
372 lines
12 KiB
Go
// Package server serves the API (/in, /out, /counters) behind the HTTP
|
|
// validation layer, and the admin endpoints (/health, /metrics).
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
|
|
"migadu_counters/internal/api"
|
|
"migadu_counters/internal/counting"
|
|
"migadu_counters/internal/domains"
|
|
"migadu_counters/internal/metrics"
|
|
"migadu_counters/internal/notify"
|
|
"migadu_counters/internal/store"
|
|
"migadu_counters/internal/txlog"
|
|
)
|
|
|
|
// Deps wires the server to the rest of the daemon.
|
|
type Deps struct {
|
|
AuthToken string
|
|
AllowedCIDRs []netip.Prefix
|
|
HandlerTimeout time.Duration // bounds the synchronous part (validation) and each queued job
|
|
QueueSize int // counting queue capacity (default 10 000)
|
|
Workers int // counting workers (default 8)
|
|
Exempt map[string]bool
|
|
|
|
Counter *counting.Counter
|
|
Store *store.Store
|
|
Domains func() *domains.DB // current CDB (swapped on reload); nil = not loaded
|
|
Notifier *notify.Notifier // nil = never notify
|
|
TxLog *txlog.Logger // nil = transaction log disabled
|
|
Metrics *metrics.Metrics
|
|
Gatherer prometheus.Gatherer // served on /metrics; nil = no endpoint
|
|
Logger *slog.Logger
|
|
Now func() time.Time
|
|
}
|
|
|
|
// Server holds the handlers and the counting queue.
|
|
type Server struct {
|
|
d Deps
|
|
queue *counting.Queue
|
|
|
|
panicHook func() // tests: injected into the synchronous path
|
|
}
|
|
|
|
// New applies defaults and starts the counting workers.
|
|
func New(d Deps) *Server {
|
|
if d.Now == nil {
|
|
d.Now = time.Now
|
|
}
|
|
if d.HandlerTimeout <= 0 {
|
|
d.HandlerTimeout = 4 * time.Second
|
|
}
|
|
d.Logger = d.logger()
|
|
s := &Server{d: d}
|
|
obs := counting.Observer{
|
|
Panic: func(job counting.Job, p any, stack []byte) {
|
|
s.d.Logger.Error("panic while counting", "panic", fmt.Sprint(p), "route", job.Route, "trace_id", job.TraceID, "stack", string(stack))
|
|
},
|
|
}
|
|
if d.Metrics != nil {
|
|
obs.Depth = d.Metrics.SetQueueDepth
|
|
obs.Wait = func(w time.Duration) { d.Metrics.ObserveQueueWait(w.Seconds()) }
|
|
}
|
|
s.queue = counting.NewQueue(counting.QueueConfig{Size: d.QueueSize, Workers: d.Workers, Timeout: d.HandlerTimeout}, s.process, obs)
|
|
return s
|
|
}
|
|
|
|
// Drain stops accepting jobs and waits for the queued ones, bounded by ctx.
|
|
// Call it after the HTTP server stopped and before the notifier shuts down.
|
|
func (s *Server) Drain(ctx context.Context) error { return s.queue.Drain(ctx) }
|
|
|
|
// Pending is the number of accepted jobs not yet counted.
|
|
func (s *Server) Pending() int { return s.queue.Pending() }
|
|
|
|
// Depth is the number of jobs waiting for a worker.
|
|
func (s *Server) Depth() int { return s.queue.Depth() }
|
|
|
|
// NotifyHooks wires notification outcomes into the transaction log and the
|
|
// metrics; used by the daemon and by tests.
|
|
func NotifyHooks(tx *txlog.Logger, m *metrics.Metrics) notify.Hooks {
|
|
return notify.Hooks{
|
|
Sent: func(ev notify.Event, url string, attempt int) {
|
|
if m != nil {
|
|
m.RecordNotification(ev.Level, "sent")
|
|
}
|
|
if tx != nil {
|
|
tx.NotificationSent(ev, url, attempt)
|
|
}
|
|
},
|
|
Failed: func(ev notify.Event, url string, attempts int, err error) {
|
|
if m != nil {
|
|
m.RecordNotification(ev.Level, "failed")
|
|
}
|
|
if tx != nil {
|
|
tx.NotificationFailed(ev, url, attempts, err)
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
// Handler returns the API handler: recover → metrics → timeout → routes.
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.Handle("/out", s.route("/out", http.MethodPost, s.handleOut))
|
|
mux.Handle("/in", s.route("/in", http.MethodPost, s.handleIn))
|
|
mux.Handle("/counters", s.route("/counters", http.MethodGet, s.handleCounters))
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
})
|
|
timed := http.TimeoutHandler(mux, s.d.HandlerTimeout, `{"error":"request timeout"}`)
|
|
return s.recoverer(s.observe(timed))
|
|
}
|
|
|
|
// AdminHandler returns /health and /metrics (no auth; bind admin_addr locally).
|
|
func (s *Server) AdminHandler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/health", s.handleHealth)
|
|
if s.d.Gatherer != nil {
|
|
mux.Handle("/metrics", promhttp.HandlerFor(s.d.Gatherer, promhttp.HandlerOpts{}))
|
|
}
|
|
return mux
|
|
}
|
|
|
|
func (s *Server) handleOut(w http.ResponseWriter, r *http.Request) {
|
|
var req api.OutRequest
|
|
if e := decodeJSON(r, &req); e != nil {
|
|
s.reject(w, r, "/out", e)
|
|
return
|
|
}
|
|
v, err := api.ValidateOut(req)
|
|
if err != nil {
|
|
s.reject(w, r, "/out", &httpError{http.StatusBadRequest, err.Error()})
|
|
return
|
|
}
|
|
job := counting.Job{
|
|
Route: counting.RouteOut, TraceID: v.TraceID, Received: s.d.Now(),
|
|
AuthUser: v.AuthUser.Value, LoginSuffix: v.LoginSuffix,
|
|
MailFrom: v.MailFrom, MailFromUsable: v.MailFromUsable,
|
|
Recipient: v.Recipient.Value,
|
|
}
|
|
if !v.AuthUser.Usable() {
|
|
job.SenderUnusable = counting.Unusable{Role: "sender", Value: v.AuthUser.Raw, Reason: v.AuthUser.Reason}
|
|
}
|
|
if !v.Recipient.Usable() {
|
|
job.RecipientUnusable = counting.Unusable{Role: "recipient", Value: v.Recipient.Raw, Reason: v.Recipient.Reason}
|
|
}
|
|
s.enqueue(w, r, "/out", job)
|
|
}
|
|
|
|
func (s *Server) handleIn(w http.ResponseWriter, r *http.Request) {
|
|
var req api.InRequest
|
|
if e := decodeJSON(r, &req); e != nil {
|
|
s.reject(w, r, "/in", e)
|
|
return
|
|
}
|
|
v, err := api.ValidateIn(req)
|
|
if err != nil {
|
|
s.reject(w, r, "/in", &httpError{http.StatusBadRequest, err.Error()})
|
|
return
|
|
}
|
|
s.enqueue(w, r, "/in", counting.Job{
|
|
Route: counting.RouteIn, TraceID: v.TraceID, Received: s.d.Now(),
|
|
MailFrom: v.MailFrom, MailFromUsable: v.MailFromUsable, Recipient: v.Recipient, Junk: v.Junk,
|
|
})
|
|
}
|
|
|
|
// enqueue hands the validated job to the workers and answers 202; a full
|
|
// queue answers 503 and the drop is counted and logged.
|
|
func (s *Server) enqueue(w http.ResponseWriter, r *http.Request, route string, job counting.Job) {
|
|
if s.panicHook != nil {
|
|
s.panicHook()
|
|
}
|
|
if !s.queue.Enqueue(job) {
|
|
if s.d.Metrics != nil {
|
|
s.d.Metrics.RecordDropped(route)
|
|
}
|
|
if s.d.TxLog != nil {
|
|
s.d.TxLog.Dropped(job.Route, job.TraceID)
|
|
}
|
|
s.d.Logger.Error("counting queue full, request dropped", "route", route, "trace_id", job.TraceID, "remote", r.RemoteAddr)
|
|
writeError(w, http.StatusServiceUnavailable, "queue full")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, api.CountResponse{Accepted: true})
|
|
}
|
|
|
|
// process is the worker body: plan, execute, dispatch notifications for
|
|
// crossed limits and write the transaction log. ctx is the queue's own
|
|
// bounded context, never a request's.
|
|
func (s *Server) process(ctx context.Context, job counting.Job) {
|
|
plan, err := s.d.Counter.Planner.Plan(job)
|
|
if err != nil {
|
|
s.d.Logger.Error("domain lookup failed", "route", job.Route, "trace_id", job.TraceID, "error", err)
|
|
if s.d.TxLog != nil {
|
|
s.d.TxLog.Error(job.Route, job.TraceID, err)
|
|
}
|
|
return
|
|
}
|
|
for _, lk := range plan.Lookups {
|
|
if errors.Is(lk.Err, domains.ErrMalformed) {
|
|
s.d.Logger.Warn("malformed row in domains db, domain treated as not hosted", "domain", lk.DomainName, "route", job.Route, "trace_id", job.TraceID, "error", lk.Err)
|
|
}
|
|
}
|
|
out, err := s.d.Counter.Execute(ctx, plan)
|
|
if err != nil {
|
|
if s.d.Metrics != nil {
|
|
s.d.Metrics.RecordRedisError("incr")
|
|
}
|
|
if s.d.TxLog != nil {
|
|
s.d.TxLog.Failed(plan, err)
|
|
}
|
|
s.d.Logger.Error("counting failed", "route", plan.Route, "trace_id", plan.TraceID, "error", err)
|
|
return
|
|
}
|
|
if out.Unknown != nil {
|
|
if s.d.Metrics != nil {
|
|
s.d.Metrics.RecordRedisError("incr_unknown")
|
|
}
|
|
s.d.Logger.Warn("redis outcome unknown, counts re-read from master", "route", plan.Route, "trace_id", plan.TraceID, "error", out.Unknown)
|
|
}
|
|
|
|
dispatched := make([]bool, len(out.Results))
|
|
for i, res := range out.Results {
|
|
if res.Err != nil {
|
|
if s.d.Metrics != nil {
|
|
s.d.Metrics.RecordRedisError("incr")
|
|
}
|
|
s.d.Logger.Error("redis error on key", "route", plan.Route, "trace_id", plan.TraceID, "key", res.Key, "error", res.Err)
|
|
continue
|
|
}
|
|
if s.d.Metrics != nil {
|
|
s.d.Metrics.RecordIncrement(res.Level, res.Flow)
|
|
}
|
|
if res.Reached {
|
|
if s.d.Metrics != nil {
|
|
s.d.Metrics.RecordLimitReached(res.Level, res.Flow)
|
|
}
|
|
ev := notify.Event{TraceID: plan.TraceID, Route: plan.Route, Level: res.Level, ID: res.ID,
|
|
Flow: res.Flow, Address: res.By, Count: res.Count, Limit: res.Limit}
|
|
dispatched[i] = s.d.Notifier != nil && s.d.Notifier.Notify(ev)
|
|
if !dispatched[i] && s.d.Metrics != nil {
|
|
s.d.Metrics.RecordNotification(res.Level, "disabled")
|
|
}
|
|
}
|
|
}
|
|
if s.d.TxLog != nil {
|
|
s.d.TxLog.Outcome(out, dispatched)
|
|
}
|
|
}
|
|
|
|
// handleCounters answers GET /counters?address=|domain=[&date=] from the
|
|
// read side.
|
|
func (s *Server) handleCounters(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
address, domain := strings.TrimSpace(q.Get("address")), strings.ToLower(strings.TrimSpace(q.Get("domain")))
|
|
if (address == "") == (domain == "") {
|
|
s.reject(w, r, "/counters", &httpError{http.StatusBadRequest, "exactly one of address or domain is required"})
|
|
return
|
|
}
|
|
date, err := api.ParseDate(q.Get("date"), s.d.Now())
|
|
if err != nil {
|
|
s.reject(w, r, "/counters", &httpError{http.StatusBadRequest, "date: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
resp := api.CountersResponse{Date: date, Levels: []api.CountersLevel{}}
|
|
var local string
|
|
if address != "" {
|
|
addr, err := api.NormalizeEmail(address)
|
|
if err != nil {
|
|
s.reject(w, r, "/counters", &httpError{http.StatusBadRequest, "address: invalid e-mail address"})
|
|
return
|
|
}
|
|
addr = domains.Mailbox(addr)
|
|
resp.Query, resp.Mode, resp.Exempt = addr, "address", s.d.Exempt[addr]
|
|
at := strings.LastIndex(addr, "@")
|
|
local, domain = addr[:at], addr[at+1:]
|
|
} else {
|
|
resp.Query, resp.Mode = domain, "domain"
|
|
}
|
|
|
|
db := s.d.Domains()
|
|
if db == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "domains db not loaded")
|
|
return
|
|
}
|
|
d, err := db.Lookup(domain)
|
|
if errors.Is(err, domains.ErrMalformed) {
|
|
s.d.Logger.Warn("malformed row in domains db, domain treated as not hosted", "domain", domain, "route", "/counters", "error", err)
|
|
} else if err != nil && !errors.Is(err, domains.ErrNotFound) {
|
|
writeError(w, http.StatusServiceUnavailable, "domain lookup failed: "+err.Error())
|
|
return
|
|
}
|
|
managed := err == nil
|
|
if managed {
|
|
org := d.OrgID
|
|
resp.Managed, resp.Domain, resp.OrgID = true, d.Name, &org
|
|
}
|
|
|
|
type row struct{ level, id string }
|
|
var rows []row
|
|
if resp.Mode == "address" {
|
|
rows = append(rows, row{domains.AddressLevel, resp.Query})
|
|
}
|
|
rows = append(rows, row{domains.DomainLevel, domain})
|
|
if managed {
|
|
rows = append(rows, row{domains.OrganizationLevel, strconv.FormatInt(d.OrgID, 10)})
|
|
}
|
|
keys := make([]string, 0, 2*len(rows))
|
|
for _, rw := range rows {
|
|
keys = append(keys, store.Key(rw.level, rw.id, date, domains.FlowIn), store.Key(rw.level, rw.id, date, domains.FlowOut))
|
|
}
|
|
values, err := s.d.Store.Get(r.Context(), keys)
|
|
if err != nil {
|
|
if s.d.Metrics != nil {
|
|
s.d.Metrics.RecordRedisError("mget")
|
|
}
|
|
writeError(w, http.StatusServiceUnavailable, "redis unavailable: "+err.Error())
|
|
return
|
|
}
|
|
limit := func(level, flow string) *int64 {
|
|
if !managed {
|
|
return nil
|
|
}
|
|
if n, ok := d.Limits.Limit(level, flow, local); ok {
|
|
return &n
|
|
}
|
|
return nil
|
|
}
|
|
for i, rw := range rows {
|
|
resp.Levels = append(resp.Levels, api.CountersLevel{
|
|
Level: rw.level, ID: rw.id,
|
|
In: values[2*i], InLimit: limit(rw.level, domains.FlowIn),
|
|
Out: values[2*i+1], OutLimit: limit(rw.level, domains.FlowOut),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
db := s.d.Domains()
|
|
if db == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "domains db not loaded")
|
|
return
|
|
}
|
|
if err := db.Ping(); err != nil {
|
|
writeError(w, http.StatusServiceUnavailable, "domains db not readable: "+err.Error())
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
|
|
defer cancel()
|
|
if err := s.d.Store.Ping(ctx); err != nil {
|
|
if s.d.Metrics != nil {
|
|
s.d.Metrics.RecordRedisError("ping")
|
|
}
|
|
writeError(w, http.StatusServiceUnavailable, fmt.Sprintf("redis: %v", err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|