Files
selfpost/internal/milter/milter.go
T
mixandClaude Opus 5.5 c171fb5bc7
test / test (push) Canceled after 0s
from-header-binding: the sender check, what it costs, its pages; guide, architecture, security
A message's From: line is held against the application that sends it, under
the rule Postfix holds the envelope sender to. OpenDKIM chooses the key by
that line, so until now an application could have mail signed as another
domain of the server or as the server itself.

The check (internal/milter/sender.go)
- A role of the panel on a socket of its own (SENDER_CHECK_SOCKET), first in
  smtpd_milters and strict: default_action=tempfail. The journal milter keeps
  its socket and stays fail-open. postfix-wrapper waits for the new socket.
- It reads no database: what an application may send as comes from Postfix's
  own sender_login_maps, what the server signs for from OpenDKIM's
  SigningTable. Both are read again when they change; one that cannot be
  read defers mail.
- One From: field, one address, allowed by the envelope's lookup; then no
  other name of the SigningTable may appear anywhere in the raw line.
  Refused: 550 5.7.1 naming the line, reduced to printable ASCII.
- A refusal is a send-log row with reason "from" and the header address
  (migration 0006), written by another goroutine and not waited for. The
  journal's refusals record their reason too (limit, client), and the
  notifier takes only a limit's.

What it costs (docs/plans/from-header-binding.md, Statistics)
- The role marks itself alive every 2 s; at start the gap since the mark is
  downtime, counted from Postfix's start if later and not at all when
  supervisord says Postfix is not running.
- The log tailer counts the connections Postfix turned away: a warning that
  names the socket, then milter-reject "Service unavailable" from the same
  smtpd. Seen on the stand: the client is refused from CONNECT on and leaves
  at AUTH (454), so no MAIL line is ever written.
- sender_check_days, a row per UTC day, kept a year.

Pages
- Health: the card "Sender check", the socket's row, and the cards layout -
  boxes numbered by importance in two stacks read in rows; the left stack
  polls, the three readings of the right one are swapped in by id.
- Outbound log: a refused row says why in its tag (wrong From, wrong client,
  rate-limited); the message's page shows the From: line and what the
  application may send as.
- Help: topic "Sender check", word for word the mockup's.

Found by the stand, fixed here: the names for the second look came from the
sender bindings, and a domain with a key and no application yet was not
among them.

e2e: negative_journal_milter_fail_open is gone - it proved that mail passes
with the panel down, which is no longer so. In its place
sender_check_holds_the_from_line (another domain's From:, the hostname's, a
stranger's, an address off the list, another sender in the display name -
refused; own addresses - delivered) and panel_down_defers_mail (4xx with the
panel stopped, the container alive, mail again after, downtime and one
deferred message on Health, the card warning).

Test server, this tree before the last cosmetic edits (a fixture row, a
fact's size, docs): go vet ok, go test 23 packages ok, make e2e ok (337 s).
Locally after them: internal/web/view ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:35:43 +03:00

251 lines
9.3 KiB
Go

// Package milter implements the SelfPost journal-milter: a lightweight milter
// (architecture.md § Mail path) attached to Postfix's smtpd_milters alongside
// OpenDKIM. On the receive path it reads the SASL login, From, recipients and
// Subject of each accepted message and records one send-log row per (queue-id,
// recipient), giving the panel a structured, filterable history that raw
// mail.log cannot.
//
// It is monitoring only: it never rejects, and every callback returns Continue
// or Accept so a failure of this milter can never block the relay. Postfix is
// configured with default_action=accept for this milter's socket, so even a
// crash or hang fails open (architecture.md § Mail path).
package milter
import (
"context"
"log"
"net"
"net/textproto"
"strings"
"time"
"github.com/emersion/go-milter"
"github.com/mixeme/selfpost/internal/mailhdr"
"github.com/mixeme/selfpost/internal/store"
)
// Store is the persistence the milter needs on the receive path: recording
// accepted messages (architecture.md § Mail path) and, for level-2 rate
// limiting (guide § Rate limiting), 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)
ApplicationByLogin(login string) (store.Application, error)
}
// session accumulates the fields of one message as the milter callbacks fire.
// Milter macros arrive per-stage and do not accumulate, so each value is
// captured at the stage that carries it (architecture.md § Mail path): SASL
// login and From at MAIL, each recipient at RCPT, Subject in the headers, and
// the queue-id at end-of-message. go-milter creates one session per
// connection; a connection may carry several messages, so per-message fields
// are reset at MailFrom (the start of every transaction).
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 is the client's address. Postfix gives it at Connect, once per
// connection, and again with every MAIL FROM as the {client_addr} macro
// (milter_mail_macros in build/postfix-config.sh). The second is the one
// that can be relied on: see MailFrom.
clientIP string
login string
from string
rcpts []string
subject string
reserved []*reservation // level-2 slots held by the current message
}
// Connect captures the client IP of a connection's first message. It is not
// enough on its own - see MailFrom.
func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milter.Modifier) (milter.Response, error) {
if addr != nil {
s.clientIP = addr.String()
}
return milter.RespContinue, nil
}
// 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). 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 (guide § Rate limiting).
// Enforcement is fail-open — see overLimit.
//
// The client address is read again here, from the {client_addr} macro. The
// milter library replaces the session with a new one after every reply that is
// not "continue" - a refusal, or the accept that used to end a message - and
// Postfix does not send Connect again on the same connection. A session made
// that way never saw the client, so every message of a connection after the
// first, and every MAIL FROM after a refused one, used to be judged with no
// client address: the level-2 limit and an application's client-address list
// both let it through. (Found on 2026-10-12; test/e2e sends two MAIL FROM on
// one connection.)
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
if ip := clientAddr(m); ip != "" {
s.clientIP = ip
}
s.from = cleanAddress(from)
s.login = macro(m, "auth_authen")
s.rcpts = nil
s.subject = ""
if !s.authIPAllowed() {
s.recordRejected(store.ReasonClient)
return milter.RespTempFail, nil
}
if s.overLimit() {
s.recordRejected(store.ReasonLimit)
return milter.RespTempFail, nil
}
return milter.RespContinue, nil
}
// RcptTo records each recipient. Postfix calls this once per recipient, which
// is what lets the journal keep a separate row per (queue-id, recipient).
func (s *session) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, error) {
s.rcpts = append(s.rcpts, cleanAddress(rcpt))
return milter.RespContinue, nil
}
// Header captures the Subject. Only the first Subject header is kept.
func (s *session) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
if s.subject == "" && textproto.CanonicalMIMEHeaderKey(name) == "Subject" {
s.subject = mailhdr.DecodeSubject(value)
}
return milter.RespContinue, nil
}
// Body fires at end-of-message, when the queue-id macro {i} is set and the
// message is about to be committed to the queue. This is where the "queued"
// rows are written. The reply is "continue", which at end-of-message lets the
// message through just as "accept" does - and, unlike it, leaves this session
// in place for the connection's next message (see MailFrom).
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.RespContinue, 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
// Postfix sends verbatim, so a lookup must try both forms.
func macro(m *milter.Modifier, name string) string {
if v, ok := m.Macros[name]; ok {
return v
}
return m.Macros["{"+name+"}"]
}
// clientAddr is the client's address from the {client_addr} macro, or "" when
// Postfix did not send one that reads as an address. An IPv6 address may come
// in Sendmail's form, with "IPv6:" in front.
func clientAddr(m *milter.Modifier) string {
raw := strings.TrimSpace(macro(m, "client_addr"))
if len(raw) > 5 && strings.EqualFold(raw[:5], "IPv6:") {
raw = raw[5:]
}
ip := net.ParseIP(raw)
if ip == nil {
return ""
}
return ip.String()
}
// record writes one send-log row per recipient. Failures are logged, never
// propagated: journalling must not affect mail acceptance (architecture.md §
// Mail path).
func (s *session) record(queueID string) {
domain := domainOf(s.from)
rcpts := s.rcpts
if len(rcpts) == 0 {
// No recipient seen (unusual) — still record the message so it is
// visible in the log rather than silently dropped.
rcpts = []string{""}
}
for _, to := range rcpts {
err := s.rec.InsertQueued(store.SendLogEntry{
QueueID: queueID,
Domain: domain,
AppLogin: s.login,
From: s.from,
To: to,
Subject: s.subject,
})
if err != nil {
log.Printf("journal-milter: record %s -> %s: %v", queueID, to, err)
}
}
}
// cleanAddress strips the angle brackets and any ESMTP parameters Postfix may
// pass with an address, leaving the bare mailbox.
func cleanAddress(a string) string {
a = strings.TrimSpace(a)
if i := strings.IndexByte(a, ' '); i >= 0 { // drop "addr SIZE=… BODY=…" params
a = a[:i]
}
a = strings.TrimPrefix(a, "<")
a = strings.TrimSuffix(a, ">")
return a
}
// domainOf returns the lower-cased domain of an email address, or "" if there
// is no domain part. Sender binding guarantees the From domain equals the
// application's domain, so this is the sending domain (architecture.md § Mail
// path).
func domainOf(addr string) string {
if i := strings.LastIndexByte(addr, '@'); i >= 0 {
return strings.ToLower(addr[i+1:])
}
return ""
}
// 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, flight: flight} },
Actions: 0, // read-only: we make no message modifications
Protocol: milter.OptNoBody, // the journal needs headers/EOM, not the body
}
go func() {
<-ctx.Done()
_ = srv.Close()
}()
if err := srv.Serve(ln); err != nil {
if ctx.Err() != nil {
return nil // expected: Close() during shutdown unblocks Serve
}
return err
}
return nil
}