Commit Graph
10 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 dab7715e80 audit 5: the supervisor socket the panel's alone, OpenDKIM logs and oversigns From
test / test (push) Canceled after 0s
M17: the supervisord control socket is root:supervisor 0770, a group whose
one member is the panel; OpenDKIM and Postfix keep `selfpost` for the files
under /data and no longer reach the socket.

L30, read in OpenDKIM 2.11.0~beta2's source first: it logs through
syslog(3) only and has no Logfile option, and nothing in the container
served /dev/log. internal/syslogrelay (the panel binary with
-syslog-relay=/run/syslog/log, program syslog-relay before OpenDKIM,
/dev/log a link to its socket) passes the datagrams to stdout, error and
worse marked. opendkim.conf: Syslog yes, Canonicalization relaxed/simple,
OversignHeaders From (RFC 6376 § 3.4, § 8.15). e2e: OpenDKIM's start line
reaches the container log; every program RUNNING; DKIM verifies.

Tested on the test server: go vet + go test of 26 packages, make e2e (all
pass, 626 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:07:28 +03:00
mixandClaude Opus 5.5 9eddd49602 audit 2c: findings of the two reviews
test / test (push) Canceled after 0s
Technical review (Opus, 11 findings, 2 medium) and security review (Fable,
no high or medium) of 3c86280..HEAD. Both confirm the port gives
dkim_mail_parse()'s result on every input it parses, and that no line the
check passes is signed with another domain's key.

- F1 (medium): OpenDKIM's parser takes lines no mail program reads as one
  address - "ceo@bank.com:me@mine.com", "Name <ceo@bank.com:me@mine.com>",
  "<b@evil.com, a@mine.com>", "ceo@bank.com <me@mine.com>",
  "ceo@bank.com@mine.com" - and signs for the last while a lenient reader
  shows the first. The line must now also be one address in RFC 5322's
  grammar (net/mail, display names in any charset passed through), at the
  domain OpenDKIM signs for. A quoted display name may still hold anything.
- F2 (medium): the bindings were asked about OpenDKIM's form of the
  address, quotes and all, so "x"@mine.com passed for an application
  holding @mine.com while x@mine.com belonged to another. They are now
  asked about the reader's address, its local part unquoted, as Postfix
  holds the envelope's.
- F3 / security (low): Bindings.Allowed and DomainOf fold case in ASCII
  only: Postfix folds as Unicode (casefold.c, ICU), but every key and
  login the panel writes is ASCII, and the Unicode fold let the Kelvin sign
  pass for "k" (net/mail then refuses such a line on the domain test).
- F4 (low): a TERM between "postfix start-fg &" and "child=$!" is no longer
  lost: the wrapper stops Postfix as soon as the pid is known.
- F5 (low): for startsecs after a start any failed reload passed as "not
  running". postfix-reload is now build/postfix-reload.sh: exit 75 when
  master -t says no master runs, else exec postfix reload; RunOnce returns
  EndError with the status, and only 75 is taken as done. supervisor.State
  goes.
- F6 (low): crashexit spares postfix-reload too - a reload that could not
  be spawned is reported to whoever asked, not a reason to stop the
  container.
- F7 (low): a certs/ that is itself a link is resolved before the walk
  (filepath.WalkDir does not follow its root).
- F8 (low): at shutdown the tailer reads what is left of the rotated
  file before saving its place. Not changed: a refusal line still waiting
  on the old file at shutdown is not kept - the panel writes refused rows
  itself, only a kill loses one, and that window is the minute's grace.
- F9 (low): pct read as text - empty or unreadable is 100, the rest held
  to 0..100.
- F10 (low): the directory is synced after sasldb2's rename.
- F11 (low): POSTFIX_STARTSECS by expr, so "010" or "08" in
  MILTER_WAIT_TIMEOUT are decimal, not octal or an error.
- Security (low): pagehide also empties defaultValue - the input's value
  attribute and the textarea's text - not only .value. A sasldb2.new left
  by a killed panel is removed at start (entrypoint) and never archived.
  security.md says that links under certs/ are followed and by whom they
  can be placed.

Tests: sender check cases for F1 (four lines), F2 (a bound address
quoted, for its owner and for another), F3; EndError's state and status;
pct cases. architecture.md and CHANGELOG describe the reader rule. Stand
(2026-10-15, make e2e, exit 0); go vet and go test ./... pass on the
server (24 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:17:33 +03:00
mixandClaude Opus 5.5 f2ad699473 supervision as supervisord does it: Postfix stopped, give-ups FATAL, reload's exit read
test / test (push) Canceled after 0s
C-P42. postfix-script's start-fg runs master as its child and master
calls setsid() (Postfix 3.7.11, conf/postfix-script; src/master/master.c);
supervisord signals the pid alone (supervisor 4.2.5, process.py,
Subprocess.kill()). A stop of program postfix left master running - a
restart then found "already running" - and a container stop cut Postfix
off with SIGKILL. The wrapper now stays the parent of "postfix start-fg"
and on TERM/INT runs "postfix stop", asking again while start-fg is still
in its checks (master -t). stopwaitsecs=15.

C-V6. The wrapper's give-up on the milter sockets came after the default
startsecs, so it was EXITED and restarted for ever. startsecs is now
MILTER_WAIT_TIMEOUT + 10, exported by the entrypoint as POSTFIX_STARTSECS
and expanded by supervisord (options.py, processes_from_section(); a
non-number MILTER_WAIT_TIMEOUT stops the start): a give-up is "Exited too
quickly", BACKOFF, FATAL after startretries, crashexit. For those seconds
after each start the program is STARTING (the healthcheck's 90 s start
period covers it; the sender check counts no downtime then).

C-V5. logrotate had startsecs=0, so a refused config restarted it every
second for ever. startsecs=1: FATAL after startretries; crashexit reads
the event's processname and leaves logrotate's FATAL on Server > Health.

B-U2. "supervisorctl start" returns when a startsecs=0 program is RUNNING,
not when it ends, and mapped "already started" to success. The panel now
speaks supervisord's XML-RPC itself (internal/supervisor, RunOnce):
startProcess without waiting, getProcessInfo until it leaves
STARTING/RUNNING, exitstatus 0 or an error; a run in progress is waited
out and one of its own follows. A failed reload while program postfix is
not RUNNING is logged, not an error: the next master reads the files.
C-V10: postfix-reload rests EXITED after a run; the e2e process check
takes STOPPED or EXITED. C-P34: the dead "postfix set-permissions" branch
goes - postfix check's create-missing builds the queue tree.

Stand (2026-10-14, make e2e, exit 0; MILTER_WAIT_TIMEOUT=10 on the stand):
new supervision_stops_postfix_and_gives_up - supervisorctl restart postfix
writes postfix-script's "stopping the Postfix mail system" and leaves one
master; with the panel's sockets removed, start answers "postfix: ERROR
(spawn error)" (finish() sets spawnerr "Exited too quickly", onwait()
reads it first) and Postfix comes up once the panel is back; a
group-writable logrotate config ends FATAL with the container up; the
panel's Reload configuration answers only after mail.log has "refreshing
the Postfix mail system", postfix-reload EXITED. session_survives_restart
sees the stop line for the container's restart. Measured: master's own
"terminating on signal 15" never reaches mail.log (master TERMs its group,
postlogd included, before it logs). go vet and go test ./... pass on the
server (24 packages; new unit tests of RunOnce against a fake supervisord).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:49:25 +03:00
mixandClaude Opus 5.5 599149bf83 build, deploy, inbound, dmarc, backup, e2e: cite the source of what they rely on (assumptions part C)
Comments only, no behaviour changed. Each main.cf/master.cf parameter the
build sets, the wrapper's and the reload hook's reliance on Postfix 3.7.11
(postfix-script, master.5, postconf.5, cleanup_init.c, smtpd_peer.c,
README_FILES), supervisor 4.2.5 (process.py, options.py, events.rst),
logrotate 3.21.0, OpenDKIM's config, Cyrus SASL's sasldb, rspamd 3.11.1
(milter_headers.lua, multimap, the map watcher), Docker Compose, CoreDNS
and certbot now say where that is written. Measurements already there stay
measurements ("date not recorded" where none was); hand-composed fixtures
say so.

Contradictions left for their own commits: C-P31 cleanup's size limit,
C-P42 master outside supervisor's signal, C-V5/V6 startsecs, C-V10,
C-S3/Q3 sasldb written in place, C-Q4/A1 certbot's symlinks, C-R7 rspamd
skips local clients but the signature routine does not, C-P8, C-R4, C-R12,
C-M7, C-E2, C-K8/K9, C-D1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:39:21 +03:00
mixandClaude Opus 5.5 7af37654a1 cert-reload: renew Postfix's copy of a renewed certificate (audit H2)
postfix-config.sh copies the mounted certificate and key into
/etc/postfix/tls-internal at start (root-owned, key 0600) and Postfix reads
the copy; cert-reload only ran `postfix reload` daily, which re-read the
stale copy, so a certificate renewed while the container ran expired on
465/587 with the old one.

postfix-cert-reload.sh now, every TLS_RELOAD_INTERVAL_SECONDS: compares the
mount with the copy (cmp); when they differ and the certificate and key
belong together (openssl public keys equal - a renewer writes them one
after the other), writes each file beside its target, root-owned with the
start's modes, renames it over the target, and reloads Postfix. Nothing
changed - no reload. No copy (files missing at start) - the old periodic
reload. Never exits non-zero.

e2e renewed_certificate_picked_up: writes a new certificate into the
stand's mount and waits (30 s) for port 465 to present its serial;
compose.override.yml sets TLS_RELOAD_INTERVAL_SECONDS=2.
supervisord.conf comment, architecture.md (process table, § Postfix TLS
certs, the env knob), guide (after the proxies: picked up within a day).
CHANGELOG [Unreleased], audit plan line ticked, roadmap 12/61.

Evidence: GOOS=linux go vet in test/e2e clean; the server run of the stage
follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:25:20 +03:00
mixandClaude Opus 5.5 89fd0a6116 from-header-binding: findings of the technical and security reviews
test / test (push) Canceled after 0s
Both reviews were of c171fb5, read-only, given the code and questions.

Found by both, and the one that would have refused ordinary mail from the
first day: a folded From: line - what a mail library makes of a long display
name - did not parse and was refused with 550. The line is unfolded before it
is read. e2e sends one through Postfix now.

Technical (Opus)
- A display name in a charset Go does not convert (windows-1251, koi8-r) was
  refused: such a name is passed through unread, only the address is judged.
- The statistics ran between binding the socket and serving it, with a
  supervisorctl that has no timeout: they are kept beside the check now and
  nothing of them is waited for. A supervisorctl that fails is logged.
- The count of deferred mail followed smtpd's reject lines and so missed a
  check that stops answering in the middle of a message, where cleanup
  writes them: it is taken at Postfix's warning, the line that names the
  socket. The test line for that case had been invented with the wrong
  process; it is marked as composed from Postfix's source.
- A card whose figures could not be read showed zeros and "ok": dashes and
  "unknown".
- entrypoint removes the sockets a killed container left, which satisfied
  the wrapper's wait before the check was listening.
- Outside quoted strings the line may hold one "@" only - a rule that needs
  no knowledge of OpenDKIM's parser.
- Comments that still spoke of two sockets, or of a session replaced after a
  refusal; the limits of the cards layout on a phone, said where it is defined.

Security (Fable; no Critical or High)
- Another sender's name in an encoded display name passed the second look:
  the decoded name is looked at as well.
- A client could arm the deferred counter by sending the socket's name where
  Postfix quotes it in a warning: the two warnings are matched from their
  first word.
- A refused message wrote a row per recipient: one row, naming the first
  recipient and how many more.
- Docs: the check's lookup is stricter than Postfix's for address extensions
  and the case of logins, rather than "cannot disagree"; what a display name
  looks like to a person is not promised; the timeouts and the 454 at AUTH
  stated as they are; "messages" are connections turned away.

Left as found, in the plan: downtime that crosses midnight is booked on the
day of the restart; a message with the null envelope sender leaves a row
without a domain; a log read again after an unclean stop counts again.

Test server, this tree: go vet ok, go test 23 packages ok, make e2e ok (330 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:06:02 +03:00
mix 7ef7f75246 release: 1.9.2: Review pass: port-25 milters, DMARC validation, rate-limit fix
Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission milter chain, DMARC reports are validated before they are stored, and the auto rate limit is correct for a non-default level-1 window. Schema migration `0010` (index only). Upgrading is a tag bump.
Added:
  - SQLite migration `0010_send_log_app_login_index.sql` —
  - DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML
  - e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration
Changed:
  - README hero line and a **Positioning** block in `docs/product.md` record the
Fixed:
  - The setup account's password is hashed at bcrypt cost `12` like every other
  - Login/setup rate limiter: protecting blocked buckets from eviction is now
  - `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of
  - [schema-migrations.md](docs/schema-migrations.md): the schema section was
  - postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second
  … (11 more in CHANGELOG)
Changed:
  - [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code
  - Extracted `internal/configsafe` — the empty-value and forbidden-character
  - Build, deploy and migration comments no longer cite the archived
  - Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through
  - Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and
  … (7 more in CHANGELOG)
Removed:
  - Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs
  - Plan documents for already-shipped features: `domain-stats-auto-ratelimit`,

Full detail: CHANGELOG.md (1.9.2).
2026-09-22 15:24:53 +03:00
mix 60e08e802e release: 1.2.0: Panel 1.2.0-1.2.5: layout, RBAC, tests, GUI and CI fixes
[1.2.0]
The second MINOR after 1.0.0: domain administrators with per-domain scope, a panel visual refresh on the SelfPost palette, and refinements to navigation and the send log. Upgrading runs one SQLite migration (`0005_panel_users`); the single administrator becomes a global user.
Added:
  - panel: **domain-admin role** — global administrators manage panel users and
Fixed:
  - Docker build: `LICENSE` is no longer excluded by `.dockerignore`, so the
  - panel: on signed-in pages with a narrow card (**Settings**, the user form)
Changed:
  - panel: navigation session icons are distinct — **Settings** uses a gear,
  - panel: `/` redirects domain administrators to `/domains`; global users still
  - panel: **Sign out** in the navigation column uses the same type size and
  - panel: **visual style** brought in line with the SelfPost mark — brick accent
  - panel: the send log's **status is a badge**, in the same ok/warn/error/unknown
  … (1 more in CHANGELOG)
[1.2.1]
Panel refinements after 1.2.0: navigation icons, status-badge centreing, drill-down back-link placement, and safeguards for the sole global administrator. Upgrading is a tag bump; no migration.
Fixed:
  - panel: status badge text sat low in the box (and below the heading or label
  - panel: the **Users** navigation icon was two full silhouettes with staggered
  - panel: the **Settings** navigation icon was a sun-with-rays (circle plus
  - panel: the user create/edit form placed «Back to users» at the bottom of the
Changed:
  - panel: the user edit form disables role change and delete for the only global
  - panel: the user create/edit form hides **Assigned domains** when the role is
  - panel: **Settings** shows panel credentials and DMARC aggregate reports side
[1.2.2]
Status page layout after 1.2.1: paired cards in a wide column, denser machine and check copy, and a user-form checkbox fix. Upgrading is a tag bump; no migration.
Changed:
  - panel: **Status** is wide again so paired cards fill the column. Layout:
Fixed:
  - panel: cards inside `.split` used `margin: 0 auto`, which in a CSS grid
  - panel: on the user create/edit form, **Assigned domains** checkboxes stacked
[1.2.3]
Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no migration.
Changed:
  - panel: the domain detail page is wide with paired cards (DKIM+SPF ‖ DMARC;
  - panel: Domains list — **Add domain** sits beside the domain field; the
  - panel: page URLs, browser titles, and headings are aligned — **Settings** is
Fixed:
  - image: `mail.log` rotation no longer silently stops when the build context
[1.2.4]
Level-2 rate-limit semantics inverted after 1.2.3, plus a small DNS field height fix. Upgrading is a tag bump; no migration.
Changed:
  - rate limiting (level 2): domain ceilings apply to every client IP (no IP
Fixed:
  - panel: on the domain DNS status grid, the Type (TXT) field height matches
[1.2.5]
Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration.
Changed:
  - panel: rate-limit forms refined — level-1 backstop as muted copy on the

Squashed CHANGELOG sections: [1.2.0], [1.2.1], [1.2.2], [1.2.3], [1.2.4], [1.2.5].

Full detail: CHANGELOG.md (1.2.0, 1.2.1, 1.2.2, 1.2.3, 1.2.4, 1.2.5).
2026-09-22 15:24:52 +03:00
mix 75207f0d1e release: 1.0.0: Public 1.0 image; delivery log; docs and SECURITY.md (incl. 1.0.1)
[1.0.0]
Added:
  - A **Delivery log** on each delivery's page (`/deliveries/{id}`): the
  - A **History** block on the same page: the journal's two timestamps stated as
Fixed:
  - Postfix config: copy TLS cert/key from the (often `:ro`, host-owned) mount
  - Postfix config: allow `maillog_file` under `/data` via
  - E2e: read `/data/setup-token` via `docker compose exec` (file is `0600`
  - Entrypoint: check `SELFPOST_HOSTNAME` before `/data` setup (so a bad identity
  - E2e gate: wait for host-published `/healthz` before panel setup, and stop
  … (3 more in CHANGELOG)
Changed:
  - Document what `.spbk` and `.spde` stand for (SelfPost backup / SelfPost domain
  - Docs aligned with the code: setup URL is `/setup/<token>` (README and
  - Full backups no longer carry `/data/log`. It is Postfix's raw log plus its
  - Monitoring screens (status, mail queue, system log, deliveries) use adaptive
  - Documentation package consolidated into `docs/development.md`: Documentation
  … (4 more in CHANGELOG)
[1.0.1]
A documentation and packaging release: no change to the mail path, the database, or the on-disk layout. Upgrading is a tag bump.
Added:
  - `SECURITY.md` — how to report a vulnerability privately (GitHub private
  - [docs/plans/](docs/plans/) — one document per agreed extension: the optional
Changed:
  - The panel's **Account** entry is now called **Settings** — nav link, page
  - The signed-in name in the panel's nav is now labelled `User:`, so it reads as
  - [docs/product.md](docs/product.md) reframes the future line: the inbound
  - [docs/security.md](docs/security.md) is now in English, matching the rest of
  - The README documentation table now points at `SECURITY.md` for reporting a
  … (2 more in CHANGELOG)
Fixed:
  - The panel's static assets are served with a content ETag and

Squashed CHANGELOG sections: [1.0.0], [1.0.1].

Full detail: CHANGELOG.md (1.0.0, 1.0.1).
2026-09-22 15:24:51 +03:00
mix 8ce050ef7f release: 0.1.0: Phases 0-11: panel, Postfix/DKIM, milter, backup, Docker image
Initial feature-complete implementation of the v1.0 specification (phases 0-11 of `docs/implementation-plan.md`).
Added:
  - Panel (Go, single static binary) with SQLite persistence, one-time
  - Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and
  - Application (sender identity) management: SASL credentials via `sasldb2`,
  - Full Postfix relay config generated from env at container start: SMTPS 465,
  - Journal milter (pure Go, `go-milter`) recording every send to `send_log`;
  … (6 more in CHANGELOG)

Full detail: CHANGELOG.md (0.1.0).
2026-09-22 15:24:51 +03:00