fix: disable PostgreSQL TLS for refusing RDS endpoint
This commit is contained in:
@@ -43,8 +43,8 @@ ZHINIAN_DATA_BACKEND=local
|
||||
DATABASE_URL=
|
||||
# Migration runner only: PostgreSQL role used by the Go API DATABASE_URL.
|
||||
DATABASE_APP_ROLE=
|
||||
# Optional URL query for an explicit RDS CA, for example:
|
||||
# postgresql://user:password@rds-host:5432/app?sslmode=verify-full&sslrootcert=/etc/zhinian/rds/ca.pem
|
||||
# PostgreSQL transport is intentionally plaintext; clients force sslmode=disable.
|
||||
# Example: postgresql://user:password@rds-host:5432/app?sslmode=disable
|
||||
DATABASE_POOL_MAX=10
|
||||
DATABASE_IDLE_TIMEOUT_MS=30000
|
||||
DATABASE_CONNECTION_TIMEOUT_MS=5000
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Task: Disable PostgreSQL TLS for Go production
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260816-disable-postgres-tls-d4a89c12
|
||||
- Mode: Feature
|
||||
- Branch: main
|
||||
- Worktree: D:\Datas\OthersProjects\NianAIGC
|
||||
- Base commit: acf368b6fe290f44157ada79448673ab11808f7c
|
||||
- Owner: codex
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Change the Go PostgreSQL adapter so production never negotiates TLS, even
|
||||
when the existing `DATABASE_URL` contains `sslmode=verify-full` and
|
||||
`sslrootcert` from the previous deployment template.
|
||||
- Align the Node migration client, ACK Secret/template checks, Go Deployment,
|
||||
environment example, and deployment guide with plaintext PostgreSQL.
|
||||
- Preserve explicit PostgreSQL selection, credentials, authorization,
|
||||
migrations, pooling, and fail-closed startup behavior.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- The user explicitly chose code-side plaintext transport instead of changing
|
||||
Alibaba Cloud RDS SSL configuration and accepted the resulting lack of
|
||||
database link encryption.
|
||||
- A newly built Go image must start with the existing TLS-bearing Secret; no
|
||||
live Secret or RDS control-plane mutation is part of this task.
|
||||
- Work test-first at the configuration/connection seam that reproduced the
|
||||
production `server refused TLS connection` failure.
|
||||
- Keep the change surgical and do not alter API, authentication, billing,
|
||||
storage, or database schema behavior.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Go `ParseConfig` removes case-insensitive `sslmode`/`sslrootcert` values,
|
||||
writes `sslmode=disable`, never reads a CA, and records plaintext mode.
|
||||
- Go `Open` normalizes the URL again and explicitly clears pgx `TLSConfig` and
|
||||
fallbacks, so a direct or legacy `Config` cannot negotiate TLS.
|
||||
- The migration Node client and retained server-only TypeScript adapter apply
|
||||
the same normalization and pass `ssl: false` to `pg`.
|
||||
- ACK no longer defines or mounts `zhinian-rds-ca`; migration and Go Secret
|
||||
examples use `sslmode=disable`.
|
||||
- Environment examples, both READMEs, deployment guidance, and manifest
|
||||
assertions now disclose plaintext PostgreSQL transport and require private
|
||||
network isolation.
|
||||
- No live Alibaba Cloud or ACK configuration was changed.
|
||||
|
||||
## Verification
|
||||
|
||||
- RED: new Go tests failed because the old parser read `sslrootcert`, defaulted
|
||||
to `verify-full`, and `Open` required TLS configuration.
|
||||
- RED: new Node tests failed because the old clients tried to read a missing CA
|
||||
and the ACK checker found the retained CA mount.
|
||||
- GREEN: `go test ./...` passed for every backend package.
|
||||
- GREEN: `npm test` passed 53 files / 160 tests.
|
||||
- GREEN: `npx tsc --noEmit --incremental false` passed.
|
||||
- GREEN: `npm run deploy:check` passed all seven checked-in ACK manifests.
|
||||
- GREEN: `node --check` passed for both modified Node scripts.
|
||||
- GREEN: `npm run build` exported all 14 static pages successfully.
|
||||
- `git diff --check` passed with line-ending warnings only.
|
||||
- First read-only `sol_reviewer` verdict: FAIL because `backend/README.md`
|
||||
retained TLS claims and tests did not observe a real PostgreSQL startup
|
||||
packet; both findings were fixed.
|
||||
- Final read-only `sol_reviewer` verdict: PASS on both Standards and Spec after
|
||||
the Go wire test observed plaintext StartupMessage `196608` (not SSLRequest
|
||||
`80877103`) and the executable TypeScript configuration test passed.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Build and deploy an immutable Go image, then verify startup bootstrap and
|
||||
`/api/ready` against the live RDS instance.
|
||||
- Reapplying the database Secret is not required for TLS removal because the
|
||||
new clients override old TLS parameters, but the checked-in plaintext Secret
|
||||
template should be used for future rotations.
|
||||
- Reassess TLS if the network boundary or compliance requirements change.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: RDS-001/current architecture/database deployment commitments.
|
||||
Proposal: record that production PostgreSQL transport is intentionally
|
||||
plaintext and code-enforced, superseding the prior verified-CA TLS default.
|
||||
Evidence: production RDS refused TLS, the deterministic diagnosis task
|
||||
`20260816-diagnose-go-rds-tls-9c4a7e21`, and the user's explicit 2026-08-16
|
||||
instruction not to modify Alibaba Cloud and to remove TLS in code.
|
||||
Future impact: future deployment templates and database clients must not
|
||||
silently reintroduce TLS without a new operator decision and compatible RDS
|
||||
configuration.
|
||||
Semantic conflicts: canonical current state and commitments still describe
|
||||
verified-CA TLS as the prior target.
|
||||
Human confirmation required: already received for plaintext production
|
||||
transport; canonical promotion still requires the serialized Integration
|
||||
Gate.
|
||||
@@ -225,7 +225,7 @@ cp .env.example .env.local
|
||||
- `ALI_OSS_*`:用于上传素材和生成结果转存
|
||||
- `ZHINIAN_DATA_BACKEND`:生产使用 `postgres`,开发可使用 `local`
|
||||
- `DATABASE_URL`:仅服务端读取的 PostgreSQL 连接串
|
||||
- `DATABASE_URL` 的 `sslmode` / `sslrootcert`:在同一个连接串中配置 RDS TLS;默认使用 `sslmode=verify-full`
|
||||
- PostgreSQL 客户端强制使用 `sslmode=disable` 且不读取 CA。ACK 到 RDS 的数据库链路为明文,只应使用 RDS 内网地址,并通过 VPC、安全组和白名单限制访问。
|
||||
|
||||
当 `ZHINIAN_DATA_BACKEND=local` 时,应用使用 `.runtime/data/web-app-state.json` 作为单实例开发数据层。生产 `postgres` 模式缺少连接配置会直接失败,不会静默写入本地 JSON。如果 OSS 未配置,上传和 mock 结果会保存到 `.runtime/uploads` 和 `.runtime/generated-results`,并通过 Go 路由提供访问。
|
||||
|
||||
|
||||
@@ -277,7 +277,7 @@ cp .env.example .env.local
|
||||
| `ALI_OSS_*` | 上传素材和生成结果转存配置 |
|
||||
| `ZHINIAN_DATA_BACKEND` | `postgres` 或 `local` |
|
||||
| `DATABASE_URL` | PostgreSQL 连接串(仅放 Secret) |
|
||||
| `DATABASE_URL` 的 `sslmode` / `sslrootcert` | 在同一个连接串中配置 RDS TLS;默认使用 `sslmode=verify-full` |
|
||||
| PostgreSQL 传输 | 客户端强制 `sslmode=disable` 且不读取 CA;ACK 到 RDS 的链路为明文,只应走内网并通过 VPC、安全组和白名单限制访问 |
|
||||
|
||||
`ZHINIAN_DATA_BACKEND=local` 时,应用使用 `.runtime/data/web-app-state.json` 作为单实例开发数据层;生产 `postgres` 模式配置错误会直接失败。未配置 OSS 时,上传和生成结果会写入 `.runtime/uploads` 与 `.runtime/generated-results`。
|
||||
|
||||
|
||||
@@ -20,9 +20,10 @@ Implemented Modules:
|
||||
tenant-scoped reports.
|
||||
- `templates`, `prompt`, `settings`, and `logging`: the remaining compatibility
|
||||
modules used by the HTTP surface.
|
||||
- `postgres`: fail-closed configuration, verified-CA TLS, readiness, atomic
|
||||
account mutations, and calls to the existing claim and wallet PostgreSQL
|
||||
functions. PostgreSQL is the production relational source of truth.
|
||||
- `postgres`: fail-closed configuration, code-enforced plaintext
|
||||
`sslmode=disable`, readiness, atomic account mutations, and calls to the
|
||||
existing claim and wallet PostgreSQL functions. PostgreSQL is the production
|
||||
relational source of truth.
|
||||
- `localstore`: a mutex-protected, non-durable, single-process development
|
||||
store covering the same business Module ports.
|
||||
- `httpapi`: the complete checked-in route compatibility surface.
|
||||
@@ -107,5 +108,7 @@ bootstrap-creates accounts.
|
||||
Production routing, Secret ownership, probes, and rollout commands are defined
|
||||
in [`../docs/DEPLOYMENT.md`](../docs/DEPLOYMENT.md) and `../deploy/ack/`. Go owns
|
||||
the session signing Secret and backend runtime configuration; the static Web
|
||||
workload receives neither. Validate RDS/CA, OSS, providers, Webhooks, embedded
|
||||
Worker recovery, and rollback behavior for each production release.
|
||||
workload receives neither. PostgreSQL does not use TLS, so production must use
|
||||
the RDS internal endpoint and restrict access with VPC boundaries, security
|
||||
groups, and allowlists. Validate RDS connectivity, OSS, providers, Webhooks,
|
||||
embedded Worker recovery, and rollback behavior for each production release.
|
||||
|
||||
@@ -2,7 +2,6 @@ package postgres
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -39,7 +38,7 @@ type Config struct {
|
||||
ApplicationName string
|
||||
}
|
||||
|
||||
func ParseConfig(getenv Getenv, readFile ReadFile) (Config, error) {
|
||||
func ParseConfig(getenv Getenv, _ ReadFile) (Config, error) {
|
||||
if getenv == nil {
|
||||
return Config{}, fmt.Errorf("environment getter is required")
|
||||
}
|
||||
@@ -49,10 +48,9 @@ func ParseConfig(getenv Getenv, readFile ReadFile) (Config, error) {
|
||||
ConnectionTimeout: 10 * time.Second,
|
||||
StatementTimeout: 30 * time.Second,
|
||||
ApplicationName: "zhinian-go",
|
||||
// DATABASE_URL is the only database setting. Production defaults to
|
||||
// full TLS verification; sslrootcert may be supplied in the URL when
|
||||
// the RDS CA is not part of the container's system trust store.
|
||||
SSLMode: SSLVerifyFull,
|
||||
// Production RDS currently refuses TLS. Keep the transport choice in
|
||||
// code so an older Secret cannot silently re-enable negotiation.
|
||||
SSLMode: SSLDisable,
|
||||
}
|
||||
|
||||
backend := strings.ToLower(strings.TrimSpace(getenv("ZHINIAN_DATA_BACKEND")))
|
||||
@@ -97,35 +95,23 @@ func ParseConfig(getenv Getenv, readFile ReadFile) (Config, error) {
|
||||
return Config{}, fmt.Errorf("DATABASE_URL must use the postgres:// or postgresql:// scheme")
|
||||
}
|
||||
query := parsed.Query()
|
||||
for key := range query {
|
||||
for key, values := range query {
|
||||
lower := strings.ToLower(key)
|
||||
if strings.HasPrefix(lower, "ssl") && lower != "sslmode" && lower != "sslrootcert" {
|
||||
return Config{}, fmt.Errorf("DATABASE_URL contains unsupported SSL query parameter %s; use sslmode and optional sslrootcert", key)
|
||||
return Config{}, fmt.Errorf("DATABASE_URL contains unsupported SSL query parameter %s; PostgreSQL transport is forced to sslmode=disable", key)
|
||||
}
|
||||
if lower == "sslmode" {
|
||||
for _, value := range values {
|
||||
mode := strings.ToLower(strings.TrimSpace(value))
|
||||
if mode != "" && mode != string(SSLDisable) && mode != string(SSLVerifyFull) {
|
||||
return Config{}, fmt.Errorf("DATABASE_URL sslmode must be 'disable' or 'verify-full'")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if mode := strings.ToLower(strings.TrimSpace(query.Get("sslmode"))); mode != "" {
|
||||
cfg.SSLMode = SSLMode(mode)
|
||||
}
|
||||
switch cfg.SSLMode {
|
||||
case SSLDisable:
|
||||
case SSLVerifyFull:
|
||||
cfg.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if path := strings.TrimSpace(query.Get("sslrootcert")); path != "" {
|
||||
if readFile == nil {
|
||||
return Config{}, fmt.Errorf("a certificate reader is required when DATABASE_URL contains sslrootcert")
|
||||
}
|
||||
pem, readErr := readFile(path)
|
||||
if readErr != nil {
|
||||
return Config{}, fmt.Errorf("read DATABASE_URL sslrootcert: %w", readErr)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
if !roots.AppendCertsFromPEM(pem) {
|
||||
return Config{}, fmt.Errorf("DATABASE_URL sslrootcert does not contain a valid CA certificate")
|
||||
}
|
||||
cfg.TLSConfig.RootCAs = roots
|
||||
}
|
||||
default:
|
||||
return Config{}, fmt.Errorf("DATABASE_URL sslmode must be 'disable' or 'verify-full'")
|
||||
cfg.DatabaseURL, err = forcePlaintextDatabaseURL(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("normalize DATABASE_URL: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -60,52 +53,36 @@ func TestParseConfigAcceptsOnlyPostgresSchemesAndRejectsUnsupportedSSLQueryParam
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigBuildsVerifyFullTLSFromURLCA(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caPath := filepath.Join(dir, "ca.pem")
|
||||
const ca = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n"
|
||||
read := func(path string) ([]byte, error) {
|
||||
if path != caPath {
|
||||
t.Fatalf("read path = %q, want %q", path, caPath)
|
||||
}
|
||||
return []byte(ca), nil
|
||||
}
|
||||
_, err := ParseConfig(env(map[string]string{
|
||||
"ZHINIAN_DATA_BACKEND": "postgres",
|
||||
"DATABASE_URL": "postgresql://db.example/app?sslmode=verify-full&sslrootcert=" + url.QueryEscape(caPath),
|
||||
}), read)
|
||||
if err == nil || !strings.Contains(err.Error(), "CA certificate") {
|
||||
t.Fatalf("ParseConfig() error = %v, want invalid CA certificate error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigVerifyFullBuildsRootsWithoutDisablingVerification(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test CA"},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
func TestParseConfigForcesPlaintextAndDoesNotReadURLCA(t *testing.T) {
|
||||
readCalled := false
|
||||
cfg, err := ParseConfig(env(map[string]string{
|
||||
"ZHINIAN_DATA_BACKEND": "postgres",
|
||||
"DATABASE_URL": "postgresql://db.example/app?sslmode=verify-full&sslrootcert=%2Fca.pem",
|
||||
}), func(string) ([]byte, error) { return ca, nil })
|
||||
"DATABASE_URL": "postgresql://db.example/app?connect_timeout=5&sslmode=verify-full&sslrootcert=%2Fetc%2Fzhinian%2Frds%2Fca.pem",
|
||||
}), func(string) ([]byte, error) {
|
||||
readCalled = true
|
||||
return nil, os.ErrNotExist
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfig() error = %v", err)
|
||||
}
|
||||
if cfg.TLSConfig == nil || cfg.TLSConfig.RootCAs == nil {
|
||||
t.Fatal("TLSConfig.RootCAs is nil")
|
||||
if readCalled {
|
||||
t.Fatal("ParseConfig() read sslrootcert, want plaintext configuration without CA access")
|
||||
}
|
||||
if cfg.TLSConfig.InsecureSkipVerify {
|
||||
t.Fatal("TLSConfig.InsecureSkipVerify = true, want full certificate and hostname verification")
|
||||
if cfg.SSLMode != SSLDisable || cfg.TLSConfig != nil {
|
||||
t.Fatalf("TLS configuration = mode %q config %v, want disabled and nil", cfg.SSLMode, cfg.TLSConfig)
|
||||
}
|
||||
parsed, err := url.Parse(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse normalized DATABASE_URL: %v", err)
|
||||
}
|
||||
if got := parsed.Query().Get("sslmode"); got != "disable" {
|
||||
t.Fatalf("normalized sslmode = %q, want disable", got)
|
||||
}
|
||||
if parsed.Query().Has("sslrootcert") {
|
||||
t.Fatal("normalized DATABASE_URL retains sslrootcert")
|
||||
}
|
||||
if got := parsed.Query().Get("connect_timeout"); got != "5" {
|
||||
t.Fatalf("normalized connect_timeout = %q, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,11 +97,11 @@ func TestParseConfigDefaultsAndNumericValidation(t *testing.T) {
|
||||
if cfg.PoolMax != 10 || cfg.IdleTimeout != 30*time.Second || cfg.ConnectionTimeout != 10*time.Second || cfg.StatementTimeout != 30*time.Second {
|
||||
t.Fatalf("unexpected defaults: %+v", cfg)
|
||||
}
|
||||
if cfg.ApplicationName != "zhinian-go" || cfg.SSLMode != SSLVerifyFull {
|
||||
if cfg.ApplicationName != "zhinian-go" || cfg.SSLMode != SSLDisable {
|
||||
t.Fatalf("unexpected identity/TLS defaults: %+v", cfg)
|
||||
}
|
||||
if cfg.TLSConfig == nil || cfg.TLSConfig.InsecureSkipVerify {
|
||||
t.Fatal("default PostgreSQL configuration must verify the server certificate")
|
||||
if cfg.TLSConfig != nil {
|
||||
t.Fatal("default PostgreSQL configuration must not negotiate TLS")
|
||||
}
|
||||
|
||||
for name, value := range map[string]string{
|
||||
|
||||
@@ -3,7 +3,9 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -27,7 +29,11 @@ func Open(ctx context.Context, config Config) (*Module, error) {
|
||||
if config.Backend != BackendPostgres {
|
||||
return nil, fmt.Errorf("unsupported data backend %q", config.Backend)
|
||||
}
|
||||
poolConfig, err := pgxpool.ParseConfig(config.DatabaseURL)
|
||||
databaseURL, err := forcePlaintextDatabaseURL(config.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse DATABASE_URL: %w", err)
|
||||
}
|
||||
poolConfig, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse DATABASE_URL: %w", err)
|
||||
}
|
||||
@@ -36,23 +42,8 @@ func Open(ctx context.Context, config Config) (*Module, error) {
|
||||
poolConfig.ConnConfig.ConnectTimeout = config.ConnectionTimeout
|
||||
poolConfig.ConnConfig.RuntimeParams["statement_timeout"] = strconv.FormatInt(config.StatementTimeout.Milliseconds(), 10)
|
||||
poolConfig.ConnConfig.RuntimeParams["application_name"] = config.ApplicationName
|
||||
switch config.SSLMode {
|
||||
case SSLDisable:
|
||||
poolConfig.ConnConfig.TLSConfig = nil
|
||||
poolConfig.ConnConfig.Fallbacks = nil
|
||||
case SSLVerifyFull:
|
||||
if config.TLSConfig == nil {
|
||||
return nil, fmt.Errorf("TLS configuration is required when DATABASE_URL sslmode=verify-full")
|
||||
}
|
||||
tlsConfig := config.TLSConfig.Clone()
|
||||
if tlsConfig.ServerName == "" {
|
||||
tlsConfig.ServerName = poolConfig.ConnConfig.Host
|
||||
}
|
||||
poolConfig.ConnConfig.TLSConfig = tlsConfig
|
||||
poolConfig.ConnConfig.Fallbacks = nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported DATABASE_URL sslmode %q", config.SSLMode)
|
||||
}
|
||||
poolConfig.ConnConfig.TLSConfig = nil
|
||||
poolConfig.ConnConfig.Fallbacks = nil
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open PostgreSQL pool: %w", err)
|
||||
@@ -61,6 +52,23 @@ func Open(ctx context.Context, config Config) (*Module, error) {
|
||||
return &Module{pool: adapter, Store: NewDatabase(config, adapter)}, nil
|
||||
}
|
||||
|
||||
func forcePlaintextDatabaseURL(databaseURL string) (string, error) {
|
||||
parsed, err := url.Parse(databaseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
query := parsed.Query()
|
||||
for key := range query {
|
||||
switch strings.ToLower(key) {
|
||||
case "sslmode", "sslrootcert":
|
||||
query.Del(key)
|
||||
}
|
||||
}
|
||||
query.Set("sslmode", string(SSLDisable))
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
type pgxPoolAdapter struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@ package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOpenLocalReturnsStoreWithoutPool(t *testing.T) {
|
||||
@@ -48,13 +52,87 @@ func TestParseConfigIgnoresPostgresTLSSettingsForLocalBackend(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenPostgresRejectsInvalidConfiguredTLSMode(t *testing.T) {
|
||||
_, err := Open(context.Background(), Config{
|
||||
Backend: BackendPostgres,
|
||||
DatabaseURL: "postgresql://app:secret@db.example/app",
|
||||
SSLMode: SSLMode("prefer"),
|
||||
func TestOpenPostgresForcesPlaintextDespiteTLSBearingConfig(t *testing.T) {
|
||||
module, err := Open(context.Background(), Config{
|
||||
Backend: BackendPostgres,
|
||||
DatabaseURL: "postgresql://app:secret@127.0.0.1:1/app?sslmode=verify-full&sslrootcert=/missing/ca.pem",
|
||||
SSLMode: SSLVerifyFull,
|
||||
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS13},
|
||||
PoolMax: 1,
|
||||
ApplicationName: "zhinian-go-test",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported DATABASE_URL sslmode") {
|
||||
t.Fatalf("Open() error = %v, want DATABASE_URL sslmode error", err)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v, want TLS settings ignored", err)
|
||||
}
|
||||
module.Close()
|
||||
}
|
||||
|
||||
func TestOpenPostgresSendsPlaintextStartupMessageWithoutTLSFallback(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen() error = %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
firstPacket := make(chan [8]byte, 1)
|
||||
serverError := make(chan error, 1)
|
||||
go func() {
|
||||
connection, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
serverError <- acceptErr
|
||||
return
|
||||
}
|
||||
defer connection.Close()
|
||||
var packet [8]byte
|
||||
if _, readErr := io.ReadFull(connection, packet[:]); readErr != nil {
|
||||
serverError <- readErr
|
||||
return
|
||||
}
|
||||
firstPacket <- packet
|
||||
}()
|
||||
|
||||
module, err := Open(context.Background(), Config{
|
||||
Backend: BackendPostgres,
|
||||
DatabaseURL: "postgresql://app:secret@" + listener.Addr().String() + "/app?sslmode=verify-full&sslrootcert=/missing/ca.pem",
|
||||
SSLMode: SSLVerifyFull,
|
||||
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS13},
|
||||
PoolMax: 1,
|
||||
ConnectionTimeout: time.Second,
|
||||
StatementTimeout: time.Second,
|
||||
ApplicationName: "zhinian-go-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
defer module.Close()
|
||||
|
||||
adapter, ok := module.pool.(*pgxPoolAdapter)
|
||||
if !ok {
|
||||
t.Fatalf("pool type = %T, want *pgxPoolAdapter", module.pool)
|
||||
}
|
||||
connectionConfig := adapter.pool.Config().ConnConfig
|
||||
if connectionConfig.TLSConfig != nil {
|
||||
t.Fatal("pgx TLSConfig is not nil")
|
||||
}
|
||||
if len(connectionConfig.Fallbacks) != 0 {
|
||||
t.Fatalf("pgx Fallbacks = %v, want empty", connectionConfig.Fallbacks)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if readinessErr := module.Store.Readiness(ctx); readinessErr == nil {
|
||||
t.Fatal("Readiness() error = nil, want fake server disconnect")
|
||||
}
|
||||
|
||||
select {
|
||||
case packet := <-firstPacket:
|
||||
protocolCode := binary.BigEndian.Uint32(packet[4:8])
|
||||
if protocolCode != 196608 {
|
||||
t.Fatalf("first PostgreSQL protocol code = %d, want plaintext StartupMessage 196608 (SSLRequest is 80877103)", protocolCode)
|
||||
}
|
||||
case serverErr := <-serverError:
|
||||
t.Fatalf("fake PostgreSQL server error = %v", serverErr)
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for PostgreSQL startup packet")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +63,6 @@ spec:
|
||||
name: zhinian-go-bootstrap
|
||||
key: ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD
|
||||
volumeMounts:
|
||||
- name: rds-ca
|
||||
mountPath: /etc/zhinian/rds
|
||||
readOnly: true
|
||||
- name: data
|
||||
mountPath: /var/lib/zhinian
|
||||
- name: tmp
|
||||
@@ -106,9 +103,6 @@ spec:
|
||||
runAsGroup: 10001
|
||||
readOnlyRootFilesystem: true
|
||||
volumes:
|
||||
- name: rds-ca
|
||||
secret:
|
||||
secretName: zhinian-rds-ca
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
- name: tmp
|
||||
|
||||
@@ -3,24 +3,13 @@
|
||||
# intentionally not injected by the minimal production Deployment.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: zhinian-rds-ca
|
||||
namespace: zhinian
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Public CA certificate for the RDS endpoint used in DATABASE_URL.
|
||||
ca.pem: |
|
||||
REPLACE_WITH_RDS_CA_PEM
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: zhinian-migration-db
|
||||
namespace: zhinian
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Manual schema execution only: run database/migrations/*.sql with this role.
|
||||
DATABASE_URL: "postgresql://MIGRATION_USER:MIGRATION_PASSWORD@RDS_INTERNAL_HOST:5432/APP_DATABASE?sslmode=verify-full&sslrootcert=/etc/zhinian/rds/ca.pem"
|
||||
# Manual schema execution only. This connection intentionally uses plaintext.
|
||||
DATABASE_URL: "postgresql://MIGRATION_USER:MIGRATION_PASSWORD@RDS_INTERNAL_HOST:5432/APP_DATABASE?sslmode=disable"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
@@ -40,8 +29,8 @@ metadata:
|
||||
namespace: zhinian
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Application role (least privilege): grants applied manually after the SQL.
|
||||
DATABASE_URL: "postgresql://APP_USER:APP_PASSWORD@RDS_INTERNAL_HOST:5432/APP_DATABASE?sslmode=verify-full&sslrootcert=/etc/zhinian/rds/ca.pem"
|
||||
# Application role (least privilege). PostgreSQL transport is plaintext.
|
||||
DATABASE_URL: "postgresql://APP_USER:APP_PASSWORD@RDS_INTERNAL_HOST:5432/APP_DATABASE?sslmode=disable"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
|
||||
@@ -47,8 +47,11 @@ docker build -f backend/Dockerfile.alpine \
|
||||
生产固定使用 PostgreSQL。数据库连接只通过 Go Deployment 的 Secret 注入,不得进入
|
||||
Web 镜像或 ConfigMap。优先使用 RDS 内网地址,并仅对白名单中的 ACK 工作负载网段放行。
|
||||
|
||||
`DATABASE_URL` 应包含完整 TLS 参数。默认建议 `sslmode=verify-full`;需要自定义 CA 时,
|
||||
把 `sslrootcert=/etc/zhinian/rds/ca.pem` 写入同一个连接串并挂载对应 Secret。
|
||||
`DATABASE_URL` 必须使用 `sslmode=disable`。Go 和手工迁移使用的 Node 客户端都会强制
|
||||
归一化为该值;即使旧 Secret 仍包含 TLS 参数,客户端也不会读取 CA 或协商 TLS。
|
||||
这表示 ACK 到 RDS 的数据库流量不使用 TLS、链路内容为明文。只应使用 RDS 内网地址,
|
||||
并通过 VPC、安全组和白名单严格限制访问;若未来需要链路加密,必须同时修改客户端策略
|
||||
和 RDS 配置后再部署。
|
||||
|
||||
首次发布前,部署负责人按顺序手工执行:
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Pool, type PoolClient, type PoolConfig, type QueryResult, type QueryResultRow } from "pg";
|
||||
|
||||
export type DataBackend = "local" | "postgres";
|
||||
@@ -97,33 +96,30 @@ function getPool(): Pool {
|
||||
|
||||
const connectionString = process.env.DATABASE_URL?.trim();
|
||||
if (!connectionString) throw new Error("DATABASE_URL is required when ZHINIAN_DATA_BACKEND=postgres");
|
||||
const parsed = assertConnectionStringContract(connectionString);
|
||||
const config: PoolConfig = {
|
||||
connectionString,
|
||||
...buildPlaintextPoolConfig(connectionString),
|
||||
max: positiveInteger("DATABASE_POOL_MAX", 10),
|
||||
idleTimeoutMillis: nonNegativeInteger("DATABASE_IDLE_TIMEOUT_MS", 30_000),
|
||||
connectionTimeoutMillis: positiveInteger("DATABASE_CONNECTION_TIMEOUT_MS", 10_000),
|
||||
statement_timeout: positiveInteger("DATABASE_STATEMENT_TIMEOUT_MS", 30_000),
|
||||
application_name: process.env.DATABASE_APPLICATION_NAME?.trim() || "zhinian-web"
|
||||
};
|
||||
const sslMode = parsed.searchParams.get("sslmode")?.trim().toLowerCase() || "verify-full";
|
||||
if (sslMode === "verify-full") {
|
||||
const caPath = parsed.searchParams.get("sslrootcert")?.trim();
|
||||
config.ssl = {
|
||||
...(caPath ? { ca: readFileSync(caPath, "utf8") } : {}),
|
||||
rejectUnauthorized: true
|
||||
};
|
||||
} else if (sslMode !== "disable") {
|
||||
throw new Error("DATABASE_URL sslmode must be 'disable' or 'verify-full'");
|
||||
}
|
||||
parsed.searchParams.delete("sslmode");
|
||||
parsed.searchParams.delete("sslrootcert");
|
||||
config.connectionString = parsed.toString();
|
||||
pool = new Pool(config);
|
||||
pool.on("error", (error) => console.error("Unexpected PostgreSQL pool error", error));
|
||||
return pool;
|
||||
}
|
||||
|
||||
export function buildPlaintextPoolConfig(
|
||||
connectionString: string
|
||||
): Pick<PoolConfig, "connectionString" | "ssl"> {
|
||||
const parsed = assertConnectionStringContract(connectionString);
|
||||
for (const key of [...parsed.searchParams.keys()]) {
|
||||
if (["sslmode", "sslrootcert"].includes(key.toLowerCase())) parsed.searchParams.delete(key);
|
||||
}
|
||||
parsed.searchParams.set("sslmode", "disable");
|
||||
return { connectionString: parsed.toString(), ssl: false };
|
||||
}
|
||||
|
||||
function assertConnectionStringContract(connectionString: string): URL {
|
||||
let parsed: URL;
|
||||
try {
|
||||
@@ -138,7 +134,7 @@ function assertConnectionStringContract(connectionString: string): URL {
|
||||
const unsupportedSSLParameters = sslParameters.filter((key) => !["sslmode", "sslrootcert"].includes(key.toLowerCase()));
|
||||
if (unsupportedSSLParameters.length > 0) {
|
||||
throw new Error(
|
||||
`DATABASE_URL contains unsupported SSL query parameters (${unsupportedSSLParameters.join(", ")}); use sslmode and optional sslrootcert`
|
||||
`DATABASE_URL contains unsupported SSL query parameters (${unsupportedSSLParameters.join(", ")}); PostgreSQL transport is forced to sslmode=disable`
|
||||
);
|
||||
}
|
||||
const sslMode = parsed.searchParams.get("sslmode")?.trim().toLowerCase();
|
||||
|
||||
@@ -39,13 +39,20 @@ assert(goApi.includes("name: zhinian-go-runtime"), "Go API must consume the Go r
|
||||
assert(goApi.includes("name: zhinian-go-auth"), "Go API must own the browser session signing Secret");
|
||||
assert(!goApi.includes("name: zhinian-web-auth"), "Go API must not reference the removed Web auth Secret");
|
||||
assert(goApi.includes("name: zhinian-go-bootstrap"), "Go API must receive bootstrap administrator credentials");
|
||||
assert(goApi.includes("secretName: zhinian-rds-ca"), "Go API must mount the optional RDS CA used by DATABASE_URL");
|
||||
assert(!goApi.includes("DATABASE_SSL_MODE"), "Go API must keep database TLS inside DATABASE_URL");
|
||||
assert(!goApi.includes("DATABASE_CA_CERT_PATH"), "Go API must keep database TLS inside DATABASE_URL");
|
||||
assert(!goApi.includes("rds-ca"), "Go API must not mount an RDS CA when PostgreSQL TLS is disabled");
|
||||
assert(!goApi.includes("/etc/zhinian/rds"), "Go API must not retain the removed RDS CA path");
|
||||
assert(!/\bTLS\b/i.test(goApi), "Go API manifest must not retain PostgreSQL TLS configuration");
|
||||
assert(!goApi.includes("DATABASE_SSL_MODE"), "Go API must not receive a separate database SSL mode");
|
||||
assert(!goApi.includes("DATABASE_CA_CERT_PATH"), "Go API must not receive a database CA path");
|
||||
|
||||
const secrets = read("secrets.example.yaml");
|
||||
assert(secrets.includes("name: zhinian-go-auth"), "Example secrets must name Go as the session Secret owner");
|
||||
assert(!secrets.includes("name: zhinian-web-auth"), "Example secrets must not retain the removed Web auth Secret");
|
||||
assert(!secrets.includes("zhinian-rds-ca"), "Example secrets must not define the removed RDS CA Secret");
|
||||
assert(!secrets.includes("sslrootcert"), "Example DATABASE_URL values must not reference an RDS CA");
|
||||
assert(!secrets.includes("verify-full"), "Example DATABASE_URL values must not request TLS");
|
||||
assert(!/\bTLS\b/i.test(secrets), "Example secrets must not retain PostgreSQL TLS configuration");
|
||||
assert((secrets.match(/sslmode=disable/g) ?? []).length === 2, "Migration and Go DATABASE_URL examples must disable TLS");
|
||||
|
||||
const namespace = read("namespace.yaml");
|
||||
assert(namespace.includes("kind: Namespace"), "namespace.yaml must define a Namespace");
|
||||
@@ -131,6 +138,9 @@ assert(
|
||||
deploymentDocs.includes("Pod 重建或滚动升级同样会永久丢失上传文件和生成结果"),
|
||||
"Deployment docs must disclose emptyDir data loss across Go Pod replacement",
|
||||
);
|
||||
assert(deploymentDocs.includes("不使用 TLS"), "Deployment docs must disclose plaintext PostgreSQL transport");
|
||||
assert(!deploymentDocs.includes("sslrootcert"), "Deployment docs must not instruct operators to mount an RDS CA");
|
||||
assert(!deploymentDocs.includes("verify-full"), "Deployment docs must not instruct operators to enable PostgreSQL TLS");
|
||||
|
||||
const gitIgnore = readRepository(".gitignore");
|
||||
assert(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
@@ -14,8 +13,14 @@ export function createPostgresPool({ env = process.env, applicationName = "zhini
|
||||
if (!connectionString) throw new Error("DATABASE_URL is required when ZHINIAN_DATA_BACKEND=postgres");
|
||||
const parsed = assertConnectionStringContract(connectionString);
|
||||
|
||||
for (const key of [...parsed.searchParams.keys()]) {
|
||||
if (["sslmode", "sslrootcert"].includes(key.toLowerCase())) parsed.searchParams.delete(key);
|
||||
}
|
||||
parsed.searchParams.set("sslmode", "disable");
|
||||
|
||||
const config = {
|
||||
connectionString,
|
||||
connectionString: parsed.toString(),
|
||||
ssl: false,
|
||||
max: positiveInteger(env, "DATABASE_POOL_MAX", 10),
|
||||
idleTimeoutMillis: nonNegativeInteger(env, "DATABASE_IDLE_TIMEOUT_MS", 30_000),
|
||||
connectionTimeoutMillis: positiveInteger(env, "DATABASE_CONNECTION_TIMEOUT_MS", 10_000),
|
||||
@@ -23,21 +28,6 @@ export function createPostgresPool({ env = process.env, applicationName = "zhini
|
||||
application_name: applicationName
|
||||
};
|
||||
|
||||
const sslMode = parsed.searchParams.get("sslmode")?.trim().toLowerCase() || "verify-full";
|
||||
if (sslMode === "verify-full") {
|
||||
const caPath = parsed.searchParams.get("sslrootcert")?.trim();
|
||||
config.ssl = {
|
||||
...(caPath ? { ca: readFileSync(caPath, "utf8") } : {}),
|
||||
rejectUnauthorized: true
|
||||
};
|
||||
} else if (sslMode !== "disable") {
|
||||
throw new Error("DATABASE_URL sslmode must be 'disable' or 'verify-full'");
|
||||
}
|
||||
|
||||
parsed.searchParams.delete("sslmode");
|
||||
parsed.searchParams.delete("sslrootcert");
|
||||
config.connectionString = parsed.toString();
|
||||
|
||||
return new Pool(config);
|
||||
}
|
||||
|
||||
@@ -66,7 +56,7 @@ function assertConnectionStringContract(connectionString) {
|
||||
const unsupportedSSLParameters = sslParameters.filter((key) => !["sslmode", "sslrootcert"].includes(key.toLowerCase()));
|
||||
if (unsupportedSSLParameters.length > 0) {
|
||||
throw new Error(
|
||||
`DATABASE_URL contains unsupported SSL query parameters (${unsupportedSSLParameters.join(", ")}); use sslmode and optional sslrootcert`
|
||||
`DATABASE_URL contains unsupported SSL query parameters (${unsupportedSSLParameters.join(", ")}); PostgreSQL transport is forced to sslmode=disable`
|
||||
);
|
||||
}
|
||||
const sslMode = parsed.searchParams.get("sslmode")?.trim().toLowerCase();
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
import { buildPlaintextPoolConfig } from "../lib/server/database";
|
||||
|
||||
describe("PostgreSQL readiness contract", () => {
|
||||
it("checks every runtime table and both atomic functions", async () => {
|
||||
@@ -22,4 +26,27 @@ describe("PostgreSQL readiness contract", () => {
|
||||
expect(source).toContain("claim_generation_jobs(text,integer,integer)");
|
||||
expect(source).toContain("billing_post_wallet_entry(text,text,text,text,text,bigint,text,text,text,jsonb)");
|
||||
});
|
||||
|
||||
it("forces the legacy server adapter to use plaintext without reading a CA", async () => {
|
||||
const source = await readFile(new URL("../lib/server/database.ts", import.meta.url), "utf8");
|
||||
|
||||
expect(source).not.toContain("readFileSync");
|
||||
expect(source).not.toContain("rejectUnauthorized");
|
||||
expect(source).not.toContain('|| "verify-full"');
|
||||
expect(source).toContain('["sslmode", "sslrootcert"].includes(key.toLowerCase())');
|
||||
expect(source).toContain('parsed.searchParams.set("sslmode", "disable")');
|
||||
expect(source).toContain("ssl: false");
|
||||
});
|
||||
|
||||
it("normalizes a legacy TLS URL into an executable plaintext pool config", () => {
|
||||
const config = buildPlaintextPoolConfig(
|
||||
"postgresql://app:secret@rds.example:5432/app?connect_timeout=5&sslmode=verify-full&sslrootcert=/missing/ca.pem"
|
||||
);
|
||||
|
||||
expect(config.ssl).toBe(false);
|
||||
expect(config.connectionString).toContain("sslmode=disable");
|
||||
expect(config.connectionString).not.toContain("verify-full");
|
||||
expect(config.connectionString).not.toContain("sslrootcert");
|
||||
expect(config.connectionString).toContain("connect_timeout=5");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,14 +12,19 @@ describe("PostgreSQL script configuration", () => {
|
||||
expect(getScriptDataBackend({ NODE_ENV: "test", ZHINIAN_DATA_BACKEND: "postgres" })).toBe("postgres");
|
||||
});
|
||||
|
||||
it("accepts SSL settings in the single DATABASE_URL value", async () => {
|
||||
it("forces plaintext even when DATABASE_URL requests verified TLS", async () => {
|
||||
const pool = createPostgresPool({
|
||||
env: {
|
||||
NODE_ENV: "test",
|
||||
ZHINIAN_DATA_BACKEND: "postgres",
|
||||
DATABASE_URL: "postgresql://app:secret@rds.example:5432/app?sslmode=verify-full"
|
||||
DATABASE_URL: "postgresql://app:secret@rds.example:5432/app?connect_timeout=5&sslmode=verify-full&sslrootcert=/missing/ca.pem"
|
||||
}
|
||||
});
|
||||
|
||||
expect(pool.options.ssl).toBe(false);
|
||||
expect(pool.options.connectionString).toContain("sslmode=disable");
|
||||
expect(pool.options.connectionString).not.toContain("sslrootcert");
|
||||
expect(pool.options.connectionString).toContain("connect_timeout=5");
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user