test / test (push) Canceled after 0s
Audit 2026-10-12, stage 2c, "Health says what is served"; the pages of
ea8b6bf's mockup.
Sockets (A-38). A unix socket's file outlives a killed OpenDKIM or panel
and refuses every connect() (unix(7)); Health said "Listening" from a
stat. CheckSocket and CheckMilter's unix case now dial: refused is
"Nothing listens on the ... socket: the program that made it has
stopped", EACCES is unknown (the panel may not connect), anything else
"does not answer". The probe closes before a byte, which no milter logs:
libmilter takes the EOF as SMFIC_EOF without a line (sendmail
8.17.1.9-2+deb12u2, the image's libmilter1.0.1, libmilter/comm.c
mi_rd_cmd(), engine.c mi_engine() - its source is now in dev/upstream),
OpenDKIM's mlfi_close() has no connection to free, go-milter v0.4.1
returns quietly on io.EOF (session.go, HandleMilterCommands()). The page
polls every 5 s while in view, so this mattered.
Certificate (A-65). Postfix serves build/postfix-config.sh's copy,
refreshed by cert-reload every TLS_RELOAD_INTERVAL_SECONDS. The panel now
reads smtpd_tls_cert_file from postconf -h at its start (as the retry
policy) and TLS_RELOAD_INTERVAL_SECONDS; CheckServedCertificate checks the
served copy and, when the mounted file differs byte for byte (cert-reload's
own cmp -s), names it as Renewal: its expiry and mtime, two facts on the
card. Waiting is fine for the interval: a nearing expiry of the served one
is then no warning (an expiry still an error); past interval + 1 h it is a
warning pointing at cert-reload's lines. The notifier sends no expiry
notice while a renewal waits on time.
Wording (D-P1, D-P3, and the same for the spam filter): Sender check
note, help topics sender-check, upstream, filter, certificate - word for
word as in the mockup; the filter row's detail too.
Tests: stale socket file (Linux) refused for CheckSocket and CheckMilter;
CheckServedCertificate (same, waiting, late, expired + waiting, no served
path); intervalText; the card's facts; the notifier quiet while a renewal
waits, speaking once it is late. Stand (2026-10-15, make e2e, exit 0):
new health_shows_what_is_served - the three sockets dialled ok by the
panel's user; a certificate mounted without its key is refused by
cert-reload ("do not belong together") and the card shows the served
expiry, "Renewal expires" with the mounted one's, "Renewal mounted", and
"Postfix still serves the certificate above."; a matching pair is then
served again. go vet and go test ./... pass on the server (24 packages).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
505 lines
15 KiB
Go
505 lines
15 KiB
Go
// Package notify writes to the panel's users when something needs them
|
|
// (architecture.md § Notifications): a sending domain's DNS record does not
|
|
// match, a level-2 rate limit refuses mail, the TLS certificate is about to
|
|
// expire, a message has waited in the queue for more than three days.
|
|
//
|
|
// Each subject has a row of state in the database, so a thing is said once and
|
|
// a restart says nothing again. The rules of when a message goes out are the
|
|
// owner's (docs/plans/panel-notifications.md § When a message is sent) and
|
|
// live in this file; what the messages say is in text.go, how they leave in
|
|
// mailer.go.
|
|
package notify
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
|
"github.com/mixeme/selfpost/internal/health"
|
|
"github.com/mixeme/selfpost/internal/postfix"
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
)
|
|
|
|
const (
|
|
// CheckInterval is how often DNS, the certificate and the queue are looked
|
|
// at.
|
|
CheckInterval = time.Hour
|
|
// LimitInterval is how often refused mail is looked for. A refusal is
|
|
// reported within the minute rather than on the hour: it is mail not being
|
|
// sent now. The look reads the refused rows through an index of their own
|
|
// (migration 0005).
|
|
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; dnsApart is how far apart the
|
|
// first and the last of them must be. One check is a resolver's bad minute;
|
|
// two, an hour apart, is a record that is wrong. The time is asked for as
|
|
// well as the count because the first check after a start is minutes after
|
|
// the last one before it.
|
|
dnsChecks = 2
|
|
dnsApart = 45 * time.Minute
|
|
|
|
// QueueAge is how long a message waits before the queue is reported. Postfix
|
|
// keeps a message for five days by default, so two are left to act in.
|
|
QueueAge = 3 * 24 * time.Hour
|
|
|
|
// The certificate is reported this many days before it expires, and again at
|
|
// the second number if it is still the same certificate.
|
|
certFirstDays = 14
|
|
certLastDays = 3
|
|
|
|
// limitRepeat is the shortest time between two messages about the limits of
|
|
// the same domain.
|
|
limitRepeat = 24 * time.Hour
|
|
|
|
// hourlyCeiling is the most messages handed to Postfix in an hour, whatever
|
|
// the rules above arrive at. They bypass every rate limit, and the number of
|
|
// subjects grows with the number of domains; nothing here should ever come
|
|
// near it, and a fault that does is stopped by it.
|
|
hourlyCeiling = 60
|
|
|
|
phasePending = "pending" // seen wrong, not yet said
|
|
phaseOpen = "open" // said
|
|
)
|
|
|
|
// Store is what the notifier needs of the database. *store.Store satisfies it.
|
|
type Store interface {
|
|
NotificationsEnabled() (bool, error)
|
|
NotifyRecipients(domainName string) ([]string, error)
|
|
GetNotifyState(key string) (store.NotifyState, bool, error)
|
|
SetNotifyState(st store.NotifyState) error
|
|
DeleteNotifyState(key string) error
|
|
ListNotifyStateKeys(prefix string) ([]string, error)
|
|
LimitRefusalsSince(since time.Time) ([]store.LimitRefusal, error)
|
|
ListDomains() ([]store.Domain, error)
|
|
}
|
|
|
|
// Sender hands one message to the mail system. *Mailer does; tests collect.
|
|
type Sender interface {
|
|
Send(to, subject, body string) error
|
|
}
|
|
|
|
// Checks are the readings the notifier acts on, as functions so that tests
|
|
// need neither DNS nor a certificate nor a queue.
|
|
type Checks struct {
|
|
// Domain is a fresh check of a sending domain's published records.
|
|
Domain func(d store.Domain) (dnscheck.Domain, error)
|
|
// Certificate is the state of the TLS certificate Postfix serves.
|
|
Certificate func() health.Certificate
|
|
// Queue lists the messages Postfix holds; an error says nothing about any.
|
|
Queue func() ([]postfix.QueuedMessage, error)
|
|
}
|
|
|
|
// Notifier applies the rules. Create one with New and call Run.
|
|
type Notifier struct {
|
|
st Store
|
|
checks Checks
|
|
sender Sender
|
|
hostname string
|
|
now func() time.Time
|
|
// ctx ends a pass between two domains when the process is asked to stop.
|
|
ctx context.Context
|
|
// sentAt holds the times of the messages of the last hour, for the ceiling.
|
|
sentAt []time.Time
|
|
}
|
|
|
|
// New builds a notifier for the server named hostname.
|
|
func New(st Store, checks Checks, sender Sender, hostname string) *Notifier {
|
|
return &Notifier{
|
|
st: st, checks: checks, sender: sender, hostname: hostname,
|
|
now: func() time.Time { return time.Now().UTC() },
|
|
ctx: context.Background(),
|
|
}
|
|
}
|
|
|
|
// Run checks on both cadences until ctx is cancelled. The first hourly check
|
|
// runs a few minutes after the start, not at it: a container that has just
|
|
// come up is still starting Postfix and may have no network yet.
|
|
func (n *Notifier) Run(ctx context.Context) error {
|
|
n.ctx = ctx
|
|
first := time.NewTimer(3 * time.Minute)
|
|
defer first.Stop()
|
|
hourly := time.NewTicker(CheckInterval)
|
|
defer hourly.Stop()
|
|
limits := time.NewTicker(LimitInterval)
|
|
defer limits.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-first.C:
|
|
n.Check()
|
|
case <-hourly.C:
|
|
n.Check()
|
|
case <-limits.C:
|
|
n.CheckLimits()
|
|
}
|
|
}
|
|
}
|
|
|
|
// enabled reports whether anything is to be done at all. With the instance
|
|
// switch off nothing is checked and no state moves, so that what is wrong
|
|
// when it is turned on again is said then.
|
|
func (n *Notifier) enabled() bool {
|
|
on, err := n.st.NotificationsEnabled()
|
|
if err != nil {
|
|
log.Printf("notify: read the switch: %v", err)
|
|
return false
|
|
}
|
|
return on
|
|
}
|
|
|
|
// Check is the hourly pass: DNS of every sending domain, the certificate, the
|
|
// queue.
|
|
func (n *Notifier) Check() {
|
|
if !n.enabled() {
|
|
return
|
|
}
|
|
n.checkDNS()
|
|
n.checkCertificate()
|
|
n.checkQueue()
|
|
}
|
|
|
|
// outcome is what became of a message.
|
|
type outcome int
|
|
|
|
const (
|
|
// sent: at least one recipient's copy was handed to Postfix.
|
|
sent outcome = iota
|
|
// nobody: no user is to be told - none with an e-mail and the switch on.
|
|
nobody
|
|
// failed: there were recipients and none could be handed a copy.
|
|
failed
|
|
)
|
|
|
|
// send writes one message to every recipient of a subject, each in a message
|
|
// of their own - users are not shown each other's addresses.
|
|
//
|
|
// What the caller makes of the outcome is the rule that keeps a thing from
|
|
// being said never: a problem counts as said only when it was sent, so one
|
|
// that arose while nobody could be told, or while the mail system was down, is
|
|
// told at a later check. A recipient whose copy failed while another's went
|
|
// out is not tried again: the subject is said.
|
|
func (n *Notifier) send(domainName string, m message) outcome {
|
|
to, err := n.st.NotifyRecipients(domainName)
|
|
if err != nil {
|
|
log.Printf("notify: recipients: %v", err)
|
|
return failed
|
|
}
|
|
if len(to) == 0 {
|
|
return nobody
|
|
}
|
|
handed := 0
|
|
for _, addr := range to {
|
|
if !n.underCeiling() {
|
|
log.Printf("notify: more than %d messages in an hour; %q to %s is held back", hourlyCeiling, m.subject, addr)
|
|
continue
|
|
}
|
|
if err := n.sender.Send(addr, m.subject, m.body); err != nil {
|
|
log.Printf("notify: send %q to %s: %v", m.subject, addr, err)
|
|
continue
|
|
}
|
|
n.sentAt = append(n.sentAt, n.now())
|
|
handed++
|
|
}
|
|
if handed == 0 {
|
|
return failed
|
|
}
|
|
log.Printf("notify: %q sent to %d of %d", m.subject, handed, len(to))
|
|
return sent
|
|
}
|
|
|
|
// underCeiling reports whether one more message may go out this hour.
|
|
func (n *Notifier) underCeiling() bool {
|
|
cutoff := n.now().Add(-time.Hour)
|
|
keep := n.sentAt[:0]
|
|
for _, t := range n.sentAt {
|
|
if t.After(cutoff) {
|
|
keep = append(keep, t)
|
|
}
|
|
}
|
|
n.sentAt = keep
|
|
return len(n.sentAt) < hourlyCeiling
|
|
}
|
|
|
|
func (n *Notifier) save(st store.NotifyState) {
|
|
if err := n.st.SetNotifyState(st); err != nil {
|
|
log.Printf("notify: save state %s: %v", st.Key, err)
|
|
}
|
|
}
|
|
|
|
func (n *Notifier) forget(key string) {
|
|
if err := n.st.DeleteNotifyState(key); err != nil {
|
|
log.Printf("notify: forget state %s: %v", key, err)
|
|
}
|
|
}
|
|
|
|
// ---- DNS
|
|
|
|
// dnsFault is what is wrong with a domain's records: the checks that failed,
|
|
// in the order the page shows them.
|
|
type dnsFault struct {
|
|
Record string // "DKIM", "SPF", "DMARC"
|
|
Detail string
|
|
}
|
|
|
|
// dnsReading sorts a check into wrong, right, or nothing learned. A record
|
|
// that fails is wrong whatever the others say. A lookup that did not answer is
|
|
// nothing learned - unless another record is plainly wrong. A warning (no DMARC
|
|
// record, an SPF include this check does not follow) is advice the page gives
|
|
// and not a thing to write about.
|
|
func dnsReading(d dnscheck.Domain) (faults []dnsFault, known bool) {
|
|
known = true
|
|
for _, r := range []struct {
|
|
name string
|
|
res dnscheck.Result
|
|
}{{"DKIM", d.DKIM}, {"SPF", d.SPF}, {"DMARC", d.DMARC}} {
|
|
switch r.res.Status {
|
|
case health.StatusError:
|
|
faults = append(faults, dnsFault{Record: r.name, Detail: r.res.Detail})
|
|
case health.StatusUnknown:
|
|
known = false
|
|
}
|
|
}
|
|
if len(faults) > 0 {
|
|
known = true
|
|
}
|
|
return faults, known
|
|
}
|
|
|
|
func (n *Notifier) checkDNS() {
|
|
domains, err := n.st.ListDomains()
|
|
if err != nil {
|
|
log.Printf("notify: list domains: %v", err)
|
|
return
|
|
}
|
|
names := make(map[string]bool, len(domains))
|
|
for _, d := range domains {
|
|
names[d.Name] = true
|
|
}
|
|
for _, d := range domains {
|
|
// A dead resolver costs seconds per domain; a process asked to stop
|
|
// does not wait for the rest of them.
|
|
if n.ctx.Err() != nil {
|
|
return
|
|
}
|
|
res, err := n.checks.Domain(d)
|
|
if err != nil {
|
|
log.Printf("notify: check %s: %v", d.Name, err)
|
|
continue
|
|
}
|
|
n.dnsStep(d.Name, res)
|
|
}
|
|
// A deleted domain has no records to be right or wrong.
|
|
keys, err := n.st.ListNotifyStateKeys("dns:")
|
|
if err != nil {
|
|
log.Printf("notify: list dns state: %v", err)
|
|
return
|
|
}
|
|
for _, key := range keys {
|
|
if !names[strings.TrimPrefix(key, "dns:")] {
|
|
n.forget(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// dnsStep moves one domain's state by one check.
|
|
//
|
|
// A subject is "pending" from the first bad check, with Since the time of it,
|
|
// and "open" from the message. While open, Count is the good checks in a row
|
|
// and Mark the time of the first of them.
|
|
func (n *Notifier) dnsStep(name string, res dnscheck.Domain) {
|
|
faults, known := dnsReading(res)
|
|
if !known {
|
|
return
|
|
}
|
|
bad := len(faults) > 0
|
|
key := "dns:" + name
|
|
st, have, err := n.st.GetNotifyState(key)
|
|
if err != nil {
|
|
log.Printf("notify: %v", err)
|
|
return
|
|
}
|
|
now := n.now()
|
|
|
|
switch {
|
|
case !have && !bad:
|
|
// Nothing wrong and nothing said.
|
|
case !have && bad:
|
|
n.save(store.NotifyState{Key: key, Phase: phasePending, Count: 1, Since: now})
|
|
case st.Phase == phasePending && !bad:
|
|
n.forget(key) // it was the resolver's bad minute
|
|
case st.Phase == phasePending && bad:
|
|
st.Count++
|
|
if st.Count >= dnsChecks && now.Sub(st.Since) >= dnsApart {
|
|
if n.send(name, dnsProblem(n.hostname, name, faults, st.Since)) == sent {
|
|
st.Phase, st.Count, st.Mark, st.SentAt = phaseOpen, 0, "", now
|
|
}
|
|
}
|
|
n.save(st)
|
|
case st.Phase == phaseOpen && bad:
|
|
if st.Count != 0 {
|
|
// It went right for a check and wrong again: still the same trouble.
|
|
st.Count, st.Mark = 0, ""
|
|
n.save(st)
|
|
}
|
|
case st.Phase == phaseOpen && !bad:
|
|
st.Count++
|
|
if st.Count == 1 {
|
|
st.Mark = now.Format(time.RFC3339)
|
|
}
|
|
firstGood, _ := time.Parse(time.RFC3339, st.Mark)
|
|
if st.Count >= dnsChecks && now.Sub(firstGood) >= dnsApart {
|
|
// With nobody to tell any more, the trouble is over all the same.
|
|
if n.send(name, dnsCleared(n.hostname, name, st.Since)) != failed {
|
|
n.forget(key)
|
|
return
|
|
}
|
|
}
|
|
n.save(st)
|
|
}
|
|
}
|
|
|
|
// ---- certificate
|
|
|
|
func (n *Notifier) checkCertificate() {
|
|
c := n.checks.Certificate()
|
|
if c.NotAfter.IsZero() {
|
|
return // no certificate configured, or one that cannot be read: Health says so
|
|
}
|
|
if c.Renewal != nil && !c.Renewal.Late {
|
|
return // a renewal is mounted and Postfix takes it up at its next check
|
|
}
|
|
const key = "cert"
|
|
mark := c.NotAfter.UTC().Format(time.RFC3339)
|
|
st, have, err := n.st.GetNotifyState(key)
|
|
if err != nil {
|
|
log.Printf("notify: %v", err)
|
|
return
|
|
}
|
|
if have && st.Mark != mark {
|
|
// A different certificate: what was said about the old one is over.
|
|
n.forget(key)
|
|
have, st = false, store.NotifyState{}
|
|
}
|
|
|
|
left := c.NotAfter.Sub(n.now())
|
|
level := ""
|
|
switch {
|
|
case left <= certLastDays*24*time.Hour:
|
|
level = "last"
|
|
case left <= certFirstDays*24*time.Hour:
|
|
level = "first"
|
|
}
|
|
if level == "" || (have && (st.Phase == level || st.Phase == "last")) {
|
|
return
|
|
}
|
|
if n.send("", certExpiring(n.hostname, c.NotAfter, left)) != sent {
|
|
return
|
|
}
|
|
n.save(store.NotifyState{Key: key, Phase: level, Mark: mark, Since: n.now(), SentAt: n.now()})
|
|
}
|
|
|
|
// ---- queue
|
|
|
|
// ownMail reports whether a queued message is the notifier's: a notification
|
|
// that could not be delivered yet, or the bounce of one coming back to
|
|
// postmaster@ this server, which receives no mail. Neither is the mail the
|
|
// queue is watched for, and reporting them would have one dead mailbox
|
|
// announce itself every three days.
|
|
func (n *Notifier) ownMail(m postfix.QueuedMessage) bool {
|
|
own := From(n.hostname)
|
|
if strings.EqualFold(m.Sender, own) {
|
|
return true
|
|
}
|
|
for _, rcpt := range m.Recipients {
|
|
if strings.EqualFold(rcpt, own) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (n *Notifier) checkQueue() {
|
|
msgs, err := n.checks.Queue()
|
|
if err != nil {
|
|
log.Printf("notify: read the queue: %v", err)
|
|
return // no listing is no information
|
|
}
|
|
now := n.now()
|
|
var old []postfix.QueuedMessage
|
|
for _, m := range msgs {
|
|
if now.Sub(m.Arrival) > QueueAge && !n.ownMail(m) {
|
|
old = append(old, m)
|
|
}
|
|
}
|
|
sort.Slice(old, func(i, j int) bool { return old[i].Arrival.Before(old[j].Arrival) })
|
|
|
|
const key = "queue"
|
|
st, have, err := n.st.GetNotifyState(key)
|
|
if err != nil {
|
|
log.Printf("notify: %v", err)
|
|
return
|
|
}
|
|
switch {
|
|
case len(old) > 0 && !have:
|
|
if n.send("", queueStuck(n.hostname, old, now)) == sent {
|
|
n.save(store.NotifyState{Key: key, Phase: phaseOpen, Since: now, SentAt: now})
|
|
}
|
|
case len(old) == 0 && have:
|
|
if n.send("", queueCleared(n.hostname, st.Since)) != failed {
|
|
n.forget(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- level-2 limits
|
|
|
|
// CheckLimits looks for mail refused under a level-2 rate limit and reports
|
|
// each sending domain whose applications were refused, at most once in
|
|
// limitRepeat.
|
|
func (n *Notifier) CheckLimits() {
|
|
if !n.enabled() {
|
|
return
|
|
}
|
|
now := n.now()
|
|
refusals, err := n.st.LimitRefusalsSince(now.Add(-limitRepeat))
|
|
if err != nil {
|
|
log.Printf("notify: %v", err)
|
|
return
|
|
}
|
|
for _, r := range refusals {
|
|
key := "limit:" + r.Domain
|
|
st, have, err := n.st.GetNotifyState(key)
|
|
if err != nil {
|
|
log.Printf("notify: %v", err)
|
|
continue
|
|
}
|
|
// Told within the day: the refusals counted now are the ones it was
|
|
// told about, or more of the same. Told longer ago: everything in the
|
|
// window is new since then.
|
|
if have && now.Sub(st.SentAt) < limitRepeat {
|
|
continue
|
|
}
|
|
if n.send(r.Domain, limitRefused(n.hostname, r)) == sent {
|
|
n.save(store.NotifyState{Key: key, Phase: phaseOpen, Since: r.First, SentAt: now})
|
|
}
|
|
}
|
|
|
|
// A domain nobody was refused under for two days is not a subject any more.
|
|
keys, err := n.st.ListNotifyStateKeys("limit:")
|
|
if err != nil {
|
|
log.Printf("notify: list limit state: %v", err)
|
|
return
|
|
}
|
|
for _, key := range keys {
|
|
st, have, err := n.st.GetNotifyState(key)
|
|
if err == nil && have && now.Sub(st.SentAt) > 2*limitRepeat {
|
|
n.forget(key)
|
|
}
|
|
}
|
|
}
|