diff --git a/.golangci.yml b/.golangci.yml index e9c38d29..31904035 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -48,6 +48,8 @@ linters: - Recorder - ca - caProvider + - CertProvider + - certIssuer # golang.org/x/crypto/ssh - Signer - PublicKey diff --git a/go.mod b/go.mod index 5e1facd5..f7671cd7 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.8 + github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hashicorp/vault/api v1.23.0 github.com/hashicorp/vault/api/auth/approle v0.12.0 github.com/hashicorp/vault/api/auth/aws v0.12.0 diff --git a/go.sum b/go.sum index b568d099..b9edac63 100644 --- a/go.sum +++ b/go.sum @@ -122,6 +122,8 @@ github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= diff --git a/internal/config/config.go b/internal/config/config.go index 4f03b9cb..20d0ee94 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -49,6 +49,8 @@ const ( defaultMetricsPort = 9090 defaultAuditLogFlushInterval = time.Minute * 10 defaultAuditLogFlushSizeThreshold = 1_000_000 // 1MB in bytes + defaultTLSCertDuration = 24 * time.Hour + defaultTLSCertRenewBefore = 8 * time.Hour ) type Config struct { @@ -87,9 +89,11 @@ type AuditLogConfig struct { FlushSizeThreshold int `yaml:"flushSizeThreshold"` // bytes } -// TLSConfig represents the downstream TLS configuration. Static must be set. +// TLSConfig represents the downstream TLS configuration. Exactly one of +// Static or Dynamic must be set. type TLSConfig struct { - Static *TLSStaticConfig `yaml:"static,omitempty"` + Static *TLSStaticConfig `yaml:"static,omitempty"` + Dynamic *TLSDynamicConfig `yaml:"dynamic,omitempty"` } type TLSStaticConfig struct { @@ -103,6 +107,31 @@ type CA struct { CertFile string `yaml:"certFile"` } +// TLSDynamicConfig configures on-demand issuing of downstream leaf certificates. +type TLSDynamicConfig struct { + CA TLSDynamicCAConfig `yaml:"ca"` + Cert TLSDynamicCertConfig `yaml:"cert"` +} + +// TLSDynamicCAConfig represents the signing CA configuration. SelfSign must be set. +type TLSDynamicCAConfig struct { + SelfSign *TLSSelfSignCAConfig `yaml:"selfSign,omitempty"` +} + +// TLSSelfSignCAConfig configures a signing CA loaded from certificate and key files. +type TLSSelfSignCAConfig struct { + CertificateFile string `yaml:"certificateFile"` + PrivateKeyFile string `yaml:"privateKeyFile"` +} + +// TLSDynamicCertConfig controls the leaf certificates issued by the dynamic CA. +type TLSDynamicCertConfig struct { + Duration time.Duration `yaml:"duration"` // Leaf certificate lifetime. Defaults to 24h. + RenewBefore time.Duration `yaml:"renewBefore"` // Window before expiry in which a fresh leaf is issued. Defaults to 8h. + KeyType string `yaml:"keyType"` // ecdsa or rsa. Defaults to ecdsa. + KeyBits int `yaml:"keyBits"` // ECDSA: 256/384/521, RSA: 2048/3072/4096. Defaults to 256 for ECDSA, 2048 for RSA. +} + type KubernetesConfig struct { Upstreams []KubernetesUpstream `yaml:"upstreams"` } @@ -353,19 +382,29 @@ func (c *Config) Validate() error { } func (t *TLSConfig) Validate() error { - if t.Static == nil { + if t.Static == nil && t.Dynamic == nil { return ErrMissingTLSConfig } - if err := t.Static.Validate(); err != nil { - return fmt.Errorf("static: %w", err) + if t.Static != nil && t.Dynamic != nil { + return ErrConflictingTLSConfig + } + + if t.Static != nil { + if err := t.Static.Validate(); err != nil { + return fmt.Errorf("static: %w", err) + } + } + + if t.Dynamic != nil { + if err := t.Dynamic.Validate(); err != nil { + return fmt.Errorf("dynamic: %w", err) + } } return nil } -var ErrMissingTLSConfig = errors.New("'static' must be specified for TLS config") - func (s *TLSStaticConfig) Validate() error { if s.CertificateFile == "" { return fmt.Errorf("%w: certificateFile", ErrRequired) @@ -378,6 +417,125 @@ func (s *TLSStaticConfig) Validate() error { return nil } +var ( + ErrMissingTLSConfig = errors.New("either 'static' or 'dynamic' must be specified for TLS config") + ErrConflictingTLSConfig = errors.New("only one of 'static' or 'dynamic' can be specified for TLS config") + ErrMissingTLSCAConfig = errors.New("'selfSign' must be specified for dynamic CA config") + ErrInvalidTLSKeyType = errors.New("invalid TLS key type") + ErrInvalidTLSKeyBits = errors.New("invalid TLS key bits") + ErrNegativeDuration = errors.New("duration must be non-negative") + ErrRenewBeforeTooLong = errors.New("'renewBefore' must be shorter than 'duration'") +) + +func (d *TLSDynamicConfig) Validate() error { + if err := d.CA.Validate(); err != nil { + return fmt.Errorf("ca: %w", err) + } + + if err := d.Cert.Validate(); err != nil { + return fmt.Errorf("cert: %w", err) + } + + return nil +} + +func (c *TLSDynamicCAConfig) Validate() error { + if c.SelfSign == nil { + return ErrMissingTLSCAConfig + } + + if err := c.SelfSign.Validate(); err != nil { + return fmt.Errorf("selfSign: %w", err) + } + + return nil +} + +func (s *TLSSelfSignCAConfig) Validate() error { + if s.CertificateFile == "" { + return fmt.Errorf("%w: certificateFile", ErrRequired) + } + + if s.PrivateKeyFile == "" { + return fmt.Errorf("%w: privateKeyFile", ErrRequired) + } + + return nil +} + +func (c *TLSDynamicCertConfig) Validate() error { + if c.Duration < 0 { + return fmt.Errorf("%w: duration", ErrNegativeDuration) + } + + if c.RenewBefore < 0 { + return fmt.Errorf("%w: renewBefore", ErrNegativeDuration) + } + + if c.GetRenewBefore() >= c.GetDuration() { + return ErrRenewBeforeTooLong + } + + switch c.GetKeyType() { + case "ecdsa": + switch c.GetKeyBits() { + case 256, 384, 521: + default: + return fmt.Errorf("%w: ECDSA %d", ErrInvalidTLSKeyBits, c.GetKeyBits()) + } + case "rsa": + switch c.GetKeyBits() { + case 2048, 3072, 4096: + default: + return fmt.Errorf("%w: RSA %d", ErrInvalidTLSKeyBits, c.GetKeyBits()) + } + default: + return fmt.Errorf("%w: %q", ErrInvalidTLSKeyType, c.KeyType) + } + + return nil +} + +// GetDuration returns the leaf certificate lifetime, defaulting to 24h. +func (c *TLSDynamicCertConfig) GetDuration() time.Duration { + if c.Duration == 0 { + return defaultTLSCertDuration + } + + return c.Duration +} + +// GetRenewBefore returns the re-issue window before expiry, defaulting to 8h. +func (c *TLSDynamicCertConfig) GetRenewBefore() time.Duration { + if c.RenewBefore == 0 { + return defaultTLSCertRenewBefore + } + + return c.RenewBefore +} + +// GetKeyType returns the leaf key type, defaulting to ecdsa. +func (c *TLSDynamicCertConfig) GetKeyType() string { + if c.KeyType == "" { + return "ecdsa" + } + + return c.KeyType +} + +// GetKeyBits returns the leaf key size, defaulting to 256 for ECDSA and 2048 for RSA. +func (c *TLSDynamicCertConfig) GetKeyBits() int { + if c.KeyBits == 0 { + if c.GetKeyType() == "rsa" { + return 2048 + } + + return 256 + } + + return c.KeyBits +} + func (k *KubernetesConfig) Validate() error { upstreamNames := make(map[string]struct{}) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 816f14b4..8834f88a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -139,6 +139,45 @@ func TestResolveTwingateHostname(t *testing.T) { }) } +func TestLoad_TLSDynamic(t *testing.T) { + yaml := ` +twingate: + network: "acme" +port: 8443 +metricsPort: 9090 +tls: + dynamic: + ca: + selfSign: + certificateFile: "ca.crt" + privateKeyFile: "ca.key" + cert: + duration: "48h" + renewBefore: "12h" + keyType: "ecdsa" + keyBits: 384 +webApp: {} +` + + tmpFile := filepath.Join(t.TempDir(), "config.yaml") + err := os.WriteFile(tmpFile, []byte(yaml), 0600) + require.NoError(t, err) + + cfg, err := Load(tmpFile) + require.NoError(t, err) + require.NotNil(t, cfg.TLS.Dynamic) + + require.NotNil(t, cfg.TLS.Dynamic.CA.SelfSign) + assert.Equal(t, "ca.crt", cfg.TLS.Dynamic.CA.SelfSign.CertificateFile) + assert.Equal(t, "ca.key", cfg.TLS.Dynamic.CA.SelfSign.PrivateKeyFile) + assert.Equal(t, 48*time.Hour, cfg.TLS.Dynamic.Cert.GetDuration()) + assert.Equal(t, 12*time.Hour, cfg.TLS.Dynamic.Cert.GetRenewBefore()) + assert.Equal(t, "ecdsa", cfg.TLS.Dynamic.Cert.GetKeyType()) + assert.Equal(t, 384, cfg.TLS.Dynamic.Cert.GetKeyBits()) + + assert.NoError(t, cfg.Validate()) +} + func TestLoad_Kubernetes(t *testing.T) { yaml := ` twingate: @@ -610,10 +649,34 @@ func TestTLSConfig_Validate(t *testing.T) { wantErr: false, }, { - name: "missing static", + name: "valid with dynamic", + tls: TLSConfig{ + Dynamic: &TLSDynamicConfig{ + CA: TLSDynamicCAConfig{ + SelfSign: &TLSSelfSignCAConfig{CertificateFile: "ca.crt", PrivateKeyFile: "ca.key"}, + }, + }, + }, + wantErr: false, + }, + { + name: "missing static and dynamic", tls: TLSConfig{}, wantErr: true, - errContains: "'static' must be specified", + errContains: "either 'static' or 'dynamic' must be specified", + }, + { + name: "conflicting static and dynamic", + tls: TLSConfig{ + Static: &TLSStaticConfig{CertificateFile: "tls.crt", PrivateKeyFile: "tls.key"}, + Dynamic: &TLSDynamicConfig{ + CA: TLSDynamicCAConfig{ + SelfSign: &TLSSelfSignCAConfig{CertificateFile: "ca.crt", PrivateKeyFile: "ca.key"}, + }, + }, + }, + wantErr: true, + errContains: "only one of 'static' or 'dynamic' can be specified", }, { name: "static missing certificate", @@ -627,6 +690,12 @@ func TestTLSConfig_Validate(t *testing.T) { wantErr: true, errContains: "static: required field is missing: privateKeyFile", }, + { + name: "invalid dynamic", + tls: TLSConfig{Dynamic: &TLSDynamicConfig{}}, + wantErr: true, + errContains: "dynamic: ca: 'selfSign' must be specified", + }, } for _, tt := range tests { @@ -642,6 +711,138 @@ func TestTLSConfig_Validate(t *testing.T) { } } +func TestTLSDynamicConfig_Validate(t *testing.T) { + validCA := TLSDynamicCAConfig{ + SelfSign: &TLSSelfSignCAConfig{CertificateFile: "ca.crt", PrivateKeyFile: "ca.key"}, + } + + tests := []struct { + name string + dynamic TLSDynamicConfig + wantErr bool + errContains string + }{ + { + name: "valid with defaults", + dynamic: TLSDynamicConfig{CA: validCA}, + wantErr: false, + }, + { + name: "valid with full cert config", + dynamic: TLSDynamicConfig{ + CA: validCA, + Cert: TLSDynamicCertConfig{ + Duration: 48 * time.Hour, + RenewBefore: 12 * time.Hour, + KeyType: "rsa", + KeyBits: 4096, + }, + }, + wantErr: false, + }, + { + name: "missing selfSign", + dynamic: TLSDynamicConfig{}, + wantErr: true, + errContains: "'selfSign' must be specified", + }, + { + name: "selfSign missing certificate", + dynamic: TLSDynamicConfig{ + CA: TLSDynamicCAConfig{SelfSign: &TLSSelfSignCAConfig{PrivateKeyFile: "ca.key"}}, + }, + wantErr: true, + errContains: "ca: selfSign: required field is missing: certificateFile", + }, + { + name: "selfSign missing private key", + dynamic: TLSDynamicConfig{ + CA: TLSDynamicCAConfig{SelfSign: &TLSSelfSignCAConfig{CertificateFile: "ca.crt"}}, + }, + wantErr: true, + errContains: "ca: selfSign: required field is missing: privateKeyFile", + }, + { + name: "negative duration", + dynamic: TLSDynamicConfig{ + CA: validCA, + Cert: TLSDynamicCertConfig{Duration: -time.Hour}, + }, + wantErr: true, + errContains: "cert: duration must be non-negative: duration", + }, + { + name: "negative renewBefore", + dynamic: TLSDynamicConfig{ + CA: validCA, + Cert: TLSDynamicCertConfig{RenewBefore: -time.Hour}, + }, + wantErr: true, + errContains: "cert: duration must be non-negative: renewBefore", + }, + { + name: "renewBefore not shorter than duration", + dynamic: TLSDynamicConfig{ + CA: validCA, + Cert: TLSDynamicCertConfig{Duration: 8 * time.Hour, RenewBefore: 8 * time.Hour}, + }, + wantErr: true, + errContains: "'renewBefore' must be shorter than 'duration'", + }, + { + name: "invalid key type", + dynamic: TLSDynamicConfig{ + CA: validCA, + Cert: TLSDynamicCertConfig{KeyType: "ed25519"}, + }, + wantErr: true, + errContains: "invalid TLS key type", + }, + { + name: "invalid ecdsa key bits", + dynamic: TLSDynamicConfig{ + CA: validCA, + Cert: TLSDynamicCertConfig{KeyType: "ecdsa", KeyBits: 2048}, + }, + wantErr: true, + errContains: "invalid TLS key bits: ECDSA 2048", + }, + { + name: "invalid rsa key bits", + dynamic: TLSDynamicConfig{ + CA: validCA, + Cert: TLSDynamicCertConfig{KeyType: "rsa", KeyBits: 256}, + }, + wantErr: true, + errContains: "invalid TLS key bits: RSA 256", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.dynamic.Validate() + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestTLSDynamicCertConfig_Defaults(t *testing.T) { + var cert TLSDynamicCertConfig + + assert.Equal(t, 24*time.Hour, cert.GetDuration()) + assert.Equal(t, 8*time.Hour, cert.GetRenewBefore()) + assert.Equal(t, "ecdsa", cert.GetKeyType()) + assert.Equal(t, 256, cert.GetKeyBits()) + + rsaCert := TLSDynamicCertConfig{KeyType: "rsa"} + assert.Equal(t, 2048, rsaCert.GetKeyBits()) +} + func TestKubernetesConfig_Validate(t *testing.T) { tests := []struct { name string diff --git a/internal/connect/cert.go b/internal/connect/cert.go new file mode 100644 index 00000000..cf457328 --- /dev/null +++ b/internal/connect/cert.go @@ -0,0 +1,259 @@ +// Copyright (c) Twingate Inc. +// SPDX-License-Identifier: MPL-2.0 + +package connect + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "fmt" + "math/big" + "net" + "slices" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + lru "github.com/hashicorp/golang-lru/v2" + + "gateway/internal/config" +) + +const ( + clockSkewBuffer = 30 * time.Second + maxCachedCerts = 1024 +) + +var ( + errNotCACertificate = errors.New("certificate is not a certificate authority") + errCAKeyNotSigner = errors.New("CA private key does not implement crypto.Signer") + errUnsupportedKeyType = errors.New("unsupported key type") + errUnsupportedKeyBits = errors.New("unsupported key bits") +) + +var serialNumberLimit = new(big.Int).Lsh(big.NewInt(1), 128) + +// DynamicCert issues short-lived leaf certificates through the configured +// issuer, caching one certificate per requested name set and re-issuing a +// fresh one once the cached certificate enters the renewal window. +type DynamicCert struct { + issuer certIssuer + cert config.TLSDynamicCertConfig + logger *zap.Logger + + mu sync.Mutex + cache *lru.Cache[string, *tls.Certificate] +} + +func NewDynamicCert(cfg config.TLSDynamicConfig, logger *zap.Logger) (*DynamicCert, error) { + issuer, err := newCertIssuer(cfg) + if err != nil { + return nil, err + } + + cache, err := lru.New[string, *tls.Certificate](maxCachedCerts) + if err != nil { + return nil, fmt.Errorf("failed to create certificate cache: %w", err) + } + + return &DynamicCert{ + issuer: issuer, + cert: cfg.Cert, + logger: logger, + cache: cache, + }, nil +} + +// certIssuer issues a certificate covering a set of names and runs any +// background maintenance its backend needs. +type certIssuer interface { + run(ctx context.Context) + issue(ctx context.Context, names []string) (*tls.Certificate, error) +} + +func newCertIssuer(cfg config.TLSDynamicConfig) (certIssuer, error) { + switch { + case cfg.CA.SelfSign != nil: + return newSelfSignIssuer(cfg.CA.SelfSign, cfg.Cert) + default: + return nil, config.ErrMissingTLSCAConfig + } +} + +// Run implements CertProvider, delegating background maintenance to the issuer. +func (c *DynamicCert) Run(ctx context.Context) { + c.issuer.run(ctx) +} + +// GetCertificateForHost returns a certificate covering host and aliases, +// issuing a new one when none is cached or the cached one is inside the +// renewal window. +func (c *DynamicCert) GetCertificateForHost(ctx context.Context, host string, aliases ...string) (*tls.Certificate, error) { + names := certNames(host, aliases) + key := strings.Join(names, ",") + + if cert, ok := c.cachedCert(key); ok { + return cert, nil + } + + c.mu.Lock() + defer c.mu.Unlock() + + // Re-check under the lock: a caller ahead in the queue may have issued + // this host already, which keeps concurrent cold misses to one issuance. + if cert, ok := c.cachedCert(key); ok { + return cert, nil + } + + cert, err := c.issuer.issue(ctx, names) + if err != nil { + return nil, err + } + + c.logger.Debug("Issued downstream certificate", + zap.Strings("hosts", names), + zap.Time("not_after", cert.Leaf.NotAfter), + ) + + c.cache.Add(key, cert) + + return cert, nil +} + +// certNames is host followed by its aliases, sorted and without duplicates, +// so the same name set always yields the same cache key. host stays first so +// it becomes the common name. +func certNames(host string, aliases []string) []string { + names := make([]string, 0, len(aliases)+1) + names = append(names, host) + + for _, alias := range aliases { + if alias != "" && !slices.Contains(names, alias) { + names = append(names, alias) + } + } + + slices.Sort(names[1:]) + + return names +} + +// cachedCert returns the cached certificate for the given name set +// while it is outside the renewal window. +func (c *DynamicCert) cachedCert(key string) (*tls.Certificate, bool) { + cert, ok := c.cache.Get(key) + if !ok { + return nil, false + } + + return cert, time.Now().Before(cert.Leaf.NotAfter.Add(-c.cert.GetRenewBefore())) +} + +// selfSignIssuer signs leaf certificates locally with a CA loaded from files. +type selfSignIssuer struct { + caCert *x509.Certificate + caKey crypto.Signer + cert config.TLSDynamicCertConfig +} + +func newSelfSignIssuer(cfg *config.TLSSelfSignCAConfig, certCfg config.TLSDynamicCertConfig) (*selfSignIssuer, error) { + pair, err := tls.LoadX509KeyPair(cfg.CertificateFile, cfg.PrivateKeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load CA key pair: %w", err) + } + + caCert, err := x509.ParseCertificate(pair.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("failed to parse CA certificate: %w", err) + } + + if !caCert.IsCA { + return nil, errNotCACertificate + } + + caKey, ok := pair.PrivateKey.(crypto.Signer) + if !ok { + return nil, errCAKeyNotSigner + } + + return &selfSignIssuer{caCert: caCert, caKey: caKey, cert: certCfg}, nil +} + +// run implements certIssuer; the self-sign backend has no background maintenance. +func (s *selfSignIssuer) run(_ context.Context) {} + +func (s *selfSignIssuer) issue(_ context.Context, names []string) (*tls.Certificate, error) { + key, err := s.generateKey() + if err != nil { + return nil, fmt.Errorf("failed to generate leaf key: %w", err) + } + + serial, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, fmt.Errorf("failed to generate serial number: %w", err) + } + + now := time.Now() + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: names[0]}, + NotBefore: now.Add(-clockSkewBuffer), + NotAfter: now.Add(s.cert.GetDuration()), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + + for _, name := range names { + if ip := net.ParseIP(name); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, name) + } + } + + leafDER, err := x509.CreateCertificate(rand.Reader, template, s.caCert, key.Public(), s.caKey) + if err != nil { + return nil, fmt.Errorf("failed to sign leaf certificate: %w", err) + } + + leaf, err := x509.ParseCertificate(leafDER) + if err != nil { + return nil, fmt.Errorf("failed to parse leaf certificate: %w", err) + } + + return &tls.Certificate{ + Certificate: [][]byte{leafDER, s.caCert.Raw}, + PrivateKey: key, + Leaf: leaf, + }, nil +} + +func (s *selfSignIssuer) generateKey() (crypto.Signer, error) { + switch s.cert.GetKeyType() { + case "ecdsa": + switch s.cert.GetKeyBits() { + case 256: + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case 384: + return ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + case 521: + return ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + default: + return nil, fmt.Errorf("%w: ECDSA %d", errUnsupportedKeyBits, s.cert.GetKeyBits()) + } + case "rsa": + return rsa.GenerateKey(rand.Reader, s.cert.GetKeyBits()) + default: + return nil, fmt.Errorf("%w: %s", errUnsupportedKeyType, s.cert.GetKeyType()) + } +} diff --git a/internal/connect/cert_provider.go b/internal/connect/cert_provider.go new file mode 100644 index 00000000..00261c48 --- /dev/null +++ b/internal/connect/cert_provider.go @@ -0,0 +1,65 @@ +// Copyright (c) Twingate Inc. +// SPDX-License-Identifier: MPL-2.0 + +package connect + +import ( + "context" + "crypto/tls" + "fmt" + "net" + + "go.uber.org/zap" + + "gateway/internal/config" +) + +// CertProvider supplies the downstream serving certificates and runs any +// background maintenance (file watching in static mode). +type CertProvider interface { + // Run runs background maintenance until the context is canceled. + Run(ctx context.Context) + + // GetCertificateForHost returns the certificate presented for the given + // host: the SNI host on the outer TLS, the validated CONNECT host + // on the inner TLS. Aliases are covered as additional subject alternative + // names, since the downstream client may have dialled any of them. + // + // Note: SNI cannot carry an IP address (see RFC 6066 ยง 3) + GetCertificateForHost(ctx context.Context, host string, aliases ...string) (*tls.Certificate, error) +} + +// getCertificateForHello serves the outer TLS handshake when the SNI +// host is present, falling back to the connection's local IP for clients +// that send none (e.g. IP-dialed clients and health probes). +func getCertificateForHello(provider CertProvider, hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + host := hello.ServerName + if host == "" { + var err error + + host, _, err = net.SplitHostPort(hello.Conn.LocalAddr().String()) + if err != nil { + return nil, fmt.Errorf("failed to parse local address: %w", err) + } + } + + return provider.GetCertificateForHost(hello.Context(), host) +} + +// newCertProviderFromConfig creates a CertProvider based on the provided +// configuration. +func newCertProviderFromConfig(tlsCfg config.TLSConfig, logger *zap.Logger) (CertProvider, error) { + switch { + case tlsCfg.Static != nil: + return NewCertReloader(tlsCfg.Static.CertificateFile, tlsCfg.Static.PrivateKeyFile, logger), nil + case tlsCfg.Dynamic != nil: + dynamicCert, err := NewDynamicCert(*tlsCfg.Dynamic, logger) + if err != nil { + return nil, fmt.Errorf("failed to create dynamic cert: %w", err) + } + + return dynamicCert, nil + default: + return nil, config.ErrMissingTLSConfig + } +} diff --git a/internal/connect/cert_provider_test.go b/internal/connect/cert_provider_test.go new file mode 100644 index 00000000..4453685e --- /dev/null +++ b/internal/connect/cert_provider_test.go @@ -0,0 +1,148 @@ +// Copyright (c) Twingate Inc. +// SPDX-License-Identifier: MPL-2.0 + +package connect + +import ( + "context" + "crypto/tls" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "gateway/internal/config" +) + +type fakeAddrConn struct { + net.Conn + + addr net.Addr +} + +func (c fakeAddrConn) LocalAddr() net.Addr { return c.addr } + +type recordingCertProvider struct { + host string + aliases []string + shouldFail bool + err error +} + +func (p *recordingCertProvider) Run(_ context.Context) {} + +func (p *recordingCertProvider) GetCertificateForHost(_ context.Context, host string, aliases ...string) (*tls.Certificate, error) { + p.host = host + p.aliases = aliases + + if p.shouldFail { + return nil, p.err + } + + return &tls.Certificate{}, nil +} + +func TestGetCertificateForHello(t *testing.T) { + tests := []struct { + name string + hello *tls.ClientHelloInfo + wantHost string + wantErr string + }{ + { + name: "SNI host", + hello: &tls.ClientHelloInfo{ServerName: "app.internal"}, + wantHost: "app.internal", + }, + { + name: "no SNI falls back to local IP", + hello: &tls.ClientHelloInfo{Conn: fakeAddrConn{addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 8443}}}, + wantHost: "127.0.0.1", + }, + { + name: "unparsable local address", + hello: &tls.ClientHelloInfo{Conn: fakeAddrConn{addr: &net.UnixAddr{Name: "/tmp/gateway.sock", Net: "unix"}}}, + wantErr: "failed to parse local address", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &recordingCertProvider{} + + _, err := getCertificateForHello(provider, tt.hello) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantHost, provider.host) + }) + } +} + +func TestNewCertProviderFromConfig(t *testing.T) { + tests := []struct { + name string + tlsCfg config.TLSConfig + wantType any + wantErr error + errContains string + }{ + { + name: "static", + tlsCfg: config.TLSConfig{Static: &config.TLSStaticConfig{CertificateFile: "../../test/data/proxy/tls.crt", PrivateKeyFile: "../../test/data/proxy/tls.key"}}, + wantType: &CertReloader{}, + }, + { + name: "dynamic", + tlsCfg: config.TLSConfig{Dynamic: &config.TLSDynamicConfig{ + CA: config.TLSDynamicCAConfig{ + SelfSign: &config.TLSSelfSignCAConfig{CertificateFile: "../../test/data/proxy/tls.crt", PrivateKeyFile: "../../test/data/proxy/tls.key"}, + }, + }}, + wantType: &DynamicCert{}, + }, + { + name: "dynamic with missing CA files", + tlsCfg: config.TLSConfig{Dynamic: &config.TLSDynamicConfig{ + CA: config.TLSDynamicCAConfig{ + SelfSign: &config.TLSSelfSignCAConfig{CertificateFile: "missing.crt", PrivateKeyFile: "missing.key"}, + }, + }}, + errContains: "failed to create dynamic cert", + }, + { + name: "neither static nor dynamic", + wantErr: config.ErrMissingTLSConfig, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := newCertProviderFromConfig(tt.tlsCfg, zap.NewNop()) + + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + + return + } + + if tt.errContains != "" { + assert.ErrorContains(t, err, tt.errContains) + + return + } + + require.NoError(t, err) + assert.IsType(t, tt.wantType, provider) + + provider.Run(t.Context()) + }) + } +} diff --git a/internal/connect/cert_reloader.go b/internal/connect/cert_reloader.go index 0cd51855..e87c05c7 100644 --- a/internal/connect/cert_reloader.go +++ b/internal/connect/cert_reloader.go @@ -39,7 +39,9 @@ func (cr *CertReloader) Run(ctx context.Context) { cr.reloader.Run(ctx) } -func (cr *CertReloader) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { +// GetCertificateForHost implements CertProvider, the configured certificate is +// served regardless of the requested host. +func (cr *CertReloader) GetCertificateForHost(_ context.Context, _ string, _ ...string) (*tls.Certificate, error) { cr.mu.RLock() defer cr.mu.RUnlock() diff --git a/internal/connect/cert_reloader_test.go b/internal/connect/cert_reloader_test.go index 000b6553..2c4f7dd3 100644 --- a/internal/connect/cert_reloader_test.go +++ b/internal/connect/cert_reloader_test.go @@ -27,7 +27,6 @@ func TestCertReloader_Run(t *testing.T) { cr.Run(t.Context()) requireCertReloader(t, cr, cert) - newCert := generateCert(t) replaceCertFiles(t, certFile, keyFile, newCert) @@ -62,7 +61,7 @@ func TestCertReloader_load(t *testing.T) { require.NoError(t, cr.load()) - got, err := cr.GetCertificate(&tls.ClientHelloInfo{}) + got, err := cr.GetCertificateForHost(t.Context(), "") require.NoError(t, err) assert.Equal(t, cert.Certificate, got.Certificate) }) @@ -72,10 +71,8 @@ func TestCertReloader_load(t *testing.T) { func requireCertReloader(t *testing.T, certReloader *CertReloader, expectedCert tls.Certificate) { t.Helper() - hello := &tls.ClientHelloInfo{} - require.EventuallyWithT(t, func(c *assert.CollectT) { - existingCert, err := certReloader.GetCertificate(hello) + existingCert, err := certReloader.GetCertificateForHost(t.Context(), "") require.NoError(c, err) require.NotNil(c, existingCert) diff --git a/internal/connect/cert_test.go b/internal/connect/cert_test.go new file mode 100644 index 00000000..5ff1a313 --- /dev/null +++ b/internal/connect/cert_test.go @@ -0,0 +1,295 @@ +// Copyright (c) Twingate Inc. +// SPDX-License-Identifier: MPL-2.0 + +package connect + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "net" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "gateway/internal/config" + "gateway/test/data" +) + +func TestNewDynamicCert_Errors(t *testing.T) { + nonCAFile, nonCAKeyFile := createCertFiles(t, generateCert(t)) + + tests := []struct { + name string + selfSign *config.TLSSelfSignCAConfig + wantErr error + errContains string + }{ + { + name: "missing selfSign", + wantErr: config.ErrMissingTLSCAConfig, + }, + { + name: "missing files", + selfSign: &config.TLSSelfSignCAConfig{CertificateFile: "missing.crt", PrivateKeyFile: "missing.key"}, + errContains: "failed to load CA key pair", + }, + { + name: "mismatched certificate and key", + selfSign: &config.TLSSelfSignCAConfig{CertificateFile: "../../test/data/proxy/tls.crt", PrivateKeyFile: "../../test/data/api_server/tls.key"}, + errContains: "failed to load CA key pair", + }, + { + name: "certificate is not a CA", + selfSign: &config.TLSSelfSignCAConfig{CertificateFile: nonCAFile, PrivateKeyFile: nonCAKeyFile}, + wantErr: errNotCACertificate, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewDynamicCert(config.TLSDynamicConfig{ + CA: config.TLSDynamicCAConfig{SelfSign: tt.selfSign}, + }, zap.NewNop()) + + require.Error(t, err) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } + + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + }) + } +} + +func TestDynamicCert_GetCertificateForHost_DNSHost(t *testing.T) { + cert, err := NewDynamicCert(config.TLSDynamicConfig{ + CA: config.TLSDynamicCAConfig{ + SelfSign: &config.TLSSelfSignCAConfig{CertificateFile: "../../test/data/proxy/tls.crt", PrivateKeyFile: "../../test/data/proxy/tls.key"}, + }, + }, zap.NewNop()) + require.NoError(t, err) + + issued, err := cert.GetCertificateForHost(t.Context(), "app.internal") + require.NoError(t, err) + + assert.Equal(t, "app.internal", issued.Leaf.Subject.CommonName) + assert.Equal(t, []string{"app.internal"}, issued.Leaf.DNSNames) + assert.WithinDuration(t, time.Now().Add(24*time.Hour), issued.Leaf.NotAfter, time.Minute) + + key, ok := issued.PrivateKey.(*ecdsa.PrivateKey) + require.True(t, ok, "expected an ECDSA leaf key by default") + assert.Equal(t, elliptic.P256(), key.Curve) + + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(data.ProxyCert) + + _, err = issued.Leaf.Verify(x509.VerifyOptions{ + DNSName: "app.internal", + Roots: pool, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }) + assert.NoError(t, err, "leaf should verify against the CA for the requested host") +} + +func TestDynamicCert_GetCertificateForHost_IPHost(t *testing.T) { + cert, err := NewDynamicCert(config.TLSDynamicConfig{ + CA: config.TLSDynamicCAConfig{ + SelfSign: &config.TLSSelfSignCAConfig{CertificateFile: "../../test/data/proxy/tls.crt", PrivateKeyFile: "../../test/data/proxy/tls.key"}, + }, + }, zap.NewNop()) + require.NoError(t, err) + + issued, err := cert.GetCertificateForHost(t.Context(), "10.0.0.5") + require.NoError(t, err) + + require.Len(t, issued.Leaf.IPAddresses, 1) + assert.True(t, issued.Leaf.IPAddresses[0].Equal(net.ParseIP("10.0.0.5"))) + assert.Empty(t, issued.Leaf.DNSNames) +} + +func TestDynamicCert_GetCertificateForHost_CoversAliases(t *testing.T) { + tests := []struct { + name string + host string + aliases []string + wantCN string + wantDNS []string + wantIPs []string + }{ + { + name: "aliases are sorted after the host", + host: "app.internal", + aliases: []string{"b.internal", "a.internal"}, + wantCN: "app.internal", + wantDNS: []string{"app.internal", "a.internal", "b.internal"}, + }, + { + name: "ip host with dns aliases", + host: "10.0.0.5", + aliases: []string{"app.internal", "alt.internal"}, + wantCN: "10.0.0.5", + wantDNS: []string{"alt.internal", "app.internal"}, + wantIPs: []string{"10.0.0.5"}, + }, + { + name: "dns host with dns aliases", + host: "app.internal", + aliases: []string{"alt.internal"}, + wantCN: "app.internal", + wantDNS: []string{"app.internal", "alt.internal"}, + }, + { + name: "alias repeating the host is not duplicated", + host: "app.internal", + aliases: []string{"app.internal", "alt.internal", ""}, + wantCN: "app.internal", + wantDNS: []string{"app.internal", "alt.internal"}, + }, + { + name: "no aliases", + host: "app.internal", + wantCN: "app.internal", + wantDNS: []string{"app.internal"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cert, err := NewDynamicCert(config.TLSDynamicConfig{ + CA: config.TLSDynamicCAConfig{ + SelfSign: &config.TLSSelfSignCAConfig{CertificateFile: "../../test/data/proxy/tls.crt", PrivateKeyFile: "../../test/data/proxy/tls.key"}, + }, + }, zap.NewNop()) + require.NoError(t, err) + + issued, err := cert.GetCertificateForHost(t.Context(), tt.host, tt.aliases...) + require.NoError(t, err) + + assert.Equal(t, tt.wantCN, issued.Leaf.Subject.CommonName) + assert.Equal(t, tt.wantDNS, issued.Leaf.DNSNames) + + var gotIPs []string + for _, ip := range issued.Leaf.IPAddresses { + gotIPs = append(gotIPs, ip.String()) + } + + assert.Equal(t, tt.wantIPs, gotIPs) + }) + } +} + +func TestDynamicCert_GetCertificateForHost_CachesPerNameSet(t *testing.T) { + cert, err := NewDynamicCert(config.TLSDynamicConfig{ + CA: config.TLSDynamicCAConfig{ + SelfSign: &config.TLSSelfSignCAConfig{CertificateFile: "../../test/data/proxy/tls.crt", PrivateKeyFile: "../../test/data/proxy/tls.key"}, + }, + }, zap.NewNop()) + require.NoError(t, err) + + first, err := cert.GetCertificateForHost(t.Context(), "app.internal", "a.internal") + require.NoError(t, err) + + // The same host with a different alias set needs its own certificate. + other, err := cert.GetCertificateForHost(t.Context(), "app.internal", "b.internal") + require.NoError(t, err) + assert.NotEqual(t, first.Leaf.SerialNumber, other.Leaf.SerialNumber) + + again, err := cert.GetCertificateForHost(t.Context(), "app.internal", "a.internal") + require.NoError(t, err) + assert.Same(t, first, again) + + // The same aliases in a different order are the same name set. + sorted, err := cert.GetCertificateForHost(t.Context(), "app.internal", "a.internal", "b.internal") + require.NoError(t, err) + + reordered, err := cert.GetCertificateForHost(t.Context(), "app.internal", "b.internal", "a.internal") + require.NoError(t, err) + assert.Same(t, sorted, reordered) +} + +func TestDynamicCert_GetCertificateForHost_RenewsInsideWindow(t *testing.T) { + cert, err := NewDynamicCert(config.TLSDynamicConfig{ + CA: config.TLSDynamicCAConfig{ + SelfSign: &config.TLSSelfSignCAConfig{CertificateFile: "../../test/data/proxy/tls.crt", PrivateKeyFile: "../../test/data/proxy/tls.key"}, + }, + Cert: config.TLSDynamicCertConfig{ + Duration: 2 * time.Hour, + RenewBefore: time.Hour, + }, + }, zap.NewNop()) + require.NoError(t, err) + + first, err := cert.GetCertificateForHost(t.Context(), "app.internal") + require.NoError(t, err) + + // Expire the cached certificate into its renewal window. + cached, ok := cert.cache.Get("app.internal") + require.True(t, ok) + + cached.Leaf.NotAfter = time.Now().Add(30 * time.Minute) + + second, err := cert.GetCertificateForHost(t.Context(), "app.internal") + require.NoError(t, err) + + assert.NotEqual(t, first.Leaf.SerialNumber, second.Leaf.SerialNumber) + + // Re-issuing replaces the cached entry rather than adding another one. + assert.Equal(t, 1, cert.cache.Len()) +} + +// Concurrent cold misses for one host must issue once. This is what the +// re-check inside the lock in GetCertificateForHost buys; without it every +// caller issues its own certificate. +func TestDynamicCert_GetCertificateForHost_ConcurrentColdMissIssuesOnce(t *testing.T) { + const callers = 10 + + cert, err := NewDynamicCert(config.TLSDynamicConfig{ + CA: config.TLSDynamicCAConfig{ + SelfSign: &config.TLSSelfSignCAConfig{CertificateFile: "../../test/data/proxy/tls.crt", PrivateKeyFile: "../../test/data/proxy/tls.key"}, + }, + }, zap.NewNop()) + require.NoError(t, err) + + var ( + mu sync.Mutex + wg sync.WaitGroup + issueErr error + serials = map[string]struct{}{} + ) + + start := make(chan struct{}) + + for range callers { + wg.Go(func() { + <-start + + got, err := cert.GetCertificateForHost(t.Context(), "cold.internal") + + mu.Lock() + defer mu.Unlock() + + if err != nil { + issueErr = err + + return + } + + serials[got.Leaf.SerialNumber.String()] = struct{}{} + }) + } + + close(start) + wg.Wait() + + require.NoError(t, issueErr) + assert.Len(t, serials, 1, "concurrent cold misses should issue exactly once") + assert.Equal(t, 1, cert.cache.Len()) +} diff --git a/internal/connect/conn.go b/internal/connect/conn.go index 30ae4bbc..9f9da70c 100644 --- a/internal/connect/conn.go +++ b/internal/connect/conn.go @@ -53,6 +53,7 @@ type ProxyConn struct { net.Conn TLSConfig *tls.Config + CertProvider CertProvider ConnectValidator Validator Logger *zap.Logger @@ -69,10 +70,18 @@ type ProxyConn struct { once sync.Once } -func NewProxyConn(conn net.Conn, tlsConfig *tls.Config, validator Validator, logger *zap.Logger, metrics *ProxyConnMetrics) *ProxyConn { +func NewProxyConn( + conn net.Conn, + tlsConfig *tls.Config, + certProvider CertProvider, + validator Validator, + logger *zap.Logger, + metrics *ProxyConnMetrics, +) *ProxyConn { return &ProxyConn{ Conn: conn, TLSConfig: tlsConfig, + CertProvider: certProvider, ConnectValidator: validator, Logger: logger, tracker: NewProxyConnMetricsTracker(ConnCategoryUnknown, metrics), @@ -234,7 +243,7 @@ func (p *ProxyConn) Authenticate() error { } func (p *ProxyConn) UpgradeToTLS() error { - tlsConn := tls.Server(p.Conn, p.TLSConfig) + tlsConn := tls.Server(p.Conn, p.getTLSConfig()) if err := tlsConn.Handshake(); err != nil { p.Logger.Error("failed to upgrade TLS", zap.Error(err)) @@ -247,6 +256,21 @@ func (p *ProxyConn) UpgradeToTLS() error { return nil } +// getTLSConfig pins the served certificate to the CONNECT host. +func (p *ProxyConn) getTLSConfig() *tls.Config { + tlsConfig := p.TLSConfig.Clone() + tlsConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + cert, err := p.CertProvider.GetCertificateForHost(hello.Context(), p.RequestedHost, p.Claims.Resource.Aliases...) + if err != nil { + return nil, fmt.Errorf("failed to get certificate for %q: %w", p.RequestedHost, err) + } + + return cert, nil + } + + return tlsConfig +} + func (p *ProxyConn) setConnectInfo(connectInfo Info) { p.ID = connectInfo.ConnID p.RequestedHost = connectInfo.RequestedHost diff --git a/internal/connect/conn_test.go b/internal/connect/conn_test.go index 22d19251..fa418379 100644 --- a/internal/connect/conn_test.go +++ b/internal/connect/conn_test.go @@ -7,6 +7,7 @@ import ( "bufio" "crypto/tls" "crypto/x509" + "errors" "fmt" "io" "net" @@ -25,6 +26,7 @@ import ( promtestutil "github.com/prometheus/client_golang/prometheus/testutil" + "gateway/internal/config" "gateway/internal/token" "gateway/test/data" ) @@ -489,6 +491,117 @@ func TestProxyConn_Authenticate_FailedValidation(t *testing.T) { <-done } +// upgradeToTLSHandshake runs proxyConn.UpgradeToTLS against a TLS client +// connected over TCP. +func upgradeToTLSHandshake(t *testing.T, proxyConn *ProxyConn, clientTLSConfig *tls.Config) error { + t.Helper() + + listener, addr := startMockListener(t) + defer listener.Close() + + clientCh := make(chan error, 1) + + go func() { + conn, err := net.Dial("tcp", addr) + if err != nil { + clientCh <- err + + return + } + + defer conn.Close() + + clientCh <- tls.Client(conn, clientTLSConfig).Handshake() + }() + + conn, err := listener.Accept() + require.NoError(t, err) + + proxyConn.Conn = conn + + serverErr := proxyConn.UpgradeToTLS() + if serverErr != nil { + _ = conn.Close() + + <-clientCh + + return serverErr + } + + require.NoError(t, <-clientCh) + + return nil +} + +func TestProxyConn_getTLSConfig(t *testing.T) { + provider := &recordingCertProvider{} + proxyConn := &ProxyConn{ + TLSConfig: &tls.Config{}, + CertProvider: provider, + RequestedHost: "grafana.internal", + Claims: &token.GATClaims{Resource: token.Resource{Type: token.ResourceTypeWebApp, Aliases: []string{"echo.internal", "echo-alt.internal"}}}, + Logger: zap.NewNop(), + } + + cert, err := proxyConn.getTLSConfig().GetCertificate(&tls.ClientHelloInfo{ServerName: "other.internal"}) + require.NoError(t, err) + + assert.NotNil(t, cert) + assert.Equal(t, "grafana.internal", provider.host) + assert.Equal(t, []string{"echo.internal", "echo-alt.internal"}, provider.aliases) +} + +func TestProxyConn_UpgradeToTLS_HandshakeError(t *testing.T) { + cert, err := NewDynamicCert(config.TLSDynamicConfig{ + CA: config.TLSDynamicCAConfig{ + SelfSign: &config.TLSSelfSignCAConfig{CertificateFile: "../../test/data/proxy/tls.crt", PrivateKeyFile: "../../test/data/proxy/tls.key"}, + }, + Cert: config.TLSDynamicCertConfig{}, + }, zap.NewNop()) + require.NoError(t, err) + + // The client trusts a different CA, so it rejects the issued certificate + // and the server-side handshake fails. + wrongPool := x509.NewCertPool() + wrongPool.AppendCertsFromPEM(data.ServerCert) + + proxyConn := &ProxyConn{ + TLSConfig: &tls.Config{}, + CertProvider: cert, + RequestedHost: "app.internal", + Claims: &token.GATClaims{Resource: token.Resource{Type: token.ResourceTypeWebApp}}, + Logger: zap.NewNop(), + } + + err = upgradeToTLSHandshake(t, proxyConn, &tls.Config{ + ServerName: "app.internal", + RootCAs: wrongPool, + MinVersion: tls.VersionTLS13, + }) + + require.Error(t, err) +} + +func TestProxyConn_UpgradeToTLS_CertProviderError(t *testing.T) { + var errCertProviderFailed = errors.New("cert provider failed") + + proxyConn := &ProxyConn{ + TLSConfig: &tls.Config{}, + CertProvider: &recordingCertProvider{shouldFail: true, err: errCertProviderFailed}, + RequestedHost: "app.internal", + Claims: &token.GATClaims{Resource: token.Resource{Type: token.ResourceTypeWebApp}}, + Logger: zap.NewNop(), + } + + err := upgradeToTLSHandshake(t, proxyConn, &tls.Config{ + ServerName: "app.internal", + MinVersion: tls.VersionTLS13, + }) + + require.ErrorIs(t, err, errCertProviderFailed) + assert.ErrorContains(t, err, `failed to get certificate for "app.internal"`) +} + func TestIsHealthCheckRequest(t *testing.T) { testCases := []struct { name string diff --git a/internal/connect/listener.go b/internal/connect/listener.go index d7974407..c2da8139 100644 --- a/internal/connect/listener.go +++ b/internal/connect/listener.go @@ -73,7 +73,7 @@ type Listener struct { channels map[token.ResourceType]chan<- Conn tokenParser *token.Parser - certReloader *CertReloader + certProvider CertProvider tlsConfig *tls.Config connectValidator Validator logger *zap.Logger @@ -101,12 +101,17 @@ func NewListener( return nil, fmt.Errorf("failed to create token parser: %w", err) } - certReloader := NewCertReloader(tlsCfg.Static.CertificateFile, tlsCfg.Static.PrivateKeyFile, logger) + certProvider, err := newCertProviderFromConfig(tlsCfg, logger) + if err != nil { + return nil, err + } tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS13, - MaxVersion: tls.VersionTLS13, - GetCertificate: certReloader.GetCertificate, + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + return getCertificateForHello(certProvider, hello) + }, } connectValidator := &MessageValidator{ @@ -118,13 +123,13 @@ func NewListener( l := &Listener{ channels: channels, tokenParser: tokenParser, - certReloader: certReloader, + certProvider: certProvider, tlsConfig: tlsConfig, connectValidator: connectValidator, logger: logger, metrics: metrics, proxyConnFactory: func(conn net.Conn, tlsConfig *tls.Config, connectValidator Validator, logger *zap.Logger) Conn { - return NewProxyConn(conn, tlsConfig, connectValidator, logger, metrics) + return NewProxyConn(conn, tlsConfig, certProvider, connectValidator, logger, metrics) }, } @@ -135,7 +140,7 @@ func NewListener( // The caller owns the listener and is responsible for closing it. // Serve closes the channels when it returns. func (l *Listener) Serve(ctx context.Context, listener net.Listener) error { - l.certReloader.Run(ctx) + l.certProvider.Run(ctx) var wg sync.WaitGroup diff --git a/internal/connect/listener_test.go b/internal/connect/listener_test.go index e8dd15f0..d9d435f5 100644 --- a/internal/connect/listener_test.go +++ b/internal/connect/listener_test.go @@ -138,7 +138,7 @@ func createTestListenerWithChannels(t *testing.T) *testListenerFixtures { channels: channels, logger: logger, metrics: CreateProxyConnMetrics(registry), - certReloader: certReloader, + certProvider: certReloader, } return &testListenerFixtures{ @@ -346,7 +346,7 @@ func TestListener_UnsupportedResourceType(t *testing.T) { channels: channels, logger: logger, metrics: CreateProxyConnMetrics(registry), - certReloader: certReloader, + certProvider: certReloader, } sshClaims := createClaims(t, token.ResourceTypeSSH) @@ -403,7 +403,7 @@ func TestListener_Serve_GracefulShutdown(t *testing.T) { channels: channels, logger: logger, metrics: CreateProxyConnMetrics(prometheus.NewRegistry()), - certReloader: NewCertReloader("../../test/data/proxy/tls.crt", "../../test/data/proxy/tls.key", logger), + certProvider: NewCertReloader("../../test/data/proxy/tls.crt", "../../test/data/proxy/tls.key", logger), } listener.proxyConnFactory = func(conn net.Conn, _ *tls.Config, _ Validator, _ *zap.Logger) Conn { diff --git a/internal/httpproxy/proxy_test.go b/internal/httpproxy/proxy_test.go index f87e76cd..79549339 100644 --- a/internal/httpproxy/proxy_test.go +++ b/internal/httpproxy/proxy_test.go @@ -31,7 +31,7 @@ func (l *mockConnListener) Accept() (net.Conn, error) { } connMetrics := connect.CreateProxyConnMetrics(prometheus.NewRegistry()) - proxyConn := connect.NewProxyConn(conn, nil, nil, zap.NewNop(), connMetrics) + proxyConn := connect.NewProxyConn(conn, nil, nil, nil, zap.NewNop(), connMetrics) proxyConn.ID = "test-conn" proxyConn.RequestedHost = "localhost" proxyConn.UpstreamHost = "localhost" @@ -46,7 +46,7 @@ func (l *mockConnListener) Accept() (net.Conn, error) { func TestProxyConnFromContext(t *testing.T) { t.Run("Returns ProxyConn from context", func(t *testing.T) { connMetrics := connect.CreateProxyConnMetrics(prometheus.NewRegistry()) - expected := connect.NewProxyConn(nil, nil, nil, zap.NewNop(), connMetrics) + expected := connect.NewProxyConn(nil, nil, nil, nil, zap.NewNop(), connMetrics) ctx := context.WithValue(t.Context(), ConnContextKey{}, expected) diff --git a/internal/kuberneteshandler/handler_test.go b/internal/kuberneteshandler/handler_test.go index 1eeb69f7..59bf44d4 100644 --- a/internal/kuberneteshandler/handler_test.go +++ b/internal/kuberneteshandler/handler_test.go @@ -20,7 +20,7 @@ import ( func TestRewrite(t *testing.T) { connMetrics := connect.CreateProxyConnMetrics(prometheus.NewRegistry()) - conn := connect.NewProxyConn(nil, nil, nil, zap.NewNop(), connMetrics) + conn := connect.NewProxyConn(nil, nil, nil, nil, zap.NewNop(), connMetrics) conn.RequestedHost = "kubernetes.default.svc" conn.UpstreamHost = "kubernetes.default.svc" conn.Claims = &token.GATClaims{ diff --git a/internal/sshhandler/proxy_test.go b/internal/sshhandler/proxy_test.go index b3128baf..2d9e44c1 100644 --- a/internal/sshhandler/proxy_test.go +++ b/internal/sshhandler/proxy_test.go @@ -640,7 +640,7 @@ func newProxyConn(conn net.Conn, upstreamAddr string) *connect.ProxyConn { upstreamHost, port, _ := net.SplitHostPort(upstreamAddr) upstreamPort, _ := strconv.Atoi(port) - proxyConn := connect.NewProxyConn(conn, nil, nil, zap.NewNop(), + proxyConn := connect.NewProxyConn(conn, nil, nil, nil, zap.NewNop(), connect.CreateProxyConnMetrics(prometheus.NewRegistry())) proxyConn.Claims = &token.GATClaims{ Resource: token.Resource{ diff --git a/internal/webapphandler/handler_test.go b/internal/webapphandler/handler_test.go index e8d37073..3d13f7b8 100644 --- a/internal/webapphandler/handler_test.go +++ b/internal/webapphandler/handler_test.go @@ -43,7 +43,7 @@ func mustParse(t *testing.T, templates map[string]string) map[string]*template.T func TestNewHandler_PanicsOnRewriteError(t *testing.T) { connMetrics := connect.CreateProxyConnMetrics(prometheus.NewRegistry()) - conn := connect.NewProxyConn(nil, nil, nil, zap.NewNop(), connMetrics) + conn := connect.NewProxyConn(nil, nil, nil, nil, zap.NewNop(), connMetrics) conn.Claims = &token.GATClaims{ User: token.User{Username: "alice@acme.com"}, } @@ -200,7 +200,7 @@ func TestRewrite(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { connMetrics := connect.CreateProxyConnMetrics(prometheus.NewRegistry()) - conn := connect.NewProxyConn(nil, nil, nil, zap.NewNop(), connMetrics) + conn := connect.NewProxyConn(nil, nil, nil, nil, zap.NewNop(), connMetrics) conn.UpstreamHost = tt.upstreamHost conn.Token = tt.jwtToken conn.Claims = tt.claims @@ -226,7 +226,7 @@ func TestRewrite(t *testing.T) { func TestRewrite_PreservesClientHost(t *testing.T) { connMetrics := connect.CreateProxyConnMetrics(prometheus.NewRegistry()) - conn := connect.NewProxyConn(nil, nil, nil, zap.NewNop(), connMetrics) + conn := connect.NewProxyConn(nil, nil, nil, nil, zap.NewNop(), connMetrics) conn.UpstreamHost = "admin.example.int" conn.Claims = &token.GATClaims{ Resource: token.Resource{GatewayMetadata: token.GatewayMetadata{Upstream: token.Upstream{Port: 80}}}, @@ -257,7 +257,7 @@ func TestRewrite_UpstreamScheme(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { connMetrics := connect.CreateProxyConnMetrics(prometheus.NewRegistry()) - conn := connect.NewProxyConn(nil, nil, nil, zap.NewNop(), connMetrics) + conn := connect.NewProxyConn(nil, nil, nil, nil, zap.NewNop(), connMetrics) conn.UpstreamHost = "admin.example.int" conn.Claims = &token.GATClaims{ Resource: token.Resource{ @@ -283,7 +283,7 @@ func TestRewrite_UpstreamScheme(t *testing.T) { func TestRewrite_StripsClientIdentityHeaders(t *testing.T) { connMetrics := connect.CreateProxyConnMetrics(prometheus.NewRegistry()) - conn := connect.NewProxyConn(nil, nil, nil, zap.NewNop(), connMetrics) + conn := connect.NewProxyConn(nil, nil, nil, nil, zap.NewNop(), connMetrics) conn.UpstreamHost = "admin.example.int" conn.Claims = &token.GATClaims{ Resource: token.Resource{GatewayMetadata: token.GatewayMetadata{Upstream: token.Upstream{Port: 80}}}, @@ -313,7 +313,7 @@ func TestRewrite_SkipsInvalidGATHeaders(t *testing.T) { } connMetrics := connect.CreateProxyConnMetrics(prometheus.NewRegistry()) - conn := connect.NewProxyConn(nil, nil, nil, zap.NewNop(), connMetrics) + conn := connect.NewProxyConn(nil, nil, nil, nil, zap.NewNop(), connMetrics) conn.Token = "test-token" conn.Claims = withRequestHeaderRewrites(baseClaims, map[string]string{ "X-Malformed": "{{unclosed", @@ -343,7 +343,7 @@ func TestCreateTransport(t *testing.T) { func TestBuildVariables_CoversAllowedKeys(t *testing.T) { connMetrics := connect.CreateProxyConnMetrics(prometheus.NewRegistry()) - conn := connect.NewProxyConn(nil, nil, nil, zap.NewNop(), connMetrics) + conn := connect.NewProxyConn(nil, nil, nil, nil, zap.NewNop(), connMetrics) conn.Claims = &token.GATClaims{} got := slices.Sorted(maps.Keys(buildVariables(conn)))