9eddd49602d7675f6e54a7c87bf342697ea04751
180
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9eddd49602 |
audit 2c: findings of the two reviews
test / test (push) Canceled after 0s
Technical review (Opus, 11 findings, 2 medium) and security review (Fable, no high or medium) of 3c86280..HEAD. Both confirm the port gives dkim_mail_parse()'s result on every input it parses, and that no line the check passes is signed with another domain's key. - F1 (medium): OpenDKIM's parser takes lines no mail program reads as one address - "ceo@bank.com:me@mine.com", "Name <ceo@bank.com:me@mine.com>", "<b@evil.com, a@mine.com>", "ceo@bank.com <me@mine.com>", "ceo@bank.com@mine.com" - and signs for the last while a lenient reader shows the first. The line must now also be one address in RFC 5322's grammar (net/mail, display names in any charset passed through), at the domain OpenDKIM signs for. A quoted display name may still hold anything. - F2 (medium): the bindings were asked about OpenDKIM's form of the address, quotes and all, so "x"@mine.com passed for an application holding @mine.com while x@mine.com belonged to another. They are now asked about the reader's address, its local part unquoted, as Postfix holds the envelope's. - F3 / security (low): Bindings.Allowed and DomainOf fold case in ASCII only: Postfix folds as Unicode (casefold.c, ICU), but every key and login the panel writes is ASCII, and the Unicode fold let the Kelvin sign pass for "k" (net/mail then refuses such a line on the domain test). - F4 (low): a TERM between "postfix start-fg &" and "child=$!" is no longer lost: the wrapper stops Postfix as soon as the pid is known. - F5 (low): for startsecs after a start any failed reload passed as "not running". postfix-reload is now build/postfix-reload.sh: exit 75 when master -t says no master runs, else exec postfix reload; RunOnce returns EndError with the status, and only 75 is taken as done. supervisor.State goes. - F6 (low): crashexit spares postfix-reload too - a reload that could not be spawned is reported to whoever asked, not a reason to stop the container. - F7 (low): a certs/ that is itself a link is resolved before the walk (filepath.WalkDir does not follow its root). - F8 (low): at shutdown the tailer reads what is left of the rotated file before saving its place. Not changed: a refusal line still waiting on the old file at shutdown is not kept - the panel writes refused rows itself, only a kill loses one, and that window is the minute's grace. - F9 (low): pct read as text - empty or unreadable is 100, the rest held to 0..100. - F10 (low): the directory is synced after sasldb2's rename. - F11 (low): POSTFIX_STARTSECS by expr, so "010" or "08" in MILTER_WAIT_TIMEOUT are decimal, not octal or an error. - Security (low): pagehide also empties defaultValue - the input's value attribute and the textarea's text - not only .value. A sasldb2.new left by a killed panel is removed at start (entrypoint) and never archived. security.md says that links under certs/ are followed and by whom they can be placed. Tests: sender check cases for F1 (four lines), F2 (a bound address quoted, for its owner and for another), F3; EndError's state and status; pct cases. architecture.md and CHANGELOG describe the reader rule. Stand (2026-10-15, make e2e, exit 0); go vet and go test ./... pass on the server (24 packages). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ec08c8606b |
audit 2c: the From: parser port checked against OpenDKIM's signing itself
test / test (push) Canceled after 0s
The last check of the port, and the plan's last line of stage 2c. Through
Postfix the sender check refuses most lines before OpenDKIM sees them, so
TestPortAgainstOpenDKIM (internal/milter) talks to OpenDKIM directly:
test/upstream/opendkim-probe.sh starts OpenDKIM from the SelfPost image in
a throwaway container, with keys and a refile SigningTable for three
names, and the test plays it each From: line through go-milter's client,
as a client OpenDKIM trusts (127.0.0.1, InternalHosts' default). For each
line the d= of the signature OpenDKIM adds must be the domain the port
predicts - its address, the domain cut to DKIM_MAXHOSTNAMELEN, the whole
cut to MAXADDRESS, matched against "^.*@<domain>$" case-insensitive as
dkimf_apply_signtable() matches - or neither signs.
Run on the test server, 2026-10-15 (OpenDKIM Filter v2.11.0 from the
image): 385 lines - both shared files and lines composed from display
names plain, quoted, escaped and encoded, comments nested and open,
groups, lists, brackets, source routes, domain literals and quoted local
parts, with the other sender's name and the server's in each place - 385
agree, 287 signed. The two lines padded so that, cut at 256 bytes, they
end in "@other.e2e.test" are signed by OpenDKIM with other.e2e.test's key:
the danger the sender check's 256-byte rule (
|
||
|
|
3584adcbc2 |
sender check: From: read with OpenDKIM's own parser; L8 closed
test / test (push) Canceled after 0s
Audit 2026-10-12, stage 2c, the owner's decision of 2026-10-14: the check
decides by the address OpenDKIM signs for, whatever a display name or a
comment holds.
internal/milter/mailparse.go ports libopendkim's dkim_mail_parse() with
dkim_mail_first_special(), dkim_mail_token() and
dkim_mail_matching_paren() from Debian's opendkim 2.11.0~beta2-8+deb12u1,
line for line: the '<' form, group and list punctuation, an '@' before the
first ':', ';' or ',' deciding where the address starts, source routes
dropped, quoted strings and domain literals kept with their delimiters,
comments skipped, whitespace between atoms one space, no encoded word
decoded; and the C's own quirks ("@d" gives d as the local part too).
testdata/mailparse_harness.c compiles the C from the image's source; the
test feeds it and the port 200 019 lines (the hand-written cases and
200 000 random ones over the specials) - 93 443 parse and agree byte for
byte, 106 576 fail and agree on the error (on an error the C leaves its
in-place result unterminated, and OpenDKIM uses neither part then).
judge(): one From: field and one address stay required (moreAfter: only
list or group punctuation may follow); what OpenDKIM cannot read is
refused (mlfi_eoh() would not sign it); the address is lower-cased in
ASCII only, as dkimf_lowercase() and Postfix's tables do. Found reading
dkimf_apply_signtable(): OpenDKIM matches its refile SigningTable against
"user@domain" printed into MAXADDRESS + 1 = 257 bytes, so a longer address
is matched cut short - a quoted local part padded to end there in
"@another.tenant" would be signed with that tenant's key while its real
domain is the application's. Such an address (over 256 bytes) is refused;
one that fits is matched whole. The display-name and comment scan, the
"one @ outside quotes" rule and the SigningTable reader (Signers) go; the
check asks for header values with their leading space as OpenDKIM does
(OptHeaderLeadingSpace; Postfix keeps a v2 milter's event mask, milter8.c
milter8_negotiate()).
L8 closed: refused or passed no longer depends on other domains of the
server; security.md's accepted risk is struck. The shared other-sender
lines are now held to "never signed for the other sender": unit test (a
passed line is read at the application's domain) and e2e (a passed line
arrives signed for the application's domain, none for the other).
Documents: architecture "How the header is read", security's sender
check paragraph, the guide; the help topic per
|
||
|
|
2cf118cafc |
design: help says the sender check reads From: as OpenDKIM does
Audit 2026-10-12, stage 2c: the sender check now reads the From: line with OpenDKIM's own parser, so the help topic says what that means for the reader - only the address counts; a display name or a comment around it is not judged; a line with more than one address is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
cad1182150 |
audit 2c: what only the stand could answer, measured and written down
test / test (push) Canceled after 0s
All 2026-10-15; each written beside the code or text it settles. - A-43: /usr/sbin/postlog is -rwxr-xr-x root:root in the image (postfix 3.7.11-0+deb12u1; dpkg-statoverride lists postdrop and postqueue only): Debian does not install it set-gid, so it is no use to dmarc-ingest (cmd/panel/dmarc_ingest.go). - C-X1: OpenSSL 3.0.22 in the image prints the same public key from `x509 -pubkey` and `pkey -pubout` for RSA-2048, P-256, P-384 and a SEC1 P-256 key - cert-reload's same_pair holds for certbot's ECDSA keys. - C-K5, D-guide:933/:233: on the live server (Docker 29.6.1, userland proxy on 0.0.0.0 and ::, no IPv6 on the network) an outside IPv4 client and the host's own public IPv4 address are logged as themselves; the host's IPv6 address and 127.0.0.1 arrive as 172.18.0.1. Over IPv6 every client is the gateway: one level-1 budget, an application IP list cannot name them, rspamd sees the gateway. Guide paragraph under level 1, notes at the milter and allow-list, comments at the anvil setup and authip.go, and a Security entry in the CHANGELOG (an allow-list naming the gateway would admit every IPv6 client). - D-guide:61: a published port nothing listens on (a throwaway container, -p 20999:25) is refused from outside over IPv4 and from the host's public IPv4, accepted and closed through 127.0.0.1 - Docker's proxy, which IPv6 goes through too (not probed over IPv6 for this). - C-M8: the live installation has received one DMARC report, of one record: nothing to set maxReportRecords by yet - said at the constant. - D-S7, D-arch:516, D-arch:532: a new database filled through the store to 120 days at 1 900 messages a day (228 000 messages, 1.37 million lines, 464 MB) on the test server (2 cores): the longest write is the line prune after a 30-day gap, 1.9 s for 342 000 lines (0.4 s with nothing to delete); the send-log delete 0.6 s; 2 000 lines of a tailer pass 18 ms; VACUUM INTO 1.7 s; a second connection writing every 50 ms waited 1.8 s at most, no SQLITE_BUSY - inside busy_timeout's 5 s. architecture.md's "about a second per million lines" was five times too fast; its "six to ten lines of 200 bytes" is 6.0 lines of 146 bytes on the live data. - D-B16: which favicon is fetched, by the probe server's log: Edge 155 the SVG, the desktop app's embedded Chromium 152 the PNG, with or without sizes="any" - layout.html's "vector wins where supported, raster catches the rest" holds; Firefox and Safari not measured. - D-arch:121: the allow/deny trial again, in rspamd/rspamd:3.11.1-3 with today's multimap.conf: every mix of allow and deny entries matching one message ended reject; a changed map was used about 10 s later. - D-sec:424: rspamd runs as _rspamd, uid 11333; the panel is uid 999. Stand: go vet and go test ./... pass on the server (24 packages); make e2e exit 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fbd0acd035 |
audit 2c: the rest the sources contradict, and the documents
test / test (push) Canceled after 0s
Code and config: - C-M7: a DMARC report's explicit <pct>0</pct> stays 0; only an absent pct takes RFC 7489 § 6.3's default 100 (Pct *int). - C-D1: docs/development.md now tables the Debian versions the citations were read at (postfix 3.7.11-0+deb12u1, opendkim and libopendkim11 2.11.0~beta2-8+deb12u1, libmilter1.0.1 8.17.1.9-2+deb12u2, libsasl2-2 and sasl2-bin 2.1.28+dfsg-10, supervisor 4.2.5-1, logrotate 3.21.0-1 - read off the image with dpkg-query, 2026-10-15); the e2e stand's new checkCitedPackageVersions fails when the image it built differs. - C-E2: the e2e zone's SOA serial only grows (CoreDNS v1.14.6's file plugin reloads on a grown serial, not on mtime); Corefile comment says so; TestZoneSerialGrows. - C-K8, C-K9: the e2e compose and stage comments say how --project-directory and .env resolve. - D-arch:154: postfix-config.sh's "Postfix insists" comment says what TLS_README advises and that smtpd reads the key as root. - B-G7: inflight.go's reservationTTL comment: a dropped client does give ABORT (smtpd_proto() -> mail_reset() -> milter_abort()); the TTL is for smtpd dying or the milter connection breaking. - D-guide:301: the nginx fragment's first-certificate command now reaches certbot (--entrypoint certbot, --standalone, port 80 free), and the renewal loop passes --webroot -w /var/www/certbot - a standalone certificate would otherwise renew standalone while nginx holds port 80 (certbot docs, "Renewing certificates": the issuing plugin is used "unless you specify other plugins or options", and options of a successful renewal are saved). The register's proposal missed that. Documents: every D-arch, D-guide and D-sec claim the register's Answers E found false or partly true, rewritten - by three Sonnet agents, one per document, then reviewed: two proposals were obsolete after this stage's fixes and not used (a bounce's From: line - extract_from = "smtp" now; "Origin: null" - Referrer-Policy is same-origin now); a stand observation the agent had dated from a commit is now tied to the e2e test that checks it; the SQLite writers paragraph describes _txlock=immediate; the log tailer's rotation paragraph describes the minute's grace; the guide's nginx steps were put in working order (links and modes before `up`, no reconfigure step). "Deferred" is kept for Postfix's queue in the guide too. guide.md's CRLF from the agent normalised. Stand (2026-10-15, make e2e, exit 0, the version gate and zone serial test included); go vet and go test ./... pass on the server (24 packages). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
2e1544dac5 |
certbot: certs/ links into /etc/letsencrypt, mounted at the same path; the backup follows them
test / test (push) Canceled after 0s
C-A1, C-Q4, D-guide:258, :291, :722. certbot's live/<host>/*.pem are relative links into ../../archive/<host>/ (eff-certbot docs, "Where are my certificates?": point a server at them "or create symlinks"). The guide had `ln -s /etc/letsencrypt/live/<host> certs` and the nginx fragment mounted live/<host> alone: inside the container the links led into a directory it did not have - postfix-config.sh's [ -f ] false, no copy, Postfix without a certificate - and the panel's backup, reading certs/ through /selfpost-deploy, skipped links silently. Now certs/fullchain.pem and certs/privkey.pem are links to /etc/letsencrypt/live/<host>/, and /etc/letsencrypt is mounted read-only at the same path: a commented line in deploy/docker-compose.yml for the Apache scenario, mounted in the nginx fragment (./nginx/certbot-etc). The guide's steps add certbot's own commands for a server that is not root - chmod 0755 live archive, chgrp 998 + chmod 0640 the key, which certbot keeps on renewal - so the panel reads them (Health, backup). Postfix's side follows links already ([ -f ], cp, cmp, openssl). The vhost comment says the same. The backup table: links are followed by the panel's backup; a stopped tar stores them as links unless -h (GNU tar). internal/backup: addTree follows, for certs/ only, a link to a regular file and archives that file under the link's name; a link to nothing reachable is an error naming it and the mount. Tests: TestCertsLinksAreFollowed (certs/ -> live/ -> archive/, the archive holds the files; the tree gone, Check names the dangling link; Linux). Stand (2026-10-15, make e2e, exit 0): the stand mounts ./letsencrypt at /etc/letsencrypt; new certbot_layout_followed puts a new pair into letsencrypt/archive/, relative links in live/, absolute links in certs/, recreates the container: 465 serves the new serial, Health names no renewal waiting, and the full backup holds certs/*.pem as regular files with the archive's bytes. go vet and go test ./... pass on the server (24 packages); the Linux-only tests of this session run there and pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3c63ecc1ad |
store: migrations under BEGIN IMMEDIATE, the version read again inside
test / test (push) Canceled after 0s
D-S3. A deferred BEGIN starts nothing until the first access (SQLite, lang_transaction.html § 2.2), and migrate() read user_version outside the transaction: after an upgrade the panel and dmarc-ingest, opening the file at once, could both read the old version, both begin, and the second applied the next migration again on the migrated file - "already exists", that Open failing (nothing damaged: its transaction rolled back). The DSN now has _txlock=immediate (modernc.org/sqlite v1.60.1, sqlite.go applyQueryParams() sets beginMode, tx.go newTx() issues "begin immediate" for any transaction not read-only), so a migration holds the write lock from its BEGIN, and migrate() reads user_version again inside it and skips one another process applied meanwhile. Test TestTwoOpensMigrateOnce: a raw connection holds BEGIN IMMEDIATE on a new file while two Opens read version 0 and queue on their first BEGIN; released, both succeed and user_version is the number of migrations. Without the fix it fails as the register said: "apply migration 0001_init.sql: SQL logic error: table settings already exists (1)". Stand (2026-10-15): go vet and go test ./... pass on the server (24 packages); make e2e exit 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
9ca50d33fd |
web: Referrer-Policy same-origin; a password shown once emptied on pagehide
test / test (push) Canceled after 0s
D-B3. Fetch Standard § 3.2 "append a request `Origin` header": under Referrer-Policy no-referrer a request that is not GET/HEAD and not CORS - a form POST, a navigation - gets Origin "null". originAllowed() refuses "null" whenever Sec-Fetch-Site is absent, and Fetch Metadata § 3 sends Sec-Fetch-Site only to a potentially trustworthy URL: every form, sign-in included, failed in Safari before 16.4 and in any browser on a plain-HTTP panel at a name. same-origin nulls Origin only for a request not same origin with its URL and still sends no referrer to another site. The comments of the origin check say so. D-B13. noStore keeps the page out of HTTP caches (RFC 9111 § 5.2.2.5); a history mechanism "might honor" it (§ 6), and Pragma in a response "was never specified" (§ 5.4) - it goes. CopyField.Secret marks the password of out-app-created data-secret, and panel.js empties such fields on pagehide (HTML § 7.5.9, "unload a Document", step 10.2: fired whether or not the document is kept for history traversal). Measured 2026-10-15 in Chrome 152 (the desktop app's browser), a local probe page with Cache-Control: no-store and a data-secret field: Back reloads it (pageshow persisted=false, a new load), so this browser keeps no-store pages out of its back/forward cache; the handler, fired on the same page, empties the field. Firefox and Safari not measured. Tests: the header value; the created page has the password data-secret and the login not, no Pragma. Stand (2026-10-15, make e2e, exit 0): new form_post_with_the_browsers_origin - Referrer-Policy: same-origin on a page; a POST carrying the page's own Origin and no Sec-Fetch-Site, what a browser sends now, passes; one carrying Origin: null, what it sent before, is 403. (A first run failed where the e2e client and a view test scraped the old input markup; both read data-secret now.) go vet and go test ./... pass on the server (24 packages). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
98e09fef4a |
health: sockets dialled, the certificate Postfix serves, "deferred" only for the queue
test / test (push) Canceled after 0s
Audit 2026-10-12, stage 2c, "Health says what is served"; the pages of
ea8b6bf's mockup.
Sockets (A-38). A unix socket's file outlives a killed OpenDKIM or panel
and refuses every connect() (unix(7)); Health said "Listening" from a
stat. CheckSocket and CheckMilter's unix case now dial: refused is
"Nothing listens on the ... socket: the program that made it has
stopped", EACCES is unknown (the panel may not connect), anything else
"does not answer". The probe closes before a byte, which no milter logs:
libmilter takes the EOF as SMFIC_EOF without a line (sendmail
8.17.1.9-2+deb12u2, the image's libmilter1.0.1, libmilter/comm.c
mi_rd_cmd(), engine.c mi_engine() - its source is now in dev/upstream),
OpenDKIM's mlfi_close() has no connection to free, go-milter v0.4.1
returns quietly on io.EOF (session.go, HandleMilterCommands()). The page
polls every 5 s while in view, so this mattered.
Certificate (A-65). Postfix serves build/postfix-config.sh's copy,
refreshed by cert-reload every TLS_RELOAD_INTERVAL_SECONDS. The panel now
reads smtpd_tls_cert_file from postconf -h at its start (as the retry
policy) and TLS_RELOAD_INTERVAL_SECONDS; CheckServedCertificate checks the
served copy and, when the mounted file differs byte for byte (cert-reload's
own cmp -s), names it as Renewal: its expiry and mtime, two facts on the
card. Waiting is fine for the interval: a nearing expiry of the served one
is then no warning (an expiry still an error); past interval + 1 h it is a
warning pointing at cert-reload's lines. The notifier sends no expiry
notice while a renewal waits on time.
Wording (D-P1, D-P3, and the same for the spam filter): Sender check
note, help topics sender-check, upstream, filter, certificate - word for
word as in the mockup; the filter row's detail too.
Tests: stale socket file (Linux) refused for CheckSocket and CheckMilter;
CheckServedCertificate (same, waiting, late, expired + waiting, no served
path); intervalText; the card's facts; the notifier quiet while a renewal
waits, speaking once it is late. Stand (2026-10-15, make e2e, exit 0):
new health_shows_what_is_served - the three sockets dialled ok by the
panel's user; a certificate mounted without its key is refused by
cert-reload ("do not belong together") and the card shows the served
expiry, "Renewal expires" with the mounted one's, "Renewal mounted", and
"Postfix still serves the certificate above."; a matching pair is then
served again. go vet and go test ./... pass on the server (24 packages).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
ea8b6bfa6d |
design: Health shows the certificate Postfix serves; "deferred" only for the queue
Audit 2026-10-12, stage 2c, "Health says what is served" - the mockup first. TLS certificate (assumptions A-65): Postfix serves its own copy of the mounted file, refreshed by cert-reload once a day by default, so the card reads that copy. When the mounted file holds a renewal not taken up yet, two facts name it (its expiry, when it was mounted) and the paragraph says when Postfix takes it up; the help topic says the card warns once it has waited longer than that check allows. Wording (D-P1, D-P3): a message refused while the panel or the spam filter does not answer is never queued, so it is not "deferred" in Postfix's sense - the Sender check note, the sender-check and spam filter help topics now say "refused with a temporary error". The upstream topic's Required: mail waits in the queue while the upstream offers no TLS or the handshake fails, and an upstream answering STARTTLS with a permanent error bounces it (Postfix 3.7.11, src/smtp/smtp_proto.c; smtp_trouble.c, smtp_bulk_fail()). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d20009c0f0 |
sasldb2 changed through a copy renamed over it, never in place
test / test (push) Canceled after 0s
Cyrus SASL 2.1.28 opens sasldb2 with no Berkeley DB environment, so with
no locks (sasldb/db_berkeley.c, berkeleydb_open(): db_create(mbdb, NULL,
0)); saslpasswd2 puts and deletes in the file itself (_sasldb_putdata()),
while smtpd opens and closes it at every AUTH (_sasldb_getdata()) and the
backup copies it whenever it runs. Either could read a half-written page:
a spurious 535, or a torn sasldb2 in a backup whose restore breaks every
application's AUTH (assumptions C-S3, C-Q3).
SASLDB.Set and Delete now run saslpasswd2 on sasldb2.new - a byte copy of
the database, or no file when there is none yet (saslpasswd2 creates it,
DB_CREATE) - then chmod 0640, fsync, rename over sasldb2; a failed run
removes the copy and leaves the database; a copy a killed panel left is
removed first. A mutex keeps two changes of the panel from copying the
same old file. The backup needs no lock after all (the plan said one): it
reads each file through one descriptor, and a rename leaves that file
whole; the walk now leaves out a file that vanished between listing and
open (a writer's temporary file), while the SQLite snapshot's absence
stays an error. CHANGELOG also gains the entries of
|
||
|
|
f2ad699473 |
supervision as supervisord does it: Postfix stopped, give-ups FATAL, reload's exit read
test / test (push) Canceled after 0s
C-P42. postfix-script's start-fg runs master as its child and master calls setsid() (Postfix 3.7.11, conf/postfix-script; src/master/master.c); supervisord signals the pid alone (supervisor 4.2.5, process.py, Subprocess.kill()). A stop of program postfix left master running - a restart then found "already running" - and a container stop cut Postfix off with SIGKILL. The wrapper now stays the parent of "postfix start-fg" and on TERM/INT runs "postfix stop", asking again while start-fg is still in its checks (master -t). stopwaitsecs=15. C-V6. The wrapper's give-up on the milter sockets came after the default startsecs, so it was EXITED and restarted for ever. startsecs is now MILTER_WAIT_TIMEOUT + 10, exported by the entrypoint as POSTFIX_STARTSECS and expanded by supervisord (options.py, processes_from_section(); a non-number MILTER_WAIT_TIMEOUT stops the start): a give-up is "Exited too quickly", BACKOFF, FATAL after startretries, crashexit. For those seconds after each start the program is STARTING (the healthcheck's 90 s start period covers it; the sender check counts no downtime then). C-V5. logrotate had startsecs=0, so a refused config restarted it every second for ever. startsecs=1: FATAL after startretries; crashexit reads the event's processname and leaves logrotate's FATAL on Server > Health. B-U2. "supervisorctl start" returns when a startsecs=0 program is RUNNING, not when it ends, and mapped "already started" to success. The panel now speaks supervisord's XML-RPC itself (internal/supervisor, RunOnce): startProcess without waiting, getProcessInfo until it leaves STARTING/RUNNING, exitstatus 0 or an error; a run in progress is waited out and one of its own follows. A failed reload while program postfix is not RUNNING is logged, not an error: the next master reads the files. C-V10: postfix-reload rests EXITED after a run; the e2e process check takes STOPPED or EXITED. C-P34: the dead "postfix set-permissions" branch goes - postfix check's create-missing builds the queue tree. Stand (2026-10-14, make e2e, exit 0; MILTER_WAIT_TIMEOUT=10 on the stand): new supervision_stops_postfix_and_gives_up - supervisorctl restart postfix writes postfix-script's "stopping the Postfix mail system" and leaves one master; with the panel's sockets removed, start answers "postfix: ERROR (spawn error)" (finish() sets spawnerr "Exited too quickly", onwait() reads it first) and Postfix comes up once the panel is back; a group-writable logrotate config ends FATAL with the container up; the panel's Reload configuration answers only after mail.log has "refreshing the Postfix mail system", postfix-reload EXITED. session_survives_restart sees the stop line for the container's restart. Measured: master's own "terminating on signal 15" never reaches mail.log (master TERMs its group, postlogd included, before it logs). go vet and go test ./... pass on the server (24 packages; new unit tests of RunOnce against a fake supervisord). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ee37d16dc7 |
audit: stage 2c - seven lines built, ticked
|
||
|
|
c8f669714e |
journal milter: recipients as Postfix logs them, once each
test / test (push) Canceled after 0s
The row's recipient has to be the address Postfix writes in to=<..> or orig_to=<..>, or the delivery line finds no row and the sweep closes it as bounced. Postfix 3.7.11 hands the milter the RCPT argument as the client wrote it (smtpd.c, milter_argv(); extract_addr() leaves it), go-milter v0.4.1 trims "<>" and drops the ESMTP parameters (session.go, case CodeRcpt) - so cleanAddress's cut at the first blank broke a quoted local part (B-G5). Postfix keeps the address in internal form (tok822: quotes off, backslash escapes taken) and logs it external (info_log_addr_form.c, quote_822_local(): quoted again only when not a dot-string); recipientAsLogged does the same, "ab"@x -> ab@x, "a b"@x stays. A recipient given twice in any case is one row: cleanup drops the duplicate, comparing folded (cleanup_out_recipient.c, been_here(), BH_FLAG_FOLD; A-31). A rewritten recipient is matched by orig_to=, which log_adhoc() writes only when it differs (A-9). The store's case-insensitive match stays, now with its reason from the source (D-S20). Stand (2026-10-14, make e2e, exit 0): new recipients_as_postfix_logs_them - RCPT TO "x y"@, "ab"@ and "X Y"@ sink.e2e.test give two rows, "x y"@sink.e2e.test and ab@sink.e2e.test, both sent. go vet and go test ./... pass on the server; a unit test drives the milter through go-milter's own client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0ed03b2094 |
log tailer: expiry, Postfix's time values and refusal lines as Postfix writes them
test / test (push) Canceled after 0s
Expiry (assumptions A-8). Postfix 3.7.11 logs the end of a message's queue lifetime once per message, "<id>: from=<..>, status=expired, returned to sender" (src/qmgr/qmgr_active.c, qmgr_active_done_25_generic()), with no line per recipient; the tailer waited for "to=..., status=expired", which no Postfix writes, and the sweep closed only queued rows, so such rows stayed deferred. The tailer now reads qmgr's line (ExpireMessage: each recipient's newest open row of the id becomes bounced); the sweep closes a deferred row too when Postfix no longer holds the message, and counts as missed only a final line (sent, bounced, expiry). The queue listing's '#' (forced expiry, showq_compat.c showq_message()) is read as a flag (B-PQ1). Time values (B-PQ10, B-PQ11): conv_time() ported - one number, one lower-case unit, the parameter's default unit when none (get_def_time_unit: d for the lifetimes, h for delay_warning_time), the rest ignored; postconf -h -x expands $name; bounce_queue_lifetime capped at maximal_queue_lifetime as qmgr_post_init() caps it, so LongestLifetime is the queue lifetime. Refusals (A-21, A-23, A-24, A-15): smtpd_whatsup() ends the NOQUEUE line with sasl_username=<login> on an authenticated session; the line is now matched to its row by client, login and sender, and a line without a login is no application's. The client= line's login ends at the comma. The "IPv6:" trim goes (namaddr is built from state->addr). A row recovered from cleanup's line says that line names the last recipient only. Stand (2026-10-14, make e2e, exit 0): new expired_message_is_closed - defer_transports=smtp makes the retry service defer the message, postsuper -e and postqueue -i force its expiry; mail.log has "<id>: from=<alerts@sender.e2e.test>, status=force-expired, returned to sender" and the row goes deferred -> bounced; refusals_show_postfixs_line passes with the login match. go vet and go test ./... pass on the server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
55aef6e721 |
sender check: the sender map read and looked up as Postfix does it
Postfix 3.7.11 takes the owners of the first key mail_addr_find() finds -
the address, then without its extension, then @domain, each tried only
when the one before is absent (src/global/mail_addr_find.c,
mail_addr_find_opt()) - and compares the login with them by
strcasecmp_utf8() (src/smtpd/smtpd_check.c,
reject_auth_sender_login_mismatch()). The check accepted a From: address
when any of the three keys named the application: an application holding
@domain could write an address listed for another application of the
domain, which Postfix refuses it in the envelope, and have it signed
(assumptions B-PS1).
The table is now read as dict_thash_open() reads it: continuation lines,
owners split on commas and blanks (B-PS4), the key ending at the first
blank outside quotes, keys folded; a key given twice makes Postfix refuse
the whole table (no DICT_FLAG_DUP_* flag), and the check then defers too.
The writer folds keys, so two cases of one address cannot be two lines.
e2e on the stand (2026-10-14, make e2e, exit 0): the wildcard application
is refused billing@ - listed for app-list - in From: (550 5.7.1), and in
the envelope by Postfix at RCPT ("553 5.7.1 <billing@...>: Sender address
rejected: not owned by user"). go vet and go test ./... pass on the
server (23 packages).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
31c2b50457 |
antispam: rspamd strips a sender's own verdict for every client
test / test (push) Canceled after 0s
rspamd 3.11.1's milter_headers skips its verdict routines - removes none of the sender's X-Spamd-Result / X-Rspamd-Action, adds none - for a client it counts as local (loopback, a unix socket, local_addrs: the private ranges) or one that authenticated (skip_local, skip_authenticated, both on by default; milter_headers.lua skip_wanted(), lua_ip.c lua_ip_is_local()). The custom routine that signs X-SelfPost-Filter is never put through skip_wanted(), so it signed the sender's own verdict, and Inbound > Log showed it as rspamd's. Every sender is private when Docker's proxy relays port 25 from the bridge gateway. Both options set to false. Tried 2026-10-14 in a throwaway rspamd/rspamd:3.11.1-3 on the test server (rspamc --ucl, a message carrying its own verdict headers): old config, from 10.0.0.5 and as an authenticated user, the milter reply removed and added neither and added the signature; new config, both removed and rspamd's own added, as from 203.0.113.9. Also: the sender rules take the envelope only (extract_from = "smtp"; by default a bounce's From: header is used - tried: a bounce whose From: domain is denied was refused before, is not now; a denied sender is refused either way); the image pinned to 3.11.1-3, the build read (3.11 and 3.11.1 move with each rebuild); the prefilter lines no longer read go. architecture.md and security.md say so. Assumptions C-R7, B-R1, C-R4, C-R12, C-R5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a658da11f7 |
audit: stage 2c - the answers, and what they make to fix
test / test (push) Canceled after 0s
docs/assumptions-2026-10-14.md gets its second half: every entry of the inventory settled in the source of the version the image runs, with the citation now beside it in the code. 559 verdicts (the documents' claims that repeat one another answered once): 415 hold, 77 hold in part, 55 do not hold, 12 need a measurement on the stand. The plan's heuristics line is done; what the sources contradict becomes fourteen lines of stage 2c, worst first: rspamd signs a forged verdict for a client it counts as local; the sender check takes any key of the sender map where Postfix takes the first; expiry, the forced-expire flag and Postfix's time units; refusals and recipients matched by what Postfix really writes; postlogd after a rename; inbound mail over 10 MB refused by cleanup; master outside supervisord's reach; sasldb written in place; Health showing a socket file and a certificate Postfix does not serve; the Origin check refusing every POST where Sec-Fetch-Site is missing; two processes migrating at once; certbot's symlinks; the documents; and the measurements. Roadmap 31/81. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a8b6276b0d |
audit: stage 2c - the inventory of what SelfPost assumes of other programs
test / test (push) Canceled after 0s
docs/assumptions-2026-10-14.md: four read-only readings of the tree at
|
||
|
|
811055d4c0 |
development: the rule's example cites the function Postfix really has
The example comment named check_sender_login() and strcasecmp; Postfix 3.7.11 compares the login in reject_auth_sender_login_mismatch() with strcasecmp_utf8 (src/smtpd/smtpd_check.c:3913, 3936). Found by the heuristics inventory - the rule's own example was written from memory. Says where the Debian sources are fetched from and kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0f984ff8d8 |
development: code relying on another component cites its source; stage 2c: the heuristics review
test / test (push) Canceled after 0s
Owner, 2026-10-14: "нам нужно полное ревью кода на предмет всей этой эвристики. Её не должно там, где можно посмотреть код и ответить точно и однозначно", and "в коде таких наших блоков должно быть обоснование со ссылкой на код компонента". - development.md § Code that relies on another component's behaviour: the rule, with the form of the comment (component, the image's version, file, function; or a document's URL and section; or "measured on the stand, date" said as such), and fixtures saying whether they were seen or composed. - Audit stage 2c: the line "what was taken on trust" becomes the heuristics review - an inventory over the whole tree written down as docs/assumptions-2026-10-14.md, then each answered from the source of the version the image runs, the comment added, the code fixed where it contradicts. Plan still 66 lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3c8628074f |
release: 2.5.1: audit stage 2b - the log tailer and what the panel shows of a refusal
test / test (push) Canceled after 0s
A log rotated while the panel was down is read to its end; the deferred counter remembers a place in the log, not a second; a refused message's page shows what Postfix wrote; a refusal the panel did not get to write is written from mail.log; downtime is estimated (≈) when supervisord is silent; the Sender check card counts connections and messages apart (the owner's decision); a message the sweep closed says the panel closed it. Plus the findings of both reviews of the stage. Rolled out to the test server as 2.5.0-post.e1ddf12 (the same code): migration 0009 ran on the live database - schema 9, the new columns in place, 68/68 send-log rows still tied to their domain, integrity ok. CHANGELOG cut; pin 2.5.1 in deploy/docker-compose.yml, README (with the local-build note), guide; development.md "from pin"; roadmap row and the recommended order; stage 2b marked shipped in the plan. No tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e1ddf12744 |
audit: stage 2c - what was taken on trust, read in Postfix's source
test / test (push) Canceled after 0s
Owner, 2026-10-14, after the rule "do not guess how code works when its documentation or source can be read": "Есть ли ещё у нас места, где мы угадывали?". The inventory found four: qmgr's expiry against the queue lifetime (a margin in queued_over_lifetime), postlogd after a rename (the rotation window), the stale smtpd after a reload (measured, not understood), and the NOQUEUE line's fields (two fixtures carry a sasl_username Postfix does not write, one presented as seen on the stand). A line in stage 2c reads Postfix 3.7.11 for them, records what is read, fixes the fixtures and the code where they disagree. Plan 66 lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
af130e268a |
findings of the reviews of audit stage 2b
Technical review (Opus) and security review (Fable) of 5be109b..5154bba;
reports in the session's scratchpad. Nothing above low from security; one
bug and three risks from the technical review. Migration 0009 is not
deployed, so it was extended.
- Bug: two refusals of one second swapped their NOQUEUE lines (each line
went to the newest lineless row). AttachRefusalLine now matches the
client address too (send_log.client_ip, written by the journal milter -
Postfix's line names the client and the sender, never the login: the
sasl_username part of the pattern never matched and could be fed by HELO,
security F1/F2), takes the nearest row in time and the oldest of equals,
and a line already on a row goes on no other (security F6).
- Waiting lines and recoveries lived only in memory while the saved offset
moved past them: a stop within 30 s lost them. The tracker now never saves
an offset past the oldest waiting item in its file (tracker.hold,
refusalLines.oldest) - for a recovery, the client= line that names its
login - so they are read again; the "once" guards make that harmless.
- A recovered row whose login the tailer did not see was nobody's: its
domain_id falls back to the envelope domain's.
- The fingerprint's file identity is the inode alone: the device number can
change across a host reboot and make the same log look rotated.
- A refused row carries its queue id; UpdateStatus's "newest row" now skips
refused rows, so a later message with that id does not flip it to sent.
- The alive mark: its age is read first and kept from then on, so asking
supervisord for 30 s cannot make it stale; the entrypoint removes it with
the sockets - a container restart is not the check's downtime, and a mark
from before would have counted the whole outage as "≈".
- Security F3: the sender check prints no ";" of the client's in the From:
text of its reply, and the tailer reads the envelope after the first ";"
- Postfix's own text.
- A refused row with a queue id and no stored lines says so (it was told the
mail.log text of 2.2.0); the fact's em dash is the house hyphen.
- Docs: architecture (client address, no login), guide § Health (warns on
either count), help topics log and sender-check (design
|
||
|
|
1ca87f2d1a |
design: help wording after the review of stage 2b
Topic log: a message's lines are found by queue id - and for a message refused before it had one, Postfix's one line about the refusal (the review: "found by queue id" no longer holds for MAIL FROM refusals). Topic sender-check: the ≈ sentence said "at most what Postfix was up", which reads backwards; it is the time since the panel's last sign of life, "which may be more than Postfix was up". build.py, check.py: 0 violations. help.go follows in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5dcff8c2b9 |
audit: stage 2c - the sender check reads From: as OpenDKIM does (owner's decision)
test / test (push) Canceled after 0s
Owner, 2026-10-14, after reading OpenDKIM's source together (dkim_mail_parse, called in mlfi_eoh on the first From:; SenderHeaders is unset in build/opendkim.conf): "Да" to porting the parser so that the check decides by the address OpenDKIM will take. The scan of the display name and comments for another sender of the server - a guard against two parsers parting ways, and the reason L8 is half closed - then goes. New stage 2c (three lines) after 2.5.1 and before stage 3: the port from the version the image runs, checked against OpenDKIM on the stand with generated lines, then docs and wording. Stages 3 and 4 move to 2.5.3 and 2.5.4 (development.md's planned cuts). Plan 65 lines, roadmap 30/65. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
57142b21b5 |
security: L8's remaining probe accepted by the owner
test / test (push) Canceled after 0s
Owner, 2026-10-14, asked whether to refuse every foreign address in a display name to close what L8 leaves (refused or passed still tells an application which domains are hosted here): "Ответ 1" - leave it. The probe is a trusted application of this server guessing names one at a time, as it already had to before L8, when the reply said in words what the line itself had named. security.md (Mail path and accepted risks, with the trigger to revisit: untrusted tenants, or the display-name rule decided anew) and the audit plan record it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5154bba61c |
docs: stage 2b of the audit - the log tailer, refusals, the Sender check card
test / test (push) Canceled after 0s
architecture.md - § Log tailer: the fingerprint carries the inode; a log rotated while the panel was down is read from mail.log.1 to its end, or the gap is logged; a refusal's line (the queue id of a From: refusal, the NOQUEUE line put on a MAIL FROM refusal's row) and the refusal written from mail.log when the panel had not. - § Sender check, what it costs: supervisord asked for half a minute, then the estimate and ≈; connections and messages in two columns; the count's saved place in the log. guide.md - § Outbound log: a refused message's page shows Postfix's line(s); rows from before, and a row written from mail.log, say so. - § Overview and Health: the two columns and ≈. schema-migrations row 9 was written with the migration. Audit plan line ticked: stage 2b's lines are all done. Roadmap 30/62. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
415334c2cc |
message page: a row the sweep closed says the panel closed it (stage 2b)
The History step of every bounced row said the receiving server refused
the message permanently or Postfix gave up, "the reason is in the delivery
log below" - for a row the reconcile sweep closed (reason "reconcile")
neither is known and the log has nothing (review of stage 1). Such a row's
step is now "Closed by the panel - Postfix no longer held the message and
mail.log never said what became of it ... rather than show a delivery it
cannot evidence." The help topic log names the case (design
|
||
|
|
ac985d03a8 |
sender check: connections and messages counted apart; downtime estimated when supervisord is silent (stage 2b)
Following the design commit
|
||
|
|
df96690ba6 |
design: the Sender check card counts connections and messages apart; ≈ for an estimate
Owner, 2026-10-14 ("Может просто показывать и то, и то и не смешивать?"):
the figure that was "Messages deferred" is two - connections Postfix turned
away while the panel did not answer (refused from the connection on, before
a message is offered), and messages deferred in the middle, when the panel
stopped answering after a message had begun (cleanup's case, counted since
the M13 fix). The card has a column for each.
The audit's stage 2b also marks a day whose downtime is an estimate - a
start at which supervisord could not say since when Postfix ran: such a
period's time is shown with ≈, and the text under the table says it is at
most what is shown.
Help: topic sender-check says what each column counts and what ≈ means;
topic log names the bounced row the panel closed itself (the sweep, when
Postfix no longer held the message and mail.log never said what became of
it). build.py, check.py: 0 violations, outlines unchanged. The code follows
in the next commits; until then the help text guard fails - not pushed in
between.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
65d916f26a |
logtail: a refusal the panel did not get to write is written from mail.log (stage 2b)
test / test (push) Canceled after 0s
The sender check answers 550 5.7.1 and writes the refused row through a
buffer a moment later; a panel stopped in between (a rollout, a kill) left
an application refused and a log that did not know it. A refusal at MAIL
FROM needs nothing: the journal milter writes its row before it answers.
- logtail.refusalLines remembers each queue id's login from smtpd's
"<id>: client=..., sasl_username=<login>" (4096 ids at most) and, on
cleanup's "<id>: milter-reject: END-OF-MESSAGE ...: 5.7.1 From: <line> -
<why>; from=<...> to=<...>", waits 30 s and hands it to
store.RecoverRefusal - an INSERT ... WHERE NOT EXISTS a refused row of that
queue id within two minutes: the row the panel wrote, or one recovered
before, means nothing is written. It is read from the saved offset on, so
a kill is covered as well as a clean stop - which a flush of the buffer at
shutdown would not.
- Migration 0009 (not deployed yet) gains send_log.recovered; the row is
written with the line's time, the login's domain id, reason "from", the
From: line as the check printed it (to the last " - " before ";").
- The message page: a fact "Written: from mail.log after a restart: the
panel stopped before it wrote the refusal".
Tests: store (not written when the panel wrote it; written once however
often); logtail (the login from client=, the From: line with a " - " in it,
nothing before the wait is over); view (the fact). e2e
refusal_recovered_from_the_log: a From: refusal, the panel stopped, its row
deleted and the tailer's saved place moved before the message's first line
(python3 in the container, as the panel's user - a kill cannot be timed to
that moment from outside); started again, the row is back, recovered, once.
Also fixed: handlers TestMessagePageOfARefusedMessage still expected the
old sentence; it failed on the server after
|
||
|
|
e166a2ebd6 |
refused messages: the page shows what Postfix wrote (stage 2b, 0009)
The Delivery log box of every refused row said "This message never reached
the queue, so Postfix wrote no delivery lines for it" - untrue: Postfix
wrote a line for each. The owner, 2026-10-13: the box is required on every
message page. Pictured for him first (dev/shots, the refused variant of
out-message: wrong From with its queue id lines, rate-limited with the
NOQUEUE line); the variant has no mockup screen of its own - the guard ties
screens to pages, and its outline is out-message's.
- Wrong From: cleanup gives the message a queue id before the headers are
judged; the sender check reads {i} at end-of-headers and writes it on the
refused row. Its lines - stored under the id like any other's - are on the
page, but only those from 10 minutes before to 1 minute after the refusal
(store.SendLogLines for a rejected row): Postfix frees the id at once, and
a later message with it lends nothing. No mail.log fallback for a refused
row, for the same reason.
- Rate-limited and wrong client: refused at MAIL FROM, no queue id. Migration
0009 adds send_log.refusal_line; logtail.refusalLines matches smtpd's
"NOQUEUE: milter-reject: MAIL from ...; from=<...>" (not port 25's, not
CONNECT/EHLO) and store.AttachRefusalLine puts it on the newest refused row
of that sender (and login, when the line names it) within two minutes; a
line whose row is not written yet waits up to 30 s (retried at each pass).
- The page: the queue id fact says "given, the message was not queued" or
"none - refused at MAIL FROM, before one is given"; the box's caption is
"The line from mail.log for this refusal" for the latter; a row from
before says the line was not kept. LogLineLevel colours a refusal by its
reply: 5xx red, 4xx amber (IPv6 clients included). The id in the fact is
not muted as in the picture: the kit has no muted inline.
Tests: store (attach by sender, login, time; not a queued row, not one an
hour off; a refused row's lines are its own minutes); logtail (attach, wait
and retry, port 25 and other lines ignored); milter (the refused row carries
{i}); view (facts, caption, line levels); handlers (the line on the page,
the sentence without it). e2e refusals_show_postfixs_line: a client-address
refusal made there, and the pages of a wrong From, a rate-limited and a
wrong client refusal each show Postfix's line.
schema-migrations row 9, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 25/62.
Evidence on the test server for this tree: make e2e exit 0 (the new case
in 5.4 s); go test ./... ok but for two schema tests, fixed after the run
(the schema at 0009) - store tests ok locally; the full run follows before
the push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
c452606175 |
logtail: the deferred counter remembers a place in the log, not a second (stage 2b)
test / test (push) Canceled after 0s
The counter passed over, while catching up after a start, every warning no later than the last one counted - by time; mail.log stamps whole seconds, so a second warning of that second, later in the file, was lost. - The tailer gives each line its place (Place: the file's fingerprint and the offset just past the line): follow tracks the bytes the reader hands over, readRotated likewise in mail.log.1. Run hands tr.line to the counter. - store.AddSenderCheckDeferred(at, n, place) counts, moves the last-deferral mark (never back - an upsert WHERE) and saves the place (settings sender_check_counted_place) in one transaction. - Catching up, a warning in the file of the last one counted is passed over by place (end <= counted end); in another file - a rotated log read again - by time, as before. The fingerprint carries the inode, so the place stays true across a rotation. Tests: two warnings of one second, an unclean stop between them, all lines read again - each counted once; the store saves the place with the count and keeps the latest deferral time. Existing counter tests pass a zero place (time rule). CHANGELOG [Unreleased], audit plan line ticked, roadmap 24/62. Evidence: go vet ./... clean; go test ./internal/store, ./internal/logtail ok (but for the Windows-only test); the server run follows before the push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
4678015bbd |
logtail: a log rotated while the panel was down is read to its end (stage 2b)
At start, a saved offset whose fingerprint was not the current file's meant "start on the new file": what Postfix wrote between the panel's stop and the rotation was never read - delivery statuses, stored lines, refusals, inbound journal rows, the deferred counter. - tracker.resume marks the case (behind, prev); follow calls readRotated before the new file: it opens mail.log.1 (delaycompress leaves it plain), checks by fingerprint that it is the file the place belongs to, reads it from the saved offset to its end through handle (catching up: the once-protections apply) and flushes the stored lines. - Not found, not that file, or the offset past its end (rotated twice): logged, with the saved place's time - from when nothing was read. - The fingerprint gains the file's identity (device.inode, fileid_unix.go; empty elsewhere): two logs that begin alike are two files - the review-2026-08-followups row on collisions. A fingerprint saved before (hash alone) still matches by its hash (sameLog), so the first start after the upgrade resumes where it was. - store.LogtailState reads back updated_at. Tests: stop, append, rotate, append, start - both halves read, once each, the earlier line not again; a mail.log.1 that is another file is not read from the place; sameLog with an old and a new fingerprint. CHANGELOG [Unreleased], audit plan line ticked, roadmap 23/62. Evidence: GOOS=linux go vet ./internal/logtail clean; go test ./internal/logtail ok on Windows but for TestFollowTailsAndRotates; the server run follows before the push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5be109b871 |
release: 2.5.0: audit stages 1 and 2 - the delivery log and the tenant boundary
test / test (push) Canceled after 0s
Stage 1: a cut queue listing no longer bounces held mail; the reconcile sweep closes only queued rows, by id, marked "reconcile", and its fate is measured (delivery_log_days, migration 0007); delivery lines are read at the head of the line; a renewed TLS certificate is served without a restart; Health counts the message a hung panel deferred. Stage 2: logins folded to lower case and unique without regard to case; the last global administrator kept inside the transaction, users saved whole; a domain import held to the forms' rules; log rows by domain id (migration 0008); the 550 reply names no other domain; which key OpenDKIM signs with, tested. Plus the findings of both reviews of each stage. Rolled out to the test server as 2.4.2-post.ef18ef2 (the same code): migrations 0007 and 0008 ran on the live database - schema 8, 68/68 send-log and 10/10 inbound rows tied to their domain, integrity ok. CHANGELOG cut; pin 2.5.0 in deploy/docker-compose.yml, README (with the local-build note), guide; development.md "from pin"; roadmap row and the recommended order; stages 1 and 2 marked shipped in the plan. No tag (the owner's command only). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ef18ef21a0 |
findings of the reviews of audit stage 2
test / test (push) Canceled after 0s
Technical review (Opus) and security review (Fable) of a59e3d4..cb5a4a2;
reports in the session's scratchpad. Nothing critical, high or medium; no
outright bug. Fixed:
- F2, F3 (0008 backfill, before it runs on the live database): rows are
filled by the rule new rows follow - the application's domain, then the
name's - and only by a domain and application that existed when the row
was written (created_at); the inbound log likewise. Test: the migration's
UPDATEs re-run on prepared rows (before the domain, after, null sender,
application gone, a stranger's name). Dry run on a copy of the live
database: 68/68 and 10/10 rows filled, none disagreeing with its login's
domain, integrity ok.
- V2, F4: an application's statistics and level-2 count are its login's
rows of its domain (appByLogin), and StatsWindow starts no earlier than
the entity's creation; the message page's "may send as" only for a login
still of the row's domain. Test: a login taken again in another domain
starts at zero.
- F7: MAIL FROM:<> is held to the domain limit of its login
(ApplicationByLogin fills DomainName).
- V3: an imported DKIM selector is held to DNS labels (validate.Selector).
- F5, F6: an imported auto limit's numbers are dropped and recalculated
right after the import; its mode is stored trimmed.
- V6: the store refuses a role that is neither global nor domain.
- F9: SaveUser keeps the stored hash when no new password is given (the
form no longer writes back the hash it read). F10: a reach naming a
domain deleted meanwhile is ErrNoSuchDomain, answered on the form.
- V4, F12: from-lines-other.txt - lines naming another sender ({other}) in
a quoted string, a quoted pair, capitals, a comment, an encoded word, a
quoted local part; the unit test wants each refused, the e2e
(lines_naming_another_sender_never_signed_for_it) wants each refused and
none signed for it. The passed list gained a.{DOMAIN}, a comment after
the angle address, a quoted local part, and "b@ {other}" - passed by
design (security.md), and the e2e shows it signed for the own domain.
- F13: e2e login_in_other_capitals_does_not_authenticate - AUTH with the
login upper-cased gets 535.
Not changed, written down: V1/F1 - L8 is half closed (refused or passed
still tells an application which domains are hosted here); security.md's
accepted risks and the CHANGELOG say so, and the owner is asked whether to
refuse every foreign address in a display name (changes the 2.4.0 rule).
F11 - deleting a domain rewrites its rows' domain_id (seconds at 1e5 rows),
accepted at this size. F8 (test cannot tell write-first from read-first;
the reviewer verified it with two stores, 30/30), V5, V7, P2: noted.
Evidence on the test server for this tree: go vet clean, go test ./... 23
packages ok, make e2e exit 0 with the three new cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
8b53d63160 |
audit: the owner's decision on the Sender check columns (stage 2b)
test / test (push) Canceled after 0s
Owner, 2026-10-14: show connections turned away and messages deferred mid-message apart, not mixed. The stage 2b step now says: smtpd's warning to the existing column (Connections turned away), cleanup's to a new column of sender_check_days in the stage's migration (Messages deferred), both on the card, the help topic saying what each counts; mockup first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
cb5a4a22e1 |
docs: stage 2 of the audit - roles, the guard, logs by id, import, logins
test / test (push) Canceled after 0s
security.md - § Roles: the last-global guard is inside the transaction that deletes or demotes (write, then count); setup is one statement; a user is saved in one transaction. A log row, outbound or inbound, belongs to a domain by id; a domain added again starts with an empty log. The Inbound › Log row of the table says by id. - § Mail path, sender check: the agreement of the check and OpenDKIM is tested (the shared list of passed From: lines); the refusal does not say which other sender a line names. - Accepted risks: "a row of the inbound log belongs to a domain by name" struck (L12). guide.md - Domain export and import: the file is checked like the forms before anything is created; a login keeps its case and a case clash is refused. - The application form: a new login is stored in lower case. schema-migrations.md was updated with 0008 in its commits. Audit plan line ticked; stage 2's lines are all done. Roadmap 22/62. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5747f0b25c |
sender check: the reply names no other domain (L8); which key signs, tested (stage 2)
L8: a From: line naming another sender of the server was refused with
"it names another sender of this server, <domain>" - a confirmation, to
whoever tries names, that the domain is hosted here. The reason now stops
before the domain; the line as written stays in the reply (the client's own
text) and on the refused message's page (header_from). Unit test: the reason
of every refusal names none of the server's other domains; e2e: the reason
of each sender-check refusal does not contain other.e2e.test.
Which key OpenDKIM signs with (owner, 2026-10-13): the check and OpenDKIM
read From: with two parsers, and their agreement was argued, never run.
internal/milter/testdata/from-lines-passed.txt holds the lines the check
passes for an application - bare and bracketed, quoted names with a comma,
escaped quotes and an address inside, an extension, three encoded charsets,
two folded lines, comments (nested too), a stranger's address in the name -
as Go-quoted strings with {domain}. The unit test wants each passed; the new
e2e case passed_from_lines_signed_for_their_domain_only sends each as app1
and wants every DKIM signature at the sink to be for the sender domain,
with one valid. All fifteen were. "(the shop) a@domain" - a comment before
the address, valid RFC 5322 - is refused by the check ("it must be one
address"); strict, not a hole, so it is not in the file. Groups are refused
too.
Audit plan lines ticked (two) with what was found, CHANGELOG [Unreleased],
roadmap 21/62.
Evidence on the test server for this tree (with
|
||
|
|
a618ffb0e4 |
send log: rows belong to a domain by id (audit L12, 0008)
A domain user's log, the message page, a domain's statistics, the level-2 count and the notifier's refusal count picked send-log rows by domain name, so a domain deleted and re-added under its old name showed its earlier mail to the next assignee and went on counting it. Migration 0008 (not run on the live database yet, so extended here): send_log.domain_id and inbound_spam_log.inbound_domain_id, REFERENCES ... ON DELETE SET NULL, filled once by name from the registry as it is at migration time, each with an (id, created_at) index. - Writes: InsertQueued / InsertRejected set domain_id to the domain of the row's login (loginDomainID) - the journal milter and the sender check's refusal rows both go through them; the inbound journal sets inbound_domain_id from the inbound domain's name at insert. - Reads: SendLogFilter.DomainIDs (was Domains, by name) and InboundSpamFilter.DomainIDs are the scope; the global role still reads everything. The message page checks row.DomainID against the user's domains. DomainSendStats and CountMessages keep their name argument and select domain_id = (SELECT id FROM domains WHERE name = ?). The notifier joins send_log.domain_id to the domain and its application. - DMARC reports stay by name on purpose (a report is about the DNS name). Tests: store - scope by ids; a re-added domain's log, level-2 count and statistics hold its own row only, the global log both; inbound scope by ids. Handlers - the new assignee gets 404 for the old row's page and does not see it in the log, the global role sees both. Tests that wrote rows for a login that is no application now register it (a row is a domain's through its application). Schema at 0008 with the two columns and indexes. schema-migrations row 8, CHANGELOG [Unreleased], audit plan line ticked, roadmap 19/62. Evidence: go vet ./... clean; go test ./... ok locally but for the two Windows-only tests; the server run follows before the push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e469034477 |
import: the export file held to the forms' rules before anything is created (audit M10)
test / test (push) Canceled after 0s
domain.Import trusted the file for everything but the domain name, the selector and the password: DMARCRua was not validate.Email'ed, client IPs not parsed, a rate limit's mode, window and count not checked - a window of 0 counted nothing, a garbage IP made the application refuse every client. handlers.checkDomainImport runs in HandleImportDomain after the domain name check and before Import, and refuses the file with "Invalid <what> in export file: <the form's own text>": the report address (validate.Email), each rate limit (parseRateLimitMode; auto - checkAutoMultiplier, 0 meaning the default as an empty field does; manual - checkManualLimit, which the form now uses too: count > 0, <= the level-1 backstop, window > 0; both 0 is an export without a limit of its own), each application's client addresses and the legacy ones on its rate limit (parseIPList, written back as the form stores them; allow-list on with none is refused). Login, sending addresses and password stay checked in app.ImportApplication. Tests: a good export passes and its addresses come out normalised; nine broken fields each refused with their message; a refused file posted to the handler creates no domain. CHANGELOG [Unreleased], audit plan line ticked, roadmap 18/62. Evidence: go vet clean; the import, rate-limit and application tests of ./internal/web/handlers ok; the server run follows before the push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f9e75de8d1 |
store: users saved in one transaction, the last global kept inside it (audit M7, M9)
M7: DeleteUser counted the global users and then deleted; the handler's demotion and CreateGlobalUser checked and then wrote. MaxOpenConns(1) serialises statements, not pairs: two globals deleting or demoting each other at once could leave none. Now: - DeleteUser: a transaction that deletes first (taking the write lock) and then requires a global left (requireAGlobal) - ErrLastGlobal rolls back. - SaveUser: likewise for a demotion, the count inside the transaction after the write. The handler's early count stays only to answer before hashing a password; SaveUser decides, and ErrLastGlobal maps to the same message. - CreateGlobalUser: INSERT ... SELECT ... WHERE NOT EXISTS (SELECT 1 FROM users), one statement. M9: submitUserUpdate made five writes in a row (UpdateUser, sessions, SetUserRole, ClearUserReach with its error only logged, SetUserReach). store.SaveUser(UserChange) writes name, hash, e-mail, role and reach in one transaction (a global user's reach cleared); sessions end after it commits. The create path had the same flaw - user, reach and then e-mail in three writes - and now goes through AddUser, one transaction; CreateUser stays as a wrapper for its many test callers. SetUserRole is gone; writeUserReach is the reach write inside a transaction. Tests: store - two globals demoting, and deleting, each other from two goroutines: exactly one refused, one global left; a refused SaveUser (taken name, a reach to no domain, nothing assigned) changes nothing; promoted to global loses the reach; AddUser with a bad reach leaves no user; four setups at once create one user. Handlers (the users pages, direct): the whole change saved, back to domain with a reach; the last global not demoted and the typed rename not written; a domain user with nothing assigned refused, nothing changed; create with e-mail and reach. CHANGELOG [Unreleased], audit plan lines ticked (two), roadmap 17/62. Evidence: go vet ./... clean; go test ./internal/store ok; the users tests of ./internal/web/handlers ok; the server run follows before the push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
60c7b85f84 |
findings of the reviews of audit stage 1
test / test (push) Canceled after 0s
Technical review (Opus) and security review (Fable) of 9efc9c7..a59e3d4; reports in the session's scratchpad. Fixed: - B1 (verified bug): store.UpdateStatus read before writing in its transaction; a commit by another connection in between (the dmarc-ingest process opens the same file) failed it at once with "database is locked" and the delivery line was lost. The first statement is now a write. - R3: a delivery line updated every row of its queue id and recipient; it now updates only the newest (Postfix reuses short ids). The sweep closes by row id (QueuedDelivery.ID, CloseUnreported(id)). - R4: a contradicted sweep keeps a mark, reason "reconcile-contradicted", instead of losing which row it was. - R1: contradicted cannot see a lost line (never read late). New counter reconcile_missed: at close, the sweep looks for the row's delivery line in mail.log and mail.log.1 (QueueLines). 0007 edited - it has run on no database but throwaway stands. - R2: queued_over_lifetime cannot measure "what removing the sweep would leave" while the sweep runs; its text now says rows stuck despite the sweep, and the rule uses closed - missed - contradicted. - cert-reload (R5, R6, R7, security medium/low): both files copied to .new and the copies checked as a pair before both are renamed; acts on the copy only when postconf says Postfix reads it; a failed reload is retried next pass; umask 077; says so when the mounted files cannot be read. The e2e case asserts Postfix reads the copy, the copy equals the mount, the key is 600 root. - Security low: a recipient with a quoted local part - the journal cut "a b"@x at the space, deliveryRe cut "a>b"@x at the ">" - matched no delivery line and was closed by the sweep. cleanAddress ends a bracketed address at the first ">" outside quotes; deliveryRe takes the recipient up to the ">, " before relay= (test fixtures given the relay= every real delivery line has). - Security low: postqueue -p's error carries at most 512 bytes of output. - Nits: addDeliveryLogDay refuses a column not on its list; the truncation test checks its message. - To stage 2b (page wording, mockup-first): the history step of a bounced row says the receiving server refused it, false for a row the sweep closed. To the owner when stage 2b is reached: since M13 the "deferred" figure counts whole messages too, which "Connections turned away" would not name. Found while verifying: the e2e's main positive path failed once with "553 not owned by user app1" - an smtpd idle from before the panel's reload took the connection in the reload's second. Measured on the stand (one-off probe, not committed): sending 0, 10, 40, 110 s after the save (two seconds at the soonest) always met an smtpd started after the reload. The e2e now waits for the sender map after each application it creates (waitForSenderMap: AUTH, MAIL, RCPT, RSET - no message, no row), and the level-1 case uses it instead of its own loop. Docs: architecture § Queue reconcile, schema-migrations (rows 7 and 8, chain head 8), CHANGELOG, audit plan (stage 1 note, the measured window, a new stage 2b line: 62 lines), roadmap 15/62. Evidence on the test server for this tree: go vet clean, go test ./... 23 packages ok, make e2e exit 0 (renewed certificate, hung panel, level 1 at 49/60). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f51f5db245 |
app: logins folded to lower case, unique without regard to case (audit H4, 0008)
Postfix's reject_sender_login_mismatch compares the SASL login with the sender map without regard to case - seen on the e2e stand this time (a one-off probe, not committed: map rewritten to "APP1", client authenticated as "app1", RCPT accepted). The panel stored logins as typed under a BINARY UNIQUE, so "Ops" and "ops" were two applications to the panel and one sender to Postfix: a tenant could send with another tenant's envelope sender. - app.foldLogin: Service.Create lower-cases the login (owner: fold, do not refuse); CreateWithSettings rolls back the stored (folded) login. - ImportApplication keeps the login as written: folding it would rename the SASL account and break the imported application's working credentials; the index below refuses a case clash there too. - Migration 0008 (DDL): CREATE UNIQUE INDEX idx_applications_login_nocase ON applications (login COLLATE NOCASE). The column's own BINARY UNIQUE stays (SQLite cannot change a collation without a table rebuild, which a migration transaction cannot do with foreign keys on). Checked on the live installation first: 4 logins, no case clash; "Quieter3C" keeps its case. 0008 is not run anywhere yet; send_log.domain_id joins it later in stage 2. Tests: "Ops" created as ops, SASL account and map binding in lower case, OPS in another domain and an imported oPs refused; the store refuses ops beside Ops; schema at user_version 8 with the index. CHANGELOG [Unreleased], audit plan line ticked, roadmap 15/61. Evidence on the test server for this tree (with the probe): go vet clean, go test ./... 23 packages ok, make e2e exit 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a59e3d4d61 |
docs: stage 1 of the audit - log tailer, reconcile, sender check, guide
test / test (push) Canceled after 0s
architecture.md - § Log tailer: the rotation drain does not close the gap entirely (postlogd writes to the renamed file until the reload reaches it); the fix waits for the measurement. Delivery lines read at the head of the line (M12). - § Queue reconcile: reason "reconcile", the 4 MiB cap, the conditional close; the measurement (delivery_log_days, its four counters) and the decision rule written before the data. - § Sender check, what it costs: cleanup meets a check that hangs mid-message, with the warning seen on the stand; counted once per message. guide.md - § Outbound log: `bounced` is the panel's word when Postfix no longer holds a message and the log never said; it is counted. - § Overview and Health, Sender check: the message a hung panel deferred is counted too. Audit plan line ticked, roadmap 14/61. Stage 1's code items are done (the two deferred tailer fixes wait for the data, as planned). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
db36aa2c0e |
logtail: count the message a hung sender check defers in cleanup (audit M13)
Seen on the e2e stand, the panel frozen with SIGSTOP once DATA was
answered: the client gets 451 4.7.1 after 30 s, and the warning that names
the socket is cleanup's, not smtpd's:
postfix/cleanup[1339]: warning: milter unix:/run/selfpost/sender.sock:
can't read SMFIC_HEADER reply packet header: Connection timed out
postfix/cleanup[1339]: 7BCAF3C75DA0: milter-reject: END-OF-MESSAGE ...
4.7.1 Service unavailable - try again later; ...
The counter read smtpd lines only, so Health's "messages deferred" missed
the one case that tells a hung panel from a stopped one. milterLineRe now
takes smtpd and cleanup; cleanup's "milter unix:<socket>:" warning counts a
message each time (one cleanup process serves message after message, and the
first warning ends the message); smtpd keeps once per connection. The
composed fixture is replaced by the seen lines, with two messages through one
cleanup process and OpenDKIM hung in cleanup (not counted).
e2e panel_hung_mid_message_is_counted: freezes the panel after DATA's 354,
wants a 4xx at the end of DATA, logs what mail.log says, and waits for
Health's today count to grow. kill through sh -c: the image has no kill(1).
CHANGELOG [Unreleased], audit plan line ticked, roadmap 13/61.
Evidence on the test server for this tree (with the next commit's docs):
go vet clean, go test ./... 23 packages ok, make e2e exit 0 -
renewed_certificate_picked_up 1.2 s, panel_hung_mid_message_is_counted
31.7 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
7af37654a1 |
cert-reload: renew Postfix's copy of a renewed certificate (audit H2)
postfix-config.sh copies the mounted certificate and key into /etc/postfix/tls-internal at start (root-owned, key 0600) and Postfix reads the copy; cert-reload only ran `postfix reload` daily, which re-read the stale copy, so a certificate renewed while the container ran expired on 465/587 with the old one. postfix-cert-reload.sh now, every TLS_RELOAD_INTERVAL_SECONDS: compares the mount with the copy (cmp); when they differ and the certificate and key belong together (openssl public keys equal - a renewer writes them one after the other), writes each file beside its target, root-owned with the start's modes, renames it over the target, and reloads Postfix. Nothing changed - no reload. No copy (files missing at start) - the old periodic reload. Never exits non-zero. e2e renewed_certificate_picked_up: writes a new certificate into the stand's mount and waits (30 s) for port 465 to present its serial; compose.override.yml sets TLS_RELOAD_INTERVAL_SECONDS=2. supervisord.conf comment, architecture.md (process table, § Postfix TLS certs, the env knob), guide (after the proxies: picked up within a day). CHANGELOG [Unreleased], audit plan line ticked, roadmap 12/61. Evidence: GOOS=linux go vet in test/e2e clean; the server run of the stage follows before the push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
63520f1678 |
logtail: deliveryRe anchored at the head of the line (audit M12)
The pattern that writes a row's status matched anywhere in a line: a port-25 reject line ending helo=<ID: to=<victim@...>, status=sent> parsed as (ID, victim, sent). parseDelivery now takes the stamp off with SplitTimestamp and matches `^host program[pid]: <id>: to=<...>, ... status=`, as queueLineRe and the inbound patterns do. Tests: the probed HELO line, the same text in a quoted sender on a qmgr line, the text after an smtpd line's queue id - none parse; a syslog-stamped line as Postfix 3.7 writes it parses. The existing cases unchanged. CHANGELOG [Unreleased], audit plan line ticked, roadmap 11/61. Evidence: go test ./internal/logtail ok but for TestFollowTailsAndRotates (Windows); the server run of the stage follows before the push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |