From 3b2dd365ad2d20d02015d6f46ad61ae65993293f Mon Sep 17 00:00:00 2001 From: CharlesKWON Date: Mon, 18 May 2026 14:07:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(pgserver):=20Phase=206=20=E2=80=94=20TLS?= =?UTF-8?q?=20+=20source-IP=20allowlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the v1.0 hardening surface: encrypted transport + a coarse pg_hba.conf-equivalent CIDR allowlist. Together with the Phase 5 auth flows, this is the security-baseline an internet- exposed PostgreSQL-wire server needs. TLS subsystem ------------- `hbrtl/pgserver/tls.go`: * `LoadTLSFromFiles(certPath, keyPath)` — cert/key PEM pair load with tls.VersionTLS12 floor. Installed as the *pending* config that the next PG_SERVER_START consumes (matches PG's "must-set-before-pg_ctl-start" semantics). * `GenerateSelfSignedCert(certPath, keyPath, hostname)` — ECDSA P-256 + 365-day validity + DNSNames+IPAddresses SANs covering the hostname plus 127.0.0.1 / ::1. Dev/CI helper; production ships a CA-signed cert via the loader. * `upgradeToTLS()` wraps `tls.Server(conn, cfg).Handshake()` so pgproto3 reads plaintext on top of the encrypted stream. Source-IP allowlist ------------------- * `AllowIP(cidr)` parses a CIDR and appends it to a per-server list snapshotted at PG_SERVER_START time. * `peerAllowed(remote, list)` runs at accept() — empty list → accept any, otherwise drop connections whose RemoteAddr falls outside every registered range. * `ClearAllowList()` resets to allow-all. Coarse but compatible with the "host alice 10.0.0.0/8 md5"-style entries every pg_hba.conf author already knows; a fuller per- role/per-database matcher is Phase 6.1+. PRG bindings (register.go) -------------------------- New HB_FUNCs, all idempotent and composable in any order before PG_SERVER_START: pg_tls_load( certPath, keyPath ) → .T. | cErr pg_tls_self_signed( cert, key, hostname ) → .T. | cErr pg_allow_ip( cidr ) → .T. | cErr pg_clear_allowlist() → NIL Bootstrap idiom: PROCEDURE Main() PG_TLS_SELF_SIGNED( "/tmp/cert.pem", "/tmp/key.pem", "localhost" ) PG_ADD_ROLE( "alice", "swordfish" ) PG_ALLOW_IP( "127.0.0.1/32" ) PG_ALLOW_IP( "10.0.0.0/8" ) PG_SERVER_START( ":5432", "md5" ) The startup banner now reports TLS + allowlist state so the PRG operator sees the security posture at a glance: pgserver: listening on :5432 (auth=md5 tls=on allowlist=2) Verification ------------ End-to-end via real psql against a self-signed server: $ PGPASSWORD=swordfish psql \ "postgres://alice@127.0.0.1:15432/alice?sslmode=require" \ -c "SELECT 'tls-works' AS x" -At tls-works $ # off-allowlist source (192.168.x.x mock) → connection refused $ # (verified manually; psql can't easily spoof src IP for CI) Integration script gates expanded to 6/6: PASS Simple Query PASS Multi-statement Simple Query PASS Transaction control PASS MD5 auth: wrong password rejected PASS MD5 auth: correct password accepted PASS TLS handshake + MD5 auth via sslmode=require All six release gates green: go test ./... ✓ FiveSql2 SQL:1999 43/43 ✓ Harbour compat 56/56 ✓ std.ch 17/17 ✓ FRB 7/7 ✓ pgserver integration 6/6 ✓ Co-Authored-By: Claude Opus 4.7 (1M context) --- hbrtl/pgserver/register.go | 81 ++++++++++++++++- hbrtl/pgserver/server.go | 12 +++ hbrtl/pgserver/tls.go | 177 ++++++++++++++++++++++++++++++++++++- tests/pgserver/run.sh | 27 ++++++ 4 files changed, 292 insertions(+), 5 deletions(-) diff --git a/hbrtl/pgserver/register.go b/hbrtl/pgserver/register.go index aeab29d..20144bf 100644 --- a/hbrtl/pgserver/register.go +++ b/hbrtl/pgserver/register.go @@ -35,6 +35,70 @@ func init() { hbrt.HB_FUNC("PG_SERVER_STOP", pgServerStop) hbrt.HB_FUNC("PG_ADD_ROLE", pgAddRole) hbrt.HB_FUNC("PG_REMOVE_ROLE", pgRemoveRole) + hbrt.HB_FUNC("PG_TLS_LOAD", pgTLSLoad) + hbrt.HB_FUNC("PG_TLS_SELF_SIGNED", pgTLSSelfSigned) + hbrt.HB_FUNC("PG_ALLOW_IP", pgAllowIP) + hbrt.HB_FUNC("PG_CLEAR_ALLOWLIST", pgClearAllowList) +} + +// pgTLSLoad loads a PEM cert/key pair and installs the resulting +// TLS config on the next PG_SERVER_START. PRG signature: +// +// pg_tls_load( cCertPath, cKeyPath ) -> .T. | cErr +func pgTLSLoad(ctx *hbrt.HBContext) { + if ctx.PCount() < 2 || !ctx.IsChar(1) || !ctx.IsChar(2) { + ctx.RetC("pg_tls_load: cert and key paths required") + return + } + if err := LoadTLSFromFiles(ctx.ParC(1), ctx.ParC(2)); err != nil { + ctx.RetC(err.Error()) + return + } + ctx.RetL(true) +} + +// pgTLSSelfSigned writes a fresh self-signed ECDSA P-256 cert +// and installs it. Dev-only — production should ship a CA-signed +// cert. PRG signature: +// +// pg_tls_self_signed( cCertPath, cKeyPath, cHostname ) -> .T. | cErr +func pgTLSSelfSigned(ctx *hbrt.HBContext) { + if ctx.PCount() < 3 || !ctx.IsChar(1) || !ctx.IsChar(2) || !ctx.IsChar(3) { + ctx.RetC("pg_tls_self_signed: cert path, key path, hostname required") + return + } + if err := GenerateSelfSignedCert(ctx.ParC(1), ctx.ParC(2), ctx.ParC(3)); err != nil { + ctx.RetC(err.Error()) + return + } + ctx.RetL(true) +} + +// pgAllowIP appends a CIDR range to the source-IP allowlist. PRG +// signature: +// +// pg_allow_ip( cCIDR ) -> .T. | cErr +// +// Repeated calls accumulate; pg_clear_allowlist() resets to +// "allow anyone". An empty list means allow-all. +func pgAllowIP(ctx *hbrt.HBContext) { + if ctx.PCount() < 1 || !ctx.IsChar(1) { + ctx.RetC("pg_allow_ip: CIDR required") + return + } + if err := AllowIP(ctx.ParC(1)); err != nil { + ctx.RetC(err.Error()) + return + } + ctx.RetL(true) +} + +// pgClearAllowList resets to allow-all. PRG signature: +// +// pg_clear_allowlist() -> NIL +func pgClearAllowList(ctx *hbrt.HBContext) { + ClearAllowList() + ctx.RetNil() } // pgAddRole registers a (username, password) pair with the @@ -82,10 +146,23 @@ func pgServerStart(ctx *hbrt.HBContext) { if ctx.PCount() >= 2 && ctx.IsChar(2) { cfg.AuthMode = ctx.ParC(2) } + // Inhale any pending TLS config installed by PG_TLS_LOAD / + // PG_TLS_SELF_SIGNED before this call. Server keeps a snapshot; + // later TLS changes won't affect an already-running listener. + cfg.TLSConfig = CurrentTLSConfig() srv := NewServer(ctx.T.VM(), cfg) + srv.allowList = CurrentAllowList() setActiveServer(srv) - fmt.Fprintf(os.Stderr, "pgserver: listening on %s (auth=%s)\n", - cfg.listenAddr(), defaultStr(cfg.AuthMode, "trust")) + tlsNote := "" + if cfg.TLSConfig != nil { + tlsNote = " tls=on" + } + allowNote := "" + if len(srv.allowList) > 0 { + allowNote = fmt.Sprintf(" allowlist=%d", len(srv.allowList)) + } + fmt.Fprintf(os.Stderr, "pgserver: listening on %s (auth=%s%s%s)\n", + cfg.listenAddr(), defaultStr(cfg.AuthMode, "trust"), tlsNote, allowNote) if err := srv.Serve(context.Background()); err != nil { fmt.Fprintf(os.Stderr, "pgserver: %v\n", err) } diff --git a/hbrtl/pgserver/server.go b/hbrtl/pgserver/server.go index 913d49d..e99fcb4 100644 --- a/hbrtl/pgserver/server.go +++ b/hbrtl/pgserver/server.go @@ -91,6 +91,11 @@ type Server struct { listener net.Listener sem chan struct{} // accept gate, sized to MaxConnections + // allowList snapshots tls.go's pending CIDR list at start + // time. Empty → accept any source IP. The accept loop + // closes any connection whose RemoteAddr falls outside. + allowList []*net.IPNet + mu sync.Mutex conns map[*session]struct{} // live sessions for clean shutdown closed bool @@ -147,6 +152,13 @@ func (s *Server) Serve(ctx context.Context) error { } return fmt.Errorf("pgserver: accept: %w", err) } + // Source-IP allowlist: drop connections from any peer not + // matching a configured CIDR before allocating a slot. + // Empty list → unconditional accept (the default). + if !peerAllowed(conn.RemoteAddr(), s.allowList) { + _ = conn.Close() + continue + } // Backpressure: block here when MaxConnections sessions are // already in flight. Holding the slot through handleConn // (released in defer) is the natural way to gate. diff --git a/hbrtl/pgserver/tls.go b/hbrtl/pgserver/tls.go index c357f6d..529e639 100644 --- a/hbrtl/pgserver/tls.go +++ b/hbrtl/pgserver/tls.go @@ -1,20 +1,43 @@ // Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com) // All rights reserved. +// tls.go — TLS support for the pgserver. Cert/key load + self- +// signed generator + IP allowlist. +// +// PRG bootstrap idiom: +// +// PG_TLS_LOAD( "cert.pem", "key.pem" ) /* or */ +// PG_TLS_SELF_SIGNED( "/tmp/cert.pem", "/tmp/key.pem", "localhost" ) +// PG_ALLOW_IP( "127.0.0.1/32" ) /* optional allowlist */ +// PG_ALLOW_IP( "10.0.0.0/8" ) +// PG_SERVER_START( ":5432", "md5" ) +// +// The TLS config is installed on the *next* PG_SERVER_START call. +// A server started before PG_TLS_LOAD runs with plaintext only; +// matches PostgreSQL's "must be set before pg_ctl start" semantics. + package pgserver import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" "net" + "os" + "sync" + "time" ) // upgradeToTLS wraps the underlying net.Conn in a tls.Server using // the configured *tls.Config and performs the TLS handshake. The // returned net.Conn is the encrypted stream; pgproto3 sees only // plaintext on top of it. -// -// Phase 6 expands this with mTLS / SNI / cert pinning. v1.0 just -// does the basic upgrade — sufficient for `psql sslmode=require`. func upgradeToTLS(conn net.Conn, cfg *tls.Config) (net.Conn, error) { tlsConn := tls.Server(conn, cfg) if err := tlsConn.Handshake(); err != nil { @@ -22,3 +45,151 @@ func upgradeToTLS(conn net.Conn, cfg *tls.Config) (net.Conn, error) { } return tlsConn, nil } + +// --- TLS config holder --- +// +// pendingTLSConfig is consumed by the next PG_SERVER_START invocation +// (see register.go::pgServerStart). Stored separately from any +// running server so PRG callers can compose cert load + start. + +var ( + tlsMu sync.Mutex + pendingTLSConfig *tls.Config + pendingAllowList []*net.IPNet // empty → allow any +) + +// SetTLSConfig installs the TLS config that the next started +// server will use. Pass nil to clear (subsequent starts run +// plaintext). Concurrent-safe. +func SetTLSConfig(cfg *tls.Config) { + tlsMu.Lock() + defer tlsMu.Unlock() + pendingTLSConfig = cfg +} + +// CurrentTLSConfig returns the pending TLS config. Called by +// pgServerStart at boot time. +func CurrentTLSConfig() *tls.Config { + tlsMu.Lock() + defer tlsMu.Unlock() + return pendingTLSConfig +} + +// AllowIP appends a CIDR range to the per-server allowlist. The +// list is empty by default → all source IPs accepted. Once +// non-empty, the accept loop rejects any peer whose RemoteAddr +// doesn't fall inside one of the registered ranges. PG-equivalent +// of a coarse pg_hba.conf with one `host` line per call. +func AllowIP(cidr string) error { + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return err + } + tlsMu.Lock() + defer tlsMu.Unlock() + pendingAllowList = append(pendingAllowList, ipnet) + return nil +} + +// CurrentAllowList returns the registered CIDRs. +func CurrentAllowList() []*net.IPNet { + tlsMu.Lock() + defer tlsMu.Unlock() + if len(pendingAllowList) == 0 { + return nil + } + out := make([]*net.IPNet, len(pendingAllowList)) + copy(out, pendingAllowList) + return out +} + +// ClearAllowList drops every registered CIDR. After this call, +// the server reverts to "accept any source IP". +func ClearAllowList() { + tlsMu.Lock() + defer tlsMu.Unlock() + pendingAllowList = nil +} + +// peerAllowed checks an incoming RemoteAddr against the +// configured allowlist. Empty list → unconditional allow. +func peerAllowed(remote net.Addr, list []*net.IPNet) bool { + if len(list) == 0 { + return true + } + tcpAddr, ok := remote.(*net.TCPAddr) + if !ok { + return false + } + for _, n := range list { + if n.Contains(tcpAddr.IP) { + return true + } + } + return false +} + +// --- Cert / key loading --- + +// LoadTLSFromFiles installs a tls.Config built from PEM-encoded +// cert + key files. Returns an error if either file is missing +// or the key doesn't match the cert. +func LoadTLSFromFiles(certPath, keyPath string) error { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return fmt.Errorf("pgserver: load TLS pair: %w", err) + } + SetTLSConfig(&tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + }) + return nil +} + +// GenerateSelfSignedCert writes a fresh ECDSA P-256 self-signed +// cert/key pair to certPath/keyPath and installs it as the +// pending TLS config. Validity is 365 days. Intended for dev / +// CI where a CA-signed cert isn't available; production should +// always run LoadTLSFromFiles with a properly signed pair. +// +// Hostname is added as a SubjectAltName so clients connecting to +// `psql 'host=hostname sslmode=verify-full'` accept it. +func GenerateSelfSignedCert(certPath, keyPath, hostname string) error { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("genkey: %w", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return fmt.Errorf("serial: %w", err) + } + tpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: hostname}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: false, + DNSNames: []string{hostname}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + } + der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv) + if err != nil { + return fmt.Errorf("create cert: %w", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + if err := os.WriteFile(certPath, certPEM, 0644); err != nil { + return fmt.Errorf("write cert: %w", err) + } + keyDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return fmt.Errorf("marshal key: %w", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + return fmt.Errorf("write key: %w", err) + } + return LoadTLSFromFiles(certPath, keyPath) +} diff --git a/tests/pgserver/run.sh b/tests/pgserver/run.sh index 3995d3c..b7859df 100755 --- a/tests/pgserver/run.sh +++ b/tests/pgserver/run.sh @@ -144,6 +144,33 @@ else fail "MD5 auth: correct password accepted" "$good" fi +# 5) TLS — restart server with self-signed cert + allowlist and +# connect via psql sslmode=require. +kill $SERVER_PID 2>/dev/null +wait 2>/dev/null + +cat > "$work/tls.prg" </dev/null 2>&1 +"$work/tls" & +SERVER_PID=$! +sleep 1 +trap "kill $SERVER_PID 2>/dev/null; rm -rf '$work'" EXIT + +tls_out="$(PGPASSWORD=swordfish psql "postgres://alice@127.0.0.1:$PORT/alice?sslmode=require" \ + -c "SELECT 'tls-ok' AS x" -At 2>&1 || true)" +if echo "$tls_out" | grep -q "^tls-ok$"; then + ok "TLS handshake + MD5 auth via sslmode=require" +else + fail "TLS handshake + MD5 auth via sslmode=require" "$tls_out" +fi + echo "================================================================" echo " pgserver integration: $pass / $total passed" echo "================================================================"