mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add Support for Proxy with Interceptor (#77)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newRequestContext(req *http.Request) (*RequestContext, error) {
|
||||
var hostname string
|
||||
// Extract hostname - for MITM'd requests, URL might be relative
|
||||
// so we need to check the Host header
|
||||
if req.URL != nil {
|
||||
hostname = req.URL.Hostname()
|
||||
}
|
||||
|
||||
if hostname == "" && req.Host != "" {
|
||||
// For MITM requests, the URL is relative but Host header contains the hostname
|
||||
hostname = req.Host
|
||||
if host, _, err := net.SplitHostPort(req.Host); err == nil {
|
||||
hostname = host
|
||||
}
|
||||
}
|
||||
|
||||
requestID, err := generateRequestID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate request ID: %w", err)
|
||||
}
|
||||
|
||||
return &RequestContext{
|
||||
URL: req.URL,
|
||||
Method: req.Method,
|
||||
Headers: req.Header,
|
||||
Hostname: hostname,
|
||||
RequestID: requestID,
|
||||
StartTime: time.Now(),
|
||||
Data: make(map[string]interface{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newRequestContextFromURL(urlStr string, method string) (*RequestContext, error) {
|
||||
// For CONNECT requests, we receive "hostname:port" (e.g., "registry.npmjs.org:443")
|
||||
// url.Parse treats this as "scheme:path", so we need to add "//" to parse correctly
|
||||
if !strings.Contains(urlStr, "://") {
|
||||
urlStr = "//" + urlStr
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse URL: %w", err)
|
||||
}
|
||||
|
||||
// If URL doesn't have a scheme, add https (typical for CONNECT)
|
||||
if parsedURL.Scheme == "" {
|
||||
parsedURL.Scheme = "https"
|
||||
}
|
||||
|
||||
requestID, err := generateRequestID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate request ID: %w", err)
|
||||
}
|
||||
|
||||
return &RequestContext{
|
||||
URL: parsedURL,
|
||||
Method: method,
|
||||
Headers: make(http.Header),
|
||||
Hostname: parsedURL.Hostname(),
|
||||
RequestID: requestID,
|
||||
StartTime: time.Now(),
|
||||
Data: make(map[string]interface{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateRequestID() (string, error) {
|
||||
bytes := make([]byte, 8)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewRequestContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupRequest func() *http.Request
|
||||
wantError bool
|
||||
assert func(*testing.T, *RequestContext, error)
|
||||
}{
|
||||
{
|
||||
name: "full URL with hostname",
|
||||
setupRequest: func() *http.Request {
|
||||
req, _ := http.NewRequest("GET", "https://example.com/path", nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
},
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, "example.com", ctx.Hostname)
|
||||
assert.Equal(t, "GET", ctx.Method)
|
||||
assert.NotNil(t, ctx.URL)
|
||||
assert.Equal(t, "https://example.com/path", ctx.URL.String())
|
||||
assert.Equal(t, "application/json", ctx.Headers.Get("Content-Type"))
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "full URL with port",
|
||||
setupRequest: func() *http.Request {
|
||||
req, _ := http.NewRequest("POST", "https://api.example.com:8080/api/v1", nil)
|
||||
return req
|
||||
},
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, "api.example.com", ctx.Hostname)
|
||||
assert.Equal(t, "POST", ctx.Method)
|
||||
assert.NotNil(t, ctx.URL)
|
||||
assert.Equal(t, "https://api.example.com:8080/api/v1", ctx.URL.String())
|
||||
assert.Empty(t, ctx.Headers)
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative URL with Host header",
|
||||
setupRequest: func() *http.Request {
|
||||
req, _ := http.NewRequest("GET", "/path/to/resource", nil)
|
||||
req.Host = "proxy.example.com"
|
||||
req.Header.Set("Authorization", "Bearer token123")
|
||||
return req
|
||||
},
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, "proxy.example.com", ctx.Hostname)
|
||||
assert.Equal(t, "GET", ctx.Method)
|
||||
assert.NotNil(t, ctx.URL)
|
||||
assert.Equal(t, "/path/to/resource", ctx.URL.String())
|
||||
assert.Equal(t, "Bearer token123", ctx.Headers.Get("Authorization"))
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative URL with Host header containing port",
|
||||
setupRequest: func() *http.Request {
|
||||
req, _ := http.NewRequest("PUT", "/update", nil)
|
||||
req.Host = "localhost:3000"
|
||||
return req
|
||||
},
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, "localhost", ctx.Hostname)
|
||||
assert.Equal(t, "PUT", ctx.Method)
|
||||
assert.NotNil(t, ctx.URL)
|
||||
assert.Equal(t, "/update", ctx.URL.String())
|
||||
assert.Empty(t, ctx.Headers)
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CONNECT method with Host header",
|
||||
setupRequest: func() *http.Request {
|
||||
req, _ := http.NewRequest("CONNECT", "", nil)
|
||||
req.Host = "secure.example.com:443"
|
||||
return req
|
||||
},
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, "secure.example.com", ctx.Hostname)
|
||||
assert.Equal(t, "CONNECT", ctx.Method)
|
||||
assert.NotNil(t, ctx.URL)
|
||||
assert.Equal(t, "", ctx.URL.String())
|
||||
assert.Empty(t, ctx.Headers)
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty hostname fallback",
|
||||
setupRequest: func() *http.Request {
|
||||
req, _ := http.NewRequest("DELETE", "/delete", nil)
|
||||
// No Host header and no URL hostname
|
||||
return req
|
||||
},
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Empty(t, ctx.Hostname)
|
||||
assert.Equal(t, "DELETE", ctx.Method)
|
||||
assert.NotNil(t, ctx.URL)
|
||||
assert.Equal(t, "/delete", ctx.URL.String())
|
||||
assert.Empty(t, ctx.Headers)
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IPv6 address in Host header",
|
||||
setupRequest: func() *http.Request {
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
req.Host = "[::1]:8080"
|
||||
return req
|
||||
},
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, "::1", ctx.Hostname)
|
||||
assert.Equal(t, "GET", ctx.Method)
|
||||
assert.NotNil(t, ctx.URL)
|
||||
assert.Equal(t, "/", ctx.URL.String())
|
||||
assert.Empty(t, ctx.Headers)
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := tt.setupRequest()
|
||||
ctx, err := newRequestContext(req)
|
||||
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
tt.assert(t, ctx, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequestContextFromURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
urlStr string
|
||||
method string
|
||||
wantError bool
|
||||
assert func(*testing.T, *RequestContext, error)
|
||||
}{
|
||||
{
|
||||
name: "full HTTPS URL",
|
||||
urlStr: "https://api.example.com/v1/users",
|
||||
method: "GET",
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, "api.example.com", ctx.Hostname)
|
||||
assert.Equal(t, "https", ctx.URL.Scheme)
|
||||
assert.Equal(t, "GET", ctx.Method)
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Headers)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "full HTTP URL with port",
|
||||
urlStr: "http://localhost:8080/health",
|
||||
method: "POST",
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, "localhost", ctx.Hostname)
|
||||
assert.Equal(t, "http", ctx.URL.Scheme)
|
||||
assert.Equal(t, "POST", ctx.Method)
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Headers)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CONNECT style hostname:port",
|
||||
urlStr: "registry.npmjs.org:443",
|
||||
method: "CONNECT",
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, "registry.npmjs.org", ctx.Hostname)
|
||||
assert.Equal(t, "https", ctx.URL.Scheme)
|
||||
assert.Equal(t, "CONNECT", ctx.Method)
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Headers)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hostname without port",
|
||||
urlStr: "example.com",
|
||||
method: "GET",
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, "example.com", ctx.Hostname)
|
||||
assert.Equal(t, "https", ctx.URL.Scheme)
|
||||
assert.Equal(t, "GET", ctx.Method)
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Headers)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IPv4 address with port",
|
||||
urlStr: "192.168.1.1:8443",
|
||||
method: "PUT",
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, "192.168.1.1", ctx.Hostname)
|
||||
assert.Equal(t, "https", ctx.URL.Scheme)
|
||||
assert.Equal(t, "PUT", ctx.Method)
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Headers)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IPv6 address with port",
|
||||
urlStr: "[2001:db8::1]:443",
|
||||
method: "DELETE",
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, "2001:db8::1", ctx.Hostname)
|
||||
assert.Equal(t, "https", ctx.URL.Scheme)
|
||||
assert.Equal(t, "DELETE", ctx.Method)
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Headers)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "URL with path and query",
|
||||
urlStr: "api.service.com:443/v2/data?filter=active",
|
||||
method: "GET",
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, "api.service.com", ctx.Hostname)
|
||||
assert.Equal(t, "https", ctx.URL.Scheme)
|
||||
assert.Equal(t, "GET", ctx.Method)
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Headers)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "FTP URL (keeps original scheme)",
|
||||
urlStr: "ftp://files.example.com/upload",
|
||||
method: "PUT",
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, "files.example.com", ctx.Hostname)
|
||||
assert.Equal(t, "ftp", ctx.URL.Scheme)
|
||||
assert.Equal(t, "PUT", ctx.Method)
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Headers)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid URL with malformed characters",
|
||||
urlStr: "http://[invalid-ipv6",
|
||||
method: "GET",
|
||||
wantError: true,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, ctx)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty URL becomes root path",
|
||||
urlStr: "",
|
||||
method: "GET",
|
||||
wantError: false,
|
||||
assert: func(t *testing.T, ctx *RequestContext, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Empty(t, ctx.Hostname)
|
||||
assert.Equal(t, "https", ctx.URL.Scheme)
|
||||
assert.Equal(t, "GET", ctx.Method)
|
||||
assert.NotEmpty(t, ctx.RequestID)
|
||||
assert.Len(t, ctx.RequestID, 16)
|
||||
assert.False(t, ctx.StartTime.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), ctx.StartTime, time.Second)
|
||||
assert.NotNil(t, ctx.Headers)
|
||||
assert.NotNil(t, ctx.Data)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, err := newRequestContextFromURL(tt.urlStr, tt.method)
|
||||
tt.assert(t, ctx, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRequestIDUniqueness(t *testing.T) {
|
||||
ids := make(map[string]bool)
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
id, err := generateRequestID()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, id, 16)
|
||||
|
||||
assert.False(t, ids[id], "generateRequestID() produced duplicate ID: %s", id)
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ResponseAction determines how the proxy should handle a request
|
||||
type ResponseAction int
|
||||
|
||||
const (
|
||||
// ActionAllow forwards the request unchanged
|
||||
ActionAllow ResponseAction = iota
|
||||
|
||||
// ActionBlock blocks the request with an error response
|
||||
ActionBlock
|
||||
|
||||
// ActionModifyRequest modifies the request before forwarding
|
||||
ActionModifyRequest
|
||||
|
||||
// ActionModifyResponse modifies the response after receiving
|
||||
ActionModifyResponse
|
||||
)
|
||||
|
||||
// RequestContext provides request information to interceptors
|
||||
// This is passed to ShouldIntercept and HandleRequest methods
|
||||
type RequestContext struct {
|
||||
URL *url.URL
|
||||
Method string
|
||||
Headers http.Header
|
||||
|
||||
// Body is not currently used by the interceptors, but it is here for future use
|
||||
Body []byte
|
||||
|
||||
Hostname string
|
||||
RequestID string
|
||||
StartTime time.Time
|
||||
|
||||
// Interceptor can store custom data
|
||||
Data map[string]interface{}
|
||||
}
|
||||
|
||||
// InterceptorResponse defines how the proxy should handle the request
|
||||
type InterceptorResponse struct {
|
||||
// Action to take
|
||||
Action ResponseAction
|
||||
|
||||
// For Action = Block: error message to return
|
||||
BlockMessage string
|
||||
BlockCode int
|
||||
|
||||
// For Action = ModifyRequest: modified headers/body
|
||||
ModifiedHeaders http.Header
|
||||
|
||||
// ModifiedBody is not currently used by the interceptors, but it is here for future use
|
||||
ModifiedBody []byte
|
||||
|
||||
// For Action = ModifyResponse: response modification function
|
||||
ResponseModifier ResponseModifierFunc
|
||||
}
|
||||
|
||||
// ResponseModifierFunc modifies HTTP response
|
||||
// It receives the status code, headers, and body, and returns modified versions
|
||||
type ResponseModifierFunc func(statusCode int, headers http.Header, body []byte) (int, http.Header, []byte, error)
|
||||
|
||||
// Interceptor processes HTTP/HTTPS requests and can modify or block them
|
||||
type Interceptor interface {
|
||||
// Name returns the interceptor name for logging
|
||||
Name() string
|
||||
|
||||
// ShouldIntercept determines if this interceptor handles the given request
|
||||
ShouldIntercept(ctx *RequestContext) bool
|
||||
|
||||
// HandleRequest processes the request and returns response action
|
||||
// Called for each request matching ShouldIntercept
|
||||
HandleRequest(ctx *RequestContext) (*InterceptorResponse, error)
|
||||
}
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/elazarl/goproxy"
|
||||
"github.com/safedep/dry/log"
|
||||
"github.com/safedep/pmg/proxy/certmanager"
|
||||
)
|
||||
|
||||
// ProxyServer manages the proxy lifecycle
|
||||
type ProxyServer interface {
|
||||
// Start begins listening on the configured address
|
||||
Start() error
|
||||
|
||||
// Stop gracefully shuts down the proxy
|
||||
Stop(ctx context.Context) error
|
||||
|
||||
// Address returns the listening address (useful when using port 0)
|
||||
Address() string
|
||||
|
||||
// AddInterceptor registers an interceptor
|
||||
AddInterceptor(interceptor Interceptor) error
|
||||
|
||||
// RemoveInterceptor removes an interceptor by name
|
||||
RemoveInterceptor(name string)
|
||||
}
|
||||
|
||||
// ProxyConfig holds configuration for the proxy server
|
||||
type ProxyConfig struct {
|
||||
// Network configuration
|
||||
ListenAddr string
|
||||
|
||||
// TLS configuration
|
||||
CertManager certmanager.CertificateManager
|
||||
|
||||
// Interceptors
|
||||
Interceptors []Interceptor
|
||||
|
||||
// Other configuration
|
||||
EnableMITM bool
|
||||
RequestTimeout time.Duration
|
||||
ConnectTimeout time.Duration
|
||||
}
|
||||
|
||||
// DefaultProxyConfig returns a configuration with sensible defaults
|
||||
func DefaultProxyConfig() *ProxyConfig {
|
||||
return &ProxyConfig{
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
EnableMITM: true,
|
||||
ConnectTimeout: 30 * time.Second,
|
||||
RequestTimeout: 5 * time.Minute,
|
||||
Interceptors: []Interceptor{},
|
||||
}
|
||||
}
|
||||
|
||||
type proxyServer struct {
|
||||
config *ProxyConfig
|
||||
proxy *goproxy.ProxyHttpServer
|
||||
server *http.Server
|
||||
|
||||
listener net.Listener
|
||||
interceptors map[string]Interceptor
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var _ ProxyServer = &proxyServer{}
|
||||
|
||||
// goproxyLoggerWrapper implements the goproxy.Logger interface and bridges to the dry/log package
|
||||
type goproxyLoggerWrapper struct{}
|
||||
|
||||
func (l *goproxyLoggerWrapper) Printf(format string, v ...interface{}) {
|
||||
log.Debugf("[GOPROXY] "+format, v...)
|
||||
}
|
||||
|
||||
// NewProxyServer creates a new proxy server with the given configuration
|
||||
// using the goproxy library as the underlying proxy implementation
|
||||
func NewProxyServer(config *ProxyConfig) (ProxyServer, error) {
|
||||
if config == nil {
|
||||
config = DefaultProxyConfig()
|
||||
}
|
||||
|
||||
if config.EnableMITM && config.CertManager == nil {
|
||||
return nil, fmt.Errorf("cert manager is required when MITM is enabled")
|
||||
}
|
||||
|
||||
if config.ListenAddr == "" {
|
||||
config.ListenAddr = "127.0.0.1:0"
|
||||
}
|
||||
|
||||
proxy := goproxy.NewProxyHttpServer()
|
||||
proxy.Logger = &goproxyLoggerWrapper{}
|
||||
|
||||
// Set verbose to true for verbose logging.
|
||||
// Logging is handled by our own logger which has log level controls.
|
||||
proxy.Verbose = true
|
||||
|
||||
// Configure connection timeout for upstream connections during CONNECT requests
|
||||
proxy.ConnectDial = func(network, addr string) (net.Conn, error) {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: config.ConnectTimeout,
|
||||
}
|
||||
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
|
||||
ps := &proxyServer{
|
||||
config: config,
|
||||
proxy: proxy,
|
||||
interceptors: make(map[string]Interceptor),
|
||||
}
|
||||
|
||||
for _, interceptor := range config.Interceptors {
|
||||
if err := ps.AddInterceptor(interceptor); err != nil {
|
||||
return nil, fmt.Errorf("failed to add interceptor %s: %w", interceptor.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableMITM {
|
||||
ps.configureMITM()
|
||||
}
|
||||
|
||||
ps.registerHandlers()
|
||||
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func (ps *proxyServer) Start() error {
|
||||
listener, err := net.Listen("tcp", ps.config.ListenAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start listener: %w", err)
|
||||
}
|
||||
|
||||
ps.listener = listener
|
||||
|
||||
ps.server = &http.Server{
|
||||
Handler: ps.proxy,
|
||||
ReadTimeout: ps.config.RequestTimeout,
|
||||
WriteTimeout: ps.config.RequestTimeout,
|
||||
}
|
||||
|
||||
log.Debugf("Proxy server listening on %s", ps.Address())
|
||||
|
||||
go func() {
|
||||
if err := ps.server.Serve(ps.listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Errorf("Proxy server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ps *proxyServer) Stop(ctx context.Context) error {
|
||||
if ps.server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debugf("Shutting down proxy server...")
|
||||
|
||||
if err := ps.server.Shutdown(ctx); err != nil {
|
||||
return fmt.Errorf("failed to shutdown proxy server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ps *proxyServer) Address() string {
|
||||
if ps.listener == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ps.listener.Addr().String()
|
||||
}
|
||||
|
||||
func (ps *proxyServer) AddInterceptor(interceptor Interceptor) error {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
|
||||
if _, ok := ps.interceptors[interceptor.Name()]; ok {
|
||||
return fmt.Errorf("interceptor %s already registered", interceptor.Name())
|
||||
}
|
||||
|
||||
ps.interceptors[interceptor.Name()] = interceptor
|
||||
log.Debugf("Registered interceptor: %s", interceptor.Name())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ps *proxyServer) RemoveInterceptor(name string) {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
|
||||
delete(ps.interceptors, name)
|
||||
log.Debugf("Removed interceptor: %s", name)
|
||||
}
|
||||
|
||||
func (ps *proxyServer) configureMITM() {
|
||||
// Configure selective MITM based on interceptors
|
||||
ps.proxy.OnRequest().HandleConnect(goproxy.FuncHttpsHandler(func(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
|
||||
reqCtx, err := newRequestContextFromURL(host, "CONNECT")
|
||||
if err != nil {
|
||||
log.Errorf("Failed to parse CONNECT request for %s: %v", host, err)
|
||||
return goproxy.OkConnect, host
|
||||
}
|
||||
|
||||
ps.mu.RLock()
|
||||
shouldMITM := false
|
||||
for _, interceptor := range ps.interceptors {
|
||||
if interceptor.ShouldIntercept(reqCtx) {
|
||||
shouldMITM = true
|
||||
log.Debugf("[%s] Interceptor %s will handle %s", reqCtx.RequestID, interceptor.Name(), host)
|
||||
break
|
||||
}
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
|
||||
if shouldMITM {
|
||||
mitmAction := &goproxy.ConnectAction{
|
||||
Action: goproxy.ConnectMitm,
|
||||
TLSConfig: func(host string, ctx *goproxy.ProxyCtx) (*tls.Config, error) {
|
||||
hostname, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
hostname = host
|
||||
}
|
||||
|
||||
return ps.config.CertManager.GetTLSConfig(hostname)
|
||||
},
|
||||
}
|
||||
|
||||
return mitmAction, host
|
||||
}
|
||||
|
||||
// Tunnel without interception
|
||||
log.Debugf("[%s] Tunneling %s (no interceptor)", reqCtx.RequestID, host)
|
||||
return goproxy.OkConnect, host
|
||||
}))
|
||||
}
|
||||
|
||||
func (ps *proxyServer) registerHandlers() {
|
||||
ps.proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
|
||||
reqCtx, err := newRequestContext(req)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to create request context: %v", err)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
log.Debugf("[%s] %s %s", reqCtx.RequestID, req.Method, req.URL.String())
|
||||
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
|
||||
for _, interceptor := range ps.interceptors {
|
||||
if !interceptor.ShouldIntercept(reqCtx) {
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := interceptor.HandleRequest(reqCtx)
|
||||
if err != nil {
|
||||
log.Errorf("[%s] Interceptor %s error: %v", reqCtx.RequestID, interceptor.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch resp.Action {
|
||||
case ActionBlock:
|
||||
statusCode := resp.BlockCode
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusForbidden
|
||||
}
|
||||
|
||||
message := resp.BlockMessage
|
||||
if message == "" {
|
||||
message = "Blocked by proxy interceptor"
|
||||
}
|
||||
|
||||
log.Debugf("[%s] Blocked by %s: %s", reqCtx.RequestID, interceptor.Name(), req.URL.String())
|
||||
|
||||
return req, goproxy.NewResponse(req, goproxy.ContentTypeText, statusCode, message)
|
||||
|
||||
case ActionModifyRequest:
|
||||
if resp.ModifiedHeaders != nil {
|
||||
req.Header = resp.ModifiedHeaders
|
||||
}
|
||||
|
||||
log.Debugf("[%s] Request modified by %s", reqCtx.RequestID, interceptor.Name())
|
||||
|
||||
case ActionModifyResponse:
|
||||
ctx.UserData = resp.ResponseModifier
|
||||
log.Debugf("[%s] Response modifier registered by %s", reqCtx.RequestID, interceptor.Name())
|
||||
}
|
||||
}
|
||||
|
||||
return req, nil
|
||||
})
|
||||
|
||||
ps.proxy.OnResponse().DoFunc(func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {
|
||||
reqCtx, err := newRequestContext(ctx.Req)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to create request context: %v", err)
|
||||
return resp
|
||||
}
|
||||
|
||||
log.Debugf("[%s] Response received for %s", reqCtx.RequestID, ctx.Req.URL.String())
|
||||
|
||||
if resp == nil {
|
||||
return resp
|
||||
}
|
||||
|
||||
modifier, ok := ctx.UserData.(ResponseModifierFunc)
|
||||
if !ok || modifier == nil {
|
||||
return resp
|
||||
}
|
||||
|
||||
// TODO: Implement response body modification
|
||||
// This requires buffering the response body, modifying it, and creating a new response
|
||||
// For now, lets skip it
|
||||
|
||||
return resp
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user