First commit Go counter service

This commit is contained in:
Dimitrije Dimitrijevic
2026-10-15 02:12:18 +02:00
commit c43ff17627
59 changed files with 11205 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
package server
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"net"
"net/http"
"net/netip"
"runtime/debug"
"strconv"
"strings"
"time"
"migadu_counters/internal/api"
)
// maxBody bounds every request body (the biggest legitimate body is a few
// hundred bytes).
const maxBody = 64 << 10
// httpError is a rejection by the validation layer.
type httpError struct {
status int
msg string
}
func (e *httpError) Error() string { return e.msg }
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, api.ErrorResponse{Error: msg})
}
// reject answers an httpError and logs it: a burst of 4xx from mailqueuer
// means contract drift or a token mismatch.
func (s *Server) reject(w http.ResponseWriter, r *http.Request, route string, e *httpError) {
writeError(w, e.status, e.msg)
s.d.Logger.Warn("request rejected", "route", route, "remote", r.RemoteAddr, "status", e.status, "reason", e.msg)
}
// statusWriter captures the status code for metrics.
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
if w.status == 0 {
w.status = code
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(p []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
return w.ResponseWriter.Write(p)
}
// recoverer keeps the daemon alive when a handler panics. It sits outside
// http.TimeoutHandler, which re-panics in the caller's goroutine.
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if p := recover(); p != nil {
s.d.Logger.Error("panic in handler", "panic", fmt.Sprint(p), "path", r.URL.Path, "stack", string(debug.Stack()))
writeError(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
// observe records request metrics per route and status.
func (s *Server) observe(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w}
next.ServeHTTP(sw, r)
if sw.status == 0 {
sw.status = http.StatusOK
}
route := r.URL.Path
switch route {
case "/in", "/out", "/counters":
default:
route = "other"
}
if s.d.Metrics != nil {
s.d.Metrics.RecordRequest(route, strconv.Itoa(sw.status), time.Since(start).Seconds())
}
})
}
// route applies the validation steps common to every endpoint, in order:
// method, declared body size, bearer token, source IP, content type (POST).
func (s *Server) route(path, method string, h http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != method {
w.Header().Set("Allow", method)
s.reject(w, r, path, &httpError{http.StatusMethodNotAllowed, "method not allowed"})
return
}
if r.ContentLength > maxBody {
s.reject(w, r, path, &httpError{http.StatusRequestEntityTooLarge, "request body too large"})
return
}
if e := s.checkAuth(r); e != nil {
w.Header().Set("WWW-Authenticate", "Bearer")
s.reject(w, r, path, e)
return
}
if e := s.checkSource(r); e != nil {
s.reject(w, r, path, e)
return
}
if method == http.MethodPost {
if e := checkContentType(r); e != nil {
s.reject(w, r, path, e)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
}
h(w, r)
})
}
func (s *Server) checkAuth(r *http.Request) *httpError {
if s.d.AuthToken == "" {
return nil // config.Warnings() flags this at startup
}
const prefix = "Bearer "
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, prefix) {
return &httpError{http.StatusUnauthorized, "unauthorized"}
}
if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(h[len(prefix):])), []byte(s.d.AuthToken)) != 1 {
return &httpError{http.StatusUnauthorized, "unauthorized"}
}
return nil
}
// checkSource matches RemoteAddr (never X-Forwarded-For: nothing proxies
// mailqueuer's calls) against allowed_cidrs when configured.
func (s *Server) checkSource(r *http.Request) *httpError {
if len(s.d.AllowedCIDRs) == 0 {
return nil
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
addr, err := netip.ParseAddr(host)
if err != nil {
return &httpError{http.StatusForbidden, "source address not allowed"}
}
addr = addr.Unmap()
for _, p := range s.d.AllowedCIDRs {
if p.Contains(addr) {
return nil
}
}
return &httpError{http.StatusForbidden, "source address not allowed"}
}
func checkContentType(r *http.Request) *httpError {
mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mt != "application/json" {
return &httpError{http.StatusUnsupportedMediaType, "Content-Type must be application/json"}
}
return nil
}
// decodeJSON reads exactly one JSON object into dst. Unknown fields are
// ignored so mailqueuer can add fields without breaking counting.
func decodeJSON(r *http.Request, dst any) *httpError {
dec := json.NewDecoder(r.Body)
if err := dec.Decode(dst); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return &httpError{http.StatusRequestEntityTooLarge, "request body too large"}
}
return &httpError{http.StatusBadRequest, "invalid json: " + err.Error()}
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return &httpError{http.StatusRequestEntityTooLarge, "request body too large"}
}
return &httpError{http.StatusBadRequest, "body must contain exactly one JSON object"}
}
return nil
}
// logger returns the configured logger or slog's default.
func (d Deps) logger() *slog.Logger {
if d.Logger != nil {
return d.Logger
}
return slog.Default()
}
+371
View File
@@ -0,0 +1,371 @@
// 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"})
}
File diff suppressed because it is too large Load Diff