from-header-binding: the sender check, what it costs, its pages; guide, architecture, security
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
A message's From: line is held against the application that sends it, under the rule Postfix holds the envelope sender to. OpenDKIM chooses the key by that line, so until now an application could have mail signed as another domain of the server or as the server itself. The check (internal/milter/sender.go) - A role of the panel on a socket of its own (SENDER_CHECK_SOCKET), first in smtpd_milters and strict: default_action=tempfail. The journal milter keeps its socket and stays fail-open. postfix-wrapper waits for the new socket. - It reads no database: what an application may send as comes from Postfix's own sender_login_maps, what the server signs for from OpenDKIM's SigningTable. Both are read again when they change; one that cannot be read defers mail. - One From: field, one address, allowed by the envelope's lookup; then no other name of the SigningTable may appear anywhere in the raw line. Refused: 550 5.7.1 naming the line, reduced to printable ASCII. - A refusal is a send-log row with reason "from" and the header address (migration 0006), written by another goroutine and not waited for. The journal's refusals record their reason too (limit, client), and the notifier takes only a limit's. What it costs (docs/plans/from-header-binding.md, Statistics) - The role marks itself alive every 2 s; at start the gap since the mark is downtime, counted from Postfix's start if later and not at all when supervisord says Postfix is not running. - The log tailer counts the connections Postfix turned away: a warning that names the socket, then milter-reject "Service unavailable" from the same smtpd. Seen on the stand: the client is refused from CONNECT on and leaves at AUTH (454), so no MAIL line is ever written. - sender_check_days, a row per UTC day, kept a year. Pages - Health: the card "Sender check", the socket's row, and the cards layout - boxes numbered by importance in two stacks read in rows; the left stack polls, the three readings of the right one are swapped in by id. - Outbound log: a refused row says why in its tag (wrong From, wrong client, rate-limited); the message's page shows the From: line and what the application may send as. - Help: topic "Sender check", word for word the mockup's. Found by the stand, fixed here: the names for the second look came from the sender bindings, and a domain with a key and no application yet was not among them. e2e: negative_journal_milter_fail_open is gone - it proved that mail passes with the panel down, which is no longer so. In its place sender_check_holds_the_from_line (another domain's From:, the hostname's, a stranger's, an address off the list, another sender in the display name - refused; own addresses - delivered) and panel_down_defers_mail (4xx with the panel stopped, the container alive, mail again after, downtime and one deferred message on Health, the card warning). Test server, this tree before the last cosmetic edits (a fixture row, a fact's size, docs): go vet ok, go test 23 packages ok, make e2e ok (337 s). Locally after them: internal/web/view ok. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+88
-15
@@ -27,13 +27,13 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
|
||||
| Program | User | Priority | Role |
|
||||
|---|---|---|---|
|
||||
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter |
|
||||
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer + rate-limit recalc; with the inbound relay on also the inbound journal milter |
|
||||
| `postfix` | root (wrapper) | 300 | MTA — started only after the milter sockets exist (OpenDKIM's, the journal milter's and, with the inbound relay on, the inbound journal milter's) |
|
||||
| `panel` | panel | 200 | HTTP UI + sender check + journal-milter + log-tailer + rate-limit recalc; with the inbound relay on also the inbound journal milter |
|
||||
| `postfix` | root (wrapper) | 300 | MTA — started only after the milter sockets exist (OpenDKIM's, the sender check's, the journal milter's and, with the inbound relay on, the inbound journal milter's) |
|
||||
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
|
||||
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
|
||||
| `logrotate` | root | 400 | Periodic `mail.log` rotation |
|
||||
|
||||
Start order: OpenDKIM → panel (opens journal-milter socket) → Postfix wrapper
|
||||
Start order: OpenDKIM → panel (opens the sender check's and the journal-milter's sockets) → Postfix wrapper
|
||||
polls unix sockets (timeout `MILTER_WAIT_TIMEOUT`, default 30s) then
|
||||
`postfix start-fg`.
|
||||
|
||||
@@ -50,6 +50,7 @@ panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe.
|
||||
```
|
||||
Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
|
||||
│
|
||||
├─► sender check (From: against the login, tempfail on failure)
|
||||
├─► OpenDKIM milter (sign, tempfail on failure)
|
||||
├─► journal-milter (send log + L2 rate limits, fail-open)
|
||||
└─► outbound MX delivery (port 25 client)
|
||||
@@ -144,7 +145,9 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
|
||||
`INBOUND_RATE_LIMIT_MESSAGES_PER_IP` and message size uses the greater of
|
||||
`INBOUND_MESSAGE_SIZE_LIMIT` and `DMARC_MESSAGE_SIZE_LIMIT`.
|
||||
- **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`;
|
||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`. That holds the
|
||||
envelope sender; the `From:` header is held to the same table by the
|
||||
panel's sender check (§ Panel roles).
|
||||
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
|
||||
`anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter.
|
||||
- **Chroot disabled** for all services (DNS/TLS inside container).
|
||||
@@ -183,7 +186,9 @@ One process, five roles (six with the inbound relay on):
|
||||
numbers; they never call `postconf` per request. If `postconf` is missing,
|
||||
the panel logs a warning and uses Postfix 3.x compiled-in defaults
|
||||
(`300s` / `4000s` / `5d` / `0`) with a muted note on the box.
|
||||
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||
2. **sender check** — unix socket `SENDER_CHECK_SOCKET`, first in the chain
|
||||
and **strict** (`default_action=tempfail`): § Sender check below.
|
||||
3. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
||||
(`default_action=accept`) so milter failure does not stop mail. Domain
|
||||
ceilings apply to every client IP; an application ceiling **overrides** the
|
||||
@@ -195,20 +200,85 @@ One process, five roles (six with the inbound relay on):
|
||||
this process has admitted but not yet written (`internal/milter/inflight.go`),
|
||||
so concurrent sessions cannot each spend the same last slot; a reservation
|
||||
is released at end-of-message, on ABORT, or after a 10-minute TTL.
|
||||
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
||||
4. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
||||
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
||||
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
||||
It also keeps the lines Postfix writes under a queue id, for the message's
|
||||
page (§ Log tailer, *Stored lines*).
|
||||
4. **rate-limit recalc** — every six hours (and on demand from Domain settings and
|
||||
page (§ Log tailer, *Stored lines*), and counts the mail Postfix deferred
|
||||
because the sender check did not answer.
|
||||
5. **rate-limit recalc** — every six hours (and on demand from Domain settings and
|
||||
the application form), recomputes level-2 **Auto** rate limits from send-log statistics.
|
||||
5. **notifier** — writes to the panel's users when something needs them
|
||||
6. **notifier** — writes to the panel's users when something needs them
|
||||
(§ Notifications below).
|
||||
|
||||
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
|
||||
Mail submitted locally with `sendmail(1)` — the notifier's — passes OpenDKIM
|
||||
alone (`non_smtpd_milters`): it is signed, and it is not an application's
|
||||
mail to be journalled or limited. Bounces Postfix generates pass no milter.
|
||||
Milter chain in Postfix: the sender check (tempfail), OpenDKIM (tempfail),
|
||||
then the journal (accept on failure). Mail submitted locally with
|
||||
`sendmail(1)` — the notifier's — passes OpenDKIM alone (`non_smtpd_milters`):
|
||||
it is signed, it is the panel's own and so needs no check of whose it is, and
|
||||
it is not an application's mail to be journalled or limited. Bounces Postfix
|
||||
generates pass no milter.
|
||||
|
||||
### Sender check
|
||||
|
||||
[internal/milter/sender.go](../internal/milter/sender.go) holds a message's
|
||||
`From:` header against the application that sends it
|
||||
([plans/from-header-binding.md](plans/from-header-binding.md)). Postfix holds
|
||||
the envelope sender against the SASL login, and OpenDKIM chooses the signing
|
||||
key by the header; until `2.4.0` nothing connected the two, and an
|
||||
application could have mail signed as another domain of the server or as the
|
||||
server itself.
|
||||
|
||||
- **The rule is the envelope's.** The check reads the file Postfix reads for
|
||||
`smtpd_sender_login_maps` and makes the same lookup: the address itself is
|
||||
bound to the login, or `@domain` is. So an application set to any address of
|
||||
its domain may write any address of that domain, and one with a list only
|
||||
the listed ones; everything else is refused - another registered domain,
|
||||
the server's name, a domain the server knows nothing of. One source for
|
||||
both senders: the two cannot disagree.
|
||||
- **How the header is read.** Exactly one `From:` field (none, or more than
|
||||
one, is refused), parsed as an address list that must hold exactly one
|
||||
address. Then a cruder second look at the raw value: OpenDKIM finds the
|
||||
address with a parser of its own, and wherever the two could read a line
|
||||
differently - a display name, a comment, a quoted string - the other
|
||||
address would have to be spelled out in it. So no `@name` may appear there
|
||||
that is another name the server signs for; those names are read from
|
||||
OpenDKIM's own `SigningTable`.
|
||||
- **The verdict** comes at the end of the headers: `550 5.7.1`, permanent,
|
||||
naming the line, as a reply code of the milter's own. What the client wrote
|
||||
is reduced to printable ASCII and cut before it is said back, logged or
|
||||
stored. A message with no SASL login is refused as well; the submission
|
||||
ports do not let one this far.
|
||||
- **Strict, and therefore small.** Postfix attaches the socket with
|
||||
`default_action=tempfail`: while the panel does not answer on it within the
|
||||
milter timeouts, mail is deferred, not accepted unchecked. The role
|
||||
therefore reads no database. Its two inputs are files the panel rewrites by
|
||||
rename and the mail path itself goes by; they are read again when their
|
||||
time or size has changed, and a file that cannot be read defers mail - a
|
||||
table from before would let a deleted application go on sending. The
|
||||
send-log row of a refusal (`status=rejected`, `reason=from`,
|
||||
`header_from`) is handed to another goroutine through a buffer and is not
|
||||
waited for.
|
||||
- **What it costs is counted.** The role marks itself alive every two
|
||||
seconds (the socket's path with `.alive`, under `/run`, so a new container
|
||||
starts without one). At start the time since the mark is the check's
|
||||
downtime - counted from Postfix's own start if that was later, and not at
|
||||
all when supervisord says Postfix is not running, since mail was then
|
||||
stopped for another reason. The log tailer counts the mail: Postfix
|
||||
writes a warning naming the socket and then, from the same smtpd process,
|
||||
`milter-reject: … 451 4.7.1 Service unavailable` from the connection on
|
||||
(the client is told `454 4.3.0` at `AUTH` and leaves), so what is counted
|
||||
is a connection turned away, once - a client brings a message when it
|
||||
connects. The reject line does not name the milter and the journal milter's
|
||||
level-2 refusal reads the same, hence the warning. Both go into
|
||||
`sender_check_days`, a
|
||||
row per UTC day kept a year, and Server › Health shows today, 30 days and
|
||||
12 months. The second figure also sees a panel that is running and not
|
||||
answering, which the mark does not. A log read twice after an unclean stop
|
||||
can count a message twice.
|
||||
- **Why it is not a process of its own.** Decided with the owner: measure
|
||||
first. The role has a socket of its own so that the journal stays
|
||||
fail-open, and moving it out later changes where it is started and nothing
|
||||
else.
|
||||
|
||||
### Notifications
|
||||
|
||||
@@ -229,7 +299,9 @@ is sent; the operator's view is guide § Notifications.
|
||||
- **A refused row is not believed whole.** Its domain is the one in
|
||||
`MAIL FROM`, which the client typed and Postfix has not yet held against
|
||||
the login when the milter refuses; the same status is written when an
|
||||
application is refused for its client address. So refusals are counted by
|
||||
application is refused for its client address or its `From:` line. Since
|
||||
`2.4.0` a row says which (`send_log.reason`) and only a limit's rows are
|
||||
taken; a row from before has no reason. So refusals are counted by
|
||||
the domain the login is registered under, only for logins that exist, and
|
||||
only while a level-2 limit is set on the login or its domain
|
||||
(`Store.LimitRefusalsSince`). One subject per sending domain, whichever of
|
||||
@@ -808,7 +880,8 @@ unsupported rather than as a missing doc:
|
||||
- **Panel paths and tuning:** `SELFPOST_DATA_DIR` (`/data`), `SELFPOST_DB_PATH`
|
||||
(`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE`
|
||||
(`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`),
|
||||
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`), `MAIL_LOG`
|
||||
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`),
|
||||
`SENDER_CHECK_SOCKET` (`/run/selfpost/sender.sock`), `MAIL_LOG`
|
||||
(`/data/log/mail.log` — read by the panel and written by Postfix, so a change
|
||||
here has to be matched in `build/postfix-config.sh`),
|
||||
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET`
|
||||
|
||||
+57
-10
@@ -377,7 +377,7 @@ and reverse DNS. A check the panel could not run shows as a warning, never as
|
||||
fine. Below the cards are the outbound domain list and, with the inbound relay
|
||||
on, the inbound one. Each card leads to its table on **Server › Health**
|
||||
(`/server/health`) — the queue card to the Outbound queue — and Health has the
|
||||
readings:
|
||||
readings, in the order of what stops mail first:
|
||||
|
||||
- **Machine** — processor (core and thread counts), memory and swap, and
|
||||
per-interface network throughput and totals, read from the kernel's
|
||||
@@ -386,7 +386,16 @@ readings:
|
||||
memory is a warning, because both delay or kill the mail path; throughput is
|
||||
only reported.
|
||||
- **Processes**, **TLS certificate** (validity and expiry) and **Milter
|
||||
sockets** (OpenDKIM and the journal-milter).
|
||||
sockets** (OpenDKIM, the sender check and the journal-milter).
|
||||
- **Sender check** — what the strict check of the `From:` line has cost
|
||||
today, in the last 30 days and in the last 12 months: for how long the
|
||||
panel did not answer while Postfix was running, and how many messages
|
||||
Postfix deferred because of it (see [Who an application may send
|
||||
as](#who-an-application-may-send-as)). A restart of the whole container is
|
||||
not counted: mail was stopped for another reason then. The card warns when
|
||||
a message was deferred in the last 24 hours. If these numbers do not stay
|
||||
at nothing, the panel is in the way of your mail more than it should be -
|
||||
report it.
|
||||
- **Hostname and reverse DNS** — compares `SELFPOST_HOSTNAME` against the PTR
|
||||
record the internet publishes for this server's IP (forward-confirmed
|
||||
reverse DNS), see [Server-level DNS](#server-level-dns-ptrrdns); use
|
||||
@@ -1076,6 +1085,38 @@ to a domain:
|
||||
level 2](#rate-limiting--level-2-domain-and-application)), *Export domain*
|
||||
and, for the global role, *Delete domain*, which asks once more.
|
||||
|
||||
#### Who an application may send as
|
||||
|
||||
An application sends as any address of its domain, or only as the addresses
|
||||
listed for it. Since `2.4.0` that rule covers both senders a message has:
|
||||
|
||||
- the **envelope sender** (`MAIL FROM`), which Postfix checks;
|
||||
- the **`From:` line** of the message - the one a mail program shows and the
|
||||
one the DKIM signature is chosen by - which the panel checks.
|
||||
|
||||
A message whose `From:` is anything else is refused with `550 5.7.1` and a
|
||||
text naming the line: another of your domains, the server's own name, an
|
||||
address at a domain that is not on this server, a line with two addresses,
|
||||
or no `From:` at all. A name this server signs for may not appear anywhere
|
||||
in the line besides the address itself, a display name included. The refusal
|
||||
is permanent, so the application does not send the message again; it is a
|
||||
row of the [Outbound log](#outbound-log) tagged `wrong From`, and the row's
|
||||
page shows what the line said.
|
||||
|
||||
What this stops working when you upgrade from an earlier version:
|
||||
|
||||
- an application that sends with the `From:` of another of your domains -
|
||||
give that domain an application of its own;
|
||||
- an application with a list whose envelope address and `From:` address
|
||||
differ - add the second address to the list;
|
||||
- a contact form that puts its visitor's address into `From:` - put it into
|
||||
`Reply-To:` and send from an address of your own, which is also what
|
||||
DMARC requires.
|
||||
|
||||
The check is strict: while the panel does not answer, Postfix replies `451`
|
||||
and applications send again later; nothing is accepted unchecked. **Server ›
|
||||
Health** counts how often that happens.
|
||||
|
||||
*Export domain* writes a single-domain file; *Import a domain* on *Server ›
|
||||
Backup* reads one back in (**global role only**) — see [Exporting and importing
|
||||
a single domain](#exporting-and-importing-a-single-domain).
|
||||
@@ -1149,8 +1190,8 @@ rate limits, and it takes exact addresses only — CIDR ranges are not parsed.
|
||||
|
||||
It is **not an authentication boundary.** The SASL login itself still succeeds;
|
||||
the check runs in the journal-milter, on the same fail-open path as level 2
|
||||
(below), so if the milter is unavailable the restriction is skipped rather than
|
||||
enforced. Use it to narrow where an application may send from, not to contain a
|
||||
(below), so if that milter does not answer the restriction is skipped rather
|
||||
than enforced. Use it to narrow where an application may send from, not to contain a
|
||||
leaked password — for that, regenerate the password.
|
||||
|
||||
**Level 2 — application** — optional override of the domain limit for one
|
||||
@@ -1173,10 +1214,12 @@ hours and on demand via **Recalculate now**.
|
||||
journal-milter and is deliberately fail-open: if the rate-limit lookup hits
|
||||
a store error, or the connecting client's IP is not available to the
|
||||
milter, level 2 is skipped and the message is accepted rather than held up.
|
||||
The same applies if the milter itself is down — Postfix is configured to
|
||||
accept rather than defer when it cannot reach it. Level 1 is the backstop
|
||||
that keeps working even when level 2 cannot run, and it is the only one of
|
||||
the two that does not depend on the panel process.
|
||||
The same applies if the journal-milter itself does not answer — Postfix is
|
||||
configured to accept rather than defer when it cannot reach it. (Since
|
||||
`2.4.0` a panel that is down altogether defers mail anyway, through the
|
||||
[sender check](#who-an-application-may-send-as) of the same process.) Level 1
|
||||
is the backstop that keeps working even when level 2 cannot run, and it is
|
||||
the only one of the two that does not depend on the panel process.
|
||||
|
||||
### Outbound log
|
||||
|
||||
@@ -1186,8 +1229,12 @@ rows of their domains. It refreshes its rows without resetting a filter being
|
||||
chosen. A row identifies its message and nothing more — time,
|
||||
sender, recipient, subject and status `queued` (accepted, not yet
|
||||
delivered), `sent` (handed off successfully), `deferred` (Postfix is
|
||||
retrying), `bounced` (final failure), or `rejected` (refused — for example
|
||||
by a [level-2 rate limit](#rate-limiting--level-2-domain-and-application));
|
||||
retrying), `bounced` (final failure), or refused before it was queued. A
|
||||
refused row says why in its tag: `rate-limited` (a [level-2 rate
|
||||
limit](#rate-limiting--level-2-domain-and-application)), `wrong client` (the
|
||||
application's client IP list) or `wrong From` (see [Who an application may
|
||||
send as](#who-an-application-may-send-as)); a row refused before `2.4.0`
|
||||
says `rejected`;
|
||||
*Details* opens that row's own page (`/outbound/log/{id}`). That page carries
|
||||
the sending domain, the application it was submitted under, the Postfix
|
||||
queue id and the journal id, beside the message's history — when it was
|
||||
|
||||
@@ -80,7 +80,8 @@ in the last 24 hours; downtime that deferred nothing warns of nothing.
|
||||
- **In the Outbound log:** a row with the reason in the status tag -
|
||||
`wrong From`, beside `rate-limited` and the tag of a refused client address.
|
||||
The built panel wrote `rejected` for all of them; it follows the mockup now.
|
||||
The status filter keeps one entry, `rejected`.
|
||||
(The mockup's status filter is not built - the log filters by domain and
|
||||
application - so there is nothing to keep in step with the tags.)
|
||||
- **On the message's page:** a fact *From: line* with the address written in
|
||||
the header, and a History step that says which address was refused, for
|
||||
which application, what that application may write, and that it was told
|
||||
@@ -101,7 +102,30 @@ the fourth was not.
|
||||
beside a tall one and the eye still goes 01, 02, 03. On a phone the boxes
|
||||
come in the order of their numbers.
|
||||
|
||||
## To settle while building
|
||||
## Settled while building
|
||||
|
||||
- **The header is read twice.** One `From:` field holding one address, by
|
||||
Go's parser; then every `@name` of the raw value is held against OpenDKIM's
|
||||
own `SigningTable`. The first build took the names for the second look from
|
||||
the sender bindings, and the e2e stand let a line through that named a
|
||||
domain with a key and no application yet: a domain is a signing name from
|
||||
the moment it is added, not from its first application.
|
||||
- **What Postfix writes when the check does not answer** (seen on the stand):
|
||||
a warning naming the socket, then `milter-reject: CONNECT … 451 4.7.1
|
||||
Service unavailable` and the same for `EHLO`; the client is told `454 4.3.0
|
||||
Try again later` at `AUTH` and leaves. No `MAIL` is ever offered, so what
|
||||
is counted is the connection turned away, once. The journal milter's
|
||||
level-2 refusal is written in the same words, which is why a reject line
|
||||
counts only after the warning.
|
||||
- **When Postfix started** is asked of supervisord (`supervisorctl status`,
|
||||
the uptime of `postfix`).
|
||||
- **The reply** is a milter reply code, `550 5.7.1 From: <line> - <why>`; what
|
||||
the client wrote is reduced to printable ASCII and cut first.
|
||||
- **The Delivery log box stays on a refused message's page**, with the one
|
||||
sentence it already had for a message that never reached the queue; the
|
||||
picture shown to the owner had left the box out.
|
||||
|
||||
## To settle while building (as first written)
|
||||
|
||||
- How the header is read so that the check and OpenDKIM cannot disagree: one
|
||||
`From:` field, one address, and a cruder second look at the raw value.
|
||||
@@ -116,11 +140,11 @@ the fourth was not.
|
||||
## Implementation checklist
|
||||
|
||||
- [x] Mockups: Health card and layout, the log row, help topic; accepted by the owner 2026-10-12 (`design:` commit)
|
||||
- [ ] Migration and store: refusal reason and header address, days of the sender check
|
||||
- [ ] The check: a role of the panel on its own socket, bindings read from Postfix's file; Postfix attaches it strictly; start order
|
||||
- [ ] Statistics: the alive mark and downtime, deferred mail from `mail.log`
|
||||
- [ ] Panel: Health (card, sockets row, layout), Outbound log tags, the message's page, help
|
||||
- [ ] guide.md, architecture.md, security.md (the accepted risk goes), schema-migrations.md
|
||||
- [ ] `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
|
||||
- [x] Migration and store: refusal reason and header address, days of the sender check (`0006_sender_check.sql`)
|
||||
- [x] The check: a role of the panel on its own socket, bindings read from Postfix's file; Postfix attaches it strictly; start order
|
||||
- [x] Statistics: the alive mark and downtime, deferred mail from `mail.log`
|
||||
- [x] Panel: Health (card, sockets row, layout), Outbound log tags, the message's page, help
|
||||
- [x] guide.md, architecture.md, security.md (the accepted risk goes), schema-migrations.md
|
||||
- [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`)
|
||||
- [ ] Technical review — **Opus**
|
||||
- [ ] Security review — **Fable**
|
||||
|
||||
+1
-1
@@ -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 | in progress, for `2.4.0` | 1/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 | in progress, for `2.4.0` | 7/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) |
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ When you add or review a migration, update this file in the same change.
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Chain head | `user_version = 5` |
|
||||
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql`, `0005_send_log_rejected_index.sql` |
|
||||
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql`, `0005_send_log_rejected_index.sql`, `0006_sender_check.sql` |
|
||||
| File mark | `PRAGMA application_id = 0x53503230` ("SP20") |
|
||||
| Database file | `/data/selfpost.db` (bind mount) |
|
||||
| Compatibility | 2.x MINOR releases must boot a `2.0.0` data directory. **Nothing is carried over from 1.x.** |
|
||||
@@ -63,8 +63,9 @@ again and no file is deleted, renamed or reordered — every 2.x database has
|
||||
already run it. `2.0.0` and `2.0.1` shipped with the baseline alone; the
|
||||
features after them add files, the first being `0002_inbound_spam_log.sql` of
|
||||
inbound-antispam-panel (`2.1.0`), then `0003_send_log_lines.sql` of
|
||||
delivery-log-storage (`2.2.0`), and `0004_notifications.sql` and
|
||||
`0005_send_log_rejected_index.sql` of panel-notifications (`2.3.0`).
|
||||
delivery-log-storage (`2.2.0`), `0004_notifications.sql` and
|
||||
`0005_send_log_rejected_index.sql` of panel-notifications (`2.3.0`), and
|
||||
`0006_sender_check.sql` of from-header-binding (`2.4.0`).
|
||||
|
||||
---
|
||||
|
||||
@@ -77,6 +78,7 @@ delivery-log-storage (`2.2.0`), and `0004_notifications.sql` and
|
||||
| 3 | `0003_send_log_lines.sql` | 2.2.0 | DDL | `send_log_lines` and one index; nothing existing changes |
|
||||
| 4 | `0004_notifications.sql` | 2.3.0 | DDL | `users.notify` (default 1: every user starts with notifications on) and `notify_state` |
|
||||
| 5 | `0005_send_log_rejected_index.sql` | 2.3.0 | DDL | `idx_send_log_rejected`, a partial index of the refused rows of `send_log` by time; a file of its own because the test installation had run 0004 before the review asked for it |
|
||||
| 6 | `0006_sender_check.sql` | 2.4.0 | DDL | `send_log.reason` and `send_log.header_from` (both default `''`: a row refused earlier keeps no reason), and `sender_check_days` |
|
||||
|
||||
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
|
||||
for operators upgrading from older 2.x images.
|
||||
|
||||
+38
-20
@@ -188,6 +188,24 @@ operator can act on it.
|
||||
|
||||
- **Not an open relay** — SASL only on 465/587; `reject_unauth_destination`;
|
||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||
- **An application signs only as itself (`2.4.0`).** OpenDKIM chooses the
|
||||
key by the `From:` header and Postfix holds only the envelope sender
|
||||
against the login, so until `2.4.0` an application of one sending domain
|
||||
could write another registered domain - or, since `2.3.0`, the server's
|
||||
own name - into `From:` and have the message signed with that name's key
|
||||
(found by the security review of panel-notifications, 2026-10-12). The
|
||||
panel's sender check now holds the header to the table the envelope is
|
||||
held to and refuses anything else with `550 5.7.1`, a domain the server
|
||||
does not sign for included (the owner's rule: the header may say what the
|
||||
envelope may, nothing more). It is the first milter of the chain and
|
||||
strict: a message it could not judge is deferred, never passed on to the
|
||||
signer. It reads the header as one field holding one address and then
|
||||
looks at the raw value once more for any other name of OpenDKIM's
|
||||
`SigningTable`, so that a line its parser and OpenDKIM's could read
|
||||
differently is refused rather than argued about. What the client wrote
|
||||
reaches the reply, the log and the send log only as printable ASCII, cut.
|
||||
Mail the panel submits itself does not pass the check: only root,
|
||||
`postfix` and the panel can submit locally. Architecture § Sender check.
|
||||
- **Inbound relay (optional, `INBOUND_RELAY_ENABLE`)** — port 25 is not an
|
||||
open relay either: SASL is off; `smtpd_relay_restrictions` /
|
||||
`smtpd_recipient_restrictions` are `reject_unauth_destination` and
|
||||
@@ -353,11 +371,14 @@ deferred item from the roadmap.
|
||||
changes, rate limiting, alerting an operator into a config change).
|
||||
- **The application auth-IP allow-list is enforced fail-open.** The check runs
|
||||
in the journal milter (`internal/milter/authip.go`) with
|
||||
`default_action=accept` and skips on store errors, so if the milter is down
|
||||
the restriction is not applied; the SASL login itself always succeeds.
|
||||
(While the milter is up the list holds for every message, and an unknown
|
||||
client address is refused - see Mail path; what is accepted here is the
|
||||
milter being down.) This
|
||||
`default_action=accept` and skips on store errors, so if the journal milter
|
||||
does not answer the restriction is not applied; the SASL login itself
|
||||
always succeeds. (While the milter is up the list holds for every message,
|
||||
and an unknown client address is refused - see Mail path; what is accepted
|
||||
here is the milter not answering. Since `2.4.0` a panel that is down
|
||||
altogether defers mail through the sender check, which shares its process;
|
||||
what is left of this window is a journal milter that hangs or a database
|
||||
that errs while the check beside it still answers.) This
|
||||
is documented in [guide.md](guide.md) as **not an authentication boundary** —
|
||||
it is a blast-radius limiter for a leaked application password, on the same
|
||||
fail-open path as level-2 rate limiting, and a milter outage already stops
|
||||
@@ -436,21 +457,18 @@ deferred item from the roadmap.
|
||||
a dozen times a day at most by someone who flaps the zone; a domain's
|
||||
limits once a day; and no more than 60 messages leave in any hour whatever
|
||||
the rules arrive at.
|
||||
- **A notification can be forged by an application, signed as the server.**
|
||||
OpenDKIM chooses the key by the `From:` header, and Postfix holds only the
|
||||
envelope sender against the login. An authenticated application can
|
||||
therefore send a message whose header says `From: postmaster@<hostname>` and
|
||||
have it signed with the key of the server's own name - a message that
|
||||
passes DMARC and looks like the panel's. The same has always let an
|
||||
application of one sending domain write another registered domain into
|
||||
`From:` and get that domain's signature; the server's name is one more name
|
||||
it works for. Accepted for now: applications are the operator's own, and a
|
||||
forged notification can ask for nothing the panel acts on - it carries no
|
||||
link and the panel takes no mail. The fix is to hold the `From:` header
|
||||
against the login in the journal milter, which refuses mail and is a
|
||||
decision of its own ([roadmap](roadmap.md) § from-header-binding). Trigger
|
||||
to revisit: applications handed to anyone the operator does not trust with
|
||||
every domain of the instance.
|
||||
- **The sender check stops mail while the panel does not answer.** It is a
|
||||
role of the panel process, attached with `default_action=tempfail`, so a
|
||||
panel that is down or slower than the milter timeouts (15 s a command)
|
||||
defers every submission: availability of sending now depends on the panel,
|
||||
which it did not before `2.4.0`. Chosen by the owner over the alternative,
|
||||
a check that is absent whenever the panel is - that one could be switched
|
||||
off by whoever can make the panel slow. Supervisord restarts the panel at
|
||||
once and takes the container down after three failed starts, so the
|
||||
outage is seconds or the whole instance; Server › Health counts the
|
||||
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)).
|
||||
- **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
|
||||
|
||||
Reference in New Issue
Block a user