Files
five/hbrtl/pgserver/server.go
CharlesKWON 3b2dd365ad 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>
2026-05-18 14:07:19 +09:00

216 lines
6.0 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
// Package pgserver implements a PostgreSQL-wire-protocol-compatible
// TCP/IP frontend for FiveSql2. The protocol layer is provided by
// github.com/jackc/pgx/v5/pgproto3 (the same library pgx itself uses
// internally); this package wires it into Five's runtime so that
// psql / pgx / JDBC / DBeaver / Tableau and any other PostgreSQL
// driver can connect to a Five process as if it were Postgres.
//
// Lifecycle
//
// five pgserver --listen :5432 starts Listen() in the main goroutine.
// accept() spawns one goroutine per connection. Each connection
// owns a *hbrt.Thread + a TSqlSession on the PRG side, so concurrent
// clients don't share transaction state or plan caches (see
// refactor commit 93cf5c8).
//
// Scope
//
// v1.0 ships Simple Query (SELECT/INSERT/UPDATE/DELETE), per-session
// BEGIN/COMMIT/ROLLBACK, trust + password/MD5/SCRAM auth, and TLS.
// Extended protocol (Parse/Bind/Execute) and pg_catalog shim are
// v1.1+. See /Users/charleskwon/.claude/plans/compiled-launching-shore.md.
package pgserver
import (
"context"
"crypto/tls"
"fmt"
"net"
"sync"
"five/hbrt"
)
// Config holds runtime knobs for the server. All fields optional;
// zero values yield a trust-auth, no-TLS server bound to :5432.
type Config struct {
// Listen is the bind address (":5432" by default).
Listen string
// MaxConnections caps concurrent accepted sessions. Excess
// connections block in accept() until a slot frees. 0 → 100.
MaxConnections int
// AuthMode: "trust" (default), "password", "md5", "scram-sha-256".
// Roles + password hashes are loaded from __five_roles.dbf when
// not in trust mode; see auth.go.
AuthMode string
// TLSConfig, if non-nil, is presented when a client sends
// SSLRequest. tls.go provides helpers for loading a cert/key
// pair or auto-generating a self-signed pair for dev.
TLSConfig *tls.Config
// ParameterStatus values broadcast right after auth. Five
// announces itself as PostgreSQL 14 by default so probing
// clients (pgx, JDBC) negotiate without erroring out on
// unsupported features.
ServerVersion string // default "14.0 (FiveSql2)"
}
func (c *Config) listenAddr() string {
if c.Listen == "" {
return ":5432"
}
return c.Listen
}
func (c *Config) maxConns() int {
if c.MaxConnections <= 0 {
return 100
}
return c.MaxConnections
}
func (c *Config) serverVersion() string {
if c.ServerVersion == "" {
return "14.0 (FiveSql2)"
}
return c.ServerVersion
}
// Server is the live server state. Create via NewServer and drive
// with Serve(); Close() drains in-flight connections gracefully.
type Server struct {
vm *hbrt.VM
cfg Config
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
closeCh chan struct{}
}
// NewServer constructs an unstarted Server. The hbrt.VM is the
// runtime instance whose `FIVE_SQL` function will execute incoming
// queries — every accepted connection spawns a fresh thread on this
// VM via vm.NewThread() so statics + workarea factory are inherited
// (see hbrt/vm.go NewThread, fixed in cde8673 to propagate both).
func NewServer(vm *hbrt.VM, cfg Config) *Server {
return &Server{
vm: vm,
cfg: cfg,
sem: make(chan struct{}, cfg.maxConns()),
conns: make(map[*session]struct{}),
closeCh: make(chan struct{}),
}
}
// Serve binds the listener and runs the accept loop until Close()
// fires or a fatal accept error is hit. Each accepted connection
// runs handleConn in its own goroutine.
func (s *Server) Serve(ctx context.Context) error {
if s.vm == nil {
return fmt.Errorf("pgserver: nil VM")
}
ln, err := net.Listen("tcp", s.cfg.listenAddr())
if err != nil {
return fmt.Errorf("pgserver: listen %s: %w", s.cfg.listenAddr(), err)
}
s.mu.Lock()
s.listener = ln
s.mu.Unlock()
// Stop the listener when ctx cancels so Accept() returns.
go func() {
select {
case <-ctx.Done():
case <-s.closeCh:
}
_ = ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
s.mu.Lock()
closed := s.closed
s.mu.Unlock()
if closed {
return nil
}
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.
s.sem <- struct{}{}
go func(c net.Conn) {
defer func() { <-s.sem }()
s.handleConn(ctx, c)
}(conn)
}
}
// Close signals the accept loop to exit, drops the listener, and
// closes every in-flight connection. Outstanding handleConn
// goroutines exit on the first failed Receive.
func (s *Server) Close() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
ln := s.listener
conns := make([]*session, 0, len(s.conns))
for c := range s.conns {
conns = append(conns, c)
}
s.mu.Unlock()
close(s.closeCh)
if ln != nil {
_ = ln.Close()
}
for _, c := range conns {
_ = c.conn.Close()
}
return nil
}
// handleConn is the entry point for each accepted connection.
// The full implementation (TLS upgrade, startup handshake, auth,
// query loop) lives in session.go; this function just allocates
// the session and tracks it for clean shutdown.
func (s *Server) handleConn(ctx context.Context, conn net.Conn) {
sess := newSession(s, conn)
s.mu.Lock()
s.conns[sess] = struct{}{}
s.mu.Unlock()
defer func() {
s.mu.Lock()
delete(s.conns, sess)
s.mu.Unlock()
_ = conn.Close()
}()
sess.run(ctx)
}