HH-445: deploy production observability and runbooks (#96)
* HH-445: deploy production observability and runbooks * fix(ops): share production database DSN * fix(HH-445): enforce database TLS gate * fix(HH-445): preserve production serve command * fix(prod): require external database dependencies * fix(prod): unify database host rejection gates * test(prod): enforce exact database TLS runbook contract --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -292,15 +294,18 @@ func TestValidate_ReleaseDatabaseTLS(t *testing.T) {
|
||||
}{
|
||||
{"external disable", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=disable", true},
|
||||
{"external missing sslmode", "postgres://gochat:database-secret@db.example.test:5432/gochat", true},
|
||||
{"external duplicate downgrade", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=disable&sslmode=verify-full", true},
|
||||
{"external duplicate allowed", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=verify-full&sslmode=verify-full", true},
|
||||
{"external non-fixed certificate path", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=verify-full&sslrootcert=/tmp/ca.crt&sslcert=/run/secrets/external-db-client.crt&sslkey=/run/secrets/external-db-client.key", true},
|
||||
{"external require", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require", true},
|
||||
{"external verify ca", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=verify-ca", false},
|
||||
{"external verify full", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=verify-full", false},
|
||||
{"built-in compose disable", "postgres://gochat:database-secret@postgres:5432/gochat?sslmode=disable", true},
|
||||
{"external fixed certificate paths", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=verify-full&sslrootcert=/run/secrets/external-db-ca.crt&sslcert=/run/secrets/external-db-client.crt&sslkey=/run/secrets/external-db-client.key", false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg.Database.DSN = tt.dsn
|
||||
if tt.wantErr {
|
||||
assert.ErrorContains(t, Validate(cfg), "production database DSN must use sslmode")
|
||||
assert.Error(t, Validate(cfg))
|
||||
} else {
|
||||
assert.NoError(t, Validate(cfg))
|
||||
}
|
||||
@@ -308,6 +313,32 @@ func TestValidate_ReleaseDatabaseTLS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProductionDatabaseDSN_RejectsHostMatrix(t *testing.T) {
|
||||
file, err := os.Open("../../../deploy/docker/database_host_rejection_cases.txt")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, file.Close()) })
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
name, dsn, ok := strings.Cut(scanner.Text(), "|")
|
||||
require.True(t, ok)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assert.ErrorContains(t, ValidateProductionDatabaseDSN(dsn), "must use an external PostgreSQL host")
|
||||
})
|
||||
}
|
||||
require.NoError(t, scanner.Err())
|
||||
}
|
||||
|
||||
func TestProductionDatabaseTLSRunbookContract(t *testing.T) {
|
||||
runbook, err := os.ReadFile("../../../docs/ops/02-production-operations.md")
|
||||
require.NoError(t, err)
|
||||
runbookText := string(runbook)
|
||||
assert.Equal(t, 1, strings.Count(runbookText, "`sslmode=verify-ca|verify-full`"))
|
||||
assert.Equal(t, 1, strings.Count(runbookText, "sslmode="))
|
||||
assert.NotContains(t, runbookText, "sslmode=disable")
|
||||
assert.NotContains(t, runbookText, "sslmode=require")
|
||||
}
|
||||
|
||||
func TestLoadWithEnv_ProductionRequiresOverlay(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "configs"), 0o755))
|
||||
|
||||
@@ -125,9 +125,8 @@ func Validate(cfg *Config) error {
|
||||
if password, ok := dbURL.User.Password(); !ok || password == "" || containsPlaceholder(password) {
|
||||
return fmt.Errorf("production database password is required and must not contain placeholders")
|
||||
}
|
||||
sslMode := dbURL.Query().Get("sslmode")
|
||||
if sslMode != "verify-full" && sslMode != "verify-ca" {
|
||||
return fmt.Errorf("production database DSN must use sslmode=verify-full or verify-ca")
|
||||
if err := ValidateProductionDatabaseDSN(cfg.Database.DSN); err != nil {
|
||||
return err
|
||||
}
|
||||
if redisURL.User == nil {
|
||||
return fmt.Errorf("production Redis credentials are required")
|
||||
@@ -183,6 +182,53 @@ func Validate(cfg *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateProductionDatabaseDSN protects every direct Go database client from
|
||||
// local targets, duplicate sslmode downgrades, and unsafe certificate paths.
|
||||
func ValidateProductionDatabaseDSN(dsn string) error {
|
||||
dbURL, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid production database DSN: %w", err)
|
||||
}
|
||||
hostname := strings.TrimSuffix(strings.ToLower(dbURL.Hostname()), ".")
|
||||
if zone := strings.LastIndexByte(hostname, '%'); zone >= 0 {
|
||||
hostname = hostname[:zone]
|
||||
}
|
||||
ip := net.ParseIP(hostname)
|
||||
if hostname == "" || hostname == "postgres" || hostname == "localhost" || ip != nil && ip.IsLoopback() {
|
||||
return fmt.Errorf("production database DSN must use an external PostgreSQL host")
|
||||
}
|
||||
query, err := url.ParseQuery(dbURL.RawQuery)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid production database DSN query: %w", err)
|
||||
}
|
||||
modes := query["sslmode"]
|
||||
if len(modes) != 1 {
|
||||
return fmt.Errorf("production database DSN must contain exactly one sslmode")
|
||||
}
|
||||
if modes[0] != "verify-ca" && modes[0] != "verify-full" {
|
||||
return fmt.Errorf("production database DSN must use sslmode=verify-full or verify-ca")
|
||||
}
|
||||
|
||||
fixedPaths := map[string]string{
|
||||
"sslrootcert": "/run/secrets/external-db-ca.crt",
|
||||
"sslcert": "/run/secrets/external-db-client.crt",
|
||||
"sslkey": "/run/secrets/external-db-client.key",
|
||||
}
|
||||
usesCertificateFiles := false
|
||||
for parameter := range fixedPaths {
|
||||
usesCertificateFiles = usesCertificateFiles || len(query[parameter]) > 0
|
||||
}
|
||||
if usesCertificateFiles {
|
||||
for parameter, path := range fixedPaths {
|
||||
values := query[parameter]
|
||||
if len(values) != 1 || values[0] != path {
|
||||
return fmt.Errorf("production database DSN %s must appear exactly once and use %s", parameter, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsPlaceholder(value string) bool {
|
||||
value = strings.ToLower(value)
|
||||
return strings.Contains(value, "change_me") || strings.Contains(value, "change-me") || strings.Contains(value, "changeme")
|
||||
|
||||
Reference in New Issue
Block a user