From ed978142ebbea4ed8e4d3743f9ece42a334c6ea5 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 16 Aug 2026 23:26:03 +0800 Subject: [PATCH] fix: disable PostgreSQL TLS for refusing RDS endpoint --- .env.example | 4 +- .../20260816-disable-postgres-tls-d4a89c12.md | 94 +++++++++++++++++++ README.md | 2 +- README.zh-CN.md | 2 +- backend/README.md | 13 ++- backend/internal/postgres/config.go | 48 ++++------ backend/internal/postgres/config_test.go | 77 ++++++--------- backend/internal/postgres/open.go | 44 +++++---- backend/internal/postgres/open_test.go | 94 +++++++++++++++++-- deploy/ack/go-api.yaml | 6 -- deploy/ack/secrets.example.yaml | 19 +--- docs/DEPLOYMENT.md | 7 +- lib/server/database.ts | 30 +++--- scripts/check-ack-manifests.mjs | 16 +++- scripts/postgres-client.mjs | 26 ++--- tests/database-readiness-contract.test.ts | 29 +++++- tests/postgres-client-config.test.ts | 9 +- 17 files changed, 340 insertions(+), 180 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260816-disable-postgres-tls-d4a89c12.md diff --git a/.env.example b/.env.example index b3acd6d..a12cbf1 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.project-docs/30-worklog/tasks/20260816-disable-postgres-tls-d4a89c12.md b/.project-docs/30-worklog/tasks/20260816-disable-postgres-tls-d4a89c12.md new file mode 100644 index 0000000..ab05327 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260816-disable-postgres-tls-d4a89c12.md @@ -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. diff --git a/README.md b/README.md index a841339..c79b039 100644 --- a/README.md +++ b/README.md @@ -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 路由提供访问。 diff --git a/README.zh-CN.md b/README.zh-CN.md index b75082b..772b0ba 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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`。 diff --git a/backend/README.md b/backend/README.md index 899a490..625854c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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. diff --git a/backend/internal/postgres/config.go b/backend/internal/postgres/config.go index 19664c2..3347d3a 100644 --- a/backend/internal/postgres/config.go +++ b/backend/internal/postgres/config.go @@ -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 } diff --git a/backend/internal/postgres/config_test.go b/backend/internal/postgres/config_test.go index a885b0a..757c8be 100644 --- a/backend/internal/postgres/config_test.go +++ b/backend/internal/postgres/config_test.go @@ -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{ diff --git a/backend/internal/postgres/open.go b/backend/internal/postgres/open.go index 142d492..20a37c5 100644 --- a/backend/internal/postgres/open.go +++ b/backend/internal/postgres/open.go @@ -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 } diff --git a/backend/internal/postgres/open_test.go b/backend/internal/postgres/open_test.go index 0de2d23..d2a9648 100644 --- a/backend/internal/postgres/open_test.go +++ b/backend/internal/postgres/open_test.go @@ -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") } } diff --git a/deploy/ack/go-api.yaml b/deploy/ack/go-api.yaml index cfe807c..0794e46 100644 --- a/deploy/ack/go-api.yaml +++ b/deploy/ack/go-api.yaml @@ -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 diff --git a/deploy/ack/secrets.example.yaml b/deploy/ack/secrets.example.yaml index fe55725..0523848 100644 --- a/deploy/ack/secrets.example.yaml +++ b/deploy/ack/secrets.example.yaml @@ -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 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index a78f5d3..ef995a4 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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 配置后再部署。 首次发布前,部署负责人按顺序手工执行: diff --git a/lib/server/database.ts b/lib/server/database.ts index 4e3f408..d1cd026 100644 --- a/lib/server/database.ts +++ b/lib/server/database.ts @@ -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 { + 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(); diff --git a/scripts/check-ack-manifests.mjs b/scripts/check-ack-manifests.mjs index d500b98..106c982 100644 --- a/scripts/check-ack-manifests.mjs +++ b/scripts/check-ack-manifests.mjs @@ -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( diff --git a/scripts/postgres-client.mjs b/scripts/postgres-client.mjs index 4a4c138..7abf4c3 100644 --- a/scripts/postgres-client.mjs +++ b/scripts/postgres-client.mjs @@ -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(); diff --git a/tests/database-readiness-contract.test.ts b/tests/database-readiness-contract.test.ts index 8665cb9..18105a7 100644 --- a/tests/database-readiness-contract.test.ts +++ b/tests/database-readiness-contract.test.ts @@ -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"); + }); }); diff --git a/tests/postgres-client-config.test.ts b/tests/postgres-client-config.test.ts index 2d3b4d1..63db90d 100644 --- a/tests/postgres-client-config.test.ts +++ b/tests/postgres-client-config.test.ts @@ -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(); });