Commit Graph
11 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 faa3c11ae7 panel-dkim-signing stage 2: OpenDKIM removed
test / test (push) Canceled after 0s
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>
2026-10-16 01:05:39 +03:00
mixandClaude Opus 5.5 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 68ec8b2), e2e.
- smtpd_hide_client_session=yes on smtps and submission (owner's
  decision): Received: names no client, TLS, login or protocol; port 25
  keeps the full header. e2e checks both.
- test/upstream/opendkim-probe.sh makes keys with openssl genrsa.

Tests: on the server, make e2e exit 0 (674 s, the image built on trixie),
go vet + go test 27 packages ok. CHANGELOG [Unreleased], guide,
architecture.md, security.md, roadmap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:31:35 +03:00
mixandClaude Opus 5.5 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>
2026-10-15 13:53:14 +03:00
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 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>
2026-10-15 01:53:04 +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 bd746c61d7 panel-redesign: paths follow the menu; Account and Server settings split
Stage 1, step 3 (plan § Routes). BREAKING: every panel URL changes and the
old paths are removed without redirects.

Routes (web.go)
  - /status -> /overview; /domains -> /outbound/domains with the domain's
    settings actions under .../settings/ (reports, ratelimit, export);
    /deliveries -> /outbound/log; /mail-queue -> /outbound/queue; /dmarc ->
    /outbound/dmarc; /inbound -> /inbound/domains; /system-log ->
    /server/log; /backup -> /server/backup (import under it); /users ->
    /server/users; recheck and reload under /server/health/.
  - One fragment name: <page>/fragment everywhere.
  - Everything under /server/ is registered through server(), which wraps
    the handler in globalOnly: the subtree is guarded once, where a new
    route cannot forget it. Handlers keep their own requireGlobal.
  - GET / leads to /overview, /outbound/domains or /inbound/domains.
  - The /account -> /settings redirect is gone with /settings.

Account and Server > Settings
  - handlers_account.go: GET /account and three POSTs - profile (username,
    e-mail; no password asked, as designed), password (current password,
    rate-limited like a sign-in, signs out other sessions), dmarc (the
    user's default: hosted / account / custom / none, each validated).
    This replaces step 1's bridge through the old Settings form.
  - handlers_settings.go is the instance's page only: log retention and the
    read-only rate limits, global role.
  - One legacy template serves both pages by a flag until stage 2 gives each
    its own: a new legacy page cannot be added to the ratchet.

Still to come inside stage 1, and why @routes stays on the ratchet:
  - step 4: the application routes (one POST, under the domain);
  - step 5: GET /server/health.
With @routes removed by hand TestRoutesFollowNavigation reports exactly
those and nothing else - no template links to a path the router lacks.

Old templates, tests and the e2e client are retargeted. The e2e module was
edited but not run here (no Docker, and its dependency could not be
downloaded on this machine).

Tests: Account (page, profile, password, DMARC default in every mode, a
domain administrator's own row only), Server settings (saved without a
password, range, global only), the /server/ subtree answered 404 by the
router for a domain administrator with All on both lists and for no
principal.

go test ./...  green except the two Windows-only failures in internal/domain
and internal/logtail.

Roadmap 13/27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:51:55 +03:00
mix 656c4d4d7f release: 1.4.0: Optional inbound relay (backup-MX) on port 25
Optional inbound relay (backup-MX / forwarder) on port 25, off by default. Mail is accepted only for domains configured in the panel and forwarded to an upstream; there are still no mailboxes. A dedicated security review of the inbound path is still open on the plan (Fable).
Added:
  - `INBOUND_RELAY_ENABLE` (default `false`): when true, Postfix listens on
  - Panel **Inbound** pages (global administrator): domains, upstream host/port
  - Optional `INBOUND_ANTISPAM_MILTER` on the inbound listener only, plus
  - Inbound rate limit (`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`, default 20) and
Changed:
  - Compose publishes host port 25 even when the flag is off (nothing listens
  - Full-backup restore Resync also rebuilds inbound maps when the flag is on.

Full detail: CHANGELOG.md (1.4.0).
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