Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ CLAUDE.local.md

# dev things
dev/certs/
docs/

# build things
./proxy
Expand Down
41 changes: 1 addition & 40 deletions e2e/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,10 @@ package e2e

import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"sync"
"testing"
"time"

"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -158,7 +151,7 @@ func newFakeTLSUpstream(t *testing.T) fakeTLSUpstream {
go func() { _ = fakeUpstream.Serve(lis) }()
t.Cleanup(fakeUpstream.Stop)

clientCertFile, clientKeyFile := generateMatchingRSAKeyPair(t)
clientCertFile, clientKeyFile := testutil.GenerateRSACert(t)

return fakeTLSUpstream{
svc: svc,
Expand Down Expand Up @@ -214,35 +207,3 @@ func dialUnix(t *testing.T, upstream string) *grpc.ClientConn {
require.NoError(t, err)
return conn
}

// generateMatchingRSAKeyPair writes a self-signed RSA-2048 certificate and its
// matching private key to a fresh [testing.T.TempDir] and returns the paths.
// Unlike [testutil.RSACert], the private key is retained and written out, so
// the pair loads via [tls.LoadX509KeyPair] for use as a real TLS client
// identity.
func generateMatchingRSAKeyPair(t *testing.T) (certFile, keyFile string) {
t.Helper()

dir := t.TempDir()

key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)

tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "proxy-client"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}

der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
require.NoError(t, err)

certFile = testutil.WriteFile(t, dir, "client-cert.pem", pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
keyFile = testutil.WriteFile(t, dir, "client-key.pem",
pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))

return certFile, keyFile
}
4 changes: 1 addition & 3 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,12 @@ func TestConfig_Validate(t *testing.T) {
cfg: &config.Config{
Listen: config.ListenConfig{
HostPort: ":8080",
TLS: &config.TLSConfig{}, // empty -> creds.TLS PEM read failures
TLS: &config.TLSConfig{}, // empty -> "a server certificate is required"
},
Upstreams: validUpstreams,
},
wantTuples: [][2]string{
{"tls", "cert"},
{"tls", "key"},
},
},
{
Expand All @@ -230,7 +229,6 @@ func TestConfig_Validate(t *testing.T) {
wantTuples: [][2]string{
{"", "hostPort"},
{"tls", "cert"},
{"tls", "key"},
},
},
{
Expand Down
96 changes: 37 additions & 59 deletions internal/config/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,6 @@ type (
Key string `yaml:"key"` // PEM-encoded private key
ServerName string `yaml:"serverName"` // Optional SNI override
}

// caTrustAnchor validates a certificate used as an outbound trust anchor
// (client-side TLS with no client certificate presented). The anchor may be
// either a CA or a pinned self-signed leaf. It is checked for expiry, a secure
// signature algorithm, and sufficient key size.
caTrustAnchor struct {
caFile string
}
)

// Validate checks the host:port and, when present, the TLS configuration.
Expand All @@ -46,64 +38,50 @@ func (l *ListenConfig) Validate() error {
)
}

// Validate checks the configured certificate material: mutual TLS when a CA is
// set, otherwise server-only TLS.
func (t *TLSConfig) Validate() error {
return validation.Validate(
"",
validation.WhenRules(
func() bool { return t.CA != "" },
validation.Nested("", t.mtlsCreds()),
),
validation.WhenRules(
func() bool { return t.CA == "" },
validation.Nested("", creds.NewServerTLS(t.Cert, t.Key)),
),
)
// Listener resolves the inbound (server) credential for this TLS block. A nil
// receiver yields an insecure listener.
func (t *TLSConfig) Listener() *creds.Listener {
return creds.NewListener(t.credsOptions()...)
}

// Dialer resolves the outbound (client) credential for this TLS block. A nil
// receiver yields an insecure dialer.
func (t *TLSConfig) Dialer() *creds.Dialer {
return creds.NewDialer(t.credsOptions()...)
}

// mtlsCreds builds the mutual-TLS credential described by the config: the CA
// verifies the peer and the client key pair is the presented certificate. It is
// shared by the inbound listener validation and the outbound upstream validation
// so the construction lives in one place.
func (t *TLSConfig) mtlsCreds() *creds.MTLS {
return creds.NewMTLS(t.CA, t.Cert, t.Key, creds.MTLSOptions{ServerName: t.ServerName})
// Validate checks the inbound (listener) TLS material. It delegates to the
// resolved server credential, which owns the mode decision (server TLS vs mutual
// TLS) and the certificate file checks.
func (t *TLSConfig) Validate() error {
return t.Listener().Validate()
}

// validateOutbound validates the config as client-side TLS used to dial an
// upstream. A client certificate (cert+key) selects mutual TLS and requires a
// CA; a CA alone verifies the upstream against a private trust anchor while
// presenting no client certificate; neither means client TLS against the system
// root pool. Callers must invoke this only when the receiver is non-nil.
// upstream. It delegates to the resolved client credential, which owns the mode
// decision (system-root, custom-CA, or mutual TLS) and the legality and file
// checks. Callers must invoke this only when the receiver is non-nil.
func (t *TLSConfig) validateOutbound() validation.Errors {
hasCert := t.Cert != "" || t.Key != ""
return validation.Nested("tls", t.Dialer())()
}

switch {
case (t.Cert == "") != (t.Key == ""):
return validation.Errors{{Subject: "tls", Message: "cert and key must be set together"}}
case hasCert && t.CA == "":
return validation.Errors{{Subject: "tls", Field: "ca", Message: "is required when a client certificate is set"}}
case hasCert:
return validation.Nested("tls", t.mtlsCreds())()
case t.CA != "":
return validation.Nested("tls", caTrustAnchor{caFile: t.CA})()
default:
return nil
// credsOptions maps the TLS block onto a set of creds options shared by both
// roles; the caller picks the role via creds.NewListener or creds.NewDialer. A
// nil block is plaintext. A present block with no CA and no client certificate
// resolves to system-root TLS for a client.
func (t *TLSConfig) credsOptions() []creds.Option {
if t == nil {
return []creds.Option{creds.Insecure()}
}
}

// Validate checks that the trust anchor is not expired and is signed with a
// strong algorithm and a sufficiently large key.
func (c caTrustAnchor) Validate() error {
return validation.Validate(
"",
validation.Field("ca", c.caFile, func(path string) error {
return creds.ValidatePEMFile(
path,
creds.CertificateNotExpired(),
creds.UsesSecureCertificateAlgorithm(),
creds.HasSufficientKeySize(),
)
}),
)
var opts []creds.Option
if t.CA != "" {
opts = append(opts, creds.WithCA(t.CA))
}

if t.Cert != "" || t.Key != "" {
opts = append(opts, creds.WithCertificate(t.Cert, t.Key))
}

return opts
}
95 changes: 5 additions & 90 deletions internal/config/listen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,97 +48,12 @@ func TestListenConfig_Validate(t *testing.T) {
}
}

// TestListenConfig_Validate_TLS_RouteSelection exercises the branch in
// TLSConfig.Validate that picks creds.TLS when CA is empty and creds.MTLS
// when CA is set. The deep cert-content checks live in creds' own tests; we
// fingerprint the routing decision via the (Subject, Field) tuples that
// surface for missing files, which is stable across both routes.
func TestListenConfig_Validate_TLS_RouteSelection(t *testing.T) {
t.Parallel()

tests := []struct {
name string
tls *config.TLSConfig
// Each entry is the (Subject, Field) tuple expected on one Error.
// Messages are intentionally not pinned: creds errors include the
// host's filesystem-error wording which differs across OSes.
wantTuples [][2]string
}{
{
name: "empty TLS routes through creds.TLS (CA empty)",
tls: &config.TLSConfig{},
wantTuples: [][2]string{
{"tls", "cert"}, // creds.TLS PEM read failure on cert
{"tls", "key"}, // creds.TLS PEM read failure on key
},
},
{
name: "missing-file paths route through creds.TLS (CA empty)",
tls: &config.TLSConfig{
Cert: "/missing/cert.pem",
Key: "/missing/key.pem",
},
wantTuples: [][2]string{
{"tls", "cert"}, // creds.TLS PEM read failure on cert
{"tls", "key"}, // creds.TLS PEM read failure on key (no "ca")
},
},
{
name: "CA set routes through creds.MTLS",
tls: &config.TLSConfig{
Cert: "/missing/cert.pem",
Key: "/missing/key.pem",
CA: "/missing/ca.pem",
},
wantTuples: [][2]string{
{"tls", "cert"}, // creds.MTLS PEM read failure on cert
{"tls", "key"}, // creds.MTLS PEM read failure on key
{"tls", "ca"}, // creds.MTLS PEM read failure on ca
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

cfg := &config.ListenConfig{HostPort: ":8080", TLS: tt.tls}
err := cfg.Validate()
require.Error(t, err)

var errs validation.Errors
require.True(t, errors.As(err, &errs))

got := make([][2]string, len(errs))
for i, e := range errs {
got[i] = [2]string{e.Subject, e.Field}
}
require.ElementsMatch(t, tt.wantTuples, got)
})
}
}

func TestTLSConfig_Validate(t *testing.T) {
t.Parallel()

// Called standalone (not via Nested), entries keep the empty Subject
// that TLSConfig.Validate sets. An empty struct routes through the
// CA=="" branch (creds.TLS), so we expect cert + key PEM read failures.
tls := &config.TLSConfig{}
err := tls.Validate()
require.Error(t, err)

var errs validation.Errors
require.True(t, errors.As(err, &errs))
require.Len(t, errs, 2)

for _, e := range errs {
require.Empty(t, e.Subject, "standalone Validate leaves Subject empty")
}

fields := make([]string, len(errs))
for i, e := range errs {
fields[i] = e.Field
}
require.ElementsMatch(t, []string{"cert", "key"}, fields)
// An empty inbound TLS block resolves to server TLS but supplies no
// certificate, so validation reports a single legality failure. The
// per-mode certificate-content checks live in the creds package tests.
err := (&config.TLSConfig{}).Validate()
require.ErrorContains(t, err, "a server certificate is required")
}
4 changes: 2 additions & 2 deletions internal/config/upstream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,15 +556,15 @@ func TestUpstreamOutboundTLS(t *testing.T) {
_, certFile, _ := testutil.GenerateMTLSCerts(t)
return &config.TLSConfig{Cert: certFile}
},
wantErr: "cert and key must be set together",
wantErr: "certificate and key must be set together",
},
{
name: "key without cert is rejected",
tls: func(t *testing.T) *config.TLSConfig {
_, _, keyFile := testutil.GenerateMTLSCerts(t)
return &config.TLSConfig{Key: keyFile}
},
wantErr: "cert and key must be set together",
wantErr: "certificate and key must be set together",
},
{
name: "client cert without CA is rejected",
Expand Down
Loading