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
+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"})
}