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>
170 lines
4.3 KiB
Go
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
|
|
}
|