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>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 02:45:16 -06:00
1 parent 995ef9a6aa
commit 9448a532e9
17 files changed
+3492 -1

No files matched your search

+510
View File
@@ -0,0 +1,510 @@
package blacktip
import (
"bufio"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/smtp"
"net/textproto"
"sort"
"strconv"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/reef/model"
"git.veillax.com/Veillax/reef/netguard"
"git.veillax.com/Veillax/reef/tunnel"
)
// Mail egress: a site's SMTP relay hands this edge one mail transaction at a
// time through the WireGuard tunnel. The edge finds the recipient's mail server
// (MX lookup, or the configured smarthost), connects from the policy's source
// IP, introduces itself with the policy's HELO name, and relays the exchange,
// passing the receiving server's replies straight back so the sender's own
// queue and retry logic keep working.
//
// Guard rails: only the owning site (by tunnel address) may use a policy;
// destinations on internal address ranges are refused so the edge can't be
// aimed at its own network; and a per-policy rate limit caps how much mail a
// compromised site could push out from the IP.
// MailHost is somewhere to deliver mail.
type MailHost struct {
Host string
Port int
}
type smtpErr struct {
Code int
Text string
}
func (e *smtpErr) Error() string { return strconv.Itoa(e.Code) + " " + e.Text }
func tempErr(text string) *smtpErr { return &smtpErr{451, "4.4.1 " + text} }
// asSMTPErr converts an error from net/smtp into a reply for the sender.
func asSMTPErr(err error) *smtpErr {
var te *textproto.Error
if errors.As(err, &te) {
return &smtpErr{te.Code, te.Msg}
}
return tempErr(err.Error())
}
func domainOf(addr string) string {
if i := strings.LastIndexByte(addr, '@'); i >= 0 {
return strings.ToLower(strings.Trim(addr[i+1:], "<> "))
}
return ""
}
// lookupMail returns where to deliver mail for domain, best first.
func (b *Blacktip) lookupMail(domain string) ([]MailHost, *smtpErr) {
if b.cfg.MailLookup != nil {
hosts, err := b.cfg.MailLookup(domain)
if err != nil {
return nil, tempErr(err.Error())
}
return hosts, nil
}
mxs, err := net.LookupMX(domain)
var dnsErr *net.DNSError
if err != nil && !(errors.As(err, &dnsErr) && dnsErr.IsNotFound) && len(mxs) == 0 {
return nil, tempErr("DNS lookup for " + domain + " failed: " + err.Error())
}
if len(mxs) == 1 && mxs[0].Host == "." {
return nil, &smtpErr{556, "5.1.10 " + domain + " does not accept mail"}
}
var out []MailHost
for _, mx := range mxs {
out = append(out, MailHost{Host: strings.TrimSuffix(mx.Host, "."), Port: 25})
}
if len(out) == 0 { // no MX records: the domain's own address is the mail server (RFC 5321)
if ips, err := net.LookupHost(domain); err != nil || len(ips) == 0 {
return nil, &smtpErr{550, "5.1.2 domain " + domain + " not found"}
}
out = append(out, MailHost{Host: domain, Port: 25})
}
return out, nil
}
// mailRateOK counts a new transaction against the policy's per-minute limit.
func (b *Blacktip) mailRateOK(pol model.MailPolicy) bool {
limit := pol.RatePerMinute
if limit <= 0 {
limit = 60
}
b.mailMu.Lock()
defer b.mailMu.Unlock()
now := time.Now()
kept := b.mailRate[pol.ID][:0]
for _, t := range b.mailRate[pol.ID] {
if now.Sub(t) < time.Minute {
kept = append(kept, t)
}
}
if len(kept) >= limit {
b.mailRate[pol.ID] = kept
return false
}
b.mailRate[pol.ID] = append(kept, now)
return true
}
type mailSession struct {
b *Blacktip
c net.Conn
pol model.MailPolicy
peer string
cl *smtp.Client
from string
domain string
mx string
rcpts int
dw io.WriteCloser
start time.Time
sts bool // the recipient's MTA-STS policy was enforced
note string // what was done to the message (signed...), for the log
}
func (b *Blacktip) serveMail(ln net.Listener) {
for {
c, err := ln.Accept()
if err != nil {
return
}
go b.handleMail(c)
}
}
func (s *mailSession) close() {
if s.cl != nil {
s.cl.Close()
s.cl = nil
}
s.dw, s.rcpts, s.domain = nil, 0, ""
}
func (s *mailSession) reply(code int, text string) {
b, _ := json.Marshal(tunnel.MailReply{Code: code, Text: text})
s.c.Write(append(b, '\n'))
}
func (s *mailSession) replyErr(e *smtpErr) { s.reply(e.Code, e.Text) }
func (b *Blacktip) handleMail(c net.Conn) {
s := &mailSession{b: b, c: c, peer: hostFromAddr(c.RemoteAddr().String()), start: time.Now()}
defer func() { s.close(); c.Close() }()
br := bufio.NewReader(c)
authed := false
for {
c.SetReadDeadline(time.Now().Add(5 * time.Minute))
line, err := br.ReadBytes('\n')
if err != nil {
return
}
var m tunnel.MailMsg
if json.Unmarshal(line, &m) != nil {
return
}
if !authed {
b.mu.Lock()
pol, ok := b.mail[m.Egress]
peer := b.peerIP[pol.SiteID]
b.mu.Unlock()
if !ok || peer == "" || peer != s.peer {
s.reply(554, "5.7.1 not allowed: this site may not use that mail egress")
return
}
s.pol, authed = pol, true
}
switch m.Op {
case "rcpt":
s.rcpt(m)
case "data":
if s.cl == nil || s.rcpts == 0 {
s.reply(503, "5.5.1 no recipients")
continue
}
w, err := s.cl.Data()
if err != nil {
s.replyErr(asSMTPErr(err))
s.close()
continue
}
s.dw = w
s.reply(354, "start mail input")
case "body":
if !s.body(br, m.Len) {
return
}
case "rset", "quit":
if s.cl != nil {
s.cl.Quit()
}
s.close()
s.reply(250, "2.0.0 ok")
if m.Op == "quit" {
return
}
default:
s.reply(500, "5.5.1 unknown request")
}
}
}
// connect opens a connection to a mail server for domain and gets as far as MAIL FROM.
func (s *mailSession) connect(domain, from string) (*smtp.Client, string, *smtpErr) {
hosts := []MailHost(nil)
if s.pol.Smarthost != "" {
h, p, _ := net.SplitHostPort(s.pol.Smarthost)
port, _ := strconv.Atoi(p)
hosts = []MailHost{{Host: h, Port: port}}
} else {
var e *smtpErr
if hosts, e = s.b.lookupMail(domain); e != nil {
return nil, "", e
}
}
// a recipient domain's MTA-STS policy can forbid some servers and demand verified TLS
requireTLS, why := s.pol.RequireTLS, "this egress requires it"
if s.pol.Smarthost == "" {
if sts := s.b.stsFor(domain); sts != nil && sts.Mode == "enforce" {
var allowed []MailHost
for _, h := range hosts {
if sts.Allows(h.Host) {
allowed = append(allowed, h)
}
}
if len(allowed) == 0 {
return nil, "", &smtpErr{451, "4.7.5 " + domain + "'s MTA-STS policy doesn't allow delivery to any of its listed mail servers"}
}
hosts, requireTLS, why = allowed, true, "the recipient's MTA-STS policy requires it"
s.sts = true
}
}
src := net.ParseIP(s.pol.SourceIP)
last := tempErr("could not reach any mail server for " + domain)
for _, h := range hosts {
var ips []net.IP
if ip := net.ParseIP(h.Host); ip != nil {
ips = []net.IP{ip}
} else {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
res, err := net.DefaultResolver.LookupIPAddr(ctx, h.Host)
cancel()
if err != nil {
last = tempErr("cannot resolve " + h.Host)
continue
}
for _, r := range res {
ips = append(ips, r.IP)
}
}
for _, ip := range ips {
if (src.To4() != nil) != (ip.To4() != nil) {
continue // can't connect to an IPv6 server from an IPv4 source or the reverse
}
if !s.b.cfg.MailAllowPrivate && netguard.Blocked(ip) {
last = &smtpErr{554, "5.7.1 not allowed: " + h.Host + " is an internal address"}
continue
}
d := net.Dialer{Timeout: 15 * time.Second, LocalAddr: &net.TCPAddr{IP: src}}
conn, err := d.Dial("tcp", net.JoinHostPort(ip.String(), strconv.Itoa(h.Port)))
if err != nil {
last = tempErr("cannot connect to " + h.Host + ": " + err.Error())
continue
}
conn.SetDeadline(time.Now().Add(3 * time.Minute))
cl, err := smtp.NewClient(conn, h.Host)
if err != nil {
conn.Close()
last = asSMTPErr(err)
continue
}
if err := cl.Hello(s.pol.HeloName); err != nil {
cl.Close()
last = asSMTPErr(err)
continue
}
if ok, _ := cl.Extension("STARTTLS"); ok {
if err := cl.StartTLS(&tls.Config{ServerName: h.Host, InsecureSkipVerify: !requireTLS, RootCAs: s.b.cfg.MailRootCAs}); err != nil {
cl.Close()
last = tempErr("TLS to " + h.Host + " failed: " + err.Error())
continue
}
} else if requireTLS {
cl.Close()
last = tempErr(h.Host + " does not offer TLS and " + why)
continue
}
if err := cl.Mail(from); err != nil {
cl.Close()
if e := asSMTPErr(err); e.Code >= 500 {
return nil, "", e // the server refused this sender for good: another MX won't differ
} else {
last = e
}
continue
}
return cl, h.Host, nil
}
}
return nil, "", last
}
func (s *mailSession) rcpt(m tunnel.MailMsg) {
if s.cl == nil {
if !s.b.mailRateOK(s.pol) {
s.reply(451, "4.7.1 sending rate limit reached for this egress, try again shortly")
return
}
domain := domainOf(m.To)
if domain == "" {
s.reply(501, "5.1.3 bad recipient address")
return
}
cl, mx, e := s.connect(domain, m.From)
if e != nil {
s.logEntry(0, e.Code, "mail-failed: "+e.Text, domain, "")
s.replyErr(e)
return
}
s.cl, s.mx, s.domain, s.from = cl, mx, domain, m.From
} else if domainOf(m.To) != s.domain {
s.reply(452, "4.5.3 one destination domain per transaction")
return
}
if err := s.cl.Rcpt(m.To); err != nil {
e := asSMTPErr(err)
if s.rcpts == 0 {
s.close()
}
s.replyErr(e)
return
}
s.rcpts++
s.reply(250, "2.1.5 ok")
}
func (s *mailSession) body(br *bufio.Reader, n int) bool {
if n < 0 || n > tunnel.MaxMessage || s.dw == nil || s.cl == nil {
s.reply(552, "5.3.4 message size not accepted")
return false
}
s.c.SetReadDeadline(time.Now().Add(5 * time.Minute))
data := make([]byte, n)
if _, err := io.ReadFull(br, data); err != nil {
s.close()
return false
}
data, s.note = s.sign(data)
if _, err := s.dw.Write(data); err != nil {
e := asSMTPErr(err)
s.logEntry(n, e.Code, "mail-failed: "+e.Text, s.domain, s.mx)
s.replyErr(e)
s.close()
return true
}
cerr := s.dw.Close()
s.dw = nil
domain, mx := s.domain, s.mx
if cerr != nil {
e := asSMTPErr(cerr)
s.logEntry(n, e.Code, "mail-failed: "+e.Text, domain, mx)
s.replyErr(e)
s.close()
return true
}
s.cl.Quit()
s.logEntry(n, 250, "mail", domain, mx)
s.reply(250, "2.0.0 ok: delivered to "+mx)
s.close()
return true
}
func (s *mailSession) logEntry(size, code int, result, domain, mx string) {
path := "via " + mx
if s.sts {
path += " (MTA-STS)"
}
if s.note != "" {
path += " · " + s.note
}
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()})
}
// ---------- deliverability checks ----------
const defaultMailProbe = "gmail-smtp-in.l.google.com:25"
// mailCheck inspects how receiving servers will see mail from the policy's source IP.
func (b *Blacktip) mailCheck(pol model.MailPolicy) model.MailCheck {
chk := model.MailCheck{CheckedAt: time.Now(), PTR: []string{}}
src := net.ParseIP(pol.SourceIP)
if names, err := net.LookupAddr(pol.SourceIP); err == nil {
for _, n := range names {
n = strings.ToLower(strings.TrimSuffix(n, "."))
chk.PTR = append(chk.PTR, n)
if n == strings.ToLower(pol.HeloName) {
chk.PTRMatchesHelo = true
}
}
}
if ips, err := net.LookupHost(pol.HeloName); err == nil {
for _, ip := range ips {
if p := net.ParseIP(ip); p != nil && p.Equal(src) {
chk.HeloResolves = true
}
}
}
if len(pol.DKIM) > 0 {
chk.DKIM = b.checkDKIM(pol.DKIM)
}
probe := b.cfg.MailProbe
if probe == "" {
probe = defaultMailProbe
}
d := net.Dialer{Timeout: 8 * time.Second, LocalAddr: &net.TCPAddr{IP: src}}
c, err := d.Dial("tcp", probe)
if err != nil {
chk.Port25 = "can't connect out on port 25 from this address (" + err.Error() + "); many hosts block it, so ask your provider to open it"
return chk
}
defer c.Close()
c.SetReadDeadline(time.Now().Add(8 * time.Second))
banner, err := bufio.NewReader(c).ReadString('\n')
if err != nil || !strings.HasPrefix(banner, "220") {
chk.Port25 = "connected, but the server didn't greet us normally"
return chk
}
chk.Port25 = "open"
return chk
}
// runMailChecks refreshes the report for every current policy.
func (b *Blacktip) runMailChecks() {
b.mu.Lock()
pols := make([]model.MailPolicy, 0, len(b.mail))
for _, p := range b.mail {
pols = append(pols, p)
}
b.mu.Unlock()
sort.Slice(pols, func(i, j int) bool { return pols[i].ID < pols[j].ID })
res := map[string]model.MailCheck{}
var wg sync.WaitGroup
var mu sync.Mutex
for _, p := range pols {
wg.Add(1)
go func(p model.MailPolicy) {
defer wg.Done()
c := b.mailCheck(p)
mu.Lock()
res[p.ID] = c
mu.Unlock()
}(p)
}
wg.Wait()
b.mu.Lock()
b.mailChecks = res
b.mu.Unlock()
b.report()
}
func (b *Blacktip) mailCheckLoop(ctx context.Context) {
t := time.NewTicker(15 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
b.runMailChecks()
}
}
}
func mailPolicySig(m map[string]model.MailPolicy) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
var sb strings.Builder
for _, k := range keys {
p := m[k]
fmt.Fprintf(&sb, "%s|%s|%s|%s;", p.ID, p.SourceIP, p.HeloName, p.Smarthost)
for _, d := range p.DKIM {
fmt.Fprintf(&sb, "%s/%s/%s,", d.KeyID, d.Selector, d.Domain)
}
}
return sb.String()
}