feat(ssl): Add LetsEncrypt certificate option
This commit is contained in:
@@ -12,4 +12,6 @@ FROM alpine:latest
|
|||||||
|
|
||||||
COPY --from=builder /app/pgproxy /usr/local/bin/pgproxy
|
COPY --from=builder /app/pgproxy /usr/local/bin/pgproxy
|
||||||
|
|
||||||
|
EXPOSE 5432
|
||||||
|
|
||||||
CMD ["pgproxy"]
|
CMD ["pgproxy"]
|
||||||
@@ -13,10 +13,21 @@ type Config struct {
|
|||||||
Address string `yaml:"address"`
|
Address string `yaml:"address"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
} `yaml:"listen"`
|
} `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 {
|
Mappings []struct {
|
||||||
External string `yaml:"external"`
|
External string `yaml:"external"`
|
||||||
Internal string `yaml:"internal"`
|
Internal string `yaml:"internal"`
|
||||||
Port int `yaml:"port"` // Optional, defaults to listen port
|
Port int `yaml:"port"` // Optional, defaults to 5432
|
||||||
} `yaml:"mappings"`
|
} `yaml:"mappings"`
|
||||||
Debug bool `yaml:"debug"`
|
Debug bool `yaml:"debug"`
|
||||||
}
|
}
|
||||||
|
|||||||
20
app/go.mod
20
app/go.mod
@@ -4,5 +4,25 @@ go 1.25.3
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
git.secnex.io/secnex/masterlog v0.1.0
|
git.secnex.io/secnex/masterlog v0.1.0
|
||||||
|
github.com/caddyserver/certmagic v0.25.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
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
|
||||||
|
)
|
||||||
|
|||||||
48
app/go.sum
48
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 h1:74j9CATpfeK0lxpWIQC9ag9083akwG8khi5BwLedD8E=
|
||||||
git.secnex.io/secnex/masterlog v0.1.0/go.mod h1:OnDlwEzdkKMnqY+G5O9kHdhoJ6fH1llbVdXpgSc5SdM=
|
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
125
app/proxy/cert.go
Normal file
125
app/proxy/cert.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
@@ -53,11 +54,65 @@ func (p *Proxy) handleConnection(clientConn net.Conn) {
|
|||||||
remoteAddr := clientConn.RemoteAddr().String()
|
remoteAddr := clientConn.RemoteAddr().String()
|
||||||
masterlog.Info("New connection", map[string]interface{}{"remoteAddr": remoteAddr})
|
masterlog.Info("New connection", map[string]interface{}{"remoteAddr": remoteAddr})
|
||||||
|
|
||||||
// Create peek connection to inspect first bytes
|
var hostname string
|
||||||
peekConn := newPeekConn(clientConn)
|
var peekConn *peekConn
|
||||||
|
|
||||||
// Try to extract hostname
|
// Create peek connection to inspect first bytes
|
||||||
hostname := p.extractHostname(peekConn)
|
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 (
|
var (
|
||||||
targetMapping struct {
|
targetMapping struct {
|
||||||
host string
|
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})
|
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
|
// Connect to backend PostgreSQL server
|
||||||
backendAddr := net.JoinHostPort(targetMapping.host, fmt.Sprintf("%d", targetMapping.port))
|
backendAddr := net.JoinHostPort(targetMapping.host, fmt.Sprintf("%d", targetMapping.port))
|
||||||
backendConn, err := net.DialTimeout("tcp", backendAddr, 10*time.Second)
|
backendConn, err := net.DialTimeout("tcp", backendAddr, 10*time.Second)
|
||||||
@@ -339,3 +404,48 @@ func (p *Proxy) extractHostnameFromPostgresStartup(conn *peekConn) string {
|
|||||||
|
|
||||||
return ""
|
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
|
||||||
|
}
|
||||||
|
|||||||
114
app/proxy/letsencrypt.go
Normal file
114
app/proxy/letsencrypt.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
"git.secnex.io/secnex/masterlog"
|
"git.secnex.io/secnex/masterlog"
|
||||||
"git.secnex.io/secnex/pgproxy/config"
|
"git.secnex.io/secnex/pgproxy/config"
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Proxy handles PostgreSQL connection proxying
|
// Proxy handles PostgreSQL connection proxying
|
||||||
@@ -16,16 +18,59 @@ type Proxy struct {
|
|||||||
host string
|
host string
|
||||||
port int
|
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
|
// NewProxy creates a new proxy instance
|
||||||
func NewProxy(config *config.Config) (*Proxy, error) {
|
func NewProxy(config *config.Config) (*Proxy, error) {
|
||||||
addr := fmt.Sprintf("%s:%d", config.Listen.Address, config.Listen.Port)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// Build mappings map for quick lookup
|
||||||
mappings := make(map[string]struct {
|
mappings := make(map[string]struct {
|
||||||
host string
|
host string
|
||||||
@@ -49,6 +94,8 @@ func NewProxy(config *config.Config) (*Proxy, error) {
|
|||||||
listener: listener,
|
listener: listener,
|
||||||
config: config,
|
config: config,
|
||||||
mappings: mappings,
|
mappings: mappings,
|
||||||
|
certmagic: certmagicConfig,
|
||||||
|
certificate: certificate,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
config.yaml
20
config.yaml
@@ -4,7 +4,21 @@
|
|||||||
# Listen address and port
|
# Listen address and port
|
||||||
listen:
|
listen:
|
||||||
address: "0.0.0.0"
|
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
|
debug: true
|
||||||
|
|
||||||
@@ -13,5 +27,7 @@ debug: true
|
|||||||
mappings:
|
mappings:
|
||||||
- external: "mytestserver"
|
- external: "mytestserver"
|
||||||
internal: "localhost"
|
internal: "localhost"
|
||||||
|
port: 5432
|
||||||
- external: "mytestserver2"
|
- external: "mytestserver2"
|
||||||
internal: "deploy.deinserver.co"
|
internal: "localhost"
|
||||||
|
port: 5431
|
||||||
64
generate-cert.sh
Normal file
64
generate-cert.sh
Normal file
@@ -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 <<EOF
|
||||||
|
[req]
|
||||||
|
distinguished_name = req_distinguished_name
|
||||||
|
req_extensions = v3_req
|
||||||
|
|
||||||
|
[v3_req]
|
||||||
|
subjectAltName = @alt_names
|
||||||
|
|
||||||
|
[alt_names]
|
||||||
|
EOF
|
||||||
|
for i in "${!HOSTNAMES[@]}"; do
|
||||||
|
echo "DNS.$((i+1)) = ${HOSTNAMES[$i]}"
|
||||||
|
done
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up CSR
|
||||||
|
rm certs/server.csr
|
||||||
|
|
||||||
|
echo "Certificate generated successfully!"
|
||||||
|
echo "Certificate: certs/server.crt"
|
||||||
|
echo "Private key: certs/server.key"
|
||||||
|
echo ""
|
||||||
|
echo "Add to your config.yaml:"
|
||||||
|
echo "tls:"
|
||||||
|
echo " enabled: true"
|
||||||
|
echo " cert_file: /path/to/certs/server.crt"
|
||||||
|
echo " key_file: /path/to/certs/server.key"
|
||||||
|
|
||||||
Reference in New Issue
Block a user