Phase 8: level-2 differentiated rate limits (spec 7.4)

The journal-milter, until now a pure monitor, now refuses a message with a
4xx tempfail (RespTempFail/451) at MAIL FROM when a per-domain or per-
application limit is exceeded. Key is the client IP; the count is
COUNT(DISTINCT queue_id) over a sliding window reusing the send log; the
limit applies only when a non-empty IP binding matches the client (empty
binding => level-1 only, per spec 7.4). Enforcement is fail-open on the
milter's own errors — a limiter malfunction never blocks mail, and Postfix's
level-1 anvil limit stays the independent backstop. Refused messages are
recorded in send_log with status "rejected" for UI visibility.

- store/ratelimits.go: RateLimit type (+Active/AllowsIP), id-keyed get/set/
  delete for the panel, name/login-keyed lookup + windowed distinct-message
  count for the milter, DeleteRateLimitsForDomain. No migration — the
  rate_limits table has existed since Phase 2.
- milter: enforce at MailFrom, fail-open helper overLimit, InsertRejected.
- web: server-side validated IP/ceiling/window forms on the domain page and
  per application; routes POST /domains/{id}/ratelimit and
  /applications/{aid}/ratelimit. Milter reads rows live, so no reload.
- domain/app services clear limits on deletion (rate_limits has no FK cascade).

Unit tests + container e2e (p8) green: refusal on both scopes, unregistered
IP ignored, fail-open with the panel stopped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 21:57:38 +03:00
parent 9d4942aef6
commit 223f3cdc42
13 changed files with 1030 additions and 24 deletions
+20 -6
View File
@@ -16,16 +16,22 @@ import (
"net"
"net/textproto"
"strings"
"time"
"github.com/emersion/go-milter"
"codeberg.org/mix/selfpost/internal/store"
)
// Recorder persists queued send-log entries. *store.Store satisfies it; tests
// substitute a fake.
type Recorder interface {
// Store is the persistence the milter needs on the receive path: recording
// accepted messages (spec 7.3) and, for level-2 rate limiting (spec 7.4),
// looking up the configured limits and counting recent messages. *store.Store
// satisfies it; tests substitute a fake.
type Store interface {
InsertQueued(e store.SendLogEntry) error
InsertRejected(e store.SendLogEntry) error
RateLimit(scope, ref string) (store.RateLimit, bool, error)
CountMessages(scope, ref string, since time.Time) (int64, error)
}
// session accumulates the fields of one message as the milter callbacks fire.
@@ -37,7 +43,7 @@ type Recorder interface {
// MailFrom (the start of every transaction).
type session struct {
milter.NoOpMilter
rec Recorder
rec Store
clientIP string // captured once per connection
@@ -59,12 +65,20 @@ func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milt
// MailFrom starts a new message: reset per-message state, then capture the
// envelope sender and the SASL login ({auth_authen}, carried by the MAIL-stage
// macros).
// macros). This is also the earliest stage where both the sending domain (from
// the sender) and the application (the login) are known, so the level-2 rate
// limit is enforced here: over the limit, the message is refused with a 4xx
// 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.from = cleanAddress(from)
s.login = macro(m, "auth_authen")
s.rcpts = nil
s.subject = ""
if s.overLimit() {
s.recordRejected()
return milter.RespTempFail, nil
}
return milter.RespContinue, nil
}
@@ -152,7 +166,7 @@ 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 Recorder) error {
func Serve(ctx context.Context, ln net.Listener, rec Store) error {
srv := &milter.Server{
NewMilter: func() milter.Milter { return &session{rec: rec} },
Actions: 0, // read-only: we make no message modifications