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:
2026-05-18 14:07:19 +09:00
parent 90eafcfc06
commit 3b2dd365ad
4 changed files with 292 additions and 5 deletions

View File

@@ -35,6 +35,70 @@ func init() {
hbrt.HB_FUNC("PG_SERVER_STOP", pgServerStop) hbrt.HB_FUNC("PG_SERVER_STOP", pgServerStop)
hbrt.HB_FUNC("PG_ADD_ROLE", pgAddRole) hbrt.HB_FUNC("PG_ADD_ROLE", pgAddRole)
hbrt.HB_FUNC("PG_REMOVE_ROLE", pgRemoveRole) 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 // pgAddRole registers a (username, password) pair with the
@@ -82,10 +146,23 @@ func pgServerStart(ctx *hbrt.HBContext) {
if ctx.PCount() >= 2 && ctx.IsChar(2) { if ctx.PCount() >= 2 && ctx.IsChar(2) {
cfg.AuthMode = ctx.ParC(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 := NewServer(ctx.T.VM(), cfg)
srv.allowList = CurrentAllowList()
setActiveServer(srv) setActiveServer(srv)
fmt.Fprintf(os.Stderr, "pgserver: listening on %s (auth=%s)\n", tlsNote := ""
cfg.listenAddr(), defaultStr(cfg.AuthMode, "trust")) 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 { if err := srv.Serve(context.Background()); err != nil {
fmt.Fprintf(os.Stderr, "pgserver: %v\n", err) fmt.Fprintf(os.Stderr, "pgserver: %v\n", err)
} }

View File

@@ -91,6 +91,11 @@ type Server struct {
listener net.Listener listener net.Listener
sem chan struct{} // accept gate, sized to MaxConnections 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 mu sync.Mutex
conns map[*session]struct{} // live sessions for clean shutdown conns map[*session]struct{} // live sessions for clean shutdown
closed bool closed bool
@@ -147,6 +152,13 @@ func (s *Server) Serve(ctx context.Context) error {
} }
return fmt.Errorf("pgserver: accept: %w", err) 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 // Backpressure: block here when MaxConnections sessions are
// already in flight. Holding the slot through handleConn // already in flight. Holding the slot through handleConn
// (released in defer) is the natural way to gate. // (released in defer) is the natural way to gate.

View File

@@ -1,20 +1,43 @@
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com) // Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved. // 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 package pgserver
import ( import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls" "crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net" "net"
"os"
"sync"
"time"
) )
// upgradeToTLS wraps the underlying net.Conn in a tls.Server using // upgradeToTLS wraps the underlying net.Conn in a tls.Server using
// the configured *tls.Config and performs the TLS handshake. The // the configured *tls.Config and performs the TLS handshake. The
// returned net.Conn is the encrypted stream; pgproto3 sees only // returned net.Conn is the encrypted stream; pgproto3 sees only
// plaintext on top of it. // 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) { func upgradeToTLS(conn net.Conn, cfg *tls.Config) (net.Conn, error) {
tlsConn := tls.Server(conn, cfg) tlsConn := tls.Server(conn, cfg)
if err := tlsConn.Handshake(); err != nil { if err := tlsConn.Handshake(); err != nil {
@@ -22,3 +45,151 @@ func upgradeToTLS(conn net.Conn, cfg *tls.Config) (net.Conn, error) {
} }
return tlsConn, nil 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)
}

View File

@@ -144,6 +144,33 @@ else
fail "MD5 auth: correct password accepted" "$good" fail "MD5 auth: correct password accepted" "$good"
fi 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" <<EOF
PROCEDURE Main()
PG_TLS_SELF_SIGNED( "$work/cert.pem", "$work/key.pem", "localhost" )
PG_ADD_ROLE( "alice", "swordfish" )
PG_ALLOW_IP( "127.0.0.1/32" )
PG_SERVER_START( ":$PORT", "md5" )
RETURN
EOF
"$FIVE" build "$work/tls.prg" "$ROOT/_FiveSql2/src/"*.prg -o "$work/tls" >/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 "================================================================"
echo " pgserver integration: $pass / $total passed" echo " pgserver integration: $pass / $total passed"
echo "================================================================" echo "================================================================"