Files
VeillaxandClaude Sonnet 5.5 f521fa8668 Add the Hammerhead control plane
Dashboard and API, accounts (organizations, roles, invitations, 2FA, OIDC
single sign-on, API keys, revocable sessions), an encrypted SQLite database,
resource logins, alerts and /metrics, mail egress management with DKIM keys,
and the .env loader. Split out of the original single repository; depends on
Reef for the shared types. The end-to-end tests that run Hammerhead, Blacktip
and Bamboo together live in e2e/.

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

505 lines
16 KiB
Go

package e2e
import (
"crypto/tls"
"errors"
"fmt"
"net"
"net/smtp"
"net/textproto"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.veillax.com/Veillax/blacktip"
"git.veillax.com/Veillax/reef/model"
)
// ---------- a fake recipient mail server ----------
type mxMsg struct {
from, ehlo, remote, body string
rcpts []string
tls bool
}
type fakeMX struct {
addr string
mu sync.Mutex
msgs []mxMsg
rcptCode map[string]int // address -> reply code to give instead of 250
offerTLS bool
cert *tls.Certificate
}
func newFakeMX(t *testing.T, offerTLS bool) *fakeMX {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
m := &fakeMX{addr: l.Addr().String(), rcptCode: map[string]int{}, offerTLS: offerTLS}
if offerTLS {
m.cert = selfSigned(t, "mx.example.invalid")
}
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
go m.serve(c)
}
}()
return m
}
func (m *fakeMX) serve(c net.Conn) {
defer c.Close()
host, _, _ := net.SplitHostPort(c.RemoteAddr().String())
tp := textproto.NewConn(c)
say := func(format string, a ...any) { fmt.Fprintf(c, format+"\r\n", a...) }
say("220 fake-mx ESMTP")
cur := mxMsg{remote: host}
usedTLS := false
for {
line, err := tp.ReadLine()
if err != nil {
return
}
verb, arg, _ := strings.Cut(line, " ")
switch strings.ToUpper(verb) {
case "EHLO", "HELO":
cur.ehlo = arg
if m.offerTLS && !usedTLS {
say("250-fake-mx")
say("250-STARTTLS")
say("250 8BITMIME")
} else {
say("250-fake-mx")
say("250 8BITMIME")
}
case "STARTTLS":
say("220 go ahead")
tc := tls.Server(c, &tls.Config{Certificates: []tls.Certificate{*m.cert}})
if tc.Handshake() != nil {
return
}
c, tp, usedTLS = tc, textproto.NewConn(tc), true
say = func(format string, a ...any) { fmt.Fprintf(tc, format+"\r\n", a...) }
case "MAIL":
if i, j := strings.IndexByte(arg, '<'), strings.IndexByte(arg, '>'); i >= 0 && j > i {
cur.from = arg[i+1 : j]
}
cur.rcpts, cur.tls = nil, usedTLS
say("250 ok")
case "RCPT":
a := strings.Trim(arg[3:], "<> ")
if code, bad := m.rcptCode[a]; bad {
say("%d no thanks", code)
continue
}
cur.rcpts = append(cur.rcpts, a)
say("250 ok")
case "DATA":
say("354 go on")
b := new(strings.Builder)
r := tp.DotReader()
buf := make([]byte, 4096)
for {
n, err := r.Read(buf)
b.Write(buf[:n])
if err != nil {
break
}
}
cur.body = b.String()
m.mu.Lock()
m.msgs = append(m.msgs, cur)
m.mu.Unlock()
say("250 2.0.0 queued as FAKE%d", len(m.msgs))
case "RSET", "NOOP":
say("250 ok")
case "QUIT":
say("221 bye")
return
default:
say("502 nope")
}
}
}
func (m *fakeMX) last(t *testing.T) mxMsg {
t.Helper()
m.mu.Lock()
defer m.mu.Unlock()
if len(m.msgs) == 0 {
t.Fatal("the mail server received nothing")
}
return m.msgs[len(m.msgs)-1]
}
func (m *fakeMX) count() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.msgs)
}
// ---------- helpers ----------
type mailEnv struct {
*world
mx *fakeMX
mu sync.Mutex
hosts map[string][]blacktip.MailHost
}
func setupMail(t *testing.T, allowPrivate bool, mx *fakeMX, extra ...func(*blacktip.Config)) *mailEnv {
e := &mailEnv{mx: mx, hosts: map[string][]blacktip.MailHost{}}
host, port, _ := net.SplitHostPort(mx.addr)
p, _ := strconv.Atoi(port)
e.hosts["dest.example"] = []blacktip.MailHost{{Host: host, Port: p}}
opts := append([]func(*blacktip.Config){func(c *blacktip.Config) {
c.MailAllowPrivate = allowPrivate
c.MailProbe = mx.addr
c.MailLookup = func(domain string) ([]blacktip.MailHost, error) {
e.mu.Lock()
defer e.mu.Unlock()
if h, ok := e.hosts[domain]; ok {
return h, nil
}
return nil, errors.New("no such domain in this test")
}
}}, extra...)
e.world = setup(t, blacktip.TraefikConfig{}, opts...)
return e
}
func (e *mailEnv) add(m map[string]any) (id, listen string) {
e.t.Helper()
listen = "127.0.0.1:" + strconv.Itoa(freePort(e.t))
defaults := map[string]any{"listen": listen, "sourceIp": "127.0.0.2", "heloName": "mail.example.invalid",
"siteId": e.site, "edgeId": e.edge, "enabled": true}
for k, v := range defaults {
if _, ok := m[k]; !ok {
m[k] = v
}
}
var out struct{ ID string }
e.admin.must("POST", "/api/orgs/"+e.org+"/mail-egress", m, &out)
return out.ID, m["listen"].(string)
}
// send delivers one message through the relay at addr, retrying while the tunnel comes up.
func send(t *testing.T, addr string, auth smtp.Auth, from string, to []string, body string) error {
t.Helper()
var err error
for i := 0; i < 50; i++ {
err = trySend(addr, auth, from, to, body)
var te *textproto.Error
// retry only while the relay or the tunnel is still coming up
transient := err != nil && (!errors.As(err, &te) || (te.Code == 451 && strings.Contains(te.Msg, "edge")))
if !transient {
return err
}
time.Sleep(200 * time.Millisecond)
}
return err
}
func trySend(addr string, auth smtp.Auth, from string, to []string, body string) error {
c, err := smtp.Dial(addr)
if err != nil {
return err
}
defer c.Close()
if err := c.Hello("client.local"); err != nil {
return err
}
if auth != nil {
if err := c.Auth(auth); err != nil {
return err
}
}
if err := c.Mail(from); err != nil {
return err
}
for _, r := range to {
if err := c.Rcpt(r); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write([]byte(body)); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
func smtpCode(err error) int {
var te *textproto.Error
if errors.As(err, &te) {
return te.Code
}
return 0
}
const letter = "Subject: hi\r\nFrom: me@home.example\r\nTo: you@dest.example\r\n\r\nHello.\r\n.line that starts with a dot\r\nBye.\r\n"
// ---------- the tests ----------
func TestMailEgressDeliversFromTheEdgeIP(t *testing.T) {
mx := newFakeMX(t, true) // offers STARTTLS (self-signed)
e := setupMail(t, true, mx)
_, listen := e.add(map[string]any{"name": "mail out"})
if err := send(t, listen, nil, "me@home.example", []string{"you@dest.example", "other@dest.example"}, letter); err != nil {
t.Fatalf("send: %v", err)
}
got := mx.last(t)
if got.remote != "127.0.0.2" {
t.Errorf("mail left from %s, want the edge source IP 127.0.0.2", got.remote)
}
if got.ehlo != "mail.example.invalid" {
t.Errorf("announced itself as %q, want the configured HELO name", got.ehlo)
}
if got.from != "me@home.example" || strings.Join(got.rcpts, ",") != "you@dest.example,other@dest.example" {
t.Errorf("envelope wrong: from=%q rcpts=%v", got.from, got.rcpts)
}
if !got.tls {
t.Error("opportunistic STARTTLS was not used although the server offered it")
}
if !strings.Contains(got.body, "\n.line that starts with a dot\n") && !strings.Contains(got.body, "\r\n.line that starts with a dot\r\n") {
t.Errorf("a line starting with a dot was mangled: %q", got.body)
}
if !strings.Contains(got.body, "Hello.") || !strings.Contains(got.body, "Bye.") {
t.Errorf("body incomplete: %q", got.body)
}
// a 1 MB message goes through intact
big := "Subject: big\r\n\r\n" + strings.Repeat("0123456789abcdef", 64*1024) + "\r\n"
if err := send(t, listen, nil, "me@home.example", []string{"you@dest.example"}, big); err != nil {
t.Fatalf("big message: %v", err)
}
if n := len(mx.last(t).body); n < len(big)-200 {
t.Errorf("large message truncated: got %d bytes of %d", n, len(big))
}
// the null sender (bounces) is allowed
if err := send(t, listen, nil, "", []string{"you@dest.example"}, letter); err != nil {
t.Errorf("null sender: %v", err)
}
if mx.last(t).from != "" {
t.Errorf("null sender became %q", mx.last(t).from)
}
// the delivery shows up in the access log with the recipient domain and the SMTP status
deadline := time.Now().Add(10 * time.Second)
for {
var logs []model.LogEntry
e.admin.must("GET", "/api/orgs/"+e.org+"/logs?kind=access&limit=100", nil, &logs)
found := false
for _, l := range logs {
if l.Result == "mail" && l.Host == "dest.example" && l.Status == 250 {
found = true
}
}
if found {
break
}
if time.Now().After(deadline) {
t.Fatalf("no mail entry in the access log: %+v", logs)
}
time.Sleep(300 * time.Millisecond)
}
}
func TestMailEgressReplyHandling(t *testing.T) {
mx := newFakeMX(t, false)
mx.rcptCode["nobody@dest.example"] = 550
mx.rcptCode["full@dest.example"] = 452
e := setupMail(t, true, mx)
_, listen := e.add(map[string]any{})
// the receiving server's refusal reaches the sender with its own code
err := send(t, listen, nil, "me@home.example", []string{"nobody@dest.example"}, letter)
if smtpCode(err) != 550 {
t.Errorf("a 550 from the recipient's server came back as %v", err)
}
// ...and a refused first recipient doesn't poison the next transaction
if err := send(t, listen, nil, "me@home.example", []string{"you@dest.example"}, letter); err != nil {
t.Errorf("a good message after a refusal: %v", err)
}
// a refused extra recipient is reported for that recipient only
c, err := smtp.Dial(listen)
if err != nil {
t.Fatal(err)
}
defer c.Close()
c.Hello("client.local")
c.Mail("me@home.example")
if err := c.Rcpt("you@dest.example"); err != nil {
t.Fatalf("rcpt 1: %v", err)
}
if err := c.Rcpt("full@dest.example"); smtpCode(err) != 452 {
t.Errorf("second recipient: %v", err)
}
// recipients in a different domain are deferred to another transaction
e.mu.Lock()
e.hosts["elsewhere.example"] = e.hosts["dest.example"]
e.mu.Unlock()
if err := c.Rcpt("x@elsewhere.example"); smtpCode(err) != 452 {
t.Errorf("a second domain in one transaction: %v", err)
}
c.Reset()
if err := c.Mail("me@home.example"); err != nil {
t.Errorf("RSET then a new transaction: %v", err)
}
// a mail server that can't be reached is a temporary failure: the sender will retry
e.mu.Lock()
e.hosts["down.example"] = []blacktip.MailHost{{Host: "127.0.0.1", Port: freePort(t)}}
e.mu.Unlock()
if err := send(t, listen, nil, "me@home.example", []string{"a@down.example"}, letter); smtpCode(err) != 451 {
t.Errorf("unreachable server: %v", err)
}
// an unknown domain with no records is temporary here too (the test resolver errors)
if err := send(t, listen, nil, "me@home.example", []string{"a@nowhere.example"}, letter); smtpCode(err) < 400 {
t.Errorf("unknown domain: %v", err)
}
}
func TestMailEgressAuthTLSSmarthostAndRate(t *testing.T) {
plain := newFakeMX(t, false)
e := setupMail(t, true, plain)
// passwords
_, authAddr := e.add(map[string]any{"name": "auth", "username": "relay", "password": "hunter2!"})
if err := send(t, authAddr, smtp.PlainAuth("", "relay", "hunter2!", "127.0.0.1"), "me@home.example", []string{"you@dest.example"}, letter); err != nil {
t.Fatalf("authenticated send: %v", err)
}
if err := trySend(authAddr, nil, "me@home.example", []string{"you@dest.example"}, letter); smtpCode(err) != 530 {
t.Errorf("sending without credentials: %v", err)
}
if err := trySend(authAddr, smtp.PlainAuth("", "relay", "wrong", "127.0.0.1"), "me@home.example", []string{"you@dest.example"}, letter); smtpCode(err) != 535 {
t.Errorf("wrong password: %v", err)
}
// require-TLS refuses a server with no STARTTLS, and one whose certificate can't be verified
_, tlsAddr := e.add(map[string]any{"name": "strict", "requireTls": true})
err := send(t, tlsAddr, nil, "me@home.example", []string{"you@dest.example"}, letter)
if smtpCode(err) != 451 || !strings.Contains(err.Error(), "TLS") {
t.Errorf("require-TLS against a plain server: %v", err)
}
selfSignedMX := newFakeMX(t, true)
h, p, _ := net.SplitHostPort(selfSignedMX.addr)
pn, _ := strconv.Atoi(p)
e.mu.Lock()
e.hosts["tls.example"] = []blacktip.MailHost{{Host: h, Port: pn}}
e.mu.Unlock()
err = send(t, tlsAddr, nil, "me@home.example", []string{"you@tls.example"}, letter)
if smtpCode(err) != 451 || selfSignedMX.count() != 0 {
t.Errorf("require-TLS with an untrusted certificate: err=%v delivered=%d", err, selfSignedMX.count())
}
// a smarthost receives everything, whatever the domain
smart := newFakeMX(t, false)
_, smartAddr := e.add(map[string]any{"name": "smart", "smarthost": smart.addr})
if err := send(t, smartAddr, nil, "me@home.example", []string{"anyone@unknown.example"}, letter); err != nil {
t.Fatalf("smarthost: %v", err)
}
if got := smart.last(t); got.remote != "127.0.0.2" || strings.Join(got.rcpts, ",") != "anyone@unknown.example" {
t.Errorf("smarthost got %+v", got)
}
// the per-minute limit protects the IP's reputation
_, rateAddr := e.add(map[string]any{"name": "slow", "ratePerMinute": 2})
for i := 0; i < 2; i++ {
if err := send(t, rateAddr, nil, "me@home.example", []string{"you@dest.example"}, letter); err != nil {
t.Fatalf("message %d: %v", i+1, err)
}
}
if err := send(t, rateAddr, nil, "me@home.example", []string{"you@dest.example"}, letter); smtpCode(err) != 451 || !strings.Contains(err.Error(), "rate") {
t.Errorf("third message in a minute: %v", err)
}
}
func TestMailEgressGuardsAndValidation(t *testing.T) {
mx := newFakeMX(t, false)
e := setupMail(t, false, mx) // loopback destinations are NOT allowed here
_, listen := e.add(map[string]any{})
// a mail server on an internal address is refused: the edge can't be aimed at its own network
err := send(t, listen, nil, "me@home.example", []string{"you@dest.example"}, letter)
if smtpCode(err) != 554 || !strings.Contains(err.Error(), "not allowed") || mx.count() != 0 {
t.Errorf("internal destination: err=%v delivered=%d", err, mx.count())
}
base := "/api/orgs/" + e.org + "/mail-egress"
good := func(over map[string]any) map[string]any {
m := map[string]any{"siteId": e.site, "edgeId": e.edge, "sourceIp": "127.0.0.2", "heloName": "mail.example.invalid",
"listen": "127.0.0.1:" + strconv.Itoa(freePort(t)), "enabled": true}
for k, v := range over {
m[k] = v
}
return m
}
e.admin.expect(400, "POST", base, good(map[string]any{"siteId": e.local})) // the local site needs no relay
e.admin.expect(400, "POST", base, good(map[string]any{"sourceIp": ""})) // the exit IP must be chosen
e.admin.expect(400, "POST", base, good(map[string]any{"sourceIp": "nope"})) //
e.admin.expect(400, "POST", base, good(map[string]any{"heloName": "not a host"})) //
e.admin.expect(400, "POST", base, good(map[string]any{"heloName": ""})) //
e.admin.expect(400, "POST", base, good(map[string]any{"smarthost": "no-port"})) //
e.admin.expect(400, "POST", base, good(map[string]any{"ratePerMinute": 99999})) //
e.admin.expect(400, "POST", base, good(map[string]any{"username": "x"})) // user without password
e.admin.expect(400, "POST", base, good(map[string]any{"listen": "0.0.0.0:2525"})) // open to the LAN without a password
e.admin.expect(400, "POST", base, good(map[string]any{"listen": listen})) // already taken
e.admin.must("POST", base, good(map[string]any{"listen": "0.0.0.0:" + strconv.Itoa(freePort(t)), "username": "u", "password": "p"}), nil) // LAN is fine with a password
// the state shows the egress without its password, plus the edge's deliverability report
deadline := time.Now().Add(15 * time.Second)
for {
var st struct {
MailEgress []struct {
ID, Name string
PasswordSet bool
Password string
Check *model.MailCheck
}
}
e.admin.must("GET", "/api/orgs/"+e.org+"/state", nil, &st)
ready := len(st.MailEgress) > 0
for _, m := range st.MailEgress {
if m.Password != "" {
t.Fatalf("state leaks the password of %s", m.Name)
}
if m.Check == nil || m.Check.Port25 == "" {
ready = false
}
}
if ready {
for _, m := range st.MailEgress {
if m.Check.Port25 != "open" {
t.Errorf("port 25 probe = %q, want open (the probe target is the fake server)", m.Check.Port25)
}
if m.Check.PTRMatchesHelo || m.Check.HeloResolves {
t.Errorf("a made-up HELO name can't match reverse DNS: %+v", m.Check)
}
}
break
}
if time.Now().After(deadline) {
t.Fatalf("no deliverability report arrived: %+v", st.MailEgress)
}
time.Sleep(300 * time.Millisecond)
}
}