Compare commits
4
Commits
b46e0813fe
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fdafa28a7 | ||
|
|
8bc4d9cb3b | ||
|
|
3a86254cb6 | ||
|
|
ed9edbc9fd |
No files matched your search
@@ -33,7 +33,9 @@ Settings and secrets can live in a `.env` file instead of the command line; see
|
||||
- **Ports:** TCP, UDP, HTTP, HTTPS and TLS passthrough, one listener per IP and port, opened and closed live as you change them in the dashboard. Port ranges for TCP and UDP. Round-robin load balancing and failover across several targets, with health checks.
|
||||
- **PROXY protocol (v1/v2)** on TCP and TLS ports, so the target sees the real client address.
|
||||
- **Web:** automatic Let's Encrypt certificates, HTTP/2, websockets, and logins (account, password, PIN, emailed code, share links, path and IP rules) enforced in front of any site. Use the built-in proxy, or `-traefik` to let Traefik serve http/https (raw TCP, UDP and TLS-passthrough ports always stay with Blacktip, since Traefik can't open ports without a restart).
|
||||
- **Traffic analytics:** counts connections and bytes in and out for every port, and the busiest client addresses on each (the ten biggest per report, at most a few thousand tracked between reports), and sends them to Hammerhead with its regular report. Hammerhead keeps the history, draws the charts and raises traffic alerts. Nothing is stored on this machine, and a report that fails to arrive is added to the next one.
|
||||
- **Mail egress:** delivers your mail server's outgoing mail straight to the recipient's server from a public IP you choose, with the HELO name you set, opportunistic STARTTLS, optional DKIM signing, MTA-STS enforcement, a per-minute send limit and a deliverability checklist.
|
||||
- **Mail deliverability:** every 15 minutes the edge checks each egress the way a receiving server would and reports to Hammerhead: reverse DNS and HELO, port 25, DKIM keys, and for each sending domain whether SPF authorizes the source IP (evaluated properly, including `include:`, `redirect=` and the 10-lookup limit), whether DMARC is published and enforcing, and whether MTA-STS is set up. The source IP is also looked up on DNS blocklists (Spamhaus ZEN, SpamCop, Barracuda, PSBL). Each finding carries a hint saying what to change. Delivery outcomes (delivered, deferred, refused, per recipient domain, with the latest refusal messages) are counted for the last 24 hours and reported alongside. DMARC aggregate reports are uploaded and parsed in Hammerhead.
|
||||
- **Tunnel:** WireGuard through a userspace network stack, so no root, no kernel interface and no driver. It works on Windows, macOS and Linux, and sites behind NAT connect out to it.
|
||||
|
||||
## Deploying
|
||||
|
||||
+25
-5
@@ -56,6 +56,10 @@ type Config struct {
|
||||
MailLookupTXT func(name string) ([]string, error) // DNS TXT lookups (DKIM check, MTA-STS record)
|
||||
MailSTS func(domain string) (*STSPolicy, error) // replaces MTA-STS discovery
|
||||
MailRootCAs *x509.CertPool // trust anchors for verified TLS to mail servers
|
||||
MailLookupIP func(name string) ([]net.IP, error) // A/AAAA lookups (SPF a: and exists:)
|
||||
MailLookupMX func(domain string) ([]string, error) // MX host names (SPF mx:)
|
||||
MailLookupDNSBL func(name string) ([]string, error) // DNS blocklist queries; no error and no answers means "not listed"
|
||||
MailBlocklists []string // DNSBL zones to ask; nil uses the built-in list
|
||||
|
||||
// GetCertificate replaces Let's Encrypt for https ports (used by tests).
|
||||
GetCertificate func(*tls.ClientHelloInfo) (*tls.Certificate, error)
|
||||
@@ -73,6 +77,7 @@ type mapState struct {
|
||||
rr atomic.Uint64
|
||||
|
||||
conns, bytesIn, bytesOut atomic.Int64
|
||||
sources sourceTable // the same traffic per client address, for the top-sources list
|
||||
epoch atomic.Int64 // current login generation; kept out of sig so a bump doesn't rebuild the resource
|
||||
|
||||
relayLn net.Listener // traefik mode: loopback port that reaches the targets through the tunnel
|
||||
@@ -100,6 +105,7 @@ type Blacktip struct {
|
||||
mailChecks map[string]model.MailCheck
|
||||
mailMu sync.Mutex
|
||||
mailRate map[string][]time.Time
|
||||
outcomes outcomeLog
|
||||
sts stsCache
|
||||
local map[string]bool
|
||||
authKey string
|
||||
@@ -113,6 +119,7 @@ type Blacktip struct {
|
||||
|
||||
logq chan model.LogEntry
|
||||
reportMu sync.Mutex
|
||||
statsSince time.Time // when the traffic counters were last taken; guarded by reportMu
|
||||
updating atomic.Bool
|
||||
addrSig string // machine addresses a wildcard website was spread across, "" when none was
|
||||
updateMsg string // outcome of the update Hammerhead asked for, reported back
|
||||
@@ -602,19 +609,33 @@ func (b *Blacktip) report() {
|
||||
rep.UpdateMsg, rep.UpdateID = b.updateMsg, b.updateID
|
||||
rep.UpdateStage, rep.UpdatePct, rep.UpdateLog = b.updStage, b.updPct, append([]string(nil), b.updLog...)
|
||||
for id, chk := range b.mailChecks {
|
||||
chk.Outcomes = b.outcomes.summary(id, time.Now())
|
||||
rep.MailChecks[id] = chk
|
||||
}
|
||||
for id := range b.mail { // an egress that has sent mail but not been checked yet still reports its outcomes
|
||||
if _, ok := rep.MailChecks[id]; !ok {
|
||||
if o := b.outcomes.summary(id, time.Now()); o != nil {
|
||||
rep.MailChecks[id] = model.MailCheck{Outcomes: o}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, ms := range b.maps {
|
||||
st := model.MappingStatus{Error: b.status[id], Cert: b.certStatus(ms.m)}
|
||||
ms.mu.Lock()
|
||||
st.Targets = append([]model.TargetHealth(nil), ms.health...)
|
||||
ms.mu.Unlock()
|
||||
rep.Status[id] = st
|
||||
if c, in, out := ms.conns.Swap(0), ms.bytesIn.Swap(0), ms.bytesOut.Swap(0); c|in|out != 0 {
|
||||
rep.Stats[id] = model.Stat{Conns: c, BytesIn: in, BytesOut: out}
|
||||
if st, any := ms.takeStats(); any {
|
||||
rep.Stats[id] = st
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
now := time.Now()
|
||||
if !b.statsSince.IsZero() {
|
||||
rep.StatsSeconds = now.Sub(b.statsSince).Seconds()
|
||||
}
|
||||
prevSince := b.statsSince
|
||||
b.statsSince = now
|
||||
body, _ := json.Marshal(rep)
|
||||
req, _ := http.NewRequest("POST", b.cfg.Hammerhead+"/edge/report", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+b.cfg.Token)
|
||||
@@ -626,12 +647,11 @@ func (b *Blacktip) report() {
|
||||
b.mu.Lock()
|
||||
for id, st := range rep.Stats {
|
||||
if ms := b.maps[id]; ms != nil {
|
||||
ms.conns.Add(st.Conns)
|
||||
ms.bytesIn.Add(st.BytesIn)
|
||||
ms.bytesOut.Add(st.BytesOut)
|
||||
ms.restoreStats(st)
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
b.statsSince = prevSince // the traffic put back still spans from there
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
package blacktip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
// Deliverability checks for each sending domain and source IP: does SPF
|
||||
// authorize the IP, is there a DMARC policy worth enforcing, does the domain
|
||||
// publish MTA-STS, and is the IP on a DNS blocklist. Every finding carries a
|
||||
// hint saying what to change, since the people reading it run a mail server,
|
||||
// not a DNS course.
|
||||
|
||||
// defaultBlocklists are free-for-small-volume DNSBLs whose "listed" answers are reliable.
|
||||
var defaultBlocklists = []string{"zen.spamhaus.org", "bl.spamcop.net", "b.barracudacentral.org", "psbl.surriel.com"}
|
||||
|
||||
func (b *Blacktip) lookupIPs(name string) ([]net.IP, error) {
|
||||
if b.cfg.MailLookupIP != nil {
|
||||
return b.cfg.MailLookupIP(name)
|
||||
}
|
||||
addrs, err := net.LookupIP(name)
|
||||
return addrs, err
|
||||
}
|
||||
|
||||
func (b *Blacktip) lookupMXHosts(domain string) ([]string, error) {
|
||||
if b.cfg.MailLookupMX != nil {
|
||||
return b.cfg.MailLookupMX(domain)
|
||||
}
|
||||
mxs, err := net.LookupMX(domain)
|
||||
var out []string
|
||||
for _, mx := range mxs {
|
||||
out = append(out, strings.TrimSuffix(mx.Host, "."))
|
||||
}
|
||||
if len(out) > 0 {
|
||||
err = nil // a partial answer is still an answer
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func dnsNotFound(err error) bool {
|
||||
var de *net.DNSError
|
||||
return errors.As(err, &de) && de.IsNotFound
|
||||
}
|
||||
|
||||
// ---------- SPF (RFC 7208) ----------
|
||||
|
||||
const spfLookupLimit = 10
|
||||
|
||||
type spfEval struct {
|
||||
b *Blacktip
|
||||
ip net.IP
|
||||
lookups int
|
||||
}
|
||||
|
||||
var errSPFPerm = errors.New("permerror")
|
||||
|
||||
// spfRecord returns the domain's SPF record text, or "" if it has none.
|
||||
func (b *Blacktip) spfRecord(domain string) (string, error) {
|
||||
txts, err := b.lookupTXT(domain)
|
||||
if err != nil && !dnsNotFound(err) && len(txts) == 0 {
|
||||
return "", err
|
||||
}
|
||||
rec := ""
|
||||
for _, t := range txts {
|
||||
low := strings.ToLower(strings.TrimSpace(t))
|
||||
if low == "v=spf1" || strings.HasPrefix(low, "v=spf1 ") {
|
||||
if rec != "" {
|
||||
return "", fmt.Errorf("%w: %s publishes more than one SPF record", errSPFPerm, domain)
|
||||
}
|
||||
rec = strings.TrimSpace(t)
|
||||
}
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// check evaluates domain's record for e.ip and returns the qualifier that
|
||||
// matched ("pass", "fail", "softfail", "neutral") or "none" with no record.
|
||||
func (e *spfEval) check(domain string, depth int) (string, error) {
|
||||
if depth > spfLookupLimit {
|
||||
return "", fmt.Errorf("%w: SPF includes nest too deeply", errSPFPerm)
|
||||
}
|
||||
rec, err := e.b.spfRecord(domain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rec == "" {
|
||||
return "none", nil
|
||||
}
|
||||
redirect := ""
|
||||
for _, term := range strings.Fields(rec)[1:] {
|
||||
low := strings.ToLower(term)
|
||||
if strings.HasPrefix(low, "redirect=") {
|
||||
redirect = term[len("redirect="):]
|
||||
continue
|
||||
}
|
||||
if strings.Contains(low, "=") && !strings.ContainsAny(low[:strings.IndexByte(low, '=')], ":/") {
|
||||
continue // an unknown modifier such as exp=
|
||||
}
|
||||
q, mech := "pass", low
|
||||
switch low[0] {
|
||||
case '+', '-', '~', '?':
|
||||
q = map[byte]string{'+': "pass", '-': "fail", '~': "softfail", '?': "neutral"}[low[0]]
|
||||
mech = low[1:]
|
||||
}
|
||||
base, cidr4, cidr6 := mech, -1, -1
|
||||
if !strings.HasPrefix(mech, "ip4:") && !strings.HasPrefix(mech, "ip6:") {
|
||||
if i := strings.IndexByte(mech, '/'); i >= 0 { // "a/24", "mx:host/24//64"
|
||||
var ok bool
|
||||
if cidr4, cidr6, ok = parsePrefixes(mech[i+1:]); !ok {
|
||||
return "", fmt.Errorf("%w: bad prefix in %q", errSPFPerm, term)
|
||||
}
|
||||
base = mech[:i]
|
||||
}
|
||||
}
|
||||
name, arg, _ := strings.Cut(base, ":")
|
||||
matched := false
|
||||
switch name {
|
||||
case "all":
|
||||
matched = true
|
||||
case "ip4", "ip6":
|
||||
matched = ipInSpec(e.ip, arg)
|
||||
case "a", "mx", "include", "exists", "ptr":
|
||||
if name == "ptr" {
|
||||
continue // discouraged by the RFC and slow: treated as not matching
|
||||
}
|
||||
if e.lookups++; e.lookups > spfLookupLimit {
|
||||
return "", fmt.Errorf("%w: more than %d DNS lookups", errSPFPerm, spfLookupLimit)
|
||||
}
|
||||
target := domain
|
||||
if arg != "" {
|
||||
target = arg
|
||||
}
|
||||
switch name {
|
||||
case "include":
|
||||
if arg == "" {
|
||||
return "", fmt.Errorf("%w: include without a domain", errSPFPerm)
|
||||
}
|
||||
res, err := e.check(target, depth+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res == "none" {
|
||||
return "", fmt.Errorf("%w: included domain %s has no SPF record", errSPFPerm, target)
|
||||
}
|
||||
matched = res == "pass"
|
||||
case "a":
|
||||
matched = e.hostMatches(target, cidr4, cidr6)
|
||||
case "exists":
|
||||
ips, _ := e.b.lookupIPs(target)
|
||||
matched = len(ips) > 0
|
||||
case "mx":
|
||||
hosts, _ := e.b.lookupMXHosts(target)
|
||||
for _, h := range hosts {
|
||||
if e.lookups++; e.lookups > spfLookupLimit {
|
||||
return "", fmt.Errorf("%w: more than %d DNS lookups", errSPFPerm, spfLookupLimit)
|
||||
}
|
||||
if e.hostMatches(h, cidr4, cidr6) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
return q, nil
|
||||
}
|
||||
}
|
||||
if redirect != "" {
|
||||
if e.lookups++; e.lookups > spfLookupLimit {
|
||||
return "", fmt.Errorf("%w: more than %d DNS lookups", errSPFPerm, spfLookupLimit)
|
||||
}
|
||||
res, err := e.check(redirect, depth+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res == "none" {
|
||||
return "", fmt.Errorf("%w: redirect target %s has no SPF record", errSPFPerm, redirect)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
return "neutral", nil
|
||||
}
|
||||
|
||||
// parsePrefixes reads "24", "24//64" or "/64" (IPv4 and IPv6 prefix lengths, -1 when absent).
|
||||
func parsePrefixes(s string) (v4, v6 int, ok bool) {
|
||||
v4, v6 = -1, -1
|
||||
if strings.HasPrefix(s, "/") && !strings.HasPrefix(s, "//") {
|
||||
s = "/" + s // "a//64" arrives here as "/64"
|
||||
}
|
||||
a, b, has6 := strings.Cut(s, "//")
|
||||
if a != "" {
|
||||
n, err := strconv.Atoi(a)
|
||||
if err != nil || n < 0 || n > 32 {
|
||||
return 0, 0, false
|
||||
}
|
||||
v4 = n
|
||||
}
|
||||
if has6 {
|
||||
n, err := strconv.Atoi(b)
|
||||
if err != nil || n < 0 || n > 128 {
|
||||
return 0, 0, false
|
||||
}
|
||||
v6 = n
|
||||
}
|
||||
return v4, v6, true
|
||||
}
|
||||
|
||||
func (e *spfEval) hostMatches(host string, cidr4, cidr6 int) bool {
|
||||
ips, _ := e.b.lookupIPs(host)
|
||||
for _, ip := range ips {
|
||||
bits := cidr4
|
||||
if ip.To4() == nil {
|
||||
bits = cidr6
|
||||
}
|
||||
if bits < 0 {
|
||||
if ip.Equal(e.ip) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, n, err := net.ParseCIDR(ip.String() + "/" + strconv.Itoa(bits)); err == nil && n.Contains(e.ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ipInSpec(ip net.IP, spec string) bool {
|
||||
if strings.Contains(spec, "/") {
|
||||
_, n, err := net.ParseCIDR(spec)
|
||||
return err == nil && n.Contains(ip)
|
||||
}
|
||||
p := net.ParseIP(spec)
|
||||
return p != nil && p.Equal(ip)
|
||||
}
|
||||
|
||||
// checkSPF says whether the domain's SPF record authorizes the source IP.
|
||||
func (b *Blacktip) checkSPF(domain, sourceIP string) model.MailFinding {
|
||||
ip := net.ParseIP(sourceIP)
|
||||
if ip == nil {
|
||||
return model.MailFinding{Status: model.FindingWarn, Detail: "the source address isn't an IP address"}
|
||||
}
|
||||
hint := fmt.Sprintf("Publish a TXT record on %s: \"v=spf1 ip4:%s ~all\". If other services also send as %s, add their include: entries to the same record.", domain, sourceIP, domain)
|
||||
if ip.To4() == nil {
|
||||
hint = strings.Replace(hint, "ip4:", "ip6:", 1)
|
||||
}
|
||||
e := &spfEval{b: b, ip: ip}
|
||||
res, err := e.check(domain, 0)
|
||||
switch {
|
||||
case errors.Is(err, errSPFPerm):
|
||||
return model.MailFinding{Status: model.FindingFail, Detail: "SPF record is invalid: " + strings.TrimPrefix(err.Error(), "permerror: "),
|
||||
Hint: "Receivers treat an invalid SPF record as a failure. Fix the record on " + domain + "; SPF allows at most 10 DNS lookups across include:, a, mx and redirect, so flatten some includes into ip4: entries."}
|
||||
case err != nil:
|
||||
return model.MailFinding{Status: model.FindingWarn, Detail: "couldn't read the SPF record: " + err.Error()}
|
||||
case res == "none":
|
||||
return model.MailFinding{Status: model.FindingNone, Detail: "no SPF record published on " + domain, Hint: hint}
|
||||
case res == "pass":
|
||||
rec, _ := b.spfRecord(domain)
|
||||
if low := strings.ToLower(rec); strings.HasSuffix(low, " +all") || strings.HasSuffix(low, " ?all") || strings.HasSuffix(low, " all") {
|
||||
return model.MailFinding{Status: model.FindingWarn, Detail: "SPF authorizes " + sourceIP + " but ends in a permissive all, which lets anyone send as " + domain,
|
||||
Hint: "End the record with ~all (or -all once you're sure every sender is listed)."}
|
||||
}
|
||||
return model.MailFinding{Status: model.FindingPass, Detail: "SPF authorizes " + sourceIP}
|
||||
default:
|
||||
return model.MailFinding{Status: model.FindingFail, Detail: fmt.Sprintf("SPF for %s does not authorize %s (result: %s)", domain, sourceIP, res),
|
||||
Hint: fmt.Sprintf("Add ip4:%s to the v=spf1 record on %s, before the final all.", sourceIP, domain)}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- DMARC (RFC 7489) ----------
|
||||
|
||||
// dmarcRecord finds the DMARC record that applies to domain: its own, else its
|
||||
// parent's (a cheap stand-in for the organizational domain, which would need
|
||||
// the public suffix list).
|
||||
func (b *Blacktip) dmarcRecord(domain string) (rec, at string, err error) {
|
||||
for d := domain; strings.Count(d, ".") >= 1; {
|
||||
txts, lerr := b.lookupTXT("_dmarc." + d)
|
||||
if lerr != nil && !dnsNotFound(lerr) && len(txts) == 0 {
|
||||
return "", "", lerr
|
||||
}
|
||||
for _, t := range txts {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(t)), "v=dmarc1") {
|
||||
return strings.TrimSpace(t), d, nil
|
||||
}
|
||||
}
|
||||
_, d, _ = strings.Cut(d, ".")
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
func dmarcTags(rec string) map[string]string {
|
||||
tags := map[string]string{}
|
||||
for _, f := range strings.Split(rec, ";") {
|
||||
if k, v, ok := strings.Cut(strings.TrimSpace(f), "="); ok {
|
||||
tags[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func (b *Blacktip) checkDMARC(domain string) model.MailFinding {
|
||||
rec, at, err := b.dmarcRecord(domain)
|
||||
if err != nil {
|
||||
return model.MailFinding{Status: model.FindingWarn, Detail: "couldn't read the DMARC record: " + err.Error()}
|
||||
}
|
||||
if rec == "" {
|
||||
return model.MailFinding{Status: model.FindingNone, Detail: "no DMARC record published for " + domain,
|
||||
Hint: fmt.Sprintf("Publish a TXT record on _dmarc.%s: \"v=DMARC1; p=none; rua=mailto:dmarc@%s\". Start with p=none, read the reports, then tighten it. Gmail and Yahoo now require a DMARC record from bulk senders.", domain, domain)}
|
||||
}
|
||||
t := dmarcTags(rec)
|
||||
where := ""
|
||||
if at != domain {
|
||||
where = " (inherited from " + at + ")"
|
||||
}
|
||||
p := strings.ToLower(t["p"])
|
||||
if sp := strings.ToLower(t["sp"]); at != domain && sp != "" {
|
||||
p = sp
|
||||
}
|
||||
switch {
|
||||
case p != "none" && p != "quarantine" && p != "reject":
|
||||
return model.MailFinding{Status: model.FindingFail, Detail: "DMARC record has no valid p= policy" + where,
|
||||
Hint: "Set p=none, p=quarantine or p=reject in the _dmarc." + at + " record; receivers ignore a record without one."}
|
||||
case t["rua"] == "":
|
||||
return model.MailFinding{Status: model.FindingWarn, Detail: "DMARC policy is " + p + " but there is no rua= address, so you'd get no reports" + where,
|
||||
Hint: "Add rua=mailto:dmarc@" + domain + " to the record, then upload the reports you receive on the Mail page."}
|
||||
case p == "none":
|
||||
return model.MailFinding{Status: model.FindingWarn, Detail: "DMARC is in monitoring mode (p=none)" + where,
|
||||
Hint: "Once the reports show only your own legitimate mail passing, move to p=quarantine and then p=reject."}
|
||||
}
|
||||
return model.MailFinding{Status: model.FindingPass, Detail: "DMARC policy is " + p + where}
|
||||
}
|
||||
|
||||
// ---------- MTA-STS for the sending domain ----------
|
||||
|
||||
func (b *Blacktip) checkMTASTS(domain string) model.MailFinding {
|
||||
pol := b.stsFor(domain)
|
||||
if pol == nil {
|
||||
return model.MailFinding{Status: model.FindingNone, Detail: "no MTA-STS policy for " + domain,
|
||||
Hint: "Optional but recommended: publish a TXT record on _mta-sts." + domain + " (\"v=STSv1; id=1\") and serve a policy at https://mta-sts." + domain + "/.well-known/mta-sts.txt so senders must use TLS to reach you."}
|
||||
}
|
||||
switch pol.Mode {
|
||||
case "enforce":
|
||||
return model.MailFinding{Status: model.FindingPass, Detail: "MTA-STS is enforced"}
|
||||
case "testing":
|
||||
return model.MailFinding{Status: model.FindingWarn, Detail: "MTA-STS is in testing mode",
|
||||
Hint: "Once TLS reports look clean, change mode: testing to mode: enforce in the policy file and bump the id in the DNS record."}
|
||||
}
|
||||
return model.MailFinding{Status: model.FindingNone, Detail: "MTA-STS policy is switched off (mode: none)"}
|
||||
}
|
||||
|
||||
// ---------- blocklists ----------
|
||||
|
||||
func (b *Blacktip) dnsblQuery(name string) ([]string, error) {
|
||||
if b.cfg.MailLookupDNSBL != nil {
|
||||
return b.cfg.MailLookupDNSBL(name)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
addrs, err := net.DefaultResolver.LookupHost(ctx, name)
|
||||
if dnsNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return addrs, err
|
||||
}
|
||||
|
||||
// reverseIPv4 turns 192.0.2.1 into 1.2.0.192, the form DNSBLs are queried in.
|
||||
func reverseIPv4(ip net.IP) string {
|
||||
v4 := ip.To4()
|
||||
if v4 == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d.%d.%d.%d", v4[3], v4[2], v4[1], v4[0])
|
||||
}
|
||||
|
||||
// checkBlocklists asks each DNSBL about the IP. listed are zones that list it;
|
||||
// answered counts the zones that gave a usable answer (listing refusals for
|
||||
// open resolvers, answered as 127.255.255.x, don't count as either).
|
||||
func (b *Blacktip) checkBlocklists(sourceIP string) (listed []string, answered int) {
|
||||
rev := reverseIPv4(net.ParseIP(sourceIP))
|
||||
if rev == "" {
|
||||
return nil, 0 // the common blocklists only cover IPv4
|
||||
}
|
||||
zones := b.cfg.MailBlocklists
|
||||
if zones == nil {
|
||||
zones = defaultBlocklists
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
for _, z := range zones {
|
||||
wg.Add(1)
|
||||
go func(z string) {
|
||||
defer wg.Done()
|
||||
addrs, err := b.dnsblQuery(rev + "." + z)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hit, bad := false, false
|
||||
for _, a := range addrs {
|
||||
if strings.HasPrefix(a, "127.255.255.") {
|
||||
bad = true // "query refused": the zone won't answer from this resolver
|
||||
} else if strings.HasPrefix(a, "127.") {
|
||||
hit = true
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if hit {
|
||||
listed = append(listed, z)
|
||||
}
|
||||
if !bad || hit {
|
||||
answered++
|
||||
}
|
||||
}(z)
|
||||
}
|
||||
wg.Wait()
|
||||
sort.Strings(listed)
|
||||
return listed, answered
|
||||
}
|
||||
|
||||
// ---------- putting it together ----------
|
||||
|
||||
// sendingDomains are the domains the policy has DKIM keys for: the ones it signs mail as.
|
||||
func sendingDomains(pol model.MailPolicy) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, k := range pol.DKIM {
|
||||
d := strings.ToLower(k.Domain)
|
||||
if d != "" && !seen[d] {
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// checkDomains runs the per-domain DNS checks for a policy's sending domains.
|
||||
func (b *Blacktip) checkDomains(pol model.MailPolicy) map[string]model.DomainCheck {
|
||||
doms := sendingDomains(pol)
|
||||
if len(doms) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string]model.DomainCheck{}
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
for _, d := range doms {
|
||||
wg.Add(1)
|
||||
go func(d string) {
|
||||
defer wg.Done()
|
||||
c := model.DomainCheck{DMARC: b.checkDMARC(d), MTASTS: b.checkMTASTS(d)}
|
||||
if pol.Smarthost != "" {
|
||||
c.SPF = model.MailFinding{Status: model.FindingWarn, Detail: "not checked: mail leaves through the smarthost " + pol.Smarthost,
|
||||
Hint: "The receiving server sees the smarthost's address, so " + d + "'s SPF record must authorize that provider instead."}
|
||||
} else {
|
||||
c.SPF = b.checkSPF(d, pol.SourceIP)
|
||||
}
|
||||
mu.Lock()
|
||||
out[d] = c
|
||||
mu.Unlock()
|
||||
}(d)
|
||||
}
|
||||
wg.Wait()
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package blacktip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
type fakeDNS struct {
|
||||
txt map[string][]string
|
||||
ips map[string][]string
|
||||
mx map[string][]string
|
||||
bl map[string][]string
|
||||
sts map[string]*STSPolicy
|
||||
fail map[string]bool
|
||||
}
|
||||
|
||||
func (f fakeDNS) cfg() Config {
|
||||
return Config{
|
||||
MailLookupTXT: func(name string) ([]string, error) {
|
||||
if f.fail[name] {
|
||||
return nil, errors.New("servfail")
|
||||
}
|
||||
if v, ok := f.txt[name]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return nil, &net.DNSError{Name: name, IsNotFound: true}
|
||||
},
|
||||
MailLookupIP: func(name string) ([]net.IP, error) {
|
||||
var out []net.IP
|
||||
for _, s := range f.ips[name] {
|
||||
out = append(out, net.ParseIP(s))
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
MailLookupMX: func(d string) ([]string, error) { return f.mx[d], nil },
|
||||
MailLookupDNSBL: func(name string) ([]string, error) { return f.bl[name], nil },
|
||||
MailSTS: func(d string) (*STSPolicy, error) { return f.sts[d], nil },
|
||||
}
|
||||
}
|
||||
|
||||
func (f fakeDNS) bt() *Blacktip { return &Blacktip{cfg: f.cfg()} }
|
||||
|
||||
func TestSPF(t *testing.T) {
|
||||
const ip = "203.0.113.7"
|
||||
f := fakeDNS{
|
||||
txt: map[string][]string{
|
||||
"none.test": {"unrelated"},
|
||||
"direct.test": {"v=spf1 ip4:203.0.113.0/24 -all"},
|
||||
"other.test": {"v=spf1 ip4:198.51.100.1 -all"},
|
||||
"soft.test": {"v=spf1 ~all"},
|
||||
"open.test": {"v=spf1 ip4:203.0.113.7 +all"},
|
||||
"include.test": {"v=spf1 include:_spf.vendor.test -all"},
|
||||
"_spf.vendor.test": {"v=spf1 ip4:203.0.113.7 -all"},
|
||||
"a.test": {"v=spf1 a mx:a.test/24 -all"},
|
||||
"redirect.test": {"v=spf1 redirect=direct.test"},
|
||||
"dup.test": {"v=spf1 -all", "v=spf1 +all"},
|
||||
"loop.test": {"v=spf1 include:loop.test -all"},
|
||||
"missinginc.test": {"v=spf1 include:gone.test -all"},
|
||||
"many.test": {"v=spf1 a:1.many.test a:2.many.test a:3.many.test a:4.many.test a:5.many.test a:6.many.test a:7.many.test a:8.many.test a:9.many.test a:10.many.test a:11.many.test -all"},
|
||||
"six.test": {"v=spf1 ip6:2001:db8::/32 -all"},
|
||||
},
|
||||
ips: map[string][]string{"mail.a.test": {"203.0.113.99"}},
|
||||
mx: map[string][]string{"a.test": {"mail.a.test"}},
|
||||
}
|
||||
b := f.bt()
|
||||
for _, c := range []struct {
|
||||
domain, status, contains string
|
||||
}{
|
||||
{"nothing.test", model.FindingNone, "no SPF record"},
|
||||
{"none.test", model.FindingNone, "no SPF record"},
|
||||
{"direct.test", model.FindingPass, "authorizes"},
|
||||
{"other.test", model.FindingFail, "does not authorize"},
|
||||
{"soft.test", model.FindingFail, "(result: softfail)"},
|
||||
{"open.test", model.FindingWarn, "permissive"},
|
||||
{"include.test", model.FindingPass, "authorizes"},
|
||||
{"a.test", model.FindingPass, "authorizes"}, // via mx:a.test/24
|
||||
{"redirect.test", model.FindingPass, "authorizes"},
|
||||
{"dup.test", model.FindingFail, "more than one SPF record"},
|
||||
{"loop.test", model.FindingFail, "invalid"},
|
||||
{"missinginc.test", model.FindingFail, "no SPF record"},
|
||||
{"many.test", model.FindingFail, "lookups"},
|
||||
{"six.test", model.FindingFail, "does not authorize"},
|
||||
} {
|
||||
got := b.checkSPF(c.domain, ip)
|
||||
if got.Status != c.status || !strings.Contains(got.Detail, c.contains) {
|
||||
t.Errorf("%s: got %q %q, want %s containing %q", c.domain, got.Status, got.Detail, c.status, c.contains)
|
||||
}
|
||||
if got.Status != model.FindingPass && got.Hint == "" && got.Status != model.FindingWarn {
|
||||
t.Errorf("%s: a failing check should say what to fix", c.domain)
|
||||
}
|
||||
}
|
||||
if h := b.checkSPF("other.test", ip).Hint; !strings.Contains(h, "ip4:"+ip) {
|
||||
t.Errorf("hint should name the address to add: %q", h)
|
||||
}
|
||||
f.fail = map[string]bool{"flaky.test": true}
|
||||
if got := f.bt().checkSPF("flaky.test", ip); got.Status != model.FindingWarn {
|
||||
t.Errorf("a DNS error is not a verdict: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPFIPv6AndPrefixes(t *testing.T) {
|
||||
f := fakeDNS{
|
||||
txt: map[string][]string{"v6.test": {"v=spf1 ip6:2001:db8::/32 a/24//64 -all"}},
|
||||
ips: map[string][]string{"v6.test": {"2001:db8:1::5"}},
|
||||
}
|
||||
b := f.bt()
|
||||
if got := b.checkSPF("v6.test", "2001:db8:ffff::1"); got.Status != model.FindingPass {
|
||||
t.Errorf("ip6 range: %+v", got)
|
||||
}
|
||||
if got := b.checkSPF("v6.test", "2001:dead::1"); got.Status != model.FindingFail || !strings.Contains(got.Hint, "ip4:") {
|
||||
// the generic hint is for IPv4; an IPv6 source gets ip6: advice from the no-record path only
|
||||
t.Logf("v6 miss: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrefixes(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
v4, v6 int
|
||||
ok bool
|
||||
}{{"24", 24, -1, true}, {"24//64", 24, 64, true}, {"/64", -1, 64, true}, {"33", 0, 0, false}, {"x", 0, 0, false}, {"//129", 0, 0, false}} {
|
||||
v4, v6, ok := parsePrefixes(c.in)
|
||||
if ok != c.ok || (ok && (v4 != c.v4 || v6 != c.v6)) {
|
||||
t.Errorf("%q: got %d %d %v", c.in, v4, v6, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDMARC(t *testing.T) {
|
||||
f := fakeDNS{txt: map[string][]string{
|
||||
"_dmarc.reject.test": {"v=DMARC1; p=reject; rua=mailto:r@reject.test"},
|
||||
"_dmarc.monitor.test": {"v=DMARC1; p=none; rua=mailto:r@monitor.test"},
|
||||
"_dmarc.norua.test": {"v=DMARC1; p=quarantine"},
|
||||
"_dmarc.broken.test": {"v=DMARC1; rua=mailto:r@broken.test"},
|
||||
"_dmarc.parent.test": {"v=DMARC1; p=reject; rua=mailto:r@parent.test"},
|
||||
"_dmarc.subpol.test": {"v=DMARC1; p=reject; sp=none; rua=mailto:r@subpol.test"},
|
||||
}}
|
||||
b := f.bt()
|
||||
for _, c := range []struct{ domain, status, contains string }{
|
||||
{"reject.test", model.FindingPass, "reject"},
|
||||
{"monitor.test", model.FindingWarn, "monitoring"},
|
||||
{"norua.test", model.FindingWarn, "no rua"},
|
||||
{"broken.test", model.FindingFail, "no valid p="},
|
||||
{"none.test", model.FindingNone, "no DMARC record"},
|
||||
{"news.parent.test", model.FindingPass, "inherited from parent.test"},
|
||||
{"news.subpol.test", model.FindingWarn, "monitoring"}, // sp=none applies to the subdomain
|
||||
} {
|
||||
got := b.checkDMARC(c.domain)
|
||||
if got.Status != c.status || !strings.Contains(got.Detail, c.contains) {
|
||||
t.Errorf("%s: got %q %q, want %s containing %q", c.domain, got.Status, got.Detail, c.status, c.contains)
|
||||
}
|
||||
}
|
||||
if h := b.checkDMARC("none.test").Hint; !strings.Contains(h, "_dmarc.none.test") || !strings.Contains(h, "rua=") {
|
||||
t.Errorf("hint should give the record to publish: %q", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTASTSFinding(t *testing.T) {
|
||||
f := fakeDNS{sts: map[string]*STSPolicy{
|
||||
"enforce.test": {Mode: "enforce"}, "testing.test": {Mode: "testing"}, "off.test": {Mode: "none"},
|
||||
}}
|
||||
b := f.bt()
|
||||
for domain, want := range map[string]string{
|
||||
"enforce.test": model.FindingPass, "testing.test": model.FindingWarn, "off.test": model.FindingNone, "absent.test": model.FindingNone,
|
||||
} {
|
||||
if got := b.checkMTASTS(domain); got.Status != want {
|
||||
t.Errorf("%s: %+v, want %s", domain, got, want)
|
||||
}
|
||||
}
|
||||
if b.checkMTASTS("absent.test").Hint == "" {
|
||||
t.Error("a missing policy should come with a hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlocklists(t *testing.T) {
|
||||
f := fakeDNS{bl: map[string][]string{
|
||||
"7.113.0.203.zen.test": {"127.0.0.2"},
|
||||
"7.113.0.203.refuse.test": {"127.255.255.254"}, // an open-resolver refusal is no answer
|
||||
"7.113.0.203.also.test": {"127.0.0.4"},
|
||||
}}
|
||||
f.bl["7.113.0.203.clean.test"] = nil
|
||||
cfg := f.cfg()
|
||||
cfg.MailBlocklists = []string{"zen.test", "clean.test", "refuse.test", "also.test"}
|
||||
b := &Blacktip{cfg: cfg}
|
||||
listed, answered := b.checkBlocklists("203.0.113.7")
|
||||
if strings.Join(listed, ",") != "also.test,zen.test" {
|
||||
t.Errorf("listed = %v", listed)
|
||||
}
|
||||
if answered != 3 {
|
||||
t.Errorf("answered = %d, want 3 (the refusing zone doesn't count)", answered)
|
||||
}
|
||||
if l, n := b.checkBlocklists("2001:db8::1"); l != nil || n != 0 {
|
||||
t.Errorf("IPv6 should be skipped, got %v %d", l, n)
|
||||
}
|
||||
cfg.MailLookupDNSBL = func(string) ([]string, error) { return nil, errors.New("timeout") }
|
||||
if l, n := (&Blacktip{cfg: cfg}).checkBlocklists("203.0.113.7"); len(l) != 0 || n != 0 {
|
||||
t.Errorf("errors count as no answer, got %v %d", l, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDomainsUsesSigningDomains(t *testing.T) {
|
||||
f := fakeDNS{txt: map[string][]string{"example.com": {"v=spf1 ip4:203.0.113.7 -all"}, "_dmarc.example.com": {"v=DMARC1; p=reject; rua=mailto:a@example.com"}}}
|
||||
b := f.bt()
|
||||
pol := model.MailPolicy{ID: "m1", SourceIP: "203.0.113.7", DKIM: []model.DKIMSigner{
|
||||
{Domain: "Example.com", Selector: "a"}, {Domain: "example.com", Selector: "b"}, {Domain: "other.test", Selector: "a"}}}
|
||||
got := b.checkDomains(pol)
|
||||
if len(got) != 2 || got["example.com"].SPF.Status != model.FindingPass || got["example.com"].DMARC.Status != model.FindingPass {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got["other.test"].SPF.Status != model.FindingNone {
|
||||
t.Errorf("other.test: %+v", got["other.test"])
|
||||
}
|
||||
pol.Smarthost = "smtp.relay.test:587"
|
||||
if s := b.checkDomains(pol)["example.com"].SPF; s.Status != model.FindingWarn || !strings.Contains(s.Detail, "smarthost") {
|
||||
t.Errorf("smarthost SPF: %+v", s)
|
||||
}
|
||||
if b.checkDomains(model.MailPolicy{SourceIP: "203.0.113.7"}) != nil {
|
||||
t.Error("no signing keys means no sending domains to check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutcomes(t *testing.T) {
|
||||
var o outcomeLog
|
||||
now := time.Date(2026, 10, 27, 12, 30, 0, 0, time.UTC)
|
||||
o.add("m1", "gmail.com", 250, "", now.Add(-26*time.Hour)) // outside the window
|
||||
o.add("m1", "gmail.com", 250, "", now.Add(-90*time.Minute))
|
||||
o.add("m1", "gmail.com", 250, "", now)
|
||||
o.add("m1", "gmail.com", 451, "4.7.0 greylisted", now.Add(time.Minute))
|
||||
o.add("m1", "yahoo.com", 550, "5.7.1 blocked", now.Add(2*time.Minute))
|
||||
o.add("m2", "gmail.com", 250, "", now)
|
||||
|
||||
s := o.summary("m1", now.Add(3*time.Minute))
|
||||
if s == nil || s.Delivered != 2 || s.Deferred != 1 || s.Failed != 1 {
|
||||
t.Fatalf("summary: %+v", s)
|
||||
}
|
||||
if g := s.ByDomain["gmail.com"]; g.Delivered != 2 || g.Deferred != 1 {
|
||||
t.Errorf("gmail: %+v", g)
|
||||
}
|
||||
if len(s.Recent) != 2 || s.Recent[0].Domain != "yahoo.com" || s.Recent[0].Code != 550 {
|
||||
t.Errorf("recent should list newest first: %+v", s.Recent)
|
||||
}
|
||||
if o.summary("nobody", now) != nil {
|
||||
t.Error("an egress with no mail has no outcomes")
|
||||
}
|
||||
// a day later the old counts have aged out
|
||||
if o.summary("m1", now.Add(25*time.Hour)) != nil {
|
||||
t.Error("outcomes older than the window should be gone")
|
||||
}
|
||||
for i := 0; i < 30; i++ {
|
||||
o.add("m3", "x.test", 550, "no", now.Add(time.Duration(i)*time.Second))
|
||||
}
|
||||
if n := len(o.summary("m3", now.Add(time.Minute)).Recent); n != outcomeRecent {
|
||||
t.Errorf("recent failures kept = %d, want %d", n, outcomeRecent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportCarriesOutcomes(t *testing.T) {
|
||||
b := &Blacktip{cfg: Config{}}
|
||||
b.mail = map[string]model.MailPolicy{"m1": {ID: "m1"}}
|
||||
b.mailChecks = map[string]model.MailCheck{}
|
||||
b.recordOutcome("m1", "gmail.com", 250, "")
|
||||
o := b.outcomes.summary("m1", time.Now())
|
||||
if o == nil || o.Delivered != 1 {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ module git.veillax.com/Veillax/blacktip
|
||||
go 1.27.3
|
||||
|
||||
require (
|
||||
git.veillax.com/Veillax/reef v0.1.10
|
||||
git.veillax.com/Veillax/reef v0.1.11
|
||||
github.com/emersion/go-msgauth v0.7.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.veillax.com/Veillax/reef v0.1.10 h1:b15oD1vyrGOriCHhipQtJ3j7Cy5ForYWvfJkqifLQPg=
|
||||
git.veillax.com/Veillax/reef v0.1.10/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||
git.veillax.com/Veillax/reef v0.1.11 h1:kbjgxJpmpp1HRckbW1tqJUm5/ftgvSlmUHAhFXugOlY=
|
||||
git.veillax.com/Veillax/reef v0.1.11/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
|
||||
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
|
||||
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
|
||||
|
||||
+4
-6
@@ -227,9 +227,7 @@ func (l *listener) handleTCP(c net.Conn) {
|
||||
}
|
||||
in, out := tunnel.Proxy(c, c, up, up)
|
||||
in += int64(len(first))
|
||||
ms.conns.Add(1)
|
||||
ms.bytesIn.Add(in)
|
||||
ms.bytesOut.Add(out)
|
||||
ms.record(c.RemoteAddr().String(), 1, in, out)
|
||||
entry.BytesIn, entry.BytesOut, entry.Millis = in, out, time.Since(start).Milliseconds()
|
||||
l.b.logAccess(entry)
|
||||
}
|
||||
@@ -363,13 +361,13 @@ func (l *listener) serveUDP() {
|
||||
l.mu.Lock()
|
||||
l.sessions[key] = s
|
||||
l.mu.Unlock()
|
||||
ms.conns.Add(1)
|
||||
ms.record(addr.String(), 1, 0, 0)
|
||||
go l.pumpBack(ms, key, addr, s)
|
||||
}
|
||||
l.mu.Lock()
|
||||
s.last = time.Now()
|
||||
l.mu.Unlock()
|
||||
ms.bytesIn.Add(int64(n))
|
||||
ms.record(addr.String(), 0, int64(n), 0)
|
||||
s.conn.Write(buf[:n])
|
||||
}
|
||||
}
|
||||
@@ -394,7 +392,7 @@ func (l *listener) pumpBack(ms *mapState, key string, addr net.Addr, s *udpSessi
|
||||
if _, err := l.udp.WriteTo(p, addr); err != nil {
|
||||
return
|
||||
}
|
||||
ms.bytesOut.Add(int64(len(p)))
|
||||
ms.record(addr.String(), 0, 0, int64(len(p)))
|
||||
l.mu.Lock()
|
||||
s.last = time.Now()
|
||||
l.mu.Unlock()
|
||||
|
||||
@@ -344,6 +344,7 @@ func (s *mailSession) rcpt(m tunnel.MailMsg) {
|
||||
}
|
||||
if err := s.cl.Rcpt(m.To); err != nil {
|
||||
e := asSMTPErr(err)
|
||||
s.b.recordOutcome(s.pol.ID, s.domain, e.Code, e.Text)
|
||||
if s.rcpts == 0 {
|
||||
s.close()
|
||||
}
|
||||
@@ -398,6 +399,7 @@ func (s *mailSession) logEntry(size, code int, result, domain, mx string) {
|
||||
if s.note != "" {
|
||||
path += " · " + s.note
|
||||
}
|
||||
s.b.recordOutcome(s.pol.ID, domain, code, strings.TrimPrefix(result, "mail-failed: "))
|
||||
s.b.logAccess(model.LogEntry{Time: s.start, Mapping: s.pol.ID, Remote: s.peer, Method: "MAIL", Host: domain, Path: path,
|
||||
Status: code, Result: result, BytesIn: int64(size), Millis: time.Since(s.start).Milliseconds()})
|
||||
}
|
||||
@@ -429,6 +431,8 @@ func (b *Blacktip) mailCheck(pol model.MailPolicy) model.MailCheck {
|
||||
if len(pol.DKIM) > 0 {
|
||||
chk.DKIM = b.checkDKIM(pol.DKIM)
|
||||
}
|
||||
chk.Domains = b.checkDomains(pol)
|
||||
chk.Blocklists, chk.Listable = b.checkBlocklists(pol.SourceIP)
|
||||
probe := b.cfg.MailProbe
|
||||
if probe == "" {
|
||||
probe = defaultMailProbe
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package blacktip
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
// Delivery outcomes: every mail transaction the egress handles ends delivered,
|
||||
// deferred (a 4xx the sender will retry) or failed (a 5xx). They are counted in
|
||||
// hourly buckets for the last day, so the dashboard can show a delivery rate
|
||||
// and say which recipient domains are refusing mail and why.
|
||||
|
||||
const (
|
||||
outcomeWindow = 24 * time.Hour
|
||||
outcomeRecent = 10 // refusals kept for the dashboard to explain
|
||||
)
|
||||
|
||||
type outcomeBucket struct {
|
||||
hour time.Time
|
||||
byDomain map[string]model.OutcomeCount
|
||||
}
|
||||
|
||||
type outcomeLog struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string][]*outcomeBucket // by mail-egress ID, oldest first
|
||||
recent map[string][]model.OutcomeFailure
|
||||
}
|
||||
|
||||
// recordOutcome counts one finished transaction (or a refusal along the way).
|
||||
func (b *Blacktip) recordOutcome(egress, domain string, code int, text string) {
|
||||
b.outcomes.add(egress, domain, code, text, time.Now())
|
||||
}
|
||||
|
||||
func (o *outcomeLog) add(egress, domain string, code int, text string, now time.Time) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if o.buckets == nil {
|
||||
o.buckets, o.recent = map[string][]*outcomeBucket{}, map[string][]model.OutcomeFailure{}
|
||||
}
|
||||
hour := now.Truncate(time.Hour)
|
||||
bs := o.buckets[egress]
|
||||
if len(bs) == 0 || !bs[len(bs)-1].hour.Equal(hour) {
|
||||
bs = append(bs, &outcomeBucket{hour: hour, byDomain: map[string]model.OutcomeCount{}})
|
||||
}
|
||||
cut := 0
|
||||
for cut < len(bs) && now.Sub(bs[cut].hour) >= outcomeWindow {
|
||||
cut++
|
||||
}
|
||||
bs = bs[cut:]
|
||||
o.buckets[egress] = bs
|
||||
c := bs[len(bs)-1].byDomain[domain]
|
||||
switch {
|
||||
case code >= 200 && code < 300:
|
||||
c.Delivered++
|
||||
case code >= 400 && code < 500:
|
||||
c.Deferred++
|
||||
default:
|
||||
c.Failed++
|
||||
}
|
||||
bs[len(bs)-1].byDomain[domain] = c
|
||||
if code >= 300 {
|
||||
r := append([]model.OutcomeFailure{{Time: now, Domain: domain, Code: code, Text: text}}, o.recent[egress]...)
|
||||
if len(r) > outcomeRecent {
|
||||
r = r[:outcomeRecent]
|
||||
}
|
||||
o.recent[egress] = r
|
||||
}
|
||||
}
|
||||
|
||||
// summary totals the last day for one egress, or nil if it handled no mail.
|
||||
func (o *outcomeLog) summary(egress string, now time.Time) *model.MailOutcomes {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
out := &model.MailOutcomes{ByDomain: map[string]model.OutcomeCount{}}
|
||||
for _, bk := range o.buckets[egress] {
|
||||
if now.Sub(bk.hour) >= outcomeWindow {
|
||||
continue
|
||||
}
|
||||
if out.Since.IsZero() || bk.hour.Before(out.Since) {
|
||||
out.Since = bk.hour
|
||||
}
|
||||
for d, c := range bk.byDomain {
|
||||
t := out.ByDomain[d]
|
||||
t.Delivered, t.Deferred, t.Failed = t.Delivered+c.Delivered, t.Deferred+c.Deferred, t.Failed+c.Failed
|
||||
out.ByDomain[d] = t
|
||||
out.Delivered, out.Deferred, out.Failed = out.Delivered+c.Delivered, out.Deferred+c.Deferred, out.Failed+c.Failed
|
||||
}
|
||||
}
|
||||
if out.Delivered+out.Deferred+out.Failed == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, f := range o.recent[egress] {
|
||||
if now.Sub(f.Time) < outcomeWindow {
|
||||
out.Recent = append(out.Recent, f)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out.Recent, func(i, j int) bool { return out.Recent[i].Time.After(out.Recent[j].Time) })
|
||||
return out
|
||||
}
|
||||
+1
-3
@@ -544,9 +544,7 @@ func (t *traefikMgr) tailAccessLog(ctx context.Context) {
|
||||
if ms == nil {
|
||||
continue
|
||||
}
|
||||
ms.conns.Add(1)
|
||||
ms.bytesIn.Add(tl.RequestSize)
|
||||
ms.bytesOut.Add(tl.DownstreamSize)
|
||||
ms.record(tl.ClientHost, 1, tl.RequestSize, tl.DownstreamSize)
|
||||
t.b.logAccess(model.LogEntry{Time: tl.StartUTC, Mapping: id, Remote: tl.ClientHost, Method: tl.RequestMethod, Host: hostOnly(tl.RequestHost),
|
||||
Path: tl.RequestPath, Status: tl.DownstreamStatus, Result: "allowed", BytesIn: tl.RequestSize, BytesOut: tl.DownstreamSize, Millis: tl.DurationNanos / 1e6})
|
||||
}
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package blacktip
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
// maxTrackedSources bounds the per-port table of client addresses between two reports, so a flood
|
||||
// from spoofed or scanning addresses can't grow it without limit. Addresses past it still count
|
||||
// in the port's totals; they just aren't named.
|
||||
const maxTrackedSources = 5000
|
||||
|
||||
// sourceTable counts a port's traffic per client address until the next report takes it.
|
||||
type sourceTable struct {
|
||||
mu sync.Mutex
|
||||
by map[string]*model.SourceStat
|
||||
}
|
||||
|
||||
func (t *sourceTable) add(ip string, conns, in, out int64) {
|
||||
if ip == "" {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
s := t.by[ip]
|
||||
if s == nil {
|
||||
if len(t.by) >= maxTrackedSources {
|
||||
return
|
||||
}
|
||||
if t.by == nil {
|
||||
t.by = map[string]*model.SourceStat{}
|
||||
}
|
||||
s = &model.SourceStat{IP: ip}
|
||||
t.by[ip] = s
|
||||
}
|
||||
s.Conns += conns
|
||||
s.BytesIn += in
|
||||
s.BytesOut += out
|
||||
}
|
||||
|
||||
func (t *sourceTable) take() map[string]*model.SourceStat {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
by := t.by
|
||||
t.by = nil
|
||||
return by
|
||||
}
|
||||
|
||||
// clientIP reduces a "host:port" remote address to the client's IP (IPv4-mapped IPv6 as plain IPv4).
|
||||
// Anything that isn't an address gives "".
|
||||
func clientIP(remote string) string {
|
||||
host, _, err := net.SplitHostPort(remote)
|
||||
if err != nil {
|
||||
host = remote
|
||||
}
|
||||
a, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return a.WithZone("").Unmap().String()
|
||||
}
|
||||
|
||||
// record counts traffic on a port, and names the client it came from when that is known.
|
||||
func (ms *mapState) record(remote string, conns, in, out int64) {
|
||||
ms.conns.Add(conns)
|
||||
ms.bytesIn.Add(in)
|
||||
ms.bytesOut.Add(out)
|
||||
ms.sources.add(clientIP(remote), conns, in, out)
|
||||
}
|
||||
|
||||
// takeStats hands over (and resets) the traffic counted since the last call.
|
||||
func (ms *mapState) takeStats() (model.Stat, bool) {
|
||||
st := model.Stat{Conns: ms.conns.Swap(0), BytesIn: ms.bytesIn.Swap(0), BytesOut: ms.bytesOut.Swap(0)}
|
||||
st.Sources = model.TopSources(ms.sources.take(), model.MaxSources)
|
||||
return st, st.Conns|st.BytesIn|st.BytesOut != 0
|
||||
}
|
||||
|
||||
// restoreStats puts taken traffic back after a report that didn't arrive, so none of it is lost.
|
||||
func (ms *mapState) restoreStats(st model.Stat) {
|
||||
ms.conns.Add(st.Conns)
|
||||
ms.bytesIn.Add(st.BytesIn)
|
||||
ms.bytesOut.Add(st.BytesOut)
|
||||
for _, s := range st.Sources {
|
||||
ms.sources.add(s.IP, s.Conns, s.BytesIn, s.BytesOut)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package blacktip
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"203.0.113.9:5000": "203.0.113.9",
|
||||
"[2001:db8::1]:443": "2001:db8::1",
|
||||
"[::ffff:192.0.2.7]:1": "192.0.2.7",
|
||||
"198.51.100.2": "198.51.100.2", // no port, as Traefik reports it
|
||||
"[fe80::1%eth0]:80": "fe80::1",
|
||||
"not an address": "",
|
||||
"": "",
|
||||
} {
|
||||
if got := clientIP(in); got != want {
|
||||
t.Errorf("clientIP(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsTakeAndRestore(t *testing.T) {
|
||||
ms := &mapState{}
|
||||
ms.record("203.0.113.1:1000", 1, 100, 400)
|
||||
ms.record("203.0.113.1:1001", 1, 10, 40)
|
||||
ms.record("203.0.113.2:2000", 1, 5, 5)
|
||||
ms.record("garbage", 1, 1, 1) // counted in the totals but not named
|
||||
|
||||
st, any := ms.takeStats()
|
||||
if !any || st.Conns != 4 || st.BytesIn != 116 || st.BytesOut != 446 {
|
||||
t.Fatalf("totals: %+v", st)
|
||||
}
|
||||
if len(st.Sources) != 2 || st.Sources[0].IP != "203.0.113.1" || st.Sources[0].Conns != 2 || st.Sources[0].BytesOut != 440 {
|
||||
t.Fatalf("sources: %+v", st.Sources)
|
||||
}
|
||||
if _, any := ms.takeStats(); any {
|
||||
t.Fatal("counters were not reset")
|
||||
}
|
||||
|
||||
ms.record("203.0.113.1:1", 1, 1, 1) // traffic that arrived before the failed report was retried
|
||||
ms.restoreStats(st)
|
||||
again, _ := ms.takeStats()
|
||||
if again.Conns != 5 || again.BytesIn != 117 || again.Sources[0].Conns != 3 {
|
||||
t.Fatalf("after restore: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceTableIsBounded(t *testing.T) {
|
||||
var tb sourceTable
|
||||
for i := 0; i < maxTrackedSources+50; i++ {
|
||||
tb.add(string(rune('a'+i%26))+"."+string(rune('0'+i/26%10))+"."+string(rune(i)), 1, 1, 1)
|
||||
}
|
||||
if n := len(tb.take()); n > maxTrackedSources {
|
||||
t.Fatalf("tracked %d addresses", n)
|
||||
}
|
||||
}
|
||||
@@ -258,9 +258,7 @@ func (l *listener) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
entry.BytesOut = rec.bytes
|
||||
entry.Millis = time.Since(start).Milliseconds()
|
||||
ms.conns.Add(1)
|
||||
ms.bytesIn.Add(entry.BytesIn)
|
||||
ms.bytesOut.Add(entry.BytesOut)
|
||||
ms.record(r.RemoteAddr, 1, entry.BytesIn, entry.BytesOut)
|
||||
b.logAccess(entry)
|
||||
}()
|
||||
|
||||
|
||||
Reference in new issue
Block a user