release: 2.4.0: an application signs only as itself
test / test (push) Canceled after 0s

from-header-binding (docs/plans/from-header-binding.md, 9/9), at the owner's
word (2026-10-12, "Да").

  - A message's From: line is held to what its envelope sender is held to:
    any address of the application's domain, or the addresses of its list,
    with or without a +extension. Anything else is refused with 550 5.7.1 -
    another registered domain, the server's own name, a domain this server
    does not sign for. Until now an application could have mail signed with
    another domain's key by writing that domain into From:.
  - The check is the panel's, first in the milter chain and strict: while
    the panel does not answer, Postfix defers mail.
  - Server > Health counts what that costs - the panel's downtime under a
    running Postfix and the submissions turned away - and is laid out anew,
    boxes numbered by importance.
  - A refused row of the Outbound log says why in its tag.
  - Migration 0006.

Upgrading: mail whose From: is not the application's own stops being
accepted - another of your domains, an address off an application's list, a
visitor's address a form puts there. The guide says what to do about each.
A stopped panel now stops submission.

Not done, written into the plan at the owner's word: the Delivery log box on
a refused message's page, the log's status filter, "messages" that are
connections, and what was reasoned about rather than run - OpenDKIM's own
parser above all.

Cut: CHANGELOG 2.4.0 written (nothing had been put under [Unreleased]
while building); image pin 2.4.0 in compose, README, guide; version table,
roadmap, plan status. security.md: a malformed packet on a milter socket,
as an accepted risk. No tag: the owner tags when he decides to, so there is
no image of this version until then.

On the test server, on c817f08: go vet ./... ok, go test ./... ok
(23 packages), make e2e ok. This commit changes documents and the pin only.

Full detail: CHANGELOG.md (2.4.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-12 22:00:29 +03:00
co-authored by Claude Opus 5.5
parent c817f0833c
commit 8f226bd82f
8 changed files with 166 additions and 11 deletions
+59
View File
@@ -5,6 +5,65 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased]
## [2.4.0] - 2026-10-12
### Security
- **An application signs only as itself.** OpenDKIM chooses the key by the
`From:` header, and Postfix held only the envelope sender against the
login: an application of one sending domain could write another registered
domain, or the server's own name, into `From:` and have the message signed
with that name's key (security review of `2.3.0`, where it was recorded as
an accepted risk). The panel now holds the header to what the envelope is
held to - any address of the application's domain, or the addresses of its
list, with or without a `+extension` - and refuses anything else with
`550 5.7.1`, a domain this server does not sign for included
([plans/from-header-binding.md](docs/plans/from-header-binding.md)).
- **The check is strict.** It is the first milter of the chain, on a socket
of its own (`SENDER_CHECK_SOCKET`), attached with
`default_action=tempfail`: while the panel does not answer, Postfix defers
mail and accepts nothing unchecked. The journal milter keeps its socket and
stays fail-open.
### Added
- **Server › Health counts what the strict check costs** in a card of its
own, *Sender check*: for how long the panel did not answer while Postfix
was running, and how many submissions Postfix turned away because of it -
today, in the last 30 days and in the last 12 months (UTC days, kept a
year). The card warns when mail was deferred in the last 24 hours. The
numbers are there to decide whether the check needs a process of its own.
- **A refused row of the Outbound log says why**, in its tag: `wrong From`,
`wrong client` (the application's client IP list) or `rate-limited`; a row
refused before this version says `rejected`. A message refused for its
`From:` line shows on its page what the line said and what the application
may send as. Help has a *Sender check* topic.
- Migration `0006_sender_check.sql` (DDL only): `send_log.reason`,
`send_log.header_from`, `sender_check_days`.
### Changed
- **Mail with a `From:` the application is not bound to is refused.** What
stops working on upgrade: an application sending with the `From:` of
another of your domains (give that domain an application); an application
with a list whose envelope and `From:` addresses differ (add the second to
the list); a form that puts its visitor's address into `From:` (use
`Reply-To:`). The line is read strictly: one `From:` field, one address,
one `@` outside a quoted display name; a name with a comma or brackets has
to be quoted. Guide § Who an application may send as.
- **With the panel down, mail is deferred, not accepted.** Until now a
stopped panel cost the send log and the level-2 limits and nothing else.
- **Server › Health is laid out as two stacks of boxes numbered by
importance**, read left to right and top to bottom, each stack closed up:
Processes, Milter sockets, Sender check, TLS certificate, Hostname and
reverse DNS, Notification mail, Machine, Configuration. On a phone they
come in that order.
- A refused message is one row of the log, naming its first recipient and
how many more there were.
- `recipient_delimiter` is set to `+` by the image instead of being left to
the distribution's default; the start removes milter sockets a killed
container left behind.
## [2.3.0] - 2026-10-12
### Added
+1 -1
View File
@@ -112,7 +112,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:2.3.0
ghcr.io/mixeme/selfpost:2.4.0
```
**Get the setup URL** (pick one):
+1 -1
View File
@@ -21,7 +21,7 @@
services:
selfpost:
image: ghcr.io/mixeme/selfpost:2.3.0
image: ghcr.io/mixeme/selfpost:2.4.0
restart: unless-stopped
environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
+2 -1
View File
@@ -107,7 +107,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
[§ Release image](#release-image)).
**Planned version cuts** (from pin `2.3.0`; adjust if semver changes mid-track):
**Planned version cuts** (from pin `2.4.0`; adjust if semver changes mid-track):
| Stage | ID | Cut |
|---|---|---|
@@ -128,6 +128,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
| 15 | inbound-antispam-panel | `2.1.0` (was `1.10.0`, reordered 2026-10-09) |
| 16 | delivery-log-storage | `2.2.0` |
| 17 | panel-notifications | `2.3.0` |
| 18 | from-header-binding | `2.4.0` |
Docs-only prep (checklists in plans, no product code) uses the same per-step
commit rule but **no** version cut until the next product stage ships.
+1 -1
View File
@@ -184,7 +184,7 @@ cat ./data/setup-token
#### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `2.3.0`. Intermediate
deliberately never `:latest`. The current pin is `2.4.0`. Intermediate
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
image was published. Pinning matters because of the backup version check (see
[Full backup and restore](#full-backup-and-restore)): the panel binary's
+85 -1
View File
@@ -1,6 +1,6 @@
# Plan: from-header-binding
**Status:** agreed 2026-10-12 - the design questions were closed with the owner one at a time and he took it into work
**Status:** shipped in `2.4.0` (2026-10-12); agreed the same day - the design questions were closed with the owner one at a time and he took it into work ("Да")
**Date:** 2026-10-12
**Version:** `2.4.0` MINOR (one migration; it refuses mail that was accepted before).
@@ -148,3 +148,87 @@ the fourth was not.
- [x] `go vet`, `go test`, e2e: refused for another domain's `From:`, for the hostname's and for an address off the list; accepted for its own (on the test server: vet ok, 23 packages ok, e2e ok - `sender_check_holds_the_from_line`, `panel_down_defers_mail`)
- [x] Technical review — **Opus** (2026-10-12; one Critical, fixed: a folded `From:` line - what a mail library makes of a long display name - was refused for good. Also fixed: a display name in a charset Go does not convert was refused; the statistics ran before the check served, and a hung `supervisorctl` would have kept it from serving; the count of deferred mail missed a check that stops answering in the middle of a message, and is now taken at Postfix's warning; a card whose figures could not be read said ok; a killed container's stale socket satisfied the wrapper's wait; outside quoted strings the line may now hold one `@` only. Three were first left as found and fixed the same evening at the owner's question: downtime is cut at the UTC midnights it crossed; the row of a message with the null envelope sender takes the domain the login is bound to; a log read again after an unclean stop does not count again what it counted before)
- [x] Security review — **Fable** (2026-10-12; no Critical or High. The same folded line, as its one Medium. Fixed: another sender's name in an encoded display name passed the second look; a client could arm the deferred counter by writing the socket's name where Postfix quotes it in a warning; a refused message wrote a row per recipient, a thousand at most - now one. Recorded: what a display name looks like to a person is not promised. The review also found the check stricter than Postfix for an address with an extension (`invoices+x@`, allowed in the envelope and refused in the header); the owner asked for it to work, and the check now makes Postfix's second try, with `recipient_delimiter` set explicitly. Confirmed from the code: no path to the signer around the check, and every failure of the check defers)
## Not done
Written at the owner's word when he had the version cut ("Запиши все
недоделки в план", 2026-10-12). Nothing here was promised and dropped in
silence; each line says what is missing and what it would take.
### Decided to wait
- **A process of its own for the check.** The owner: measure first. Server ›
Health now has the numbers; the decision is his when they have had time to
say something. Moving the role out is a change of where it is started -
a subcommand of the panel binary and a `[program:]` in supervisord - and
the downtime figure then needs a new meaning.
### Shown to the owner one way and built another
- **The Delivery log box is on a refused message's page.** The picture he
approved had left it out; the built page keeps it, with one sentence. And
the sentence is less than the truth for a `From:` refusal: cleanup did
write a line about it under a queue id (`milter-reject: END-OF-MESSAGE …
5.7.1 From: …`), but the check answers before the queue id reaches it, so
the row keeps none and the page cannot show that line. To do: either drop
the box for a refused row, or have the tailer attach the line to the row.
- **The Outbound log has no status filter.** The mockup draws one; the built
log filters by domain and application. With three kinds of refusal now
told apart by tag, a filter by status and reason would be worth having.
- **"Messages deferred" counts connections Postfix turned away.** Postfix
refuses from the connection on, before a message is offered, so a monitor
that only connects during an outage is counted. The guide says so; the
card's column head and the help topic still say "messages".
### Known and small
- **"Today" on the card is the UTC day**, and the card does not say so.
- **A deferral in the very second of the last one counted before an unclean
stop is passed over** at the restart: `mail.log` stamps whole seconds.
Chosen over counting a stretch of the log twice.
- **Deferrals are lost when `mail.log` is rotated while the panel is down**:
the tailer starts on the new file.
- **A start at which `supervisorctl` fails counts no downtime**; it is
logged.
- **Logins are compared as written**, Postfix compares them without regard
to case. The panel writes logins one way, so nothing differs today.
- **The phone order of the `cards` layout is written for four boxes to a
stack** and takes every child of a stack for a box (said at the partial).
The rule the technical review suggested instead - the order from the
box's own number - changes the component and would be drawn first.
- **Health's socket check looks at the file, not at who listens**: a socket
nobody answers on would read "ok". The start now removes stale sockets,
which was the one way to get such a file.
- **The kit page's table of status tags does not list the new ones**
(`wrong From`, `wrong client`, `rate-limited`), and `rate-limited` is grey
beside two red tags - as the mockup has it, not as a decision.
- **Refusal rows still in the buffer when the panel stops are not written**;
the refusals themselves were answered.
### Accepted, in security.md
- **The panel's downtime is mail's.** The price of the strict check.
- **What a display name looks like to a person is not promised.** A name of
another sender written without an `@` the scan can find passes, signed
with the application's own key.
- **A malformed packet on the check's socket ends the panel** (the milter
library panics on an empty one). The socket is reachable only inside the
container, by the group Postfix is in; the journal milter always had this,
and what is new is that the price is deferred mail.
### Not verified
- **OpenDKIM's reading of a `From:` line** was reasoned about, not run: its
parser is not in this repository. The two rules that do not depend on it -
no other signing name after any `@`, one `@` outside quotes - are what the
claim rests on. A test that feeds the stand's OpenDKIM the lines the check
passes and reads which key it signs with would settle it.
- **The pages were not looked at in a browser on the deployed panel**: I
cannot sign in there. They were rendered from the fixtures and
screenshotted, the polling and the phone order read from the code and
exercised by e2e only as far as the Health numbers.
- **No refusal has been seen on the test server itself**, only on the e2e
stand: there is no application there I can send as.
- **A check that stops answering in the middle of a message** is counted
from a warning composed from Postfix's source; the stand only produced the
case of nobody listening.
+9 -6
View File
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
| password-reset | Password reset by e-mail | candidate | — | — |
| phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | candidate | — | — |
| from-header-binding | Hold a message's `From:` header against the application that sends it | built and reviewed, for `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
@@ -47,7 +47,8 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
**inbound-antispam-panel** as `2.1.0` (2026-10-12), built in the new component
kit — styling its two screens once instead of twice. **delivery-log-storage**
shipped as `2.2.0` the same day, and **panel-notifications** as `2.3.0`.
shipped as `2.2.0` the same day, **panel-notifications** as `2.3.0` and
**from-header-binding** as `2.4.0`.
Nothing is agreed after it: every remaining
item is a candidate. panel-docs shipped in
[CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
@@ -440,8 +441,9 @@ since 2.3.0, the server's own name - into `From:` and have the message signed
with that name's key: mail that passes DMARC as a domain the application has
no right to, or that looks like a notification of the panel.
**Agreed with the owner on 2026-10-12** and taken into work
([plans/from-header-binding.md](plans/from-header-binding.md)). The rule he
**Agreed with the owner on 2026-10-12**, built and reviewed the same day
([plans/from-header-binding.md](plans/from-header-binding.md); what is not
done is listed there under *Not done*). The rule he
chose is wider than the one first written here: `From:` is held to exactly
what the envelope is held to - any address of the application's domain, or
the addresses of its list - and anything else is refused, a stranger's domain
@@ -454,8 +456,9 @@ needs a process of its own.
tenant's `From:`, for the hostname's and for an address off its list, and
accepted for its own.
**Version:** `2.4.0` MINOR; in progress. Until it ships:
[security.md](security.md) § Accepted risks.
**Version:** shipped `2.4.0` (2026-10-12), MINOR. What it costs - the
panel's downtime is now mail's - is in [security.md](security.md) § Accepted
risks.
---
+8
View File
@@ -476,6 +476,14 @@ deferred item from the roadmap.
downtime and the mail deferred for it. Trigger to revisit: those numbers
not staying at nothing - then the check moves into a process of its own
([plans/from-header-binding.md](plans/from-header-binding.md)).
- **A malformed packet on a milter socket of the panel ends the panel.** The
milter library indexes into an empty packet and nobody recovers the panic,
so the process exits and supervisord starts it again. The sockets are
reachable only inside the container, by the group Postfix is in (mode
0660); the journal milter always had this, and since `2.4.0` the seconds
of the restart defer mail instead of passing it unjournalled (technical
review, 2026-10-12). Trigger to revisit: anything untrusted able to open
a Unix socket in the container.
- **When the hostname is itself a sending domain,** the server's mail is
signed with that domain's key. Whoever may export the domain has the key,
and its applications may send as `postmaster@<hostname>` by right. The