HH-439: harden production artifact pipeline (#86)

* HH-439: harden production artifact pipeline

* fix(HH-439): address production compose review

* fix(HH-439): preserve previous JWT secrets in production

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-21 20:20:00 +08:00
committed by GitHub
co-authored by rogee
parent 7e3872170f
commit 7a9fec33c5
11 changed files with 436 additions and 153 deletions
-36
View File
@@ -1,36 +0,0 @@
# GoChat Production Environment Overrides
# Reference: Chatwoot config/environments/production.rb
server:
mode: "release"
cors:
# PRODUCTION: Must specify exact origins or wildcard patterns.
# NEVER leave empty in production — empty + non-debug = no CORS allowed.
allowed_origins:
- "https://app.yourdomain.com"
- "https://admin.yourdomain.com"
- "*.yourdomain.com" # matches any subdomain
allow_credentials: true # needed for JWT cookie-based auth
max_age: 86400
jwt:
allow_insecure_header_auth: false
database:
dsn: "postgres://gochat:CHANGE_ME@localhost:5432/gochat_production?sslmode=require"
pool_max: 20
log_level: "warn"
log:
level: "info"
format: "json"
worker:
concurrency: 10
redis_stream_prefix: "gochat:jobs"
redis_consumer_group: "gochat-workers"
redis_block_timeout_s: 5
redis_sweep_interval_s: 30
redis:
channel_prefix: "gochat_production"
+38
View File
@@ -0,0 +1,38 @@
# GoChat production overrides. Secrets and public origins must come from the environment.
server:
mode: "release"
cors:
allowed_origins: ["https://CHANGE_ME.example.com"]
allow_credentials: true
database:
dsn: "postgres://gochat:CHANGE_ME@postgres:5432/gochat_production?sslmode=disable"
run_migrations: true
migrations_path: "/app/migrations"
redis:
dsn: "redis://:CHANGE_ME@redis:6379"
jwt:
secret: "CHANGE_ME"
allow_insecure_header_auth: false
search:
engine: "meilisearch"
host: "http://meilisearch:7700"
api_key: "CHANGE_ME"
log:
level: "info"
format: "json"
worker:
concurrency: 10
redis_stream_prefix: "gochat:jobs"
redis_consumer_group: "gochat-workers"
redis_block_timeout_s: 5
redis_sweep_interval_s: 30
storage:
provider: "local"
local_path: "/app/storage/uploads"
+10 -7
View File
@@ -157,9 +157,9 @@ type LogConfig struct {
Format string `mapstructure:"format"` // json, text
}
// SAMLConfig and LDAPConfig removed — only OIDC is supported for enterprise SSO.
// SAMLConfig and LDAPConfig removed — only OIDC is supported for enterprise SSO.
// OIDCConfig holds OIDC/OAuth2 enterprise authentication configuration.
// OIDCConfig holds OIDC/OAuth2 enterprise authentication configuration.
// Reference: M13 §4.3 — OIDC (OpenID Connect) provider integration.
// Supports Google Workspace, Auth0, Keycloak, Azure AD and any OIDC-compliant IdP.
// Per-account OIDC settings override these defaults (stored in DB).
@@ -460,6 +460,10 @@ func (r *ConfigReloader) Stop() {
//
// config/environments/development.rb overrides config/application.rb defaults.
func LoadWithEnv(env string) (*Config, error) {
if env == "prod" {
return nil, fmt.Errorf("GOCHAT_ENV=prod is unsupported; use GOCHAT_ENV=production")
}
v := viper.New()
// Env key replacer: GOCHAT_DATABASE_DSN → database.dsn
@@ -473,6 +477,7 @@ func LoadWithEnv(env string) (*Config, error) {
"GOCHAT_SERVER_HOST": "server.host",
"GOCHAT_SERVER_PORT": "server.port",
"GOCHAT_SERVER_MODE": "server.mode",
"GOCHAT_SERVER_CORS_ALLOWED_ORIGINS": "server.cors.allowed_origins",
"GOCHAT_DATABASE_DSN": "database.dsn",
"GOCHAT_DATABASE_MAX_IDLE_CONNS": "database.max_idle_conns",
"GOCHAT_DATABASE_MAX_OPEN_CONNS": "database.max_open_conns",
@@ -547,11 +552,7 @@ func LoadWithEnv(env string) (*Config, error) {
// Overlay environment-specific config: config.{env}.yaml
if env != "" && env != "default" {
overlayEnv := env
if env == "production" {
overlayEnv = "prod"
}
envFile := fmt.Sprintf("config.%s.yaml", overlayEnv)
envFile := fmt.Sprintf("config.%s.yaml", env)
// Search in the same directory as the base config
baseConfigPath := v.ConfigFileUsed()
if baseConfigPath != "" {
@@ -562,6 +563,8 @@ func LoadWithEnv(env string) (*Config, error) {
return nil, fmt.Errorf("env config merge failed (%s): %w", env, err)
}
applogger.L().Infof("Merged env config overlay: %s", envConfigPath)
} else if env == "production" {
return nil, fmt.Errorf("production config overlay not found: %s", envConfigPath)
} else {
applogger.L().Warnf("Env config file not found: %s (continuing with base config)", envConfigPath)
}
+91 -4
View File
@@ -2,6 +2,8 @@ package config
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
@@ -127,13 +129,13 @@ func TestValidate_ReleaseJWTSecurity(t *testing.T) {
validSecret := "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE"
base := func() *Config {
return &Config{
Server: ServerConfig{Port: 8080, Mode: "release"},
Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db"},
Redis: RedisConfig{DSN: "redis://localhost:6379"},
Server: ServerConfig{Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
Database: DatabaseConfig{DSN: "postgres://user:database-secret@postgres:5432/db?sslmode=disable"},
Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"},
JWT: JWTConfig{Secret: validSecret},
Log: LogConfig{Level: "info"},
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 1, SweepIntervalS: 1},
Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700"},
Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700", APIKey: "search-secret-123"},
}
}
@@ -241,6 +243,91 @@ func TestValidate_SearchDBFallbackRejectedInRelease(t *testing.T) {
assert.Contains(t, err.Error(), "release mode requires meilisearch")
}
func TestValidate_ReleaseRejectsPlaceholders(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
Database: DatabaseConfig{DSN: "postgres://gochat:CHANGE_ME@postgres:5432/gochat"},
Redis: RedisConfig{DSN: "redis://:secret@redis:6379"},
JWT: JWTConfig{Secret: "production-jwt-secret-at-least-32-characters"},
Log: LogConfig{Level: "info"},
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "search-secret", TimeoutSeconds: 5},
}
err := Validate(cfg)
assert.ErrorContains(t, err, "database password")
}
func TestValidate_ReleaseRejectsShortSearchKey(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
Database: DatabaseConfig{DSN: "postgres://gochat:database-secret@postgres:5432/gochat?sslmode=disable"},
Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"},
JWT: JWTConfig{Secret: "production-jwt-secret-at-least-32-characters"},
Log: LogConfig{Level: "info"},
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "123456789012345", TimeoutSeconds: 5},
}
assert.ErrorContains(t, Validate(cfg), "search API key must be at least 16 bytes")
}
func TestValidate_ReleaseDatabaseTLS(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"},
JWT: JWTConfig{Secret: "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE"},
Log: LogConfig{Level: "info"},
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "search-secret-123", TimeoutSeconds: 5},
}
for _, tt := range []struct {
name string
dsn string
wantErr bool
}{
{"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 require", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require", false},
{"built-in compose disable", "postgres://gochat:database-secret@postgres:5432/gochat?sslmode=disable", 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")
} else {
assert.NoError(t, Validate(cfg))
}
})
}
}
func TestLoadWithEnv_ProductionRequiresOverlay(t *testing.T) {
tmpDir := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "configs"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "configs", "config.yaml"), []byte("server:\n mode: debug\n"), 0o644))
t.Chdir(tmpDir)
_, err := LoadWithEnv("production")
assert.ErrorContains(t, err, "production config overlay not found")
}
func TestLoadWithEnv_ProductionOverlay(t *testing.T) {
t.Setenv("GOCHAT_DATABASE_DSN", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require")
t.Setenv("GOCHAT_REDIS_DSN", "redis://:redis-secret@redis:6379")
t.Setenv("GOCHAT_JWT_SECRET", "production-jwt-secret-at-least-32-characters")
t.Setenv("GOCHAT_SEARCH_API_KEY", "search-secret-123")
t.Setenv("GOCHAT_SERVER_CORS_ALLOWED_ORIGINS", "https://chat.example.test")
t.Chdir("../..")
cfg, err := LoadWithEnv("production")
require.NoError(t, err)
assert.Equal(t, "release", cfg.Server.Mode)
assert.Equal(t, []string{"https://chat.example.test"}, cfg.Server.CORS.AllowedOrigins)
assert.NoError(t, Validate(cfg))
}
func TestDatabaseConfig_MigrateDSN(t *testing.T) {
cfg := DatabaseConfig{
DSN: "postgres://gochat:secret@localhost:5432/gochat_db?sslmode=disable",
+30
View File
@@ -111,9 +111,39 @@ func Validate(cfg *Config) error {
return fmt.Errorf("search.timeout_seconds must be >= 0")
}
if cfg.Server.Mode == "release" {
if len(cfg.Server.CORS.AllowedOrigins) == 0 || containsPlaceholder(strings.Join(cfg.Server.CORS.AllowedOrigins, ",")) {
return fmt.Errorf("production CORS origins are required and must not contain placeholders")
}
if dbURL.User == nil || dbURL.User.Username() == "" {
return fmt.Errorf("production database credentials are required")
}
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 !(dbURL.Hostname() == "postgres" && sslMode == "disable") && sslMode != "require" && sslMode != "verify-ca" && sslMode != "verify-full" {
return fmt.Errorf("production database DSN must use sslmode=require, verify-ca, or verify-full (sslmode=disable is only allowed for the built-in postgres service)")
}
if redisURL.User == nil {
return fmt.Errorf("production Redis credentials are required")
}
if password, ok := redisURL.User.Password(); !ok || password == "" || containsPlaceholder(password) {
return fmt.Errorf("production Redis password is required and must not contain placeholders")
}
if len(cfg.Search.APIKey) < 16 || containsPlaceholder(cfg.Search.APIKey) {
return fmt.Errorf("production search API key must be at least 16 bytes and must not contain placeholders")
}
}
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")
}
func validateProductionJWTSecret(name, secret string) error {
secret = strings.TrimSpace(secret)
if len([]byte(secret)) < 32 {