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>
This commit is contained in:
1 parent
3daa368233
commit
f521fa8668
35 files changed
+10392
-1
No files matched your search
@@ -0,0 +1,279 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/blacktip"
|
||||
"github.com/emersion/go-msgauth/dkim"
|
||||
)
|
||||
|
||||
// trustedMXCert makes a certificate for 127.0.0.1 and a pool that trusts it, so
|
||||
// verified TLS to the fake mail server can succeed.
|
||||
func trustedMXCert(t *testing.T) (*tls.Certificate, *x509.CertPool) {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tpl := &x509.Certificate{SerialNumber: big.NewInt(7), Subject: pkix.Name{CommonName: "127.0.0.1"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
BasicConstraintsValid: true, IsCA: true, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf, _ := x509.ParseCertificate(der)
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(leaf)
|
||||
return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, pool
|
||||
}
|
||||
|
||||
// asCRLF puts a message back in the form it travels in (the fake server reads it with LF).
|
||||
func asCRLF(s string) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\n", "\r\n")
|
||||
}
|
||||
|
||||
type txtTable struct {
|
||||
mu sync.Mutex
|
||||
m map[string][]string
|
||||
}
|
||||
|
||||
func (t *txtTable) set(name string, vals ...string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.m[name] = vals
|
||||
}
|
||||
|
||||
func (t *txtTable) lookup(name string) ([]string, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if v, ok := t.m[name]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
|
||||
}
|
||||
|
||||
func TestDKIMSignsOutgoingMailAndChecksDNS(t *testing.T) {
|
||||
mx := newFakeMX(t, false)
|
||||
dns := &txtTable{m: map[string][]string{}}
|
||||
e := setupMail(t, true, mx, func(c *blacktip.Config) { c.MailLookupTXT = dns.lookup })
|
||||
base := "/api/orgs/" + e.org + "/dkim"
|
||||
|
||||
// validation
|
||||
for name, in := range map[string]map[string]string{
|
||||
"a bad domain": {"domain": "not a domain"},
|
||||
"a wildcard": {"domain": "*.home.example"},
|
||||
"a bad selector": {"domain": "home.example", "selector": "Bad Selector!"},
|
||||
} {
|
||||
if code, _ := e.admin.do("POST", base, in, nil); code != 400 {
|
||||
t.Errorf("%s was accepted", name)
|
||||
}
|
||||
}
|
||||
var key struct {
|
||||
ID, Domain, Selector, PublicKey, RecordName, Record, Status string
|
||||
}
|
||||
e.admin.must("POST", base, map[string]string{"domain": "Home.Example", "selector": "s1"}, &key)
|
||||
if key.Domain != "home.example" || key.RecordName != "s1._domainkey.home.example" || !strings.HasPrefix(key.Record, "v=DKIM1; k=rsa; p=") || !strings.HasSuffix(key.Record, key.PublicKey) {
|
||||
t.Fatalf("key: %+v", key)
|
||||
}
|
||||
if code, _ := e.admin.do("POST", base, map[string]string{"domain": "home.example", "selector": "s1"}, nil); code != 400 {
|
||||
t.Error("the same selector twice must be refused (rotate by choosing a new one)")
|
||||
}
|
||||
if code, raw := e.admin.do("GET", base, nil, nil); code != 200 || strings.Contains(raw, "PRIVATE KEY") {
|
||||
t.Errorf("listing must work and never reveal the private key: %d %s", code, raw)
|
||||
}
|
||||
|
||||
_, listen := e.add(map[string]any{"name": "signed"})
|
||||
verify := func(body string) []*dkim.Verification {
|
||||
v, err := dkim.VerifyWithOptions(strings.NewReader(asCRLF(body)), &dkim.VerifyOptions{LookupTXT: func(string) ([]string, error) { return []string{key.Record}, nil }})
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// mail from the signed domain is signed, and an independent verifier accepts it
|
||||
if err := send(t, listen, nil, "me@home.example", []string{"you@dest.example"}, letter); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
got := mx.last(t).body
|
||||
v := verify(got)
|
||||
if len(v) != 1 || v[0].Err != nil || v[0].Domain != "home.example" {
|
||||
t.Fatalf("the signature did not verify: %+v", v)
|
||||
}
|
||||
if !strings.Contains(got, "Hello.") || !strings.Contains(got, ".line that starts with a dot") {
|
||||
t.Errorf("signing damaged the body: %q", got)
|
||||
}
|
||||
|
||||
// mail from another domain is left alone
|
||||
other := strings.ReplaceAll(letter, "me@home.example", "me@other.example")
|
||||
if err := send(t, listen, nil, "me@other.example", []string{"you@dest.example"}, other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b := mx.last(t).body; strings.Contains(b, "DKIM-Signature") {
|
||||
t.Errorf("mail for a domain without a key was signed:\n%s", b)
|
||||
}
|
||||
|
||||
// a message your mail server already signed for that domain is not signed twice
|
||||
pre := "DKIM-Signature: v=1; a=rsa-sha256; d=home.example; s=old; h=from; bh=x; b=y\r\n" + letter
|
||||
if err := send(t, listen, nil, "me@home.example", []string{"you@dest.example"}, pre); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := strings.Count(strings.ToLower(mx.last(t).body), "dkim-signature:"); n != 1 {
|
||||
t.Errorf("an already-signed message now has %d signatures", n)
|
||||
}
|
||||
|
||||
// subdomains are covered by the parent domain's key
|
||||
sub := strings.ReplaceAll(letter, "me@home.example", "me@news.home.example")
|
||||
if err := send(t, listen, nil, "me@news.home.example", []string{"you@dest.example"}, sub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v := verify(mx.last(t).body); len(v) != 1 || v[0].Err != nil {
|
||||
t.Errorf("a subdomain wasn't signed by the parent's key: %+v", v)
|
||||
}
|
||||
|
||||
// the DNS check: missing until you publish the record
|
||||
status := func() string {
|
||||
var list []struct{ ID, Status string }
|
||||
e.admin.must("GET", base, nil, &list)
|
||||
return list[0].Status
|
||||
}
|
||||
waitStatus2 := func(want string) {
|
||||
deadline := time.Now().Add(12 * time.Second)
|
||||
for status() != want {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("DKIM status stuck at %q, want %q", status(), want)
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
waitStatus2("missing")
|
||||
dns.set("s1._domainkey.home.example", "v=DKIM1; k=rsa; p=AAAA") // somebody else's key
|
||||
e.admin.must("PUT", "/api/orgs/"+e.org+"/mail-egress/"+firstMailEgress(t, e), egressBody(e, "nudge1.example.invalid"), nil)
|
||||
waitStatus2("mismatch")
|
||||
dns.set("s1._domainkey.home.example", "v=DKIM1; k=rsa; p="+key.PublicKey[:40], "p="+key.PublicKey[40:]) // published, split over strings like long records are
|
||||
dns.set("s1._domainkey.home.example", "v=DKIM1; k=rsa; "+"p="+key.PublicKey)
|
||||
e.admin.must("PUT", "/api/orgs/"+e.org+"/mail-egress/"+firstMailEgress(t, e), egressBody(e, "nudge2.example.invalid"), nil)
|
||||
waitStatus2("published")
|
||||
|
||||
// removing the key stops signing
|
||||
e.admin.must("DELETE", base+"/"+key.ID, nil, nil)
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
for {
|
||||
if err := send(t, listen, nil, "me@home.example", []string{"you@dest.example"}, letter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(mx.last(t).body, "DKIM-Signature") {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("mail is still signed after the key was deleted")
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func firstMailEgress(t *testing.T, e *mailEnv) string {
|
||||
var st struct{ MailEgress []struct{ ID string } }
|
||||
e.admin.must("GET", "/api/orgs/"+e.org+"/state", nil, &st)
|
||||
return st.MailEgress[0].ID
|
||||
}
|
||||
|
||||
func egressBody(e *mailEnv, helo string) map[string]any {
|
||||
var st struct {
|
||||
MailEgress []struct {
|
||||
Listen string
|
||||
}
|
||||
}
|
||||
e.admin.must("GET", "/api/orgs/"+e.org+"/state", nil, &st)
|
||||
return map[string]any{"name": "signed", "siteId": e.site, "edgeId": e.edge, "sourceIp": "127.0.0.2", "heloName": helo, "listen": st.MailEgress[0].Listen, "enabled": true}
|
||||
}
|
||||
|
||||
func TestMTASTSAndVerifiedTLS(t *testing.T) {
|
||||
cert, pool := trustedMXCert(t)
|
||||
good := newFakeMX(t, true) // offers STARTTLS with a certificate the edge trusts
|
||||
good.cert = cert
|
||||
plain := newFakeMX(t, false)
|
||||
untrusted := newFakeMX(t, true) // self-signed, not trusted
|
||||
|
||||
var mu sync.Mutex
|
||||
policies := map[string]*blacktip.STSPolicy{}
|
||||
e := setupMail(t, true, good, func(c *blacktip.Config) {
|
||||
c.MailRootCAs = pool
|
||||
c.MailSTS = func(domain string) (*blacktip.STSPolicy, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return policies[domain], nil
|
||||
}
|
||||
})
|
||||
hostOf := func(m *fakeMX) blacktip.MailHost {
|
||||
h, p, _ := net.SplitHostPort(m.addr)
|
||||
n, _ := strconv.Atoi(p)
|
||||
return blacktip.MailHost{Host: h, Port: n}
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.hosts["tls.example"] = []blacktip.MailHost{hostOf(good)}
|
||||
e.hosts["plain.example"] = []blacktip.MailHost{hostOf(plain)}
|
||||
e.hosts["untrusted.example"] = []blacktip.MailHost{hostOf(untrusted)}
|
||||
e.mu.Unlock()
|
||||
mu.Lock()
|
||||
policies["tls.example"] = &blacktip.STSPolicy{Mode: "enforce", MX: []string{"127.0.0.1"}}
|
||||
policies["plain.example"] = &blacktip.STSPolicy{Mode: "enforce", MX: []string{"127.0.0.1"}}
|
||||
policies["untrusted.example"] = &blacktip.STSPolicy{Mode: "enforce", MX: []string{"127.0.0.1"}}
|
||||
policies["wrongmx.example"] = &blacktip.STSPolicy{Mode: "enforce", MX: []string{"mail.elsewhere.example", "*.mx.elsewhere.example"}}
|
||||
policies["testing.example"] = &blacktip.STSPolicy{Mode: "testing", MX: []string{"mail.elsewhere.example"}}
|
||||
mu.Unlock()
|
||||
e.mu.Lock()
|
||||
e.hosts["wrongmx.example"] = []blacktip.MailHost{hostOf(good)}
|
||||
e.hosts["testing.example"] = []blacktip.MailHost{hostOf(plain)}
|
||||
e.mu.Unlock()
|
||||
_, listen := e.add(map[string]any{"name": "sts"})
|
||||
|
||||
mail := func(domain string) error {
|
||||
return send(t, listen, nil, "me@home.example", []string{"you@" + domain}, letter)
|
||||
}
|
||||
// enforce + a listed server + verified TLS: delivered, over TLS
|
||||
if err := mail("tls.example"); err != nil {
|
||||
t.Fatalf("enforce with a good server: %v", err)
|
||||
}
|
||||
if got := good.last(t); !got.tls {
|
||||
t.Error("the message was not sent over TLS under an enforcing policy")
|
||||
}
|
||||
// enforce + a server the policy doesn't list: refused before any connection
|
||||
if err := mail("wrongmx.example"); smtpCode(err) != 451 || !strings.Contains(err.Error(), "MTA-STS") {
|
||||
t.Errorf("a server outside the policy: %v", err)
|
||||
}
|
||||
// enforce + no TLS offered: refused, and nothing sent in the clear
|
||||
if err := mail("plain.example"); smtpCode(err) != 451 || !strings.Contains(err.Error(), "TLS") || plain.count() != 0 {
|
||||
t.Errorf("enforce without TLS: err=%v delivered=%d", err, plain.count())
|
||||
}
|
||||
// enforce + a certificate nobody trusts: refused
|
||||
if err := mail("untrusted.example"); smtpCode(err) != 451 || untrusted.count() != 0 {
|
||||
t.Errorf("enforce with an untrusted certificate: err=%v delivered=%d", err, untrusted.count())
|
||||
}
|
||||
// testing mode only observes: delivered even in the clear
|
||||
if err := mail("testing.example"); err != nil {
|
||||
t.Errorf("testing mode must not block mail: %v", err)
|
||||
}
|
||||
// and with no policy at all, mail flows as before
|
||||
if err := send(t, listen, nil, "me@home.example", []string{"you@dest.example"}, letter); err != nil {
|
||||
t.Errorf("no policy: %v", err)
|
||||
}
|
||||
|
||||
// the egress's own "require verified TLS" now succeeds against a trusted certificate and still refuses the rest
|
||||
_, strict := e.add(map[string]any{"name": "strict", "requireTls": true})
|
||||
if err := send(t, strict, nil, "me@home.example", []string{"you@tls.example"}, letter); err != nil {
|
||||
t.Errorf("require TLS against a trusted server: %v", err)
|
||||
}
|
||||
if err := send(t, strict, nil, "me@home.example", []string{"you@untrusted.example"}, letter); smtpCode(err) != 451 {
|
||||
t.Errorf("require TLS against an untrusted server: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user