Merge branch 'feature/mail-deliverability'
This commit is contained in:
commit
1281d6cd50
11 files changed
+890
-18
No files matched your search
@@ -200,7 +200,10 @@ headers, Host rewrite, HTTPS to the backend, certificate status per port.
|
|||||||
**Alerts**
|
**Alerts**
|
||||||
- Email or webhook (JSON, Slack, Discord, ntfy) when an edge or site goes quiet, a port can't bind, a
|
- Email or webhook (JSON, Slack, Discord, ntfy) when an edge or site goes quiet, a port can't bind, a
|
||||||
target fails its health check, a certificate errors, or a mail relay isn't deliverable (reverse DNS,
|
target fails its health check, a certificate errors, or a mail relay isn't deliverable (reverse DNS,
|
||||||
HELO, port 25, DKIM record missing).
|
HELO, port 25, DKIM record missing), a sending domain's SPF doesn't authorize the relay or its
|
||||||
|
DMARC record is missing, the relay's IP lands on a DNS blocklist, too much of its mail is being
|
||||||
|
refused, or too little of the mail it sends passes DMARC in the reports you upload. Each alert says
|
||||||
|
what to change.
|
||||||
- A problem must last the **debounce time** (default 2 minutes, per organization) before you hear about
|
- A problem must last the **debounce time** (default 2 minutes, per organization) before you hear about
|
||||||
it, so restarts and blips stay quiet. One message when it starts, one when it clears, optional
|
it, so restarts and blips stay quiet. One message when it starts, one when it clears, optional
|
||||||
reminders. Active alerts are stored, so restarting Hammerhead doesn't repeat them, and an alert is
|
reminders. Active alerts are stored, so restarting Hammerhead doesn't repeat them, and an alert is
|
||||||
@@ -227,6 +230,20 @@ headers, Host rewrite, HTTPS to the backend, certificate status per port.
|
|||||||
record it shows. Mail is signed by From domain (a parent domain's key covers subdomains); a message
|
record it shows. Mail is signed by From domain (a parent domain's key covers subdomains); a message
|
||||||
your mail server already signed for that domain is left alone, and a signing problem sends the mail
|
your mail server already signed for that domain is left alone, and a signing problem sends the mail
|
||||||
unsigned rather than dropping it. Hammerhead checks the DNS record and alerts if it's missing or wrong.
|
unsigned rather than dropping it. Hammerhead checks the DNS record and alerts if it's missing or wrong.
|
||||||
|
- **Deliverability dashboard.** The Mail egress page shows a checklist per relay, each line with a fix:
|
||||||
|
reverse DNS, HELO, outbound port 25, every DKIM key, and for each domain you sign mail for, SPF
|
||||||
|
(evaluated against the relay's IP, including `include:`/`redirect=` and the 10-lookup limit), DMARC
|
||||||
|
(missing, monitoring-only, no `rua`, enforcing) and MTA-STS. The edge also looks the IP up on DNS
|
||||||
|
blocklists (Spamhaus ZEN, SpamCop, Barracuda, PSBL) and counts the last 24 hours of delivery outcomes
|
||||||
|
(delivered, deferred, refused, by recipient domain, with the latest refusal messages and what they
|
||||||
|
usually mean). Checks run every 15 minutes; failures raise alerts and clear when fixed.
|
||||||
|
- **DMARC reports.** Put `rua=mailto:...` in a domain's DMARC record, and upload the daily aggregate
|
||||||
|
reports receivers send (the email itself, or its .xml/.gz/.zip attachment, several at once) on the same
|
||||||
|
page. Hammerhead parses them, keeps 90 days, and shows per domain how much mail passed, which sending
|
||||||
|
addresses fail (marking your own relays), and whether the policy can move from `none` to
|
||||||
|
`quarantine`/`reject`. `POST /api/orgs/<org>/dmarc` takes a report as the raw request body, so a mail
|
||||||
|
filter or script can forward reports automatically. Reports are only accepted for domains you have a
|
||||||
|
DKIM key for.
|
||||||
- **MTA-STS enforcement.** When a recipient domain publishes an `enforce` policy, mail only goes to the
|
- **MTA-STS enforcement.** When a recipient domain publishes an `enforce` policy, mail only goes to the
|
||||||
servers it lists, over TLS with a verified certificate; otherwise it's a temporary failure so your
|
servers it lists, over TLS with a verified certificate; otherwise it's a temporary failure so your
|
||||||
server retries. (`testing` mode doesn't block.) Policies are cached for their `max_age`. The policy
|
server retries. (`testing` mode doesn't block.) Policies are cached for their `max_age`. The policy
|
||||||
|
|||||||
@@ -146,6 +146,27 @@ func (s *Server) problemsLocked(now time.Time) map[string]problem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, x := range s.st.MailEgress {
|
||||||
|
if !x.Enabled || edgeDown[x.EdgeID] || siteDown[x.SiteID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chk, ok := s.mailChecks[x.ID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, f := range s.mailEgressFindingsLocked(x, chk) {
|
||||||
|
// ptr, helo, port 25 and dkim have alerts of their own above
|
||||||
|
if !f.alertWorthy() || f.ID == "ptr" || f.ID == "helo" || f.ID == "port25" || strings.HasPrefix(f.ID, "dkim:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("%s: %s: %s", x.Name, f.Label, f.Detail)
|
||||||
|
if f.Hint != "" {
|
||||||
|
msg += ". " + f.Hint
|
||||||
|
}
|
||||||
|
add("mailfind:"+x.ID+":"+f.ID, x.OrgID, "mail", x.Name, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.dmarcProblemsLocked(add)
|
||||||
for _, k := range s.st.DKIM {
|
for _, k := range s.st.DKIM {
|
||||||
for _, x := range s.st.MailEgress {
|
for _, x := range s.st.MailEgress {
|
||||||
if x.OrgID != k.OrgID || !x.Enabled || edgeDown[x.EdgeID] {
|
if x.OrgID != k.OrgID || !x.Enabled || edgeDown[x.EdgeID] {
|
||||||
@@ -185,6 +206,13 @@ func (s *Server) judgeableLocked(st *alertState) bool {
|
|||||||
}
|
}
|
||||||
_, known := s.mailStatus[rest]
|
_, known := s.mailStatus[rest]
|
||||||
return known
|
return known
|
||||||
|
case "mailfind":
|
||||||
|
id, _, _ := strings.Cut(rest, ":")
|
||||||
|
if s.findMailEgress(id) == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, known := s.mailChecks[id]
|
||||||
|
return known
|
||||||
case "mailptr", "mailhelo", "mail25":
|
case "mailptr", "mailhelo", "mail25":
|
||||||
if s.findMailEgress(rest) == nil {
|
if s.findMailEgress(rest) == nil {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS alert_events (
|
|||||||
key TEXT, status TEXT, name TEXT, message TEXT
|
key TEXT, status TEXT, name TEXT, message TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS alert_events_org ON alert_events (org_id, id);
|
CREATE INDEX IF NOT EXISTS alert_events_org ON alert_events (org_id, id);
|
||||||
`
|
` + dmarcSchema
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
sql *sql.DB
|
sql *sql.DB
|
||||||
@@ -547,6 +547,7 @@ func (d *DB) queryAudit(org string, limit int, before int64) []AuditEntry {
|
|||||||
|
|
||||||
// purge deletes logs older than the retention periods.
|
// purge deletes logs older than the retention periods.
|
||||||
func (d *DB) purge(accessDays, auditDays int) {
|
func (d *DB) purge(accessDays, auditDays int) {
|
||||||
|
d.purgeDMARC(time.Now().AddDate(0, 0, -dmarcKeepDays))
|
||||||
if accessDays > 0 {
|
if accessDays > 0 {
|
||||||
d.sql.Exec(`DELETE FROM access_log WHERE ts < ?`, ms(time.Now().AddDate(0, 0, -accessDays)))
|
d.sql.Exec(`DELETE FROM access_log WHERE ts < ?`, ms(time.Now().AddDate(0, 0, -accessDays)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Finding is one line of a mail egress's deliverability checklist: what was
|
||||||
|
// checked, how it came out, and what to do about it. The edge produces the raw
|
||||||
|
// results; the findings put old and new checks in one shape for the dashboard
|
||||||
|
// and the alerts.
|
||||||
|
type Finding struct {
|
||||||
|
ID string `json:"id"` // stable, e.g. "ptr", "spf:example.com"
|
||||||
|
Label string `json:"label"` // "Reverse DNS", "SPF"
|
||||||
|
Domain string `json:"domain,omitempty"` // the sending domain, for per-domain checks
|
||||||
|
Status string `json:"status"` // pass, warn, fail or none (nothing published)
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertWorthy says whether a finding should raise an alert: failures do, and so
|
||||||
|
// does a missing SPF or DMARC record (bulk senders are required to publish both).
|
||||||
|
// Warnings and a missing MTA-STS policy are advice, not problems.
|
||||||
|
func (f Finding) alertWorthy() bool {
|
||||||
|
if f.Status == model.FindingFail {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return f.Status == model.FindingNone && (strings.HasPrefix(f.ID, "spf:") || strings.HasPrefix(f.ID, "dmarc:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// minOutcomeSample is how many messages a window needs before its failure rate says anything.
|
||||||
|
const minOutcomeSample = 10
|
||||||
|
|
||||||
|
func deliverabilityFindings(x *model.MailEgress, chk model.MailCheck, keys []*model.DKIMKey) []Finding {
|
||||||
|
var out []Finding
|
||||||
|
add := func(f Finding) { out = append(out, f) }
|
||||||
|
|
||||||
|
if chk.PTRMatchesHelo {
|
||||||
|
add(Finding{ID: "ptr", Label: "Reverse DNS", Status: model.FindingPass, Detail: x.SourceIP + " resolves back to " + x.HeloName})
|
||||||
|
} else {
|
||||||
|
found := "no reverse DNS record"
|
||||||
|
if len(chk.PTR) > 0 {
|
||||||
|
found = "reverse DNS is " + strings.Join(chk.PTR, ", ")
|
||||||
|
}
|
||||||
|
add(Finding{ID: "ptr", Label: "Reverse DNS", Status: model.FindingFail, Detail: found + ", not " + x.HeloName,
|
||||||
|
Hint: fmt.Sprintf("Reverse DNS is set at your hosting provider, not in your DNS zone: set the PTR record of %s to %s.", x.SourceIP, x.HeloName)})
|
||||||
|
}
|
||||||
|
if chk.HeloResolves {
|
||||||
|
add(Finding{ID: "helo", Label: "HELO name", Status: model.FindingPass, Detail: x.HeloName + " resolves to " + x.SourceIP})
|
||||||
|
} else {
|
||||||
|
add(Finding{ID: "helo", Label: "HELO name", Status: model.FindingFail, Detail: x.HeloName + " doesn't resolve to " + x.SourceIP,
|
||||||
|
Hint: fmt.Sprintf("Add an A record for %s pointing at %s (an AAAA record for an IPv6 address).", x.HeloName, x.SourceIP)})
|
||||||
|
}
|
||||||
|
if chk.Port25 == "open" {
|
||||||
|
add(Finding{ID: "port25", Label: "Outgoing port 25", Status: model.FindingPass, Detail: "connections to other mail servers work"})
|
||||||
|
} else if chk.Port25 != "" {
|
||||||
|
add(Finding{ID: "port25", Label: "Outgoing port 25", Status: model.FindingFail, Detail: chk.Port25,
|
||||||
|
Hint: "Ask your hosting provider to unblock outgoing port 25 for this address; many VPS hosts block it until you ask."})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.OrgID != x.OrgID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := k.Selector + "._domainkey." + k.Domain
|
||||||
|
switch chk.DKIM[k.ID] {
|
||||||
|
case "published":
|
||||||
|
add(Finding{ID: "dkim:" + k.ID, Label: "DKIM", Domain: k.Domain, Status: model.FindingPass, Detail: name + " is published"})
|
||||||
|
case "missing":
|
||||||
|
add(Finding{ID: "dkim:" + k.ID, Label: "DKIM", Domain: k.Domain, Status: model.FindingFail, Detail: name + " isn't in DNS",
|
||||||
|
Hint: "Publish the TXT record shown under DKIM signing; signed mail fails verification until it exists."})
|
||||||
|
case "mismatch":
|
||||||
|
add(Finding{ID: "dkim:" + k.ID, Label: "DKIM", Domain: k.Domain, Status: model.FindingFail, Detail: name + " holds a different key",
|
||||||
|
Hint: "Replace the TXT record's value with the one shown under DKIM signing; it was probably left over from an older key."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doms := make([]string, 0, len(chk.Domains))
|
||||||
|
for d := range chk.Domains {
|
||||||
|
doms = append(doms, d)
|
||||||
|
}
|
||||||
|
sort.Strings(doms)
|
||||||
|
for _, d := range doms {
|
||||||
|
c := chk.Domains[d]
|
||||||
|
add(Finding{ID: "spf:" + d, Label: "SPF", Domain: d, Status: c.SPF.Status, Detail: c.SPF.Detail, Hint: c.SPF.Hint})
|
||||||
|
add(Finding{ID: "dmarc:" + d, Label: "DMARC", Domain: d, Status: c.DMARC.Status, Detail: c.DMARC.Detail, Hint: c.DMARC.Hint})
|
||||||
|
add(Finding{ID: "mtasts:" + d, Label: "MTA-STS", Domain: d, Status: c.MTASTS.Status, Detail: c.MTASTS.Detail, Hint: c.MTASTS.Hint})
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case len(chk.Blocklists) > 0:
|
||||||
|
add(Finding{ID: "blocklist", Label: "Blocklists", Status: model.FindingFail, Detail: x.SourceIP + " is listed on " + strings.Join(chk.Blocklists, ", "),
|
||||||
|
Hint: "Look the address up on each list's website to see why it was listed and request removal. Until then expect mail to be rejected or sent to spam; check that no one is relaying spam through this server."})
|
||||||
|
case chk.Listable > 0:
|
||||||
|
add(Finding{ID: "blocklist", Label: "Blocklists", Status: model.FindingPass, Detail: fmt.Sprintf("%s isn't on any of the %d blocklists checked", x.SourceIP, chk.Listable)})
|
||||||
|
case chk.Port25 != "" || chk.PTR != nil:
|
||||||
|
add(Finding{ID: "blocklist", Label: "Blocklists", Status: model.FindingWarn, Detail: "no blocklist answered, so the address couldn't be checked",
|
||||||
|
Hint: "Blocklists often refuse queries from public DNS resolvers; the edge needs a resolver of its own, or its provider's, to check them."})
|
||||||
|
}
|
||||||
|
|
||||||
|
if o := chk.Outcomes; o != nil {
|
||||||
|
if total := o.Delivered + o.Deferred + o.Failed; total >= minOutcomeSample {
|
||||||
|
bad := float64(o.Deferred+o.Failed) / float64(total)
|
||||||
|
detail := fmt.Sprintf("%d of %d messages in the last day were delivered (%d deferred, %d refused)", o.Delivered, total, o.Deferred, o.Failed)
|
||||||
|
hint := outcomeHint(o)
|
||||||
|
switch {
|
||||||
|
case bad > 0.3:
|
||||||
|
add(Finding{ID: "outcomes", Label: "Delivery", Status: model.FindingFail, Detail: detail, Hint: hint})
|
||||||
|
case bad > 0.1:
|
||||||
|
add(Finding{ID: "outcomes", Label: "Delivery", Status: model.FindingWarn, Detail: detail, Hint: hint})
|
||||||
|
default:
|
||||||
|
add(Finding{ID: "outcomes", Label: "Delivery", Status: model.FindingPass, Detail: detail})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// outcomeHint explains the latest refusal in terms of what to fix.
|
||||||
|
func outcomeHint(o *model.MailOutcomes) string {
|
||||||
|
if len(o.Recent) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
r := o.Recent[0]
|
||||||
|
low := strings.ToLower(r.Text)
|
||||||
|
advice := "Read the message, it usually says what the receiver objected to."
|
||||||
|
switch {
|
||||||
|
case strings.Contains(low, "spf") || strings.Contains(low, "dmarc") || strings.Contains(low, "dkim") || strings.Contains(low, "authenticat"):
|
||||||
|
advice = "The receiver wants authentication to pass: check the SPF, DKIM and DMARC lines above."
|
||||||
|
case strings.Contains(low, "blocklist") || strings.Contains(low, "blacklist") || strings.Contains(low, "rbl") || strings.Contains(low, "listed") || strings.Contains(low, "spamhaus"):
|
||||||
|
advice = "The receiver rejected the address as listed on a blocklist: see the Blocklists line and request delisting."
|
||||||
|
case strings.Contains(low, "greylist") || strings.Contains(low, "try again") || strings.Contains(low, "temporar"):
|
||||||
|
advice = "That receiver is delaying unfamiliar senders; these retry and normally get through."
|
||||||
|
case strings.Contains(low, "rate") || strings.Contains(low, "too many") || strings.Contains(low, "throttl"):
|
||||||
|
advice = "The receiver is limiting how fast you send to it; slow the sending or lower the relay's per-minute limit."
|
||||||
|
case strings.Contains(low, "ptr") || strings.Contains(low, "reverse"):
|
||||||
|
advice = "The receiver objects to the reverse DNS: see the Reverse DNS line."
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Latest from %s: %d %s. %s", r.Domain, r.Code, r.Text, advice)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func find(fs []Finding, id string) *Finding {
|
||||||
|
for i := range fs {
|
||||||
|
if fs[i].ID == id {
|
||||||
|
return &fs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindingsCarryFixes(t *testing.T) {
|
||||||
|
x := &model.MailEgress{ID: "x1", OrgID: "o", SourceIP: "203.0.113.7", HeloName: "mail.example.com"}
|
||||||
|
keys := []*model.DKIMKey{{ID: "k1", OrgID: "o", Domain: "example.com", Selector: "s1"}, {ID: "k2", OrgID: "other", Domain: "x.test", Selector: "s"}}
|
||||||
|
chk := model.MailCheck{
|
||||||
|
PTR: []string{"host.vps.test"}, Port25: "can't connect",
|
||||||
|
DKIM: map[string]string{"k1": "mismatch", "k2": "missing"},
|
||||||
|
Domains: map[string]model.DomainCheck{"example.com": {
|
||||||
|
SPF: model.MailFinding{Status: model.FindingFail, Detail: "no", Hint: "add ip4"},
|
||||||
|
DMARC: model.MailFinding{Status: model.FindingNone, Detail: "none", Hint: "publish"},
|
||||||
|
MTASTS: model.MailFinding{Status: model.FindingNone, Detail: "none", Hint: "optional"},
|
||||||
|
}},
|
||||||
|
Blocklists: []string{"zen.spamhaus.org"},
|
||||||
|
}
|
||||||
|
fs := deliverabilityFindings(x, chk, keys)
|
||||||
|
for id, want := range map[string]string{"ptr": "fail", "helo": "fail", "port25": "fail", "dkim:k1": "fail", "spf:example.com": "fail", "blocklist": "fail"} {
|
||||||
|
f := find(fs, id)
|
||||||
|
if f == nil || f.Status != want {
|
||||||
|
t.Errorf("%s: %+v", id, f)
|
||||||
|
} else if want == "fail" && f.Hint == "" {
|
||||||
|
t.Errorf("%s fails without saying how to fix it", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if find(fs, "dkim:k2") != nil {
|
||||||
|
t.Error("another org's key leaked into the checklist")
|
||||||
|
}
|
||||||
|
if f := find(fs, "ptr"); !strings.Contains(f.Detail, "host.vps.test") || !strings.Contains(f.Hint, "mail.example.com") {
|
||||||
|
t.Errorf("ptr: %+v", f)
|
||||||
|
}
|
||||||
|
if find(fs, "mtasts:example.com").alertWorthy() {
|
||||||
|
t.Error("a missing MTA-STS policy is advice, not an alert")
|
||||||
|
}
|
||||||
|
if !find(fs, "dmarc:example.com").alertWorthy() {
|
||||||
|
t.Error("a missing DMARC record should alert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindingsAllGood(t *testing.T) {
|
||||||
|
x := &model.MailEgress{OrgID: "o", SourceIP: "203.0.113.7", HeloName: "mail.example.com"}
|
||||||
|
chk := model.MailCheck{PTRMatchesHelo: true, HeloResolves: true, Port25: "open", Listable: 4,
|
||||||
|
Outcomes: &model.MailOutcomes{Delivered: 100}}
|
||||||
|
for _, f := range deliverabilityFindings(x, chk, nil) {
|
||||||
|
if f.Status != model.FindingPass || f.alertWorthy() {
|
||||||
|
t.Errorf("%+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutcomeFindings(t *testing.T) {
|
||||||
|
x := &model.MailEgress{OrgID: "o", SourceIP: "203.0.113.7", HeloName: "h"}
|
||||||
|
status := func(o *model.MailOutcomes) string {
|
||||||
|
f := find(deliverabilityFindings(x, model.MailCheck{Outcomes: o}, nil), "outcomes")
|
||||||
|
if f == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return f.Status
|
||||||
|
}
|
||||||
|
for name, c := range map[string]struct {
|
||||||
|
o *model.MailOutcomes
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"too few to judge": {&model.MailOutcomes{Delivered: 2, Failed: 5}, ""},
|
||||||
|
"healthy": {&model.MailOutcomes{Delivered: 95, Deferred: 5}, "pass"},
|
||||||
|
"some trouble": {&model.MailOutcomes{Delivered: 80, Failed: 20}, "warn"},
|
||||||
|
"mostly refused": {&model.MailOutcomes{Delivered: 20, Failed: 80}, "fail"},
|
||||||
|
} {
|
||||||
|
if got := status(c.o); got != c.want {
|
||||||
|
t.Errorf("%s: %q, want %q", name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h := outcomeHint(&model.MailOutcomes{Recent: []model.OutcomeFailure{{Domain: "gmail.com", Code: 550, Text: "5.7.26 DMARC authentication failed"}}})
|
||||||
|
if !strings.Contains(h, "gmail.com") || !strings.Contains(h, "SPF, DKIM and DMARC") {
|
||||||
|
t.Errorf("hint: %q", h)
|
||||||
|
}
|
||||||
|
if outcomeHint(&model.MailOutcomes{}) != "" {
|
||||||
|
t.Error("no refusals, no hint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverabilityRaisesAlertsWithHints(t *testing.T) {
|
||||||
|
f, _ := dmarcFx(t)
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.mailChecks["x1"] = model.MailCheck{PTRMatchesHelo: true, HeloResolves: true, Port25: "open", Listable: 4,
|
||||||
|
Blocklists: []string{"bl.spamcop.net"},
|
||||||
|
Domains: map[string]model.DomainCheck{"example.com": {
|
||||||
|
SPF: model.MailFinding{Status: model.FindingFail, Detail: "SPF for example.com does not authorize 203.0.113.7", Hint: "Add ip4:203.0.113.7"},
|
||||||
|
DMARC: model.MailFinding{Status: model.FindingWarn, Detail: "monitoring"},
|
||||||
|
MTASTS: model.MailFinding{Status: model.FindingNone},
|
||||||
|
}}}
|
||||||
|
probs := f.s.problemsLocked(f.base)
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
spf, bl := probs["mailfind:x1:spf:example.com"], probs["mailfind:x1:blocklist"]
|
||||||
|
if !strings.Contains(spf.msg, "Add ip4:203.0.113.7") || !strings.Contains(bl.msg, "bl.spamcop.net") {
|
||||||
|
t.Fatalf("alerts should carry the fix: %+v %+v", spf, bl)
|
||||||
|
}
|
||||||
|
if _, noisy := probs["mailfind:x1:dmarc:example.com"]; noisy {
|
||||||
|
t.Error("a DMARC warning shouldn't alert")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixed: both clear
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.mailChecks["x1"] = model.MailCheck{PTRMatchesHelo: true, HeloResolves: true, Port25: "open", Listable: 4}
|
||||||
|
probs = f.s.problemsLocked(f.base)
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
for k := range probs {
|
||||||
|
if strings.HasPrefix(k, "mailfind:") {
|
||||||
|
t.Errorf("%s should have cleared", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/dmarc"
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DMARC aggregate reports. Receivers that handle mail claiming to be from your
|
||||||
|
// domain mail a daily summary to the rua= address in its DMARC record. Those
|
||||||
|
// emails are uploaded here (the raw .xml, .gz, .zip or the whole .eml), parsed,
|
||||||
|
// and kept for 90 days; the dashboard shows how much of the mail sent as your
|
||||||
|
// domain passed, and which senders are failing.
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxDMARCUpload = 8 << 20
|
||||||
|
dmarcKeepDays = 90
|
||||||
|
dmarcWindowDays = 30
|
||||||
|
dmarcAlertMinMsg = 20 // messages from our own relays before their pass rate says anything
|
||||||
|
dmarcAlertBelow = 0.9 // alert when less of our own mail than this passes DMARC
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- storage ----------
|
||||||
|
|
||||||
|
const dmarcSchema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS dmarc_reports (
|
||||||
|
org_id TEXT NOT NULL, domain TEXT NOT NULL, reporter TEXT NOT NULL, report_id TEXT NOT NULL,
|
||||||
|
begin_ts INTEGER NOT NULL, end_ts INTEGER NOT NULL, added INTEGER NOT NULL, data TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (org_id, domain, reporter, report_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS dmarc_end ON dmarc_reports (org_id, end_ts);
|
||||||
|
`
|
||||||
|
|
||||||
|
// addDMARC stores a report; it reports false if this exact report was already there.
|
||||||
|
func (d *DB) addDMARC(org string, r *dmarc.Report) (bool, error) {
|
||||||
|
data, err := json.Marshal(r)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
res, err := d.sql.Exec(`INSERT OR IGNORE INTO dmarc_reports (org_id, domain, reporter, report_id, begin_ts, end_ts, added, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
org, r.Domain, strings.ToLower(r.Org), r.ReportID, ms(r.Begin), ms(r.End), ms(time.Now()), string(data))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmarcReports returns an org's reports that ended after since.
|
||||||
|
func (d *DB) dmarcReports(org string, since time.Time) []*dmarc.Report {
|
||||||
|
rows, err := d.sql.Query(`SELECT data FROM dmarc_reports WHERE org_id = ? AND end_ts > ? ORDER BY end_ts`, org, ms(since))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*dmarc.Report
|
||||||
|
for rows.Next() {
|
||||||
|
var data string
|
||||||
|
var r dmarc.Report
|
||||||
|
if rows.Scan(&data) == nil && json.Unmarshal([]byte(data), &r) == nil {
|
||||||
|
out = append(out, &r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) purgeDMARC(before time.Time) {
|
||||||
|
d.sql.Exec(`DELETE FROM dmarc_reports WHERE end_ts < ?`, ms(before))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) deleteDMARCDomain(org, domain string) (int64, error) {
|
||||||
|
var res sql.Result
|
||||||
|
res, err := d.sql.Exec(`DELETE FROM dmarc_reports WHERE org_id = ? AND domain = ?`, org, domain)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- summary ----------
|
||||||
|
|
||||||
|
type dmarcSource struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Messages int `json:"messages"`
|
||||||
|
Passed int `json:"passed"` // messages that passed DMARC
|
||||||
|
DKIMPass int `json:"dkimPass"`
|
||||||
|
SPFPass int `json:"spfPass"`
|
||||||
|
Ours bool `json:"ours"` // the address is one of this org's mail egress IPs
|
||||||
|
Rejected int `json:"rejected"` // the receiver quarantined or rejected these
|
||||||
|
Reporters int `json:"reporters"` // how many receivers saw it
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dmarcDomain struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Reports int `json:"reports"`
|
||||||
|
Messages int `json:"messages"`
|
||||||
|
Passed int `json:"passed"`
|
||||||
|
OurMsgs int `json:"ourMessages"` // from our own relays
|
||||||
|
OurPassed int `json:"ourPassed"`
|
||||||
|
Policy string `json:"policy"` // the latest published policy a receiver reported
|
||||||
|
Pct int `json:"pct"`
|
||||||
|
Reporters []string `json:"reporters"`
|
||||||
|
First time.Time `json:"first"`
|
||||||
|
Last time.Time `json:"last"`
|
||||||
|
Sources []dmarcSource `json:"sources"`
|
||||||
|
Advice []string `json:"advice,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeDMARC totals reports per domain. ours holds the org's egress source IPs.
|
||||||
|
func summarizeDMARC(reports []*dmarc.Report, ours map[string]bool) []dmarcDomain {
|
||||||
|
type acc struct {
|
||||||
|
d *dmarcDomain
|
||||||
|
src map[string]*dmarcSource
|
||||||
|
srcRep map[string]map[string]bool
|
||||||
|
reporters map[string]bool
|
||||||
|
lastEnd time.Time
|
||||||
|
}
|
||||||
|
by := map[string]*acc{}
|
||||||
|
for _, r := range reports {
|
||||||
|
a := by[r.Domain]
|
||||||
|
if a == nil {
|
||||||
|
a = &acc{d: &dmarcDomain{Domain: r.Domain, First: r.Begin}, src: map[string]*dmarcSource{}, srcRep: map[string]map[string]bool{}, reporters: map[string]bool{}}
|
||||||
|
by[r.Domain] = a
|
||||||
|
}
|
||||||
|
d := a.d
|
||||||
|
d.Reports++
|
||||||
|
if r.Begin.Before(d.First) {
|
||||||
|
d.First = r.Begin
|
||||||
|
}
|
||||||
|
if r.End.After(a.lastEnd) { // the newest report's policy is the one in force
|
||||||
|
a.lastEnd, d.Last, d.Policy, d.Pct = r.End, r.End, r.Policy.P, r.Policy.Pct
|
||||||
|
}
|
||||||
|
a.reporters[r.Org] = true
|
||||||
|
for _, rec := range r.Records {
|
||||||
|
s := a.src[rec.SourceIP]
|
||||||
|
if s == nil {
|
||||||
|
s = &dmarcSource{IP: rec.SourceIP, Ours: ours[rec.SourceIP]}
|
||||||
|
a.src[rec.SourceIP] = s
|
||||||
|
a.srcRep[rec.SourceIP] = map[string]bool{}
|
||||||
|
}
|
||||||
|
a.srcRep[rec.SourceIP][r.Org] = true
|
||||||
|
s.Messages += rec.Count
|
||||||
|
d.Messages += rec.Count
|
||||||
|
if rec.Passed() {
|
||||||
|
s.Passed += rec.Count
|
||||||
|
d.Passed += rec.Count
|
||||||
|
}
|
||||||
|
if rec.DKIM == "pass" {
|
||||||
|
s.DKIMPass += rec.Count
|
||||||
|
}
|
||||||
|
if rec.SPF == "pass" {
|
||||||
|
s.SPFPass += rec.Count
|
||||||
|
}
|
||||||
|
if rec.Disposition == "quarantine" || rec.Disposition == "reject" {
|
||||||
|
s.Rejected += rec.Count
|
||||||
|
}
|
||||||
|
if s.Ours {
|
||||||
|
d.OurMsgs += rec.Count
|
||||||
|
if rec.Passed() {
|
||||||
|
d.OurPassed += rec.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []dmarcDomain
|
||||||
|
for _, a := range by {
|
||||||
|
d := a.d
|
||||||
|
for ip, s := range a.src {
|
||||||
|
s.Reporters = len(a.srcRep[ip])
|
||||||
|
s.Hint = sourceHint(s, d.Domain)
|
||||||
|
d.Sources = append(d.Sources, *s)
|
||||||
|
}
|
||||||
|
sort.Slice(d.Sources, func(i, j int) bool {
|
||||||
|
fi, fj := d.Sources[i].Messages-d.Sources[i].Passed, d.Sources[j].Messages-d.Sources[j].Passed
|
||||||
|
if fi != fj {
|
||||||
|
return fi > fj
|
||||||
|
}
|
||||||
|
return d.Sources[i].Messages > d.Sources[j].Messages
|
||||||
|
})
|
||||||
|
for r := range a.reporters {
|
||||||
|
d.Reporters = append(d.Reporters, r)
|
||||||
|
}
|
||||||
|
sort.Strings(d.Reporters)
|
||||||
|
d.Advice = dmarcAdvice(d)
|
||||||
|
out = append(out, *d)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceHint(s *dmarcSource, domain string) string {
|
||||||
|
failed := s.Messages - s.Passed
|
||||||
|
if failed == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case s.Ours && s.DKIMPass == 0 && s.SPFPass == 0:
|
||||||
|
return "Your own relay's mail failed both SPF and DKIM alignment. Check that " + domain + " has a published DKIM key here and an SPF record authorizing " + s.IP + "."
|
||||||
|
case s.Ours && s.DKIMPass == 0:
|
||||||
|
return "Mail from your relay only passes through SPF. Create and publish a DKIM key for " + domain + " so it survives forwarding."
|
||||||
|
case s.Ours:
|
||||||
|
return "Some mail from your relay failed. It is probably mail signed or sent under a different domain than the From address."
|
||||||
|
case s.Rejected >= failed:
|
||||||
|
return "Receivers are already quarantining or rejecting this sender. If it isn't yours, your policy is working; if it is, add it to your SPF record and set up DKIM with it."
|
||||||
|
default:
|
||||||
|
return "A sender outside your relays is failing. If it's a service you use (a newsletter or helpdesk tool), add its SPF include and set up its DKIM; if you don't recognise it, someone may be spoofing your domain and a stricter policy would stop that."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dmarcAdvice(d *dmarcDomain) []string {
|
||||||
|
var out []string
|
||||||
|
failedOther := 0
|
||||||
|
for _, s := range d.Sources {
|
||||||
|
if !s.Ours {
|
||||||
|
failedOther += s.Messages - s.Passed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.OurMsgs >= dmarcAlertMinMsg && float64(d.OurPassed)/float64(d.OurMsgs) < dmarcAlertBelow {
|
||||||
|
out = append(out, fmt.Sprintf("Only %d%% of the mail from your own relays passed DMARC. Fix this before tightening the policy.", 100*d.OurPassed/d.OurMsgs))
|
||||||
|
}
|
||||||
|
if d.Policy == "none" && d.Messages >= dmarcAlertMinMsg && failedOther == 0 && (d.OurMsgs == 0 || d.OurPassed == d.OurMsgs) {
|
||||||
|
out = append(out, "Everything that sent as "+d.Domain+" passed. You can move the policy from p=none to p=quarantine, then p=reject.")
|
||||||
|
}
|
||||||
|
if d.Policy == "none" && failedOther > 0 {
|
||||||
|
out = append(out, fmt.Sprintf("%d messages from other senders failed. Identify them below before you enforce a policy, or legitimate mail will be blocked.", failedOther))
|
||||||
|
}
|
||||||
|
if d.Policy != "none" && d.Pct > 0 && d.Pct < 100 {
|
||||||
|
out = append(out, fmt.Sprintf("The policy applies to only %d%% of mail (pct=%d). Raise it to 100 once reports look clean.", d.Pct, d.Pct))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- API ----------
|
||||||
|
|
||||||
|
func (s *Server) orgEgressIPsLocked(org string) map[string]bool {
|
||||||
|
ips := map[string]bool{}
|
||||||
|
for _, x := range s.st.MailEgress {
|
||||||
|
if x.OrgID == org {
|
||||||
|
ips[x.SourceIP] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dmarcDomainsLocked(org string) map[string]bool {
|
||||||
|
doms := map[string]bool{}
|
||||||
|
for _, k := range s.st.DKIM {
|
||||||
|
if k.OrgID == org {
|
||||||
|
doms[strings.ToLower(k.Domain)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doms
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmarcForOrg summarizes an org's recent reports. It reads the database without holding s.mu.
|
||||||
|
func (s *Server) dmarcForOrg(org string, ours map[string]bool) []dmarcDomain {
|
||||||
|
return summarizeDMARC(s.db.dmarcReports(org, time.Now().AddDate(0, 0, -dmarcWindowDays)), ours)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listDMARC(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
s.mu.Lock()
|
||||||
|
ours := s.orgEgressIPsLocked(c.Org.ID)
|
||||||
|
s.mu.Unlock()
|
||||||
|
out := s.dmarcForOrg(c.Org.ID, ours)
|
||||||
|
if out == nil {
|
||||||
|
out = []dmarcDomain{}
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadDMARC accepts one or more reports in the request body (a report, or a whole email with one attached).
|
||||||
|
func (s *Server) uploadDMARC(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxDMARCUpload))
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 413, errors.New("that file is too large to be a DMARC report"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rep, err := dmarc.Parse(body)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
known := s.dmarcDomainsLocked(c.Org.ID)[rep.Domain]
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !known {
|
||||||
|
failf(w, 400, "this report is about %s, which isn't a domain you have a DKIM key for. Create the key first, or check the report is yours.", rep.Domain)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
added, err := s.db.addDMARC(c.Org.ID, rep)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if added {
|
||||||
|
s.audit(c.Org.ID, c.Actor, "dmarc.upload", rep.Domain+" from "+rep.Org)
|
||||||
|
}
|
||||||
|
msgs := 0
|
||||||
|
for _, rec := range rep.Records {
|
||||||
|
msgs += rec.Count
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"domain": rep.Domain, "reporter": rep.Org, "messages": msgs, "duplicate": !added})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteDMARC(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||||
|
domain := strings.ToLower(r.PathValue("domain"))
|
||||||
|
if _, err := s.db.deleteDMARCDomain(c.Org.ID, domain); err != nil {
|
||||||
|
fail(w, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.audit(c.Org.ID, c.Actor, "dmarc.delete", domain)
|
||||||
|
ok(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmarcProblems derives the alerts from reports: the mail our own relays send
|
||||||
|
// as a domain is failing DMARC. Caller holds s.mu; the database is read inside,
|
||||||
|
// which takes no server locks.
|
||||||
|
func (s *Server) dmarcProblemsLocked(add func(key, org, kind, name, msg string)) {
|
||||||
|
orgs := map[string]bool{}
|
||||||
|
for _, k := range s.st.DKIM {
|
||||||
|
orgs[k.OrgID] = true
|
||||||
|
}
|
||||||
|
for org := range orgs {
|
||||||
|
for _, d := range s.dmarcForOrg(org, s.orgEgressIPsLocked(org)) {
|
||||||
|
if d.OurMsgs >= dmarcAlertMinMsg && float64(d.OurPassed)/float64(d.OurMsgs) < dmarcAlertBelow {
|
||||||
|
add("dmarcrate:"+org+":"+d.Domain, org, "mail", d.Domain,
|
||||||
|
fmt.Sprintf("Only %d%% of the mail your relays sent as %s passed DMARC in the last %d days (%d of %d messages). Open Mail → DMARC reports for the failing senders.",
|
||||||
|
100*d.OurPassed/d.OurMsgs, d.Domain, dmarcWindowDays, d.OurPassed, d.OurMsgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- findings for the dashboard ----------
|
||||||
|
|
||||||
|
// mailEgressFindings lists the checklist for one egress. Caller holds s.mu.
|
||||||
|
func (s *Server) mailEgressFindingsLocked(x *model.MailEgress, chk model.MailCheck) []Finding {
|
||||||
|
return deliverabilityFindings(x, chk, s.st.DKIM)
|
||||||
|
}
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
package hammerhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/reef/dmarc"
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func reportXML(domain, id, reporter string, rows ...[4]string) string { // ip, count, dkim, spf
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(`<feedback><report_metadata><org_name>` + reporter + `</org_name><report_id>` + id + `</report_id><date_range><begin>` +
|
||||||
|
strconv.FormatInt(time.Now().Add(-26*time.Hour).Unix(), 10) + `</begin><end>` + strconv.FormatInt(time.Now().Add(-2*time.Hour).Unix(), 10) + `</end></date_range></report_metadata><policy_published><domain>` + domain + `</domain><p>none</p><pct>100</pct></policy_published>`)
|
||||||
|
for _, r := range rows {
|
||||||
|
disp := "none"
|
||||||
|
if r[2] != "pass" && r[3] != "pass" {
|
||||||
|
disp = "quarantine"
|
||||||
|
}
|
||||||
|
sb.WriteString(`<record><row><source_ip>` + r[0] + `</source_ip><count>` + r[1] + `</count><policy_evaluated><disposition>` + disp + `</disposition><dkim>` + r[2] + `</dkim><spf>` + r[3] + `</spf></policy_evaluated></row><identifiers><header_from>` + domain + `</header_from></identifiers></record>`)
|
||||||
|
}
|
||||||
|
sb.WriteString(`</feedback>`)
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func gzipped(s string) []byte {
|
||||||
|
var b bytes.Buffer
|
||||||
|
w := gzip.NewWriter(&b)
|
||||||
|
w.Write([]byte(s))
|
||||||
|
w.Close()
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmarcFx is an org that sends as example.com from 203.0.113.7.
|
||||||
|
func dmarcFx(t *testing.T) (*fx, *orgCtx) {
|
||||||
|
f := newFx(t)
|
||||||
|
f.s.mu.Lock()
|
||||||
|
f.s.st.DKIM = append(f.s.st.DKIM, &model.DKIMKey{ID: "k1", OrgID: f.org, Domain: "example.com", Selector: "s1"})
|
||||||
|
f.s.st.MailEgress = append(f.s.st.MailEgress, &model.MailEgress{ID: "x1", OrgID: f.org, Name: "relay", SiteID: "s1", EdgeID: "e1", SourceIP: "203.0.113.7", HeloName: "mail.example.com", Enabled: true})
|
||||||
|
org := f.s.findOrg(f.org)
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
return f, &orgCtx{Org: *org, Actor: "test"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func upload(t *testing.T, f *fx, c *orgCtx, body []byte) (int, map[string]any) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
f.s.uploadDMARC(w, httptest.NewRequest("POST", "/", bytes.NewReader(body)), c)
|
||||||
|
var out map[string]any
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &out)
|
||||||
|
return w.Code, out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadAndSummarize(t *testing.T) {
|
||||||
|
f, c := dmarcFx(t)
|
||||||
|
code, out := upload(t, f, c, gzipped(reportXML("example.com", "r1", "google.com", [4]string{"203.0.113.7", "30", "pass", "pass"}, [4]string{"198.51.100.9", "5", "fail", "fail"})))
|
||||||
|
if code != 200 || out["messages"] != float64(35) || out["duplicate"] != false {
|
||||||
|
t.Fatalf("%d %v", code, out)
|
||||||
|
}
|
||||||
|
if _, out = upload(t, f, c, []byte(reportXML("example.com", "r1", "google.com"))); out["duplicate"] != true {
|
||||||
|
t.Errorf("the same report twice should be a duplicate: %v", out)
|
||||||
|
}
|
||||||
|
upload(t, f, c, []byte(reportXML("example.com", "r2", "yahoo.com", [4]string{"203.0.113.7", "10", "pass", "fail"})))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
f.s.listDMARC(w, httptest.NewRequest("GET", "/", nil), c)
|
||||||
|
var doms []dmarcDomain
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &doms); err != nil || len(doms) != 1 {
|
||||||
|
t.Fatalf("%v %s", err, w.Body.String())
|
||||||
|
}
|
||||||
|
d := doms[0]
|
||||||
|
if d.Reports != 2 || d.Messages != 45 || d.Passed != 40 || d.OurMsgs != 40 || d.OurPassed != 40 {
|
||||||
|
t.Errorf("totals: %+v", d)
|
||||||
|
}
|
||||||
|
if len(d.Sources) != 2 || d.Sources[0].IP != "198.51.100.9" || d.Sources[0].Ours || d.Sources[0].Hint == "" {
|
||||||
|
t.Errorf("failing sources should come first with a hint: %+v", d.Sources)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(d.Advice, " "), "failed") {
|
||||||
|
t.Errorf("advice should flag the unknown sender: %v", d.Advice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadRefusals(t *testing.T) {
|
||||||
|
f, c := dmarcFx(t)
|
||||||
|
if code, _ := upload(t, f, c, []byte("garbage")); code != 400 {
|
||||||
|
t.Errorf("garbage: %d", code)
|
||||||
|
}
|
||||||
|
code, out := upload(t, f, c, []byte(reportXML("someone-else.org", "r9", "google.com", [4]string{"1.2.3.4", "1", "pass", "pass"})))
|
||||||
|
if code != 400 || !strings.Contains(out["error"].(string), "someone-else.org") {
|
||||||
|
t.Errorf("a domain the org doesn't send from: %d %v", code, out)
|
||||||
|
}
|
||||||
|
big := make([]byte, maxDMARCUpload+1)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
f.s.uploadDMARC(w, httptest.NewRequest("POST", "/", bytes.NewReader(big)), c)
|
||||||
|
if w.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("oversize: %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportsStayInTheirOrg(t *testing.T) {
|
||||||
|
f, c := dmarcFx(t)
|
||||||
|
upload(t, f, c, []byte(reportXML("example.com", "r1", "google.com", [4]string{"203.0.113.7", "5", "pass", "pass"})))
|
||||||
|
if n := len(f.s.db.dmarcReports("another-org", time.Time{})); n != 0 {
|
||||||
|
t.Errorf("another org sees %d reports", n)
|
||||||
|
}
|
||||||
|
if n, _ := f.s.db.deleteDMARCDomain(f.org, "example.com"); n != 1 {
|
||||||
|
t.Errorf("delete removed %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummarizeAdvice(t *testing.T) {
|
||||||
|
rep := func(p string, pct int, rows ...dmarc.Record) *dmarc.Report {
|
||||||
|
return &dmarc.Report{Org: "g", ReportID: "1", Domain: "example.com", Begin: time.Unix(1, 0), End: time.Unix(2, 0), Policy: dmarc.Policy{P: p, Pct: pct}, Records: rows}
|
||||||
|
}
|
||||||
|
ours := map[string]bool{"203.0.113.7": true}
|
||||||
|
pass := dmarc.Record{SourceIP: "203.0.113.7", Count: 50, DKIM: "pass", SPF: "pass"}
|
||||||
|
|
||||||
|
got := summarizeDMARC([]*dmarc.Report{rep("none", 100, pass)}, ours)[0]
|
||||||
|
if !strings.Contains(strings.Join(got.Advice, " "), "p=quarantine") {
|
||||||
|
t.Errorf("a clean domain at p=none should be told to tighten: %v", got.Advice)
|
||||||
|
}
|
||||||
|
bad := dmarc.Record{SourceIP: "203.0.113.7", Count: 50, DKIM: "fail", SPF: "fail", Disposition: "quarantine"}
|
||||||
|
got = summarizeDMARC([]*dmarc.Report{rep("quarantine", 50, pass, bad)}, ours)[0]
|
||||||
|
joined := strings.Join(got.Advice, " ")
|
||||||
|
if !strings.Contains(joined, "50%") || !strings.Contains(joined, "pct=50") {
|
||||||
|
t.Errorf("advice: %v", got.Advice)
|
||||||
|
}
|
||||||
|
got = summarizeDMARC([]*dmarc.Report{rep("quarantine", 100, bad)}, ours)[0]
|
||||||
|
if got.Sources[0].Hint == "" || !strings.Contains(got.Sources[0].Hint, "SPF and DKIM") {
|
||||||
|
t.Errorf("own relay failing both: %q", got.Sources[0].Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnMailFailingDMARCRaisesAnAlert(t *testing.T) {
|
||||||
|
f, c := dmarcFx(t)
|
||||||
|
upload(t, f, c, []byte(reportXML("example.com", "r1", "google.com", [4]string{"203.0.113.7", "40", "fail", "fail"}, [4]string{"203.0.113.7", "10", "pass", "pass"})))
|
||||||
|
f.s.mu.Lock()
|
||||||
|
probs := f.s.problemsLocked(f.base)
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
p, ok := probs["dmarcrate:"+f.org+":example.com"]
|
||||||
|
if !ok || !strings.Contains(p.msg, "20%") {
|
||||||
|
t.Fatalf("expected a DMARC pass-rate alert, got %v", probs)
|
||||||
|
}
|
||||||
|
// a healthy domain raises none
|
||||||
|
upload(t, f, c, []byte(reportXML("example.com", "r2", "yahoo.com", [4]string{"203.0.113.7", "500", "pass", "pass"})))
|
||||||
|
f.s.mu.Lock()
|
||||||
|
probs = f.s.problemsLocked(f.base)
|
||||||
|
f.s.mu.Unlock()
|
||||||
|
if _, ok := probs["dmarcrate:"+f.org+":example.com"]; ok {
|
||||||
|
t.Errorf("alert should clear once the pass rate recovers: %v", probs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,6 +149,7 @@ type mailEgressView struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
StatusKnown bool `json:"statusKnown"`
|
StatusKnown bool `json:"statusKnown"`
|
||||||
Check *model.MailCheck `json:"check,omitempty"`
|
Check *model.MailCheck `json:"check,omitempty"`
|
||||||
|
Findings []Finding `json:"findings"` // the deliverability checklist, with fixes
|
||||||
}
|
}
|
||||||
|
|
||||||
// mailEgressViewsLocked lists an org's mail egress without secrets. Caller holds s.mu.
|
// mailEgressViewsLocked lists an org's mail egress without secrets. Caller holds s.mu.
|
||||||
@@ -164,6 +165,10 @@ func (s *Server) mailEgressViewsLocked(orgID string) []mailEgressView {
|
|||||||
if chk, ok := s.mailChecks[e.ID]; ok {
|
if chk, ok := s.mailChecks[e.ID]; ok {
|
||||||
c := chk
|
c := chk
|
||||||
v.Check = &c
|
v.Check = &c
|
||||||
|
v.Findings = s.mailEgressFindingsLocked(e, chk)
|
||||||
|
}
|
||||||
|
if v.Findings == nil {
|
||||||
|
v.Findings = []Finding{}
|
||||||
}
|
}
|
||||||
out = append(out, v)
|
out = append(out, v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ func (s *Server) metrics(w http.ResponseWriter, r *http.Request) {
|
|||||||
line("hammerhead_mail_check_ok", "gauge", "1 if the deliverability check passes", b2f(c.PTRMatchesHelo), "relay", x.Name, "org", org, "check", "reverse_dns")
|
line("hammerhead_mail_check_ok", "gauge", "1 if the deliverability check passes", b2f(c.PTRMatchesHelo), "relay", x.Name, "org", org, "check", "reverse_dns")
|
||||||
line("hammerhead_mail_check_ok", "gauge", "1 if the deliverability check passes", b2f(c.HeloResolves), "relay", x.Name, "org", org, "check", "helo_resolves")
|
line("hammerhead_mail_check_ok", "gauge", "1 if the deliverability check passes", b2f(c.HeloResolves), "relay", x.Name, "org", org, "check", "helo_resolves")
|
||||||
line("hammerhead_mail_check_ok", "gauge", "1 if the deliverability check passes", b2f(c.Port25 == "open"), "relay", x.Name, "org", org, "check", "port25")
|
line("hammerhead_mail_check_ok", "gauge", "1 if the deliverability check passes", b2f(c.Port25 == "open"), "relay", x.Name, "org", org, "check", "port25")
|
||||||
|
for _, f := range s.mailEgressFindingsLocked(x, c) {
|
||||||
|
if f.Domain != "" || f.ID == "blocklist" || f.ID == "outcomes" {
|
||||||
|
line("hammerhead_mail_finding_ok", "gauge", "1 if a per-domain, blocklist or delivery check passes", b2f(f.Status == "pass"), "relay", x.Name, "org", org, "check", f.Label, "domain", f.Domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
firing := map[string]int{}
|
firing := map[string]int{}
|
||||||
|
|||||||
@@ -156,6 +156,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/orgs/{org}/dkim", s.withOrg(true, s.listDKIM))
|
mux.HandleFunc("GET /api/orgs/{org}/dkim", s.withOrg(true, s.listDKIM))
|
||||||
mux.HandleFunc("POST /api/orgs/{org}/dkim", s.withOrg(true, s.createDKIM))
|
mux.HandleFunc("POST /api/orgs/{org}/dkim", s.withOrg(true, s.createDKIM))
|
||||||
mux.HandleFunc("DELETE /api/orgs/{org}/dkim/{id}", s.withOrg(true, s.deleteDKIM))
|
mux.HandleFunc("DELETE /api/orgs/{org}/dkim/{id}", s.withOrg(true, s.deleteDKIM))
|
||||||
|
mux.HandleFunc("GET /api/orgs/{org}/dmarc", s.withOrg(true, s.listDMARC))
|
||||||
|
mux.HandleFunc("POST /api/orgs/{org}/dmarc", s.withOrg(true, s.uploadDMARC))
|
||||||
|
mux.HandleFunc("DELETE /api/orgs/{org}/dmarc/{domain}", s.withOrg(true, s.deleteDMARC))
|
||||||
mux.HandleFunc("GET /api/orgs/{org}/logs", s.withOrg(true, s.orgLogs))
|
mux.HandleFunc("GET /api/orgs/{org}/logs", s.withOrg(true, s.orgLogs))
|
||||||
mux.HandleFunc("GET /api/orgs/{org}/stats", s.withOrg(false, s.orgStats))
|
mux.HandleFunc("GET /api/orgs/{org}/stats", s.withOrg(false, s.orgStats))
|
||||||
mux.HandleFunc("GET /api/orgs/{org}/traffic", s.withOrg(false, s.orgTraffic))
|
mux.HandleFunc("GET /api/orgs/{org}/traffic", s.withOrg(false, s.orgTraffic))
|
||||||
|
|||||||
+52
-16
@@ -1030,23 +1030,58 @@ views.domains = async () => {
|
|||||||
// ----- mail egress -----
|
// ----- mail egress -----
|
||||||
const spfFor = ip => ip.includes(':') ? `v=spf1 ip6:${ip} ~all` : `v=spf1 ip4:${ip} ~all`;
|
const spfFor = ip => ip.includes(':') ? `v=spf1 ip6:${ip} ~all` : `v=spf1 ip4:${ip} ~all`;
|
||||||
|
|
||||||
|
const findingBadge = f => f.status === 'pass' ? badge('Pass', 'success') : f.status === 'fail' ? badge('Fix', 'danger')
|
||||||
|
: f.status === 'none' ? (f.id.startsWith('mtasts:') ? badge('Optional') : badge('Set up', 'danger')) : badge('Check', 'warning');
|
||||||
|
const findingsOk = e => !!e.check && (e.findings || []).every(f => f.status === 'pass' || f.status === 'warn' || (f.status === 'none' && f.id.startsWith('mtasts:')));
|
||||||
|
|
||||||
function checklist(e) {
|
function checklist(e) {
|
||||||
const c = e.check, ip = e.sourceIp, helo = e.heloName;
|
const c = e.check, ip = e.sourceIp;
|
||||||
const item = (ok, title, fix) => h('div', {style:'display:flex;gap:10px;align-items:flex-start;margin-bottom:12px'},
|
const row = f => h('div', {style:'display:flex;gap:10px;align-items:flex-start;margin-bottom:12px'},
|
||||||
h('span', {style:'margin-top:1px;flex:none'}, ok === true ? badge('Pass', 'success') : ok === false ? badge('Fix', 'danger') : badge('Check', 'warning')),
|
h('span', {style:'margin-top:1px;flex:none'}, findingBadge(f)),
|
||||||
h('div', {}, h('div', {}, title), fix ? h('div', {class:'dim small'}, fix) : null));
|
h('div', {}, h('div', {}, h('strong', {}, f.label + (f.domain ? ' · ' + f.domain : '')), ' ', f.detail), f.hint ? h('div', {class:'dim small'}, f.hint) : null));
|
||||||
const body = h('div', {},
|
const o = c && c.outcomes;
|
||||||
|
return h('div', {},
|
||||||
c ? h('p', {class:'dim small'}, 'Checked ' + ago(c.checkedAt) + ' from the edge, using ' + ip + '.') : h('p', {class:'dim'}, 'No report yet. The edge sends one shortly after it picks up this egress.'),
|
c ? h('p', {class:'dim small'}, 'Checked ' + ago(c.checkedAt) + ' from the edge, using ' + ip + '.') : h('p', {class:'dim'}, 'No report yet. The edge sends one shortly after it picks up this egress.'),
|
||||||
c ? item(c.ptrMatchesHelo, 'Reverse DNS of ' + ip + (c.ptr.length ? ' is ' + c.ptr.join(', ') : ' is not set'),
|
...(e.findings || []).map(row),
|
||||||
c.ptrMatchesHelo ? null : `Receiving servers check this. Ask your VPS provider to set the reverse DNS (PTR) of ${ip} to ${helo}.`) : null,
|
!(e.findings || []).some(f => f.id.startsWith('spf:')) && c ? row({id:'spf', label:'SPF', status:'warn', detail:'Create a DKIM key for each domain you send from and the edge will check its SPF, DMARC and MTA-STS records too.', hint:'Meanwhile, publish this on each sending domain: ' + spfFor(ip)}) : null,
|
||||||
c ? item(c.heloResolves, helo + (c.heloResolves ? ' resolves to ' + ip : ' does not resolve to ' + ip),
|
o ? h('div', {style:'margin-top:16px'}, h('h3', {}, 'Delivery, last 24 hours'),
|
||||||
c.heloResolves ? null : `Create an A record: ${helo} → ${ip} (it must match the reverse DNS).`) : null,
|
h('p', {class:'small'}, `${o.delivered} delivered, ${o.deferred} deferred, ${o.failed} refused.`),
|
||||||
c ? item(c.port25 === 'open', 'Outbound port 25 from ' + ip + ': ' + (c.port25 === 'open' ? 'open' : 'blocked'),
|
Object.keys(o.byDomain || {}).length ? h('table', {}, h('thead', {}, h('tr', {}, ['Recipient domain', 'Delivered', 'Deferred', 'Refused'].map(t => h('th', {}, t)))),
|
||||||
c.port25 === 'open' ? null : c.port25) : null,
|
h('tbody', {}, Object.entries(o.byDomain).sort((a, b) => (b[1].failed + b[1].deferred) - (a[1].failed + a[1].deferred)).slice(0, 15).map(([d, n]) =>
|
||||||
item(null, 'SPF', 'On each domain you send from, publish a TXT record, or add this address to your existing one:'),
|
h('tr', {}, h('td', {}, d), h('td', {}, n.delivered), h('td', {}, n.deferred), h('td', {}, n.failed))))) : null,
|
||||||
cmdBox(spfFor(ip)),
|
(o.recent || []).length ? h('div', {style:'margin-top:8px'}, h('div', {class:'dim small'}, 'Latest problems'),
|
||||||
item(null, 'DKIM and DMARC', 'Keep signing mail with DKIM in your mail server, as before. This relay passes the message through untouched, so signatures stay valid.'));
|
...o.recent.slice(0, 5).map(r => h('div', {class:'mono small'}, `${ago(r.time)} · ${r.domain} · ${r.code} ${r.text}`))) : null) : null);
|
||||||
return body;
|
}
|
||||||
|
|
||||||
|
async function dmarcCard() {
|
||||||
|
const doms = await api('GET', O('/dmarc'));
|
||||||
|
const upload = h('input', {type:'file', multiple:true, accept:'.xml,.gz,.zip,.eml,.txt', style:'display:none'});
|
||||||
|
upload.onchange = act(async () => {
|
||||||
|
let added = 0, dup = 0, msgs = 0;
|
||||||
|
for (const f of upload.files) {
|
||||||
|
const r = await fetch(O('/dmarc'), {method:'POST', body: await f.arrayBuffer()});
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(f.name + ': ' + (j.error || r.statusText));
|
||||||
|
j.duplicate ? dup++ : (added++, msgs += j.messages);
|
||||||
|
}
|
||||||
|
upload.value = '';
|
||||||
|
toast(`${added} report${added === 1 ? '' : 's'} added (${msgs} messages)` + (dup ? `, ${dup} already uploaded` : ''));
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
const pct = (a, b) => b ? Math.round(100 * a / b) + '%' : '-';
|
||||||
|
return card('DMARC reports',
|
||||||
|
h('p', {class:'dim'}, 'Add rua=mailto:you@yourdomain to the DMARC record of a domain and receivers send a daily summary of every message that claimed to be from it. Upload those emails or their attachments (.xml, .gz, .zip or .eml) to see what passed and which senders are failing. Reports for the last 30 days are shown.'),
|
||||||
|
doms.length ? h('div', {}, ...doms.map(d => h('div', {style:'margin-bottom:18px'},
|
||||||
|
h('div', {class:'row-h'}, h('strong', {}, d.domain), ' ', badge('Policy: ' + (d.policy || '?')), ' ',
|
||||||
|
badge(pct(d.passed, d.messages) + ' passed', d.messages && d.passed / d.messages >= 0.9 ? 'success' : 'danger'),
|
||||||
|
h('span', {class:'dim small', style:'margin-left:8px'}, `${d.messages} messages, ${d.reports} reports from ${d.reporters.join(', ')}`),
|
||||||
|
h('button', {class:'ghost danger', style:'margin-left:auto', onclick: ask({title:`Delete the DMARC reports for ${d.domain}?`, body:'The uploaded reports are removed. You can upload them again.', confirm:'Delete reports'}, async () => { await api('DELETE', O('/dmarc/' + d.domain)); refresh(); })}, 'Delete')),
|
||||||
|
...(d.advice || []).map(a => h('p', {class:'small'}, a)),
|
||||||
|
h('table', {}, h('thead', {}, h('tr', {}, ['Sending address', 'Messages', 'Passed', 'DKIM', 'SPF', 'Fix'].map(t => h('th', {}, t)))),
|
||||||
|
h('tbody', {}, d.sources.slice(0, 12).map(s => h('tr', {}, h('td', {class:'mono'}, s.ip, s.ours ? h('span', {style:'margin-left:6px'}, badge('Your relay', 'accent')) : null),
|
||||||
|
h('td', {}, s.messages), h('td', {}, pct(s.passed, s.messages)), h('td', {}, pct(s.dkimPass, s.messages)), h('td', {}, pct(s.spfPass, s.messages)),
|
||||||
|
h('td', {class:'dim small'}, s.hint || '')))))))) : h('p', {class:'dim'}, 'No reports yet.'),
|
||||||
|
h('div', {class:'row-h'}, h('button', {class:'ghost', onclick:() => upload.click()}, icon('add', 16), 'Upload reports'), upload));
|
||||||
}
|
}
|
||||||
|
|
||||||
views.egress = async () => {
|
views.egress = async () => {
|
||||||
@@ -1063,7 +1098,7 @@ views.egress = async () => {
|
|||||||
else if (e.status) { tone = 'danger'; label = 'Error'; why = e.status; }
|
else if (e.status) { tone = 'danger'; label = 'Error'; why = e.status; }
|
||||||
else { tone = 'success'; label = 'Listening'; why = 'listening'; }
|
else { tone = 'success'; label = 'Listening'; why = 'listening'; }
|
||||||
}
|
}
|
||||||
const good = c && c.ptrMatchesHelo && c.heloResolves && c.port25 === 'open';
|
const good = findingsOk(e);
|
||||||
return h('tr', {},
|
return h('tr', {},
|
||||||
h('td', {title:why}, badge(label, tone)),
|
h('td', {title:why}, badge(label, tone)),
|
||||||
h('td', {}, e.name, e.statusKnown && e.status ? h('div', {class:'err small mono'}, e.status) : null,
|
h('td', {}, e.name, e.statusKnown && e.status ? h('div', {class:'err small mono'}, e.status) : null,
|
||||||
@@ -1082,6 +1117,7 @@ views.egress = async () => {
|
|||||||
!remote.length ? h('div', {class:'notice'}, 'This runs on a Bamboo site: add the network your mail server lives in on the Sites page and start Bamboo there first.') : null,
|
!remote.length ? h('div', {class:'notice'}, 'This runs on a Bamboo site: add the network your mail server lives in on the Sites page and start Bamboo there first.') : null,
|
||||||
h('div', {class:'card flush'}, table),
|
h('div', {class:'card flush'}, table),
|
||||||
await dkimCard(),
|
await dkimCard(),
|
||||||
|
await dmarcCard(),
|
||||||
card('How it works', h('p', {class:'dim'}, 'Bamboo opens a small SMTP relay on your mail server\'s network. Point your mail server at it, and each outgoing message is carried through the WireGuard tunnel to the edge, which delivers it straight to the recipient\'s mail server from the edge\'s IP, announcing itself with the HELO name you set. The recipient\'s replies come straight back, so your server\'s queue and retries work as usual.'),
|
card('How it works', h('p', {class:'dim'}, 'Bamboo opens a small SMTP relay on your mail server\'s network. Point your mail server at it, and each outgoing message is carried through the WireGuard tunnel to the edge, which delivers it straight to the recipient\'s mail server from the edge\'s IP, announcing itself with the HELO name you set. The recipient\'s replies come straight back, so your server\'s queue and retries work as usual.'),
|
||||||
cmdBox('# Postfix (main.cf)\nrelayhost = [127.0.0.1]:2525\n\n# Exim: use a smarthost router pointing at 127.0.0.1 port 2525')));
|
cmdBox('# Postfix (main.cf)\nrelayhost = [127.0.0.1]:2525\n\n# Exim: use a smarthost router pointing at 127.0.0.1 port 2525')));
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user