diff --git a/docs/proxy.md b/docs/proxy.md new file mode 100644 index 0000000..6aa7416 --- /dev/null +++ b/docs/proxy.md @@ -0,0 +1,88 @@ +# PMG Proxy + +A generic, extensible HTTP/HTTPS proxy server with man-in-the-middle (MITM) capabilities for intercepting and analyzing package manager traffic. +Built with [goproxy](https://github.com/elazarl/goproxy) library. + +## Features + +- Selective interception of HTTPS traffic +- Pluggable interceptors for different use cases +- Certificate generation and management for HTTPS interception (MITM) + +## Architecture + +```mermaid +flowchart TD + A[Package Manager] + A -- HTTPS_PROXY --> B[Proxy Server] + B --> C{Match?} + C -- Yes --> D[MITM] + D --> E[Inspect] + C -- No --> F[TCP Tunnel] + subgraph inside_proxy [ ] + G[Interceptor
Chain] + end + B --> inside_proxy +``` + +## Example + +See [examples/proxy](../examples/proxy/README.md) for a complete example. + +## Quick Start + +```go +package main + +import ( + "github.com/safedep/pmg/proxy" + "github.com/safedep/pmg/proxy/certmanager" +) + +func main() { + // Generate CA certificate + caCert, _ := certmanager.GenerateCA(certmanager.DefaultCertManagerConfig()) + + // Create certificate manager + certMgr, _ := certmanager.NewCertificateManagerWithCA(caCert, certmanager.DefaultCertManagerConfig()) + + // Create proxy + proxyServer, _ := proxy.NewProxyServer(&proxy.ProxyConfig{ + ListenAddr: "127.0.0.1:8888", + CertManager: certMgr, + EnableMITM: true, + Interceptors: []proxy.Interceptor{NewMyInterceptor()}, + }) + + // Start proxy + proxyServer.Start() + + // ... wait for shutdown signal ... + + proxyServer.Stop(context.Background()) +} +``` + +## Certificate Manager + +The `certmanager` package provides certificate generation and caching. + +### Usage + +```go +import "github.com/safedep/pmg/proxy/certmanager" + +// Generate a new CA certificate +config := certmanager.DefaultCertManagerConfig() +caCert, err := certmanager.GenerateCA(config) + +// Handle persistence (example) +os.WriteFile("ca-cert.pem", caCert.Certificate, 0644) + +// Create certificate manager with CA +certMgr, err := certmanager.NewCertificateManagerWithCA(caCert, config) + +// Generate host certificates (automatically cached) +hostCert, err := certMgr.GenerateCertForHost("registry.npmjs.org") +``` + diff --git a/examples/proxy/.gitignore b/examples/proxy/.gitignore new file mode 100644 index 0000000..996bd0d --- /dev/null +++ b/examples/proxy/.gitignore @@ -0,0 +1,2 @@ +ca-cert.pem +proxy diff --git a/examples/proxy/README.md b/examples/proxy/README.md new file mode 100644 index 0000000..01e6784 --- /dev/null +++ b/examples/proxy/README.md @@ -0,0 +1,43 @@ +# Proxy Example: HTTPS Logging + +This example demonstrates using the PMG proxy server to intercept and log HTTPS requests to package registries. + +## Usage + +### Build and Run + +```bash +cd examples/proxy +go run . +``` + +### Configure Your Environment + +Open a new terminal and configure the proxy: + +```bash +export HTTPS_PROXY=http://127.0.0.1:8888 +export NODE_EXTRA_CA_CERTS=./ca-cert.pem +export SSL_CERT_FILE=./ca-cert.pem +export PIP_CERT=./ca-cert.pem +export REQUESTS_CA_BUNDLE=./ca-cert.pem +export PIP_PROXY=http://127.0.0.1:8888 +``` + +### Test with Package Managers + +Test with `npm`: + +```bash +npm --no-cache --prefer-online install express +``` + +Test with `pip`: + +```bash +pip3 --proxy http://127.0.0.1:8888 index versions requests +``` + +```bash +pip3 install --proxy http://127.0.0.1:8888 --no-cache-dir requests +``` diff --git a/examples/proxy/logging_interceptor.go b/examples/proxy/logging_interceptor.go new file mode 100644 index 0000000..ee3622e --- /dev/null +++ b/examples/proxy/logging_interceptor.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "time" + + "github.com/safedep/pmg/proxy" +) + +type loggingInterceptor struct { + domains []string +} + +func newLoggingInterceptor() *loggingInterceptor { + return &loggingInterceptor{ + domains: []string{ + "registry.npmjs.org", + "registry.yarnpkg.com", + "pypi.org", + "files.pythonhosted.org", + }, + } +} + +func (li *loggingInterceptor) Name() string { + return "logging-interceptor" +} + +func (li *loggingInterceptor) ShouldIntercept(ctx *proxy.RequestContext) bool { + for _, domain := range li.domains { + if ctx.Hostname == domain { + return true + } + } + + return false +} + +func (li *loggingInterceptor) HandleRequest(ctx *proxy.RequestContext) (*proxy.InterceptorResponse, error) { + fmt.Printf("LOGGING INTERCEPTOR: [%s] %s %s %s\n", + ctx.StartTime.Format(time.RFC3339), + ctx.RequestID, + ctx.Method, + ctx.URL.String(), + ) + + return &proxy.InterceptorResponse{ + Action: proxy.ActionAllow, + }, nil +} diff --git a/examples/proxy/main.go b/examples/proxy/main.go new file mode 100644 index 0000000..a7bcff6 --- /dev/null +++ b/examples/proxy/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/safedep/dry/log" + "github.com/safedep/pmg/proxy" + "github.com/safedep/pmg/proxy/certmanager" +) + +const ( + listenAddr = "127.0.0.1:8888" + connectTimeout = 30 * time.Second + requestTimeout = 5 * time.Minute +) + +func main() { + log.InitZapLogger("proxy-example", "dev") + + fmt.Println("Generating CA certificate...") + + caConfig := certmanager.DefaultCertManagerConfig() + caCert, err := certmanager.GenerateCA(caConfig) + if err != nil { + log.Fatalf("Failed to generate CA: %v", err) + } + + // Save CA cert for use with clients + if err := os.WriteFile("ca-cert.pem", caCert.Certificate, 0644); err != nil { + log.Fatalf("Failed to save CA cert: %v", err) + } + + fmt.Println("✓ CA certificate saved to ca-cert.pem") + fmt.Println() + fmt.Println("To trust this CA:") + fmt.Println(" Node.js: export NODE_EXTRA_CA_CERTS=./ca-cert.pem") + fmt.Println(" Python: export SSL_CERT_FILE=./ca-cert.pem") + fmt.Println(" System: Add ca-cert.pem to your OS trust store") + fmt.Println() + + // Create certificate manager with CA certificate for use with proxy + certMgr, err := certmanager.NewCertificateManagerWithCA(caCert, caConfig) + if err != nil { + log.Fatalf("Failed to create cert manager: %v", err) + } + + // Create proxy with certificate manager and logging interceptor + proxyConfig := &proxy.ProxyConfig{ + ListenAddr: listenAddr, + CertManager: certMgr, + EnableMITM: true, + Interceptors: []proxy.Interceptor{newLoggingInterceptor()}, + ConnectTimeout: connectTimeout, + RequestTimeout: requestTimeout, + } + + proxyServer, err := proxy.NewProxyServer(proxyConfig) + if err != nil { + log.Fatalf("Failed to create proxy server: %v", err) + } + + // Start proxy + if err := proxyServer.Start(); err != nil { + log.Fatalf("Failed to start proxy: %v", err) + } + + fmt.Printf("Proxy listening on %s\n", proxyServer.Address()) + fmt.Printf("Configure clients with: export HTTPS_PROXY=http://%s\n", listenAddr) + fmt.Println() + fmt.Println("Press Ctrl+C to stop") + fmt.Println() + + // Wait for interrupt signal to gracefully shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + <-sigChan + + // Graceful shutdown + fmt.Println("\nShutting down proxy...") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := proxyServer.Stop(ctx); err != nil { + log.Errorf("Error during shutdown: %v", err) + } + + fmt.Println("Proxy stopped") +} diff --git a/go.mod b/go.mod index d7986f9..e618d06 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( buf.build/gen/go/safedep/api/grpc/go v1.5.1-20250418165058-162f6b0cc319.2 buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.6-20250418165058-162f6b0cc319.1 github.com/Masterminds/semver v1.5.0 + github.com/elazarl/goproxy v1.7.2 github.com/fatih/color v1.18.0 github.com/google/osv-scalibr v0.2.1 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 014a2c8..b6f34ee 100644 --- a/go.sum +++ b/go.sum @@ -97,6 +97,8 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42 github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= diff --git a/proxy/certmanager/cache.go b/proxy/certmanager/cache.go new file mode 100644 index 0000000..6164158 --- /dev/null +++ b/proxy/certmanager/cache.go @@ -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) +} diff --git a/proxy/certmanager/certmanager.go b/proxy/certmanager/certmanager.go new file mode 100644 index 0000000..78a9117 --- /dev/null +++ b/proxy/certmanager/certmanager.go @@ -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 +} diff --git a/proxy/certmanager/certmanager_test.go b/proxy/certmanager/certmanager_test.go new file mode 100644 index 0000000..59c9475 --- /dev/null +++ b/proxy/certmanager/certmanager_test.go @@ -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") + } + }) +} diff --git a/proxy/certmanager/generator.go b/proxy/certmanager/generator.go new file mode 100644 index 0000000..720ec0e --- /dev/null +++ b/proxy/certmanager/generator.go @@ -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 +} diff --git a/proxy/context.go b/proxy/context.go new file mode 100644 index 0000000..6b996bf --- /dev/null +++ b/proxy/context.go @@ -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 +} diff --git a/proxy/context_test.go b/proxy/context_test.go new file mode 100644 index 0000000..66bc7ae --- /dev/null +++ b/proxy/context_test.go @@ -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 + } +} diff --git a/proxy/interceptor.go b/proxy/interceptor.go new file mode 100644 index 0000000..0b3dc4f --- /dev/null +++ b/proxy/interceptor.go @@ -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) +} diff --git a/proxy/proxy.go b/proxy/proxy.go new file mode 100644 index 0000000..893a342 --- /dev/null +++ b/proxy/proxy.go @@ -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 + }) +}