fix: disable PostgreSQL TLS for refusing RDS endpoint

This commit is contained in:
2026-08-16 23:26:03 +08:00
parent acf368b6fe
commit ed978142eb
17 changed files with 340 additions and 180 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 路由提供访问。

View File

@@ -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` 且不读取 CAACK 到 RDS 的链路为明文,只应走内网并通过 VPC、安全组和白名单限制访问 |
`ZHINIAN_DATA_BACKEND=local` 时,应用使用 `.runtime/data/web-app-state.json` 作为单实例开发数据层;生产 `postgres` 模式配置错误会直接失败。未配置 OSS 时,上传和生成结果会写入 `.runtime/uploads``.runtime/generated-results`

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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{

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 配置后再部署。
首次发布前,部署负责人按顺序手工执行:

View File

@@ -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();

View File

@@ -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(

View File

@@ -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();

View File

@@ -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");
});
});

View File

@@ -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();
});