feat(pgserver): Phase 6 — TLS + source-IP allowlist
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user