commit c6b269cf35f75f0eda926733ea06e76e274929bd Author: Björn Benouarets Date: Mon Dec 15 14:13:43 2025 +0100 init: Initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..27edb22 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.25.3-alpine AS builder + +WORKDIR /app + +COPY app/go.mod app/go.sum ./ +RUN go mod download && go mod verify + +COPY app/ ./ +RUN go build -o pgproxy main.go + +FROM alpine:latest + +COPY --from=builder /app/pgproxy /usr/local/bin/pgproxy + +CMD ["pgproxy"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a269cbb --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# Reverse Proxy Service for PostgreSQL + +This service will forward requests to a PostgreSQL server like Nginx does for HTTP. + +## Project Structure + +``` +pgproxy/ +├── app/ +│ ├── main.go # Main application entry point +│ ├── config/ +│ │ └── config.go # Configuration loading and parsing +│ ├── proxy/ +│ │ └── proxy.go # Main proxy logic +│ └── utils/ +│ └── env.go # Environment variable utilities +├── config.yaml # Configuration file +├── go.mod # Go module definition +└── README.md # This file + +``` + +## Configuration + +The proxy is configured via a YAML configuration file (`config.yaml`): + +```yaml +# Listen address and port +listen: + address: "0.0.0.0" + port: 5400 + +debug: true # Set to true to enable debug logging + +# Hostname mappings +# External hostname -> Internal hostname and port +mappings: + - external: "host1.example.com" + internal: "host1.example.internal" + port: 5432 # Optional, defaults to 5432 if not specified + - external: "host2.example.com" + internal: "host2.example.internal" + port: 5432 # Optional, defaults to 5432 if not specified +``` + +### Environment Variables + +- `CONFIG_PATH`: Path to the configuration file. Defaults to `config.yaml` if not set. + +## Usage + +1. Configure the `config.yaml` file with your hostname mappings +2. Install dependencies: `go mod tidy` +3. Run the proxy: `go run cmd/pgproxy/main.go [config.yaml]` + - If no config file is specified, it defaults to `config.yaml` in the current directory + +## Example + +All DNS records are pointing to this service. + +- `postgres://user:password@host1.example.com:5400/database` -> `postgres://user:password@host1.example.internal:5432/database` +- `postgres://user:password@host2.example.com:5400/database` -> `postgres://user:password@host2.example.internal:5432/database` + +## How it works + +The proxy extracts the hostname from incoming connections using TLS SNI (Server Name Indication) for TLS-encrypted connections. For non-TLS connections, if only one mapping is configured, it uses that as the default backend. It then maps the external hostname to the internal hostname according to the configuration and forwards the connection to the appropriate backend PostgreSQL server. + +## Roadmap + +### Short-term (Next Release) +- [ ] **Connection Pooling**: Implement connection pooling to backend PostgreSQL servers for better performance and resource management +- [ ] **Health Checks**: Add health check endpoints and periodic backend server health monitoring +- [ ] **Metrics & Observability**: Integrate Prometheus metrics for connection counts, latency, error rates, and throughput +- [ ] **Graceful Shutdown**: Implement graceful shutdown handling to allow in-flight connections to complete before termination +- [ ] **Configuration Validation**: Add comprehensive validation for configuration files with clear error messages + +### Medium-term (Future Releases) +- [ ] **Load Balancing**: Support multiple backend servers per hostname with round-robin, least-connections, or weighted load balancing +- [ ] **TLS Termination**: Add support for TLS termination at the proxy level with configurable certificates per hostname +- [ ] **Connection Limits**: Implement per-hostname and global connection limits with configurable thresholds +- [ ] **Request/Response Logging**: Add optional detailed logging of PostgreSQL protocol messages for debugging +- [ ] **Rate Limiting**: Implement rate limiting per client IP or hostname to prevent abuse +- [ ] **Authentication Proxy**: Support for PostgreSQL authentication passthrough with optional credential mapping +- [ ] **Dynamic Configuration**: Support for hot-reloading configuration without service restart + +### Long-term (Future Considerations) +- [ ] **High Availability**: Support for active-passive or active-active proxy clustering +- [ ] **Query Routing**: Advanced query routing based on database name, user, or query patterns +- [ ] **Connection Multiplexing**: Implement connection multiplexing to reduce backend connections +- [ ] **Audit Logging**: Comprehensive audit logging for compliance and security monitoring +- [ ] **Web Dashboard**: Web-based management interface for monitoring and configuration +- [ ] **REST API**: RESTful API for configuration management and monitoring +- [ ] **Plugin System**: Extensible plugin system for custom routing and filtering logic \ No newline at end of file diff --git a/app/config/config.go b/app/config/config.go new file mode 100644 index 0000000..7cf9591 --- /dev/null +++ b/app/config/config.go @@ -0,0 +1,37 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Config represents the proxy configuration +type Config struct { + Listen struct { + Address string `yaml:"address"` + Port int `yaml:"port"` + } `yaml:"listen"` + Mappings []struct { + External string `yaml:"external"` + Internal string `yaml:"internal"` + Port int `yaml:"port"` // Optional, defaults to listen port + } `yaml:"mappings"` + Debug bool `yaml:"debug"` +} + +// loadConfig loads configuration from YAML file +func LoadConfig(configPath string) (*Config, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + return &config, nil +} diff --git a/app/go.mod b/app/go.mod new file mode 100644 index 0000000..43641a8 --- /dev/null +++ b/app/go.mod @@ -0,0 +1,8 @@ +module git.secnex.io/secnex/pgproxy + +go 1.25.3 + +require ( + git.secnex.io/secnex/masterlog v0.1.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/app/go.sum b/app/go.sum new file mode 100644 index 0000000..eda6617 --- /dev/null +++ b/app/go.sum @@ -0,0 +1,6 @@ +git.secnex.io/secnex/masterlog v0.1.0 h1:74j9CATpfeK0lxpWIQC9ag9083akwG8khi5BwLedD8E= +git.secnex.io/secnex/masterlog v0.1.0/go.mod h1:OnDlwEzdkKMnqY+G5O9kHdhoJ6fH1llbVdXpgSc5SdM= +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= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/app/main.go b/app/main.go new file mode 100644 index 0000000..fe3eaea --- /dev/null +++ b/app/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "os" + + "git.secnex.io/secnex/masterlog" + "git.secnex.io/secnex/pgproxy/config" + "git.secnex.io/secnex/pgproxy/proxy" + "git.secnex.io/secnex/pgproxy/utils" +) + +func main() { + pseudonymizer := masterlog.NewPseudonymizerFromString("1234567890") + + masterlog.SetPseudonymizer(pseudonymizer) + masterlog.AddSensitiveFields("password") + + // Load configuration + configPath := utils.GetEnv("CONFIG_PATH", "config.yaml") + if len(os.Args) > 1 { + configPath = os.Args[1] + } + + config, err := config.LoadConfig(configPath) + if err != nil { + masterlog.Error("Failed to load configuration", map[string]interface{}{"error": err}) + os.Exit(1) + } + + masterlog.Info("Loaded configuration", map[string]interface{}{"configPath": configPath, "mappings": len(config.Mappings)}) + + if config.Debug { + masterlog.SetLevel(masterlog.LevelDebug) + } else { + masterlog.SetLevel(masterlog.LevelInfo) + } + + // Create proxy + proxy, err := proxy.NewProxy(config) + if err != nil { + masterlog.Error("Failed to create proxy", map[string]interface{}{"error": err}) + os.Exit(1) + } + + // Start the proxy + if err := proxy.Start(); err != nil { + masterlog.Error("Proxy error", map[string]interface{}{"error": err}) + os.Exit(1) + } +} diff --git a/app/proxy/conn.go b/app/proxy/conn.go new file mode 100644 index 0000000..6ad646e --- /dev/null +++ b/app/proxy/conn.go @@ -0,0 +1,341 @@ +package proxy + +import ( + "fmt" + "io" + "net" + "sync" + "time" + + "git.secnex.io/secnex/masterlog" +) + +// peekConn wraps a connection to allow peeking at data without consuming it +type peekConn struct { + net.Conn + peeked []byte + peekedOffset int +} + +func newPeekConn(conn net.Conn) *peekConn { + return &peekConn{Conn: conn} +} + +func (p *peekConn) Read(b []byte) (int, error) { + if p.peekedOffset < len(p.peeked) { + n := copy(b, p.peeked[p.peekedOffset:]) + p.peekedOffset += n + return n, nil + } + return p.Conn.Read(b) +} + +func (p *peekConn) peek(n int) ([]byte, error) { + if len(p.peeked) >= n { + return p.peeked[:n], nil + } + + needed := n - len(p.peeked) + buf := make([]byte, needed) + read, err := io.ReadFull(p.Conn, buf) + if err != nil && err != io.EOF { + return nil, err + } + + p.peeked = append(p.peeked, buf[:read]...) + return p.peeked[:len(p.peeked)], nil +} + +// handleConnection handles a new client connection +func (p *Proxy) handleConnection(clientConn net.Conn) { + defer clientConn.Close() + + remoteAddr := clientConn.RemoteAddr().String() + masterlog.Info("New connection", map[string]interface{}{"remoteAddr": remoteAddr}) + + // Create peek connection to inspect first bytes + peekConn := newPeekConn(clientConn) + + // Try to extract hostname + hostname := p.extractHostname(peekConn) + var ( + targetMapping struct { + host string + port int + } + ok bool + ) + + // If we have a hostname (e.g. from TLS SNI), try to use it first + if hostname != "" { + targetMapping, ok = p.mappings[hostname] + if !ok { + masterlog.Info("No mapping found for hostname", map[string]interface{}{"hostname": hostname}) + } + } + + // Fallback: if we couldn't determine a hostname or no mapping exists, + // but there is exactly one mapping configured, use it as a default backend. + if !ok { + if len(p.mappings) == 1 { + for _, m := range p.mappings { + targetMapping = m + break + } + masterlog.Info("Using default backend", map[string]interface{}{"host": targetMapping.host, "port": targetMapping.port, "remoteAddr": remoteAddr, "hostname": hostname}) + ok = true + } + } + + if !ok { + masterlog.Error("No suitable backend found", map[string]interface{}{"remoteAddr": remoteAddr, "hostname": hostname}) + return + } + + masterlog.Info("Proxying", map[string]interface{}{"hostname": hostname, "host": targetMapping.host, "port": targetMapping.port}) + + // Connect to backend PostgreSQL server + backendAddr := net.JoinHostPort(targetMapping.host, fmt.Sprintf("%d", targetMapping.port)) + backendConn, err := net.DialTimeout("tcp", backendAddr, 10*time.Second) + if err != nil { + masterlog.Error("Failed to connect to backend", map[string]interface{}{"backendAddr": backendAddr, "error": err}) + return + } + defer backendConn.Close() + + // Start bidirectional proxying + var wg sync.WaitGroup + wg.Add(2) + + // Copy from client to backend + go func() { + defer wg.Done() + io.Copy(backendConn, peekConn) + backendConn.Close() + }() + + // Copy from backend to client + go func() { + defer wg.Done() + io.Copy(peekConn, backendConn) + peekConn.Close() + }() + + wg.Wait() + masterlog.Info("Connection closed", map[string]interface{}{"hostname": hostname, "host": targetMapping.host, "port": targetMapping.port}) +} + +// extractHostname extracts hostname from connection (TLS SNI or PostgreSQL startup) +func (p *Proxy) extractHostname(conn *peekConn) string { + // Peek at first byte to determine protocol + firstBytes, err := conn.peek(1) + if err != nil { + return "" + } + + // TLS handshake starts with 0x16 + if len(firstBytes) > 0 && firstBytes[0] == 0x16 { + return p.extractHostnameFromTLS(conn) + } + + // Otherwise, try PostgreSQL startup message + return p.extractHostnameFromPostgresStartup(conn) +} + +// extractHostnameFromTLS extracts hostname by parsing TLS ClientHello manually +func (p *Proxy) extractHostnameFromTLS(conn *peekConn) string { + // Read enough bytes to parse TLS ClientHello + // TLS Record Header: 5 bytes (type, version, length) + peekBuf, err := conn.peek(5) + if err != nil || len(peekBuf) < 5 { + return "" + } + + // Check TLS record type (should be 0x16 for Handshake) + if peekBuf[0] != 0x16 { + return "" + } + + // Get record length + recordLength := int(peekBuf[3])<<8 | int(peekBuf[4]) + + // Read the full TLS record (5 bytes header + recordLength bytes) + fullRecord, err := conn.peek(5 + recordLength) + if err != nil || len(fullRecord) < 5+recordLength { + return "" + } + + // Parse ClientHello to extract SNI + // Skip TLS record header (5 bytes) + handshake := fullRecord[5:] + if len(handshake) < 4 { + return "" + } + + // Handshake message type (should be 0x01 for ClientHello) + if handshake[0] != 0x01 { + return "" + } + + // Handshake message length (3 bytes) + handshakeLength := int(handshake[1])<<16 | int(handshake[2])<<8 | int(handshake[3]) + if len(handshake) < 4+handshakeLength { + return "" + } + + // Skip handshake header (4 bytes) and protocol version (2 bytes) + offset := 4 + 2 + + // Read random (32 bytes) + if len(handshake) < offset+32 { + return "" + } + offset += 32 + + // Read session ID length (1 byte) and session ID + if len(handshake) < offset+1 { + return "" + } + sessionIDLength := int(handshake[offset]) + offset++ + if len(handshake) < offset+sessionIDLength { + return "" + } + offset += sessionIDLength + + // Read cipher suites length (2 bytes) and cipher suites + if len(handshake) < offset+2 { + return "" + } + cipherSuitesLength := int(handshake[offset])<<8 | int(handshake[offset+1]) + offset += 2 + if len(handshake) < offset+cipherSuitesLength { + return "" + } + offset += cipherSuitesLength + + // Read compression methods length (1 byte) and compression methods + if len(handshake) < offset+1 { + return "" + } + compressionMethodsLength := int(handshake[offset]) + offset++ + if len(handshake) < offset+compressionMethodsLength { + return "" + } + offset += compressionMethodsLength + + // Read extensions length (2 bytes) + if len(handshake) < offset+2 { + return "" + } + extensionsLength := int(handshake[offset])<<8 | int(handshake[offset+1]) + offset += 2 + + // Parse extensions to find SNI (extension type 0x0000) + extensionsEnd := offset + extensionsLength + for offset < extensionsEnd-4 { + if len(handshake) < offset+4 { + break + } + + extType := int(handshake[offset])<<8 | int(handshake[offset+1]) + extLength := int(handshake[offset+2])<<8 | int(handshake[offset+3]) + offset += 4 + + if len(handshake) < offset+extLength { + break + } + + // SNI extension type is 0x0000 + if extType == 0x0000 { + // SNI format: list length (2 bytes) + server name list + if extLength < 2 { + break + } + sniListLength := int(handshake[offset])<<8 | int(handshake[offset+1]) + sniOffset := offset + 2 + + if len(handshake) < sniOffset+sniListLength { + break + } + + // Parse server name entry + if sniListLength >= 3 { + // Name type (1 byte) - 0x00 for hostname + nameType := handshake[sniOffset] + if nameType == 0x00 { + nameLength := int(handshake[sniOffset+1])<<8 | int(handshake[sniOffset+2]) + if len(handshake) >= sniOffset+3+nameLength { + hostname := string(handshake[sniOffset+3 : sniOffset+3+nameLength]) + return hostname + } + } + } + } + + offset += extLength + } + + return "" +} + +// extractHostnameFromPostgresStartup extracts hostname from PostgreSQL startup message +func (p *Proxy) extractHostnameFromPostgresStartup(conn *peekConn) string { + // Peek at first 4 bytes to get message length + lengthBuf, err := conn.peek(4) + if err != nil || len(lengthBuf) < 4 { + return "" + } + + length := int(lengthBuf[0])<<24 | int(lengthBuf[1])<<16 | int(lengthBuf[2])<<8 | int(lengthBuf[3]) + if length < 8 || length > 10000 { + return "" + } + + // Peek at the full startup message + startupBuf, err := conn.peek(length) + if err != nil || len(startupBuf) < length { + return "" + } + + // Skip length (4 bytes) and protocol version (4 bytes) + offset := 8 + + // Parse key-value pairs + for offset < len(startupBuf)-1 { + // Find null terminator for key + keyEnd := offset + for keyEnd < len(startupBuf) && startupBuf[keyEnd] != 0 { + keyEnd++ + } + if keyEnd >= len(startupBuf) { + break + } + + _ = string(startupBuf[offset:keyEnd]) // key (not used, PostgreSQL startup doesn't contain hostname) + offset = keyEnd + 1 + + // Find null terminator for value + valueEnd := offset + for valueEnd < len(startupBuf) && startupBuf[valueEnd] != 0 { + valueEnd++ + } + if valueEnd >= len(startupBuf) { + break + } + + _ = string(startupBuf[offset:valueEnd]) // value (not used, PostgreSQL startup doesn't contain hostname) + offset = valueEnd + 1 + + // PostgreSQL startup message doesn't contain hostname directly + // Hostname should come from TLS SNI for TLS connections + + // If we hit the final null byte, we're done + if offset >= len(startupBuf) || startupBuf[offset] == 0 { + break + } + } + + return "" +} diff --git a/app/proxy/proxy.go b/app/proxy/proxy.go new file mode 100644 index 0000000..27c1fcc --- /dev/null +++ b/app/proxy/proxy.go @@ -0,0 +1,68 @@ +package proxy + +import ( + "fmt" + "net" + + "git.secnex.io/secnex/masterlog" + "git.secnex.io/secnex/pgproxy/config" +) + +// Proxy handles PostgreSQL connection proxying +type Proxy struct { + listener net.Listener + config *config.Config + mappings map[string]struct { + host string + port int + } +} + +// 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) + if err != nil { + return nil, err + } + + // Build mappings map for quick lookup + mappings := make(map[string]struct { + host string + port int + }) + for _, mapping := range config.Mappings { + port := mapping.Port + if port == 0 { + port = 5432 // Default PostgreSQL port + } + mappings[mapping.External] = struct { + host string + port int + }{ + host: mapping.Internal, + port: port, + } + } + + return &Proxy{ + listener: listener, + config: config, + mappings: mappings, + }, nil +} + +// Start starts the proxy server +func (p *Proxy) Start() error { + masterlog.Info("PostgreSQL proxy listening", map[string]interface{}{"address": p.config.Listen.Address, "port": p.config.Listen.Port}) + + for { + conn, err := p.listener.Accept() + if err != nil { + masterlog.Error("Error accepting connection", map[string]interface{}{"error": err}) + continue + } + + go p.handleConnection(conn) + } +} diff --git a/app/utils/env.go b/app/utils/env.go new file mode 100644 index 0000000..c2e46da --- /dev/null +++ b/app/utils/env.go @@ -0,0 +1,22 @@ +package utils + +import ( + "os" + "strings" +) + +func GetEnv(key, defaultValue string) string { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + return value +} + +func GetEnvBool(key string, defaultValue bool) bool { + value := strings.ToLower(GetEnv(key, "")) + if value == "" { + return defaultValue + } + return value == "true" || value == "1" +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..5895b7f --- /dev/null +++ b/config.yaml @@ -0,0 +1,17 @@ +# PostgreSQL Proxy Configuration +# Maps external hostnames to internal backend servers + +# Listen address and port +listen: + address: "0.0.0.0" + port: 5432 + +debug: true + +# Hostname mappings +# External hostname -> Internal hostname and port +mappings: + - external: "mytestserver" + internal: "localhost" + - external: "mytestserver2" + internal: "deploy.deinserver.co" \ No newline at end of file