From eec632ff972910098245ad24786da42c8337e273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Benouarets?= Date: Tue, 16 Dec 2025 14:15:16 +0100 Subject: [PATCH] feat(ssl): Add LetsEncrypt certificate option --- Dockerfile | 2 + app/config/config.go | 13 +++- app/go.mod | 20 +++++++ app/go.sum | 48 +++++++++++++++ app/proxy/cert.go | 125 +++++++++++++++++++++++++++++++++++++++ app/proxy/conn.go | 118 ++++++++++++++++++++++++++++++++++-- app/proxy/letsencrypt.go | 114 +++++++++++++++++++++++++++++++++++ app/proxy/proxy.go | 55 +++++++++++++++-- config.yaml | 20 ++++++- generate-cert.sh | 64 ++++++++++++++++++++ 10 files changed, 568 insertions(+), 11 deletions(-) create mode 100644 app/proxy/cert.go create mode 100644 app/proxy/letsencrypt.go create mode 100644 generate-cert.sh diff --git a/Dockerfile b/Dockerfile index 27edb22..1af2d55 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,4 +12,6 @@ FROM alpine:latest COPY --from=builder /app/pgproxy /usr/local/bin/pgproxy +EXPOSE 5432 + CMD ["pgproxy"] \ No newline at end of file diff --git a/app/config/config.go b/app/config/config.go index 7cf9591..0f7d9c9 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -13,10 +13,21 @@ type Config struct { Address string `yaml:"address"` Port int `yaml:"port"` } `yaml:"listen"` + TLS struct { + Enabled bool `yaml:"enabled"` + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` + LetsEncrypt struct { + Enabled bool `yaml:"enabled"` + Email string `yaml:"email"` // Email for Let's Encrypt registration + CacheDir string `yaml:"cache_dir"` // Directory to store certificates (default: ./certs/letsencrypt) + Staging bool `yaml:"staging"` // Use Let's Encrypt staging environment + } `yaml:"letsencrypt"` + } `yaml:"tls"` Mappings []struct { External string `yaml:"external"` Internal string `yaml:"internal"` - Port int `yaml:"port"` // Optional, defaults to listen port + Port int `yaml:"port"` // Optional, defaults to 5432 } `yaml:"mappings"` Debug bool `yaml:"debug"` } diff --git a/app/go.mod b/app/go.mod index 43641a8..1b1c387 100644 --- a/app/go.mod +++ b/app/go.mod @@ -4,5 +4,25 @@ go 1.25.3 require ( git.secnex.io/secnex/masterlog v0.1.0 + github.com/caddyserver/certmagic v0.25.0 gopkg.in/yaml.v3 v3.0.1 ) + +require ( + github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/libdns/libdns v1.1.1 // indirect + github.com/mholt/acmez/v3 v3.1.3 // indirect + github.com/miekg/dns v1.1.68 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.35.0 // indirect +) diff --git a/app/go.sum b/app/go.sum index eda6617..0deba7f 100644 --- a/app/go.sum +++ b/app/go.sum @@ -1,5 +1,53 @@ git.secnex.io/secnex/masterlog v0.1.0 h1:74j9CATpfeK0lxpWIQC9ag9083akwG8khi5BwLedD8E= git.secnex.io/secnex/masterlog v0.1.0/go.mod h1:OnDlwEzdkKMnqY+G5O9kHdhoJ6fH1llbVdXpgSc5SdM= +github.com/caddyserver/certmagic v0.25.0 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx63i+F5ic= +github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA= +github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= +github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/mholt/acmez/v3 v3.1.3 h1:gUl789rjbJSuM5hYzOFnNaGgWPV1xVfnOs59o0dZEcc= +github.com/mholt/acmez/v3 v3.1.3/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= +github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= +github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/app/proxy/cert.go b/app/proxy/cert.go new file mode 100644 index 0000000..a9461d6 --- /dev/null +++ b/app/proxy/cert.go @@ -0,0 +1,125 @@ +package proxy + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "time" + + "git.secnex.io/secnex/masterlog" + "git.secnex.io/secnex/pgproxy/config" +) + +// generateSelfSignedCert generates a self-signed certificate for the given hostnames +func generateSelfSignedCert(hostnames []string) (tls.Certificate, error) { + // Generate private key + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return tls.Certificate{}, err + } + + // Create certificate template + notBefore := time.Now() + notAfter := notBefore.Add(365 * 24 * time.Hour * 10) // Valid for 10 years + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return tls.Certificate{}, err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"PostgreSQL Proxy"}, + Country: []string{"DE"}, + Province: []string{""}, + Locality: []string{""}, + StreetAddress: []string{""}, + PostalCode: []string{""}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + // Set CommonName to first hostname + if len(hostnames) > 0 { + template.Subject.CommonName = hostnames[0] + } + + // Add all hostnames as DNS names (SAN) + template.DNSNames = hostnames + + // Create certificate + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) + if err != nil { + return tls.Certificate{}, err + } + + // Parse certificate + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return tls.Certificate{}, err + } + + // Create TLS certificate + return tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: privKey, + Leaf: cert, + }, nil +} + +// getCertificateForMappings generates or loads a certificate for the given mappings +func getCertificateForMappings(config *config.Config) (tls.Certificate, error) { + // If certificate files are specified, try to load them + if config.TLS.CertFile != "" && config.TLS.KeyFile != "" { + cert, err := tls.LoadX509KeyPair(config.TLS.CertFile, config.TLS.KeyFile) + if err == nil { + masterlog.Info("Loaded TLS certificate from files", map[string]interface{}{ + "certFile": config.TLS.CertFile, + "keyFile": config.TLS.KeyFile, + }) + return cert, nil + } + // If loading failed, log warning and fall through to generate + masterlog.Info("Failed to load TLS certificate, generating self-signed certificate", map[string]interface{}{ + "error": err, + "certFile": config.TLS.CertFile, + "keyFile": config.TLS.KeyFile, + }) + } + + // Generate self-signed certificate for all external hostnames + hostnames := make([]string, 0, len(config.Mappings)) + for _, mapping := range config.Mappings { + hostnames = append(hostnames, mapping.External) + } + + // If no hostnames, use a default + if len(hostnames) == 0 { + hostnames = []string{"localhost"} + } + + masterlog.Info("Generating self-signed certificate", map[string]interface{}{ + "hostnames": hostnames, + }) + + cert, err := generateSelfSignedCert(hostnames) + if err != nil { + return tls.Certificate{}, err + } + + masterlog.Info("Generated self-signed certificate", map[string]interface{}{ + "hostnames": hostnames, + "validUntil": cert.Leaf.NotAfter.Format(time.RFC3339), + }) + + return cert, nil +} diff --git a/app/proxy/conn.go b/app/proxy/conn.go index 6ad646e..8c0cd78 100644 --- a/app/proxy/conn.go +++ b/app/proxy/conn.go @@ -1,6 +1,7 @@ package proxy import ( + "crypto/tls" "fmt" "io" "net" @@ -53,11 +54,65 @@ func (p *Proxy) handleConnection(clientConn net.Conn) { remoteAddr := clientConn.RemoteAddr().String() masterlog.Info("New connection", map[string]interface{}{"remoteAddr": remoteAddr}) - // Create peek connection to inspect first bytes - peekConn := newPeekConn(clientConn) + var hostname string + var peekConn *peekConn - // Try to extract hostname - hostname := p.extractHostname(peekConn) + // Create peek connection to inspect first bytes + tempPeekConn := newPeekConn(clientConn) + + // Check if this is a TLS handshake (first byte is 0x16) + firstByte, err := tempPeekConn.peek(1) + if err != nil { + masterlog.Error("Failed to peek connection", map[string]interface{}{"error": err, "remoteAddr": remoteAddr}) + return + } + + // If TLS is enabled and this looks like a TLS handshake, upgrade to TLS + if p.config.TLS.Enabled && len(firstByte) > 0 && firstByte[0] == 0x16 { + // Get TLS config + var tlsConfig *tls.Config + if p.certmagic != nil { + // Use Let's Encrypt certmagic + tlsConfig = createTLSConfigWithLetsEncrypt(p.certmagic) + } else if p.certificate != nil { + // Use stored certificate for regular TLS + tlsConfig = &tls.Config{ + Certificates: []tls.Certificate{*p.certificate}, + ClientAuth: tls.NoClientCert, + } + } else { + // Fallback: get certificate on demand + cert, err := getCertificateForMappings(p.config) + if err != nil { + masterlog.Error("Failed to get TLS certificate", map[string]interface{}{"error": err}) + return + } + tlsConfig = &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientAuth: tls.NoClientCert, + } + } + + // Upgrade connection to TLS + tlsConn := tls.Server(clientConn, tlsConfig) + if err := tlsConn.Handshake(); err != nil { + masterlog.Error("TLS handshake failed", map[string]interface{}{"error": err, "remoteAddr": remoteAddr}) + return + } + + // Extract SNI from connection state + state := tlsConn.ConnectionState() + if state.ServerName != "" { + hostname = state.ServerName + masterlog.Info("Extracted hostname from TLS SNI", map[string]interface{}{"hostname": hostname}) + } + peekConn = newPeekConn(tlsConn) + } else { + // Non-TLS connection + peekConn = tempPeekConn + // Try to extract hostname from raw connection (won't work for non-TLS, but that's OK) + hostname = p.extractHostname(peekConn) + } var ( targetMapping struct { host string @@ -94,6 +149,16 @@ func (p *Proxy) handleConnection(clientConn net.Conn) { masterlog.Info("Proxying", map[string]interface{}{"hostname": hostname, "host": targetMapping.host, "port": targetMapping.port}) + // If TLS is enabled, handle PostgreSQL SSL request + // After TLS handshake, PostgreSQL clients may send an SSL request + // We respond with 'S' (SSL supported) since encryption is already handled by TLS + if p.config.TLS.Enabled { + if err := p.handlePostgresSSLRequest(peekConn); err != nil { + masterlog.Error("Failed to handle PostgreSQL SSL request", map[string]interface{}{"error": err}) + return + } + } + // Connect to backend PostgreSQL server backendAddr := net.JoinHostPort(targetMapping.host, fmt.Sprintf("%d", targetMapping.port)) backendConn, err := net.DialTimeout("tcp", backendAddr, 10*time.Second) @@ -339,3 +404,48 @@ func (p *Proxy) extractHostnameFromPostgresStartup(conn *peekConn) string { return "" } + +// handlePostgresSSLRequest handles PostgreSQL SSL request after TLS handshake +// PostgreSQL clients may send an SSL request (0x00 0x00 0x00 0x08 0x04 0xD2 0x16 0x2F) +// We respond with 'S' (0x53) to indicate SSL is supported, since encryption is already handled by TLS +func (p *Proxy) handlePostgresSSLRequest(conn *peekConn) error { + // Peek at first 8 bytes to check for SSL request + peekBuf, err := conn.peek(8) + if err != nil { + // If we can't peek, it's not an SSL request, continue normally + return nil + } + + // PostgreSQL SSL request: 4 bytes length (0x00 0x00 0x00 0x08) + 4 bytes code (0x04 0xD2 0x16 0x2F) + sslRequest := []byte{0x00, 0x00, 0x00, 0x08, 0x04, 0xD2, 0x16, 0x2F} + + if len(peekBuf) >= 8 { + isSSLRequest := true + for i := 0; i < 8; i++ { + if peekBuf[i] != sslRequest[i] { + isSSLRequest = false + break + } + } + + if isSSLRequest { + // Consume the SSL request (read it from the connection) + buf := make([]byte, 8) + _, err := io.ReadFull(conn, buf) + if err != nil { + return fmt.Errorf("failed to read SSL request: %w", err) + } + + // Respond with 'S' (0x53) - SSL supported + // Since encryption is already handled by TLS termination, we signal SSL support + _, err = conn.Write([]byte{'S'}) + if err != nil { + return fmt.Errorf("failed to write SSL response: %w", err) + } + + masterlog.Info("Handled PostgreSQL SSL request", map[string]interface{}{"response": "S", "reason": "TLS already enabled"}) + } + } + + return nil +} diff --git a/app/proxy/letsencrypt.go b/app/proxy/letsencrypt.go new file mode 100644 index 0000000..9da4c3f --- /dev/null +++ b/app/proxy/letsencrypt.go @@ -0,0 +1,114 @@ +package proxy + +import ( + "context" + "crypto/tls" + "fmt" + "os" + + "git.secnex.io/secnex/masterlog" + "git.secnex.io/secnex/pgproxy/config" + "github.com/caddyserver/certmagic" +) + +// setupLetsEncrypt configures certmagic for Let's Encrypt certificate management +func setupLetsEncrypt(config *config.Config) (*certmagic.Config, error) { + // Get hostnames from mappings + hostnames := make([]string, 0, len(config.Mappings)) + for _, mapping := range config.Mappings { + hostnames = append(hostnames, mapping.External) + } + + if len(hostnames) == 0 { + return nil, fmt.Errorf("no hostnames configured for Let's Encrypt") + } + + // Set cache directory + cacheDir := config.TLS.LetsEncrypt.CacheDir + if cacheDir == "" { + cacheDir = "./certs/letsencrypt" + } + + // Ensure cache directory exists + if err := os.MkdirAll(cacheDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create cache directory: %w", err) + } + + // Configure certmagic + magic := certmagic.NewDefault() + magic.Storage = &certmagic.FileStorage{Path: cacheDir} + + // Set email for Let's Encrypt registration + email := config.TLS.LetsEncrypt.Email + if email == "" { + // Use a default email if not provided + email = "admin@" + hostnames[0] + masterlog.Info("Using default email for Let's Encrypt", map[string]interface{}{ + "email": email, + }) + } + + // Configure ACME issuer + acmeIssuer := certmagic.NewACMEIssuer(magic, certmagic.ACMEIssuer{ + Email: email, + Agreed: true, + }) + + // Use staging environment if configured + if config.TLS.LetsEncrypt.Staging { + acmeIssuer.CA = certmagic.LetsEncryptStagingCA + masterlog.Info("Using Let's Encrypt staging environment", map[string]interface{}{}) + } else { + acmeIssuer.CA = certmagic.LetsEncryptProductionCA + } + + magic.Issuers = []certmagic.Issuer{acmeIssuer} + + // Obtain certificates for all hostnames + masterlog.Info("Obtaining Let's Encrypt certificates", map[string]interface{}{ + "hostnames": hostnames, + "email": email, + "staging": config.TLS.LetsEncrypt.Staging, + }) + + // Obtain certificates synchronously (this will block until certificates are obtained) + ctx := context.Background() + err := magic.ManageSync(ctx, hostnames) + if err != nil { + return nil, fmt.Errorf("failed to obtain Let's Encrypt certificates: %w", err) + } + + masterlog.Info("Successfully obtained Let's Encrypt certificates", map[string]interface{}{ + "hostnames": hostnames, + }) + + return magic, nil +} + +// getCertificateForMappingsWithLetsEncrypt gets certificates using Let's Encrypt +func getCertificateForMappingsWithLetsEncrypt(config *config.Config) (*certmagic.Config, error) { + magic, err := setupLetsEncrypt(config) + if err != nil { + return nil, err + } + + return magic, nil +} + +// createTLSConfigWithLetsEncrypt creates a TLS config that uses certmagic for certificate management +func createTLSConfigWithLetsEncrypt(magic *certmagic.Config) *tls.Config { + return &tls.Config{ + GetCertificate: func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { + cert, err := magic.GetCertificate(clientHello) + if err != nil { + masterlog.Error("Failed to get certificate from Let's Encrypt", map[string]interface{}{ + "error": err, + "serverName": clientHello.ServerName, + }) + return nil, err + } + return cert, nil + }, + ClientAuth: tls.NoClientCert, + } +} diff --git a/app/proxy/proxy.go b/app/proxy/proxy.go index 27c1fcc..252c7df 100644 --- a/app/proxy/proxy.go +++ b/app/proxy/proxy.go @@ -1,11 +1,13 @@ package proxy import ( + "crypto/tls" "fmt" "net" "git.secnex.io/secnex/masterlog" "git.secnex.io/secnex/pgproxy/config" + "github.com/caddyserver/certmagic" ) // Proxy handles PostgreSQL connection proxying @@ -16,16 +18,59 @@ type Proxy struct { host string port int } + certmagic *certmagic.Config // For Let's Encrypt certificate management + certificate *tls.Certificate // For regular TLS (non-Let's Encrypt) } // NewProxy creates a new proxy instance func NewProxy(config *config.Config) (*Proxy, error) { addr := fmt.Sprintf("%s:%d", config.Listen.Address, config.Listen.Port) - listener, err := net.Listen("tcp", addr) + var listener net.Listener + var err error + + var certmagicConfig *certmagic.Config + var certificate *tls.Certificate + + // Always create a TCP listener (not TLS listener) + // We'll handle TLS in handleConnection to support both TLS and non-TLS connections + tcpListener, err := net.Listen("tcp", addr) if err != nil { return nil, err } + // If TLS is enabled, prepare certificates but don't wrap listener in TLS + // This allows both TLS and non-TLS connections + if config.TLS.Enabled { + // Check if Let's Encrypt is enabled + if config.TLS.LetsEncrypt.Enabled { + // Setup Let's Encrypt + certmagicConfig, err = getCertificateForMappingsWithLetsEncrypt(config) + if err != nil { + return nil, fmt.Errorf("failed to setup Let's Encrypt: %w", err) + } + + masterlog.Info("TLS enabled with Let's Encrypt (optional)", map[string]interface{}{ + "email": config.TLS.LetsEncrypt.Email, + "staging": config.TLS.LetsEncrypt.Staging, + "cacheDir": config.TLS.LetsEncrypt.CacheDir, + }) + } else { + // Get certificate (load from files or generate self-signed) + cert, err := getCertificateForMappings(config) + if err != nil { + return nil, fmt.Errorf("failed to get TLS certificate: %w", err) + } + certificate = &cert + + masterlog.Info("TLS enabled (optional)", map[string]interface{}{ + "certFile": config.TLS.CertFile, + "keyFile": config.TLS.KeyFile, + }) + } + } + + listener = tcpListener + // Build mappings map for quick lookup mappings := make(map[string]struct { host string @@ -46,9 +91,11 @@ func NewProxy(config *config.Config) (*Proxy, error) { } return &Proxy{ - listener: listener, - config: config, - mappings: mappings, + listener: listener, + config: config, + mappings: mappings, + certmagic: certmagicConfig, + certificate: certificate, }, nil } diff --git a/config.yaml b/config.yaml index 5895b7f..9a4612e 100644 --- a/config.yaml +++ b/config.yaml @@ -4,7 +4,21 @@ # Listen address and port listen: address: "0.0.0.0" - port: 5432 + port: 5400 + +# TLS configuration +# If enabled is true but cert_file/key_file are not specified or not found, +# a self-signed certificate will be automatically generated for all external hostnames +tls: + enabled: true + # cert_file: "/path/to/cert.pem" # Optional: path to certificate file + # key_file: "/path/to/key.pem" # Optional: path to private key file + # Let's Encrypt configuration (automatic certificate management) + letsencrypt: + enabled: false # Set to true to use Let's Encrypt + email: "admin@secnex.io" # Email for Let's Encrypt registration + cache_dir: "./certs/letsencrypt" # Directory to store certificates (default: ./certs/letsencrypt) + staging: true # Use Let's Encrypt staging environment for testing debug: true @@ -13,5 +27,7 @@ debug: true mappings: - external: "mytestserver" internal: "localhost" + port: 5432 - external: "mytestserver2" - internal: "deploy.deinserver.co" \ No newline at end of file + internal: "localhost" + port: 5431 \ No newline at end of file diff --git a/generate-cert.sh b/generate-cert.sh new file mode 100644 index 0000000..f273cb3 --- /dev/null +++ b/generate-cert.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +# Generate self-signed certificate for PostgreSQL proxy +# Usage: ./generate-cert.sh [hostname1] [hostname2] ... + +HOSTNAMES=("dev.db.deinserver.co" "tst.db.deinserver.co" "prd.db.deinserver.co") + +# Add additional hostnames from command line arguments +if [ $# -gt 0 ]; then + HOSTNAMES=("$@") +fi + +# Create certs directory if it doesn't exist +mkdir -p certs + +# Build subject alternative names (SAN) +SAN="" +for hostname in "${HOSTNAMES[@]}"; do + if [ -n "$SAN" ]; then + SAN="${SAN},DNS:${hostname}" + else + SAN="DNS:${hostname}" + fi +done + +echo "Generating self-signed certificate for: ${HOSTNAMES[*]}" +echo "SAN: ${SAN}" + +# Generate private key +openssl genrsa -out certs/server.key 2048 + +# Generate certificate signing request +openssl req -new -key certs/server.key -out certs/server.csr -subj "/CN=${HOSTNAMES[0]}" -addext "subjectAltName=${SAN}" + +# Generate self-signed certificate (valid for 10 years) +openssl x509 -req -days 3650 -in certs/server.csr -signkey certs/server.key -out certs/server.crt -extensions v3_req -extfile <( +cat <