feat: log-tailer offset persistence + in-flight L2 rate-limit accounting (code-review.md § Phase 3)

- logtail: persist the read position (offset + fingerprint of the log's
  first 512 bytes) in a new logtail_state table (migration 0003) and
  resume from it on start, so delivery lines written while the panel was
  down are parsed instead of skipped and their send-log rows no longer
  stay "queued" forever. Fingerprint mismatch (rotated/recreated while
  down) reads the file from the start — re-parsing is idempotent; a
  first-ever start with nothing stored still begins at end-of-file.
  Writes are throttled to one per 5s, forced on rotation and shutdown.

- milter: count messages that passed the level-2 check but have not
  reached the send log yet (internal/milter/inflight.go), so concurrent
  SMTP sessions cannot each spend the same last slot. A literal
  count+insert transaction, as the review suggested, is not possible:
  the count happens at MAIL FROM and the insert at end-of-message.
  Reservations are released after the insert, on ABORT, and after a
  10-minute TTL — a client that drops mid-transaction must not be able
  to hold a slot, since the limiter is fail-open by design.

Docs: architecture.md (log tailer, persistence, L2 counting),
security.md and roadmap.md (restart gap closed, container recreate
remains), CHANGELOG, progress.md, code-review.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 17:12:37 +03:00
parent 4b0f537508
commit ed0a786739
15 changed files with 683 additions and 40 deletions
+31 -9
View File
@@ -20,11 +20,14 @@ import (
"codeberg.org/mix/selfpost/internal/store"
)
// StatusStore is the slice of the store the log-tailer needs. *store.Store
// satisfies it.
// StatusStore is the slice of the store the log-tailer needs: advancing
// delivery statuses, pruning the retention window, and remembering how far into
// mail.log it has read. *store.Store satisfies it.
type StatusStore interface {
UpdateStatus(queueID, recipient, status string) (int64, error)
DeleteSendLogBefore(cutoff time.Time) (int64, error)
LogtailState(path string) (store.LogtailState, bool, error)
SaveLogtailState(path string, st store.LogtailState) error
}
// pollInterval is how often the tail loop checks for new bytes / rotation. It
@@ -73,12 +76,13 @@ func parseDelivery(line string) (queueID, recipient, status string, ok bool) {
}
// Run follows path and updates send-log statuses until ctx is cancelled, while
// a background sweep prunes rows older than retentionDays. It returns nil on a
// clean shutdown.
// a background sweep prunes rows older than retentionDays. Reading resumes at
// the offset the previous run persisted, so a restart parses the delivery lines
// written while the panel was down. It returns nil on a clean shutdown.
func Run(ctx context.Context, path string, st StatusStore, retentionDays int) error {
go retentionLoop(ctx, st, retentionDays)
return follow(ctx, path, func(line string) {
return follow(ctx, path, &tracker{st: st, path: path}, func(line string) {
queueID, recipient, status, ok := parseDelivery(line)
if !ok {
return
@@ -166,10 +170,11 @@ func TailLines(path string, n int) ([]string, error) {
}
// follow tails path line by line, calling handle for each complete line, until
// ctx is cancelled. It starts at end-of-file (so a restart does not reprocess
// history) and reopens the file when it is rotated (inode change from
// ctx is cancelled. Where it starts is tr's decision (a persisted offset, the
// start of a file that changed while the panel was down, or end-of-file on a
// first ever run); it reopens the file when it is rotated (inode change from
// logrotate's create, or truncation from copytruncate) so nothing is missed.
func follow(ctx context.Context, path string, handle func(string)) error {
func follow(ctx context.Context, path string, tr *tracker, handle func(string)) error {
var (
f *os.File
r *bufio.Reader
@@ -199,7 +204,7 @@ func follow(ctx context.Context, path string, handle func(string)) error {
// The container may start before Postfix has created mail.log; wait for it.
for {
if err := openAt(0, io.SeekEnd); err == nil {
if err := openAt(0, io.SeekStart); err == nil {
break
}
select {
@@ -208,6 +213,10 @@ func follow(ctx context.Context, path string, handle func(string)) error {
case <-time.After(pollInterval):
}
}
if _, err := f.Seek(tr.resume(f), io.SeekStart); err != nil {
log.Printf("log-tailer: seek %s: %v", path, err)
}
r.Reset(f) // the reader buffered from the pre-seek position
defer func() {
if f != nil {
f.Close()
@@ -231,11 +240,20 @@ func follow(ctx context.Context, path string, handle func(string)) error {
}
}
// read returns how many bytes of the open file have actually been consumed:
// the descriptor position less the partial line bufio handed back at EOF,
// which is re-read (and completed) on the next drain or the next start.
read := func() int64 {
pos, _ := f.Seek(0, io.SeekCurrent)
return pos - int64(len(pending))
}
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
tr.record(f, read(), true) // shutdown: the next start resumes here
return nil
case <-ticker.C:
drain()
@@ -252,8 +270,12 @@ func follow(ctx context.Context, path string, handle func(string)) error {
drain()
if err := openAt(0, io.SeekStart); err != nil {
log.Printf("log-tailer: reopen %s: %v", path, err)
continue
}
tr.adopt(f)
continue
}
tr.record(f, read(), false)
}
}
}
+107 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -77,10 +78,16 @@ func TestParseDelivery(t *testing.T) {
}
}
// captureStore records UpdateStatus calls for the follow integration test.
// captureStore records UpdateStatus calls for the follow integration test and
// keeps the persisted read offset in memory, so a "restart" in a test is a
// second Run against the same captureStore.
type captureStore struct {
mu sync.Mutex
calls []string
state store.LogtailState
haveState bool
stateErr error
}
func (c *captureStore) UpdateStatus(queueID, recipient, status string) (int64, error) {
@@ -92,12 +99,37 @@ func (c *captureStore) UpdateStatus(queueID, recipient, status string) (int64, e
func (c *captureStore) DeleteSendLogBefore(time.Time) (int64, error) { return 0, nil }
func (c *captureStore) LogtailState(string) (store.LogtailState, bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.stateErr != nil {
return store.LogtailState{}, false, c.stateErr
}
return c.state, c.haveState, nil
}
func (c *captureStore) SaveLogtailState(_ string, st store.LogtailState) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.stateErr != nil {
return c.stateErr
}
c.state, c.haveState = st, true
return nil
}
func (c *captureStore) snapshot() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.calls...)
}
func (c *captureStore) reset() {
c.mu.Lock()
defer c.mu.Unlock()
c.calls = nil
}
// TestFollowTailsAndRotates writes delivery lines to a log file, then rotates
// it (rename + fresh create, as logrotate does) and writes more, asserting the
// tailer picks up lines from both the original and rotated file.
@@ -142,6 +174,80 @@ func TestFollowTailsAndRotates(t *testing.T) {
}
}
// TestFollowResumesAfterRestart covers the persisted read offset: a restart
// must parse the delivery lines written while the tailer was down (rows that
// would otherwise stay "queued" forever), without re-parsing what it already
// read, and must fall back to reading the whole file when the log was rotated
// or recreated in the meantime.
func TestFollowResumesAfterRestart(t *testing.T) {
old := pollInterval
pollInterval = 10 * time.Millisecond
t.Cleanup(func() { pollInterval = old })
dir := t.TempDir()
path := filepath.Join(dir, "mail.log")
// A head longer than fingerprintSize, so the file stays identifiable across
// the restart; the lines themselves predate the first start and are ignored.
seed := strings.Repeat("host postfix/qmgr[1]: seed line, not a delivery\n", 20)
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed log: %v", err)
}
cs := &captureStore{}
stop := startRun(t, path, cs)
appendLine(t, path, "host postfix/smtp[1]: Q1: to=<a@example.net>, dsn=2.0.0, status=sent (ok)")
waitFor(t, func() bool { return contains(cs.snapshot(), "Q1|a@example.net|sent") })
stop() // persists the offset past Q1
// Down: Postfix keeps delivering.
appendLine(t, path, "host postfix/smtp[1]: Q2: to=<b@example.net>, dsn=2.0.0, status=sent (ok)")
cs.reset()
stop = startRun(t, path, cs)
waitFor(t, func() bool { return contains(cs.snapshot(), "Q2|b@example.net|sent") })
if contains(cs.snapshot(), "Q1|a@example.net|sent") {
t.Fatal("resumed run re-parsed Q1: offset was not honoured")
}
stop()
// Down again, and this time the log is replaced (logrotate + fresh create).
// The stored offset belongs to a file that no longer exists, so the new one
// must be read from the start.
if err := os.WriteFile(path, []byte(strings.Repeat("host postfix/qmgr[1]: fresh log after rotation\n", 20)+
"host postfix/smtp[1]: Q3: to=<c@example.net>, dsn=5.1.1, status=bounced (nope)\n"), 0o644); err != nil {
t.Fatalf("recreate log: %v", err)
}
cs.reset()
stop = startRun(t, path, cs)
waitFor(t, func() bool { return contains(cs.snapshot(), "Q3|c@example.net|bounced") })
stop()
}
// startRun launches the tailer and returns a function that cancels it and waits
// for a clean return, the way a panel restart bookends a run.
func startRun(t *testing.T, path string, cs *captureStore) func() {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- Run(ctx, path, cs, 90) }()
// follow() opens and seeks on start; give it a moment before the caller
// appends, so the append is not raced by the initial open.
time.Sleep(50 * time.Millisecond)
return func() {
t.Helper()
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("Run: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Run did not return after cancel")
}
}
}
func appendLine(t *testing.T, path, line string) {
t.Helper()
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
+136
View File
@@ -0,0 +1,136 @@
package logtail
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"log"
"os"
"time"
"codeberg.org/mix/selfpost/internal/store"
)
const (
// fingerprintSize is how many bytes from the head of the log identify it.
// Postfix writes a timestamped line per event, so the first 512 bytes are
// effectively unique per log generation — enough to tell "the file we were
// reading" from "a fresh one created by logrotate while we were down",
// which os.SameFile cannot answer across a restart.
fingerprintSize = 512
// persistInterval throttles the offset write. Losing up to this much
// progress on a crash only means re-parsing a few lines (UpdateStatus is
// idempotent), which is much cheaper than a database write per poll tick.
persistInterval = 5 * time.Second
)
// tracker persists the tailer's read position so a restart resumes where the
// previous run stopped instead of jumping to end-of-file — the "send-log rows
// stay queued forever" gap (architecture.md § Log tailer). It is used from the
// follow loop only, so it needs no locking.
type tracker struct {
st StatusStore
path string
fp string // fingerprint of the file currently open ("" if too short)
saved int64 // last offset written to the store
lastSave time.Time
}
// resume returns the byte offset the tailer should start reading f at, having
// recorded f's fingerprint for later saves.
//
// The rules, in order: no stored state at all (first ever start) means start at
// the end, so installing the panel does not replay a pre-existing log; a stored
// state whose fingerprint still matches means continue from it, parsing the
// tail written while the panel was down; anything else means the file is not
// the one the offset referred to (rotated, recreated or truncated in the
// meantime), so read it from the start. Re-parsing lines already seen is
// harmless: UpdateStatus writes the same status onto the same row.
func (t *tracker) resume(f *os.File) int64 {
size, err := fileSize(f)
if err != nil {
log.Printf("log-tailer: stat %s: %v (reading from the start)", t.path, err)
return 0
}
t.fp = fingerprintOf(f)
prev, ok, err := t.st.LogtailState(t.path)
if err != nil {
log.Printf("log-tailer: read stored offset: %v (starting at end)", err)
return size
}
switch {
case !ok:
t.saved = size
return size
case prev.Fingerprint != "" && prev.Fingerprint == t.fp && prev.Offset <= size:
t.saved = prev.Offset
if prev.Offset < size {
log.Printf("log-tailer: resuming %s at offset %d (%d bytes to catch up)",
t.path, prev.Offset, size-prev.Offset)
}
return prev.Offset
default:
log.Printf("log-tailer: %s changed while the panel was down; reading from the start", t.path)
t.saved = 0
return 0
}
}
// adopt re-fingerprints after the follow loop switched to a rotated-in file and
// persists the fresh start immediately, so a restart right after a rotation
// does not resume at the old file's offset.
func (t *tracker) adopt(f *os.File) {
t.fp = fingerprintOf(f)
t.saved = -1 // force the write below even if the old offset happened to be 0
t.record(f, 0, true)
}
// record persists offset, at most once per persistInterval unless force is set
// (rotation and shutdown, where the write must not be skipped).
func (t *tracker) record(f *os.File, offset int64, force bool) {
if offset == t.saved {
return
}
if !force && time.Since(t.lastSave) < persistInterval {
return
}
if t.fp == "" {
// The log was shorter than a fingerprint when we opened it; now that it
// has grown, an identifiable one may be available.
t.fp = fingerprintOf(f)
}
if err := t.st.SaveLogtailState(t.path, store.LogtailState{Fingerprint: t.fp, Offset: offset}); err != nil {
log.Printf("log-tailer: save offset: %v", err)
return
}
t.saved = offset
t.lastSave = time.Now()
}
// fingerprintOf hashes the head of the file. It returns "" for a file too short
// to identify — the head would still change as Postfix appends, so such a
// fingerprint could not be compared meaningfully on the next start.
func fingerprintOf(f *os.File) string {
buf := make([]byte, fingerprintSize)
n, err := f.ReadAt(buf, 0)
if err != nil && !errors.Is(err, io.EOF) {
log.Printf("log-tailer: fingerprint read: %v", err)
return ""
}
if n < fingerprintSize {
return ""
}
sum := sha256.Sum256(buf)
return hex.EncodeToString(sum[:])
}
func fileSize(f *os.File) (int64, error) {
info, err := f.Stat()
if err != nil {
return 0, err
}
return info.Size(), nil
}
+99
View File
@@ -0,0 +1,99 @@
package milter
import (
"sync"
"time"
)
// reservationTTL bounds how long a message may stay reserved. A reservation is
// released at end-of-message or on ABORT, but a client that simply drops the
// connection after MAIL FROM produces neither callback (go-milter has no
// connection-close hook), and a reservation that never expired would count
// against the limit forever — a fail-closed drift this milter must not have.
// The TTL is generously longer than any realistic DATA transfer, so a message
// still being received is never dropped from the count.
const reservationTTL = 10 * time.Minute
// reservation is one message that passed the level-2 check and has not been
// written to the send log yet.
type reservation struct {
key string
at time.Time
}
// inflight counts messages that are between the limit check (MAIL FROM) and the
// send-log insert (end-of-message). The stored count alone cannot see them, so
// without this several concurrent SMTP sessions each read the same pre-insert
// count, each conclude they are under the ceiling, and the limit is overshot by
// however many were in flight. Counting reservations closes that window without
// writing placeholder rows the operator would see in the UI.
//
// One instance is shared by every session of the process, hence the mutex.
// Methods tolerate a nil receiver so a session built without one (tests) simply
// behaves as it did before.
type inflight struct {
mu sync.Mutex
m map[string]map[*reservation]struct{}
}
// count returns how many reservations for key were taken within the limit's
// window (at or after since), pruning any that outlived reservationTTL.
func (f *inflight) count(key string, since time.Time) int64 {
if f == nil {
return 0
}
f.mu.Lock()
defer f.mu.Unlock()
set := f.m[key]
cutoff := time.Now().Add(-reservationTTL)
var n int64
for r := range set {
if r.at.Before(cutoff) {
delete(set, r)
continue
}
if !r.at.Before(since) {
n++
}
}
if len(set) == 0 {
delete(f.m, key)
}
return n
}
// reserve claims a slot for key until the message is recorded or released.
func (f *inflight) reserve(key string) *reservation {
if f == nil {
return nil
}
f.mu.Lock()
defer f.mu.Unlock()
if f.m == nil {
f.m = make(map[string]map[*reservation]struct{})
}
if f.m[key] == nil {
f.m[key] = make(map[*reservation]struct{})
}
r := &reservation{key: key, at: time.Now()}
f.m[key][r] = struct{}{}
return r
}
// release drops a reservation, either because the message reached the send log
// (where the stored count takes over) or because it never will.
func (f *inflight) release(r *reservation) {
if f == nil || r == nil {
return
}
f.mu.Lock()
defer f.mu.Unlock()
set := f.m[r.key]
delete(set, r)
if len(set) == 0 {
delete(f.m, r.key)
}
}
+25 -5
View File
@@ -45,13 +45,18 @@ type Store interface {
type session struct {
milter.NoOpMilter
rec Store
// flight is shared by every session of the process; it holds the messages
// that passed the level-2 check but are not in the send log yet. Nil is a
// valid zero value (no in-flight accounting).
flight *inflight
clientIP string // captured once per connection
login string
from string
rcpts []string
subject string
login string
from string
rcpts []string
subject string
reserved []*reservation // level-2 slots held by the current message
}
// Connect captures the client IP, which comes from the addr parameter rather
@@ -72,6 +77,7 @@ func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milt
// tempfail before recipients are even offered (spec 7.4). Enforcement is
// fail-open — see overLimit.
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
s.from = cleanAddress(from)
s.login = macro(m, "auth_authen")
s.rcpts = nil
@@ -127,9 +133,22 @@ func decodeSubject(v string) string {
// rows are written. We accept (this milter is done) without ever rejecting.
func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
s.record(macro(m, "i"))
// The rows are in the send log now, so the stored count sees this message
// and its level-2 slots are no longer needed.
s.releaseReservations()
return milter.RespAccept, nil
}
// Abort ends the current transaction without an end-of-message (client RSET, or
// Postfix rejecting the message for its own reasons). No send-log row will be
// written, so the level-2 slots this message held must go back.
func (s *session) Abort(m *milter.Modifier) error {
s.releaseReservations()
s.rcpts = nil
s.subject = ""
return nil
}
// macro reads a milter macro, tolerating Postfix's convention of wrapping
// multi-character macro names in curly braces (e.g. {auth_authen}) while
// single-character names (e.g. i) arrive bare. go-milter stores whatever name
@@ -191,8 +210,9 @@ func domainOf(addr string) string {
// Serve runs the journal-milter on ln until ctx is cancelled. Each connection
// gets a fresh session bound to rec. It returns nil on a clean shutdown.
func Serve(ctx context.Context, ln net.Listener, rec Store) error {
flight := &inflight{} // shared: the level-2 window spans all connections
srv := &milter.Server{
NewMilter: func() milter.Milter { return &session{rec: rec} },
NewMilter: func() milter.Milter { return &session{rec: rec, flight: flight} },
Actions: 0, // read-only: we make no message modifications
Protocol: milter.OptNoBody, // the journal needs headers/EOM, not the body
}
+124
View File
@@ -331,6 +331,130 @@ func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
}
}
// mailFromIn is mailFrom with an explicit shared in-flight registry, so a test
// can play several concurrent SMTP sessions of one process against each other.
func mailFromIn(t *testing.T, rec Store, fl *inflight, ip, from, login string) (*session, milter.Response) {
t.Helper()
s := &session{rec: rec, flight: fl}
if _, err := s.Connect("h", "tcp4", 0, net.ParseIP(ip), mods(nil)); err != nil {
t.Fatalf("Connect: %v", err)
}
resp, err := s.MailFrom(from, mods(map[string]string{"auth_authen": login}))
if err != nil {
t.Fatalf("MailFrom: %v", err)
}
return s, resp
}
func limitedRecorder(count int64) *fakeRecorder {
return &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": count},
}
}
// Messages between MAIL FROM and end-of-message are not in the send log yet, so
// counting the stored rows alone lets concurrent sessions each pass the same
// check and overshoot the ceiling. The last free slot may only be taken once.
func TestRateLimitCountsInFlightMessages(t *testing.T) {
rec := limitedRecorder(4) // one below the ceiling of 5
fl := &inflight{}
if _, resp := mailFromIn(t, rec, fl, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("first message = %v, want Continue (4/5 stored)", resp)
}
// Same window, nothing written yet: the first message holds the fifth slot.
if _, resp := mailFromIn(t, rec, fl, limitIP, "b@example.com", "app1"); resp != milter.RespTempFail {
t.Fatalf("concurrent message = %v, want TempFail (would overshoot)", resp)
}
if len(rec.rejected) != 1 {
t.Fatalf("want one rejected send-log row, got %+v", rec.rejected)
}
}
// Once the message is recorded the stored count sees it, so its reservation
// must be given back — otherwise it would be counted twice and the ceiling
// would drift closed.
func TestReservationReleasedAtEndOfMessage(t *testing.T) {
rec := limitedRecorder(4)
fl := &inflight{}
s, resp := mailFromIn(t, rec, fl, limitIP, "a@example.com", "app1")
if resp != milter.RespContinue {
t.Fatalf("first message = %v, want Continue", resp)
}
if _, err := s.Body(mods(map[string]string{"i": "Q1"})); err != nil {
t.Fatalf("Body: %v", err)
}
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 {
t.Fatalf("in-flight count after EOM = %d, want 0", n)
}
}
// A transaction the client abandons (RSET, or a Postfix-side rejection) never
// reaches the send log, so its slot must not stay claimed.
func TestReservationReleasedOnAbort(t *testing.T) {
rec := limitedRecorder(4)
fl := &inflight{}
s, resp := mailFromIn(t, rec, fl, limitIP, "a@example.com", "app1")
if resp != milter.RespContinue {
t.Fatalf("first message = %v, want Continue", resp)
}
if err := s.Abort(mods(nil)); err != nil {
t.Fatalf("Abort: %v", err)
}
if _, resp := mailFromIn(t, rec, fl, limitIP, "b@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("after abort = %v, want Continue (slot released)", resp)
}
}
// A refused message must not leave the slots it claimed for the limits checked
// before the one that tripped, or every refusal would tighten the ceiling.
func TestRefusalReleasesEarlierReservation(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
},
counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 0, // domain: plenty of room
store.RateLimitScopeApp + "|app1": 5, // app: at the ceiling
},
}
fl := &inflight{}
if _, resp := mailFromIn(t, rec, fl, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
t.Fatalf("app over limit = %v, want TempFail", resp)
}
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 {
t.Fatalf("domain reservation left behind after refusal: %d", n)
}
}
// The in-flight count only covers the limit's own window: a reservation older
// than it (a session stuck mid-DATA for longer than the window) must not be
// counted against a window it no longer belongs to.
func TestInflightIgnoresReservationsOutsideWindow(t *testing.T) {
fl := &inflight{}
r := fl.reserve("domain|example.com")
r.at = time.Now().Add(-time.Minute)
if n := fl.count("domain|example.com", time.Now().Add(-time.Hour)); n != 1 {
t.Fatalf("count inside window = %d, want 1", n)
}
if n := fl.count("domain|example.com", time.Now().Add(-time.Second)); n != 0 {
t.Fatalf("count outside window = %d, want 0", n)
}
// Past the TTL the reservation is dropped even for a wide window, so a
// client that vanished after MAIL FROM cannot hold a slot forever.
r.at = time.Now().Add(-2 * reservationTTL)
if n := fl.count("domain|example.com", time.Now().Add(-3*reservationTTL)); n != 0 {
t.Fatalf("expired reservation still counted: %d", n)
}
}
func TestDomainOf(t *testing.T) {
cases := map[string]string{
"user@Example.COM": "example.com",
+24
View File
@@ -17,6 +17,10 @@ import (
// can never block mail — Postfix's level-1 anvil limit (spec 5) remains the
// backstop, and it does not depend on this milter at all. Only a clean count at
// or above a configured ceiling returns true.
//
// A message that passes reserves a slot per applicable limit, released once it
// reaches the send log (or is abandoned) — see inflight for why the stored
// count alone is not enough.
func (s *session) overLimit() bool {
if s.clientIP == "" {
return false // no client IP to key on; level-2 does not apply
@@ -25,6 +29,7 @@ func (s *session) overLimit() bool {
{store.RateLimitScopeDomain, domainOf(s.from)},
{store.RateLimitScopeApp, s.login},
}
var taken []*reservation
for _, c := range checks {
if c.ref == "" {
continue
@@ -45,15 +50,34 @@ func (s *session) overLimit() bool {
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", c.scope, c.ref, err)
continue
}
key := c.scope + "|" + c.ref
n += s.flight.count(key, since)
if n >= int64(rl.MaxMessages) {
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
c.scope, c.ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
// The message is refused, so the slots claimed for the limits
// checked before this one must not stay claimed.
for _, r := range taken {
s.flight.release(r)
}
return true
}
taken = append(taken, s.flight.reserve(key))
}
s.reserved = append(s.reserved, taken...)
return false
}
// releaseReservations gives back every slot this message holds. It runs once
// the message is in the send log (where the stored count sees it), and whenever
// the transaction ends without getting there.
func (s *session) releaseReservations() {
for _, r := range s.reserved {
s.flight.release(r)
}
s.reserved = nil
}
// recordRejected writes a send-log row for a message refused by a level-2 limit
// (spec 7.4, "опционально фиксирует ... для видимости в UI"), so the rejection
// shows up in the monitoring screen. Only MAIL-stage fields are known; the write
+54
View File
@@ -0,0 +1,54 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// LogtailState is the persisted read position of the mail.log tailer: how far
// into the log it had got, and a fingerprint of the file that offset refers to.
// It lets the tailer resume after a panel restart instead of jumping to
// end-of-file and losing the delivery lines written while it was down.
type LogtailState struct {
Fingerprint string
Offset int64
}
// LogtailState returns the stored read position for path. ok is false the first
// time a path is followed (nothing persisted yet), which the tailer treats as
// "start at the end".
func (s *Store) LogtailState(path string) (LogtailState, bool, error) {
var st LogtailState
err := s.db.QueryRow(
`SELECT fingerprint, read_offset FROM logtail_state WHERE path = ?`,
path,
).Scan(&st.Fingerprint, &st.Offset)
if errors.Is(err, sql.ErrNoRows) {
return LogtailState{}, false, nil
}
if err != nil {
return LogtailState{}, false, fmt.Errorf("read logtail state for %q: %w", path, err)
}
return st, true, nil
}
// SaveLogtailState records the tailer's read position for path, replacing any
// previous one. It is called on a timer while tailing, so it is a single small
// upsert rather than a transaction.
func (s *Store) SaveLogtailState(path string, st LogtailState) error {
_, err := s.db.Exec(
`INSERT INTO logtail_state (path, fingerprint, read_offset, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
fingerprint = excluded.fingerprint,
read_offset = excluded.read_offset,
updated_at = excluded.updated_at`,
path, st.Fingerprint, st.Offset, time.Now().UTC().Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("save logtail state for %q: %w", path, err)
}
return nil
}
@@ -0,0 +1,12 @@
-- Log-tailer read position. Without it the tailer starts at end-of-file on
-- every start, so delivery lines written while the panel was down are never
-- parsed and their send-log rows stay "queued" forever. One row per followed
-- path; fingerprint identifies the file the offset belongs to (the head bytes
-- of the log), so a rotated or recreated mail.log is detected across a restart,
-- where os.SameFile cannot help.
CREATE TABLE logtail_state (
path TEXT PRIMARY KEY,
fingerprint TEXT NOT NULL,
read_offset INTEGER NOT NULL,
updated_at TEXT NOT NULL
);