package config import ( "fmt" "os" "strconv" "strings" "time" ) const ( EnvListenAddr = "SUB2API_CRM_LISTEN_ADDR" EnvSQLiteDSN = "SUB2API_CRM_SQLITE_DSN" EnvAdminToken = "SUB2API_CRM_ADMIN_TOKEN" EnvAdminUsername = "SUB2API_CRM_ADMIN_USERNAME" EnvAdminPassword = "SUB2API_CRM_ADMIN_PASSWORD" EnvAdminSessionTTL = "SUB2API_CRM_ADMIN_SESSION_TTL" EnvRepoRoot = "SUB2API_CRM_REPO_ROOT" EnvReconcileWorkerEnabled = "SUB2API_CRM_RECONCILE_WORKER_ENABLED" EnvReconcilePollInterval = "SUB2API_CRM_RECONCILE_POLL_INTERVAL" EnvRouteRuntimeBackend = "SUB2API_CRM_ROUTE_RUNTIME_BACKEND" EnvRedisAddr = "SUB2API_CRM_REDIS_ADDR" EnvRedisPassword = "SUB2API_CRM_REDIS_PASSWORD" EnvRedisDB = "SUB2API_CRM_REDIS_DB" DefaultListenAddr = ":8080" DefaultSQLiteDSN = "file:sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000" DefaultAdminUsername = "admin" DefaultAdminSessionTTL = 12 * time.Hour DefaultReconcilePollInterval = 10 * time.Minute DefaultRouteRuntimeBackend = "memory" ) type ServerConfig struct { ListenAddr string } type DatabaseConfig struct { SQLiteDSN string } type RedisRuntimeConfig struct { Addr string Password string DB int } type RouteRuntimeConfig struct { Backend string Redis RedisRuntimeConfig } type ReconcileConfig struct { WorkerEnabled bool PollInterval time.Duration } type RepositoryConfig struct { RepoRoot string } type StartupConfig struct { Server ServerConfig Database DatabaseConfig Repository RepositoryConfig RouteRuntime RouteRuntimeConfig Reconcile ReconcileConfig } type AdminSessionConfig struct { Username string Password string SessionTTL time.Duration } func LoadStartupFromEnv() (StartupConfig, error) { return loadStartupFromLookupEnv(os.LookupEnv) } func loadStartupFromLookupEnv(lookup func(string) (string, bool)) (StartupConfig, error) { reconcilePollInterval, err := readOptionalDurationEnv(lookup, EnvReconcilePollInterval, DefaultReconcilePollInterval) if err != nil { return StartupConfig{}, err } redisDB, err := readOptionalIntEnv(lookup, EnvRedisDB, 0) if err != nil { return StartupConfig{}, err } cfg := StartupConfig{ Server: ServerConfig{ ListenAddr: readOptionalEnv(lookup, EnvListenAddr, DefaultListenAddr), }, Database: DatabaseConfig{ SQLiteDSN: readOptionalEnv(lookup, EnvSQLiteDSN, DefaultSQLiteDSN), }, Repository: RepositoryConfig{ RepoRoot: readOptionalEnv(lookup, EnvRepoRoot, ""), }, RouteRuntime: RouteRuntimeConfig{ Backend: readOptionalEnv(lookup, EnvRouteRuntimeBackend, DefaultRouteRuntimeBackend), Redis: RedisRuntimeConfig{ Addr: readOptionalEnv(lookup, EnvRedisAddr, ""), Password: readOptionalEnv(lookup, EnvRedisPassword, ""), DB: redisDB, }, }, Reconcile: ReconcileConfig{ WorkerEnabled: readOptionalBoolEnv(lookup, EnvReconcileWorkerEnabled, false), PollInterval: reconcilePollInterval, }, } return cfg, nil } func LoadAdminTokenFromEnv() (string, error) { return loadAdminTokenFromLookupEnv(os.LookupEnv) } func LoadAdminSessionFromEnv() (AdminSessionConfig, error) { return loadAdminSessionFromLookupEnv(os.LookupEnv) } func loadAdminTokenFromLookupEnv(lookup func(string) (string, bool)) (string, error) { token := strings.TrimSpace(readRequiredEnv(lookup, EnvAdminToken)) if token == "" { return "", fmt.Errorf("%s is required", EnvAdminToken) } return token, nil } func loadAdminSessionFromLookupEnv(lookup func(string) (string, bool)) (AdminSessionConfig, error) { ttl, err := readOptionalDurationEnv(lookup, EnvAdminSessionTTL, DefaultAdminSessionTTL) if err != nil { return AdminSessionConfig{}, err } return AdminSessionConfig{ Username: readOptionalEnv(lookup, EnvAdminUsername, DefaultAdminUsername), Password: strings.TrimSpace(readOptionalEnv(lookup, EnvAdminPassword, "")), SessionTTL: ttl, }, nil } func readOptionalEnv(lookup func(string) (string, bool), key string, defaultValue string) string { value, ok := lookup(key) if !ok { return defaultValue } value = strings.TrimSpace(value) if value == "" { return defaultValue } return value } func readRequiredEnv(lookup func(string) (string, bool), key string) string { value, ok := lookup(key) if !ok { return "" } return value } func readOptionalBoolEnv(lookup func(string) (string, bool), key string, defaultValue bool) bool { value, ok := lookup(key) if !ok { return defaultValue } switch strings.ToLower(strings.TrimSpace(value)) { case "1", "true", "yes", "on": return true case "0", "false", "no", "off", "": return false default: return defaultValue } } func readOptionalDurationEnv(lookup func(string) (string, bool), key string, defaultValue time.Duration) (time.Duration, error) { value, ok := lookup(key) if !ok || strings.TrimSpace(value) == "" { return defaultValue, nil } duration, err := time.ParseDuration(strings.TrimSpace(value)) if err != nil { return 0, fmt.Errorf("%s: parse duration: %w", key, err) } if duration <= 0 { return 0, fmt.Errorf("%s: duration must be positive", key) } return duration, nil } func readOptionalIntEnv(lookup func(string) (string, bool), key string, defaultValue int) (int, error) { value, ok := lookup(key) if !ok || strings.TrimSpace(value) == "" { return defaultValue, nil } number, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil { return 0, fmt.Errorf("%s: parse int: %w", key, err) } if number < 0 { return 0, fmt.Errorf("%s: value must be >= 0", key) } return number, nil }