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:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 02:50:03 -06:00
1 parent 3daa368233
commit f521fa8668
35 files changed
+10392 -1

No files matched your search

+941
View File
@@ -0,0 +1,941 @@
// Package e2e runs hammerhead, blacktip and bamboo together in-process.
package e2e
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base32"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.veillax.com/Veillax/bamboo"
"git.veillax.com/Veillax/blacktip"
"git.veillax.com/Veillax/hammerhead"
"git.veillax.com/Veillax/reef/model"
)
// ---------- helpers ----------
func freeUDPPort(t *testing.T) int {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer pc.Close()
return pc.LocalAddr().(*net.UDPAddr).Port
}
func freePort(t *testing.T) int {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// tcpBackend answers every connection with "<tag>:<what you sent>".
func tcpBackend(t *testing.T, tag string) int {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
buf := make([]byte, 4096)
n, _ := c.Read(buf)
fmt.Fprintf(c, "%s:%s", tag, buf[:n])
}()
}
}()
return l.Addr().(*net.TCPAddr).Port
}
func udpBackend(t *testing.T, tag string) int {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pc.Close() })
go func() {
buf := make([]byte, 4096)
for {
n, addr, err := pc.ReadFrom(buf)
if err != nil {
return
}
pc.WriteTo([]byte(tag+":"+string(buf[:n])), addr)
}
}()
return pc.LocalAddr().(*net.UDPAddr).Port
}
// webBackend echoes what it saw as JSON; /healthz returns healthStatus.
func webBackend(t *testing.T, name string, healthStatus int) int {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
w.WriteHeader(healthStatus)
return
}
json.NewEncoder(w).Encode(map[string]string{
"backend": name, "host": r.Host, "path": r.URL.Path,
"user": r.Header.Get("X-Hammerhead-User"), "xff": r.Header.Get("X-Forwarded-For"),
"proto": r.Header.Get("X-Forwarded-Proto"), "cookie": r.Header.Get("Cookie"),
})
}))
t.Cleanup(srv.Close)
return srv.Listener.Addr().(*net.TCPAddr).Port
}
type client struct {
t *testing.T
c *http.Client
u string
key string
}
func newClient(t *testing.T, base string) *client {
jar, _ := cookiejar.New(nil)
return &client{t: t, c: &http.Client{Jar: jar, Timeout: 15 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, u: base}
}
func (a *client) do(method, path string, in, out any) (int, string) {
a.t.Helper()
var body io.Reader
if in != nil {
b, _ := json.Marshal(in)
body = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, a.u+path, body)
req.Header.Set("Content-Type", "application/json")
if a.key != "" {
req.Header.Set("Authorization", "Bearer "+a.key)
}
resp, err := a.c.Do(req)
if err != nil {
a.t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if out != nil && resp.StatusCode == 200 {
if err := json.Unmarshal(raw, out); err != nil {
a.t.Fatalf("%s %s: bad json %q: %v", method, path, raw, err)
}
}
return resp.StatusCode, string(raw)
}
func (a *client) must(method, path string, in, out any) {
a.t.Helper()
if code, raw := a.do(method, path, in, out); code != 200 {
a.t.Fatalf("%s %s -> %d %s", method, path, code, raw)
}
}
func (a *client) expect(code int, method, path string, in any) string {
a.t.Helper()
got, raw := a.do(method, path, in, nil)
if got != code {
a.t.Fatalf("%s %s -> %d (want %d): %s", method, path, got, code, raw)
}
return raw
}
func tcpAsk(t *testing.T, addr, msg string) string {
t.Helper()
var last error
for i := 0; i < 40; i++ {
c, err := net.DialTimeout("tcp", addr, time.Second)
if err == nil {
c.SetDeadline(time.Now().Add(3 * time.Second))
fmt.Fprint(c, msg)
b, _ := io.ReadAll(c)
c.Close()
if len(b) > 0 {
return string(b)
}
last = fmt.Errorf("empty reply")
} else {
last = err
}
time.Sleep(150 * time.Millisecond)
}
t.Fatalf("tcp %s: %v", addr, last)
return ""
}
func udpAsk(t *testing.T, addr, msg string) string {
t.Helper()
var last error
for i := 0; i < 40; i++ {
c, err := net.Dial("udp", addr)
if err != nil {
t.Fatal(err)
}
c.SetDeadline(time.Now().Add(400 * time.Millisecond))
c.Write([]byte(msg))
buf := make([]byte, 4096)
n, err := c.Read(buf)
c.Close()
if err == nil {
return string(buf[:n])
}
last = err
}
t.Fatalf("udp %s: %v", addr, last)
return ""
}
func totpNow(secret string) string {
key, _ := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
var b [8]byte
binary.BigEndian.PutUint64(b[:], uint64(time.Now().Unix()/30))
m := hmac.New(sha1.New, key)
m.Write(b[:])
sum := m.Sum(nil)
off := sum[len(sum)-1] & 0xf
return fmt.Sprintf("%06d", (binary.BigEndian.Uint32(sum[off:off+4])&0x7fffffff)%1000000)
}
func selfSigned(t *testing.T, host string) *tls.Certificate {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: host}, DNSNames: []string{host},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
}
// ---------- the environment ----------
type world struct {
t *testing.T
ts *httptest.Server
hh *hammerhead.Server
admin *client
org string
edge string
edgeTok string
site string // remote site
local string // built-in local site
mails chan string
mu sync.Mutex
}
func setup(t *testing.T, tr blacktip.TraefikConfig, opts ...func(*blacktip.Config)) *world {
w := &world{t: t, mails: make(chan string, 16)}
var handler http.Handler
w.ts = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { handler.ServeHTTP(rw, r) }))
t.Cleanup(w.ts.Close)
hh, err := hammerhead.New(t.TempDir(), hammerhead.Options{AdminEmail: "admin@test.local", AdminPassword: "password123", DashboardURL: w.ts.URL})
if err != nil {
t.Fatal(err)
}
t.Cleanup(hh.Close)
hh.Mailer = func(to, subject, body string) error { w.mails <- to + "|" + subject + "|" + body; return nil }
handler = hh.Handler()
w.hh = hh
w.admin = newClient(t, w.ts.URL)
w.admin.must("POST", "/api/login", map[string]string{"email": "admin@test.local", "password": "password123"}, nil)
var me struct{ Orgs []struct{ ID string } }
w.admin.must("GET", "/api/me", nil, &me)
w.org = me.Orgs[0].ID
tunnelPort := freeUDPPort(t)
var edge struct{ ID, Token string }
w.admin.must("POST", "/api/edges", map[string]any{"name": "edge1", "tunnelAddr": "127.0.0.1:" + strconv.Itoa(tunnelPort)}, &edge)
w.edge, w.edgeTok = edge.ID, edge.Token
var site model.Site
w.admin.must("POST", "/api/orgs/"+w.org+"/sites", map[string]string{"name": "home"}, &site)
w.site = site.ID
w.refreshLocal()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
cert := selfSigned(t, "secure.example.test")
btCfg := blacktip.Config{Hammerhead: w.ts.URL, Token: edge.Token, TunnelAddr: "127.0.0.1:" + strconv.Itoa(tunnelPort), DataDir: t.TempDir(),
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return cert, nil }, Traefik: tr}
for _, o := range opts {
o(&btCfg)
}
bt, err := blacktip.New(btCfg)
if err != nil {
t.Fatal(err)
}
go bt.Run(ctx)
bb, err := bamboo.New(bamboo.Config{Hammerhead: w.ts.URL, Token: site.Token, DataDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
go bb.Run(ctx)
return w
}
func (w *world) refreshLocal() {
var st struct{ Sites []model.Site }
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
for _, s := range st.Sites {
if s.Local {
w.local = s.ID
}
}
if w.local == "" {
w.t.Fatal("edge did not get a built-in local site")
}
}
func (w *world) open(m model.Mapping) model.Mapping {
w.t.Helper()
m.EdgeID, m.Enabled = w.edge, true
if m.BindIP == "" {
m.BindIP = "127.0.0.1"
}
var out model.Mapping
w.admin.must("POST", "/api/orgs/"+w.org+"/mappings", map[string]any{
"proto": m.Proto, "edgeId": m.EdgeID, "bindIp": m.BindIP, "port": m.Port, "hostname": m.Hostname, "targets": m.Targets,
"enabled": true, "auth": m.Auth, "rules": m.Rules, "healthPath": m.HealthPath, "healthSeconds": m.HealthSeconds,
"name": m.Name,
}, &out)
return out
}
func tgt(site string, port int) []model.Target {
return []model.Target{{SiteID: site, Host: "127.0.0.1", Port: port}}
}
// ---------- tests ----------
func TestPortsMultiIPFailoverAndValidation(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
remoteA, remoteB, localSvc := tcpBackend(t, "A"), tcpBackend(t, "B"), tcpBackend(t, "LOCAL")
udpSvc := udpBackend(t, "UDP")
port := freePort(t) // the SAME public port on different IPs
w.open(model.Mapping{Proto: "tcp", BindIP: "127.0.0.1", Port: port, Targets: tgt(w.site, remoteA)})
w.open(model.Mapping{Proto: "tcp", BindIP: "127.0.0.2", Port: port, Targets: tgt(w.site, remoteB)})
w.open(model.Mapping{Proto: "tcp", BindIP: "127.0.0.3", Port: port, Targets: tgt(w.local, localSvc)})
w.open(model.Mapping{Proto: "udp", BindIP: "127.0.0.1", Port: port, Targets: tgt(w.site, udpSvc)})
w.open(model.Mapping{Proto: "udp", BindIP: "127.0.0.3", Port: port, Targets: tgt(w.local, udpSvc)})
// failover: first target is dead, second is alive
dead := freePort(t)
fo := freePort(t)
w.open(model.Mapping{Proto: "tcp", BindIP: "127.0.0.4", Port: fo,
Targets: []model.Target{{SiteID: w.local, Host: "127.0.0.1", Port: dead}, {SiteID: w.site, Host: "127.0.0.1", Port: remoteA}}})
check := func(got, want string) {
t.Helper()
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
pa := strconv.Itoa(port)
check(tcpAsk(t, "127.0.0.1:"+pa, "hi"), "A:hi")
check(tcpAsk(t, "127.0.0.2:"+pa, "hi"), "B:hi")
check(tcpAsk(t, "127.0.0.3:"+pa, "hi"), "LOCAL:hi")
check(udpAsk(t, "127.0.0.1:"+pa, "ping"), "UDP:ping")
check(udpAsk(t, "127.0.0.3:"+pa, "ping"), "UDP:ping")
for i := 0; i < 4; i++ { // round robin must keep landing on the live target
check(tcpAsk(t, "127.0.0.4:"+strconv.Itoa(fo), "x"), "A:x")
}
base := "/api/orgs/" + w.org + "/mappings"
for name, m := range map[string]map[string]any{
"duplicate": {"proto": "tcp", "bindIp": "127.0.0.1", "port": port},
"wildcard": {"proto": "tcp", "bindIp": "0.0.0.0", "port": port},
"no targets": {"proto": "tcp", "bindIp": "127.0.0.9", "port": port, "targets": []any{}},
"bad udp": {"proto": "udp", "bindIp": "127.0.0.9", "port": port, "targets": append(tgt(w.site, 1), tgt(w.site, 2)...)},
"no hostname": {"proto": "http", "bindIp": "127.0.0.9", "port": port},
} {
m["edgeId"] = w.edge
if _, ok := m["targets"]; !ok {
m["targets"] = tgt(w.site, remoteA)
}
w.admin.expect(400, "POST", base, m)
_ = name
}
// dead target gets flagged unhealthy in the UI state
var st struct {
Mappings []struct {
ID, BindIP string
Status model.MappingStatus
}
}
deadline := time.Now().Add(8 * time.Second)
for {
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
good := false
for _, m := range st.Mappings {
if m.BindIP == "127.0.0.4" && len(m.Status.Targets) == 2 && !m.Status.Targets[0].Healthy && m.Status.Targets[1].Healthy {
good = true
}
}
if good {
break
}
if time.Now().After(deadline) {
t.Fatalf("health state never showed the dead target: %+v", st.Mappings)
}
time.Sleep(250 * time.Millisecond)
}
// closing a port frees it
for _, m := range st.Mappings {
if m.BindIP == "127.0.0.2" {
w.admin.must("DELETE", base+"/"+m.ID, nil, nil)
}
}
time.Sleep(600 * time.Millisecond)
if c, err := net.DialTimeout("tcp", "127.0.0.2:"+pa, time.Second); err == nil {
c.Close()
t.Error("port still open after closing it")
}
check(tcpAsk(t, "127.0.0.1:"+pa, "again"), "A:again")
}
type webResp struct {
status int
location string
body map[string]string
raw string
}
// edgeClient talks to the edge's listener while presenting a chosen hostname.
func edgeClient(scheme string, port int) (*http.Client, func(method, host, path string) webResp) {
jar, _ := cookiejar.New(nil)
tr := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(port))
},
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
c := &http.Client{Jar: jar, Transport: tr, Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
return c, func(method, host, path string) webResp {
resp, err := c.Do(mustReq(method, scheme+"://"+host+":"+strconv.Itoa(port)+path))
if err != nil {
return webResp{status: -1, raw: err.Error()}
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
out := webResp{status: resp.StatusCode, location: resp.Header.Get("Location"), raw: string(raw)}
json.Unmarshal(raw, &out.body)
return out
}
}
func mustReq(method, u string) *http.Request {
r, _ := http.NewRequest(method, u, nil)
return r
}
// TestWebProxyAndResourceAuth runs the web suite on Blacktip's built-in proxy.
func TestWebProxyAndResourceAuth(t *testing.T) {
webSuite(t, setup(t, blacktip.TraefikConfig{}))
}
// TestWebViaTraefik runs the identical suite with Traefik serving http/https.
// It needs a Traefik binary: set TRAEFIK_BIN to its path.
func TestWebViaTraefik(t *testing.T) {
bin := os.Getenv("TRAEFIK_BIN")
if bin == "" {
t.Skip("set TRAEFIK_BIN to a traefik binary to run this test")
}
webSuite(t, setup(t, blacktip.TraefikConfig{Path: bin, NoACME: true}))
}
func webSuite(t *testing.T, w *world) {
w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
be1, be2 := webBackend(t, "one", 200), webBackend(t, "two", 500)
port := freePort(t)
pub := w.open(model.Mapping{Proto: "http", Port: port, Hostname: "open.example.test", Targets: tgt(w.site, be1)})
_, get := edgeClient("http", port)
// wait for the listener and tunnel
var r webResp
for i := 0; i < 60; i++ {
if r = get("GET", "open.example.test", "/hello"); r.status == 200 {
break
}
time.Sleep(150 * time.Millisecond)
}
if r.status != 200 || r.body["backend"] != "one" || r.body["path"] != "/hello" {
t.Fatalf("unprotected proxy: %+v", r)
}
if r.body["host"] != "open.example.test:"+strconv.Itoa(port) || !strings.HasPrefix(r.body["xff"], "127.0.0.1") {
t.Errorf("forwarded headers wrong: %+v", r.body)
}
if got := get("GET", "nothing.example.test", "/"); got.status != 404 {
t.Errorf("unknown host -> %d", got.status)
}
_ = pub
// ----- password + rules + share link on a protected mapping -----
port2 := freePort(t)
prot := w.open(model.Mapping{Proto: "http", Port: port2, Hostname: "app.example.test", Targets: tgt(w.local, be1),
Auth: model.Auth{Password: true, SessionHours: 1},
Rules: []model.Rule{{Action: "deny", Match: "path", Value: "/admin"}, {Action: "allow", Match: "path", Value: "/public"}}})
// the password itself is write-only: set it through a PUT carrying newPassword
w.admin.must("PUT", "/api/orgs/"+w.org+"/mappings/"+prot.ID, map[string]any{
"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port2, "hostname": "app.example.test", "targets": tgt(w.local, be1),
"enabled": true, "auth": model.Auth{Password: true, SessionHours: 1}, "rules": prot.Rules, "newPassword": "hunter22",
}, nil)
c2, get2 := edgeClient("http", port2)
var login webResp
for i := 0; i < 60; i++ {
if login = get2("GET", "app.example.test", "/dash?x=1"); login.status == 302 {
break
}
time.Sleep(150 * time.Millisecond)
}
wantPrefix := w.ts.URL + "/auth/r/" + prot.ID + "?return="
if login.status != 302 || !strings.HasPrefix(login.location, wantPrefix) {
t.Fatalf("expected redirect to login, got %+v", login)
}
if got := get2("GET", "app.example.test", "/admin"); got.status != 403 {
t.Errorf("deny rule -> %d", got.status)
}
if got := get2("GET", "app.example.test", "/public/x"); got.status != 200 || got.body["user"] != "" {
t.Errorf("allow rule -> %+v", got)
}
if got := get2("POST", "app.example.test", "/dash"); got.status != 401 {
t.Errorf("unauthenticated POST -> %d", got.status)
}
visitor := newClient(t, w.ts.URL)
back := "http://app.example.test:" + strconv.Itoa(port2) + "/dash?x=1"
visitor.expect(401, "POST", "/auth/r/"+prot.ID+"/password", map[string]string{"password": "wrong", "return": back})
visitor.expect(400, "POST", "/auth/r/"+prot.ID+"/password", map[string]string{"password": "hunter22", "return": "http://evil.test/dash"})
visitor.expect(400, "POST", "/auth/r/"+prot.ID+"/password", map[string]string{"password": "hunter22", "return": "http://app.example.test:1/dash"})
var grant struct{ Redirect string }
visitor.must("POST", "/auth/r/"+prot.ID+"/password", map[string]string{"password": "hunter22", "return": back}, &grant)
u, _ := url.Parse(grant.Redirect)
if u.Host != "app.example.test:"+strconv.Itoa(port2) || u.Path != "/.hammerhead/callback" {
t.Fatalf("bad grant redirect %q", grant.Redirect)
}
// a forged token must be rejected
if got := get2("GET", "app.example.test", "/.hammerhead/callback?t=abc.def&r=/"); got.status != 403 {
t.Errorf("forged token -> %d", got.status)
}
cb := get2("GET", "app.example.test", u.RequestURI())
if cb.status != 302 || cb.location != "/dash?x=1" {
t.Fatalf("callback: %+v", cb)
}
got := get2("GET", "app.example.test", "/dash")
if got.status != 200 || got.body["user"] != "password" || strings.Contains(got.body["cookie"], "hh_auth") {
t.Fatalf("authenticated request: %+v", got)
}
_ = c2
// ----- share link -----
var share struct{ URL string }
w.admin.must("POST", "/api/orgs/"+w.org+"/mappings/"+prot.ID+"/shares", map[string]any{"name": "friend", "maxUses": 1}, &share)
su, _ := url.Parse(share.URL)
if su.Host != "app.example.test" { // default port 80 isn't used in tests: rebuild the URL against the test port
t.Logf("share url host %s", su.Host)
}
tok := su.Query().Get("hh_share")
_, get3 := edgeClient("http", port2)
sh := get3("GET", "app.example.test", "/?hh_share="+tok)
if sh.status != 302 || !strings.Contains(sh.location, "&share="+tok) {
t.Fatalf("share redirect: %+v", sh)
}
v2 := newClient(t, w.ts.URL)
v2.must("POST", "/auth/r/"+prot.ID+"/share", map[string]string{"token": tok, "return": "http://app.example.test:" + strconv.Itoa(port2) + "/"}, &grant)
v2.expect(401, "POST", "/auth/r/"+prot.ID+"/share", map[string]string{"token": tok, "return": back}) // single use
// ----- SSO with roles -----
port3 := freePort(t)
var roles []struct {
ID, Name string
Admin bool
}
w.admin.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
var memberRole, adminRole string
for _, r := range roles {
if r.Name == "Member" {
memberRole = r.ID
}
if r.Admin {
adminRole = r.ID
}
}
var staffRole struct{ ID string }
w.admin.must("POST", "/api/orgs/"+w.org+"/roles", map[string]any{"name": "Staff"}, nil)
w.admin.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
for _, r := range roles {
if r.Name == "Staff" {
staffRole.ID = r.ID
}
}
sso := w.open(model.Mapping{Proto: "http", Port: port3, Hostname: "sso.example.test", Targets: tgt(w.site, be1),
Auth: model.Auth{SSO: true, AllowedRoles: []string{staffRole.ID}}})
invite := func(email, role string) *client {
var inv struct{ Link string }
w.admin.must("POST", "/api/orgs/"+w.org+"/invites", map[string]string{"email": email, "roleId": role}, &inv)
tok := inv.Link[strings.Index(inv.Link, "#invite=")+len("#invite="):]
c := newClient(t, w.ts.URL)
c.must("POST", "/api/invites/accept", map[string]string{"token": tok, "name": email, "password": "password123"}, nil)
return c
}
staff := invite("staff@test.local", staffRole.ID)
plain := invite("plain@test.local", memberRole)
retSSO := "http://sso.example.test:" + strconv.Itoa(port3) + "/"
plain.expect(403, "POST", "/auth/r/"+sso.ID+"/sso", map[string]string{"return": retSSO})
staff.must("POST", "/auth/r/"+sso.ID+"/sso", map[string]string{"return": retSSO}, &grant)
newClient(t, w.ts.URL).expect(401, "POST", "/auth/r/"+sso.ID+"/sso", map[string]string{"return": retSSO})
_ = adminRole
// ----- email code -----
w.admin.must("PUT", "/api/admin/settings", map[string]any{"dashboardUrl": w.ts.URL, "smtp": map[string]any{"host": "mail.invalid", "port": 25, "from": "x@y.z"}}, nil)
port4 := freePort(t)
em := w.open(model.Mapping{Proto: "http", Port: port4, Hostname: "mail.example.test", Targets: tgt(w.site, be1),
Auth: model.Auth{Email: true, AllowedMails: []string{"*@corp.test"}}})
retEm := "http://mail.example.test:" + strconv.Itoa(port4) + "/"
v3 := newClient(t, w.ts.URL)
v3.must("POST", "/auth/r/"+em.ID+"/email/start", map[string]string{"email": "bob@corp.test", "return": retEm}, nil)
var code string
select {
case m := <-w.mails:
parts := strings.SplitN(m, "|", 3)
code = strings.TrimSpace(strings.Split(strings.SplitN(parts[2], "Your code is ", 2)[1], "\n")[0])
case <-time.After(3 * time.Second):
t.Fatal("no email sent")
}
v3.expect(401, "POST", "/auth/r/"+em.ID+"/email/verify", map[string]string{"email": "bob@corp.test", "code": "000000", "return": retEm})
v3.must("POST", "/auth/r/"+em.ID+"/email/verify", map[string]string{"email": "bob@corp.test", "code": code, "return": retEm}, &grant)
v3.must("POST", "/auth/r/"+em.ID+"/email/start", map[string]string{"email": "eve@evil.test", "return": retEm}, nil) // same answer, no mail
select {
case m := <-w.mails:
t.Errorf("mail sent to a non-allowed address: %s", m)
case <-time.After(300 * time.Millisecond):
}
// ----- health check marks the 500 backend unhealthy; traffic avoids it -----
port5 := freePort(t)
hc := w.open(model.Mapping{Proto: "http", Port: port5, Hostname: "hc.example.test", HealthPath: "/healthz", HealthSeconds: 5,
Targets: []model.Target{{SiteID: w.site, Host: "127.0.0.1", Port: be2}, {SiteID: w.local, Host: "127.0.0.1", Port: be1}}})
_, get5 := edgeClient("http", port5)
deadline := time.Now().Add(8 * time.Second)
for {
var st struct {
Mappings []struct {
ID string
Status model.MappingStatus
}
}
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
done := false
for _, m := range st.Mappings {
if m.ID == hc.ID && len(m.Status.Targets) == 2 && !m.Status.Targets[0].Healthy && m.Status.Targets[1].Healthy {
done = true
}
}
if done {
break
}
if time.Now().After(deadline) {
t.Fatal("health check never flagged the bad backend")
}
time.Sleep(250 * time.Millisecond)
}
for i := 0; i < 5; i++ {
if got := get5("GET", "hc.example.test", "/x"); got.status != 200 || got.body["backend"] != "one" {
t.Fatalf("traffic reached the unhealthy backend: %+v", got)
}
}
// ----- https with a certificate -----
port6 := freePort(t)
w.open(model.Mapping{Proto: "https", Port: port6, Hostname: "secure.example.test", Targets: tgt(w.site, be1)})
_, get6 := edgeClient("https", port6)
var sec webResp
for i := 0; i < 60; i++ {
if sec = get6("GET", "secure.example.test", "/tls"); sec.status == 200 {
break
}
time.Sleep(150 * time.Millisecond)
}
if sec.status != 200 || sec.body["proto"] != "https" {
t.Fatalf("https: %+v", sec)
}
// ----- access log and traffic counters reach hammerhead -----
deadline = time.Now().Add(10 * time.Second)
for {
var logs []model.LogEntry
w.admin.must("GET", "/api/orgs/"+w.org+"/logs?kind=access&limit=500", nil, &logs)
sawAuth, sawDeny := false, false
for _, l := range logs {
if l.Mapping == prot.ID && l.Result == "auth-required" {
sawAuth = true
}
if l.Mapping == prot.ID && l.Result == "denied" {
sawDeny = true
}
}
if sawAuth && sawDeny {
break
}
if time.Now().After(deadline) {
t.Fatalf("access log incomplete (%d entries)", len(logs))
}
time.Sleep(300 * time.Millisecond)
}
deadline = time.Now().Add(25 * time.Second)
for {
var stats map[string]struct{ Conns, BytesOut int64 }
w.admin.must("GET", "/api/orgs/"+w.org+"/stats", nil, &stats)
if stats[pub.ID].Conns > 0 && stats[pub.ID].BytesOut > 0 {
break
}
if time.Now().After(deadline) {
t.Fatalf("no traffic stats: %+v", stats)
}
time.Sleep(500 * time.Millisecond)
}
}
func TestAccountsOrgsAndTOTP(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
base := "/api/orgs/" + w.org
// a second org the first can't see into
var other struct{ ID string }
w.admin.must("POST", "/api/orgs", map[string]any{"name": "Other"}, &other)
var roles []struct {
ID, Name string
Admin bool
}
w.admin.must("GET", base+"/roles", nil, &roles)
var member, adminRole string
for _, r := range roles {
if r.Name == "Member" {
member = r.ID
}
if r.Admin {
adminRole = r.ID
}
}
invite := func(org, email, role string) *client {
var inv struct{ Link string }
w.admin.must("POST", "/api/orgs/"+org+"/invites", map[string]string{"email": email, "roleId": role}, &inv)
c := newClient(t, w.ts.URL)
c.must("POST", "/api/invites/accept", map[string]string{"token": inv.Link[strings.Index(inv.Link, "#invite=")+8:], "name": "N", "password": "password123"}, nil)
return c
}
m := invite(w.org, "m@test.local", member)
m.must("GET", base+"/state", nil, nil)
m.expect(403, "POST", base+"/mappings", map[string]any{})
m.expect(403, "POST", base+"/sites", map[string]string{"name": "x"})
m.expect(403, "GET", base+"/members", nil)
m.expect(404, "GET", "/api/orgs/"+other.ID+"/state", nil)
m.expect(403, "POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "1.2.3.4:5"})
m.expect(403, "GET", "/api/admin/users", nil)
// domains can't be claimed twice across orgs
w.admin.must("POST", base+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
for _, d := range []string{"example.test", "api.example.test", "test"} {
code, _ := w.admin.do("POST", "/api/orgs/"+other.ID+"/domains", map[string]any{"name": d}, nil)
if code != 400 && d != "test" {
t.Errorf("org 2 claimed %s (status %d)", d, code)
}
}
// hostnames must be under the org's own domains
w.admin.expect(400, "POST", base+"/mappings", map[string]any{"proto": "http", "edgeId": w.edge, "port": 8081,
"hostname": "x.notmine.test", "targets": tgt(w.site, 9)})
// the other org can't use the edge's local site (AllowLocal is off for it)
w.admin.must("GET", "/api/orgs/"+other.ID+"/state", nil, nil)
w.admin.expect(400, "POST", "/api/orgs/"+other.ID+"/mappings", map[string]any{"proto": "tcp", "edgeId": w.edge, "port": 25565,
"targets": tgt(w.local, 25565)})
// API keys act inside one org only
var key struct{ Key string }
w.admin.must("POST", base+"/apikeys", map[string]string{"name": "ci"}, &key)
kc := newClient(t, w.ts.URL)
kc.key = key.Key
kc.must("POST", base+"/domains", map[string]any{"name": "keyed.test", "edgeId": w.edge}, nil)
kc.expect(404, "GET", "/api/orgs/"+other.ID+"/state", nil)
kc.expect(401, "GET", "/api/me", nil)
bad := newClient(t, w.ts.URL)
bad.key = "hhk_deadbeef_nope"
bad.expect(401, "GET", base+"/state", nil)
// removing the last admin is refused
var members []struct{ UserID, Email string }
w.admin.must("GET", base+"/members", nil, &members)
for _, mm := range members {
if mm.Email == "admin@test.local" {
w.admin.expect(400, "PUT", base+"/members/"+mm.UserID, map[string]string{"roleId": member})
w.admin.expect(400, "DELETE", base+"/members/"+mm.UserID, nil)
}
}
// two-factor: enable, then log in with a code and with a backup code
u := invite(w.org, "otp@test.local", adminRole)
var setup struct{ Secret string }
u.must("POST", "/api/me/2fa/setup", nil, &setup)
u.expect(400, "POST", "/api/me/2fa/enable", map[string]string{"code": "000000"})
var en struct{ BackupCodes []string }
u.must("POST", "/api/me/2fa/enable", map[string]string{"code": totpNow(setup.Secret)}, &en)
if len(en.BackupCodes) != 8 {
t.Fatalf("backup codes: %v", en.BackupCodes)
}
fresh := newClient(t, w.ts.URL)
var step struct {
NeedTotp bool
Ticket string
}
fresh.must("POST", "/api/login", map[string]string{"email": "otp@test.local", "password": "password123"}, &step)
if !step.NeedTotp {
t.Fatal("2fa not requested")
}
fresh.expect(401, "GET", "/api/me", nil) // not signed in until the code is given
fresh.expect(401, "POST", "/api/login/totp", map[string]string{"ticket": step.Ticket, "code": "123456"})
fresh.must("POST", "/api/login/totp", map[string]string{"ticket": step.Ticket, "code": totpNow(setup.Secret)}, nil)
fresh.must("GET", "/api/me", nil, nil)
f2 := newClient(t, w.ts.URL)
f2.must("POST", "/api/login", map[string]string{"email": "otp@test.local", "password": "password123"}, &step)
f2.must("POST", "/api/login/totp", map[string]string{"ticket": step.Ticket, "code": en.BackupCodes[0]}, nil)
f3 := newClient(t, w.ts.URL)
f3.must("POST", "/api/login", map[string]string{"email": "otp@test.local", "password": "password123"}, &step)
f3.expect(401, "POST", "/api/login/totp", map[string]string{"ticket": step.Ticket, "code": en.BackupCodes[0]}) // backup codes are single use
// wrong passwords and brute force are throttled per address+email
bf := newClient(t, w.ts.URL)
for i := 0; i < 8; i++ {
bf.expect(401, "POST", "/api/login", map[string]string{"email": "ghost@test.local", "password": "nope-nope"})
}
bf.expect(429, "POST", "/api/login", map[string]string{"email": "ghost@test.local", "password": "password123"})
// changing the password signs out the user's other sessions
m2 := newClient(t, w.ts.URL)
m2.must("POST", "/api/login", map[string]string{"email": "m@test.local", "password": "password123"}, nil)
m2.must("GET", "/api/me", nil, nil)
m.expect(400, "POST", "/api/me/password", map[string]string{"current": "wrong", "new": "newpassword1"})
m.expect(400, "POST", "/api/me/password", map[string]string{"current": "password123", "new": "short"})
m.must("POST", "/api/me/password", map[string]string{"current": "password123", "new": "newpassword1"}, nil)
m2.expect(401, "GET", "/api/me", nil)
m.must("GET", "/api/me", nil, nil)
newClient(t, w.ts.URL).must("POST", "/api/login", map[string]string{"email": "m@test.local", "password": "newpassword1"}, nil)
}
func TestOIDCLogin(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
// a tiny identity provider
var idp *httptest.Server
idp = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
json.NewEncoder(rw).Encode(map[string]string{"authorization_endpoint": idp.URL + "/authorize", "token_endpoint": idp.URL + "/token", "userinfo_endpoint": idp.URL + "/userinfo"})
case "/authorize":
q := r.URL.Query()
if q.Get("code_challenge_method") != "S256" || q.Get("code_challenge") == "" || q.Get("client_id") != "cid" {
http.Error(rw, "bad authorize request", 400)
return
}
http.Redirect(rw, r, q.Get("redirect_uri")+"?code=thecode&state="+url.QueryEscape(q.Get("state")), 302)
case "/token":
r.ParseForm()
if r.Form.Get("code") != "thecode" || r.Form.Get("client_secret") != "shh" || r.Form.Get("code_verifier") == "" {
http.Error(rw, "bad token request", 400)
return
}
json.NewEncoder(rw).Encode(map[string]string{"access_token": "at"})
case "/userinfo":
if r.Header.Get("Authorization") != "Bearer at" {
http.Error(rw, "no", 401)
return
}
json.NewEncoder(rw).Encode(map[string]any{"sub": "u-1", "email": "Sso.User@Corp.Test", "email_verified": true, "name": "Sso User"})
}
}))
defer idp.Close()
var roles []struct {
ID, Name string
}
w.admin.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
member := ""
for _, r := range roles {
if r.Name == "Member" {
member = r.ID
}
}
w.admin.must("POST", "/api/admin/idps", map[string]any{"name": "Corp", "issuer": idp.URL, "clientId": "cid", "clientSecret": "shh",
"autoProvision": true, "orgId": w.org, "roleId": member, "allowedDomains": []string{"corp.test"}}, nil)
var pub struct{ Providers []struct{ ID, Name string } }
newClient(t, w.ts.URL).must("GET", "/api/public", nil, &pub)
if len(pub.Providers) != 1 {
t.Fatalf("providers: %+v", pub)
}
c := newClient(t, w.ts.URL)
resp, err := c.c.Get(w.ts.URL + "/auth/oidc/" + pub.Providers[0].ID + "/start?return=/after")
if err != nil || resp.StatusCode != 302 {
t.Fatalf("start: %v %v", err, resp)
}
authz := resp.Header.Get("Location")
resp, err = c.c.Get(authz)
if err != nil || resp.StatusCode != 302 {
t.Fatalf("authorize: %v %v", err, resp)
}
cb := resp.Header.Get("Location")
resp, err = c.c.Get(cb)
if err != nil || resp.StatusCode != 302 || resp.Header.Get("Location") != "/after" {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("callback: %v %d %s", err, resp.StatusCode, b)
}
var me struct {
Email string
Orgs []struct{ Name, Role string }
}
c.must("GET", "/api/me", nil, &me)
if me.Email != "sso.user@corp.test" || len(me.Orgs) != 1 || me.Orgs[0].Role != "Member" {
t.Fatalf("provisioned user: %+v", me)
}
// replaying the same state is refused
resp, _ = c.c.Get(cb)
if resp.StatusCode != 400 {
t.Errorf("state replay -> %d", resp.StatusCode)
}
}
+504
View File
@@ -0,0 +1,504 @@
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)
}
}
+279
View File
@@ -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)
}
}
+177
View File
@@ -0,0 +1,177 @@
package e2e
import (
"bufio"
"bytes"
"crypto/tls"
"encoding/binary"
"fmt"
"io"
"net"
"strconv"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/blacktip"
)
// proxyBackend is a TCP server that understands the PROXY protocol the way a
// game proxy or mail server would. It replies with what it learned, or
// "plain:<first line>" when the connection did not start with a header.
func proxyBackend(t *testing.T, seen chan<- string) int {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
c.SetDeadline(time.Now().Add(5 * time.Second))
br := bufio.NewReader(c)
first, _ := br.Peek(1)
var head []byte
switch {
case len(first) == 1 && first[0] == 0x0d:
head, _ = br.Peek(12) // a v2 header starts with a 12-byte signature
case len(first) == 1 && first[0] == 'P':
head, _ = br.Peek(6) // "PROXY "
}
var desc string
switch {
case bytes.HasPrefix(head, []byte("PROXY ")):
line, _ := br.ReadString('\n')
f := strings.Fields(strings.TrimSpace(line))
if len(f) == 6 {
desc = fmt.Sprintf("v1 src=%s:%s dst=%s:%s fam=%s", f[2], f[4], f[3], f[5], f[1])
} else {
desc = "v1 odd:" + line
}
case bytes.Equal(head, []byte("\r\n\r\n\x00\r\nQUIT\n")):
fixed := make([]byte, 16)
io.ReadFull(br, fixed)
body := make([]byte, binary.BigEndian.Uint16(fixed[14:16]))
io.ReadFull(br, body)
switch fixed[13] {
case 0x11:
desc = fmt.Sprintf("v2 src=%s:%d dst=%s:%d fam=TCP4", net.IP(body[0:4]), binary.BigEndian.Uint16(body[8:10]), net.IP(body[4:8]), binary.BigEndian.Uint16(body[10:12]))
case 0x21:
desc = fmt.Sprintf("v2 src=[%s]:%d fam=TCP6", net.IP(body[0:16]), binary.BigEndian.Uint16(body[32:34]))
default:
desc = fmt.Sprintf("v2 fam=%#x", fixed[13])
}
if fixed[12] != 0x21 {
desc += " cmd=" + strconv.Itoa(int(fixed[12]))
}
default:
line, _ := br.ReadString('\n')
desc = "plain:" + strings.TrimSpace(line)
}
// what comes next shows the header was exactly the first thing and nothing was lost
c.SetReadDeadline(time.Now().Add(400 * time.Millisecond)) // don't wait forever for bytes that may never come
if b, err := br.Peek(1); err == nil && b[0] == 0x16 {
desc += " then=tls-hello"
}
if seen != nil {
seen <- desc
}
fmt.Fprintf(c, "%s\n", desc)
}()
}
}()
return l.Addr().(*net.TCPAddr).Port
}
func askFrom(t *testing.T, from, addr, send string) string {
t.Helper()
var last error
for i := 0; i < 50; i++ {
d := net.Dialer{LocalAddr: &net.TCPAddr{IP: net.ParseIP(from)}, Timeout: time.Second}
c, err := d.Dial("tcp", addr)
if err == nil {
c.SetDeadline(time.Now().Add(4 * time.Second))
fmt.Fprint(c, send)
line, rerr := bufio.NewReader(c).ReadString('\n')
c.Close()
if rerr == nil {
return strings.TrimSpace(line)
}
if ne, ok := rerr.(net.Error); ok && ne.Timeout() {
t.Fatalf("%s accepted the connection but never answered: %v", addr, rerr) // a real hang, not a startup race
}
last = rerr
} else {
last = err
}
time.Sleep(150 * time.Millisecond)
}
t.Fatalf("no answer from %s: %v", addr, last)
return ""
}
func TestProxyProtocolCarriesTheRealClientAddress(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
be := proxyBackend(t, nil)
base := "/api/orgs/" + w.org + "/mappings"
open := func(ip string, site string, proxy string) string {
port := freePort(t)
w.admin.must("POST", base, map[string]any{"proto": "tcp", "edgeId": w.edge, "bindIp": ip, "port": port, "targets": tgt(site, be),
"enabled": true, "proxyProtocol": proxy}, nil)
return ip + ":" + strconv.Itoa(port)
}
// through the WireGuard tunnel and straight to the edge's own machine, in both versions
for _, tc := range []struct{ name, site, proxy string }{
{"v1 via tunnel", w.site, "v1"}, {"v2 via tunnel", w.site, "v2"}, {"v1 local", w.local, "v1"}, {"v2 local", w.local, "v2"},
} {
addr := open("127.0.0.1", tc.site, tc.proxy)
t.Logf("case %s on %s", tc.name, addr)
got := askFrom(t, "127.0.0.9", addr, "hello\n")
if !strings.HasPrefix(got, tc.proxy) || !strings.Contains(got, "src=127.0.0.9:") || !strings.Contains(got, "dst="+addr) || !strings.Contains(got, "fam=TCP4") {
t.Errorf("%s: the target learned %q, want the real client 127.0.0.9 and the public address %s", tc.name, got, addr)
}
}
// off by default: the target gets exactly what the client sent, no header
addr := open("127.0.0.1", w.site, "")
if got := askFrom(t, "127.0.0.9", addr, "hello\n"); got != "plain:hello" {
t.Errorf("without PROXY protocol the target saw %q", got)
}
// the header comes before the client's own bytes, even when they are a TLS hello
seen := make(chan string, 4)
tbe := proxyBackend(t, seen)
port := freePort(t)
w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
w.admin.must("POST", base, map[string]any{"proto": "tls", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port, "hostname": "pass.example.test",
"targets": tgt(w.site, tbe), "enabled": true, "proxyProtocol": "v2"}, nil)
for i := 0; i < 50; i++ {
d := net.Dialer{LocalAddr: &net.TCPAddr{IP: net.ParseIP("127.0.0.9")}, Timeout: time.Second}
c, err := d.Dial("tcp", "127.0.0.1:"+strconv.Itoa(port))
if err == nil {
tls.Client(c, &tls.Config{ServerName: "pass.example.test", InsecureSkipVerify: true}).Handshake() // fails: the backend isn't TLS
c.Close()
}
select {
case got := <-seen:
if !strings.Contains(got, "v2 src=127.0.0.9:") || !strings.Contains(got, "then=tls-hello") {
t.Errorf("tls passthrough: %q", got)
}
goto validated
case <-time.After(200 * time.Millisecond):
}
}
t.Fatal("tls passthrough never reached the target")
validated:
// only tcp and tls ports can carry it
w.admin.expect(400, "POST", base, map[string]any{"proto": "udp", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": freePort(t), "targets": tgt(w.site, be), "proxyProtocol": "v1"})
w.admin.expect(400, "POST", base, map[string]any{"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": freePort(t), "hostname": "w.example.test", "targets": tgt(w.site, be), "proxyProtocol": "v2"})
w.admin.expect(400, "POST", base, map[string]any{"proto": "tcp", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": freePort(t), "targets": tgt(w.site, be), "proxyProtocol": "v3"})
}
+216
View File
@@ -0,0 +1,216 @@
package e2e
import (
"fmt"
"net"
"strconv"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/blacktip"
"git.veillax.com/Veillax/reef/model"
)
// freeRun finds n consecutive ports that are free for both TCP and UDP.
func freeRun(t *testing.T, n int) int {
t.Helper()
for try := 0; try < 200; try++ {
base := 20000 + (time.Now().Nanosecond()/1000+try*37)%30000
var closers []func()
ok := true
for i := 0; i < n && ok; i++ {
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(base+i))
if err != nil {
ok = false
break
}
closers = append(closers, func() { l.Close() })
pc, err := net.ListenPacket("udp", "127.0.0.1:"+strconv.Itoa(base+i))
if err != nil {
ok = false
break
}
closers = append(closers, func() { pc.Close() })
}
for _, c := range closers {
c()
}
if ok {
return base
}
}
t.Fatal("no free port block")
return 0
}
func tcpOn(t *testing.T, port int) {
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
buf := make([]byte, 64)
n, _ := c.Read(buf)
fmt.Fprintf(c, "tcp%d:%s", port, buf[:n])
}()
}
}()
}
func udpOn(t *testing.T, port int) {
pc, err := net.ListenPacket("udp", "127.0.0.1:"+strconv.Itoa(port))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pc.Close() })
go func() {
buf := make([]byte, 64)
for {
n, from, err := pc.ReadFrom(buf)
if err != nil {
return
}
pc.WriteTo([]byte(fmt.Sprintf("udp%d:%s", port, buf[:n])), from)
}
}()
}
func TestPortRanges(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
target := freeRun(t, 3)
pub := freeRun(t, 3)
if target == pub {
pub = freeRun(t, 4) + 0
}
for i := 0; i < 3; i++ {
tcpOn(t, target+i)
udpOn(t, target+i)
}
base := "/api/orgs/" + w.org + "/mappings"
open := func(proto, ip string, site string, port, end, tport int) (model.Mapping, int, string) {
var m model.Mapping
code, raw := w.admin.do("POST", base, map[string]any{"proto": proto, "edgeId": w.edge, "bindIp": ip, "port": port, "portEnd": end,
"targets": tgt(site, tport), "enabled": true}, &m)
return m, code, raw
}
tcp, code, raw := open("tcp", "127.0.0.1", w.site, pub, pub+2, target)
if code != 200 {
t.Fatalf("open range: %d %s", code, raw)
}
if _, code, raw = open("udp", "127.0.0.1", w.site, pub, pub+2, target); code != 200 {
t.Fatalf("a udp range over the same numbers is a different protocol and must be allowed: %d %s", code, raw)
}
if _, code, raw = open("tcp", "127.0.0.2", w.local, pub, pub+2, target); code != 200 {
t.Fatalf("local range: %d %s", code, raw)
}
if tcp.PortEnd != pub+2 {
t.Errorf("portEnd = %d, want %d", tcp.PortEnd, pub+2)
}
// each public port reaches the target port at the same offset, through the tunnel and directly
for _, ip := range []string{"127.0.0.1", "127.0.0.2"} {
for i := 0; i < 3; i++ {
got := tcpAsk(t, ip+":"+strconv.Itoa(pub+i), "hi")
if want := fmt.Sprintf("tcp%d:hi", target+i); got != want {
t.Errorf("%s port %d -> %q, want %q", ip, pub+i, got, want)
}
}
}
for i := 0; i < 3; i++ {
got := udpAsk(t, "127.0.0.1:"+strconv.Itoa(pub+i), "yo")
if want := fmt.Sprintf("udp%d:yo", target+i); got != want {
t.Errorf("udp port %d -> %q, want %q", pub+i, got, want)
}
}
// shrinking the range closes the ports that fell off the end
if code, raw := w.admin.do("PUT", base+"/"+tcp.ID, map[string]any{"proto": "tcp", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": pub, "portEnd": pub + 1,
"targets": tgt(w.site, target), "enabled": true}, nil); code != 200 {
t.Fatalf("shrink: %d %s", code, raw)
}
deadline := time.Now().Add(6 * time.Second)
for {
c, err := net.DialTimeout("tcp", "127.0.0.1:"+strconv.Itoa(pub+2), 500*time.Millisecond)
if err != nil {
break
}
c.Close()
if time.Now().After(deadline) {
t.Fatal("a port past the end of the range stayed open")
}
time.Sleep(200 * time.Millisecond)
}
if got := tcpAsk(t, "127.0.0.1:"+strconv.Itoa(pub+1), "x"); got != fmt.Sprintf("tcp%d:x", target+1) {
t.Errorf("a port still inside the range: %q", got)
}
// validation
bad := func(what, proto string, port, end, tport int) {
t.Helper()
if _, code, raw := open(proto, "127.0.0.9", w.site, port, end, tport); code != 400 {
t.Errorf("%s was accepted (%d %s)", what, code, raw)
}
}
bad("a range on http", "http", 8080, 8082, 80)
bad("an end before the start", "tcp", 5000, 4990, 80)
bad("a range of 1001 ports", "tcp", 10000, 11000, 80)
bad("a range past 65535", "tcp", 65530, 65540, 80)
bad("targets that would run past 65535", "tcp", 5000, 5009, 65530)
if _, code, _ := open("tcp", "127.0.0.1", w.site, pub+1, pub+1, target); code != 400 {
t.Error("a single port inside an existing range must conflict")
}
if _, code, _ := open("tcp", "127.0.0.1", w.site, pub-1, pub, target); code != 400 {
t.Error("a range overlapping the start of an existing range must conflict")
}
if _, code, raw := open("tcp", "127.0.0.1", w.site, pub+2, pub+4, target); code != 200 {
t.Errorf("a range that starts right after the existing one is fine: %d %s", code, raw)
}
// one busy port inside a range is reported, and the others still work
busyBase := freeRun(t, 3)
busy, err := net.Listen("tcp", "127.0.0.3:"+strconv.Itoa(busyBase+1))
if err != nil {
t.Fatal(err)
}
defer busy.Close()
bm, code, raw := open("tcp", "127.0.0.3", w.local, busyBase, busyBase+2, target)
if code != 200 {
t.Fatalf("open busy range: %d %s", code, raw)
}
if got := tcpAsk(t, "127.0.0.3:"+strconv.Itoa(busyBase), "a"); got != fmt.Sprintf("tcp%d:a", target) {
t.Errorf("a free port next to a busy one: %q", got)
}
deadline = time.Now().Add(8 * time.Second)
for {
var st struct {
Mappings []struct {
ID string
Status model.MappingStatus
}
}
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
msg := ""
for _, m := range st.Mappings {
if m.ID == bm.ID {
msg = m.Status.Error
}
}
if strings.Contains(msg, strconv.Itoa(busyBase+1)) {
break
}
if time.Now().After(deadline) {
t.Fatalf("the busy port isn't named in the status: %q", msg)
}
time.Sleep(250 * time.Millisecond)
}
}
+224
View File
@@ -0,0 +1,224 @@
package e2e
import (
"net/url"
"strconv"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/blacktip"
"git.veillax.com/Veillax/reef/model"
)
// signedIn logs a visitor in through hammerhead's page for mapping id and
// returns an edge client whose cookie jar now holds the session.
func signedIn(t *testing.T, w *world, id, host string, port int, how, secret string) func(method, host, path string) webResp {
t.Helper()
_, get := edgeClient("http", port)
back := "http://" + host + ":" + strconv.Itoa(port) + "/"
vis := newClient(t, w.ts.URL)
var grant struct{ Redirect string }
body := map[string]string{"return": back}
path := "/auth/r/" + id + "/" + how
if how == "password" {
body["password"] = secret
}
vis.must("POST", path, body, &grant)
u, _ := url.Parse(grant.Redirect)
if cb := get("GET", host, u.RequestURI()); cb.status != 302 {
t.Fatalf("callback -> %+v", cb)
}
return get
}
// waitStatus polls until the edge answers with want (config reaches it within seconds).
func waitStatus(t *testing.T, get func(method, host, path string) webResp, host string, want int, what string) {
t.Helper()
var last webResp
for i := 0; i < 50; i++ {
if last = get("GET", host, "/"); last.status == want {
return
}
time.Sleep(150 * time.Millisecond)
}
t.Fatalf("%s: wanted %d, last answer %+v", what, want, last)
}
func TestVisitorSessionsCanBeRevoked(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
be := webBackend(t, "app", 200)
base := "/api/orgs/" + w.org + "/mappings"
port := freePort(t)
var m model.Mapping
w.admin.must("POST", base, map[string]any{"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port, "hostname": "rv.example.test",
"targets": tgt(w.local, be), "enabled": true, "auth": model.Auth{Password: true}, "newPassword": "first-password", "authEpoch": 99}, &m)
if m.AuthEpoch != 0 {
t.Fatalf("a client set the epoch to %d", m.AuthEpoch)
}
put := func(pw string) {
body := map[string]any{"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port, "hostname": "rv.example.test",
"targets": tgt(w.local, be), "enabled": true, "auth": model.Auth{Password: true}, "authEpoch": 1234}
if pw != "" {
body["newPassword"] = pw
}
w.admin.must("PUT", base+"/"+m.ID, body, &m)
}
get := signedIn(t, w, m.ID, "rv.example.test", port, "password", "first-password")
waitStatus(t, get, "rv.example.test", 200, "a signed-in visitor")
// signing everyone out on demand
w.admin.must("POST", base+"/"+m.ID+"/revoke", nil, nil)
waitStatus(t, get, "rv.example.test", 302, "after 'sign out visitors'")
get = signedIn(t, w, m.ID, "rv.example.test", port, "password", "first-password")
waitStatus(t, get, "rv.example.test", 200, "signing in again after a revoke")
// editing without touching who may enter keeps visitors signed in
put("")
time.Sleep(600 * time.Millisecond)
if got := get("GET", "rv.example.test", "/"); got.status != 200 {
t.Errorf("an unrelated edit signed visitors out: %+v", got)
}
var st struct{ Mappings []model.Mapping }
w.admin.must("GET", "/api/orgs/"+w.org+"/state", nil, &st)
if e := st.Mappings[0].AuthEpoch; e != 1 {
t.Errorf("epoch = %d after one revoke and one harmless edit, want 1 (a client-sent value must be ignored)", e)
}
// changing the password signs out everyone who got in with the old one
put("second-password")
waitStatus(t, get, "rv.example.test", 302, "after a password change")
get = signedIn(t, w, m.ID, "rv.example.test", port, "password", "second-password")
waitStatus(t, get, "rv.example.test", 200, "the new password")
// removing a member ends their open sessions on SSO resources
var roles []struct {
ID, Name string
Admin bool
}
w.admin.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
member := ""
for _, r := range roles {
if r.Name == "Member" {
member = r.ID
}
}
var inv struct{ Link string }
w.admin.must("POST", "/api/orgs/"+w.org+"/invites", map[string]string{"email": "leaver@test.local", "roleId": member}, &inv)
leaver := newClient(t, w.ts.URL)
leaver.must("POST", "/api/invites/accept", map[string]string{"token": inv.Link[strings.Index(inv.Link, "#invite=")+8:], "name": "L", "password": "password123"}, nil)
var me struct{ ID string }
leaver.must("GET", "/api/me", nil, &me)
port2 := freePort(t)
var sso model.Mapping
w.admin.must("POST", base, map[string]any{"proto": "http", "edgeId": w.edge, "bindIp": "127.0.0.1", "port": port2, "hostname": "sso.example.test",
"targets": tgt(w.local, be), "enabled": true, "auth": model.Auth{SSO: true}}, &sso)
_, get2 := edgeClient("http", port2)
var grant struct{ Redirect string }
for i := 0; i < 50; i++ { // wait for the resource to exist on the edge
if err := leaver.do2("POST", "/auth/r/"+sso.ID+"/sso", map[string]string{"return": "http://sso.example.test:" + strconv.Itoa(port2) + "/"}, &grant); err == nil {
break
}
time.Sleep(150 * time.Millisecond)
}
u, _ := url.Parse(grant.Redirect)
for i := 0; i < 50; i++ {
if cb := get2("GET", "sso.example.test", u.RequestURI()); cb.status == 302 {
break
}
time.Sleep(150 * time.Millisecond)
}
waitStatus(t, get2, "sso.example.test", 200, "an SSO member")
w.admin.must("DELETE", "/api/orgs/"+w.org+"/members/"+me.ID, nil, nil)
waitStatus(t, get2, "sso.example.test", 302, "after the member was removed")
}
// do2 is like do but returns an error instead of failing the test on a non-200.
func (a *client) do2(method, path string, in, out any) error {
code, raw := a.do(method, path, in, out)
if code != 200 {
return &httpErr{code, raw}
}
return nil
}
type httpErr struct {
code int
body string
}
func (e *httpErr) Error() string { return strconv.Itoa(e.code) + " " + e.body }
func TestDashboardSessionsAreListedAndEnded(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
login := func() *client {
c := newClient(t, w.ts.URL)
c.must("POST", "/api/login", map[string]string{"email": "admin@test.local", "password": "password123"}, nil)
return c
}
a, b, c := login(), login(), login()
type row struct {
ID string
Current bool
IP string
}
var rows []row
a.must("GET", "/api/me/sessions", nil, &rows)
// the setup client (w.admin) plus the three above
if len(rows) < 4 {
t.Fatalf("expected at least 4 sessions, got %d", len(rows))
}
current := 0
for _, r := range rows {
if r.Current {
current++
}
if r.IP == "" {
t.Error("a session has no IP recorded")
}
}
if current != 1 {
t.Errorf("exactly one session should be marked current, got %d", current)
}
// end a specific one
var bRows []row
b.must("GET", "/api/me/sessions", nil, &bRows)
bID := ""
for _, r := range bRows {
if r.Current {
bID = r.ID
}
}
a.must("DELETE", "/api/me/sessions/"+bID, nil, nil)
b.expect(401, "GET", "/api/me", nil)
c.must("GET", "/api/me", nil, nil)
// "sign out everywhere else" keeps only this browser
a.must("POST", "/api/me/sessions/revoke-others", nil, nil)
c.expect(401, "GET", "/api/me", nil)
w.admin.expect(401, "GET", "/api/me", nil)
a.must("GET", "/api/me", nil, nil)
// someone else's session id can't be ended through your account
var inv struct{ Link string }
var roles []struct{ ID, Name string }
a.must("GET", "/api/orgs/"+w.org+"/roles", nil, &roles)
member := ""
for _, r := range roles {
if r.Name == "Member" {
member = r.ID
}
}
a.must("POST", "/api/orgs/"+w.org+"/invites", map[string]string{"email": "other@test.local", "roleId": member}, &inv)
other := newClient(t, w.ts.URL)
other.must("POST", "/api/invites/accept", map[string]string{"token": inv.Link[strings.Index(inv.Link, "#invite=")+8:], "name": "O", "password": "password123"}, nil)
var aRows []row
a.must("GET", "/api/me/sessions", nil, &aRows)
other.must("DELETE", "/api/me/sessions/"+aRows[0].ID, nil, nil)
a.must("GET", "/api/me", nil, nil)
}