diff --git a/build/Dockerfile b/build/Dockerfile index b0bd4c5..f975001 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -76,7 +76,9 @@ RUN groupadd --system --gid 998 selfpost \ # with `no-new-privileges`, which disables setgid transitions, so the panel # would always see "Permission denied". Making `panel` a real member of postdrop # grants the same access without relying on a setgid escalation the hardening -# deliberately forbids. postdrop membership is read-side only: it does not let +# deliberately forbids. The same membership is what lets the panel submit its +# own notifications with sendmail(1) - postdrop, too, is setgid and would fail +# to write the maildrop directory (internal/notify/mailer.go). It does not let # the panel bypass any Postfix restriction that a local user does not already # have through the world-executable sendmail. RUN usermod -aG postdrop panel diff --git a/docs/architecture.md b/docs/architecture.md index c66b075..2bba96d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -156,9 +156,17 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the Per-domain keys under `/data/opendkim/keys`; `KeyTable` / `SigningTable` maintained by the panel. Socket `/run/opendkim/opendkim.sock`. +The server's own name (`SELFPOST_HOSTNAME`) has a key and a line in both +tables too, without being a row of the registry +(`domain.Service.SetServerName`): the panel's notifications are sent as +`postmaster@` that name and signed with it. No application can send as it - +the sender map is built from the registry. A sending domain of the same name +shares its key and selector with the server, and deleting that domain leaves +the key in place. + ### Panel binary ([cmd/panel](../cmd/panel)) -One process, four roles: +One process, five roles (six with the inbound relay on): 1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse proxy only. On start it runs `postconf -h` once for the deferred-mail retry @@ -188,8 +196,51 @@ One process, four roles: page (§ Log tailer, *Stored lines*). 4. **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 + (§ 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. + +### Notifications + +[internal/notify](../internal/notify/notify.go) reports four things to the +users of the panel: a sending domain's DNS record that stopped matching, a +level-2 rate limit that refused mail, the TLS certificate about to expire, a +message more than three days in the queue. The rules of when a message goes +out are the owner's and are written in +[plans/panel-notifications.md](plans/panel-notifications.md) § When a message +is sent; the operator's view is guide § Notifications. + +- **Readings.** Once an hour, starting three minutes after the process does: + every sending domain's records through a DNS checker of the notifier's own, + forced past the cache (`cmd/panel/notifier.go` builds the same query a + domain's page does); the certificate file; `postqueue -j` for the arrival + time of each queued message. Once a minute: the send log's `rejected` rows + of the last day, per domain and application. +- **State** is a row per subject in `notify_state` (`dns:`, `cert`, + `queue`, `limit::`), which is what makes each thing be said + once and a restart say nothing again. A DNS subject goes *pending* on the + first bad check and *open*, with a message, on the second; back the same + way. The certificate's row remembers which threshold was said for which + expiry. A deleted domain's row goes with it. +- **No information is not a finding.** A lookup that did not answer, a queue + that cannot be listed, a certificate that cannot be read move no state. +- **Recipients** come from the store at the moment of sending: global users + for everything, domain users for subjects that are a domain's; only users + with an e-mail and their own switch on. One message per recipient, so users + do not see each other's addresses. +- **The instance switch** (settings key `notifications`, on unless turned + off) stops the checks themselves, not only the mail: no state moves while + it is off. +- **Sending** is `sendmail -i -f postmaster@ --
` as the + unprivileged panel user, which is a member of `postdrop` and so can write + the maildrop directory without the setgid bit `no-new-privileges` forbids. + A message that could not be handed over leaves the state where it was and + is tried at the next check. The text names the page to open and has no + link: the panel does not know its own address. ### Log tailer and `mail.log` rotation diff --git a/docs/guide.md b/docs/guide.md index 8e8e886..0849076 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -33,6 +33,7 @@ changed (see the CHANGELOG, *Removed*). - [Queue and System log](#queue-and-system-log) - [Account](#account) - [Settings](#settings) + - [Notifications](#notifications) - [Users](#users) - [Sessions](#sessions) - [Upgrading](#upgrading) @@ -419,10 +420,11 @@ readings: **Account** (`/account`, every role) is the signed-in user's own: **Profile** (username and e-mail) and **Password**. Saving the profile does not ask for the password; changing the password asks for the current one and is rate-limited -like a sign-in. The e-mail is the user's own address. Nothing is sent to it -yet; it is what a domain's *Use my e-mail* button fills in (see [Domain-level -DNS](#domain-level-dns-spf-dkim-dmarc)). Application SASL logins are separate -and are not changed here. +like a sign-in. The e-mail is the user's own address: it is where the panel +sends [notifications](#notifications) while *Send me notifications* is ticked +beside it, and what a domain's *Use my e-mail* button fills in (see +[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)). Application SASL logins +are separate and are not changed here. ### Settings @@ -431,7 +433,53 @@ the whole instance: how many days the **Outbound log** keeps its rows before the background sweep deletes them (7–365 days; takes effect on the next six-hour prune cycle without a container restart), and, read-only, the level-1 rate limit that comes from `.env` ([Rate limiting — level 1](#rate-limiting--level-1-ip-backstop)). -There is no panel-wide DMARC report address: each domain has its own. +There is no panel-wide DMARC report address: each domain has its own. The page +also has the switch for [notifications](#notifications) and the two DNS records +that make them arrive. + +### Notifications + +The panel writes to its users when something needs them. Four things are +reported, each once: + +| What | When a message is sent | A second message | +|---|---|---| +| A sending domain's DKIM, SPF or DMARC record stops matching | after two hourly checks in a row found it wrong. A resolver that did not answer is not a mismatch; a missing DMARC record is advice on the domain's page, not a notification | when two checks in a row find the records right again | +| A level-2 rate limit refuses mail | within a minute of the first refusal | at most once a day for the same domain and application, while it goes on | +| The TLS certificate is about to expire | 14 days before | 3 days before, if it is still the same certificate | +| A message has waited in the queue for more than three days | at the next hourly check | when no message that old is left | + +A user with the **global** role is told all of it; a user with the **domain** +role is told about the DNS records and rate limits of the outbound domains +assigned to them. A message goes to the e-mail on the user's +[Account](#account) page and only while *Send me notifications* is ticked +there; a user without an e-mail gets none. **Server › Settings** has one switch +that turns notifications off for everyone. With it off nothing is checked, so +what is wrong when it is turned on again is reported then. + +Notifications are the panel's own mail. They are sent from +`postmaster@`, one message per recipient, are not rows of +the Outbound log and count against no rate limit. They name the page of the +panel to open and carry no link: the panel does not know the address its +reverse proxy serves it under. + +**Two DNS records make them arrive.** The server's own name is not one of your +sending domains, so it needs records of its own, shown with the values to +publish under **Server › Settings › Notification mail**: + +- a **DKIM** key for the hostname, which the panel generates and signs + notifications with (`._domainkey.`, TXT); +- an **SPF** record for the hostname: `v=spf1 a -all` (TXT). + +**Server › Health** shows whether each is in place in the *Notification mail* +card. Without them notifications are still sent, and a receiver may file them +as spam or, if the parent domain publishes a strict DMARC policy, refuse them. +Mail of your sending domains depends on neither. If the hostname is itself one +of your sending domains, the records are that domain's and are already +published. + +A relay that cannot send cannot say so: none of the four is "the relay is +down". Watch that from outside ([Container health](#container-health)). ### Users diff --git a/docs/plans/panel-notifications.md b/docs/plans/panel-notifications.md index d2e70ad..f1f242e 100644 --- a/docs/plans/panel-notifications.md +++ b/docs/plans/panel-notifications.md @@ -111,9 +111,9 @@ to the owner before any code ([panel-redesign.md](panel-redesign.md) - [x] Migration and store: user flag, instance switch, event state - [x] Sending: local submission from `postmaster@`, message text (`sendmail` as the panel user; signed through `non_smtpd_milters` with a key of the server's own name; no link in the text - the panel does not know its own address) - [x] Background checker: DNS, certificate, queue; the rules of *When a message is sent* -- [x] Level-2 refusal event (from the `rejected` rows of the send log, looked at every five minutes) +- [x] Level-2 refusal event (from the `rejected` rows of the send log, looked at every minute) - [ ] Panel: Account, Settings, Health check, help -- [ ] guide.md, architecture.md, security.md, schema-migrations.md +- [x] guide.md, architecture.md, security.md, schema-migrations.md - [ ] `go vet`, `go test`, e2e: an event reaches a mailbox once and clears once - [ ] Technical review - [ ] Security review diff --git a/docs/roadmap.md b/docs/roadmap.md index 11907ad..cb2d801 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -35,7 +35,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — | | structured-logging | `log/slog` with levels and fields | candidate | — | — | | review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | — | -| panel-notifications | E-mail notifications about important events | in progress | 5/10 | [plans/panel-notifications.md](plans/panel-notifications.md) | +| panel-notifications | E-mail notifications about important events | in progress | 6/10 | [plans/panel-notifications.md](plans/panel-notifications.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 | — | — | diff --git a/docs/security.md b/docs/security.md index 20ea774..290fc70 100644 --- a/docs/security.md +++ b/docs/security.md @@ -412,6 +412,21 @@ deferred item from the roadmap. diagnostics. Since 2.2.0 the tailer copies the lines of queued outbound messages into the database for the message's page, and those are in a backup (§ Backup and domain export); mail offered on port 25 is not copied. +- **The panel submits mail of its own.** Notifications leave through + `sendmail(1)` run by the unprivileged panel user: a fixed argument list, the + two addresses after `--`, no shell. The recipient is a panel user's account + e-mail, validated when it was saved and checked again before it reaches a + header or the command line; the subject and body are composed by the panel + from its own readings. Two of those readings can carry text from outside - a + DNS check's finding, and a queued message's sender address - and both are + reduced to printable ASCII and cut before they are printed, so a + notification's body can contain an address a stranger chose, as a few plain + characters and nothing else. The panel's + membership in `postdrop`, described in the image as read-side, is now what + lets it submit; any local user could already do that through the + world-executable `sendmail`. Notifications are signed with a key of the + server's own name and bypass the level-1 and level-2 limits: they are a few + messages a day at most, to addresses only a panel user can set. - **A message's page can show another message's log lines after a queue-id collision.** Postfix's short queue ids repeat: the id is the microsecond of arrival and the queue file's inode, and inodes come round within seconds. diff --git a/internal/notify/notify.go b/internal/notify/notify.go index a04563b..61395af 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -29,8 +29,9 @@ const ( // at. A var-free constant: the rules below count in checks. CheckInterval = time.Hour // LimitInterval is how often refused mail is looked for. A refusal is - // reported at once rather than on the hour: it is mail not being sent now. - LimitInterval = 5 * time.Minute + // reported within the minute rather than on the hour: it is mail not being + // sent now. The look is one indexed query. + LimitInterval = time.Minute // dnsChecks is how many checks in a row must agree before a DNS mismatch is // reported, and before it is reported gone. One check is a resolver's bad diff --git a/internal/notify/text.go b/internal/notify/text.go index 390cb99..b7c7598 100644 --- a/internal/notify/text.go +++ b/internal/notify/text.go @@ -35,6 +35,24 @@ func compose(hostname, subject string, paragraphs ...string) message { func stamp(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 UTC") } +// plain makes text that came from outside fit for a message body: a queued +// message's sender is whatever its sender wrote, and a DNS finding may quote a +// name. What is not printable ASCII becomes "?", and it is cut to max bytes - +// a notification is read by a person, and nothing in it is theirs to act on +// but the panel's own words. +func plain(s string, max int) string { + if len(s) > max { + s = s[:max] + } + b := []byte(s) + for i, c := range b { + if c < 0x20 || c > 0x7e { + b[i] = '?' + } + } + return string(b) +} + // days words a duration in whole days, the unit these messages count in. func days(d time.Duration) string { n := int(d.Hours() / 24) @@ -49,7 +67,7 @@ func dnsProblem(hostname, domain string, faults []dnsFault, since time.Time) mes lines := make([]string, len(faults)) for i, f := range faults { names[i] = f.Record - lines[i] = f.Record + ": " + f.Detail + lines[i] = f.Record + ": " + plain(f.Detail, 600) } return compose(hostname, fmt.Sprintf("%s: %s no longer matches", domain, strings.Join(names, ", ")), @@ -91,7 +109,7 @@ func queueStuck(hostname string, old []postfix.QueuedMessage, now time.Time) mes if sender == "" { sender = "(bounce)" } - fmt.Fprintf(&b, "%s from %s waiting %s, since %s\n", m.ID, sender, days(now.Sub(m.Arrival)), stamp(m.Arrival)) + fmt.Fprintf(&b, "%s from %s waiting %s, since %s\n", plain(m.ID, 24), plain(sender, 120), days(now.Sub(m.Arrival)), stamp(m.Arrival)) } what := "A message has" if len(old) > 1 {