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
+250
View File
@@ -0,0 +1,250 @@
// Package domains reads the rspamd domains CDB (rspamd_domains_db) that holds
// the organisation id and the daily limits of every Migadu-hosted domain.
//
// The file is built by the agent's extract.sh with tinycdb (`cdb -m`) from
// lines of the form
//
// example.com 1049|O/i=200,O/o=20,D/i=200,D/o=20,A/i=200,A/o=20,info@/o=5
//
// i.e. key = lowercase domain (no NUL terminator), value =
// "<org_id>|<comma separated limits>". Limit keys are "<level>/<flow>" with
// level O (organisation), D (domain) or A (address default) and flow i
// (incoming) or o (outgoing); "<localpart>@/<flow>" overrides the A default for
// one mailbox. A level/flow without a key is unlimited. A domain that is not
// in the file is not hosted by Migadu and is never counted.
package domains
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"github.com/colinmarc/cdb"
)
// DefaultMaxCached is the number of parsed records a DB caches when opened
// with Open; the daemon passes its max_cached_domains instead.
const DefaultMaxCached = 20000
// Limit levels and flows, as used in the CDB values and the Redis keys.
const (
OrganizationLevel = "O"
DomainLevel = "D"
AddressLevel = "A"
FlowIn = "i"
FlowOut = "o"
)
// ErrNotFound is returned by Lookup when the domain is not in the CDB.
var ErrNotFound = errors.New("domain not found in domains db")
// ErrMalformed wraps a Lookup error for a row that exists but cannot be
// parsed (non-numeric org id). Callers treat it like "not hosted" and warn:
// one bad generated row must never fail a request (review #8).
var ErrMalformed = errors.New("malformed row in domains db")
// Mailbox strips a plus-address tag from the local part: "b+tag@d" → "b@d".
// Counters are kept per mailbox, which is what the web app reads and what
// rcptd resolves; a tag never names a different mailbox (review #7). An
// address without "@", or whose local part would become empty, is returned
// unchanged.
func Mailbox(addr string) string {
at := strings.LastIndex(addr, "@")
if at <= 0 {
return addr
}
plus := strings.Index(addr[:at], "+")
if plus <= 0 {
return addr
}
return addr[:plus] + addr[at:]
}
// Limits maps limit keys ("O/i", "A/o", "alice@/o", ...) to their values.
type Limits map[string]int64
// ParseLimits parses "O/i=5000, A/o=200, alice@/o=7" into a Limits map.
// Keys are trimmed; pairs without "=", with an empty key or with a
// non-numeric value are skipped rather than failing the whole record — the
// file is generated, and one bad pair must not disable counting for a domain.
func ParseLimits(s string) Limits {
limits := Limits{}
for _, pair := range strings.Split(s, ",") {
k, v, ok := strings.Cut(pair, "=")
if !ok {
continue
}
k = strings.TrimSpace(k)
if k == "" {
continue
}
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
continue
}
limits[k] = n
}
return limits
}
// Limit resolves the configured limit for a level and flow, or (0, false)
// when unlimited. At address level the per-mailbox override "<local>@/<flow>"
// wins over the domain-wide default "A/<flow>" — same rule as the rspamd
// plugin. localPart is ignored for the D and O levels.
func (l Limits) Limit(level, flow, localPart string) (int64, bool) {
switch level {
case AddressLevel:
if n, ok := l[localPart+"@/"+flow]; ok {
return n, true
}
n, ok := l[AddressLevel+"/"+flow]
return n, ok
case DomainLevel, OrganizationLevel:
n, ok := l[level+"/"+flow]
return n, ok
}
return 0, false
}
// String renders the limits sorted by key, e.g. "{A/i=200, A/o=20, O/i=200}",
// the limits_map notation used in the counting transaction log.
func (l Limits) String() string {
keys := make([]string, 0, len(l))
for k := range l {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteByte('{')
for i, k := range keys {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "%s=%d", k, l[k])
}
b.WriteByte('}')
return b.String()
}
// Domain is one parsed CDB record.
type Domain struct {
Name string
OrgID int64
Limits Limits
}
// ParseRecord parses a CDB value "<org_id>|<limits>" for domain. A missing
// "|" yields a domain without limits; a missing or non-numeric org id is an
// error because the O counters cannot be keyed without it.
func ParseRecord(domain, value string) (*Domain, error) {
orgPart, limitsPart, _ := strings.Cut(value, "|")
orgPart = strings.TrimSpace(orgPart)
orgID, err := strconv.ParseInt(orgPart, 10, 64)
if err != nil || orgID < 0 {
return nil, fmt.Errorf("%w: domain %s: invalid org id %q", ErrMalformed, domain, orgPart)
}
return &Domain{Name: domain, OrgID: orgID, Limits: ParseLimits(limitsPart)}, nil
}
// DB is an open domains CDB. It is safe for concurrent use.
//
// Parsed records are cached per DB (domain → *Domain) up to maxCached
// entries, because some values are huge (boxmailhere.com: 193 KB, 9 798
// overrides; ~1 ms and 1.4 MB to parse). The cache is never invalidated: a
// reload replaces the whole DB object, and the cache goes with it. The cached
// *Domain is shared read-only; callers must not mutate it.
type DB struct {
path string
db *cdb.CDB
count int
maxCached int
cache sync.Map // domain -> *Domain
cached atomic.Int64
}
// Open opens the CDB at path with the default cache size.
func Open(path string) (*DB, error) { return OpenWithCache(path, DefaultMaxCached) }
// OpenWithCache opens the CDB at path and counts its records (a few
// milliseconds for ~100k records; the count feeds the cdb_domains gauge and
// the reload log). maxCached bounds the parsed-record cache; 0 disables it.
func OpenWithCache(path string, maxCached int) (*DB, error) {
c, err := cdb.Open(path)
if err != nil {
return nil, fmt.Errorf("open domains db %s: %w", path, err)
}
n := 0
it := c.Iter()
for it.Next() {
n++
}
if err := it.Err(); err != nil {
c.Close()
return nil, fmt.Errorf("read domains db %s: %w", path, err)
}
if maxCached < 0 {
maxCached = 0
}
return &DB{path: path, db: c, count: n, maxCached: maxCached}, nil
}
// Lookup returns the record for domain (trimmed and lowercased first), or
// ErrNotFound when the domain is not hosted here. The returned *Domain may
// be shared with other callers and must be treated as read-only.
func (d *DB) Lookup(domain string) (*Domain, error) {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return nil, ErrNotFound
}
if hit, ok := d.cache.Load(domain); ok {
return hit.(*Domain), nil
}
v, err := d.db.Get([]byte(domain))
if err != nil {
return nil, fmt.Errorf("read domains db %s: %w", d.path, err)
}
if v == nil {
return nil, ErrNotFound
}
rec, err := ParseRecord(domain, string(v))
if err != nil {
return nil, err
}
if d.maxCached > 0 && d.cached.Load() < int64(d.maxCached) {
if prev, loaded := d.cache.LoadOrStore(domain, rec); loaded {
return prev.(*Domain), nil
}
d.cached.Add(1)
}
return rec, nil
}
// Cached returns the number of parsed records currently cached.
func (d *DB) Cached() int { return int(d.cached.Load()) }
// Ping reads the first record and reports an error when the file can no
// longer be read or holds no domains at all (nothing would ever be counted).
func (d *DB) Ping() error {
it := d.db.Iter()
if it.Next() {
return nil
}
if err := it.Err(); err != nil {
return fmt.Errorf("read domains db %s: %w", d.path, err)
}
return fmt.Errorf("domains db %s is empty", d.path)
}
// Path returns the file the DB was opened from.
func (d *DB) Path() string { return d.path }
// Count returns the number of records counted at Open.
func (d *DB) Count() int { return d.count }
// Close closes the underlying file; in-flight Lookups then fail with an error.
func (d *DB) Close() error { return d.db.Close() }
+389
View File
@@ -0,0 +1,389 @@
package domains
import (
"errors"
"path/filepath"
"strings"
"testing"
"github.com/colinmarc/cdb"
)
// All expectations below come from the real production-shaped CDB shipped in
// test_files/, dumped with `cdb -d -m` (tinycdb). 93 549 records, every value
// "<org_id>|O/i,O/o,D/i,D/o,A/i,A/o[,<local>@/<flow>...]".
const testCDB = "../../test_files/rspamd_domains_db.cdb"
func openTestDB(t *testing.T) *DB {
t.Helper()
db, err := Open(testCDB)
if err != nil {
t.Fatalf("Open(%s): %v", testCDB, err)
}
t.Cleanup(func() { db.Close() })
return db
}
func mustLookup(t *testing.T, db *DB, domain string) *Domain {
t.Helper()
d, err := db.Lookup(domain)
if err != nil {
t.Fatalf("Lookup(%q): %v", domain, err)
}
return d
}
func TestOpen_MissingFile(t *testing.T) {
if _, err := Open("/nonexistent/rspamd_domains_db"); err == nil {
t.Fatal("expected error for missing file")
}
}
func TestDB_PathAndCount(t *testing.T) {
db := openTestDB(t)
if db.Path() != testCDB {
t.Errorf("Path() = %q", db.Path())
}
if got := db.Count(); got != 93549 {
t.Errorf("Count() = %d, want 93549", got)
}
}
func TestLookup_RealRecord(t *testing.T) {
db := openTestDB(t)
d := mustLookup(t, db, "yagolivas.com")
if d.Name != "yagolivas.com" || d.OrgID != 1049 {
t.Fatalf("got %+v", d)
}
want := Limits{"O/i": 200, "O/o": 20, "D/i": 200, "D/o": 20, "A/i": 200, "A/o": 20}
if len(d.Limits) != len(want) {
t.Fatalf("limits = %v, want %v", d.Limits, want)
}
for k, v := range want {
if d.Limits[k] != v {
t.Errorf("limits[%s] = %d, want %d", k, d.Limits[k], v)
}
}
}
func TestLookup_NotFound(t *testing.T) {
db := openTestDB(t)
for _, domain := range []string{"gmail.com", "example.com", "example.org", ""} {
_, err := db.Lookup(domain)
if !errors.Is(err, ErrNotFound) {
t.Errorf("Lookup(%q) err = %v, want ErrNotFound", domain, err)
}
}
}
func TestLookup_NormalizesCase(t *testing.T) {
db := openTestDB(t)
d := mustLookup(t, db, " YagoLivas.COM ")
if d.OrgID != 1049 || d.Name != "yagolivas.com" {
t.Errorf("got %+v", d)
}
}
// boxmailhere.com carries a 193 KB value with 9 798 per-address overrides.
func TestLookup_HugeRecord(t *testing.T) {
db := openTestDB(t)
d := mustLookup(t, db, "boxmailhere.com")
if d.OrgID != 87716 {
t.Errorf("OrgID = %d, want 87716", d.OrgID)
}
if len(d.Limits) != 9804 {
t.Errorf("len(Limits) = %d, want 9804 (6 base + 9798 overrides)", len(d.Limits))
}
checkLimit(t, d.Limits, AddressLevel, FlowIn, "gangstaelmo7", 500, true)
checkLimit(t, d.Limits, AddressLevel, FlowOut, "gangstaelmo7", 30, true)
checkLimit(t, d.Limits, AddressLevel, FlowIn, "nobody-with-override", 250000, true)
checkLimit(t, d.Limits, OrganizationLevel, FlowOut, "", 4000, true)
}
func checkLimit(t *testing.T, l Limits, level, flow, local string, want int64, wantOK bool) {
t.Helper()
got, ok := l.Limit(level, flow, local)
if got != want || ok != wantOK {
t.Errorf("Limit(%s, %s, %q) = (%d, %v), want (%d, %v)", level, flow, local, got, ok, want, wantOK)
}
}
// crisdanshopping.com.br: A/i=500,A/o=100 plus contato@ and admin@ overrides.
func TestLimit_AddressOverride(t *testing.T) {
db := openTestDB(t)
d := mustLookup(t, db, "crisdanshopping.com.br")
if d.OrgID != 56 {
t.Fatalf("OrgID = %d", d.OrgID)
}
l := d.Limits
checkLimit(t, l, AddressLevel, FlowIn, "contato", 300, true)
checkLimit(t, l, AddressLevel, FlowOut, "contato", 150, true)
checkLimit(t, l, AddressLevel, FlowIn, "admin", 300, true)
checkLimit(t, l, AddressLevel, FlowOut, "admin", 150, true)
checkLimit(t, l, AddressLevel, FlowIn, "someone", 500, true) // falls back to A/i
checkLimit(t, l, AddressLevel, FlowOut, "someone", 100, true)
checkLimit(t, l, DomainLevel, FlowIn, "", 1000, true)
checkLimit(t, l, DomainLevel, FlowOut, "", 120, true)
checkLimit(t, l, OrganizationLevel, FlowIn, "", 1000, true)
checkLimit(t, l, OrganizationLevel, FlowOut, "", 120, true)
// local part is irrelevant for D and O
checkLimit(t, l, DomainLevel, FlowIn, "contato", 1000, true)
}
// meu-servidor.com has overrides whose local parts contain dots.
func TestLimit_DottedLocalPart(t *testing.T) {
db := openTestDB(t)
d := mustLookup(t, db, "meu-servidor.com")
if d.OrgID != 726 {
t.Fatalf("OrgID = %d", d.OrgID)
}
checkLimit(t, d.Limits, AddressLevel, FlowIn, "sexprime.xxx", 100, true)
checkLimit(t, d.Limits, AddressLevel, FlowIn, "cf-tgamesandroid.com", 1000, true)
checkLimit(t, d.Limits, AddressLevel, FlowOut, "cf-tgamesandroid.com", 100, true)
checkLimit(t, d.Limits, AddressLevel, FlowIn, "alerta", 1000, true)
checkLimit(t, d.Limits, AddressLevel, FlowOut, "alerta", 100, true)
checkLimit(t, d.Limits, AddressLevel, FlowIn, "nobody", 500, true)
}
func TestLimit_MigaduSelf(t *testing.T) {
db := openTestDB(t)
d := mustLookup(t, db, "migadu.com")
if d.OrgID != 3743 {
t.Fatalf("OrgID = %d", d.OrgID)
}
checkLimit(t, d.Limits, AddressLevel, FlowIn, "admin", 10000, true)
checkLimit(t, d.Limits, AddressLevel, FlowIn, "someone", 100000, true)
}
func TestLimit_Unlimited(t *testing.T) {
l := ParseLimits("O/i=5")
checkLimit(t, l, OrganizationLevel, FlowIn, "", 5, true)
checkLimit(t, l, OrganizationLevel, FlowOut, "", 0, false)
checkLimit(t, l, DomainLevel, FlowIn, "", 0, false)
checkLimit(t, l, AddressLevel, FlowIn, "x", 0, false)
checkLimit(t, Limits(nil), AddressLevel, FlowIn, "x", 0, false)
checkLimit(t, l, "Z", FlowIn, "", 0, false) // unknown level
}
func TestParseLimits(t *testing.T) {
l := ParseLimits(" O/i = 5000,O/o=1000, broken ,x=notanumber,=3,A/o=,,alice@/o=7")
want := Limits{"O/i": 5000, "O/o": 1000, "alice@/o": 7}
if len(l) != len(want) {
t.Fatalf("got %v, want %v", l, want)
}
for k, v := range want {
if l[k] != v {
t.Errorf("%s = %d, want %d", k, l[k], v)
}
}
if got := ParseLimits(""); len(got) != 0 {
t.Errorf("ParseLimits(\"\") = %v, want empty", got)
}
}
func TestParseRecord(t *testing.T) {
tests := []struct {
name, value string
wantOrg int64
wantLimits int
wantErr bool
}{
{"normal", "1049|O/i=200,O/o=20", 1049, 2, false},
{"spaces", " 7 | A/i=3 ", 7, 1, false},
{"no pipe", "42", 42, 0, false}, // must not panic (the Lua plugin did)
{"empty limits", "42|", 42, 0, false},
{"non-numeric org", "abc|O/i=1", 0, 0, true},
{"empty", "", 0, 0, true},
{"negative org", "-1|O/i=1", 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d, err := ParseRecord("x.com", tt.value)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got %+v", d)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Name != "x.com" || d.OrgID != tt.wantOrg || len(d.Limits) != tt.wantLimits {
t.Errorf("got %+v, want org=%d limits=%d", d, tt.wantOrg, tt.wantLimits)
}
})
}
}
// String renders the Lua-like limits_map used by the transaction log, sorted.
func TestLimits_String(t *testing.T) {
l := ParseLimits("O/o=20,A/i=200,A/o=20,O/i=200,D/i=200,D/o=20,info@/o=5")
want := "{A/i=200, A/o=20, D/i=200, D/o=20, O/i=200, O/o=20, info@/o=5}"
if got := l.String(); got != want {
t.Errorf("String() = %q, want %q", got, want)
}
if got := (Limits{}).String(); got != "{}" {
t.Errorf("empty String() = %q", got)
}
}
func TestDB_Ping(t *testing.T) {
db, err := Open(testCDB)
if err != nil {
t.Fatal(err)
}
if err := db.Ping(); err != nil {
t.Errorf("Ping() = %v", err)
}
db.Close()
if err := db.Ping(); err == nil {
t.Error("Ping() after Close() should fail")
}
}
// A CDB written with colinmarc/cdb's Writer (as tests and tooling do) must be
// readable through the same path as the agent-built file.
func TestLookup_WriterBuiltCDB(t *testing.T) {
path := filepath.Join(t.TempDir(), "small.cdb")
w, err := cdb.Create(path)
if err != nil {
t.Fatal(err)
}
if err := w.Put([]byte("newdomain.test"), []byte("5|A/i=1,A/o=1,D/i=2,D/o=2,O/i=3,O/o=3")); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
db, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if db.Count() != 1 {
t.Errorf("Count() = %d, want 1", db.Count())
}
d, err := db.Lookup("newdomain.test")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if d.OrgID != 5 {
t.Errorf("OrgID = %d", d.OrgID)
}
if _, err := db.Lookup("other.test"); !errors.Is(err, ErrNotFound) {
t.Errorf("Lookup(other) err = %v, want ErrNotFound", err)
}
}
// Parsed records are cached per loaded table: a second Lookup of the same
// domain returns the very same *Domain (no re-parse), and is safe because
// callers never mutate it.
func TestLookup_CachesParsedRecord(t *testing.T) {
db := openTestDB(t)
first := mustLookup(t, db, "boxmailhere.com")
second := mustLookup(t, db, "BoxMailHere.com ")
if first != second {
t.Error("second lookup re-parsed the record instead of serving the cached *Domain")
}
if db.Cached() != 1 {
t.Errorf("Cached() = %d, want 1", db.Cached())
}
if _, err := db.Lookup("nobody.invalid"); !errors.Is(err, ErrNotFound) {
t.Fatal(err)
}
if db.Cached() != 1 {
t.Errorf("not-found lookups must not be cached: Cached() = %d", db.Cached())
}
}
// The cache is bounded: beyond max entries lookups still work but parse
// without caching; 0 disables the cache entirely.
func TestLookup_CacheBounded(t *testing.T) {
db, err := OpenWithCache(testCDB, 1)
if err != nil {
t.Fatal(err)
}
defer db.Close()
a1 := mustLookup(t, db, "yagolivas.com")
a2 := mustLookup(t, db, "yagolivas.com")
b1 := mustLookup(t, db, "boxmailhere.com")
b2 := mustLookup(t, db, "boxmailhere.com")
if a1 != a2 {
t.Error("first domain should be cached")
}
if b1 == b2 {
t.Error("second domain exceeds max_cached_domains=1 and must not be cached")
}
if b1.OrgID != 87716 || db.Cached() != 1 {
t.Errorf("uncached lookup still parses: org=%d cached=%d", b1.OrgID, db.Cached())
}
off, err := OpenWithCache(testCDB, 0)
if err != nil {
t.Fatal(err)
}
defer off.Close()
if mustLookup(t, off, "yagolivas.com") == mustLookup(t, off, "yagolivas.com") {
t.Error("cache disabled (0) must parse every time")
}
}
func benchLookup(b *testing.B, dom string) {
db, err := Open(testCDB)
if err != nil {
b.Fatal(err)
}
defer db.Close()
if _, err := db.Lookup(dom); err != nil { // warm the cache
b.Fatal(err)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := db.Lookup(dom); err != nil {
b.Fatal(err)
}
}
}
// With the cache warm, the 193 KB boxmailhere.com record costs the same as a
// small one (review finding #5: 0.9 ms and 1.4 MB per lookup before).
func BenchmarkLookupSmall(b *testing.B) { benchLookup(b, "yagolivas.com") }
func BenchmarkLookupHuge(b *testing.B) { benchLookup(b, "boxmailhere.com") }
// A row with a non-numeric org id is reported as malformed, which callers
// treat like "not hosted" (review #8) — it must never fail the request.
func TestLookup_MalformedRow(t *testing.T) {
db, err := Open("../../review/_proof_tests/b/bad.cdb")
if err != nil {
t.Fatal(err)
}
defer db.Close()
_, err = db.Lookup("bad.com")
if !errors.Is(err, ErrMalformed) {
t.Fatalf("Lookup(bad.com) err = %v, want ErrMalformed", err)
}
if !strings.Contains(err.Error(), `invalid org id "x12"`) {
t.Errorf("error should name the problem: %v", err)
}
if d, err := db.Lookup("good.com"); err != nil || d.OrgID != 7 {
t.Errorf("good.com = %+v, %v", d, err)
}
if db.Cached() != 1 {
t.Errorf("malformed rows must not be cached: Cached() = %d", db.Cached())
}
}
func TestMailbox(t *testing.T) {
for in, want := range map[string]string{
"b+1@example.com": "b@example.com",
"b+tag+more@example.com": "b@example.com",
"b@example.com": "b@example.com",
"+lead@example.com": "+lead@example.com", // empty local part would be nonsense: keep as is
"nodomain": "nodomain",
} {
if got := Mailbox(in); got != want {
t.Errorf("Mailbox(%q) = %q, want %q", in, got, want)
}
}
}