Docs only. docs/audit-2026-10-12.md: findings of a full-tree audit (four high, twenty-one medium, the rest), doc drift, test gaps, what was checked and found sound, and a six-stage plan; roadmap index row; CHANGELOG cut and pin bump. Old tags and images stay as they are (owner): fixes go forward. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,689 @@
|
||||
# Code audit — 2026-10-12
|
||||
|
||||
**What this is.** A full-tree audit of the code and infrastructure at `8f226bd`
|
||||
(release `2.4.0`): leftovers, unfinished work, defects, performance, doc drift
|
||||
and test gaps — explicit ones (there are no `TODO`/`FIXME` markers anywhere in
|
||||
the product code) and the implicit ones that only reading finds. The panel's
|
||||
visual design (mockups, `panel.css`, layout) is a separate audit and is not
|
||||
touched here. Nothing was changed in the tree; this file is the only output.
|
||||
|
||||
**Method.** Six parallel readers, one per zone (mail path; background roles;
|
||||
store/backup/composition root; HTTP surface; view and services; build/deploy/
|
||||
CI/e2e), each reading every file of its zone and its tests in full, each
|
||||
told to find where the code does not do what `architecture.md` says, and to
|
||||
prove it (a throwaway test, `EXPLAIN QUERY PLAN`, a probe) rather than guess.
|
||||
Machine checks: `go build`, `go vet`, `gofmt -l`, `go mod tidy`/`verify`,
|
||||
`staticcheck`, `govulncheck`, `go test ./...`. Five claims the readers could
|
||||
not settle from source were settled on the test server
|
||||
(`selfpost.mixfed.ru`, running a locally built `2.3.0-post.c817f08`) and
|
||||
against GHCR. Two hypotheses were refuted there and are **not** findings:
|
||||
`saslpasswd2 -d` of a missing login exits 0 (deletion is idempotent, as the
|
||||
code says), and a duplicate key in a `texthash:` map is a warning with the
|
||||
first entry winning, not a fatal error.
|
||||
|
||||
**Machine results.** Build, vet, gofmt, tidy, verify: clean. `staticcheck`:
|
||||
four items (an unused `domainIDSet` in `handlers/authz.go:63`, two unused
|
||||
assignments in `handlers_outbound_pages_test.go:174,194`, one `S1003` in
|
||||
`view/pages_test.go:170`). `govulncheck`: nine standard-library findings
|
||||
(`net/http` HTTP/2, `net/textproto`, `html/template`), all fixed in the
|
||||
current Go patch release; `golang.org/x/crypto` `0.54.0` has three published
|
||||
fixes in `0.56.0` that this code does not call. `modernc.org/sqlite` is seven
|
||||
minor versions behind (`1.53.0` → `1.60.1`). `go test ./...`: green on Linux
|
||||
semantics; on Windows two tests fail for OS reasons (`TestWriteLoadPrivateKeyRoundtrip`
|
||||
expects a POSIX mode; `TestFollowTailsAndRotates` renames an open file) — the
|
||||
tests, not the product, are platform-bound.
|
||||
|
||||
Severity: **critical** — mail or data lost or the security boundary crossed
|
||||
today; **high** — a documented guarantee is false in a way an operator will
|
||||
hit; **medium** — wrong under a realistic condition, or a stated guard that is
|
||||
not there; **low** — wrong under an unlikely condition, or cost without
|
||||
failure; **nit** — hygiene. Confidence is stated where a finding was not
|
||||
proven.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### High
|
||||
|
||||
**H1. Every published release image is stamped `VERSION=e2e`, so the
|
||||
backup/restore version check never checks anything.**
|
||||
[release.yml:75-84](../.github/workflows/release.yml) builds the image with
|
||||
`VERSION=X.Y.Z` and tags it `selfpost:e2e`; the e2e step then runs
|
||||
`docker compose build` through
|
||||
[test/e2e/compose.override.yml:23-32](../test/e2e/compose.override.yml),
|
||||
which rebuilds **the same tag** with `args: VERSION: e2e`; `docker tag
|
||||
selfpost:e2e …X.Y.Z-amd64` pushes the rebuilt one. Confirmed: `docker run
|
||||
ghcr.io/mixeme/selfpost:1.3.0 panel -version` prints `e2e`. Consequences:
|
||||
the footer and `-version` lie; `backup.CheckRestore` compares a manifest's
|
||||
version to `e2e`, so a backup of any release restores into any other and the
|
||||
promise in development.md:270 and guide.md:191 ("the version is the single
|
||||
source … so they cannot drift apart") is false. Every image this workflow has
|
||||
ever published is affected; they stay as they are (owner, 2026-10-12: old tags
|
||||
and images are history and are not touched) — the fix is for the next
|
||||
publication.
|
||||
|
||||
**H2. Renewed TLS certificates are never picked up while the container
|
||||
runs.** [postfix-config.sh:75-89](../build/postfix-config.sh) copies the
|
||||
mounted cert and key into `/etc/postfix/tls-internal/` at start and points
|
||||
Postfix at the copy; [postfix-cert-reload.sh](../build/postfix-cert-reload.sh)
|
||||
runs `postfix reload` daily but nothing re-copies. The copy is the only
|
||||
`tls-internal` reference in the tree; on the test server the copy carries the
|
||||
container's start time. Scenario: Let's Encrypt renews at day 60, nobody
|
||||
restarts, from day 90 ports 465/587 serve an expired certificate.
|
||||
architecture.md:33,154-155 and supervisord.conf:92-95 say the opposite.
|
||||
|
||||
**H3. The queue-reconcile sweep marks delivered mail `bounced` whenever the
|
||||
tailer is blind.** Four mechanisms, one consequence (a row the log would have
|
||||
resolved as `sent` is closed as a failure, which the Outbound log shows and
|
||||
the notifier believes):
|
||||
- *Tailer stuck, sweep running.* [logtail.go:559-581](../internal/logtail/logtail.go):
|
||||
`caughtUp` closes after the first drain and never re-opens; `reconcileLoop`
|
||||
(:174-191) then runs every 5 min regardless. If `openAt` fails after a
|
||||
rotation (:573-576, e.g. postlogd recreated the file `0600` — entrypoint.sh:132
|
||||
says this happens) the loop logs and retries forever on the old inode while
|
||||
every new delivery line goes unread; two minutes later `reconcile()` finds the
|
||||
rows absent from `postqueue -p` (they were delivered) and bounces them.
|
||||
- *Truncated queue listing read as complete.*
|
||||
[postfix/queue.go:64-68,81-87](../internal/postfix/queue.go): `Queue()` caps
|
||||
`postqueue -p` at 4 MiB and appends a text note; `QueueIDs()` parses that
|
||||
text and returns the first 4 MiB of ids with no error. A deferred backlog of
|
||||
~30k messages — the outage the sweep exists for — makes every row past the
|
||||
cut `bounced` while Postfix still holds the mail. `queuejson.go:48-52` handles
|
||||
the same cap correctly for the notifier; the two drifted.
|
||||
- *Rotation drain window.* :567-578 drains the renamed inode once more; postlogd
|
||||
keeps writing to it until master's reload reaches it (seconds), so lines
|
||||
written in that window are never read. architecture.md:372-373 claims the
|
||||
drain covers it.
|
||||
- *No status guard on close.* `UpdateStatus` ([store/sendlog.go:86-98](../internal/store/sendlog.go))
|
||||
is `WHERE queue_id = ? AND to_addr = ?`; the reconcile path can overwrite a
|
||||
`sent` written an instant earlier, and a reused short queue id to the same
|
||||
recipient rewrites a months-old row.
|
||||
|
||||
**H4. Application logins are case-sensitive in the panel and case-insensitive
|
||||
in Postfix; a tenant can take another tenant's envelope sender.**
|
||||
[app/validate.go:35-48](../internal/app/validate.go) accepts `A-Z` and nothing
|
||||
lowercases a login; `applications.login` is `UNIQUE` under BINARY collation
|
||||
([0001_init.sql:91](../internal/store/migrations/0001_init.sql)), so `Ops` and
|
||||
`ops` are two applications. The sender map is written as stored
|
||||
([postfix/postfix.go:95-115](../internal/postfix/postfix.go)); Postfix's
|
||||
`reject_sender_login_mismatch` compares logins without regard to case (the
|
||||
code's own comment, [milter/sender.go:35-36](../internal/milter/sender.go),
|
||||
and architecture.md:239-240 say so). Scenario: domain A has `Ops` bound to
|
||||
`@a.com`; a *domain*-role administrator of B creates `ops` in B, authenticates
|
||||
as `ops`, sends `MAIL FROM:<x@a.com>` with `From: y@b.com`. Postfix accepts
|
||||
the envelope (`@a.com Ops` matches), the sender check judges only the header,
|
||||
which is B's — the message leaves with A as its SPF and bounce identity.
|
||||
Confidence: the panel side is confirmed; Postfix's compare is taken from the
|
||||
code's own statement. Fix shape: lower-case at validation, `COLLATE NOCASE`
|
||||
on the column (a migration — check the live registry for a pair first).
|
||||
|
||||
### Medium
|
||||
|
||||
Persistence, restore, backup
|
||||
|
||||
**M1. The restore manifest is consumed before anything that can fail.**
|
||||
[backup.go:436-446](../internal/backup/backup.go) deletes `manifest.json`
|
||||
inside `CheckRestore`; [main.go:282-300](../cmd/panel/main.go) runs
|
||||
`store.Open` and `resyncAfterRestore` afterwards. If either fails, supervisord
|
||||
restarts the panel with `restored == false` and the one-time drift-healing
|
||||
Resync never runs. Docs say "on the first *successful* boot"; the code says
|
||||
"on the first attempt". `restore_test.go` passes `testNoReload=true` and never
|
||||
fails Open/Resync, so nothing covers it.
|
||||
|
||||
**M2. Three queries on the single connection run without the index they
|
||||
need.** Measured with `EXPLAIN QUERY PLAN` against the real migrations:
|
||||
- `CountMessages` by domain ([ratelimits.go:186-190](../internal/store/ratelimits.go))
|
||||
and `sendStats` ([stats.go:77-94](../internal/store/stats.go)) use
|
||||
`idx_send_log_domain (domain)` and apply `created_at >=` by scanning every
|
||||
row of the domain over the retention window — on every `MAIL FROM` of an
|
||||
application under a domain-level limit, with the milter, tailer and HTTP
|
||||
queued behind it. The baseline's comment promises a composite index for
|
||||
"the journal milter … on every MAIL FROM"; only the `app_login` half exists.
|
||||
- `ListQueuedOlderThan` ([sendlog.go:117-122](../internal/store/sendlog.go))
|
||||
is a full `SCAN send_log` every 5 min (no index on `status`; the cutoff
|
||||
matches nearly every row). Mirror of what `0005` fixed for `rejected`: a
|
||||
partial index `(created_at) WHERE status = 'queued'` makes it a handful of
|
||||
rows.
|
||||
- Scoped `QuerySendLog` ([sendlog.go:214-219](../internal/store/sendlog.go))
|
||||
sorts a domain's whole scope with a temp b-tree for every page a domain user
|
||||
opens.
|
||||
|
||||
**M3. `selfpost-backup` with a wrong `SELFPOST_DB_PATH` creates an empty
|
||||
database and ships a "valid" backup of nothing.** [backup.go:394-405](../internal/backup/backup.go)
|
||||
opens the source without `mode=ro` or an existence check; `VACUUM INTO` on a
|
||||
non-existent path succeeds (measured), creates the file on disk and snapshots a
|
||||
4 KiB empty database; `Create` and `Check` pass. A restore of it opens the
|
||||
`/setup` link on a host that still carries live DKIM keys and `sasldb2`.
|
||||
|
||||
**M4. `secretfile`'s KDF cap does not do what its comment says.**
|
||||
[secretfile.go:108-113,184](../internal/secretfile/secretfile.go):
|
||||
`maxLogN=20, maxR=32, maxP=16` lets a 44-byte header demand
|
||||
`128·2²⁰·32` = 4 GiB and sixteen times the CPU, under a comment that says the
|
||||
cap exists "so a hostile header cannot make the reader allocate its way to an
|
||||
out-of-memory kill". The domain-import form calls `NewReader` on the upload.
|
||||
Operator foot-gun rather than attack surface (global role only), but the
|
||||
stated guarantee is false; the real writer never exceeds N=2¹⁵, r=8, p=1.
|
||||
|
||||
**M5. The CLI enforces no password floor for an encrypted backup.**
|
||||
[cmd/selfpost-backup/main.go:170-187](../cmd/selfpost-backup/main.go) takes
|
||||
any non-empty string; the panel enforces `validate.MinSecretFilePasswordLen`
|
||||
(12); security.md:291 says the minimum applies to these files. (The roadmap's
|
||||
"empty password accepted" item: an empty password is *not* accepted as an
|
||||
encryption password — empty means plain output by design; the gap is the
|
||||
floor.)
|
||||
|
||||
**M6. The compose pin, README and guide point at an image that does not
|
||||
exist.** `deploy/docker-compose.yml:24`, `README.md:115` and guide.md:187 pin
|
||||
`ghcr.io/mixeme/selfpost:2.4.0` (`2.4.1` since this cut); GHCR holds `1.0.0`
|
||||
and `1.3.0` only (checked
|
||||
through the Packages API and by pulling); no 2.x was ever published, and tag
|
||||
`v2.0.0` exists in git without a release. The quick start cannot work for
|
||||
anyone but the owner, who runs locally built `-post.<sha>` images. Decided
|
||||
(owner, 2026-10-12): the old tags and images are history and stay untouched;
|
||||
the pin points forward — it is made true by the next publication, after H1 is
|
||||
fixed, and until then the documents should not claim an image that is not
|
||||
there.
|
||||
|
||||
HTTP surface
|
||||
|
||||
**M7. The last-global-user guard is check-then-act without a transaction.**
|
||||
[store/users.go:315-341](../internal/store/users.go) (`DeleteUser`:
|
||||
`CountGlobalUsers` then `DELETE`), [handlers_users.go:277-287,317-322](../internal/web/handlers/handlers_users.go)
|
||||
(demotion), [users.go:80-97](../internal/store/users.go) (`CreateGlobalUser`:
|
||||
`UserExists` then `INSERT`). `MaxOpenConns(1)` serialises statements, not
|
||||
pairs. Two globals deleting or demoting each other at once leave no global;
|
||||
`/server/*` answers 404 to everyone and recovery is database surgery.
|
||||
security.md:155 promises the guard.
|
||||
|
||||
**M8. Login discloses whether a username exists by timing.**
|
||||
[auth/handlers.go:109-121](../internal/web/auth/handlers.go): unknown user
|
||||
returns at :114 without bcrypt; a known one costs ~250 ms (cost 12). The test
|
||||
compares bodies only. Rate limiting slows enumeration; a dummy compare stops
|
||||
it.
|
||||
|
||||
**M9. `submitUserUpdate` is five writes with no transaction.**
|
||||
[handlers_users.go:304-336](../internal/web/handlers/handlers_users.go):
|
||||
`UpdateUser` → `endSessionsAfterPasswordSet` → `SetUserRole` →
|
||||
`ClearUserReach` (error only logged) → `SetUserReach`. A failure after the
|
||||
first leaves a user renamed and signed out everywhere with the old role, or a
|
||||
`domain` user with empty reach — the state the "Assign at least one domain"
|
||||
check exists to prevent.
|
||||
|
||||
**M10. Domain import trusts the export file for everything but the domain
|
||||
name, selector and password.** [domain/transfer.go:145-190](../internal/domain/transfer.go):
|
||||
`DMARCRua` is not run through `validate.Email` (the form path is), client IPs
|
||||
not through `parseIPList`, rate-limit `Mode`/`WindowSeconds`/`MaxMessages`
|
||||
not through the form's parsers. `WindowSeconds: 0` yields a limit that counts
|
||||
nothing ([milter/ratelimit.go:61](../internal/milter/ratelimit.go)); garbage
|
||||
IPs make the imported application refuse every client with no word at import.
|
||||
[app/validate.go:50-55](../internal/app/validate.go) calls the file "untrusted
|
||||
input" — for the password only.
|
||||
|
||||
**M11. Polling stops for good after one failed refresh.**
|
||||
[panel.js:202-257](../internal/web/view/static/panel.js): the scheduled poll
|
||||
is `htmx.ajax(...)` without `source`, so htmx attributes it to `document.body`;
|
||||
`htmx:responseError` therefore reaches an element with no `data-poll` and
|
||||
nothing reschedules; `htmx:sendError` and `htmx:timeout` are not handled at
|
||||
all. A panel restart while Overview or Health is open freezes the page until
|
||||
the tab is hidden and shown. Related: an expired session answers an
|
||||
`HX-Request` with `303 /login` ([auth/middleware.go:14](../internal/web/auth/middleware.go));
|
||||
the XHR follows it and the sign-in document is swapped into the box — no
|
||||
`HX-Redirect` on either side.
|
||||
|
||||
Log tailer and checks
|
||||
|
||||
**M12. `deliveryRe` is unanchored; a stranger's HELO parses as a delivery
|
||||
result.** [logtail.go:85](../internal/logtail/logtail.go): a port-25 reject
|
||||
line ending `helo=<41E862C00D9E: to=<victim@…>, status=sent>` yields
|
||||
`(id, recipient, sent)` (probed), and `UpdateStatus` rewrites the matching row.
|
||||
The attacker must guess an id+recipient pair, so impact is bounded — but
|
||||
`lines.go:21-24` and `inbound.go:48-51` anchor at the head for exactly this
|
||||
reason, and this pattern, the one that writes status, does not.
|
||||
|
||||
**M13. The sender-check deferred counter misses the case it is documented to
|
||||
see.** [sendercheck.go:50](../internal/logtail/sendercheck.go) matches only
|
||||
`postfix/…smtpd[pid]`; header/EOH events run in cleanup(8), so the warning for
|
||||
a panel that accepts the socket but hangs at end-of-headers is logged by
|
||||
`postfix/cleanup`. The fixture at `sendercheck_test.go:64-68` puts those lines
|
||||
under `smtpd` and says it was "composed from Postfix's source rather than
|
||||
seen". architecture.md:286-287 claims the figure sees "a panel that is running
|
||||
and not answering". Confidence: plausible; one `kill -STOP` on the stand
|
||||
settles it.
|
||||
|
||||
**M14. Resolver fall-back is hollow when the first resolver drops packets.**
|
||||
[dnscheck/resolver.go:72-90](../internal/dnscheck/resolver.go) tries resolvers
|
||||
in turn under one 5 s context; a blackhole eats the budget and the second is
|
||||
asked with an expired context. The `DefaultResolvers` comment promises
|
||||
otherwise. **M15.** [dnscheck/server.go:26-33,42-46](../internal/dnscheck/server.go)
|
||||
turns a resolver timeout into "does not resolve" / "no PTR record" (every
|
||||
other check routes transient errors to Unknown), and empties `ServerIPs` so
|
||||
the SPF check goes Unknown in the same minute. architecture.md:339-340.
|
||||
|
||||
Infrastructure
|
||||
|
||||
**M16. `envdoc_test.go` compares three hand-written lists to each other and
|
||||
reads neither `main.go`, the scripts nor `guide.md`.**
|
||||
[envdoc_test.go:70-136](../cmd/panel/envdoc_test.go). Already drifted:
|
||||
`ANTISPAM_DIR`, `INBOUND_JOURNAL_MILTER_SOCKET`, `INBOUND_SPAM_LOG_RETENTION_DAYS`,
|
||||
`RATE_LIMIT_MESSAGES_PER_IP`, `RATE_LIMIT_WINDOW_SECONDS` are read by
|
||||
`loadConfig` and absent from `loadConfigKeys`; `postfix-config.sh` and
|
||||
`postfix-wrapper.sh` read `INBOUND_JOURNAL_MILTER_SOCKET`, `SELFPOST_DATA_DIR`,
|
||||
`SELFPOST_DB_PATH`, `INBOUND_RELAY_ENABLE`, absent from `buildScriptKeys`. The
|
||||
tests pass; development.md:353-357 and :503-504 describe a guard that does
|
||||
not exist.
|
||||
|
||||
**M17. The supervisord control socket is `0770 root:selfpost` and the
|
||||
`postfix` and `opendkim` users are in `selfpost`.**
|
||||
[supervisord.conf:26-29](../build/supervisord.conf) ("only root and the two
|
||||
SelfPost services"), [Dockerfile:68-71](../build/Dockerfile) (three users);
|
||||
confirmed on the server. A compromised smtpd can `supervisorctl stop panel`
|
||||
(all submission deferred), `stop opendkim` or `shutdown`. No escalation; a
|
||||
whole-stack denial from the least trusted process.
|
||||
|
||||
**M18. DMARC maps are never ownership-normalised.** entrypoint.sh:108-125
|
||||
normalises five maps; `dmarc_recipients`/`dmarc_transport`/`dmarc_relay_domains`
|
||||
are created by postfix-config.sh:315-317 as root only when missing. A tar
|
||||
restore as root (guide, methods 3/4) yields `root:root 0640`, which smtpd
|
||||
cannot open → `451 table lookup problem` on every report until the panel next
|
||||
rewrites them. Confidence: plausible, needs a run.
|
||||
|
||||
**M19. logrotate state lives in the container layer.** [logrotate-loop.sh:42](../build/logrotate-loop.sh)
|
||||
uses the default `/var/lib/logrotate/status`, not under `/data`. On the server
|
||||
the state file is dated the container's start; with no entry logrotate records
|
||||
today and does not rotate, so a host that recreates the container daily
|
||||
(`.env` change, image bump) never rotates `mail.log`. The e2e uses `-f` and
|
||||
cannot see it.
|
||||
|
||||
**M20. `test.yml` never compiles `test/e2e`.** A type error there surfaces in
|
||||
`release.yml` after the GitHub Release is published and the tag checked out.
|
||||
|
||||
**M21. `deploy/traefik/extract-cert.sh` writes empty PEM files and exits 0 on
|
||||
a `jq` miss** (:17-25, `sh` without pipefail; `jq` exits 0 on no match); the
|
||||
files are `0644` until :27. Postfix TLS breaks at the next restart.
|
||||
|
||||
### Low
|
||||
|
||||
- **L1** `-decrypt -o FILE` truncates the output before the password is
|
||||
verified ([main.go:132-148](../cmd/selfpost-backup/main.go)); a wrong
|
||||
password leaves an empty file where a good one may have been.
|
||||
- **L2** Backups skip symlinks silently and `Check` does not notice
|
||||
([backup.go:323-327](../internal/backup/backup.go)); a `certs/` of symlinks
|
||||
(the Let's Encrypt layout) yields an archive with no TLS material while the
|
||||
guide promises refusal.
|
||||
- **L3** Trailing bytes after the last `secretfile` chunk are ignored
|
||||
(:400-416): two envelopes concatenated open as the first.
|
||||
- **L4** `parseTrustedProxies` turns a bare `::ffff:10.0.0.1` into `::/32`
|
||||
silently ([main.go:247-253](../cmd/panel/main.go)).
|
||||
- **L5** `tls_policy` is keyed by next-hop; two inbound domains to one
|
||||
`[host]:port` with different TLS modes write duplicate keys and the first
|
||||
(by domain order) wins with a warning — confirmed on the server. The per-
|
||||
domain TLS mode in the form is not what Postfix does
|
||||
([postfix/inbound.go:69-72](../internal/postfix/inbound.go)).
|
||||
- **L6** Multi-file map rebuilds are not all-or-nothing
|
||||
([inbound.go:42-55](../internal/postfix/inbound.go), `dmarc.go:31-41`); a
|
||||
write failure mid-sequence leaves `relay_domains` ahead of `transport`.
|
||||
- **L7** `atomicfile.Write` never fsyncs the directory and the package has no
|
||||
tests ([atomicfile.go:40-44](../internal/atomicfile/atomicfile.go)); every map
|
||||
writer goes through it.
|
||||
- **L8** The refusal reply names the other sender's domain
|
||||
([sender.go:334](../internal/milter/sender.go)) — an oracle for the server's
|
||||
signing names, available to any authenticated application.
|
||||
- **L9** go-milter accumulates every message's headers for a connection's
|
||||
life (library `session.go:173-187`, cleared on ABORT only); the sender check
|
||||
is safe because it ignores `h`, and the test passes `nil` so would not catch
|
||||
a "simplification" to `h.Values("From")`.
|
||||
- **L10** `parsePage` is not clamped to `lastPage` for the Outbound log
|
||||
([handlers_monitor.go:357-371](../internal/web/handlers/handlers_monitor.go));
|
||||
the inbound log clamps. Known roadmap row, confirmed.
|
||||
- **L11** `parseAutoMultiplier` accepts `NaN` ([handlers_ratelimit.go:44-49](../internal/web/handlers/handlers_ratelimit.go));
|
||||
SQLite stores NULL, read-back is 0, recalc silently uses 2.5 (probed).
|
||||
- **L12** Outbound rows belong to a domain by **name** ([handlers_monitor.go:97,345](../internal/web/handlers/handlers_monitor.go)):
|
||||
a domain deleted and re-added shows its earlier rows to the new assignee.
|
||||
security.md:419 accepts this for the inbound log only.
|
||||
- **L13** `HandleReload` answers failures as plain-text `http.Error`
|
||||
(handlers_domains.go:173-184), and the flash "Configuration regenerated from
|
||||
the database" oversells: DMARC maps and filter-list files are skipped
|
||||
(architecture.md:856-859 says so for DMARC; `Lists.Sync` is documented
|
||||
nowhere).
|
||||
- **L14** `http.Server` has only `ReadHeaderTimeout` ([httpserver.go:145-149](../cmd/panel/httpserver.go)).
|
||||
- **L15** The Account password change shares the per-IP login bucket
|
||||
([handlers_account.go:142](../internal/web/handlers/handlers_account.go)):
|
||||
ten wrong current passwords lock sign-in for that IP 15 min.
|
||||
- **L16** Login issues a fresh token but does not destroy the session a
|
||||
presented cookie already holds ([auth/handlers.go:123-130](../internal/web/auth/handlers.go)).
|
||||
- **L17** `domain.Service.Delete` returns a bare error after the row is gone
|
||||
when `resync()` fails ([service.go:202-210](../internal/domain/service.go));
|
||||
`apps.Resync()` is not reached, so the sender map keeps the purged logins.
|
||||
- **L18** A `rate-limited` refusal is a grey tag in the Outbound log
|
||||
([outbound_log.go:213](../internal/web/view/outbound_log.go), no such status
|
||||
in `components.go:132-146`); its two siblings are red, and the test pins the
|
||||
grey.
|
||||
- **L19** *Recalculate rate limit* on the application form submits the whole
|
||||
form and discards every unsaved edit ([out_app.html:67](../internal/web/view/templates/out_app.html),
|
||||
handlers_ratelimit.go:178-189).
|
||||
- **L20** DMARC parse rewrites `<pct>0</pct>` to 100 and stores a missing
|
||||
`date_range` as 1970 ([parse.go:125-136](../internal/dmarc/parse.go)).
|
||||
- **L21** Several `mailStack` instances per process with no shared lock on
|
||||
table rebuilds (`run`, `newPanel`, `runNotifier`); the notifier's
|
||||
`EnsureServerSigned` at start can write a stale table over a domain added
|
||||
in the same second. **L22** `IncrDMARCParseFailures` is read-modify-write
|
||||
across concurrent `dmarc-ingest` processes ([dmarc.go:163-173](../internal/store/dmarc.go)).
|
||||
- **L23** The restore-boot Resync reloads Postfix before Postfix exists
|
||||
(panel priority 200, postfix 300) — an error line per restore boot; the
|
||||
DMARC bootstrap works around the same thing with a no-op hook and does not
|
||||
say why ([main.go:301-307](../cmd/panel/main.go)).
|
||||
- **L24** `health/processes.go:43`, `supervisor.go:17,30`: `exec.Command`
|
||||
without context; `/healthz` and the 5 s status poll hang on a wedged
|
||||
supervisord. **L25** `health/cert.go:70-83` assumes the first PEM block is
|
||||
the leaf and grades a not-yet-valid certificate OK; `machine.go:193-204`
|
||||
advances `prevAt` when only one of cpu/net succeeded.
|
||||
- **L26** Only successful sends count toward the notifier's 60/h ceiling
|
||||
([notify.go:196-207](../internal/notify/notify.go)); with sendmail broken,
|
||||
`CheckLimits` spawns a 20 s process per recipient per minute forever.
|
||||
- **L27** `v=dmarc1` matched case-insensitively ([records.go:74,121](../internal/dnscheck/records.go));
|
||||
RFC 7489 §6.4 says the version must match exactly, so a lower-case record is
|
||||
graded OK and ignored by receivers. **L28** The domain cache is keyed by
|
||||
name only ([dnscheck.go:155-173](../internal/dnscheck/dnscheck.go)) though
|
||||
the verdict depends on `ExpectedDKIM`/`ServerIPs`.
|
||||
- **L29** `lineTime` normalises a Feb 29 line read the following January to
|
||||
Mar 1 ([inbound.go:254-261](../internal/logtail/inbound.go)); `isoStampRe`
|
||||
accepts a zone-less stamp that `time.Parse` then rejects (dated `now`).
|
||||
- **L30** OpenDKIM: `Syslog no` with no `LogWhy`/`Logfile` — it logs nothing
|
||||
anywhere; no `Canonicalization relaxed/simple`; no `OversignHeaders From`
|
||||
(the header-append replay is the class 2.4.0 is about).
|
||||
- **L31** Postfix: `smtpd_tls_protocols` at the 3.7 default `>=TLSv1`,
|
||||
`disable_vrfy_command = no` (VRFY on 25 enumerates `relay_recipient_maps`),
|
||||
the stock `(Debian/GNU)` banner — all confirmed on the server.
|
||||
`RATE_LIMIT_*`, `*_SIZE_LIMIT`, `MILTER_*_TIMEOUT` reach `postconf -e`
|
||||
unvalidated; a non-integer passes `postfix check` and kills every smtpd at
|
||||
connect.
|
||||
- **L32** `crashexit` fires on FATAL only, which arises only from
|
||||
`startretries` exhausted within `startsecs` (1 s): a program that crashes
|
||||
after one second cycles forever and never exits the container.
|
||||
architecture.md:40-41 reads wider than that.
|
||||
- **L33** `.dockerignore` excludes `docs`, `dev`, `bin` but not `test/`; a
|
||||
developer build after an e2e run ships `test/e2e/.stage/data` (DKIM keys,
|
||||
`sasldb2`) into the build context. Base images pinned by tag, not digest.
|
||||
- **L34** e2e: `stage.go:77` pulls `alpine:3.20` from the internet;
|
||||
`hostname_gate_test.go:97-101` runs with the host's real network;
|
||||
`sender_check_test.go:141` has a fixed `time.Sleep(2s)` against
|
||||
development.md:409 "no fixed sleep".
|
||||
|
||||
### Nits
|
||||
|
||||
Dead code: `handlers/authz.go:63 domainIDSet`; `view.Facts`
|
||||
(`components.go:414`); `dmarc.Service.HostedSuggestion`, `ValidateHostedAddress`
|
||||
(`dmarc/service.go:98-109`); `app.Service.UpdateMode` (`app/service.go:132-145`,
|
||||
superseded by `SaveSettings`); `Settings.WithResult` (`view/account.go:137-145`)
|
||||
re-implements `resultFlash`; `KitPage()`'s hard-coded `User: "admin"`
|
||||
(overwritten by the handler); dead method checks in `handlers_dmarc.go:20-24`
|
||||
and the `default:` branches of `HandleUserNew`/`HandleUserEdit`/`HandleServerSettings`
|
||||
(mux patterns are method-bound); the `postfix set-permissions` branch in
|
||||
entrypoint.sh:170-172 (`postfix check` has already created the tree).
|
||||
Duplication: `canViewDMARCDomain` repeats `lookupDomain`'s check; `ParseForm`
|
||||
twice on the user-edit POST; `Lookup` then `Touch` per request in
|
||||
`auth/middleware.go:17,28`; `retry.go:58-67` repeats the six `postconf` keys as
|
||||
literals instead of `retryConfKeys`; three header-name canonicalisers with two
|
||||
behaviours (`milter.go:124`, `inbound.go:89`, `sender.go:248`). Behaviour:
|
||||
`submitUserCreate` does `CreateUser` then `UpdateUser` for the e-mail;
|
||||
self-demotion redirects to a page that is now 404; `redirectHome` gives a
|
||||
domain user with no outbound reach an empty 200 where the inbound list gives
|
||||
404; `sort.Slice` in `postfix/inbound.go:59` reorders the caller's slice;
|
||||
`strings.Split(raw, "\n")` in `sender.go:65,104` keeps `\r`; `lists.go:428`
|
||||
trims the filter key while the Lua side reads it verbatim; `InDomain.WithMX`
|
||||
labels a `warn` status "NOT CHECKED"; `senderSession` does not override `Body`
|
||||
(the library discards the session after every accepted message — harmless,
|
||||
inconsistent with `milter.go:130-134`). The handlers test package takes ~105 s
|
||||
on this machine (likely bcrypt cost 12 on the user-creation paths).
|
||||
|
||||
### Doc drift
|
||||
|
||||
architecture.md: :167 (§ OpenDKIM) still says the `From:` header is not held
|
||||
against the login — contradicted by 2.4.0 and by § Sender check in the same
|
||||
file; :272-274 credits `/run` being tmpfs for a fresh `.alive` mark (it is a
|
||||
plain image directory; a restarted container keeps the mark); :328-329 "a
|
||||
deleted domain's row goes with it" — only `dns:` rows, `limit:` lingers up to
|
||||
48 h; :493 and § Who may open what omit `/inbound/log` and
|
||||
`/inbound/filter-lists`; :800 names only migration `0002`; :832-836 omits
|
||||
`inbound_spam_log`, `inbound_spam_list`, `notify_state`, `sender_check_days`;
|
||||
:838 "the panel is the only writer" — `dmarc-ingest` is a separate process
|
||||
that opens the store, migrates and writes; :856-859 vs `backup.go:19-23` and
|
||||
`httpserver.go:52-53` ("the same Resync") — the button skips DMARC maps and
|
||||
filter lists; the Internal env list omits `ANTISPAM_DIR` and
|
||||
`INBOUND_JOURNAL_MILTER_SOCKET`; § View engine calls the kit page "fixtures"
|
||||
(it is production data). schema-migrations.md: :20 "chain head `user_version
|
||||
= 5`" (six files; `TestBaselineSchema` asserts 6); the "Schema at head (v2)"
|
||||
table omits `sender_check_days` and `send_log.reason`/`header_from`.
|
||||
guide.md: :214 `INBOUND_JOURNAL_MILTER_SOCKET` "Set in: compose" (it is not);
|
||||
:1260 lines "deleted together with the row" (at the next six-hourly prune, and
|
||||
not while a reused id has a row); :720 "first successful boot" (M1).
|
||||
development.md: :270 version/tag coupling (H1); :353-357, :503 the env guard
|
||||
(M16); :404 "milter fail-open" as a covered negative (the suite now proves
|
||||
deferral); :409 "no fixed sleep". roadmap.md: `template-data-typing` describes
|
||||
`renderDomainDetail` with forty map keys — gone since 2.0 (one typed-before-
|
||||
render map remains, the health sampler's); `structured-logging` says `logf`
|
||||
"in five packages" — it is in 21 files of `internal/web`;
|
||||
`review-2026-08-followups` row "empty password" is misdescribed (M5). Code
|
||||
comments: `milter.go:7-11` "it never rejects" (it tempfails at `MAIL FROM`);
|
||||
`store/sendlog.go:19-22,61-65` `StatusRejected`/`InsertRejected` describe the
|
||||
level-2 case only; `store.go:2-3` "domain-admins"; `schema_test.go:15`
|
||||
`TestBaselineIsTheOnlyMigration`; `health/machine.go:225` "see roadmap
|
||||
panel-docs … later" (shipped 1.8.0); `inboundspam.go:349-350` "does not read
|
||||
the whole journal" (it scans it); `logtail.go:466` mentions `copytruncate`;
|
||||
supervisord.conf:25 "two services", :107 "7-14 files"; entrypoint.sh:119-120
|
||||
"no secrets" in `/data/antispam` (`filter.key`); docker-compose.yml:85,88 cite
|
||||
a guide section by a title it does not have.
|
||||
|
||||
### Test gaps
|
||||
|
||||
No test for: the H1 stamp (a `panel -version` assertion in the e2e against the
|
||||
version the stand was told to build); cert re-copy (H2); `follow()` with an
|
||||
unreadable or recreated log, a truncated `postqueue -p`, lines written to the
|
||||
old inode after the switch, `UpdateStatus` from `sent` (H3); two logins
|
||||
differing in case (H4); M1's ordering (restore tests never fail Open/Resync or
|
||||
exercise the real reload hook); `CheckRestore` with malformed JSON; M3, M5,
|
||||
L1, L2; `secretfile` concatenation (L3); the sender-check counter with the
|
||||
real program name (M13); a `LookupAddr` timeout (M15); `TestLimitRefusalsSince`
|
||||
EXPLAINs a stand-in query, not the real one; `TestOpenMigratesA20Database`
|
||||
stops at 0004; `Import` with a bad rua / IPs / window 0 / mode (M10); panel.js
|
||||
has no test at all (M11); login timing (M8); concurrent last-global (M7);
|
||||
`TestOriginCheck` lacks the Host-with-port vs Origin case;
|
||||
`TestServerSubtreeIsGlobalOnly` skips any `/server/` route not in
|
||||
`routes.always`; `internal/atomicfile` and `internal/supervisor` have no
|
||||
tests; `TestRenderInboundMaps` has no two-routes-one-next-hop case (L5);
|
||||
`TestSenderCheckResetsBetweenMessages` passes `h = nil` (L9); the handler test
|
||||
rows of `review-2026-08-followups` (apps/domains/users/dmarc handlers
|
||||
directly, `internal/dmarc` service) are still open.
|
||||
|
||||
### Checked and sound
|
||||
|
||||
Worth writing down so the next review does not re-read it: the sender check's
|
||||
layered rule holds against every `From:` shape tried (quoted local parts with
|
||||
`@`, CFWS, encoded words decoding to addresses, groups, `<>`, address
|
||||
literals, obs-route) — the "one `@` outside quotes" rule is the real backstop;
|
||||
extension stripping and case folding match Postfix; every error path of the
|
||||
check tempfails; level-2 accounting reserves under one lock and cannot be
|
||||
inflated by multi-RCPT or bypassed by ABORT/reconnect; the inbound verdict is
|
||||
trusted only under the queue-id-bound HMAC; the port-25 `-o` overrides are
|
||||
complete and read back by the e2e through `postconf -Ph`; the generated
|
||||
milter chain is exactly sender(tempfail) → OpenDKIM(tempfail) →
|
||||
journal(accept); `/server/` is guarded at registration and per handler;
|
||||
scopes fail closed (`1 = 0`); sessions store the hash only, `__Host-` cookie,
|
||||
constant-time setup token; `SaveSettings` restores prior state on mid-way
|
||||
failure; `secretfile`'s AAD binds header, counter and last-flag, and
|
||||
truncation, reorder and tamper are tested; shutdown order (tailer offset
|
||||
saved, HTTP drained, DB closed last) is right; rspamd map names, deny message,
|
||||
symbol prefixes and key-file format match the Go side byte for byte.
|
||||
|
||||
---
|
||||
|
||||
## Plan
|
||||
|
||||
Six stages, each a self-contained cut that can be taken in any order after
|
||||
stage 0, each small enough for one sitting, with model routing per
|
||||
development.md (security/infra → Opus, UI/docs → Sonnet). "Done when" is the
|
||||
test that proves it, not the edit. Nothing here is agreed until the owner
|
||||
says so; the order is my recommendation by harm per hour.
|
||||
|
||||
### Stage 0 — release hygiene (no product code) → `2.4.2`
|
||||
|
||||
1. **Stamp the right version** (H1). Make the e2e build take the version from
|
||||
the environment (`VERSION: ${SELFPOST_E2E_VERSION:-e2e}` in
|
||||
`compose.override.yml`, set by `release.yml`), or have the e2e reuse the
|
||||
image already built instead of rebuilding. Done when the e2e asserts
|
||||
`panel -version` equals what it was told, and `release.yml` runs that
|
||||
assertion before the push. — **Opus**
|
||||
2. **Compile the e2e in `test.yml`** (M20): `cd test/e2e && go vet ./... && go
|
||||
test -run xxx ./...`. — **Haiku**
|
||||
3. **`.dockerignore` + `test/`** (L33). — **Haiku**
|
||||
4. **The pin** (M6): old tags and images are not touched (owner,
|
||||
2026-10-12). The next publication through the fixed `release.yml` is the
|
||||
one the pin names; until then README/guide say that the current line is
|
||||
not yet published rather than name an image that is not there. — **Sonnet**
|
||||
5. **Toolchain and deps**: `modernc.org/sqlite`, `x/crypto`, `x/sys`; note in
|
||||
development.md that the image is rebuilt after each Go patch release
|
||||
(`golang:1.26-bookworm` floats, so a rebuild suffices). — **Haiku**
|
||||
6. The four `staticcheck` items. — **Haiku**
|
||||
|
||||
### Stage 1 — mail that is shown wrong (H3, H2, M12, M13) → `2.5.0`
|
||||
|
||||
1. **`QueueIDs` on a truncated listing returns an error**, as `postqueueJSON`
|
||||
does; `reconcile` already treats an error as no information. Test: a 5 MiB
|
||||
fake `postqueue -p`. — **Opus**
|
||||
2. **Reconcile only closes `queued` rows** (`AND status = 'queued'` in the
|
||||
reconcile path's update) and **only while the tailer is reading**: `follow`
|
||||
publishes "healthy" (last successful drain within N ticks) and
|
||||
`reconcileLoop` skips a sweep when it is not. Test: reopen fails → no rows
|
||||
closed. — **Opus**
|
||||
3. **Rotation window**: after switching inodes keep draining the old one until
|
||||
it has not grown for one full tick *and* the new file has received a line,
|
||||
or for a bounded number of ticks. Test on the stand with `postfix reload`
|
||||
timing. — **Opus**
|
||||
4. **Anchor `deliveryRe`** at the head of the line like its two siblings;
|
||||
test with the probed HELO and quoted-sender lines. — **Opus**
|
||||
5. **TLS re-copy** (H2): `postfix-cert-reload.sh` compares the mounted files
|
||||
to the copy and re-copies before `postfix reload` (atomically, same modes);
|
||||
e2e replaces the cert in the stand and reads the new serial off 465.
|
||||
— **Opus**
|
||||
6. **Deferred counter** (M13): on the stand, `kill -STOP` the panel mid-message
|
||||
and record which program logs the warning; widen the matcher accordingly
|
||||
and replace the composed fixture with the seen lines. — **Opus**
|
||||
7. Docs: architecture § Log tailer, § Queue reconcile, § Sender check "what it
|
||||
costs"; guide § Delivery log. — **Sonnet**
|
||||
|
||||
### Stage 2 — the tenant boundary and the registry (H4, M7, M9, M10) → in `2.5.0` (migration `0007`)
|
||||
|
||||
1. **Logins lower-cased** at validation and `applications.login COLLATE
|
||||
NOCASE` (migration rebuilds the unique index). Before the migration a check
|
||||
that no two existing logins differ only in case (there is one
|
||||
installation; look). Tests: `Ops`/`ops` refused; the map written lower-
|
||||
case. Then verify Postfix's compare on the stand once (`MAIL FROM` as the
|
||||
other case) so the doc's claim is seen, not assumed. — **Opus**
|
||||
2. **Last global in one transaction**: `DeleteUser`/`SetUserRole` take a
|
||||
`tx`, count and act inside it; `CreateGlobalUser` likewise. A concurrency
|
||||
test with two goroutines. — **Opus**
|
||||
3. **`submitUserUpdate` in one transaction** (store method that takes the
|
||||
whole change). — **Opus**
|
||||
4. **Import validates like the form**: route `DMARCRua`, IPs, limit mode,
|
||||
window and count through the same parsers; refuse the file with the
|
||||
validator's text. Tests per field. — **Opus**
|
||||
5. Docs: security.md § Roles (the guard), guide § Domain export. — **Sonnet**
|
||||
|
||||
### Stage 3 — persistence and restore (M1–M5, L1–L4, L7) → `2.5.1`
|
||||
|
||||
1. **Consume the manifest after the resync succeeds** (`CheckRestore` reports;
|
||||
`run` deletes after `resyncAfterRestore` returns nil). Test: Resync fails →
|
||||
manifest kept → second boot resyncs. — **Opus**
|
||||
2. **Indexes**: `idx_send_log_domain_created_at (domain, created_at)`;
|
||||
partial `idx_send_log_queued (created_at) WHERE status = 'queued'`;
|
||||
consider `(domain, id)` for the scoped page. Migration `0008` DDL only;
|
||||
`TestLimitRefusalsSince`-style EXPLAIN tests against the **real** query
|
||||
strings. — **Opus**
|
||||
3. **CLI backup**: open the source `mode=ro`, refuse a missing file; password
|
||||
floor of 12 shared with the panel (`validate.MinSecretFilePasswordLen`);
|
||||
`-decrypt -o` verifies the password (first chunk) before opening the
|
||||
output; `OnWarn` for skipped symlinks and `Check` fails when `certs/` is
|
||||
named but empty. — **Opus**
|
||||
4. **`secretfile` caps**: bound `128·N·r` to a stated budget (e.g. 128 MiB)
|
||||
and `p` to 1-2; refuse the rest with `ErrHeader`; reject trailing bytes
|
||||
after the last chunk. Tests. — **Opus**
|
||||
5. **`atomicfile`**: fsync the directory after rename; a test file for the
|
||||
package. — **Opus**
|
||||
6. Docs: security.md § Backup; guide § Backup. — **Sonnet**
|
||||
|
||||
### Stage 4 — panel behaviour (M8, M11, L10–L19) → `2.5.2`
|
||||
|
||||
1. Login: dummy bcrypt on unknown user; a test that measures both paths within
|
||||
a tolerance. Account password change gets its own limiter bucket. Login
|
||||
destroys the presented session. — **Opus**
|
||||
2. `http.Server`: `ReadTimeout`, `WriteTimeout`, `IdleTimeout`. — **Opus**
|
||||
3. **Polling recovery**: schedule from `htmx:afterRequest` on the box (or pass
|
||||
`source`), handle `sendError`/`timeout` with back-off; `RequireAuth`
|
||||
answers an `HX-Request` with `HX-Redirect: /login` instead of a 303. A
|
||||
minimal JS test (node + jsdom) or at least an e2e that restarts the panel
|
||||
with Overview open. — **Sonnet**
|
||||
4. `parsePage` clamp; `NaN` refused; `rate-limited` tag red (add the status to
|
||||
`components.go`, flip the test); the recalc button posts only what it
|
||||
needs or warns; `HandleReload` either covers DMARC maps and filter lists or
|
||||
the flash and the two comments say what it covers. — **Sonnet**
|
||||
5. `domain.Service.Delete`: say the domain is deleted when a later step fails,
|
||||
and run `apps.Resync()` anyway. — **Opus**
|
||||
|
||||
### Stage 5 — image and Postfix hardening (M17–M19, M21, L30–L32) → `2.6.0`
|
||||
|
||||
1. **Supervisor socket**: a group of its own (`supervisor`), panel only;
|
||||
`postfix`/`opendkim` keep `selfpost` for the file paths. — **Opus**
|
||||
2. **OpenDKIM**: `Syslog yes` (supervisord captures it) or `Logfile`;
|
||||
`Canonicalization relaxed/simple`; `OversignHeaders From`. e2e: DKIM still
|
||||
verifies; a message with two `From:` is refused before signing anyway.
|
||||
— **Opus**
|
||||
3. **Postfix**: `smtpd_tls_protocols = >=TLSv1.2`, `smtpd_tls_mandatory_protocols`
|
||||
likewise, `disable_vrfy_command = yes`, `smtpd_banner = $myhostname ESMTP`;
|
||||
integer validation of `RATE_LIMIT_*`, `*_SIZE_LIMIT`, `MILTER_*_TIMEOUT` at
|
||||
the top of `postfix-config.sh` with a clear exit. — **Opus**
|
||||
4. **logrotate state under `/data/log/.logrotate.status`** (`-s`), group-
|
||||
readable as the directory is. — **Opus**
|
||||
5. **entrypoint**: normalise the three DMARC maps with the other five;
|
||||
remove the dead `set-permissions` branch. — **Opus**
|
||||
6. **`extract-cert.sh`**: `set -o pipefail` under `bash`, check both outputs
|
||||
are non-empty PEM before moving them into place, create `0600` first.
|
||||
— **Opus**
|
||||
7. **`crashexit`**: subscribe to `PROCESS_STATE_EXITED` with a restart-count
|
||||
threshold, or raise `startsecs` so a crash after 1 s still reaches FATAL;
|
||||
say in architecture.md what actually exits the container. — **Opus**
|
||||
8. **`envdoc_test` made real**: parse `envDefault|envInt|os.Getenv("…")` out of
|
||||
`cmd/panel/*.go` and `${VAR` out of `build/*.sh`, parse the key tables out
|
||||
of guide.md and architecture.md, compare. — **Opus**
|
||||
9. Docs: architecture § Image and processes, § Configuration; security.md
|
||||
§ Process. — **Sonnet**
|
||||
|
||||
### Stage 6 — documentation and roadmap sweep (no product code, no cut)
|
||||
|
||||
1. Every item under *Doc drift* above, file by file. — **Sonnet**
|
||||
2. roadmap.md: strike `template-data-typing` (done by 2.0; one sampler map
|
||||
left, typed before render); reword `structured-logging`; in
|
||||
`review-2026-08-followups` mark `parsePage` and `secretfile` rows as
|
||||
addressed by stages 3-4, keep the `offset.go` inode row (still open:
|
||||
fingerprint of the first 512 bytes only), and the `dnscheck` fan-out row
|
||||
now points at M14. Add the open tests list above as the test row's new
|
||||
content. — **Sonnet**
|
||||
3. Comments named above (`milter.go`, `sendlog.go`, `store.go`, `schema_test.go`,
|
||||
`machine.go`, `inboundspam.go`, `logtail.go`, supervisord.conf,
|
||||
entrypoint.sh, docker-compose.yml). — **Haiku**
|
||||
4. Dead code removal from § Nits, with `staticcheck` added to `test.yml` so it
|
||||
stays gone. — **Haiku**
|
||||
|
||||
### Not planned, said out loud
|
||||
|
||||
- L5 (`tls_policy` per next-hop): the right fix is a model change (TLS mode
|
||||
per upstream, not per domain) and a form change — a design item, not a
|
||||
patch. Until then the guide should say two domains to one upstream share
|
||||
the first one's TLS mode.
|
||||
- L12 (rows by domain name): accept it for outbound as security.md already
|
||||
does for inbound, and write it there.
|
||||
- L8 (the signing-name oracle): the names are in DNS; shortening the reply
|
||||
costs a less useful error. Leave, note in security.md § Accepted risks.
|
||||
- L9 (go-milter header accumulation): a one-line comment on `Headers` and a
|
||||
test with a polluted `h`; the library behaviour itself is upstream's.
|
||||
- The Windows-bound tests: not worth platform guards; CI is Linux.
|
||||
Reference in New Issue
Block a user