diff --git a/internal/app/sasl.go b/internal/app/sasl.go index e029178..9631c71 100644 --- a/internal/app/sasl.go +++ b/internal/app/sasl.go @@ -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 // "\0\0"; 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 diff --git a/internal/app/sasl_test.go b/internal/app/sasl_test.go index 51172c0..e1ef8fb 100644 --- a/internal/app/sasl_test.go +++ b/internal/app/sasl_test.go @@ -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 ` 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") diff --git a/internal/app/validate.go b/internal/app/validate.go index 1cb368a..52f40a3 100644 --- a/internal/app/validate.go +++ b/internal/app/validate.go @@ -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 diff --git a/internal/domain/dkim.go b/internal/domain/dkim.go index 1aa1c95..5903814 100644 --- a/internal/domain/dkim.go +++ b/internal/domain/dkim.go @@ -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", diff --git a/internal/domain/opendkim.go b/internal/domain/opendkim.go index e6fb49f..c66a728 100644 --- a/internal/domain/opendkim.go +++ b/internal/domain/opendkim.go @@ -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") } diff --git a/internal/milter/inbound.go b/internal/milter/inbound.go index fa4f5e6..b6e5662 100644 --- a/internal/milter/inbound.go +++ b/internal/milter/inbound.go @@ -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} }, diff --git a/internal/milter/inbound_test.go b/internal/milter/inbound_test.go index 2f5712e..37fada5 100644 --- a/internal/milter/inbound_test.go +++ b/internal/milter/inbound_test.go @@ -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) diff --git a/internal/milter/inflight.go b/internal/milter/inflight.go index e1a4b30..206ab92 100644 --- a/internal/milter/inflight.go +++ b/internal/milter/inflight.go @@ -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 diff --git a/internal/milter/milter.go b/internal/milter/milter.go index 44c8d39..c5cfcb5 100644 --- a/internal/milter/milter.go +++ b/internal/milter/milter.go @@ -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{ diff --git a/internal/milter/milter_test.go b/internal/milter/milter_test.go index 064a615..a7deba5 100644 --- a/internal/milter/milter_test.go +++ b/internal/milter/milter_test.go @@ -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} diff --git a/internal/milter/sender.go b/internal/milter/sender.go index a7dad36..1a54435 100644 --- a/internal/milter/sender.go +++ b/internal/milter/sender.go @@ -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: , 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 ": milter-reject: END-OF-MESSAGE from +// : ; 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 { diff --git a/internal/milter/sender_test.go b/internal/milter/sender_test.go index aeb3a01..66c7ee8 100644 --- a/internal/milter/sender_test.go +++ b/internal/milter/sender_test.go @@ -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 diff --git a/internal/milter/testdata/from-lines-other.txt b/internal/milter/testdata/from-lines-other.txt index 2aad1ed..1630a7a 100644 --- a/internal/milter/testdata/from-lines-other.txt +++ b/internal/milter/testdata/from-lines-other.txt @@ -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}" "" "\"a@{domain}\"@{other}" diff --git a/internal/milter/testdata/from-lines-passed.txt b/internal/milter/testdata/from-lines-passed.txt index 3ff9b0d..8deea20 100644 --- a/internal/milter/testdata/from-lines-passed.txt +++ b/internal/milter/testdata/from-lines-passed.txt @@ -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}" "" "\"The Shop\" " diff --git a/internal/postfix/dmarc.go b/internal/postfix/dmarc.go index 568313c..8b3970f 100644 --- a/internal/postfix/dmarc.go +++ b/internal/postfix/dmarc.go @@ -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{} diff --git a/internal/postfix/inbound.go b/internal/postfix/inbound.go index 9d53618..3a678e3 100644 --- a/internal/postfix/inbound.go +++ b/internal/postfix/inbound.go @@ -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) } diff --git a/internal/postfix/postfix.go b/internal/postfix/postfix.go index dfcf052..8742d3f 100644 --- a/internal/postfix/postfix.go +++ b/internal/postfix/postfix.go @@ -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") } diff --git a/internal/postfix/queue.go b/internal/postfix/queue.go index 7815996..c0d2f19 100644 --- a/internal/postfix/queue.go +++ b/internal/postfix/queue.go @@ -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 { diff --git a/internal/postfix/queue_test.go b/internal/postfix/queue_test.go index d34b896..bed52ae 100644 --- a/internal/postfix/queue_test.go +++ b/internal/postfix/queue_test.go @@ -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 diff --git a/internal/postfix/queuejson.go b/internal/postfix/queuejson.go index 87394c8..4efe0c5 100644 --- a/internal/postfix/queuejson.go +++ b/internal/postfix/queuejson.go @@ -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" diff --git a/internal/postfix/queuejson_test.go b/internal/postfix/queuejson_test.go index c889cae..6eccc4a 100644 --- a/internal/postfix/queuejson_test.go +++ b/internal/postfix/queuejson_test.go @@ -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"}]} diff --git a/internal/postfix/retry.go b/internal/postfix/retry.go index bf86fcb..fa7d14b 100644 --- a/internal/postfix/retry.go +++ b/internal/postfix/retry.go @@ -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 diff --git a/internal/postfix/retry_test.go b/internal/postfix/retry_test.go index 747cc5d..fc5f2e9 100644 --- a/internal/postfix/retry_test.go +++ b/internal/postfix/retry_test.go @@ -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) {