diff --git a/README.md b/README.md index 8ecf935..fdb075c 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ Vite 默认地址为 `http://localhost:5173`。登录页的服务器地址默认 ## 配置 -后端从 `backend/etc/agent..yaml` 读取配置。`SENLIN_APP_MODE` 未设置时使用 `backend/etc/agent.dev.yaml`;例如设置 `$env:SENLIN_APP_MODE='prod'` 时读取 `backend/etc/agent.prod.yaml`,该生产文件需由部署方安全提供。 +后端从 `backend/etc/agent..yaml` 读取配置。`SENLIN_APP_MODE` 未设置时使用 `backend/etc/agent.dev.yaml`;例如设置 `$env:SENLIN_APP_MODE='prod'` 时读取 `backend/etc/agent.prod.yaml`。可复制 `backend/etc/agent.prod.example.yaml` 作为生产配置起点,但必须替换其中的占位值。 + +所选 mode 必须与 YAML 的 `env` 一致(`prod` 对应 `production`,`dev` 对应 `development`),不一致时服务拒绝启动。生产模式下,`auth_secret` 与 `ai_key_encryption_secret` 都必须是至少 32 bytes、估算熵至少 128 bits 的独立随机密钥;重复字符、重复短模式、开发占位值和常见口令会被拒绝。可以在 PowerShell 中分别执行两次 `[Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))` 生成两个不同密钥。 | 字段 | 用途 | | --- | --- | diff --git a/backend/etc/agent.prod.example.yaml b/backend/etc/agent.prod.example.yaml new file mode 100644 index 0000000..6adf934 --- /dev/null +++ b/backend/etc/agent.prod.example.yaml @@ -0,0 +1,11 @@ +# 复制为 agent.prod.yaml 后使用;change-me 值会被生产校验拒绝。 +env: production +port: "9150" +dsn: "postgres://change-me" +storage_dir: "/var/lib/senlinai/files" +max_upload_bytes: 33554432 +auth_secret: "change-me-with-an-independent-random-secret" +system_ai_key: "" +ai_key_encryption_secret: "change-me-with-a-different-random-secret" +allowed_origins: + - "https://workbench.example.com" diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 10aebd0..879edad 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "math" "os" "path/filepath" "strings" @@ -43,6 +44,17 @@ func LoadFromDir(configDir string) (Config, error) { if err := yaml.Unmarshal(data, &cfg); err != nil { return Config{}, err } + selectedEnvironment, err := canonicalEnvironment(mode) + if err != nil { + return Config{}, err + } + configuredEnvironment, err := canonicalEnvironment(cfg.Env) + if err != nil { + return Config{}, err + } + if selectedEnvironment != configuredEnvironment { + return Config{}, fmt.Errorf("SENLIN_APP_MODE %s does not match config env %s", mode, cfg.Env) + } if cfg.MaxUploadBytes <= 0 { cfg.MaxUploadBytes = 32 << 20 } @@ -59,28 +71,57 @@ func LoadFromDir(configDir string) (Config, error) { if !hasAllowedOrigin { return Config{}, fmt.Errorf("allowed_origins must include at least one origin") } - if err := validateSecret(cfg.Env, "auth_secret", cfg.AuthSecret); err != nil { + production := selectedEnvironment == "production" + if err := validateSecret(production, "auth_secret", cfg.AuthSecret); err != nil { return Config{}, err } - if err := validateSecret(cfg.Env, "ai_key_encryption_secret", cfg.AIKeyEncryptionSecret); err != nil { + if err := validateSecret(production, "ai_key_encryption_secret", cfg.AIKeyEncryptionSecret); err != nil { return Config{}, err } return cfg, nil } -func validateSecret(environment, field, value string) error { +func canonicalEnvironment(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "dev", "development": + return "development", nil + case "prod", "production": + return "production", nil + case "test": + return "test", nil + default: + return "", fmt.Errorf("unsupported environment %q", value) + } +} + +func validateSecret(production bool, field, value string) error { secret := strings.TrimSpace(value) if secret == "" { return fmt.Errorf("%s must not be empty", field) } - if strings.EqualFold(strings.TrimSpace(environment), "production") || strings.EqualFold(strings.TrimSpace(environment), "prod") { - if len(secret) < 32 || isCommonSecret(secret) { - return fmt.Errorf("%s must be at least 32 characters and must not use a development sentinel in production", field) + if production { + if len([]byte(secret)) < 32 || estimatedEntropyBits(secret) < 128 || isCommonSecret(secret) { + return fmt.Errorf("%s must contain at least 32 bytes and an estimated 128 bits of entropy, without development sentinels, in production", field) } } return nil } +func estimatedEntropyBits(value string) float64 { + data := []byte(value) + counts := make(map[byte]int, len(data)) + for _, item := range data { + counts[item]++ + } + length := float64(len(data)) + entropyPerByte := 0.0 + for _, count := range counts { + probability := float64(count) / length + entropyPerByte -= probability * math.Log2(probability) + } + return entropyPerByte * length +} + func isCommonSecret(value string) bool { normalized := strings.ToLower(strings.TrimSpace(value)) for _, marker := range []string{"change-me", "changeme", "development", "dev-secret", "local-secret", "test-secret", "placeholder"} { diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index cf0e3f0..875c438 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -74,6 +74,28 @@ func TestLoadFromDirUsesSENLINAppMode(t *testing.T) { require.Equal(t, []string{"https://workbench.example.com"}, cfg.AllowedOrigins) } +func TestLoadFromDirRejectsProductionModeWithDevelopmentEnvironment(t *testing.T) { + configDir := t.TempDir() + writeConfig(t, configDir, "agent.prod.yaml", "development", "80", "postgres://prod", "/data/files", "production-auth-signing-key-2026-safe", "", "production-ai-encryption-key-2026-safe") + t.Setenv("SENLIN_APP_MODE", "prod") + + _, err := LoadFromDir(configDir) + + require.ErrorContains(t, err, "SENLIN_APP_MODE prod") + require.ErrorContains(t, err, "env development") +} + +func TestLoadFromDirRejectsModeEnvironmentMismatchOutsideProduction(t *testing.T) { + configDir := t.TempDir() + writeConfig(t, configDir, "agent.dev.yaml", "production", "9150", "postgres://dev", "./files", "production-auth-signing-key-2026-safe", "", "production-ai-encryption-key-2026-safe") + t.Setenv("SENLIN_APP_MODE", "dev") + + _, err := LoadFromDir(configDir) + + require.ErrorContains(t, err, "SENLIN_APP_MODE dev") + require.ErrorContains(t, err, "env production") +} + func TestLoadFromDirRejectsMissingStorageDir(t *testing.T) { configDir := t.TempDir() writeConfig(t, configDir, "agent.dev.yaml", "development", "9150", "postgres://agent", "", "dev-auth", "", "dev-ai") @@ -111,9 +133,12 @@ func TestLoadFromDirRejectsUnsafeProductionSecrets(t *testing.T) { {name: "short auth", authSecret: "short", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"}, {name: "development auth sentinel", authSecret: "development-auth-secret-change-me", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"}, {name: "dev auth sentinel", authSecret: "dev-secret-dev-secret-dev-secret-000", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"}, + {name: "repeated auth character", authSecret: strings.Repeat("x", 32), encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"}, + {name: "repeated auth pattern", authSecret: strings.Repeat("abcd", 8), encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"}, {name: "empty encryption", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "", expectedFieldName: "ai_key_encryption_secret"}, {name: "short encryption", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "short", expectedFieldName: "ai_key_encryption_secret"}, {name: "common encryption sentinel", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "change-me-change-me-change-me-change-me", expectedFieldName: "ai_key_encryption_secret"}, + {name: "repeated encryption character", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: strings.Repeat("9", 64), expectedFieldName: "ai_key_encryption_secret"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/backend/internal/logic/ai/rate_limit_postgres_test.go b/backend/internal/logic/ai/rate_limit_postgres_test.go index 55e589e..f2505fd 100644 --- a/backend/internal/logic/ai/rate_limit_postgres_test.go +++ b/backend/internal/logic/ai/rate_limit_postgres_test.go @@ -5,23 +5,19 @@ package ai import ( "errors" "fmt" - "os" "sync" "testing" "time" "github.com/stretchr/testify/require" - "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" "senlinai-agent/backend/internal/models" + "senlinai-agent/backend/internal/testutil" ) func TestPostgresReserveRateLimitIsAtomicAcrossConcurrentConnections(t *testing.T) { - dsn := os.Getenv("TEST_DATABASE_URL") - require.NotEmpty(t, dsn, "TEST_DATABASE_URL is required for integration tests and must point to an isolated database") - database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true, Logger: logger.Default.LogMode(logger.Silent)}) - require.NoError(t, err) + database := testutil.OpenIsolatedPostgres(t, &gorm.Config{TranslateError: true, Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, models.AutoMigrate(database)) models.DBService = database suffix := fmt.Sprint(time.Now().UnixNano()) diff --git a/backend/internal/logic/search/service_postgres_test.go b/backend/internal/logic/search/service_postgres_test.go index 5360a3c..4c4a99c 100644 --- a/backend/internal/logic/search/service_postgres_test.go +++ b/backend/internal/logic/search/service_postgres_test.go @@ -4,15 +4,14 @@ package search import ( "fmt" - "os" "strings" "testing" "time" "github.com/stretchr/testify/require" - "gorm.io/driver/postgres" "gorm.io/gorm" "senlinai-agent/backend/internal/models" + "senlinai-agent/backend/internal/testutil" ) func TestPostgresSearchMatchesChinesePartialKeywords(t *testing.T) { @@ -111,13 +110,7 @@ func TestPostgresSearchUsesStableFairLimitAndRuneBoundedSnippets(t *testing.T) { func newPostgresSearchTestDB(t *testing.T) *gorm.DB { t.Helper() - dsn := os.Getenv("TEST_DATABASE_URL") - require.NotEmpty(t, dsn, "TEST_DATABASE_URL is required for integration tests and must point to an isolated database") - database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true}) - require.NoError(t, err) - transaction := database.Begin() - require.NoError(t, transaction.Error) - t.Cleanup(func() { transaction.Rollback() }) - require.NoError(t, models.AutoMigrate(transaction)) - return transaction + database := testutil.OpenIsolatedPostgres(t, &gorm.Config{TranslateError: true}) + require.NoError(t, models.AutoMigrate(database)) + return database } diff --git a/backend/internal/logic/tasks/concurrency_postgres_test.go b/backend/internal/logic/tasks/concurrency_postgres_test.go index ee688a0..f2f8e9a 100644 --- a/backend/internal/logic/tasks/concurrency_postgres_test.go +++ b/backend/internal/logic/tasks/concurrency_postgres_test.go @@ -4,22 +4,18 @@ package tasks import ( "fmt" - "os" "sync" "testing" "time" "github.com/stretchr/testify/require" - "gorm.io/driver/postgres" "gorm.io/gorm" "senlinai-agent/backend/internal/models" + "senlinai-agent/backend/internal/testutil" ) func TestPostgresMovePreventsOldProjectShareFromBeingInsertedConcurrently(t *testing.T) { - dsn := os.Getenv("TEST_DATABASE_URL") - require.NotEmpty(t, dsn, "TEST_DATABASE_URL is required for integration tests and must point to an isolated database") - database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true}) - require.NoError(t, err) + database := testutil.OpenIsolatedPostgres(t, &gorm.Config{TranslateError: true}) require.NoError(t, models.AutoMigrate(database)) suffix := fmt.Sprint(time.Now().UnixNano()) owner := models.SenlinAgentUser{Email: "lock-" + suffix + "@example.com", DisplayName: "Lock Owner", PasswordHash: "hash"} diff --git a/backend/internal/models/migrations_test.go b/backend/internal/models/migrations_test.go index 3d319e0..1d62285 100644 --- a/backend/internal/models/migrations_test.go +++ b/backend/internal/models/migrations_test.go @@ -67,6 +67,12 @@ func TestAutoMigrateUpgradesLegacyProjectsAndTagsWithoutLosingAssociations(t *te require.Error(t, database.Exec(`INSERT INTO senlin_agent_tags (project_id, name) VALUES (1, 'UI')`).Error) } +func TestMigrationAdvisoryLockIsRequiredOnlyForPostgres(t *testing.T) { + require.True(t, migrationAdvisoryLockRequired("postgres")) + require.False(t, migrationAdvisoryLockRequired("sqlite")) + require.False(t, migrationAdvisoryLockRequired("mysql")) +} + func projectIdentifiers(projects []SenlinAgentProject) []string { result := make([]string, 0, len(projects)) for _, project := range projects { diff --git a/backend/internal/models/new.go b/backend/internal/models/new.go index 67ddfb8..a7339e6 100644 --- a/backend/internal/models/new.go +++ b/backend/internal/models/new.go @@ -1,12 +1,17 @@ package models import ( + "fmt" + "gorm.io/driver/postgres" "gorm.io/gorm" ) var DBService *gorm.DB +// migrationAdvisoryLockKey scopes the PostgreSQL session lock to SenlinAI schema changes. +const migrationAdvisoryLockKey int64 = 0x53454e4c494e4149 + func New(dsn string) error { // 启用 Gorm 跨驱动错误翻译,业务层无需解析 Postgres 错误文本或 SQLSTATE。 db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true}) @@ -23,6 +28,29 @@ func New(dsn string) error { } func AutoMigrate(database *gorm.DB) error { + if !migrationAdvisoryLockRequired(database.Dialector.Name()) { + return autoMigrateUnlocked(database) + } + // Connection pins every migration statement to one PostgreSQL session so the + // advisory lock covers both Gorm's schema work and the versioned data repairs. + return database.Connection(func(connection *gorm.DB) (resultErr error) { + if err := connection.Exec("SELECT pg_advisory_lock(?)", migrationAdvisoryLockKey).Error; err != nil { + return fmt.Errorf("acquire schema migration advisory lock: %w", err) + } + defer func() { + if err := connection.Exec("SELECT pg_advisory_unlock(?)", migrationAdvisoryLockKey).Error; resultErr == nil && err != nil { + resultErr = fmt.Errorf("release schema migration advisory lock: %w", err) + } + }() + return autoMigrateUnlocked(connection) + }) +} + +func migrationAdvisoryLockRequired(dialect string) bool { + return dialect == "postgres" +} + +func autoMigrateUnlocked(database *gorm.DB) error { if err := database.AutoMigrate( &SenlinAgentSchemaMigration{}, &SenlinAgentUser{}, diff --git a/backend/internal/models/postgres_integration_test.go b/backend/internal/models/postgres_integration_test.go index 81b96be..af63516 100644 --- a/backend/internal/models/postgres_integration_test.go +++ b/backend/internal/models/postgres_integration_test.go @@ -3,20 +3,15 @@ package models import ( - "os" "testing" "github.com/stretchr/testify/require" - "gorm.io/driver/postgres" "gorm.io/gorm" + "senlinai-agent/backend/internal/testutil" ) func TestPostgresPing(t *testing.T) { - dsn := os.Getenv("TEST_DATABASE_URL") - require.NotEmpty(t, dsn, "TEST_DATABASE_URL is required for integration tests and must point to an isolated database") - - database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true}) - require.NoError(t, err) + database := testutil.OpenIsolatedPostgres(t, &gorm.Config{TranslateError: true}) sqlDB, err := database.DB() require.NoError(t, err) require.NoError(t, sqlDB.Ping()) diff --git a/backend/internal/seed/demo_postgres_test.go b/backend/internal/seed/demo_postgres_test.go index bf9aa7a..0c9d3c2 100644 --- a/backend/internal/seed/demo_postgres_test.go +++ b/backend/internal/seed/demo_postgres_test.go @@ -5,17 +5,16 @@ package seed import ( "context" "fmt" - "os" "sync" "testing" "time" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" - "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" "senlinai-agent/backend/internal/models" + "senlinai-agent/backend/internal/testutil" ) type demoSeedRunContextKey struct{} @@ -28,13 +27,10 @@ const ( ) func TestPostgresDemoSeedSerializesConcurrentRunsForSameOwner(t *testing.T) { - dsn := os.Getenv("TEST_DATABASE_URL") - require.NotEmpty(t, dsn, "TEST_DATABASE_URL is required for integration tests and must point to an isolated database") - database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + database := testutil.OpenIsolatedPostgres(t, &gorm.Config{ TranslateError: true, Logger: logger.Default.LogMode(logger.Silent), }) - require.NoError(t, err) require.NoError(t, models.AutoMigrate(database)) suffix := fmt.Sprint(time.Now().UnixNano()) diff --git a/backend/internal/testutil/postgres_integration.go b/backend/internal/testutil/postgres_integration.go new file mode 100644 index 0000000..9b04d24 --- /dev/null +++ b/backend/internal/testutil/postgres_integration.go @@ -0,0 +1,68 @@ +//go:build integration + +// Package testutil contains opt-in helpers for isolated integration databases. +package testutil + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "net/url" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// OpenIsolatedPostgres creates a random schema and binds every pooled test +// connection to it through search_path. It never reads the production DSN. +func OpenIsolatedPostgres(t testing.TB, config *gorm.Config) *gorm.DB { + t.Helper() + dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL")) + require.NotEmpty(t, dsn, "TEST_DATABASE_URL is required for integration tests and must point to an isolated database") + + schema := randomSchemaName(t) + admin, err := gorm.Open(postgres.Open(dsn), config) + require.NoError(t, err) + adminSQL, err := admin.DB() + require.NoError(t, err) + require.NoError(t, admin.Exec(fmt.Sprintf(`CREATE SCHEMA "%s"`, schema)).Error) + + schemaDSN, err := postgresSchemaDSN(dsn, schema) + require.NoError(t, err) + database, err := gorm.Open(postgres.Open(schemaDSN), config) + require.NoError(t, err) + isolationSQL, err := database.DB() + require.NoError(t, err) + t.Cleanup(func() { + _ = isolationSQL.Close() + _ = admin.Exec(fmt.Sprintf(`DROP SCHEMA IF EXISTS "%s" CASCADE`, schema)).Error + _ = adminSQL.Close() + }) + return database +} + +func randomSchemaName(t testing.TB) string { + t.Helper() + value := make([]byte, 12) + _, err := rand.Read(value) + require.NoError(t, err) + return "senlin_test_" + hex.EncodeToString(value) +} + +func postgresSchemaDSN(dsn, schema string) (string, error) { + if strings.Contains(dsn, "://") { + parsed, err := url.Parse(dsn) + if err != nil { + return "", err + } + query := parsed.Query() + query.Set("search_path", schema) + parsed.RawQuery = query.Encode() + return parsed.String(), nil + } + return strings.TrimSpace(dsn) + " search_path=" + schema, nil +} diff --git a/backend/internal/testutil/postgres_integration_test.go b/backend/internal/testutil/postgres_integration_test.go new file mode 100644 index 0000000..22311f4 --- /dev/null +++ b/backend/internal/testutil/postgres_integration_test.go @@ -0,0 +1,20 @@ +//go:build integration + +package testutil + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPostgresSchemaDSNSupportsURLAndKeywordFormats(t *testing.T) { + urlDSN, err := postgresSchemaDSN("postgres://user:pass@localhost:5432/db?sslmode=disable", "senlin_test_abc") + require.NoError(t, err) + require.Contains(t, urlDSN, "sslmode=disable") + require.Contains(t, urlDSN, "search_path=senlin_test_abc") + + keywordDSN, err := postgresSchemaDSN("host=localhost dbname=db sslmode=disable", "senlin_test_abc") + require.NoError(t, err) + require.Equal(t, "host=localhost dbname=db sslmode=disable search_path=senlin_test_abc", keywordDSN) +}