feat(ssl): Add LetsEncrypt certificate option
This commit is contained in:
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user