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
}
+86
View File
@@ -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
}
+406
View File
@@ -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
}
}
+78
View File
@@ -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
View File
@@ -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
})
}