diff --git a/internal/config/config.go b/internal/config/config.go index bbb443e5..5e0b3110 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -93,7 +93,7 @@ func Load(path string) (*Config, error) { KbDbHost: "127.0.0.1", KbDbPort: 5432, KbDbUser: "regkb", - KbDbPassword: "shared_kb_secret_pass", + KbDbPassword: "", // 禁止静默默认口令;须由根 .env / 进程环境提供 KbDbName: "regulations", ContextDbHost: "127.0.0.1", @@ -120,134 +120,18 @@ func Load(path string) (*Config, error) { } if path == "" { - if _, err := os.Stat("configs/.env.dev"); err == nil { - path = "configs/.env.dev" - } else if _, err := os.Stat(".env"); err == nil { - path = ".env" + // 与 Compose 一致:根 .env(公共密钥)+ configs/.env.{ENV}(环境差异 overlay) + _ = applyEnvFile(cfg, ".env") + envName := cfg.Env + if v := os.Getenv("ENV"); v != "" { + envName = v } - } - f, err := os.Open(path) - if err == nil { - defer f.Close() - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - key := strings.TrimSpace(parts[0]) - val := strings.TrimSpace(parts[1]) - if commentIdx := strings.Index(val, " #"); commentIdx != -1 { - val = strings.TrimSpace(val[:commentIdx]) - } - - switch key { - case "ENV": - cfg.Env = val - case "DATA_DIR": - cfg.DataDir = val - case "APP_PUBLISH_HOST", "HTTP_HOST": - cfg.HTTPHost = val - case "HTTP_PORT": - if p, err := strconv.Atoi(val); err == nil { - cfg.HTTPPort = p - } - case "AGENT_BACKEND": - cfg.AgentBackend = val - case "AGENT_TIMEOUT": - if d, err := time.ParseDuration(val); err == nil { - cfg.AgentTimeout = d - } - case "CURSOR_API_KEY": - cfg.CursorAPIKey = val - case "CURSOR_MODEL": - cfg.CursorModel = val - case "CURSOR_BIN": - cfg.CursorBin = val - case "CURSOR_BRIDGE_BIN": - cfg.CursorBridgeBin = val - case "PI_AGENT_BIN": - cfg.PiAgentBin = val - case "PI_AGENT_BRIDGE_BIN": - cfg.PiAgentBridgeBin = val - case "PI_AGENT_PROVIDER": - cfg.PiAgentProvider = val - case "PI_AGENT_MODEL": - cfg.PiAgentModel = val - case "PI_AGENT_RUNNER_URL": - cfg.PiAgentRunnerURL = val - case "KB_DB_HOST": - cfg.KbDbHost = val - case "KB_DB_PORT": - if p, err := strconv.Atoi(val); err == nil { - cfg.KbDbPort = p - } - case "KB_DB_USER": - cfg.KbDbUser = val - case "KB_DB_PASSWORD": - cfg.KbDbPassword = val - case "KB_DB_NAME": - cfg.KbDbName = val - case "CONTEXT_DB_HOST": - cfg.ContextDbHost = val - case "CONTEXT_DB_PORT": - if p, err := strconv.Atoi(val); err == nil { - cfg.ContextDbPort = p - } - case "CONTEXT_DB_USER": - cfg.ContextDbUser = val - case "CONTEXT_DB_PASSWORD": - cfg.ContextDbPassword = val - case "CONTEXT_DB_NAME": - cfg.ContextDbName = val - case "EMBEDDING_ENDPOINT_URL": - cfg.EmbeddingEndpointURL = val - case "EMBEDDING_PROVIDER": - cfg.EmbeddingProvider = val - case "EMBEDDING_MODEL": - cfg.EmbeddingModel = val - case "EMBEDDING_API_KEY": - cfg.EmbeddingAPIKey = val - case "RERANKER_ENDPOINT_URL": - cfg.RerankerEndpointURL = val - case "RERANKER_PROVIDER": - cfg.RerankerProvider = val - case "RERANKER_MODEL": - cfg.RerankerModel = val - case "RERANKER_API_KEY": - cfg.RerankerAPIKey = val - case "ADMIN_USERNAME": - cfg.AdminUsername = val - case "ADMIN_PASSWORD": - cfg.AdminPassword = val - case "AUTH_REGISTRATION_ENABLED": - cfg.AuthRegistrationEnabled = val == "true" || val == "1" - case "AUTH_REQUIRE_APPROVAL": - cfg.AuthRequireApproval = val == "true" || val == "1" - case "MAX_CONCURRENT_AGENTS": - if n, err := strconv.Atoi(val); err == nil && n > 0 { - cfg.MaxConcurrentAgents = n - } - case "ALLOW_IN_MEMORY": - cfg.AllowInMemory = val == "true" || val == "1" - case "CORS_ALLOWED_ORIGINS": - cfg.CORSAllowedOrigins = parseCSVOrigins(val) - case "VAULT_STATE_ROOT": - cfg.VaultStateRoot = val - case "VAULT_STORAGE_IDENTITY": - cfg.VaultStorageIdentity = val - case "VAULT_MASTER_KEY": - cfg.VaultMasterKey = val - case "FAV_DEGRADED_READ": - cfg.FAVDegradedRead = val == "true" || val == "1" - case "FAV_PUBLISHER_ENABLED": - cfg.FAVPublisherEnabled = val == "true" || val == "1" - } + if envName == "" { + envName = "dev" } + _ = applyEnvFile(cfg, filepath.Join("configs", ".env."+envName)) + } else { + _ = applyEnvFile(cfg, path) } // 允许系统环境变量覆盖 @@ -406,6 +290,7 @@ func Load(path string) (*Config, error) { } applyDSNOverrides(cfg) applyCORSDefaults(cfg) + NormalizeKBHostForRuntime(cfg) if err := validateProductionSecrets(cfg); err != nil { return nil, err @@ -417,6 +302,162 @@ func Load(path string) (*Config, error) { return cfg, nil } +// applyEnvFile overlays KEY=VALUE pairs from path onto cfg. Missing file is a no-op. +func applyEnvFile(cfg *Config, path string) error { + if cfg == nil || strings.TrimSpace(path) == "" { + return nil + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + if commentIdx := strings.Index(val, " #"); commentIdx != -1 { + val = strings.TrimSpace(val[:commentIdx]) + } + + switch key { + case "ENV": + cfg.Env = val + case "DATA_DIR": + cfg.DataDir = val + case "APP_PUBLISH_HOST", "HTTP_HOST": + cfg.HTTPHost = val + case "HTTP_PORT": + if p, err := strconv.Atoi(val); err == nil { + cfg.HTTPPort = p + } + case "AGENT_BACKEND": + cfg.AgentBackend = val + case "AGENT_TIMEOUT": + if d, err := time.ParseDuration(val); err == nil { + cfg.AgentTimeout = d + } + case "CURSOR_API_KEY": + cfg.CursorAPIKey = val + case "CURSOR_MODEL": + cfg.CursorModel = val + case "CURSOR_BIN": + cfg.CursorBin = val + case "CURSOR_BRIDGE_BIN": + cfg.CursorBridgeBin = val + case "PI_AGENT_BIN": + cfg.PiAgentBin = val + case "PI_AGENT_BRIDGE_BIN": + cfg.PiAgentBridgeBin = val + case "PI_AGENT_PROVIDER": + cfg.PiAgentProvider = val + case "PI_AGENT_MODEL": + cfg.PiAgentModel = val + case "PI_AGENT_RUNNER_URL": + cfg.PiAgentRunnerURL = val + case "KB_DB_HOST": + cfg.KbDbHost = val + case "KB_DB_PORT": + if p, err := strconv.Atoi(val); err == nil { + cfg.KbDbPort = p + } + case "KB_DB_USER": + cfg.KbDbUser = val + case "KB_DB_PASSWORD": + cfg.KbDbPassword = val + case "KB_DB_NAME": + cfg.KbDbName = val + case "CONTEXT_DB_HOST": + cfg.ContextDbHost = val + case "CONTEXT_DB_PORT": + if p, err := strconv.Atoi(val); err == nil { + cfg.ContextDbPort = p + } + case "CONTEXT_DB_USER": + cfg.ContextDbUser = val + case "CONTEXT_DB_PASSWORD": + cfg.ContextDbPassword = val + case "CONTEXT_DB_NAME": + cfg.ContextDbName = val + case "EMBEDDING_ENDPOINT_URL": + cfg.EmbeddingEndpointURL = val + case "EMBEDDING_PROVIDER": + cfg.EmbeddingProvider = val + case "EMBEDDING_MODEL": + cfg.EmbeddingModel = val + case "EMBEDDING_API_KEY": + cfg.EmbeddingAPIKey = val + case "RERANKER_ENDPOINT_URL": + cfg.RerankerEndpointURL = val + case "RERANKER_PROVIDER": + cfg.RerankerProvider = val + case "RERANKER_MODEL": + cfg.RerankerModel = val + case "RERANKER_API_KEY": + cfg.RerankerAPIKey = val + case "ADMIN_USERNAME": + cfg.AdminUsername = val + case "ADMIN_PASSWORD": + cfg.AdminPassword = val + case "AUTH_REGISTRATION_ENABLED": + cfg.AuthRegistrationEnabled = val == "true" || val == "1" + case "AUTH_REQUIRE_APPROVAL": + cfg.AuthRequireApproval = val == "true" || val == "1" + case "MAX_CONCURRENT_AGENTS": + if n, err := strconv.Atoi(val); err == nil && n > 0 { + cfg.MaxConcurrentAgents = n + } + case "ALLOW_IN_MEMORY": + cfg.AllowInMemory = val == "true" || val == "1" + case "CORS_ALLOWED_ORIGINS": + cfg.CORSAllowedOrigins = parseCSVOrigins(val) + case "VAULT_STATE_ROOT": + cfg.VaultStateRoot = val + case "VAULT_STORAGE_IDENTITY": + cfg.VaultStorageIdentity = val + case "VAULT_MASTER_KEY": + cfg.VaultMasterKey = val + case "FAV_DEGRADED_READ": + cfg.FAVDegradedRead = val == "true" || val == "1" + case "FAV_PUBLISHER_ENABLED": + cfg.FAVPublisherEnabled = val == "true" || val == "1" + } + } + return scanner.Err() +} + +// NormalizeKBHostForRuntime rewrites Compose DNS names to localhost when running +// on the host (no /.dockerenv). Inside containers the shared network name is kept. +func NormalizeKBHostForRuntime(cfg *Config) { + if cfg == nil { + return + } + if _, err := os.Stat("/.dockerenv"); err == nil { + return + } + switch strings.TrimSpace(cfg.KbDbHost) { + case "shuimu-kb-shared", "shuimu-kb": + cfg.KbDbHost = "127.0.0.1" + } +} + +// RequireKbPassword returns an error when KB_DB_PASSWORD is empty (common after +// layered Load if root .env is missing). Callers that need online KB should check this. +func (c *Config) RequireKbPassword() error { + if c == nil || strings.TrimSpace(c.KbDbPassword) == "" { + return fmt.Errorf("config: KB_DB_PASSWORD is empty; provide it in root .env (layered with configs/.env.{ENV}) or process environment") + } + return nil +} + // ValidateIndependentVault refuses vault roots that collide with runtime data/{env}. // Empty root is allowed (FAV stays unconfigured / disabled). func ValidateIndependentVault(root, dataDir string) error { @@ -550,11 +591,30 @@ func parsePostgresDSN(dsn string) (host string, port int, user, password, dbName return host, port, user, password, dbName, true } -// KbDSN 返回 PostgreSQL 连接字符串 +// KbDSN 返回 PostgreSQL 连接字符串。口令为空时仍返回可解析 DSN,但不应被静默当真口令使用。 func (c *Config) KbDSN() string { return "postgres://" + c.KbDbUser + ":" + c.KbDbPassword + "@" + c.KbDbHost + ":" + strconv.Itoa(c.KbDbPort) + "/" + c.KbDbName + "?sslmode=disable" } +// RedactPostgresDSN masks the password segment of a postgres:// DSN for logs. +func RedactPostgresDSN(dsn string) string { + if !strings.HasPrefix(dsn, "postgres://") { + return dsn + } + rest := strings.TrimPrefix(dsn, "postgres://") + at := strings.LastIndex(rest, "@") + if at <= 0 { + return "postgres://***" + } + creds := rest[:at] + hostPart := rest[at+1:] + userPass := strings.SplitN(creds, ":", 2) + if len(userPass) != 2 { + return "postgres://***@" + hostPart + } + return "postgres://" + userPass[0] + ":***@" + hostPart +} + // ContextDSN 返回专属记忆与多级上下文 PostgreSQL 连接字符串 func (c *Config) ContextDSN() string { return "postgres://" + c.ContextDbUser + ":" + c.ContextDbPassword + "@" + c.ContextDbHost + ":" + strconv.Itoa(c.ContextDbPort) + "/" + c.ContextDbName + "?sslmode=disable" diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 15298d75..4cfc48ee 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config_test import ( "os" "path/filepath" + "strings" "testing" "time" @@ -10,6 +11,17 @@ import ( ) func TestConfigLoadDefaultsAndFile(t *testing.T) { + t.Setenv("ENV", "") + t.Setenv("DATA_DIR", "") + t.Setenv("AGENT_BACKEND", "") + t.Setenv("AGENT_TIMEOUT", "") + t.Setenv("KB_DB_PORT", "") + t.Setenv("KB_DB_PASSWORD", "") + t.Setenv("KB_DB_HOST", "") + t.Setenv("POSTGRES_SHARED_KB_DSN", "") + t.Setenv("APP_DB_DSN", "") + t.Setenv("HTTP_ADDR", "") + tmpDir := t.TempDir() envPath := filepath.Join(tmpDir, ".env") envContent := `ENV=dev @@ -58,8 +70,13 @@ func TestConfigLoadDSNOverrides(t *testing.T) { if cfg.ContextDbHost != "system-db" || cfg.ContextDbPort != 5432 || cfg.ContextDbUser != "app_user" || cfg.ContextDbName != "agent_system_dev" { t.Fatalf("unexpected context db config: %+v", cfg) } - if cfg.KbDbHost != "shuimu-kb-shared" || cfg.KbDbPort != 5432 || cfg.KbDbUser != "kb_user" || cfg.KbDbName != "regulations" { - t.Fatalf("unexpected kb db config: %+v", cfg) + // 宿主机运行时会将 Compose DNS 归一化为 127.0.0.1(容器内保留原名) + wantHost := "shuimu-kb-shared" + if _, err := os.Stat("/.dockerenv"); err != nil { + wantHost = "127.0.0.1" + } + if cfg.KbDbHost != wantHost || cfg.KbDbPort != 5432 || cfg.KbDbUser != "kb_user" || cfg.KbDbName != "regulations" { + t.Fatalf("unexpected kb db config: host=%s want=%s cfg=%+v", cfg.KbDbHost, wantHost, cfg) } if cfg.HTTPPort != 9090 || cfg.HTTPHost != "0.0.0.0" { t.Fatalf("expected HTTP 0.0.0.0:9090, got %s:%d", cfg.HTTPHost, cfg.HTTPPort) @@ -184,3 +201,108 @@ func TestValidateIndependentVault(t *testing.T) { t.Fatal(err) } } + +func TestConfigDefaultKbPasswordEmpty(t *testing.T) { + t.Setenv("KB_DB_PASSWORD", "") + t.Setenv("KB_DB_HOST", "") + t.Setenv("POSTGRES_SHARED_KB_DSN", "") + cfg, err := config.Load("/dev/null") + if err != nil { + t.Fatal(err) + } + if cfg.KbDbPassword != "" { + t.Fatalf("expected empty default KB password, got %q", cfg.KbDbPassword) + } + if err := cfg.RequireKbPassword(); err == nil { + t.Fatal("expected RequireKbPassword to fail when empty") + } +} + +func TestConfigLayeredLoadMergesRootPassword(t *testing.T) { + t.Setenv("KB_DB_PASSWORD", "") + t.Setenv("KB_DB_HOST", "") + t.Setenv("KB_DB_PORT", "") + t.Setenv("KB_DB_USER", "") + t.Setenv("POSTGRES_SHARED_KB_DSN", "") + t.Setenv("ENV", "") + t.Setenv("DATA_DIR", "") + + root := t.TempDir() + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(old) }) + + if err := os.MkdirAll("configs", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(".env", []byte("KB_DB_PASSWORD=layered-real-pass\nKB_DB_HOST=shuimu-kb-shared\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("configs/.env.dev", []byte("ENV=dev\nKB_DB_PORT=5432\nKB_DB_USER=regkb\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := config.Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.KbDbPassword != "layered-real-pass" { + t.Fatalf("expected layered password from root .env, got %q", cfg.KbDbPassword) + } + if cfg.KbDbPassword == "shared_kb_secret_pass" { + t.Fatal("must not use obsolete insecure default password") + } + wantHost := "127.0.0.1" + if _, err := os.Stat("/.dockerenv"); err == nil { + wantHost = "shuimu-kb-shared" + } + if cfg.KbDbHost != wantHost { + t.Fatalf("expected host normalize to %s, got %s", wantHost, cfg.KbDbHost) + } +} + +func TestNormalizeKBHostForRuntime(t *testing.T) { + cfg := &config.Config{KbDbHost: "shuimu-kb-shared"} + config.NormalizeKBHostForRuntime(cfg) + if _, err := os.Stat("/.dockerenv"); err == nil { + if cfg.KbDbHost != "shuimu-kb-shared" { + t.Fatalf("in docker must keep compose host, got %s", cfg.KbDbHost) + } + return + } + if cfg.KbDbHost != "127.0.0.1" { + t.Fatalf("on host expected 127.0.0.1, got %s", cfg.KbDbHost) + } +} + +func TestRedactPostgresDSN(t *testing.T) { + got := config.RedactPostgresDSN("postgres://regkb:super-secret@127.0.0.1:5432/regulations?sslmode=disable") + if got != "postgres://regkb:***@127.0.0.1:5432/regulations?sslmode=disable" { + t.Fatalf("unexpected redact: %s", got) + } + if strings.Contains(got, "super-secret") { + t.Fatal("password leaked in redacted DSN") + } +} + +func TestLiveLayeredLoadHasNonDefaultPassword(t *testing.T) { + if os.Getenv("CHECK_LIVE_KB_ENV") != "1" { + t.Skip("set CHECK_LIVE_KB_ENV=1 to assert workspace root .env") + } + cfg, err := config.Load("") + if err != nil { + t.Fatal(err) + } + if cfg.KbDbPassword == "" || cfg.KbDbPassword == "shared_kb_secret_pass" { + t.Fatalf("live Load missing real KB password (len=%d)", len(cfg.KbDbPassword)) + } + if cfg.KbDbHost != "127.0.0.1" && cfg.KbDbHost != "shuimu-kb-shared" { + t.Fatalf("unexpected host %s", cfg.KbDbHost) + } + t.Logf("live kb host=%s port=%d password_set=true", cfg.KbDbHost, cfg.KbDbPort) +}