diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..dbb1eac6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.agent_context +.multica +**/node_modules +frontend/dist +backend/bin +backend/coverage.out +.env +.env.* diff --git a/.env.example b/.env.example index 98d099cd..2552562c 100644 --- a/.env.example +++ b/.env.example @@ -1,69 +1,28 @@ -# GoChat Environment Variables -# Copy to .env and fill in your values. NEVER commit .env to git. +# GoChat production variables. Copy to .env and replace every CHANGE_ME value. -# ---- Application ---- -GOCHAT_ENV=prod -# Keep disabled in production. Enable only for controlled QA/UAT environments. +GOCHAT_IMAGE_REF=ghcr.io/rogeecn/gochat@sha256:CHANGE_ME +GOCHAT_ENV=production +GOCHAT_PORT=3000 +GOCHAT_SERVER_MODE=release +GOCHAT_SERVER_CORS_ALLOWED_ORIGINS=https://chat.CHANGE_ME.example.com -# ---- Server ---- -GOCHAT_SERVER_HOST=0.0.0.0 -GOCHAT_SERVER_PORT=3000 -GOCHAT_SERVER_MODE=release # debug, release, test +POSTGRES_DB=gochat_production +POSTGRES_USER=gochat +POSTGRES_PASSWORD=CHANGE_ME +# The built-in PostgreSQL service is non-TLS. For an external database, set a +# complete GOCHAT_DATABASE_DSN with sslmode=require, verify-ca, or verify-full. -# ---- Database ---- -GOCHAT_DATABASE_DSN=postgres://gochat:CHANGE_ME_TO_A_STRONG_PASSWORD@localhost:5432/gochat_production?sslmode=disable -GOCHAT_DATABASE_MAX_IDLE_CONNS=10 -GOCHAT_DATABASE_MAX_OPEN_CONNS=100 -GOCHAT_DATABASE_CONN_MAX_LIFETIME=3600 +POSTGRES_IMAGE_REF=pgvector/pgvector:pg16@sha256:ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b +REDIS_IMAGE_REF=redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf +MEILI_IMAGE_REF=getmeili/meilisearch:v1.13@sha256:bed3fb650e62da53145777204891159242f6ea4ce69e215b36223af4aa64a0ae -# ---- Redis ---- -GOCHAT_REDIS_DSN=redis://localhost:6379 -GOCHAT_REDIS_POOL_SIZE=50 - -# ---- JWT ---- -GOCHAT_JWT_SECRET=CHANGE_ME_TO_A_RANDOM_64_CHAR_STRING +REDIS_PASSWORD=CHANGE_ME +MEILI_MASTER_KEY=CHANGE_ME +GOCHAT_JWT_SECRET=CHANGE_ME_WITH_AT_LEAST_32_RANDOM_CHARACTERS # Optional during a bounded rotation window; comma-separated old 32+ byte secrets. GOCHAT_JWT_PREVIOUS_SECRETS= -GOCHAT_JWT_EXPIRY_HOURS=72 -GOCHAT_JWT_ACCESS_EXPIRY_MINUTES=15 -GOCHAT_JWT_REFRESH_EXPIRY_HOURS=168 +GOCHAT_JWT_ALLOW_INSECURE_HEADER_AUTH=false -# ---- Logging ---- -GOCHAT_LOG_LEVEL=info # debug, info, warn, error -GOCHAT_LOG_FORMAT=json # json, text - -# ---- SMTP / Email ---- -SMTP_ADDRESS=smtp.example.com -SMTP_PORT=587 -SMTP_USERNAME=your-email@example.com -SMTP_PASSWORD=your-email-password - -# ---- Worker ---- -GOCHAT_WORKER_CONCURRENCY=10 - -# ---- Frontend URL ---- -FRONTEND_URL=https://your-domain.com - -# ---- Search ---- -GOCHAT_SEARCH_ENGINE=meilisearch -GOCHAT_SEARCH_HOST=http://localhost:7700 -GOCHAT_SEARCH_API_KEY= -GOCHAT_SEARCH_INDEX_PREFIX=gochat_ -GOCHAT_SEARCH_TIMEOUT_SECONDS=5 - -# ---- Storage ---- -GOCHAT_STORAGE_PROVIDER=local -GOCHAT_STORAGE_LOCAL_PATH=./uploads -GOCHAT_STORAGE_MAX_FILE_SIZE=20971520 # 20MB - -# ---- OIDC (Enterprise OAuth/OIDC SSO, disabled by default) ---- -GOCHAT_OIDC_ENABLED=false -GOCHAT_OIDC_DEFAULT_CLIENT_ID= -GOCHAT_OIDC_DEFAULT_CLIENT_SECRET= -GOCHAT_OIDC_DEFAULT_REDIRECT_URL=https://your-domain.com/api/v1/oidc/callback -GOCHAT_OIDC_DEFAULT_ISSUER_URL=https://accounts.google.com -GOCHAT_OIDC_DEFAULT_AUTHORIZATION_URL= -GOCHAT_OIDC_DEFAULT_TOKEN_URL= -GOCHAT_OIDC_DEFAULT_USER_INFO_URL= -GOCHAT_OIDC_DEFAULT_JWKS_URL= -GOCHAT_OIDC_DEFAULT_SCOPES=openid,profile,email +# Optional connector. Supply the digest published by its release pipeline. +SHANGWUTONG_IMAGE_REF=ghcr.io/rogeecn/shangwutong@sha256:CHANGE_ME +GOCHAT_CONNECTOR_SERVICE_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2c66187..251de910 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,10 +5,15 @@ on: branches: [main] push: branches: [main] + tags: ['v*.*.*'] permissions: contents: read +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + jobs: backend: name: Backend (SQLite) @@ -153,3 +158,110 @@ jobs: - name: Prettier representative JS/TS/Vue files run: pnpm --dir frontend exec prettier --check app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.spec.js app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.vue vite.config.ts app/javascript/histoire.setup.ts - run: pnpm --dir frontend build + + production-smoke: + name: Production image smoke + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + env: + GOCHAT_IMAGE_REF: gochat:production-smoke + GOCHAT_PORT: "38080" + GOCHAT_SERVER_CORS_ALLOWED_ORIGINS: https://chat.example.test + POSTGRES_PASSWORD: ci-postgres-secret + REDIS_PASSWORD: ci-redis-secret + MEILI_MASTER_KEY: ci-meili-secret-16 + GOCHAT_JWT_SECRET: ci-smoke-jwt-secret-at-least-32-characters + GOCHAT_JWT_PREVIOUS_SECRETS: ci-previous-jwt-secret-at-least-32-characters + steps: + - uses: actions/checkout@v4 + - name: Build production images from repository root + run: | + docker build -t "$GOCHAT_IMAGE_REF" -f deploy/docker/Dockerfile . + docker build -t shangwutong:production-smoke -f channels/shangwutong/Dockerfile . + docker run -d --name gochat-ci-registry -p 127.0.0.1:5000:5000 registry:2 + timeout 30 sh -c 'until curl -fsS http://127.0.0.1:5000/v2/; do sleep 1; done' + docker tag "$GOCHAT_IMAGE_REF" localhost:5000/gochat:production-smoke + docker tag shangwutong:production-smoke localhost:5000/shangwutong:production-smoke + docker push localhost:5000/gochat:production-smoke + docker push localhost:5000/shangwutong:production-smoke + echo "GOCHAT_IMAGE_REF=$(docker inspect --format '{{index .RepoDigests 0}}' localhost:5000/gochat:production-smoke)" >> "$GITHUB_ENV" + echo "SHANGWUTONG_IMAGE_REF=$(docker inspect --format '{{index .RepoDigests 0}}' localhost:5000/shangwutong:production-smoke)" >> "$GITHUB_ENV" + - name: Verify artifact contents + run: >- + docker run --rm --entrypoint sh "$GOCHAT_IMAGE_REF" -c + 'test -s /app/configs/config.production.yaml && + test -d /app/migrations && + test -s /app/frontend/dist/index.html' + - name: Start production Compose and smoke core pages + run: | + if MEILI_MASTER_KEY=too-short deploy/docker/preflight.sh; then + echo "preflight accepted a short Meilisearch key" >&2 + exit 1 + fi + if POSTGRES_IMAGE_REF=pgvector/pgvector:pg16 deploy/docker/preflight.sh; then + echo "preflight accepted a mutable PostgreSQL image" >&2 + exit 1 + fi + deploy/docker/preflight.sh + docker compose -f deploy/docker/docker-compose.prod.yml config --format json | python3 -c 'import json, os, sys; config = json.load(sys.stdin); assert all(config["services"][service]["environment"]["GOCHAT_JWT_PREVIOUS_SECRETS"] == os.environ["GOCHAT_JWT_PREVIOUS_SECRETS"] for service in ("gochat", "worker"))' + docker compose -f deploy/docker/docker-compose.prod.yml up -d --wait gochat + curl -fsS "http://127.0.0.1:$GOCHAT_PORT/health" | grep -q '"status":"ok"' + curl -fsS "http://127.0.0.1:$GOCHAT_PORT/app" | grep -q '/assets/' + - name: Stop production Compose + if: always() + run: | + docker compose -f deploy/docker/docker-compose.prod.yml down -v + docker rm -f gochat-ci-registry || true + + release: + name: Publish immutable image + if: startsWith(github.ref, 'refs/tags/v') + needs: [backend, backend-postgres, frontend] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + image_ref: ${{ steps.ref.outputs.image_ref }} + image_digest: ${{ steps.build.outputs.digest }} + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract immutable tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: latest=false + tags: | + type=semver,pattern={{version}} + type=sha + - name: Capture build date + id: date + run: echo "value=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" + - name: Build and push + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/docker/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ github.ref_name }} + COMMIT_SHA=${{ github.sha }} + BUILD_DATE=${{ steps.date.outputs.value }} + cache-from: type=gha,scope=gochat + cache-to: type=gha,mode=max,scope=gochat + - name: Record digest reference + id: ref + run: | + image_ref="${REGISTRY}/${IMAGE_NAME}@${{ steps.build.outputs.digest }}" + echo "image_ref=$image_ref" >> "$GITHUB_OUTPUT" + echo "GoChat image: \`$image_ref\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/backend/configs/config.prod.yaml b/backend/configs/config.prod.yaml deleted file mode 100644 index 01e5c1ce..00000000 --- a/backend/configs/config.prod.yaml +++ /dev/null @@ -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" diff --git a/backend/configs/config.production.yaml b/backend/configs/config.production.yaml new file mode 100644 index 00000000..a696f061 --- /dev/null +++ b/backend/configs/config.production.yaml @@ -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" diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 8b62115c..5b3315bf 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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) } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 8d3229a3..3e3cd035 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -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", diff --git a/backend/internal/config/validator.go b/backend/internal/config/validator.go index 9fc20f4e..6ac54b0a 100644 --- a/backend/internal/config/validator.go +++ b/backend/internal/config/validator.go @@ -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 { diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index f1cd7b47..4642100f 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -53,6 +53,13 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ # ========== Production Stage ========== FROM alpine:3.21 +ARG VERSION=dev +ARG COMMIT_SHA=unknown +ARG BUILD_DATE=unknown +LABEL org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${COMMIT_SHA}" \ + org.opencontainers.image.created="${BUILD_DATE}" + # Install runtime dependencies RUN apk --no-cache add ca-certificates tzdata curl && addgroup -S gochat && adduser -S gochat -G gochat diff --git a/deploy/docker/docker-compose.prod.yml b/deploy/docker/docker-compose.prod.yml index 31ff9eac..2094f25f 100644 --- a/deploy/docker/docker-compose.prod.yml +++ b/deploy/docker/docker-compose.prod.yml @@ -1,106 +1,133 @@ -# GoChat Production Environment -# Reference: Chatwoot docker-compose.production.yaml — rails + sidekiq + postgres + redis, restart policies -# Enhanced with pgvector, resource limits, non-root user, health checks +name: gochat-production -version: '3.8' +x-gochat-image: &gochat-image ${GOCHAT_IMAGE_REF:?set GOCHAT_IMAGE_REF to an immutable image digest} +x-gochat-environment: &gochat-environment + GOCHAT_ENV: production + GOCHAT_SERVER_HOST: 0.0.0.0 + GOCHAT_SERVER_PORT: 3000 + GOCHAT_SERVER_MODE: release + GOCHAT_SERVER_CORS_ALLOWED_ORIGINS: ${GOCHAT_SERVER_CORS_ALLOWED_ORIGINS:?set production CORS origins} + GOCHAT_DATABASE_DSN: ${GOCHAT_DATABASE_DSN:-postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gochat_production}?sslmode=disable} + GOCHAT_DATABASE_RUN_MIGRATIONS: "true" + GOCHAT_DATABASE_MIGRATIONS_PATH: /app/migrations + GOCHAT_REDIS_DSN: redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD}@redis:6379 + GOCHAT_SEARCH_ENGINE: meilisearch + GOCHAT_SEARCH_HOST: http://meilisearch:7700 + GOCHAT_SEARCH_API_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY} + GOCHAT_JWT_SECRET: ${GOCHAT_JWT_SECRET:?set GOCHAT_JWT_SECRET} + GOCHAT_JWT_PREVIOUS_SECRETS: ${GOCHAT_JWT_PREVIOUS_SECRETS:-} + GOCHAT_LOG_LEVEL: info + GOCHAT_LOG_FORMAT: json + GOCHAT_STORAGE_PROVIDER: local + GOCHAT_STORAGE_LOCAL_PATH: /app/storage/uploads services: postgres: - image: pgvector/pgvector:pg16 - container_name: gochat-postgres + image: ${POSTGRES_IMAGE_REF:-pgvector/pgvector:pg16@sha256:ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b} restart: always environment: POSTGRES_DB: ${POSTGRES_DB:-gochat_production} POSTGRES_USER: ${POSTGRES_USER:-gochat} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # MUST be set in .env - ports: - - "127.0.0.1:5432:5432" # Only localhost access + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-gochat}"] - interval: 10s + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s timeout: 5s - retries: 5 + retries: 20 deploy: resources: limits: memory: 1G redis: - image: redis:7-alpine - container_name: gochat-redis + image: ${REDIS_IMAGE_REF:-redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf} restart: always - command: redis-server --requirepass "${REDIS_PASSWORD}" --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru - ports: - - "127.0.0.1:6379:6379" # Only localhost access + command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?set REDIS_PASSWORD}", "--appendonly", "yes", "--maxmemory", "512mb", "--maxmemory-policy", "allkeys-lru"] + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?set REDIS_PASSWORD} volumes: - redis_data:/data healthcheck: - test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] - interval: 10s + test: ["CMD-SHELL", "redis-cli -a '$${REDIS_PASSWORD}' ping"] + interval: 5s timeout: 5s - retries: 5 + retries: 20 deploy: resources: limits: memory: 512M + meilisearch: + image: ${MEILI_IMAGE_REF:-getmeili/meilisearch:v1.13@sha256:bed3fb650e62da53145777204891159242f6ea4ce69e215b36223af4aa64a0ae} + restart: always + environment: + MEILI_ENV: production + MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY} + MEILI_NO_ANALYTICS: "true" + volumes: + - meili_data:/meili_data + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--spider", "http://127.0.0.1:7700/health"] + interval: 5s + timeout: 5s + retries: 20 + gochat: - image: gochat/gochat:${GOCHAT_VERSION:-latest} - container_name: gochat-app + image: *gochat-image restart: always depends_on: postgres: condition: service_healthy redis: condition: service_healthy + meilisearch: + condition: service_healthy + environment: *gochat-environment ports: - - "127.0.0.1:3000:3000" # Reverse proxy should handle external access - env_file: ../../.env - environment: - - GOCHAT_ENV=production - - GOCHAT_DATABASE_DSN=postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gochat_production}?sslmode=disable - - GOCHAT_REDIS_DSN=redis://:${REDIS_PASSWORD}@redis:6379 + - "127.0.0.1:${GOCHAT_PORT:-3000}:3000" + volumes: + - gochat_storage:/app/storage healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/health"] - interval: 30s + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/health"] + interval: 10s timeout: 5s start_period: 15s - retries: 3 + retries: 30 deploy: resources: limits: memory: 512M - cpus: '1.0' + cpus: "1.0" reservations: memory: 256M - cpus: '0.5' + cpus: "0.5" worker: - image: gochat/gochat:${GOCHAT_VERSION:-latest} - container_name: gochat-worker + image: *gochat-image restart: always depends_on: postgres: condition: service_healthy redis: condition: service_healthy + meilisearch: + condition: service_healthy command: ["serve", "--worker-only"] - env_file: ../../.env environment: - - GOCHAT_ENV=production - - GOCHAT_DATABASE_DSN=postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gochat_production}?sslmode=disable - - GOCHAT_REDIS_DSN=redis://:${REDIS_PASSWORD}@redis:6379 + <<: *gochat-environment + GOCHAT_DATABASE_RUN_MIGRATIONS: "false" + volumes: + - gochat_storage:/app/storage deploy: resources: limits: memory: 512M - cpus: '1.0' + cpus: "1.0" shangwutong: - image: ${SHANGWUTONG_IMAGE:-ghcr.io/gochat/shangwutong:latest} - container_name: gochat-shangwutong + image: ${SHANGWUTONG_IMAGE_REF:?set SHANGWUTONG_IMAGE_REF to an immutable image digest} restart: always stop_grace_period: ${SWT_SHUTDOWN_TIMEOUT:-30s} environment: @@ -125,13 +152,15 @@ services: resources: limits: memory: 512M - cpus: '1.0' + cpus: "1.0" reservations: memory: 128M - cpus: '0.25' + cpus: "0.25" volumes: postgres_data: redis_data: + meili_data: + gochat_storage: shangwutong_data: shangwutong_backups: diff --git a/deploy/docker/preflight.sh b/deploy/docker/preflight.sh new file mode 100755 index 00000000..11fceb74 --- /dev/null +++ b/deploy/docker/preflight.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail +export LC_ALL=C + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +compose_args=(-f "$script_dir/docker-compose.prod.yml") +if (($#)); then + env_file=$1 + set -a + source "$env_file" + set +a + compose_args=(--env-file "$env_file" "${compose_args[@]}") +fi + +required=(GOCHAT_IMAGE_REF SHANGWUTONG_IMAGE_REF GOCHAT_SERVER_CORS_ALLOWED_ORIGINS POSTGRES_PASSWORD REDIS_PASSWORD MEILI_MASTER_KEY GOCHAT_JWT_SECRET) +for name in "${required[@]}"; do + value=${!name:-} + if [[ -z $value || ${value^^} == *CHANGE_ME* ]]; then + echo "$name is required and must not contain CHANGE_ME" >&2 + exit 1 + fi +done + +if ((${#GOCHAT_JWT_SECRET} < 32)); then + echo "GOCHAT_JWT_SECRET must be at least 32 characters" >&2 + exit 1 +fi +if ((${#MEILI_MASTER_KEY} < 16)); then + echo "MEILI_MASTER_KEY must be at least 16 bytes" >&2 + exit 1 +fi +if [[ -n ${GOCHAT_DATABASE_DSN:-} && ! $GOCHAT_DATABASE_DSN =~ (^|[?&])sslmode=(require|verify-ca|verify-full)(&|$) ]]; then + echo "GOCHAT_DATABASE_DSN must explicitly require TLS for an external database" >&2 + exit 1 +fi + +images=$(docker compose "${compose_args[@]}" config --images) +while IFS= read -r image; do + if [[ $image =~ @sha256:[0-9a-fA-F]{64}$ ]]; then + continue + fi + echo "production image must be pinned to a sha256 digest: $image" >&2 + exit 1 +done <<< "$images" +echo "production preflight passed"