panel-notifications: findings of the technical and security reviews
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
Neither review (read-only agents, Opus and Fable, over 972a9f8..400aa76) has a Critical finding. Both found the same serious one, and more: - The notification about refused mail was built on what a rejected row says of itself. 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: an application over its limit could have had the panel write a message per made-up domain, to another tenant's users among others. And the same status is written when an application is refused for its client address, which is no limit. Refusals are now counted by the domain the login is registered under, only for logins that exist and only while a limit is set on the login or its domain; one subject per sending domain. They are read through a partial index of their own (migration 0005): the query walked the whole send log every minute. - Account and user e-mails were checked with the DMARC report validator, which refuses Gmail and the like - the owner's own kind of address. A new validate.AccountEmail accepts them and refuses a local part beginning "-". - "Said" now means sent: what arises while nobody can be told, or while Postfix does not take the message, is told at a later check. Its ending with nobody left to tell is simply over. - Two DNS checks must be 45 minutes apart as well as two: the last check before a restart and the first after it were minutes apart. - The notifier's own mail waiting in the queue, and its bounce coming back to postmaster@ this server, are not the queue's subject. A listing cut at its size limit or with a line too long to read is no listing; postqueue -j gets 30 seconds. - The tables must name the selector the panel shows, not only have the line. - An a/mx lookup that did not answer inside the SPF check made the record "not authorising"; it is "cannot tell" now, for the pages too. - A ceiling of 60 messages an hour; a pass stops between domains when the process is asked to stop; sendmail gets a WaitDelay; days are rounded as a person counts; a failed read of the switch shows it on, not off. Recorded as accepted in security.md, with a roadmap candidate (from-header-binding): an application can have a message signed as the server's own name - or, as always, as another registered domain - by writing it into the From: header, which nothing holds against the login. Left as found: a recipient whose copy failed while another's went out is not tried again; a certificate that lives under 14 days is reported at each renewal. On the test server, for this tree: go vet clean, go test ./... 23 packages ok, make e2e ok (326 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,8 @@ func (m *Mailer) Send(to, subject, body string) error {
|
||||
// a header and onto a command line all the same, so what cannot be part of
|
||||
// an address is refused here as well (security.md).
|
||||
if to == "" || strings.ContainsAny(to, " \t\r\n<>,;\"") || strings.HasPrefix(to, "-") || !strings.Contains(to, "@") {
|
||||
// (validate.AccountEmail refuses the same, so a user is told at the
|
||||
// form and not here, once a minute, in the log.)
|
||||
return fmt.Errorf("not an address a notification can go to: %q", to)
|
||||
}
|
||||
from := From(m.hostname)
|
||||
@@ -88,6 +90,9 @@ func sendmail(from, to string, msg []byte) error {
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, sendmailPath, "-i", "-f", from, "--", to)
|
||||
cmd.Stdin = bytes.NewReader(msg)
|
||||
// sendmail hands the message to postdrop; if that child outlives the
|
||||
// timeout holding the output pipe, do not wait for it for ever.
|
||||
cmd.WaitDelay = 5 * time.Second
|
||||
out, err := cmd.CombinedOutput()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("sendmail timed out after %s", sendmailTimeout)
|
||||
|
||||
+125
-40
@@ -1,6 +1,6 @@
|
||||
// Package notify writes to the panel's users when something needs them
|
||||
// (architecture.md § Notifications): a sending domain's DNS record stops
|
||||
// matching, a level-2 rate limit refuses mail, the TLS certificate is about to
|
||||
// (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
|
||||
@@ -12,7 +12,6 @@ package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -26,17 +25,22 @@ import (
|
||||
|
||||
const (
|
||||
// CheckInterval is how often DNS, the certificate and the queue are looked
|
||||
// at. A var-free constant: the rules below count in checks.
|
||||
// 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 is one indexed query.
|
||||
// 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. One check is a resolver's bad
|
||||
// minute; two, an hour apart, is a record that is wrong.
|
||||
// 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.
|
||||
@@ -47,10 +51,16 @@ const (
|
||||
certFirstDays = 14
|
||||
certLastDays = 3
|
||||
|
||||
// limitRepeat is the shortest time between two messages about the same
|
||||
// level-2 limit.
|
||||
// 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
|
||||
)
|
||||
@@ -90,17 +100,26 @@ type Notifier struct {
|
||||
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() }}
|
||||
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)
|
||||
@@ -144,31 +163,66 @@ func (n *Notifier) Check() {
|
||||
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. It reports
|
||||
// whether the subject counts as said: nobody to tell is said, a message that
|
||||
// could not be handed over to anyone is not, and is tried at the next check.
|
||||
func (n *Notifier) send(domainName string, m message) bool {
|
||||
// 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 false
|
||||
return failed
|
||||
}
|
||||
if len(to) == 0 {
|
||||
return true
|
||||
return nobody
|
||||
}
|
||||
sent := 0
|
||||
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
|
||||
}
|
||||
sent++
|
||||
n.sentAt = append(n.sentAt, n.now())
|
||||
handed++
|
||||
}
|
||||
if sent > 0 {
|
||||
log.Printf("notify: %q sent to %d of %d", m.subject, sent, len(to))
|
||||
if handed == 0 {
|
||||
return failed
|
||||
}
|
||||
return sent > 0
|
||||
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) {
|
||||
@@ -225,6 +279,13 @@ func (n *Notifier) checkDNS() {
|
||||
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)
|
||||
@@ -246,6 +307,10 @@ func (n *Notifier) checkDNS() {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -269,23 +334,27 @@ func (n *Notifier) dnsStep(name string, res dnscheck.Domain) {
|
||||
n.forget(key) // it was the resolver's bad minute
|
||||
case st.Phase == phasePending && bad:
|
||||
st.Count++
|
||||
if st.Count >= dnsChecks {
|
||||
if !n.send(name, dnsProblem(n.hostname, name, faults, st.Since)) {
|
||||
n.save(st)
|
||||
return
|
||||
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
|
||||
}
|
||||
st.Phase, st.Count, st.SentAt = phaseOpen, 0, now
|
||||
}
|
||||
n.save(st)
|
||||
case st.Phase == phaseOpen && bad:
|
||||
if st.Count != 0 {
|
||||
st.Count = 0 // it went right for a check and wrong again: still the same trouble
|
||||
// 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 >= dnsChecks {
|
||||
if n.send(name, dnsCleared(n.hostname, name, st.Since)) {
|
||||
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
|
||||
}
|
||||
@@ -325,7 +394,7 @@ func (n *Notifier) checkCertificate() {
|
||||
if level == "" || (have && (st.Phase == level || st.Phase == "last")) {
|
||||
return
|
||||
}
|
||||
if !n.send("", certExpiring(n.hostname, c.NotAfter, left)) {
|
||||
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()})
|
||||
@@ -333,6 +402,24 @@ func (n *Notifier) checkCertificate() {
|
||||
|
||||
// ---- 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 {
|
||||
@@ -342,7 +429,7 @@ func (n *Notifier) checkQueue() {
|
||||
now := n.now()
|
||||
var old []postfix.QueuedMessage
|
||||
for _, m := range msgs {
|
||||
if now.Sub(m.Arrival) > QueueAge {
|
||||
if now.Sub(m.Arrival) > QueueAge && !n.ownMail(m) {
|
||||
old = append(old, m)
|
||||
}
|
||||
}
|
||||
@@ -356,11 +443,11 @@ func (n *Notifier) checkQueue() {
|
||||
}
|
||||
switch {
|
||||
case len(old) > 0 && !have:
|
||||
if n.send("", queueStuck(n.hostname, old, now)) {
|
||||
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)) {
|
||||
if n.send("", queueCleared(n.hostname, st.Since)) != failed {
|
||||
n.forget(key)
|
||||
}
|
||||
}
|
||||
@@ -369,7 +456,8 @@ func (n *Notifier) checkQueue() {
|
||||
// ---- level-2 limits
|
||||
|
||||
// CheckLimits looks for mail refused under a level-2 rate limit and reports
|
||||
// each limit that refused, at most once in limitRepeat.
|
||||
// each sending domain whose applications were refused, at most once in
|
||||
// limitRepeat.
|
||||
func (n *Notifier) CheckLimits() {
|
||||
if !n.enabled() {
|
||||
return
|
||||
@@ -381,10 +469,7 @@ func (n *Notifier) CheckLimits() {
|
||||
return
|
||||
}
|
||||
for _, r := range refusals {
|
||||
if r.Domain == "" {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("limit:%s:%s", r.Domain, r.AppLogin)
|
||||
key := "limit:" + r.Domain
|
||||
st, have, err := n.st.GetNotifyState(key)
|
||||
if err != nil {
|
||||
log.Printf("notify: %v", err)
|
||||
@@ -396,12 +481,12 @@ func (n *Notifier) CheckLimits() {
|
||||
if have && now.Sub(st.SentAt) < limitRepeat {
|
||||
continue
|
||||
}
|
||||
if n.send(r.Domain, limitRefused(n.hostname, r)) {
|
||||
if n.send(r.Domain, limitRefused(n.hostname, r)) == sent {
|
||||
n.save(store.NotifyState{Key: key, Phase: phaseOpen, Since: r.First, SentAt: now})
|
||||
}
|
||||
}
|
||||
|
||||
// A limit nobody has hit for two days is not a subject any more.
|
||||
// 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)
|
||||
|
||||
+173
-15
@@ -131,7 +131,7 @@ func TestDNSMismatchIsSaidOnceAndClearedOnce(t *testing.T) {
|
||||
b.dns["example.com"] = badDKIM()
|
||||
|
||||
want(t, "first bad check", b.hour())
|
||||
problem := "[SelfPost] example.com: DKIM no longer matches"
|
||||
problem := "[SelfPost] example.com: DKIM does not match"
|
||||
want(t, "second bad check", b.hour(), "mine@example.org|"+problem, "root@example.org|"+problem)
|
||||
want(t, "third bad check", b.hour())
|
||||
|
||||
@@ -279,13 +279,30 @@ func TestQueueIsSaidOnceAndClearedOnce(t *testing.T) {
|
||||
want(t, "after", b.hour())
|
||||
}
|
||||
|
||||
// A limit that refuses mail is said to the domain's users, at most once a day.
|
||||
// A limit that refuses mail is said to the domain's users, at most once a day
|
||||
// for the domain, whichever of its applications are refused.
|
||||
func TestLimitRefusalIsSaidOnceADay(t *testing.T) {
|
||||
b := newBench(t)
|
||||
b.now = time.Now().UTC() // refusals are dated by the store's clock
|
||||
refuse := func() {
|
||||
domains, _ := b.st.ListDomains()
|
||||
var domainID int64
|
||||
for _, d := range domains {
|
||||
if d.Name == "example.com" {
|
||||
domainID = d.ID
|
||||
}
|
||||
}
|
||||
for _, login := range []string{"app1", "app2"} {
|
||||
a, err := b.st.AddApplication(domainID, login, store.AddressModeWildcard, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.st.SetRateLimit(store.RateLimit{Scope: store.RateLimitScopeApp, RefID: a.ID, MaxMessages: 1, WindowSeconds: 3600}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
refuse := func(login, mailFromDomain string) {
|
||||
t.Helper()
|
||||
if err := b.st.InsertRejected(store.SendLogEntry{Domain: "example.com", AppLogin: "app1", From: "a@example.com"}); err != nil {
|
||||
if err := b.st.InsertRejected(store.SendLogEntry{Domain: mailFromDomain, AppLogin: login, From: "a@" + mailFromDomain}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -293,20 +310,28 @@ func TestLimitRefusalIsSaidOnceADay(t *testing.T) {
|
||||
b.n.CheckLimits()
|
||||
want(t, "nothing refused", b.out.take())
|
||||
|
||||
refuse()
|
||||
refuse("app1", "example.com")
|
||||
b.n.CheckLimits()
|
||||
subject := "[SelfPost] example.com: a rate limit is refusing mail"
|
||||
want(t, "first refusal", b.out.take(), "mine@example.org|"+subject, "root@example.org|"+subject)
|
||||
|
||||
refuse()
|
||||
// The second application of the domain is refused, and the first goes on
|
||||
// under domains it makes up in MAIL FROM - another tenant's among them.
|
||||
// It is all the same subject: example.com, told today.
|
||||
refuse("app2", "example.com")
|
||||
refuse("app1", "other.example")
|
||||
refuse("app1", "made-up.example")
|
||||
b.now = b.now.Add(2 * time.Hour)
|
||||
b.n.CheckLimits()
|
||||
want(t, "more of it two hours later", b.out.take())
|
||||
if keys, _ := b.st.ListNotifyStateKeys("limit:"); strings.Join(keys, " ") != "limit:example.com" {
|
||||
t.Errorf("limit subjects = %v, want the one registered domain", keys)
|
||||
}
|
||||
|
||||
// A day after it was said, mail is still being refused: said again. The
|
||||
// refusals are dated by the store's clock, so it is the telling that is
|
||||
// moved back, not the clock forward.
|
||||
st, have, err := b.st.GetNotifyState("limit:example.com:app1")
|
||||
st, have, err := b.st.GetNotifyState("limit:example.com")
|
||||
if err != nil || !have {
|
||||
t.Fatalf("no state for the limit: %v, %v", have, err)
|
||||
}
|
||||
@@ -316,27 +341,160 @@ func TestLimitRefusalIsSaidOnceADay(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.n.CheckLimits()
|
||||
want(t, "a day later, still refusing", b.out.take(), "mine@example.org|"+subject, "root@example.org|"+subject)
|
||||
if body := limitRefused("mail.example.org", store.LimitRefusal{Domain: "example.com", AppLogin: "app1", Count: 3, First: b.now}).body; !strings.Contains(body, "3 messages from the application app1 of example.com") {
|
||||
t.Errorf("the message does not say who was refused how much:\n%s", body)
|
||||
if len(b.out.sent) != 2 || !strings.Contains(b.out.body[0], "4 messages from the applications app1, app2 of example.com") {
|
||||
t.Fatalf("a day later, still refusing: %v\n%s", b.out.sent, strings.Join(b.out.body, "\n---\n"))
|
||||
}
|
||||
b.out.take()
|
||||
|
||||
// Nobody has hit the limit for two days: it is not a subject any more.
|
||||
// Nobody was refused for two days: it is not a subject any more.
|
||||
st.SentAt = b.now.Add(-49 * time.Hour)
|
||||
if err := b.st.SetNotifyState(st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.st.LimitRefusalsSince(b.now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.now = b.now.Add(25 * time.Hour) // past the window the refusals lie in
|
||||
b.n.CheckLimits()
|
||||
want(t, "long quiet", b.out.take())
|
||||
if _, have, _ := b.st.GetNotifyState("limit:example.com:app1"); have {
|
||||
if _, have, _ := b.st.GetNotifyState("limit:example.com"); have {
|
||||
t.Error("a limit nobody hits kept its state")
|
||||
}
|
||||
}
|
||||
|
||||
// Two checks minutes apart - the last before a restart and the first after it
|
||||
// - are not "an hour apart": the mismatch is said when a check finds it again
|
||||
// with the time passed.
|
||||
func TestDNSChecksMustBeApart(t *testing.T) {
|
||||
b := newBench(t)
|
||||
b.dns["example.com"] = badDKIM()
|
||||
|
||||
b.n.Check()
|
||||
b.now = b.now.Add(3 * time.Minute) // the process restarted
|
||||
b.n.Check()
|
||||
want(t, "two checks three minutes apart", b.out.take())
|
||||
b.now = b.now.Add(time.Hour)
|
||||
b.n.Check()
|
||||
if got := b.out.take(); len(got) != 2 {
|
||||
t.Fatalf("an hour later: %v, want the two messages", got)
|
||||
}
|
||||
|
||||
// The same for its going: two good checks minutes apart do not end it.
|
||||
b.dns["example.com"] = good()
|
||||
b.now = b.now.Add(time.Hour)
|
||||
b.n.Check()
|
||||
b.now = b.now.Add(3 * time.Minute)
|
||||
b.n.Check()
|
||||
want(t, "two good checks three minutes apart", b.out.take())
|
||||
b.now = b.now.Add(time.Hour)
|
||||
b.n.Check()
|
||||
if got := b.out.take(); len(got) != 2 || !strings.Contains(got[0], "match again") {
|
||||
t.Errorf("an hour later: %v, want the two messages that it is over", got)
|
||||
}
|
||||
}
|
||||
|
||||
// What arises while there is nobody to tell is told when there is somebody:
|
||||
// a thing is said only when it was sent. Its going, with nobody left to tell,
|
||||
// is simply over.
|
||||
func TestNobodyToTellIsNotSaid(t *testing.T) {
|
||||
b := newBench(t)
|
||||
users, _ := b.st.ListUsers()
|
||||
for _, u := range users {
|
||||
if err := b.st.SetUserNotify(u.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
expiry := b.now.Add(10 * 24 * time.Hour)
|
||||
b.cert = health.Certificate{NotAfter: expiry}
|
||||
b.dns["example.com"] = badDKIM()
|
||||
b.q = []postfix.QueuedMessage{{ID: "OLD00001", Sender: "app@example.com", Arrival: b.now.Add(-5 * 24 * time.Hour)}}
|
||||
|
||||
want(t, "nobody listens", b.hour())
|
||||
want(t, "nobody listens", b.hour())
|
||||
|
||||
for _, u := range users {
|
||||
if u.Username == "root" {
|
||||
if err := b.st.SetUserNotify(u.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
got := b.hour()
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("somebody listens now: %v, want the DNS mismatch, the certificate and the queue", got)
|
||||
}
|
||||
|
||||
// Everything is put right while nobody listens again: no state is left to
|
||||
// announce an ending later.
|
||||
for _, u := range users {
|
||||
if err := b.st.SetUserNotify(u.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.dns["example.com"] = good()
|
||||
b.q = nil
|
||||
want(t, "put right", b.hour())
|
||||
want(t, "put right", b.hour())
|
||||
for _, key := range []string{"dns:example.com", "queue"} {
|
||||
if _, have, _ := b.st.GetNotifyState(key); have {
|
||||
t.Errorf("state %s left behind", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The notifier's own mail in the queue - a notification to a mailbox that does
|
||||
// not answer, or its bounce coming back to this server's postmaster - is not
|
||||
// the mail the queue is watched for.
|
||||
func TestQueueLeavesOutTheNotifiersOwnMail(t *testing.T) {
|
||||
b := newBench(t)
|
||||
old := b.now.Add(-4 * 24 * time.Hour)
|
||||
b.q = []postfix.QueuedMessage{
|
||||
{ID: "NOTE0001", Sender: "postmaster@mail.example.org", Recipients: []string{"gone@example.net"}, Arrival: old},
|
||||
{ID: "BOUNCE01", Sender: "", Recipients: []string{"Postmaster@Mail.Example.Org"}, Arrival: old},
|
||||
}
|
||||
want(t, "only its own mail is old", b.hour())
|
||||
|
||||
b.q = append(b.q, postfix.QueuedMessage{ID: "APP00001", Sender: "", Recipients: []string{"app@example.com"}, Arrival: old})
|
||||
if got := b.hour(); len(got) != 1 {
|
||||
t.Errorf("an application's bounce is old too: %v, want one message", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the rules arrive at, no more than the ceiling leaves in an hour:
|
||||
// these messages bypass every rate limit.
|
||||
func TestHourlyCeiling(t *testing.T) {
|
||||
b := newBench(t)
|
||||
for i := 0; i < hourlyCeiling+5; i++ {
|
||||
b.n.send("", message{subject: "s", body: "b"})
|
||||
}
|
||||
if got := len(b.out.take()); got != hourlyCeiling {
|
||||
t.Errorf("%d messages left in one hour, ceiling is %d", got, hourlyCeiling)
|
||||
}
|
||||
if b.n.send("", message{subject: "s", body: "b"}) != failed {
|
||||
t.Error("a message over the ceiling was counted as said")
|
||||
}
|
||||
b.now = b.now.Add(61 * time.Minute)
|
||||
if b.n.send("", message{subject: "s", body: "b"}) != sent {
|
||||
t.Error("the ceiling did not lift after an hour")
|
||||
}
|
||||
}
|
||||
|
||||
// The subject of a certificate message counts in days as a person would:
|
||||
// found an hour past the threshold it still has "14 days".
|
||||
func TestCertificateSubjectRoundsDays(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
left time.Duration
|
||||
want string
|
||||
}{
|
||||
{14*24*time.Hour - time.Hour, "expires in 14 days"},
|
||||
{3*24*time.Hour - 20*time.Minute, "expires in 3 days"},
|
||||
{30 * time.Hour, "expires in 1 day"},
|
||||
{-time.Hour, "has expired"},
|
||||
} {
|
||||
m := certExpiring("mail.example.org", time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), c.left)
|
||||
if !strings.Contains(m.subject, c.want) {
|
||||
t.Errorf("%v left: subject %q, want %q", c.left, m.subject, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailerComposesAndRefuses(t *testing.T) {
|
||||
var from, to string
|
||||
var msg []byte
|
||||
|
||||
+11
-9
@@ -53,9 +53,11 @@ func plain(s string, max int) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// days words a duration in whole days, the unit these messages count in.
|
||||
// days words a duration in days, the unit these messages count in, rounded
|
||||
// to the nearest: a certificate found thirteen days and twenty-three hours
|
||||
// from its end has fourteen days, not thirteen.
|
||||
func days(d time.Duration) string {
|
||||
n := int(d.Hours() / 24)
|
||||
n := int((d + 12*time.Hour) / (24 * time.Hour))
|
||||
if n == 1 {
|
||||
return "1 day"
|
||||
}
|
||||
@@ -70,8 +72,8 @@ func dnsProblem(hostname, domain string, faults []dnsFault, since time.Time) mes
|
||||
lines[i] = f.Record + ": " + plain(f.Detail, 600)
|
||||
}
|
||||
return compose(hostname,
|
||||
fmt.Sprintf("%s: %s no longer matches", domain, strings.Join(names, ", ")),
|
||||
fmt.Sprintf("The DNS records of %s do not match what this server sends with. Two checks an hour apart found the same, the first at %s.", domain, stamp(since)),
|
||||
fmt.Sprintf("%s: %s does not match", domain, strings.Join(names, ", ")),
|
||||
fmt.Sprintf("The DNS records of %s do not match what this server sends with. Checks about an hour apart found the same, the first at %s.", domain, stamp(since)),
|
||||
strings.Join(lines, "\n\n"),
|
||||
fmt.Sprintf("Until the records are right, receivers may refuse mail from %s or file it as spam. The records to publish are in the panel under Outbound > Domains > %s.", domain, domain),
|
||||
"One more message follows when the records match again.",
|
||||
@@ -81,7 +83,7 @@ func dnsProblem(hostname, domain string, faults []dnsFault, since time.Time) mes
|
||||
func dnsCleared(hostname, domain string, since time.Time) message {
|
||||
return compose(hostname,
|
||||
fmt.Sprintf("%s: DNS records match again", domain),
|
||||
fmt.Sprintf("The DNS records of %s match what this server sends with again; two checks an hour apart agree. They had not since %s.", domain, stamp(since)),
|
||||
fmt.Sprintf("The DNS records of %s match what this server sends with again; checks about an hour apart agree. They had not since %s.", domain, stamp(since)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -132,9 +134,9 @@ func queueCleared(hostname string, since time.Time) message {
|
||||
}
|
||||
|
||||
func limitRefused(hostname string, r store.LimitRefusal) message {
|
||||
who := "the domain " + r.Domain
|
||||
if r.AppLogin != "" {
|
||||
who = fmt.Sprintf("the application %s of %s", r.AppLogin, r.Domain)
|
||||
who := fmt.Sprintf("the application %s of %s", strings.Join(r.Apps, ", "), r.Domain)
|
||||
if len(r.Apps) > 1 {
|
||||
who = fmt.Sprintf("the applications %s of %s", strings.Join(r.Apps, ", "), r.Domain)
|
||||
}
|
||||
count := "1 message"
|
||||
if r.Count != 1 {
|
||||
@@ -144,6 +146,6 @@ func limitRefused(hostname string, r store.LimitRefusal) message {
|
||||
fmt.Sprintf("%s: a rate limit is refusing mail", r.Domain),
|
||||
fmt.Sprintf("In the last 24 hours this server refused %s from %s under a level-2 rate limit, the first at %s. Refused mail is not queued: the application has to send it again.", count, who, stamp(r.First)),
|
||||
fmt.Sprintf("If the volume is expected, raise the limit in the panel: Outbound > Domains > %s, on the domain or on the application. If it is not, the application is sending more than it should.", r.Domain),
|
||||
"This limit is reported at most once a day.",
|
||||
"Refusals of this domain's mail are reported at most once a day.",
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user