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>
198 lines
5.4 KiB
Go
198 lines
5.4 KiB
Go
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
|
// All rights reserved.
|
|
|
|
package pgserver
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
|
|
"five/hbrt"
|
|
)
|
|
|
|
// init wires the PG-server entry points into the runtime as
|
|
// HB_FUNCs. Importing this package (e.g. via _ "five/hbrtl/pgserver"
|
|
// from hbrtl's bootstrap) is enough; PRG code then sees:
|
|
//
|
|
// pg_server_start( nPort | cAddr [, cAuthMode ] )
|
|
// → starts server, blocks
|
|
// (call inside SPAWN to keep
|
|
// the calling thread free)
|
|
// pg_server_stop() → closes the active server
|
|
//
|
|
// Embedded callers compose this with their own DBF setup:
|
|
//
|
|
// #include "FiveSqlDef.ch"
|
|
// PROCEDURE Main()
|
|
// USE customers SHARED
|
|
// USE orders SHARED NEW
|
|
// pg_server_start( 5432 ) /* blocks; psql can now connect */
|
|
// RETURN
|
|
func init() {
|
|
hbrt.HB_FUNC("PG_SERVER_START", pgServerStart)
|
|
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
|
|
// pgserver auth subsystem. Idempotent — re-adding the same name
|
|
// replaces the prior credential. PRG signature:
|
|
//
|
|
// pg_add_role( cName, cPassword ) -> NIL
|
|
//
|
|
// Bootstrap pattern (combine with pg_server_start):
|
|
//
|
|
// pg_add_role( "alice", "swordfish" )
|
|
// pg_add_role( "bob", "hunter2" )
|
|
// pg_server_start( 5432, "md5" )
|
|
func pgAddRole(ctx *hbrt.HBContext) {
|
|
if ctx.PCount() < 2 || !ctx.IsChar(1) || !ctx.IsChar(2) {
|
|
ctx.RetNil()
|
|
return
|
|
}
|
|
AddRole(ctx.ParC(1), ctx.ParC(2))
|
|
ctx.RetNil()
|
|
}
|
|
|
|
// pgRemoveRole drops a previously-added role. PRG signature:
|
|
//
|
|
// pg_remove_role( cName ) -> NIL
|
|
func pgRemoveRole(ctx *hbrt.HBContext) {
|
|
if ctx.PCount() < 1 || !ctx.IsChar(1) {
|
|
ctx.RetNil()
|
|
return
|
|
}
|
|
RemoveRole(ctx.ParC(1))
|
|
ctx.RetNil()
|
|
}
|
|
|
|
func pgServerStart(ctx *hbrt.HBContext) {
|
|
listen := ":5432"
|
|
if ctx.PCount() >= 1 {
|
|
if ctx.IsNumeric(1) {
|
|
listen = ":" + strconv.Itoa(ctx.ParNI(1))
|
|
} else if ctx.IsChar(1) {
|
|
listen = ctx.ParC(1)
|
|
}
|
|
}
|
|
cfg := Config{Listen: listen}
|
|
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)
|
|
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)
|
|
}
|
|
ctx.RetNil()
|
|
}
|
|
|
|
func pgServerStop(ctx *hbrt.HBContext) {
|
|
if srv := takeActiveServer(); srv != nil {
|
|
_ = srv.Close()
|
|
}
|
|
ctx.RetNil()
|
|
}
|
|
|
|
func defaultStr(s, fallback string) string {
|
|
if s == "" {
|
|
return fallback
|
|
}
|
|
return s
|
|
}
|
|
|
|
// activeServer tracks the most recently started server so
|
|
// pg_server_stop() can find it without the PRG layer needing to
|
|
// hold a handle. v1.0 is single-server-per-process; a future
|
|
// upgrade can swap this for a slice.
|
|
var activeServerSlot *Server
|
|
|
|
func setActiveServer(s *Server) { activeServerSlot = s }
|
|
func takeActiveServer() *Server {
|
|
s := activeServerSlot
|
|
activeServerSlot = nil
|
|
return s
|
|
}
|