Files
Blacktip/mtasts.go
T
VeillaxandClaude Sonnet 5.5 9448a532e9 Add the Blacktip edge
The edge: per-IP TCP/UDP/HTTP/HTTPS/TLS listeners with port ranges and PROXY
protocol, WireGuard tunnel endpoint, logins in front of web ports (built-in
proxy or Traefik), health checks and failover, and mail egress with DKIM
signing and MTA-STS. Split out of the original single repository; depends on
Reef for the shared types.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 02:45:16 -06:00

170 lines
4.3 KiB
Go

package blacktip
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/reef/netguard"
)
// MTA-STS (RFC 8461), sending side. A recipient domain can publish a policy that
// says "mail to us must go to these servers, over verified TLS". Without
// checking it, an attacker who can tamper with DNS or the network can redirect
// or downgrade your outgoing mail. When a policy says "enforce" we only deliver
// to the listed servers and only over TLS with a valid certificate.
// STSPolicy is a recipient domain's MTA-STS policy.
type STSPolicy struct {
Mode string // "enforce", "testing" or "none"
MX []string // exact host names or "*.example.com" patterns
MaxAge time.Duration
}
// Allows reports whether host is a mail server the policy permits.
func (p *STSPolicy) Allows(host string) bool {
host = strings.ToLower(strings.TrimSuffix(host, "."))
for _, pat := range p.MX {
pat = strings.ToLower(strings.TrimSuffix(pat, "."))
if strings.HasPrefix(pat, "*.") {
// a wildcard stands for exactly one label
if rest, ok := strings.CutSuffix(host, pat[1:]); ok && rest != "" && !strings.Contains(rest, ".") {
return true
}
} else if pat == host {
return true
}
}
return false
}
func parseSTS(text string) (*STSPolicy, error) {
p := &STSPolicy{}
version := ""
for _, line := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
k, v = strings.ToLower(strings.TrimSpace(k)), strings.TrimSpace(v)
switch k {
case "version":
version = v
case "mode":
p.Mode = strings.ToLower(v)
case "mx":
p.MX = append(p.MX, v)
case "max_age":
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < 0 {
return nil, errors.New("bad max_age")
}
if n > 31557600 {
n = 31557600 // a year is the most the RFC allows
}
p.MaxAge = time.Duration(n) * time.Second
}
}
switch {
case version != "STSv1":
return nil, errors.New("not an STSv1 policy")
case p.Mode != "enforce" && p.Mode != "testing" && p.Mode != "none":
return nil, fmt.Errorf("unknown mode %q", p.Mode)
case p.Mode != "none" && len(p.MX) == 0:
return nil, errors.New("policy lists no mx")
case p.MaxAge <= 0:
return nil, errors.New("policy has no max_age")
}
return p, nil
}
type stsEntry struct {
id string
pol *STSPolicy
expires time.Time
}
type stsCache struct {
mu sync.Mutex
m map[string]*stsEntry
}
func (b *Blacktip) lookupTXT(name string) ([]string, error) {
if b.cfg.MailLookupTXT != nil {
return b.cfg.MailLookupTXT(name)
}
return netLookupTXT(name)
}
// fetchSTS downloads and parses the policy file. The recipient domain is
// attacker-controlled, so the request goes through the guarded client: no
// internal addresses, no redirects.
func fetchSTS(client *http.Client, domain string) (*STSPolicy, error) {
resp, err := client.Get("https://mta-sts." + domain + "/.well-known/mta-sts.txt")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("policy server answered %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if err != nil {
return nil, err
}
return parseSTS(string(body))
}
// stsFor returns the domain's current MTA-STS policy, or nil if it has none.
func (b *Blacktip) stsFor(domain string) *STSPolicy {
if b.cfg.MailSTS != nil {
p, _ := b.cfg.MailSTS(domain)
return p
}
id := ""
if txts, err := b.lookupTXT("_mta-sts." + domain); err == nil {
for _, t := range txts {
if strings.HasPrefix(strings.TrimSpace(t), "v=STSv1") {
for _, f := range strings.Split(t, ";") {
if k, v, ok := strings.Cut(strings.TrimSpace(f), "="); ok && k == "id" {
id = v
}
}
}
}
}
now := time.Now()
b.sts.mu.Lock()
if b.sts.m == nil {
b.sts.m = map[string]*stsEntry{}
}
ent := b.sts.m[domain]
b.sts.mu.Unlock()
valid := ent != nil && now.Before(ent.expires)
if id == "" {
if valid {
return ent.pol // a cached policy stays in force until it expires
}
return nil
}
if valid && ent.id == id {
return ent.pol
}
pol, err := fetchSTS(netguard.Client(b.cfg.MailAllowPrivate), domain)
if err != nil {
if valid {
return ent.pol
}
return nil
}
b.sts.mu.Lock()
b.sts.m[domain] = &stsEntry{id: id, pol: pol, expires: now.Add(pol.MaxAge)}
b.sts.mu.Unlock()
return pol
}