milter, postfix, app, domain: cite the source of what they rely on (assumptions part B)

Comments only, no behaviour changed. go-milter v0.4.1 (session.go: one
session per connection, replaced after a non-continue reply; macros per
stage), Postfix 3.7.11 (milter8.c, smtpd_check.c
reject_auth_sender_login_mismatch(), mail_addr_find.c, showq_compat.c,
mail_conf_time.c, mail_params.h), Cyrus SASL 2.1.28 (saslpasswd.c,
plugins/sasldb.c: deleting a missing login is not an error), OpenDKIM
2.11.0~beta2 (opendkim-db.c, opendkim-genkey), Berkeley DB 5.3 db_dump,
rspamd 3.11.0. Comments that claimed what the source denies are corrected
(sasldb keeps the password as given, not hashed; an embedded newline is
kept, not truncated; rspamd sorts symbols by absolute weight); fixtures say
seen or composed. The sender check's From: parsing gets no citations: it is
replaced by the port of OpenDKIM's dkim_mail_parse next.

Contradictions left for their own commits: B-G5 cleanAddress, B-PS1 first
key wins, B-PQ1 the forced-expire flag, B-PQ10/PQ11 Postfix's time units.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 19:36:12 +03:00
co-authored by Claude Opus 5.5
parent ce363f48dd
commit b113e72c71
23 changed files with 307 additions and 26 deletions
+27
View File
@@ -29,6 +29,12 @@ type SASLDB struct {
// NewSASLDB builds a manager for the sasldb2 at path with the given realm. The
// realm should match SELFPOST_HOSTNAME so the account identity lines up with
// Postfix's SASL configuration.
//
// Postfix 3.7.11, src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create():
// sasl_server_new() gets $myhostname and, with smtpd_sasl_local_domain empty,
// no user realm; Cyrus SASL 2.1.28, plugins/sasldb.c,
// sasldb_auxprop_lookup(), then looks a login without "@" up under the
// server's name (common/plugin_common.c, _plug_parseuser()).
func NewSASLDB(path, realm string) *SASLDB {
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
}
@@ -46,6 +52,11 @@ func (s *SASLDB) Set(login, password string) error {
if err := validateLogin(login); err != nil {
return err
}
// Cyrus SASL 2.1.28, utils/saslpasswd.c, main(): getopt(), which ends the
// options at "--"; -p reads the password with one read() and no prompt
// (read_password()); -c is SASL_SET_CREATE, which lib/server.c,
// sasl_setpass(), checks against nothing - an existing entry is written
// over (sasldb/db_berkeley.c, _sasldb_putdata(), put with no flags).
// -p: read the passphrase from stdin (pipe mode, no tty prompt).
// -c: create the account / set the password.
// -f: operate on our sasldb2 rather than the system default path.
@@ -62,6 +73,11 @@ func (s *SASLDB) Set(login, password string) error {
// Delete removes an application's SASL account (product.md). A missing account
// is not treated as an error, so deletion is idempotent and safe to retry.
//
// Cyrus SASL 2.1.28, plugins/sasldb.c, sasldb_auxprop_store(): "Deleting
// something which is not there is not an error" - the SASL_NOUSER of
// sasldb/db_berkeley.c, _sasldb_putdata(), becomes SASL_OK, so saslpasswd2 -d
// on a missing login exits 0 (utils/saslpasswd.c, main()).
func (s *SASLDB) Delete(login string) error {
if err := validateLogin(login); err != nil {
return err
@@ -110,6 +126,17 @@ func (s *SASLDB) Secret(login string) (string, error) {
// parseSASLSecret scans db_dump's byte-value output for the userPassword entry
// keyed by (login, realm). sasldb2 keys are NUL-separated tuples
// "<login>\0<realm>\0<property>"; the matching value is the stored password.
//
// Cyrus SASL 2.1.28: sasldb/allockey.c, _sasldb_alloc_key(), builds the key
// with no NUL at its end; plugins/sasldb.c, sasldb_auxprop_store(), stores the
// value with strlen(); lib/server.c, sasl_setpass(), stores the plaintext
// userPassword unless SASL_SET_NOPLAIN (saslpasswd2 -n, which Set does not
// pass). Built against Berkeley DB (m4/sasldb.m4 auto_detect; debian/rules
// records BDB_VERSION). Berkeley DB 5.3 Programmer's Reference, "Dump output
// formats" (docs.oracle.com/cd/E17076_02/html/programmer_reference/
// dumpload_format.html): header lines up to HEADER=END, then for a hash or
// btree database key and data lines in turn, each a single space and hex,
// and DATA=END.
func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) {
sc := bufio.NewScanner(bytes.NewReader(dump))
// sasldb2 records are tiny, but raise the line cap so a long hex line is
+4 -2
View File
@@ -66,8 +66,10 @@ func TestSASLDeleteArgs(t *testing.T) {
}
}
// makeDump builds a db_dump byte-value document from key/value byte pairs, the
// same shape `db_dump <sasldb2>` emits.
// makeDump builds a db_dump byte-value document from key/value byte pairs. It
// is composed by hand in the shape the Berkeley DB 5.3 "Dump output formats"
// page gives; a real dump may have more name=value header lines (the
// db_load keywords) before HEADER=END, which the parser skips.
func makeDump(pairs [][2][]byte) []byte {
var b strings.Builder
b.WriteString("VERSION=3\nformat=bytevalue\ntype=hash\nHEADER=END\n")
+6 -1
View File
@@ -31,6 +31,9 @@ func invalid(format string, args ...any) error {
// applications to the panel. The store refuses such a pair as well
// (idx_applications_login_nocase); a login imported from another instance is
// not folded, since its SASL account and its client use it as it is.
//
// Postfix 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
// the SASL login is compared with each owner by strcasecmp_utf8().
func foldLogin(login string) string {
return strings.ToLower(login)
}
@@ -38,7 +41,9 @@ func foldLogin(login string) string {
// validateLogin enforces a strict server-side whitelist for the SASL login
// (security.md). It intentionally excludes '@': the login is stored in sasldb2,
// where '@' separates the user from the realm, so allowing it would change the
// account's identity. Client validation is never trusted.
// account's identity. Client validation is never trusted. (Cyrus SASL 2.1.28,
// common/plugin_common.c, _plug_parseuser(): a login is split into user and
// realm at its first "@", for saslpasswd2's store and for the lookup alike.)
//
// The login is the one piece of user input that is passed to saslpasswd2 as a
// command argument (never through a shell, security.md); this whitelist is what
+6
View File
@@ -37,6 +37,12 @@ func generateDKIMKey() (*rsa.PrivateKey, error) {
// and entrypoint.sh); the parent directory carries setgid so the group is
// inherited. The write is atomic (temp file + rename) so OpenDKIM never observes
// a half-written key.
//
// OpenDKIM 2.11.0~beta2, libopendkim/dkim.c, dkim_privkey_load(): a key that
// starts as PEM goes to OpenSSL's PEM_read_bio_PrivateKey(), which takes this
// PKCS#1 form; opendkim/opendkim.c, dkimf_add_signrequest(): a key file
// dkimf_securefile() finds insecure is only logged while RequireSafeKeys is no
// (build/opendkim.conf).
func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error {
block := &pem.Block{
Type: "RSA PRIVATE KEY",
+22 -1
View File
@@ -175,6 +175,10 @@ func (o *OpenDKIM) Rebuild(domains []SigningDomain) error {
// The selector is part of the question because the panel shows and checks the
// record of one key; tables that name another would have OpenDKIM sign with a
// key whose record nobody is told to publish.
//
// OpenDKIM 2.11.0~beta2, opendkim/util.c, dkimf_mkregexp(): "*@name" is
// matched as ^.*@name$ (case aside), so it signs name and none of its
// subdomains (see Signers.Signs in internal/milter/sender.go).
func (o *OpenDKIM) signs(name, selector string) (bool, error) {
has := func(path, prefix string) (bool, error) {
data, err := os.ReadFile(path)
@@ -222,6 +226,13 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab
sorted := append([]SigningDomain(nil), domains...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
// OpenDKIM 2.11.0~beta2: KeyTable is a file: table, the key up to the
// first blank (opendkim/opendkim-db.c, dkimf_db_open(), DKIMF_DB_TYPE_FILE);
// opendkim/opendkim.c, dkimf_add_signrequest(), reads domain, selector and
// key from its value, split at ":" (opendkim-db.c, dkimf_db_datasplit()),
// a key starting with "/" being a file. SigningTable is refile:, a pattern
// up to the first blank and the key name after it (dkimf_db_open(), case
// DKIMF_DB_TYPE_REFILE).
var kt, st strings.Builder
for _, d := range sorted {
if err := assertConfigSafe(d.Name, d.Selector); err != nil {
@@ -240,7 +251,9 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab
// opendkimForbidden is what may never appear in a KeyTable or SigningTable
// value: whitespace and the newline that would end the line, and the colon and
// path separators that carry meaning inside a table entry.
// path separators that carry meaning inside a table entry. (OpenDKIM
// 2.11.0~beta2, opendkim/opendkim-db.c, dkimf_db_datasplit(), splits a
// KeyTable value at ":"; the domain and selector are also the key's path.)
const opendkimForbidden = " \t\r\n:/\\"
// assertConfigSafe rejects any domain/selector value that could break out of a
@@ -268,6 +281,14 @@ func assertConfigSafe(domainName, selector string) error {
//
// Arguments are fixed literals — no user input is interpolated into the command,
// and it never goes through a shell (security.md).
//
// OpenDKIM 2.11.0~beta2, opendkim/opendkim.c, dkimf_reloader(): SIGUSR1 sets
// reload when a configuration file was given (-x in build/supervisord.conf);
// dkimf_config_reload(), from mlfi_negotiate() at the next milter connection,
// loads the configuration and opens the tables again (opendkim.8, OPERATION).
// supervisor 4.2.5, supervisor/rpcinterface.py, signalProcess(), and
// process.py, signal(): kill() of the program's own pid - opendkim itself, run
// with -f (not forked into the background) and exec'd with no shell.
func reloadViaSupervisor() error {
return supervisor.Signal("USR1", "opendkim")
}
+29 -1
View File
@@ -33,6 +33,11 @@ type InboundStore interface {
// Like the send-log milter it only watches: every callback continues, and
// Postfix attaches the socket with default_action=accept, so
// neither an error here nor the panel being down holds up inbound mail.
//
// Postfix 3.7.11, src/milter/milter.c, milter_message(): the milters are asked
// in turn while the reply is null, so one that refuses is the last asked;
// src/milter/milter8.c, milter8_comm_error(): default_action=accept gives no
// reply when this milter cannot answer.
type inboundSession struct {
milter.NoOpMilter
rec InboundStore
@@ -85,6 +90,17 @@ func (s *inboundSession) RcptTo(rcpt string, m *milter.Modifier) (milter.Respons
// filter's headers arrive here. The filter replaces headers of these names, so
// the last one read is its own - when it is up. Whether it was is decided at
// end-of-message, by the signature.
//
// Postfix 3.7.11, src/milter/milter.c, milter_message(), hands each milter the
// queue file from the start of the content; src/milter/milter8.c,
// milter8_message(), reads it with rec_get(), which follows the pointer
// records src/cleanup/cleanup_milter.c, cleanup_add_header(), leaves after
// the last header for a header the milter before added.
// rspamd 3.11.0, src/plugins/lua/milter_headers.lua, routines x-spamd-result
// and x-rspamd-action: remove = 0, every instance
// (src/libserver/milter.c, rspamd_milter_remove_header_safe()), and the new
// one added at the end (order -1, ADDHEADER) - when skip_wanted() does not
// skip the routine (skip_local, skip_authenticated).
func (s *inboundSession) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
switch textproto.CanonicalMIMEHeaderKey(name) {
case "Subject":
@@ -112,6 +128,10 @@ var signatureHeader = textproto.CanonicalMIMEHeaderKey(SignatureHeader)
// filter, in hex. The key is used as the text it is stored as. A signature
// fits one queue id, so the header going on to the upstream with the mail
// gives nothing to whoever reads it there.
//
// Measured, not read: what rspamd 3.11 writes for a key and a queue id was
// taken in a throwaway container on 2026-10-12
// (TestFilterSignatureIsWhatTheFilterWrites).
func FilterSignature(key, queueID string) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(queueID))
@@ -130,6 +150,10 @@ func (s *inboundSession) signed(queueID string) bool {
// Body fires at end-of-message: the message is about to be queued for its
// upstream. "Continue" lets it through as "accept" would, and keeps this
// session for the connection's next message.
//
// The queue id: Postfix 3.7.11, src/global/mail_params.h, DEF_MILT_EOD_MACROS
// "i", answered by src/cleanup/cleanup_milter.c, cleanup_milter_eval(). The
// rest as session.Body in milter.go.
func (s *inboundSession) Body(m *milter.Modifier) (milter.Response, error) {
// The queue id is known only now, and with it whether the verdict headers
// read on the way here are the filter's.
@@ -196,6 +220,9 @@ const (
// (deploy/antispam/rspamd/multimap.conf): SELFPOST_ALLOW_IP, _FROM, _DOMAIN.
const ListSymbolPrefix = "SELFPOST_"
// rspamd 3.11.0, src/plugins/lua/milter_headers.lua, routine x-spamd-result:
// string.format('default: %s [%.2f / %.2f]', ...), then one
// '%s(%.2f)[%s]' per symbol, joined by '; '.
var (
// "default: False [7.20 / 15.00]" - the score and the reject threshold.
rspamdScore = regexp.MustCompile(`\[(-?[0-9]+(?:\.[0-9]+)?) / (-?[0-9]+(?:\.[0-9]+)?)\]`)
@@ -267,7 +294,8 @@ func rspamdVerdict(actionHeader, resultHeader string) (action, detail string) {
// ServeInbound runs the inbound journal milter on ln until ctx is cancelled.
// key is what the external filter in front of it signs messages with, or
// empty when none is attached (INBOUND_ANTISPAM_MILTER): no verdict is then
// read at all. It returns nil on a clean shutdown.
// read at all. It returns nil on a clean shutdown. What it relies on of
// go-milter and Postfix is cited at Serve in milter.go.
func ServeInbound(ctx context.Context, ln net.Listener, rec InboundStore, key string) error {
srv := &milter.Server{
NewMilter: func() milter.Milter { return &inboundSession{rec: rec, key: key} },
+8 -4
View File
@@ -34,7 +34,9 @@ func (f *fakeInbound) IsInboundDomain(name string) (bool, error) {
return f.domains[name], nil
}
// A header of the kind the documented rspamd sidecar adds.
// A header in the format of the documented rspamd sidecar, composed by hand:
// rspamd 3.11's milter_headers.lua writes the same fields, but sorts the
// symbols by absolute weight (sort_by = 'score'), which this order is not.
const spamdResult = "default: False [7.20 / 15.00];\n\tBAYES_SPAM(5.10)[100.00%];\n\tMIME_GOOD(-0.10)[text/plain];\n\tARC_NA(0.00)[];\n\tRCVD_COUNT_ZERO(0.00)[0];\n\tR_DKIM_NA(2.20)[]"
// message replays one message through s: envelope, headers in order, end.
@@ -233,9 +235,11 @@ func TestInboundAcceptsWhenTheStoreFails(t *testing.T) {
}
}
// rspamd writes symbols heaviest first, so an allow entry's -100 comes last.
// Filter lists counts an entry's matches by finding that symbol in the journal:
// it is kept whatever else there is.
// A composed header with the allow entry's -100 behind six others. rspamd
// 3.11 itself sorts by absolute weight and would put it first
// (milter_headers.lua, x-spamd-result, sort_by = 'score'); the order here is
// the hard case for the reader. Filter lists counts an entry's matches by
// finding that symbol in the journal: it is kept whatever else there is.
func TestRspamdVerdictKeepsTheListSymbols(t *testing.T) {
result := "default: False [-91.00 / 15.00]; SYM_A(3.00)[]; SYM_B(2.00)[]; SYM_C(1.50)[]; SYM_D(1.00)[]; SYM_E(0.80)[]; SYM_F(0.50)[]; SYM_G(0.20)[]; ARC_NA(0.00)[]; SELFPOST_ALLOW_FROM(-100.00)[billing@vendor.example]"
_, detail := rspamdVerdict("no action", result)
+4
View File
@@ -12,6 +12,10 @@ import (
// 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.
//
// go-milter v0.4.1, server.go, type Milter: no callback for the end of a
// connection; session.go, HandleMilterCommands(), returns on SMFIC_QUIT or a
// read error without calling the backend.
const reservationTTL = 10 * time.Minute
// reservation is one message that passed the level-2 check and has not been
+58 -1
View File
@@ -9,6 +9,10 @@
// 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).
//
// Postfix 3.7.11, src/milter/milter8.c, milter8_comm_error(): a milter that
// cannot be reached, or does not answer within milter_*_timeout, gives no
// reply with default_action=accept - the message goes on.
package milter
import (
@@ -45,6 +49,12 @@ type Store interface {
// 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).
//
// go-milter v0.4.1, session.go, Process(), case CodeMacro: every macro packet
// replaces the map; server.go, Serve(): NewMilter() for each connection.
// Postfix 3.7.11, src/milter/milter8.c, milter8_event(): a macro packet, empty
// or not, goes before every event (milter.c, milter_macro_lookup(), never
// returns null).
type session struct {
milter.NoOpMilter
rec Store
@@ -56,7 +66,9 @@ type session struct {
// 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.
// that can be relied on: see MailFrom. Postfix 3.7.11,
// src/smtpd/smtpd_milter.c, smtpd_milter_eval(): {client_addr} is
// state->rfc_addr (see clientAddr).
clientIP string
login string
@@ -68,6 +80,11 @@ type session struct {
// Connect captures the client IP of a connection's first message. It is not
// enough on its own - see MailFrom.
//
// Postfix 3.7.11, src/smtpd/smtpd.c, smtpd_proto(): milter_conn_event() is
// given state->addr, the address without "IPv6:". go-milter v0.4.1,
// session.go, case CodeConn: net.ParseIP of it - nil for "unknown" and for a
// family that sends no address.
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()
@@ -92,6 +109,17 @@ func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milt
// 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.)
//
// go-milter v0.4.1, session.go, HandleMilterCommands(): after a reply whose
// Continue() is false (response.go: accept, discard, reject, tempfail) the
// backend is replaced by NewMilter(). Postfix 3.7.11, src/milter/milter8.c,
// milter8_conn_event(): CONNECT goes once per milter connection.
// {auth_authen}: src/smtpd/smtpd_milter.c, smtpd_milter_eval(), gives
// state->sasl_username, null without authentication, and then milter.c,
// milter_macro_lookup(), sends no such macro.
// The tempfail: milter8.c, milter8_event(), SMFIR_TEMPFAIL is "451 4.7.1";
// src/smtpd/smtpd.c, mail_cmd(), replies it and calls mail_reset() - the
// transaction ends, the connection goes on.
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 != "" {
@@ -114,6 +142,10 @@ func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, er
// RcptTo records each recipient. Postfix calls this once per recipient, which
// is what lets the journal keep a separate row per (queue-id, recipient).
//
// Postfix 3.7.11, src/smtpd/smtpd.c, rcpt_cmd(): milter_rcpt_event() once per
// RCPT TO; a recipient Postfix refused goes only to a milter that asked for
// SMFIP_RCPT_REJ, which Serve's Protocol does not.
func (s *session) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, error) {
s.rcpts = append(s.rcpts, cleanAddress(rcpt))
return milter.RespContinue, nil
@@ -132,6 +164,12 @@ func (s *session) Header(name, value string, m *milter.Modifier) (milter.Respons
// 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).
//
// go-milter v0.4.1, session.go, case CodeEOB: Body(). Postfix 3.7.11,
// src/global/mail_params.h: DEF_MILT_EOD_MACROS "i", which
// src/cleanup/cleanup_milter.c, cleanup_milter_eval(), answers with the queue
// id. src/milter/milter8.c, milter8_event(): SMFIR_CONTINUE returns the
// milter's default reply, none, and the message goes on.
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
@@ -143,6 +181,11 @@ func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
// 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.
//
// Postfix 3.7.11, src/smtpd/smtpd.c: rset_cmd() and the refusals call
// mail_reset(), which calls milter_abort() (src/milter/milter8.c,
// milter8_abort(): SMFIC_ABORT); so do the end of every transaction and the
// end of the session in smtpd_proto().
func (s *session) Abort(m *milter.Modifier) error {
s.releaseReservations()
s.rcpts = nil
@@ -154,6 +197,10 @@ func (s *session) Abort(m *milter.Modifier) error {
// 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.
//
// Postfix 3.7.11, src/milter/milter.c, milter_macro_lookup(): each name is
// sent as milter_*_macros spells it. go-milter v0.4.1, session.go, case
// CodeMacro: m.macros[name] = value, the name as received.
func macro(m *milter.Modifier, name string) string {
if v, ok := m.Macros[name]; ok {
return v
@@ -164,6 +211,10 @@ func macro(m *milter.Modifier, name string) string {
// 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.
//
// Postfix 3.7.11, src/smtpd/smtpd_peer.c, smtpd_peer_sockaddr_to_hostaddr():
// rfc_addr is the dotted quad, "IPv6:" + the address for IPv6, a V4-mapped
// address rewritten to IPv4, or "unknown".
func clientAddr(m *milter.Modifier) string {
raw := strings.TrimSpace(macro(m, "client_addr"))
if len(raw) > 5 && strings.EqualFold(raw[:5], "IPv6:") {
@@ -248,6 +299,12 @@ 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.
//
// go-milter v0.4.1, server.go: Close() closes the listener and Serve() then
// returns ErrServerClosed. Postfix 3.7.11, src/milter/milter8.c,
// milter8_message(): SMFIP_NOBODY leaves out only the body chunks - headers,
// end-of-headers and end-of-body (milter8_eob()) still go; milter8_event()
// takes a reply code (SMFIR_REPLYCODE) whatever the action mask.
func Serve(ctx context.Context, ln net.Listener, rec Store) error {
flight := &inflight{} // shared: the level-2 window spans all connections
srv := &milter.Server{
+5 -3
View File
@@ -197,9 +197,11 @@ func TestSessionResetsBetweenMessages(t *testing.T) {
}
}
// Postfix sends multi-character macro names wrapped in braces ({auth_authen},
// {i} for some versions), so the milter must resolve those too — this is the
// case the SASL-less spike missed and that produced empty app_login at first.
// Postfix sends each macro name as milter_*_macros spells it (src/milter/
// milter.c, milter_macro_lookup()): {auth_authen} in braces, and {i} too where
// a configuration spells it so; the macros here are composed. The milter must
// resolve those too — this is the case the SASL-less spike missed and that
// produced empty app_login at first.
func TestBracedMacros(t *testing.T) {
rec := &fakeRecorder{}
s := &session{rec: rec}
+51 -1
View File
@@ -35,6 +35,13 @@ import (
// "+extension", the domain. (Postfix compares logins without regard to case;
// the check compares them as written, which is how the panel writes them.) The send-log row of a refusal is written by another goroutine and
// is not waited for.
//
// Postfix 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
// the SASL login is compared with an owner by strcasecmp_utf8();
// src/milter/milter8.c, milter8_comm_error(): default_action=tempfail answers
// "451 4.7.1" for a milter that does not answer. OpenDKIM 2.11.0~beta2,
// opendkim/opendkim.c, mlfi_eoh(): with SenderHeaders unset the signing
// domain is that of the first From: field, as dkim_mail_parse() reads it.
// tableFile is a table of the mail path that the panel rewrites and the check
// reads: it is read again when it has changed. An unreadable file is an error
@@ -91,6 +98,11 @@ func (b *Bindings) fresh() error {
for _, line := range lines {
// texthash: <key><whitespace><value>, the value a comma-separated
// list of logins (internal/postfix renderSenderLoginMaps).
// Postfix 3.7.11, src/util/dict_thash.c, dict_thash_open(): the key
// ends at the first blank; src/util/readlline.c skips a line whose
// first non-blank is "#"; src/util/dict_ht.c folds the key to lower
// case (DICT_FLAG_FOLD_FIX, set for smtpd_sender_login_maps in
// src/smtpd/smtpd_check.c).
fields := strings.Fields(line)
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
continue
@@ -146,6 +158,10 @@ const extensionDelimiter = "+"
// withoutExtension drops the extension of an address's local part: what
// follows its first delimiter. "user+tag@example.org" is "user@example.org";
// an address whose local part begins with the delimiter has no extension.
//
// Postfix 3.7.11, src/global/strip_addr.c, strip_addr_internal(), and
// split_addr.c, split_addr_internal(): the local part, up to the last "@", is
// cut at its first delimiter, unless that is its first character.
func withoutExtension(addr string) string {
at := strings.LastIndexByte(addr, '@')
if at < 0 {
@@ -184,6 +200,12 @@ func NewSigners(path string) *Signers {
}
// Signs reports whether mail whose From: is at domain gets a signature.
//
// OpenDKIM 2.11.0~beta2, opendkim/opendkim-db.c, dkimf_db_open(), case
// DKIMF_DB_TYPE_REFILE: the pattern is a line up to its first blank, "#"
// starts a comment; opendkim/util.c, dkimf_mkregexp(): "^" + the pattern
// ("*" as ".*", "." escaped) + "$", compiled REG_ICASE (DKIMF_DB_FLAG_ICASE,
// opendkim.c). So "*@d" signs user@d in any case, and no subdomain of d.
func (s *Signers) Signs(domain string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -231,6 +253,11 @@ type senderSession struct {
// MailFrom starts a message and forgets the one before it: a connection may
// carry several, and after a temporary failure the library hands the
// connection a new session.
//
// go-milter v0.4.1, session.go, HandleMilterCommands(): a new backend after a
// reply whose Continue() is false - the tempfail of Headers; the "y" reply is
// a CustomResponse whose Continue() is true (response.go), so it keeps this
// one. {auth_authen}: see session.MailFrom in milter.go.
func (s *senderSession) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
s.from = cleanAddress(from)
s.login = macro(m, "auth_authen")
@@ -257,6 +284,13 @@ func (s *senderSession) Header(name, value string, m *milter.Modifier) (milter.R
}
// Headers is the end of the headers, where the message is judged.
//
// go-milter v0.4.1, session.go, case CodeEOH: Headers(). Postfix 3.7.11,
// src/milter/milter8.c, milter8_event(), SMFIR_REPLYCODE: a "4"/"5" reply
// whose enhanced code starts with the same digit is the message's reply;
// src/cleanup/cleanup_milter.c, cleanup_milter_apply(): a "5" rejects the
// message, and src/smtpd/smtpd.c, common_post_message_handling(), says that
// reply to the client.
func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (milter.Response, error) {
shown, why, err := s.judge()
if err != nil {
@@ -271,6 +305,8 @@ func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (mil
// cleanup has given the message a queue id by the end of its headers
// (milter_end_of_header_macros has {i}); Postfix's lines about it - the
// refusal among them - are found by it on the message's page.
// Postfix 3.7.11, src/global/mail_params.h: DEF_MILT_EOH_MACROS "i",
// answered by src/cleanup/cleanup_milter.c, cleanup_milter_eval().
s.record(shown, macro(m, "i"))
// A reply code of its own, so that the application is told what is wrong
// and, the refusal being permanent, does not send the message again.
@@ -289,6 +325,10 @@ func (s *senderSession) judge() (shown, why string, err error) {
if s.login == "" {
// Not reachable while the submission ports demand authentication; a
// message nobody answers for is not signed either way.
// Postfix 3.7.11, src/smtpd/smtpd_check.c, smtpd_check_rcpt(): with
// smtpd_delay_reject (default yes) smtpd_client_restrictions
// (permit_sasl_authenticated,reject on 465 and 587) refuse every RCPT;
// src/smtpd/smtpd.c, data_cmd(): no valid recipients, no message.
return "(none)", "no authenticated application", nil
}
switch len(s.froms) {
@@ -419,6 +459,10 @@ func unquoted(v string) string {
// for the null sender of a bounce an application writes itself, which Postfix
// holds against nobody, the domain the login is bound to. Without it the row
// would belong to no domain and its administrator would never see it.
//
// Postfix 3.7.11, src/smtpd/smtpd_check.c, generic_checks():
// reject_*_sender_login_mismatch run only "if (state->sender &&
// *state->sender)".
func (s *senderSession) domain() string {
if d := domainOf(s.from); d != "" {
return d
@@ -452,6 +496,11 @@ func isHostByte(c byte) bool {
// printable reduces what a client wrote to printable ASCII without the
// characters that mean something in an SMTP reply or a log line, and cuts it:
// it is said back to the client, logged and stored.
//
// Postfix 3.7.11, src/milter/milter8.c, milter8_event(), SMFIR_REPLYCODE:
// "%%" becomes "%" and a single "%" is dropped; src/cleanup/cleanup_milter.c,
// cleanup_milter_apply(), logs "<id>: milter-reject: END-OF-MESSAGE from
// <client>: <text>; from=<...> to=<...> ...".
func printable(v string) string {
const max = 120
var sb strings.Builder
@@ -476,7 +525,8 @@ func printable(v string) string {
}
// ServeSender runs the sender check on ln until ctx is cancelled. rec may be
// nil: refusals are then not logged.
// nil: refusals are then not logged. What it relies on of go-milter and
// Postfix is cited at Serve in milter.go.
func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, signers *Signers, rec Refusals) error {
var refused chan store.SendLogEntry
if rec != nil {
+9 -6
View File
@@ -13,18 +13,21 @@ import (
"github.com/mixeme/selfpost/internal/store"
)
// senderMaps is a sender_login_maps file as internal/postfix writes it: shop
// and news send as any address of their domains, billing only as two listed
// addresses of example.org, and two applications share shop.example.org.
// senderMaps is a sender_login_maps file composed by hand in the format
// internal/postfix writes (key, blank, comma-separated logins), though not in
// its order (renderSenderLoginMaps sorts the keys): shop and news send as any
// address of their domains, billing only as two listed addresses of
// example.org, and two applications share shop.example.org.
const senderMaps = `@news.example.net news
@shop.example.org shop,shop2
invoices@example.org billing
billing@example.org billing
`
// signingTable is OpenDKIM's SigningTable for the same server: the three
// sending domains, one more that has no application yet, and the server's own
// name.
// signingTable is a SigningTable for the same server, composed by hand: the
// three sending domains, one more that has no application yet, and the
// server's own name. Its key-name column is not what internal/domain
// renderTables writes (the domain name); the check reads only the pattern.
const signingTable = `*@news.example.net selfpost._domainkey.news.example.net
*@shop.example.org selfpost._domainkey.shop.example.org
*@example.org selfpost._domainkey.example.org
+3 -1
View File
@@ -5,7 +5,9 @@
# (internal/milter/sender_test.go) wants each refused; the e2e
# (test/e2e/dkim_from_test.go) sends each and wants nothing signed for
# {other}. {OTHER} is {other} in capitals; {other_qp} is {other} with each
# dot escaped as a quoted pair. One Go-quoted string per line.
# dot escaped as a quoted pair. One Go-quoted string per line. The lines are
# composed by hand; that OpenDKIM signs none for {other} is measured, by the
# e2e on every run.
"b@{other}"
"<b@{other}>"
"\"a@{domain}\"@{other}"
+2
View File
@@ -8,6 +8,8 @@
# is {domain} in capitals; {other} is another sender of the server, which a
# line here names in a way the check's scan does not see (a space after the
# @) - passed by design, and so signed for {domain} alone.
# The lines are composed by hand; what OpenDKIM signs each with is measured,
# by the e2e on every run.
"a@{domain}"
"<a@{domain}>"
"\"The Shop\" <Orders@{domain}>"
+4
View File
@@ -41,6 +41,10 @@ func (p *Postfix) RebuildDMARCMaps(cfg DMARCMapsConfig) error {
return p.reload()
}
// Postfix 3.7.11, man/man5/access.5: "OK" accepts the address the key
// matches; man/man5/transport.5: "user@domain transport:nexthop", and "a
// non-null transport field with a null nexthop field resets the nexthop
// information to the recipient domain"; relay_domains as in renderInboundMaps.
func renderDMARCMaps(cfg DMARCMapsConfig) (recipients, transport, relayDomains []byte, err error) {
recipients = []byte{}
transport = []byte{}
+12
View File
@@ -55,6 +55,13 @@ func (p *Postfix) RebuildInboundMaps(routes []InboundRoute) error {
return p.reload()
}
// Postfix 3.7.11, man/man5/postconf.5: a relay_domains table "is matched when
// a (parent) domain appears as lookup key", the value unused; relay_recipient_maps
// is used as a list, "@domain" its wild-card (src/global/mail_addr_find.c,
// MA_FIND_AT_DOMAIN, the exact domain only). man/man5/transport.5: "domain
// transport:nexthop" for that domain. README_FILES/TLS_README: the
// smtp_tls_policy_maps key is the verbatim next-hop; src/tls/tls_level.c names
// the levels none, may and encrypt.
func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tlsPolicy []byte, err error) {
sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
@@ -87,6 +94,11 @@ func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tls
// inboundNexthop is the Postfix next-hop [host]:port form. The brackets are
// what disable the MX lookup, so the operator's explicit upstream is used as
// given; they also delimit a bare IPv6 address.
//
// Postfix 3.7.11, src/smtp/smtp_connect.c: no MX lookup for a destination that
// starts with "["; src/util/host_port.c, host_port(): "[host]:port", the host
// a name or an address ("ipv6:" in front is optional), checked by
// valid_hostaddr().
func inboundNexthop(host string, port int) string {
return "[" + host + "]:" + strconv.Itoa(port)
}
+18
View File
@@ -59,6 +59,9 @@ type Binding struct {
// architecture.md § Mail path) — their logins are merged onto a single line as
// a comma-separated list, which is how Postfix expects multiple owners of one
// sender.
//
// Postfix 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
// the value is split into owners with mystrtok(&cp, CHARS_COMMA_SP).
func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error {
content, err := renderSenderLoginMaps(bindings)
if err != nil {
@@ -128,6 +131,12 @@ func appendUnique(list []string, v string) []string {
// end it, the comma that separates values, the colon a lookup key cannot carry,
// and the backslash. A login additionally may not carry "@" — that would make it
// look like an address.
//
// Postfix 3.7.11, src/util/dict_thash.c, dict_thash_open(): the key ends at
// the first blank outside quotes, a backslash escapes the next character;
// src/util/readlline.c: a newline ends the line (one that starts with a blank
// continues the one before); the owners split on comma and blank (see
// RebuildSenderLoginMaps).
const (
senderMapAddressForbidden = " \t\r\n,:\\"
senderMapLoginForbidden = " \t\r\n,:@\\"
@@ -161,6 +170,15 @@ func assertMapSafe(address, login string) error {
//
// Arguments are fixed literals — no user input is interpolated into the command,
// and it never goes through a shell (security.md).
//
// Postfix 3.7.11, conf/postfix-script, start-fg: a process that is not PID 1
// runs "$daemon_directory/master -s" as its child, and src/master/master.c
// puts that master in a session of its own (setsid()). "reload" sends the
// master SIGHUP; src/master/master.c calls master_refresh(), and
// master_conf.c, master_config(), lets the running children "commit suicide
// whenever it is convenient" while new ones get the new configuration; a
// texthash table is read into memory once per process
// (README_FILES/DATABASE_README).
func reloadViaSupervisor() error {
return supervisor.Start("postfix-reload")
}
+8
View File
@@ -50,6 +50,11 @@ var postqueueP = func(ctx context.Context) *exec.Cmd {
// listQueue runs `postqueue -p` and keeps at most maxQueueOutput bytes of it;
// truncated says whether there was more.
//
// Postfix 3.7.11, src/postqueue/postqueue.c, show_queue(): any user passes
// authorized_mailq_users (DEF_SHOWQ_ACL static:anyone), and a failure is
// msg_fatal_status() - a non-zero exit with the reason on stderr;
// src/postqueue/showq_compat.c, showq_compat(): "Mail queue is empty".
func listQueue() (text string, truncated bool, err error) {
ctx, cancel := context.WithTimeout(context.Background(), postqueueTimeout)
defer cancel()
@@ -101,6 +106,9 @@ func Queue() (string, error) {
// queue. A listing cut at the size cap is such an error: the messages past the
// cut are still in the queue, and a backlog that large is the outage the sweep
// exists for (audit 2026-10-12, H3). QueueMessages treats its cap the same way.
//
// Postfix 3.7.11, src/showq/showq.c, showq_service(): maildrop, active,
// incoming, deferred and hold are the queues it lists.
func QueueIDs() (map[string]struct{}, error) {
out, truncated, err := listQueue()
if err != nil {
+8 -5
View File
@@ -12,7 +12,8 @@ import (
// TestFakePostqueue is not a test: it is the fake `postqueue -p` the tests
// below run in place of the real one, writing SP_FAKE_POSTQUEUE_BYTES bytes of
// queue entries.
// queue entries composed by hand after the layout of Postfix 3.7.11's
// src/postqueue/showq_compat.c.
func TestFakePostqueue(t *testing.T) {
if os.Getenv("SP_FAKE_POSTQUEUE") != "1" {
t.Skip("helper process")
@@ -73,10 +74,12 @@ func TestQueueIDsReadsAListingUnderTheCap(t *testing.T) {
}
// The reconcile sweep decides whether a message is still Postfix's problem, so
// the parser must pick queue ids out of a real listing and nothing else out of
// it: not the header, not the byte-count trailer, and above all not a deferred
// entry's reason line, which — unlike the recipient lines — starts at the left
// margin just as an entry does.
// the parser must pick queue ids out of a listing and nothing else out of it:
// not the header, not the byte-count trailer, and above all not a deferred
// entry's reason line. The listing is composed by hand. Postfix 3.7.11
// (src/postqueue/showq_compat.c, showq_message()) right-aligns a reason to
// column 76, so it reaches the left margin only when it is 76 characters or
// longer; the one here is put at the margin as the hard case.
func TestParseQueueIDs(t *testing.T) {
listing := `-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
3C5B04E6C1* 446 Fri Aug 8 10:12:31 app@example.com
+6
View File
@@ -12,6 +12,12 @@ import (
// QueuedMessage is one message Postfix is holding, as `postqueue -j` reports
// it: where it is, and since when.
//
// Postfix 3.7.11, src/postqueue/showq_json.c, format_json(): one object and a
// newline per message - "queue_name", "queue_id", "arrival_time" (seconds,
// %ld), "message_size", "forced_expire", "sender", "recipients" [{"address",
// "delay_reason"}]; the queue names are those of src/global/mail_queue.h that
// src/showq/showq.c, showq_service(), scans.
type QueuedMessage struct {
ID string
Queue string // "deferred", "active", "hold", "incoming", "maildrop"
+3
View File
@@ -6,6 +6,9 @@ import (
"time"
)
// The listing is composed by hand: its first line in the field order of
// Postfix 3.7.11's src/postqueue/showq_json.c, format_json(); the others
// short of fields or broken on purpose.
func TestParseQueueJSON(t *testing.T) {
out := []byte(`{"queue_name": "deferred", "queue_id": "41E862C00D9E", "arrival_time": 1791000000, "message_size": 446, "forced_expire": false, "sender": "app@example.com", "recipients": [{"address": "a@example.net", "delay_reason": "connect to mx.example.net[203.0.113.9]:25: Connection timed out"}]}
{"queue_name": "active", "queue_id": "5F00AA12", "arrival_time": 1791250000, "message_size": 900, "sender": "", "recipients": [{"address": "b@example.net"}]}
+11
View File
@@ -28,6 +28,10 @@ type RetryPolicy struct {
// DefaultRetryPolicy is Postfix 3.x compiled-in values for the six parameters
// build/postfix-config.sh does not set. Used when postconf is missing (unit
// tests, a binary outside the container) so the panel still starts.
//
// Postfix 3.7.11, src/global/mail_params.h: DEF_QUEUE_RUN_DELAY "300s",
// DEF_MIN_BACKOFF_TIME DEF_QUEUE_RUN_DELAY, DEF_MAX_BACKOFF_TIME "4000s",
// DEF_MAX_QUEUE_TIME "5d", DEF_DSN_QUEUE_TIME "5d", DEF_DELAY_WARN_TIME "0h".
func DefaultRetryPolicy() RetryPolicy {
return RetryPolicy{
QueueRunDelay: 300 * time.Second,
@@ -57,6 +61,9 @@ var readRetryConf = postconfRetryValues
func postconfRetryValues() ([]string, error) {
// Fixed argv, no user input — same pattern as Queue (security.md).
// Postfix 3.7.11, src/postconf/postconf_main.c, pcf_show_parameters():
// named parameters are printed in argument order, with -h one "%s\n"
// value each, as written in main.cf (not expanded without -x).
cmd := exec.Command("postconf", "-h",
"queue_run_delay",
"minimal_backoff_time",
@@ -133,6 +140,10 @@ func (p RetryPolicy) BackoffCap() string {
// larger of the queue lifetime for ordinary mail and for mail from the null
// sender, which an application may submit too. It bounds which stored log
// lines can be about a send-log row (store.SendLogLines).
//
// Postfix 3.7.11, src/qmgr/qmgr_active.c, qmgr_active_done(): a message
// expires after var_max_queue_time, or var_dsn_queue_time when its sender is
// null - whoever submitted it (qmgr.c caps the second at the first).
func (p RetryPolicy) LongestLifetime() time.Duration {
if p.BounceQueueLifetime > p.MaximalQueueLifetime {
return p.BounceQueueLifetime
+3
View File
@@ -6,6 +6,9 @@ import (
"time"
)
// The postconf values in these tests are composed by hand: values main.cf
// could hold. Stock Postfix 3.7.11 prints "0h" for delay_warning_time
// (src/global/mail_params.h, DEF_DELAY_WARN_TIME), not "0".
func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) {
old := readRetryConf
readRetryConf = func() ([]string, error) {