feat: Add Support for Proxy with Interceptor (#77)

This commit is contained in:
Abhisek Datta
2025-12-10 08:34:28 +05:30
committed by GitHub
parent a8723cd680
commit 21eb05373f
15 changed files with 1852 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
package certmanager
import "sync"
// InMemoryCache implements CertificateCache using an in-memory map
type InMemoryCache struct {
mu sync.RWMutex
cache map[string]*Certificate
}
// NewInMemoryCache creates a new in-memory certificate cache
func NewInMemoryCache() *InMemoryCache {
return &InMemoryCache{
cache: make(map[string]*Certificate),
}
}
func (c *InMemoryCache) Get(hostname string) (*Certificate, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
cert, found := c.cache[hostname]
return cert, found
}
func (c *InMemoryCache) Set(hostname string, cert *Certificate) {
c.mu.Lock()
defer c.mu.Unlock()
c.cache[hostname] = cert
}
func (c *InMemoryCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.cache = make(map[string]*Certificate)
}
func (c *InMemoryCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.cache)
}
+120
View File
@@ -0,0 +1,120 @@
package certmanager
import (
"crypto"
"crypto/tls"
"crypto/x509"
"fmt"
"time"
)
// Certificate represents a TLS certificate with its private key
// Both certificate and private key are stored in PEM-encoded format
type Certificate struct {
// PEM encoded certificate
Certificate []byte
// PEM encoded private key
PrivateKey []byte
// Parsed X.509 certificate
X509Cert *x509.Certificate
// Parsed private key
PrivKey crypto.PrivateKey
}
// CertificateCache defines the interface for certificate caching
type CertificateCache interface {
// Get retrieves a cached certificate for the given hostname
Get(hostname string) (*Certificate, bool)
// Set stores a certificate for the given hostname
Set(hostname string, cert *Certificate)
// Clear removes all cached certificates
Clear()
// Size returns the number of cached certificates
Size() int
}
// CertificateManager handles TLS certificate lifecycle management
type CertificateManager interface {
// GetCA returns the Certificate Authority certificate and key
GetCA() (*Certificate, error)
// GenerateCertForHost creates a certificate for the given hostname
// Uses caching to avoid regeneration of certificates
// The certificate is signed by the CA and includes the hostname in the SAN
GenerateCertForHost(hostname string) (*Certificate, error)
// GetTLSConfig returns a tls.Config for the given hostname
// This is a convenience method that generates/retrieves the certificate
// and creates a tls.Config
GetTLSConfig(hostname string) (*tls.Config, error)
}
// CertManagerConfig holds configuration for certificate generation
type CertManagerConfig struct {
// CAValidityDays specifies how many days the CA certificate is valid
CAValidityDays int
// HostCertValidityDays specifies how many days host certificates are valid
HostCertValidityDays int
// KeySize specifies the RSA key size in bits
KeySize int
}
// DefaultCertManagerConfig returns a configuration with reasonable defaults
func DefaultCertManagerConfig() CertManagerConfig {
return CertManagerConfig{
CAValidityDays: 365,
HostCertValidityDays: 1,
KeySize: 2048,
}
}
// SetDefaults sets reasonable defaults for zero values in the configuration
func (c *CertManagerConfig) SetDefaults() {
if c.CAValidityDays <= 0 {
c.CAValidityDays = 365
}
if c.HostCertValidityDays <= 0 {
c.HostCertValidityDays = 1
}
// Default to 2048 bits if key size is not set
if c.KeySize == 0 {
c.KeySize = 2048
}
}
// Validate checks if the configuration is valid after defaults have been set
func (c *CertManagerConfig) Validate() error {
if c.CAValidityDays <= 0 {
return fmt.Errorf("CA validity days must be greater than 0: %d", c.CAValidityDays)
}
if c.HostCertValidityDays <= 0 {
return fmt.Errorf("host certificate validity days must be greater than 0: %d", c.HostCertValidityDays)
}
if c.KeySize < 2048 {
return fmt.Errorf("key size must be at least 2048 bits: %d", c.KeySize)
}
return nil
}
// IsExpired checks if a certificate is expired or will expire within the given threshold
func (c *Certificate) IsExpired(threshold time.Duration) bool {
if c.X509Cert == nil {
return true
}
expiryTime := c.X509Cert.NotAfter
return time.Until(expiryTime) < threshold
}
+247
View File
@@ -0,0 +1,247 @@
package certmanager
import (
"crypto/tls"
"crypto/x509"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestGenerateCA(t *testing.T) {
config := DefaultCertManagerConfig()
ca, err := GenerateCA(config)
assert.NoError(t, err, "Failed to generate CA")
assert.NotNil(t, ca, "CA certificate should not be nil")
assert.NotNil(t, ca.Certificate, "CA certificate should not be nil")
assert.NotEmpty(t, ca.Certificate, "CA certificate should not be empty")
assert.NotNil(t, ca.PrivateKey, "CA private key should not be nil")
assert.NotEmpty(t, ca.PrivateKey, "CA private key should not be empty")
assert.NotNil(t, ca.X509Cert, "Parsed X509 certificate should not be nil")
assert.NotNil(t, ca.PrivKey, "Parsed private key should not be nil")
assert.True(t, ca.X509Cert.IsCA, "Certificate should be marked as CA")
assert.Equal(t, "PMG Proxy CA", ca.X509Cert.Subject.CommonName, "Common name should be PMG Proxy CA")
assert.Greater(t, ca.X509Cert.NotAfter.Sub(ca.X509Cert.NotBefore).Hours(),
float64(config.CAValidityDays*24-1), "CA certificate validity period should be greater than the configured validity days")
}
func TestInMemoryCache(t *testing.T) {
cache := NewInMemoryCache()
assert.Equal(t, 0, cache.Size(), "New cache should be empty")
cert := &Certificate{
Certificate: []byte("test cert"),
PrivateKey: []byte("test key"),
}
cache.Set("example.com", cert)
assert.Equal(t, 1, cache.Size(), "Cache size should be 1")
retrieved, found := cache.Get("example.com")
assert.True(t, found, "Certificate should be found in cache")
assert.Equal(t, "test cert", string(retrieved.Certificate), "Retrieved certificate should match")
_, found = cache.Get("nonexistent.com")
assert.False(t, found, "Non-existent certificate should not be found")
cache.Clear()
assert.Equal(t, 0, cache.Size(), "Cache should be empty after Clear")
}
func TestNewCertificateManagerWithCA(t *testing.T) {
config := DefaultCertManagerConfig()
ca, err := GenerateCA(config)
assert.NoError(t, err, "Failed to generate CA")
cm, err := NewCertificateManagerWithCA(ca, config)
assert.NoError(t, err, "Failed to create certificate manager")
assert.NotNil(t, cm, "Certificate manager should not be nil")
retrievedCA, err := cm.GetCA()
assert.NoError(t, err, "Failed to get CA")
assert.Equal(t, ca, retrievedCA, "Retrieved CA should match original")
}
func TestGenerateCertForHost(t *testing.T) {
config := DefaultCertManagerConfig()
ca, err := GenerateCA(config)
assert.NoError(t, err, "Failed to generate CA")
cm, err := NewCertificateManagerWithCA(ca, config)
assert.NoError(t, err, "Failed to create certificate manager")
hostname := "registry.npmjs.org"
cert, err := cm.GenerateCertForHost(hostname)
assert.NoError(t, err, "Failed to generate host certificate")
assert.NotNil(t, cert, "Host certificate should not be nil")
assert.NotEmpty(t, cert.Certificate, "Host certificate should not be empty")
assert.NotEmpty(t, cert.PrivateKey, "Host private key should not be empty")
assert.NotNil(t, cert.X509Cert, "Parsed X509 certificate should not be nil")
assert.False(t, cert.X509Cert.IsCA, "Host certificate should not be marked as CA")
assert.Contains(t, cert.X509Cert.DNSNames, hostname, "Certificate SAN should include hostname")
assert.Equal(t, hostname, cert.X509Cert.Subject.CommonName, "Common name should match hostname")
roots := x509.NewCertPool()
roots.AddCert(ca.X509Cert)
opts := x509.VerifyOptions{
DNSName: hostname,
Roots: roots,
}
_, err = cert.X509Cert.Verify(opts)
assert.NoError(t, err, "Certificate should be verified by CA")
}
func TestCertificateCaching(t *testing.T) {
config := DefaultCertManagerConfig()
ca, err := GenerateCA(config)
assert.NoError(t, err, "Failed to generate CA")
cm, err := NewCertificateManagerWithCA(ca, config)
assert.NoError(t, err, "Failed to create certificate manager")
hostname := "registry.npmjs.org"
// First generation
cert1, err := cm.GenerateCertForHost(hostname)
assert.NoError(t, err, "Failed to generate first certificate")
// Second generation should return cached certificate
cert2, err := cm.GenerateCertForHost(hostname)
assert.NoError(t, err, "Failed to generate second certificate")
// Should be the same certificate (pointer equality)
assert.Equal(t, cert1, cert2, "Second call should return cached certificate")
}
func TestGetTLSConfig(t *testing.T) {
config := DefaultCertManagerConfig()
ca, err := GenerateCA(config)
assert.NoError(t, err, "Failed to generate CA")
cm, err := NewCertificateManagerWithCA(ca, config)
assert.NoError(t, err, "Failed to create certificate manager")
hostname := "registry.npmjs.org"
tlsConfig, err := cm.GetTLSConfig(hostname)
assert.NoError(t, err, "Failed to get TLS config")
assert.NotNil(t, tlsConfig, "TLS config should not be nil")
assert.NotEmpty(t, tlsConfig.Certificates, "TLS config should have certificates")
assert.Equal(t, uint16(tls.VersionTLS12), tlsConfig.MinVersion, "TLS min version should be TLS 1.2")
}
func TestCertificateExpiry(t *testing.T) {
config := DefaultCertManagerConfig()
config.HostCertValidityDays = 1
ca, err := GenerateCA(config)
assert.NoError(t, err, "Failed to generate CA")
cm, err := NewCertificateManagerWithCA(ca, config)
assert.NoError(t, err, "Failed to create certificate manager")
cert, err := cm.GenerateCertForHost("example.com")
assert.NoError(t, err, "Failed to generate certificate")
// Certificate should not be expired with 1 hour threshold
assert.False(t, cert.IsExpired(1*time.Hour), "Certificate should not be expired yet")
// Certificate should be "expired" with threshold > validity period
assert.True(t, cert.IsExpired(25*time.Hour), "Certificate should be considered expired with large threshold")
}
func TestCertManagerConfigValidation(t *testing.T) {
config := CertManagerConfig{
CAValidityDays: 0,
HostCertValidityDays: 0,
KeySize: 2048,
}
config.SetDefaults()
err := config.Validate()
assert.NoError(t, err, "Validate should not return error")
assert.Equal(t, 365, config.CAValidityDays, "CAValidityDays should default to 365")
assert.Equal(t, 1, config.HostCertValidityDays, "HostCertValidityDays should default to 1")
assert.Equal(t, 2048, config.KeySize, "KeySize should default to 2048")
config.KeySize = 1024
err = config.Validate()
assert.Error(t, err, "Validate should return error for key size less than 2048")
}
func TestNewCertificateManagerWithNilCA(t *testing.T) {
config := DefaultCertManagerConfig()
_, err := NewCertificateManagerWithCA(nil, config)
if err == nil {
t.Error("Expected error when creating manager with nil CA")
}
}
func BenchmarkGenerateCA(b *testing.B) {
config := DefaultCertManagerConfig()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := GenerateCA(config)
assert.NoError(b, err, "Failed to generate CA")
}
}
func BenchmarkGenerateHostCert(b *testing.B) {
config := DefaultCertManagerConfig()
ca, err := GenerateCA(config)
assert.NoError(b, err, "Failed to generate CA")
cm, err := NewCertificateManagerWithCA(ca, config)
assert.NoError(b, err, "Failed to create certificate manager")
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := cm.GenerateCertForHost("example.com")
assert.NoError(b, err, "Failed to generate host certificate")
}
}
func BenchmarkCacheOperations(b *testing.B) {
cache := NewInMemoryCache()
cert := &Certificate{
Certificate: []byte("test cert"),
PrivateKey: []byte("test key"),
}
b.Run("Set", func(b *testing.B) {
for i := 0; i < b.N; i++ {
cache.Set("example.com", cert)
}
})
b.Run("Get", func(b *testing.B) {
cache.Set("example.com", cert)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Get("example.com")
}
})
}
+262
View File
@@ -0,0 +1,262 @@
package certmanager
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"time"
)
// certManager implements the CertificateManager interface
type certManager struct {
ca *Certificate
cache CertificateCache
config CertManagerConfig
}
// NewCertificateManagerWithCA creates a new certificate manager with an existing CA certificate
func NewCertificateManagerWithCA(ca *Certificate, config CertManagerConfig) (CertificateManager, error) {
if ca == nil {
return nil, fmt.Errorf("CA certificate cannot be nil")
}
config.SetDefaults()
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
if ca.X509Cert == nil || ca.PrivKey == nil {
parsedCA, err := parseCertificate(ca)
if err != nil {
return nil, fmt.Errorf("failed to parse CA certificate: %w", err)
}
ca = parsedCA
}
if ca.IsExpired(1 * time.Hour) {
return nil, fmt.Errorf("CA certificate is expired")
}
return &certManager{
ca: ca,
cache: NewInMemoryCache(),
config: config,
}, nil
}
// GetCA returns the Certificate Authority certificate
func (cm *certManager) GetCA() (*Certificate, error) {
return cm.ca, nil
}
// GenerateCertForHost creates a certificate for the given hostname
// Uses caching to avoid regeneration
func (cm *certManager) GenerateCertForHost(hostname string) (*Certificate, error) {
if cached, found := cm.cache.Get(hostname); found {
if !cached.IsExpired(1 * time.Hour) {
return cached, nil
}
}
cert, err := cm.generateHostCert(hostname)
if err != nil {
return nil, fmt.Errorf("failed to generate certificate for %s: %w", hostname, err)
}
cm.cache.Set(hostname, cert)
return cert, nil
}
// GetTLSConfig returns a tls.Config for the given hostname
func (cm *certManager) GetTLSConfig(hostname string) (*tls.Config, error) {
cert, err := cm.GenerateCertForHost(hostname)
if err != nil {
return nil, err
}
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to create tls.Certificate: %w", err)
}
return &tls.Config{
Certificates: []tls.Certificate{tlsCert},
MinVersion: tls.VersionTLS12,
}, nil
}
func (cm *certManager) generateHostCert(hostname string) (*Certificate, error) {
privKey, err := rsa.GenerateKey(rand.Reader, cm.config.KeySize)
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %w", err)
}
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("failed to generate serial number: %w", err)
}
notBefore := time.Now()
notAfter := notBefore.Add(time.Duration(cm.config.HostCertValidityDays) * 24 * time.Hour)
template := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: hostname,
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{hostname},
}
caPrivKey, ok := cm.ca.PrivKey.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("CA private key is not RSA")
}
certDER, err := x509.CreateCertificate(rand.Reader, template, cm.ca.X509Cert, &privKey.PublicKey, caPrivKey)
if err != nil {
return nil, fmt.Errorf("failed to create certificate: %w", err)
}
x509Cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("failed to parse generated certificate: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
})
privKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
})
return &Certificate{
Certificate: certPEM,
PrivateKey: privKeyPEM,
X509Cert: x509Cert,
PrivKey: privKey,
}, nil
}
// GenerateCA generates a new self-signed CA certificate using the given configuration
func GenerateCA(config CertManagerConfig) (*Certificate, error) {
config.SetDefaults()
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
privKey, err := rsa.GenerateKey(rand.Reader, config.KeySize)
if err != nil {
return nil, fmt.Errorf("failed to generate CA private key: %w", err)
}
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("failed to generate serial number: %w", err)
}
notBefore := time.Now()
notAfter := notBefore.Add(time.Duration(config.CAValidityDays) * 24 * time.Hour)
template := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: "PMG Proxy CA",
Organization: []string{"SafeDep PMG"},
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
BasicConstraintsValid: true,
IsCA: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey)
if err != nil {
return nil, fmt.Errorf("failed to create CA certificate: %w", err)
}
x509Cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("failed to parse generated CA certificate: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
})
privKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
})
return &Certificate{
Certificate: certPEM,
PrivateKey: privKeyPEM,
X509Cert: x509Cert,
PrivKey: privKey,
}, nil
}
func parseCertificate(cert *Certificate) (*Certificate, error) {
certBlock, _ := pem.Decode(cert.Certificate)
if certBlock == nil {
return nil, fmt.Errorf("failed to decode PEM certificate")
}
x509Cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse X509 certificate: %w", err)
}
keyBlock, _ := pem.Decode(cert.PrivateKey)
if keyBlock == nil {
return nil, fmt.Errorf("failed to decode PEM private key")
}
privKey, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse RSA private key: %w", err)
}
return &Certificate{
Certificate: cert.Certificate,
PrivateKey: cert.PrivateKey,
X509Cert: x509Cert,
PrivKey: privKey,
}, nil
}
// ParseTLSCertificate converts a Certificate to a tls.Certificate
// This is useful for integrating with libraries that expect tls.Certificate
func ParseTLSCertificate(cert *Certificate) (tls.Certificate, error) {
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to create X509 key pair: %w", err)
}
// Populate Leaf field if we have the parsed X.509 certificate
// This is important for libraries (like goproxy) that need to inspect the certificate
if cert.X509Cert != nil {
tlsCert.Leaf = cert.X509Cert
}
return tlsCert, nil
}