Trust mode (v1.0 default) accepts anyone; that's fine for embedded
demo but unshipping a multi-client database without credentials
would be irresponsible. This commit adds two of libpq's three
standard auth flows. SCRAM-SHA-256 is Phase 5.1 — pgx/psql both
fall back to MD5 cleanly when the server advertises only md5, so
v1.0's functional coverage is complete with the pair landed here.
Auth subsystem
--------------
`hbrtl/pgserver/auth.go` adds:
* An in-memory role registry: `roleMap map[string]*role` guarded by
sync.RWMutex. Reads (lookupRole) are hot-path during connection
startup so the RWMutex lets multiple sessions auth in parallel
without serialising through a plain Mutex.
* `AddRole(name, password)` / `RemoveRole(name)` Go API consumed
by the new HB_FUNCs `PG_ADD_ROLE` / `PG_REMOVE_ROLE` (see
register.go). Bootstrap PRG idiom:
PG_ADD_ROLE("alice", "swordfish")
PG_ADD_ROLE("bob", "hunter2")
PG_SERVER_START(":5432", "md5")
* `authPassword()` — cleartext PasswordMessage exchange. The wire
payload is plain so intended for TLS-protected links only;
Phase 6 ties the warning to actual TLS detection on the session.
* `authMD5()` — libpq's md5 challenge:
server → AuthenticationMD5Password{salt: 4 random bytes}
client → "md5" || md5_hex( md5_hex(password || user) || salt )
We recompute the canonical hash from the stored plaintext and
compare. md5Challenge() is exported for pinning by a Go unit
test (vector cross-checked against libpq's fe-auth-md5.c).
Salt is sourced from crypto/rand on every challenge so replay
attacks against a captured wire trace can't reuse a prior hash.
Dispatch matrix (Config.AuthMode → flow):
"" / "trust" → AuthenticationOk immediately, no lookup
"password" → authPassword()
"md5" → authMD5()
anything else→ 28000 + connection close
Tests
-----
Unit (hbrtl/pgserver/pgserver_test.go):
PASS TestMD5Challenge (vector + determinism + diff)
PASS TestRoleRegistry (add/replace/remove/lookup)
Integration (tests/pgserver/run.sh):
PASS Simple Query: SELECT 1, 'hello'
PASS Multi-statement Simple Query
PASS Transaction control: BEGIN/COMMIT round-trip
PASS MD5 auth: wrong password rejected
PASS MD5 auth: correct password accepted
End-to-end matrix with real psql:
wrong password → "ERROR: md5 authentication failed for user 'alice'"
correct password → SELECT returns row
unknown user → "ERROR: md5 authentication failed for user 'eve'"
password mode → cleartext exchange works equivalently
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 5/5 ✓ (up from 3/3 in Phase 4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
121 lines
3.1 KiB
Go
121 lines
3.1 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
srv := NewServer(ctx.T.VM(), cfg)
|
|
setActiveServer(srv)
|
|
fmt.Fprintf(os.Stderr, "pgserver: listening on %s (auth=%s)\n",
|
|
cfg.listenAddr(), defaultStr(cfg.AuthMode, "trust"))
|
|
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
|
|
}
|