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
+941
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user