2cfd239d16b19d738661c1f28ce711e598e9ed3c
141
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2cfd239d16 |
docs: panel-dkim-signing stage 3 - the documents without OpenDKIM
test / test (push) Waiting to run
architecture § OpenDKIM becomes § DKIM signing (keys, the selector table and when it is rewritten, the two sockets, what is signed and the limits, the old tree); the process table, start order, liveness, the mail-path diagram, § Sender check, Persistence and the restore Resync follow the code. security.md: the panel signs with the login's key, the keys are the panel's alone, group selfpost and CAP_KILL as they are now; OpenDKIM's accepted risks go; new ones: what sendmail(1) submits is signed as the server, Sender: is signed but not judged (the owner's decision), and go-milter keeps every header per session, so the signer's 1 MiB refusal does not bound the library's copy. guide: Health, the container log's dkim-signer lines, backups, the long-line limit, and upgrading to 2.8.0 - with the rollback to 2.7.x: its start reads the queue as its own postfix (uid 101) before it hands the queue over, so the hand-over by name is run in the 2.7.x image first (measured on a copy of the test server's queue: postsuper failed before, passed after). development, README, SECURITY, product, the assumptions register's header: what left the image. entrypoint.sh: a comment, the rollback note as the guide gives it. Plan stage 3 lines ticked, roadmap 12/14. Written by four agents, one document each, against a brief of what the code does; reviewed here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
faa3c11ae7 |
panel-dkim-signing stage 2: OpenDKIM removed
test / test (push) Waiting to run
The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.
Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.
Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.
Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.
KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.
e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
43ebbc9b20 |
plan: panel-dkim-signing - stage 1 done (14de9a0)
test / test (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a07ba5776c |
plan: panel-dkim-signing - agreed; Health shows the signer's two sockets
test / test (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a556a13f67 |
release: 2.7.0: base-trixie - Debian trixie, Postfix 3.10.13
Cut without asking, as the owner said on 2026-10-15. The image runs debian:trixie-slim with Postfix 3.10.13, libmilter 8.18.1, OpenSSL 3.5; every citation re-read against the trixie sources. smtpd_hide_client_session=yes on 465/587 (owner's decision). The selfpost group is 10998 and the panel 10999:10999, outside the system range; the entrypoint re-owns /data by name. The panel's database is 0600; the entrypoint removes symbolic links the panel could leave under /data. Commits |
||
|
|
3b440c2a53 |
plan: panel-dkim-signing, the draft with three open questions
test / test (push) Waiting to run
The signer in the panel's milter with go-msgauth/dkim v0.7.0 (sign.go read: streaming NewSigner, HeaderKeys over-signing, last-first picking), relaxed/simple, OpenDKIM's dkim_should_signhdrs[] plus From, the key by the login's domain and by the server's name for local mail, strict. From Postfix 3.10.13 milter8.c: multi-line headers come \n-separated, so the signer rebuilds them with CRLF. disable_mime_output_conversion=yes, unset until now. The oracle dkimpy (python3-dkim 1.1.8-2 in trixie). Three stages, 2.8.0. Open for the owner: where the signer runs, the key directory's name, Health's label. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a3050afb66 |
base-trixie: findings of the reviews
test / test (push) Canceled after 0s
Security review ( |
||
|
|
1c6e04b7d3 |
base-trixie: the image on Debian trixie, Postfix 3.10.13; Received: hidden on 465/587
test / test (push) Canceled after 0s
The runtime base is debian:trixie-slim, the build stage golang:1.26-trixie:
Postfix 3.10.13 with upstream's 2026 security fixes up to the August
batch, libmilter 8.18.1, OpenSSL 3.5. Every citation of another program in
code, scripts and documents was re-read against the trixie sources
(dev/upstream; bookworm's kept in dev/upstream-bookworm) and updated;
development.md's cited-package table holds the trixie versions.
What the re-reading changed in behaviour:
- logtail: reject lines with sasl_method/sasl_username/sasl_sender
(smtpd_check.c:1019-1026); cleanup's "removed (canceled)" forgets a
port-25 id (cleanup_api.c:363-364).
- internal/postfix/retry.go reads postconf's stdout only: since 3.9 a
deprecated-parameter warning on stderr made one value too many and the
panel fell back to the compiled-in retry policy.
- logrotate-loop.sh: logrotate 3.22 exits 1 when it ignores a config;
the loop refuses on that output whatever the status.
- postfix-config.sh: cyrus_sasl_config_path set explicitly (bookworm's
07_sasl_config.diff is gone); maillog_file_permissions=0640; mynetworks
explicit; Dockerfile: SYNC_CHROOT= in /etc/default/postfix,
opendkim-tools dropped, openssl named, systemd-standalone-sysusers so
cron does not bring the whole of systemd.
- The selfpost group is 10998, outside the system range 100-999: in
trixie a package took 998 and the build failed (owner's decision:
a conflict-free id, the distribution's own ids untouched). The
entrypoint hands /data back by name when the user or the group differs.
Guide, backup refusal text, help (design commit
|
||
|
|
b76f1ef1a0 |
release: 2.6.1: port 25 ends DATA only at CRLF.CRLF
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15.
The inbound relay's smtp/inet listener sets
smtpd_forbid_bare_newline=normalize, the SMTP-smuggling defence Postfix
3.7.11 leaves off (
|
||
|
|
aaa94120be |
port 25 ends DATA only at CRLF.CRLF: smtpd_forbid_bare_newline=normalize (port25-bare-newline)
Postfix 3.7.11 leaves its 2023 SMTP-smuggling defence off (default "no" before 3.9, src/smtpd/smtpd.c:807), and the inbound relay's smtp/inet listener did not turn it on: a "." line ended by a bare LF ended DATA and what followed was read as the next transaction. The listener now sets normalize (bare_lf_mask_table, smtpd.c:1656; the DATA loop drops such a "." line, 3649-3652), the setting Postfix recommends and 3.9's default. The submission ports keep the default (own-mta-builds, section 5). Tests: e2e inbound_bare_newline_is_one_message sends the smuggling sequence over raw SMTP (net/smtp's DotWriter would rewrite the bare LF) and finds one message at the upstream with the smuggled part inside; port25 config check asserts the parameter on smtp/inet. On the server: make e2e exit 0 (511 s), go vet + go test 27 packages ok. Docs: CHANGELOG [Unreleased] Security, security.md, architecture.md, guide.md; roadmap: port25-bare-newline, base-trixie and panel-dkim-signing agreed by the owner in this order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
2c8aec79b4 |
roadmap: own-mta-builds analysed - no own builds; three rows proposed instead
test / test (push) Canceled after 0s
docs/own-mta-builds-2026-10-15.md weighs patched builds of Postfix and OpenDKIM against what the panel works around. No Postfix limit needs a patch (the panel reads it through the interfaces it offers); every limit worked around is OpenDKIM's, a project with no release since 2018, three 2026 CVEs open in every Debian suite and no one answering (read in dev/upstream: dkim_process_set runs on a tenant's DKIM-Signature header in signing mode too). Bookworm is in LTS since 2026-07-12 and its Postfix 3.7.11 lacks the June, August and September 2026 upstream security batches, which carry no CVE ids. Proposed as candidates: port25-bare-newline (smtpd_forbid_bare_newline defaults to no on 3.7; our smtp/inet block does not set it), base-trixie (postfix 3.10.13; 331 citation lines to re-read), panel-dkim-signing (go-msgauth/dkim in the panel's milter; OpenDKIM, the syslog relay and the parser port removed, about 1,500 lines). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
6023feb405 |
audit 6: documents, comments, dead code and staticcheck; the test gaps closed
test / test (push) Canceled after 0s
The last stage of the 2026-10-12 audit; no cut. Doc drift: architecture.md (the sender check in § OpenDKIM, the limit: row's 48 hours, the inbound log and filter-list routes and who may open them, migrations to 0011 and every table, KitPage as production data, Reload configuration as a restore writes, the panel's roles counted), schema-migrations.md (tables and columns since v2), guide.md (a delivery's lines go at the next prune; INBOUND_JOURNAL_MILTER_SOCKET moved to architecture.md's internal list), development.md (the env guard, the deferral the e2e proves, its fixed sleeps), roadmap.md. Comments say what the code does now; TestBaselineIsTheOnlyMigration is TestChainStartsAtTheBaseline; the milter package doc names its three milters. Dead code gone: view.Facts, dmarc HostedSuggestion and ValidateHostedAddress, app UpdateMode (each with the test that alone kept it), the method checks the method-bound routes made dead; Settings.WithResult calls resultFlash. staticcheck 2026.1 in test.yml on both modules, clean: the e2e package builds without its tests now (a test and two constants moved out of non-test files), three findings fixed. The test moved, TestImageBuildPreservesLogrotateMode, had sat in a non-test file and never run; run, it failed on the entrypoint (docker run without a hostname) and now asks with --entrypoint stat. The test gaps no step named, each closed: follow() with mail.log gone past blindMin and made again; CheckRestore with a manifest that is not JSON; the origin check with a port; every /server/ pattern in web.go through server() and in the routes table (it found GET /server/health/fragment missing from the table - guarded, not tested); L9 as the owner decided it on 2026-10-12, the comment on senderSession.Headers and the test passing an h that holds the earlier messages' From:. Plan stage 6 ticked; roadmap audit row done, 81/81. Tested on the test server: go vet + go test of 27 packages, make e2e (all pass, 513 s) on this tree; staticcheck locally on both modules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
dc78b0ed76 |
release: 2.6.0: audit stage 5 - image and Postfix hardening
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15. The supervisord socket is root:supervisor, the panel the group's one member (M17). Postfix takes TLS 1.2 at least, the client too where TLS is required; VRFY off; the banner without the distribution; the start refuses a rate, size or milter timeout that is not a number, under its minimum or past an int (L31). OpenDKIM logs through the syslog relay to the container log and oversigns From: (L30). crashexit takes the container down after five unexpected exits of a mail-path program in ten minutes (L32). logrotate keeps its state under /data/log. extract-cert.sh refuses what is not PEM and renames whole files (M21). Plus the findings of both reviews ( |
||
|
|
95af363214 |
roadmap: own-mta-builds, an analysis of patched Postfix and OpenDKIM builds
test / test (push) Canceled after 0s
The owner asked on 2026-10-15 whether our own builds would lift limits we work around; recorded as a candidate for analysis with the starting points: the limits met so far, for, against, and the middle ways to compare. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a39eda5a0b |
audit 5: findings of the two reviews
- The e2e's numeric-settings test ran a container that passed every gate
and never ended: the good values are checked beside a bad one after
them; each run is named, bounded to a minute and removed.
- The gates refuse a value under the parameter's minimum (a zero window
kills anvil, a zero milter timeout smtpd) and one past an int, in
seconds for a time value (src/global/conv_time.c, src/anvil/anvil.c).
- OpenDKIM waits, five seconds at most, for the relay's socket.
- The relay marks its lines `syslog: `, writes control characters out,
reads into a queue at once and drops past 200 lines a second, saying
how many (security F1, F2; a stuck relay would hold OpenDKIM in
syslog(3)).
- smtp_tls_mandatory_protocols >=TLSv1.2 too.
- extract-cert.sh keeps the old files' group and sets its trap before
mktemp.
- crashexit spares cert-reload; architecture.md "four times running" and
what else exits the container; security.md the restart after five panel
exits and the /dev/log socket.
- Measured against the technical review: smtpd has the selfpost group
(Groups 104 998), so it did reach the supervisor socket before this
stage.
Accepted, reasons in the plan: the logrotate state file in the postfix
user's directory, envdoc_test's reading of `${VAR:-}` only, crashexit's
window pruning untested.
Tested on the test server: go vet + go test of 27 packages, make e2e (all
pass, 592 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
d6ed6bcb30 |
release: 2.5.4: audit stage 4 - panel behaviour
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15. Signing in costs the same for an unknown name (M8) and ends the session the browser presented (L16); the Account password check has its own limit (L15). The HTTP server bounds the request, the answer and idle connections; the full backup moves its own deadline (L14). Monitoring refreshes are scheduled from htmx:afterRequest with a back-off and a 30 s timeout, and a lost session sends htmx to sign-in (M11). The Outbound log clamps its page (L10) and filters by status and day; the multiplier refuses NaN (L11); a rate-limited refusal is red (L18); Recalculate asks before it drops an unsaved edit (L19); Reload configuration writes what a restore writes and removes the keys of deleted domains (L13, L17). The cards layout orders by number. A notification is owed to each recipient (migration 0011). A DNS outage is unknown, each resolver with its own time (M14, M15). DMARC reports too large are refused for their size. An inbound upstream has one TLS mode, changed for all its domains (L5). Plus the findings of both reviews ( |
||
|
|
006bed7022 |
audit 4: DNS outages unknown, DMARC limits said, typed Health, one TLS mode per upstream
test / test (push) Canceled after 0s
M14, M15: queryEach gives each resolver 2 s within the check's context and asks the one that answered last first; an address or PTR lookup not answered is Unknown, an answered mismatch beside it still an error; Checker.Server keeps the last answer's addresses, so SPF does not go Unknown with it. Notifications report faults only, now tested with SPF unknown alone and beside a wrong DKIM. DMARC reports: tests of malformed XML, gzip and zip bombs, a message over the limit, the service's Resync, hosted suggestion and validation. The finding: both limits cut silently and an oversized report failed as damaged; readAtMost refuses it for its size (MaxMessage 10 MiB, moved into IngestMessage). healthReadings replaces map[string]any for Health and Overview. L5: an inbound upstream has one TLS mode - a second domain to the same [host]:port with another mode is refused, naming the first; the TLS policy map holds each next hop once. Tested on the test server: go vet + go test of 25 packages, make e2e (all pass, 631 s) on this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
eee089ad4b |
audit 4: cards ordered by number, a notification owed to each recipient
test / test (push) Canceled after 0s
The cards layout (owner, 2026-10-13; the mockup first,
|
||
|
|
2b8e0c086d |
audit 4: a deleted domain says so, the Outbound log filters by status and day
test / test (push) Canceled after 0s
L17: past DeleteDomain both rebuilds run, their errors joined under domain.ErrDeletedIncomplete; the key is removed only when OpenDKIM's tables were rebuilt (they may still name it otherwise); the domain list says the domain is deleted and that Reload configuration finishes it, not "internal error". Tests in domain (the tables failing, the sender map failing) and handlers. The Outbound log's status and day filters (owner, 2026-10-13; the mockup first, |
||
|
|
ca876c1631 |
audit 4: log paging at its edges, NaN refused, a red rate limit, recalculation asks, reload writes all
test / test (push) Canceled after 0s
Step 4 of stage 4 (L10, L11, L13, L18, L19 and a row of
review-2026-08-followups):
- The Outbound log counts first and clamps the page to the last; a number
too large for an int (strconv's ErrRange with the largest int) is the
last page on both logs. Tested at the edges: first, last, past the last
however far, nonsense, an empty log asked for page 5.
- checkAutoMultiplier is "not within", so NaN and the infinities are out.
- rate-limited is red (components.go; the mockup first,
|
||
|
|
07be0cfb58 |
audit 4: sign-in timing and sessions, server timeouts, polling that recovers
test / test (push) Canceled after 0s
Steps 1-3 of stage 4, tested together (their docs share files).
Step 1 (M8, L15, L16): an unknown name pays a bcrypt comparison at the
panel's work factor too (noSuchUserHash, made once) -
TestLoginTakesAsLongForAnUnknownName holds the medians within a factor of
two; the Account form's current-password check has its own limiter, per
user (AllowPasswordChange); a sign-in destroys every session the request
presents before it issues its own.
Step 2 (L14): newHTTPServer - ReadTimeout 30 s, WriteTimeout 60 s,
IdleTimeout 2 min beside ReadHeaderTimeout; the full backup moves its write
deadline to two hours through http.ResponseController (e2e: the log shows
no failure to move it).
Step 3 (M11), read in htmx 2.0.4's source (dev/upstream/htmx-2.0.4, the npm
build byte-equal to the shipped htmx.min.js): the poll passes source: box,
and the next refresh is scheduled from htmx:afterRequest, which htmx fires
on load, error, abort and timeout, and after a swap on the nearest parent
left (the box found by detail.target's id); failures in a row double the
wait to 60 s. RequireAuth answers htmx with 401 + HX-Redirect: /login,
which htmx follows before it looks at the status. testdata/poll_test.js
runs panel.js in node against a stand-in DOM and clock (TestPanelJS; skipped
without node, which the test server lacks - run here, and it fails on the
old panel.js).
Also measured: the stage-3 e2e regression for H1 fails as it should on HEAD
with the old entrypoint ("check data/postfix/e2e_extra_map: permission
denied", the panel restarting).
Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 639 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
48832d223a |
release: 2.5.3: audit stage 3 - persistence and restore
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15: "Вопросы про резку не задавай, просто режь." A restore is ended by its Resync: the manifest stays until the first start has regenerated the maps (M1). Every archive ends with data/contents.json and the first start after a restore compares /data with it before it opens the database: half an archive, a damaged file, a journal left beside the database is refused, named. The entrypoint hands every map under /data/postfix to the panel (a restore unpacked as root left the DMARC maps unreadable - found by the review). Migration 0010: the send log's sweep, refusal-line check, level-2 count and pages each by its index, pinned by EXPLAIN tests on the statements the store runs (M2). The backup CLI refuses a missing database (M3) and a password under 12 (M5), writes -o whole, checks -decrypt's password before the output (L1), warns of what it leaves out and refuses an empty certs/ (L2). An encrypted file may ask scrypt for 128 MiB at most, r <= 64, p <= 2, and nothing may follow its last chunk (M4, L3). TRUSTED_PROXY_CIDR's bare ::ffff:a.b.c.d is one host (L4). Maps are durable across a power loss: the directory is synced (L7). Commits |
||
|
|
242515c305 |
audit 3: backup CLI, envelope budget, directory sync, docs; findings of the reviews
Steps 3-6 of stage 3 and what the two reviews found in steps 1-6, in one commit: the review fixes sit in the same hunks. Step 3, CLI backup (M3, M5, L1, L2; L4 of the stage's list): - backup.snapshotDB refuses a database that is not there and opens the source mode=ro (measured: mode=ro creates no file). - selfpost-backup holds an encrypting password to validate.MinSecretFilePasswordLen; -o of both directions writes a temporary file beside the name and renames it whole; -decrypt peeks the first chunk (password and type authenticated) before the output exists. - what a backup leaves out (a link under data/, a link under certs/ to a directory, a socket...) is told to OnWarn; certs/ with no file is backup.ErrNoCerts, refused by Check and Create, named by the panel. - parseTrustedProxies: a bare address takes /128 when written with a colon (::ffff:10.0.0.1/32 was ::/32). Step 4, secretfile (M4, L3): scrypt's V at most 128 MiB, r <= 64, p <= 2; bytes after the last chunk are ErrCorrupt. Step 5 (L7): atomicfile.SyncDir after the rename, used by Write, replaceSASLDB and the CLI; atomicfile_test.go. Step 6: security.md and guide on backup and restore. Reviews (technical, security): - H1: a restore of a server with DMARC on was refused on every start - the archive records no owner, tar as root left the DMARC maps root's, and the entrypoint only chowned the maps it named. It now hands every file beside queue/ in /data/postfix to panel:selfpost 0640; e2e writes an extra map before the backup and finds it panel:selfpost 640 after a root unpack. - the comparison reads regular files only (Lstat, size, LimitReader); a record unpacked again wins over the earlier check's mark; ConsumeRestore removes both; the mark is synced; a journal file beside a freshly unpacked database is refused. - AttachRefusalLine refuses an empty line as the old statement did; skipped entries named in words; -decrypt of an empty payload is no failure; guide wording on the shell's '>' and a killed CLI; the in-place restore's 'mv .' (older than this stage) fixed. Tested on the test server: go vet + go test of 25 packages, make e2e (all pass, 643 s) on this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
508ac96c79 |
audit 3: every per-message question of the send log has its index
test / test (push) Canceled after 0s
Migration 0010 (DDL only; 0009 went to stage 2b). EXPLAIN QUERY PLAN of
every send_log statement showed, beyond M2 as written (stage 2 had already
moved the by-domain counts onto (domain_id, created_at)):
- the tailer's sweep of open rows (every 5 min) and its count scanned the
table: partial idx_send_log_open (created_at) WHERE status IN ('queued',
'deferred'), the statuses literal in the statements, ORDER BY +id (else
SQLite walks the table in id order to skip a sort);
- AttachRefusalLine's NOT EXISTS (... refusal_line = ?1) scanned the table
for every refusal line: partial idx_send_log_refusal_line, the query
carrying the index's WHERE; its row found by idx_send_log_rejected
(status literal, +queue_id) instead of every refusal without a queue id;
- an application's level-2 count went through its domain's index:
+domain_id in appByLogin;
- a page of one domain's or one application's log sorted all of it:
idx_send_log_domain_id, idx_send_log_app_login (id order; several domains
still sort, top-N). idx_send_log_domain stays for the global filter.
The statements are constants and builders (countMessagesQuery,
peakPerHourQuery, listOpenQuery, countOpenQuery, sendLogPageQuery,
attachRefusalLineQuery, limitRefusalsQuery) and
TestSendLogQueriesUseTheirIndexes explains exactly those: the index each
must use, no SCAN of send_log, no sort where none is wanted; the literal
statuses checked against the constants. It replaces the hand-written plan
check of TestLimitRefusalsSince.
Tested on the test server: go vet + go test of 24 packages, make e2e (all
pass) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
8e3241ef98 |
audit 3: a restore is ended by its Resync, and half an archive is refused
test / test (push) Canceled after 0s
The restore manifest was consumed as soon as its version was checked, before store.Open and the Resync; a start that failed in between left a data directory the next start took for an ordinary one (M1). openData now runs CheckRestore, store.Open, the Resync, and only then ConsumeRestore. A truncated archive had nothing to refuse it: the manifest is the first entry, so a download cut short or a tar stopped by a damaged block was a restore on half a database. Every archive now ends with data/contents.json, the size and SHA-256 of each data/ file (deploy files are the operator's to edit), and CheckRestore compares /data with it before the database is opened, then renames it contents.checked.json so a start after a failed one does not compare again unless the manifest changed. Tests: a start whose Resync fails, then a start that resyncs and consumes; an archive cut in the database, before the deploy files and in its last entry (refused, database untouched, no -wal); a damaged key named; the record's names confined to data/; the domain import of a cut plain export, a cut and an altered .spde (refused, nothing created). e2e on the test server: full_backup_restore now also sees contents.json in the archive and gone after the first start, through the real entrypoint and daemons (all pass, 762 s); go vet + go test of 24 packages pass there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
68b3a7b23f |
release: 2.5.2: audit stage 2c - what SelfPost relies on, read in the sources
test / test (push) Canceled after 0s
The owner's word, 2026-10-15: "Да, режем."
Every place SelfPost relies on another program was read in that program's
source, of the version the image runs, and is cited in the code (621
entries, docs/assumptions-2026-10-14.md); what the sources contradicted is
fixed: rspamd signing a sender's own verdict, the sender map's first key,
queue expiry and Postfix's time values, refusal and recipient lines as
Postfix writes them, the rotated log read until postlogd lets it go,
inbound mail over 10 MB, local delivery to an address literal, supervision
(Postfix stopped, give-ups FATAL, the reload's exit read), sasldb2 through
a copy, Health dialling sockets and showing the certificate Postfix
serves, Referrer-Policy same-origin, migrations under BEGIN IMMEDIATE,
certbot's links followed, about forty sentences of the documents, and ten
measurements on the stand. The sender check reads From: with OpenDKIM's
own parser, ported and checked against the C and against OpenDKIM's
signing; L8 is closed; an address OpenDKIM would cut short is refused.
Plus the findings of both reviews of the stage (
|
||
|
|
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
|
||
|
|
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>
|
||
|
|
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
|
||
|
|
ee37d16dc7 |
audit: stage 2c - seven lines built, ticked
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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> |
||
|
|
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
|