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>
505 lines
16 KiB
Go
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)
|
|
}
|
|
}
|