bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
package main
|
||||
|
||||
// local agent lane walls testable without a relay: the mTLS client (a server
|
||||
// without a cert from the CA is refused; a client without the three files
|
||||
// never dials), the reply mapping into DCN typed states, the probe's
|
||||
// advertised/healthy split, and the search mask gate.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type testPKI struct {
|
||||
dir string
|
||||
caPEM string
|
||||
serverCert, client tls.Certificate
|
||||
clientCert, key string
|
||||
}
|
||||
|
||||
func writePEM(t *testing.T, path, typ string, der []byte) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: typ, Bytes: der}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func mintPKI(t *testing.T) testPKI {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
caTpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test-root"},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, BasicConstraintsValid: true}
|
||||
caDER, _ := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
|
||||
caCert, _ := x509.ParseCertificate(caDER)
|
||||
writePEM(t, filepath.Join(dir, "ca.crt"), "CERTIFICATE", caDER)
|
||||
|
||||
leaf := func(cn string, usage x509.ExtKeyUsage) (tls.Certificate, string, string) {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tpl := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: cn}, NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour), ExtKeyUsage: []x509.ExtKeyUsage{usage},
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, DNSNames: []string{cn}}
|
||||
der, _ := x509.CreateCertificate(rand.Reader, tpl, caCert, &key.PublicKey, caKey)
|
||||
kder, _ := x509.MarshalECPrivateKey(key)
|
||||
certPath, keyPath := filepath.Join(dir, cn+".crt"), filepath.Join(dir, cn+".key")
|
||||
writePEM(t, certPath, "CERTIFICATE", der)
|
||||
writePEM(t, keyPath, "EC PRIVATE KEY", kder)
|
||||
pair, _ := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
return pair, certPath, keyPath
|
||||
}
|
||||
server, _, _ := leaf("freeagent-box", x509.ExtKeyUsageServerAuth)
|
||||
client, cpath, kpath := leaf("user-host", x509.ExtKeyUsageClientAuth)
|
||||
return testPKI{dir: dir, caPEM: filepath.Join(dir, "ca.crt"), serverCert: server,
|
||||
client: client, clientCert: cpath, key: kpath}
|
||||
}
|
||||
|
||||
// fakeFreeAgent is an mTLS server speaking the local agent's wire shape.
|
||||
func fakeFreeAgent(t *testing.T, p testPKI, handler http.Handler) *httptest.Server {
|
||||
t.Helper()
|
||||
caPEM, _ := os.ReadFile(p.caPEM)
|
||||
pool := x509.NewCertPool()
|
||||
pool.AppendCertsFromPEM(caPEM)
|
||||
srv := httptest.NewUnstartedServer(handler)
|
||||
srv.TLS = &tls.Config{Certificates: []tls.Certificate{p.serverCert},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: pool}
|
||||
srv.StartTLS()
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func faHandler(seenCN *string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
|
||||
*seenCN = r.TLS.PeerCertificates[0].Subject.CommonName
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
json.NewEncoder(w).Encode(map[string]any{"ok": true, "id": "t", "serving": true})
|
||||
case "/search":
|
||||
var req map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
json.NewEncoder(w).Encode(map[string]any{"k": req["k"], "hits": []map[string]any{
|
||||
{"score": 0.9, "doc": strings.Repeat("a", 64), "idx": 0, "text": "router [PII_EMAIL_1]"}}})
|
||||
case "/catalog/dirs", "/catalog":
|
||||
kind := "files"
|
||||
if strings.HasSuffix(r.URL.Path, "/dirs") {
|
||||
kind = "dirs"
|
||||
}
|
||||
if r.URL.Query().Get("limit") == "huge" {
|
||||
w.Write(bytes.Repeat([]byte("x"), freeagentMaxBody+10)) // an oversized 200
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"kind": kind, "total": 1, "next_offset": nil,
|
||||
"q": r.URL.RawQuery, "cards": []map[string]any{
|
||||
{"path": "", "files": 2, "bytes": 88, "entities": map[string]int{"EMAIL": 1}}}})
|
||||
case "/read_range":
|
||||
var req map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req["doc"] == strings.Repeat("0", 64) {
|
||||
w.WriteHeader(404)
|
||||
json.NewEncoder(w).Encode(map[string]any{"error": "no such document"})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"doc": req["doc"], "start": 0, "end": 5,
|
||||
"total": 44, "text": "route"})
|
||||
default:
|
||||
w.WriteHeader(403)
|
||||
json.NewEncoder(w).Encode(map[string]any{"error": "not permitted for user-host: x"})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFreeagentMTLSClient(t *testing.T) {
|
||||
p := mintPKI(t)
|
||||
var seen string
|
||||
srv := fakeFreeAgent(t, p, faHandler(&seen))
|
||||
addr := strings.TrimPrefix(srv.URL, "https://")
|
||||
c := freeagentConfig{Addr: addr, Cert: p.clientCert, Key: p.key, CA: p.caPEM}
|
||||
|
||||
status, body, err := freeagentCall(c, http.MethodGet, "/health", nil)
|
||||
if err != nil || status != 200 {
|
||||
t.Fatalf("health over mTLS: %d %v", status, err)
|
||||
}
|
||||
if seen != "user-host" {
|
||||
t.Fatalf("server must see the box's client CN, got %q", seen)
|
||||
}
|
||||
if !strings.Contains(string(body), `"serving": true`) && !strings.Contains(string(body), `"serving":true`) {
|
||||
t.Fatalf("unexpected health body %s", body)
|
||||
}
|
||||
|
||||
// Missing any of the three files → no dial at all.
|
||||
for _, bad := range []freeagentConfig{{Addr: addr}, {Addr: addr, Cert: p.clientCert, Key: p.key},
|
||||
{Addr: addr, Cert: p.clientCert, Key: p.key, CA: filepath.Join(p.dir, "nope.crt")}} {
|
||||
if _, _, err := freeagentCall(bad, http.MethodGet, "/health", nil); err == nil {
|
||||
t.Fatalf("config %+v must refuse before dialing", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// A server whose cert is NOT from the CA is refused even though hostname
|
||||
// checking is relaxed (chain verification is the wall that stays).
|
||||
other := mintPKI(t)
|
||||
rogue := fakeFreeAgent(t, other, faHandler(&seen))
|
||||
c2 := freeagentConfig{Addr: strings.TrimPrefix(rogue.URL, "https://"), Cert: p.clientCert, Key: p.key, CA: p.caPEM}
|
||||
if _, _, err := freeagentCall(c2, http.MethodGet, "/health", nil); err == nil {
|
||||
t.Fatal("a server outside the CA must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeagentProbeAndEnv(t *testing.T) {
|
||||
t.Setenv("PRIVATAE_BASTION_AGENT_ADDR", "")
|
||||
if adv, _ := freeagentProbe(); adv {
|
||||
t.Fatal("unset env must not advertise")
|
||||
}
|
||||
p := mintPKI(t)
|
||||
var seen string
|
||||
srv := fakeFreeAgent(t, p, faHandler(&seen))
|
||||
t.Setenv("PRIVATAE_BASTION_AGENT_ADDR", strings.TrimPrefix(srv.URL, "https://"))
|
||||
t.Setenv("PRIVATAE_BASTION_AGENT_CERT", p.clientCert)
|
||||
t.Setenv("PRIVATAE_BASTION_AGENT_KEY", p.key)
|
||||
t.Setenv("PRIVATAE_BASTION_AGENT_CA", p.caPEM)
|
||||
adv, ok := freeagentProbe()
|
||||
if !adv || !ok {
|
||||
t.Fatalf("live local agent must be advertised and healthy, got %v %v", adv, ok)
|
||||
}
|
||||
srv.Close()
|
||||
adv, ok = freeagentProbe()
|
||||
if !adv || ok {
|
||||
t.Fatalf("dead local agent must be advertised but unhealthy, got %v %v", adv, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeagentMap(t *testing.T) {
|
||||
m, why, ok := freeagentMap("search", 200, []byte(`{"hits":[]}`), nil)
|
||||
if !ok || m["state"] != "ok" || m["sanitized"] != true || why != "" {
|
||||
t.Fatalf("200 → ok/sanitized, got %v %q", m, why)
|
||||
}
|
||||
m, _, ok = freeagentMap("read_range", 404, []byte(`{"error":"no such document"}`), nil)
|
||||
if !ok || m["state"] != "gone" {
|
||||
t.Fatalf("404 on read_range → gone, got %v", m)
|
||||
}
|
||||
_, why, ok = freeagentMap("search", 403, []byte(`{"error":"not permitted for x: search"}`), nil)
|
||||
if ok || !strings.Contains(why, "not permitted") {
|
||||
t.Fatalf("403 → refused with the local agent's reason, got %v %q", ok, why)
|
||||
}
|
||||
_, why, ok = freeagentMap("search", 503, []byte(`{"error":"manifest not serving: gate: unsigned"}`), nil)
|
||||
if ok || !strings.Contains(why, "gate") {
|
||||
t.Fatalf("503 → refused with the gate reason, got %q", why)
|
||||
}
|
||||
m, _, ok = freeagentMap("search", 0, nil, errors.New("dial tcp: refused"))
|
||||
if !ok || m["state"] != "offline" {
|
||||
t.Fatalf("transport error → offline, got %v", m)
|
||||
}
|
||||
m, _, ok = freeagentMap("search", 502, []byte(`{"error":"search unavailable: embed"}`), nil)
|
||||
if !ok || m["state"] != "offline" {
|
||||
t.Fatalf("502 (embedder down) → offline, got %v", m)
|
||||
}
|
||||
// a 200 that is not a JSON object is a refusal, never a successful empty search
|
||||
if _, why, ok := freeagentMap("search", 200, []byte(`not json`), nil); ok || !strings.Contains(why, "malformed") {
|
||||
t.Fatalf("200 + garbage must be refused, got ok=%v %q", ok, why)
|
||||
}
|
||||
if _, _, ok := freeagentMap("search", 200, []byte(`[1,2]`), nil); ok {
|
||||
t.Fatal("200 + non-object must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeagentClientIsSharedAndBounded(t *testing.T) {
|
||||
p := mintPKI(t)
|
||||
var seen string
|
||||
srv := fakeFreeAgent(t, p, faHandler(&seen))
|
||||
c := freeagentConfig{Addr: strings.TrimPrefix(srv.URL, "https://"), Cert: p.clientCert, Key: p.key, CA: p.caPEM}
|
||||
a, _ := freeagentClient(c)
|
||||
b, _ := freeagentClient(c)
|
||||
if a != b {
|
||||
t.Fatal("same config must reuse one client (the leak was one Transport per call)")
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
if _, _, err := freeagentCall(c, http.MethodGet, "/health", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tr := a.Transport.(*http.Transport)
|
||||
if tr.MaxIdleConns != 2 || tr.IdleConnTimeout != freeagentIdle {
|
||||
t.Fatalf("idle connections must be bounded, got %d/%s", tr.MaxIdleConns, tr.IdleConnTimeout)
|
||||
}
|
||||
// a different config replaces the client
|
||||
c2 := c
|
||||
c2.Addr = c.Addr + " "
|
||||
c2.Addr = strings.TrimSpace(c2.Addr) // same value: still shared
|
||||
if x, _ := freeagentClient(c2); x != a {
|
||||
t.Fatal("identical config after trim must still share")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDcnSearchNeedsSearchMask(t *testing.T) {
|
||||
// The wall itself: a grant with mask [read] must not satisfy 'search',
|
||||
// and one with [read, search] must — independent of any local agent.
|
||||
terms := &dcnTerms{Mask: []string{"read"}}
|
||||
if dcnHasMask(terms, "search") {
|
||||
t.Fatal("read-only grant must not grant search")
|
||||
}
|
||||
terms.Mask = append(terms.Mask, "search")
|
||||
if !dcnHasMask(terms, "search") || !dcnHasMask(terms, "read") {
|
||||
t.Fatal("read+search grant must grant both")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeagentCatalogCall(t *testing.T) {
|
||||
p := mintPKI(t)
|
||||
var seen string
|
||||
srv := fakeFreeAgent(t, p, faHandler(&seen))
|
||||
c := freeagentConfig{Addr: strings.TrimPrefix(srv.URL, "https://"), Cert: p.clientCert, Key: p.key, CA: p.caPEM}
|
||||
status, body, err := freeagentCall(c, http.MethodGet, "/catalog/dirs", nil)
|
||||
m, why, ok := freeagentMap("catalog", status, body, err)
|
||||
if !ok || m["state"] != "ok" || m["kind"] != "dirs" {
|
||||
t.Fatalf("catalog dirs: %v %q", m, why)
|
||||
}
|
||||
cards, _ := m["cards"].([]any)
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("cards: %v", m["cards"])
|
||||
}
|
||||
var a dcnOpArgs
|
||||
json.Unmarshal([]byte(`{"kind":"files","offset":500,"limit":250,"prefix":"docs/"}`), &a)
|
||||
if a.Kind != "files" || a.Offset != 500 || a.Limit != 250 || a.Prefix != "docs/" {
|
||||
t.Fatalf("paging args must be carried: %+v", a)
|
||||
}
|
||||
// an oversized 200 is detectable: the body is capped at exactly freeagentMaxBody
|
||||
status, body, err = freeagentCall(c, http.MethodGet, "/catalog?limit=huge", nil)
|
||||
if err != nil || status != 200 || len(body) != freeagentMaxBody {
|
||||
t.Fatalf("oversized reply must be capped at the max body, got %d %v len=%d", status, err, len(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDcnOpArgsCarryFreeagentFields(t *testing.T) {
|
||||
var a dcnOpArgs
|
||||
if err := json.Unmarshal([]byte(`{"query":"routers","k":3,"doc":"abc","start_byte":1,"end_byte":9}`), &a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Query != "routers" || a.K != 3 || a.Doc != "abc" || a.EndByte != 9 {
|
||||
t.Fatalf("args not carried: %+v", a)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user