package config import ( "time" "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" ) var runtimeFields = map[string]bool{ "SFU_PROVIDER": true, "DISTRIBUTORS": true, "CDN_RTMP_URL": true, "CDN_NAME": true, "SRS_API_BASE": true, "SRS_HTTP_BASE": true, "SRS_APP": true, "SRS_SECRET": true, "SRS_CANDIDATE": true, "CF_APP_ID": true, "CF_APP_SECRET": true, "CF_BASE_URL": true, "CF_STUN_URL": true, "SFU_TOKEN_REQUIRED": true, "ALLOW_REGISTER": true, "JWT_TTL": true, "SMTP_HOST": true, "SMTP_PORT": true, "SMTP_USER": true, "SMTP_PASS": true, "SMTP_FROM": true, "SMTP_FROM_NAME": true, "SMTP_TLS": true, "SMTP_ENABLED": true, "EMAIL_VERIFY_REQUIRED": true, "EMAIL_VERIFY_TTL": true, "EMAIL_VERIFY_BASE_URL": true, "OAUTH_ENABLED": true, "OAUTH_CALLBACK_BASE": true, "OAUTH_GOOGLE_CLIENT_ID": true, "OAUTH_GOOGLE_CLIENT_SECRET": true, "OAUTH_GITHUB_CLIENT_ID": true, "OAUTH_GITHUB_CLIENT_SECRET": true, "OAUTH_GITLAB_CLIENT_ID": true, "OAUTH_GITLAB_CLIENT_SECRET": true, "OAUTH_GENERIC_CLIENT_ID": true, "OAUTH_GENERIC_CLIENT_SECRET": true, "OAUTH_GENERIC_URL": true, "OAUTH_GENERIC_NAME": true, } type RuntimeView struct { Values map[string]string `json:"values"` Masked map[string]bool `json:"masked"` Source map[string]string `json:"source"` } var secretKeys = map[string]bool{ "CF_APP_SECRET": true, "SRS_SECRET": true, "SMTP_PASS": true, "OAUTH_GOOGLE_CLIENT_SECRET": true, "OAUTH_GITHUB_CLIENT_SECRET": true, "OAUTH_GITLAB_CLIENT_SECRET": true, "OAUTH_GENERIC_CLIENT_SECRET": true, } func (c *Config) ensureMu() { if c.mu == nil { c.mu = &sync.RWMutex{} } if c.runtimeFile == "" { c.runtimeFile = "data/runtime_config.json" } if c.overrides == nil { c.overrides = map[string]string{} } } func (c *Config) LoadRuntime() error { c.ensureMu() path := c.runtimeFile if path == "" { path = "data/runtime_config.json" } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil } return err } var m map[string]string if err := json.Unmarshal(data, &m); err != nil { return fmt.Errorf("parse runtime config: %w", err) } c.mu.Lock() defer c.mu.Unlock() for k, v := range m { nk := normalizeRuntimeKey(k) if !runtimeFields[nk] { continue } c.overrides[nk] = v } c.applyOverridesLocked() return nil } func (c *Config) SaveRuntime() error { c.ensureMu() c.mu.RLock() m := make(map[string]string, len(c.overrides)) for k, v := range c.overrides { m[k] = v } path := c.runtimeFile c.mu.RUnlock() if path == "" { path = "data/runtime_config.json" } if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } data, _ := json.MarshalIndent(m, "", " ") tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0644); err != nil { return err } return os.Rename(tmp, path) } func (c *Config) ViewRuntime(masked bool) RuntimeView { c.ensureMu() c.mu.RLock() defer c.mu.RUnlock() values := map[string]string{} maskedMap := map[string]bool{} source := map[string]string{} for k := range runtimeFields { v := c.fieldValue(k) isSecret := secretKeys[k] _, fromRuntime := c.overrides[k] if masked && isSecret && v != "" { values[k] = maskSecret(v) maskedMap[k] = true } else { values[k] = v if isSecret && v != "" { maskedMap[k] = true } } if fromRuntime { source[k] = "runtime" } else if os.Getenv(k) != "" { source[k] = "env" } else { source[k] = "default" } } return RuntimeView{Values: values, Masked: maskedMap, Source: source} } func (c *Config) ApplyRuntimePatch(patch map[string]string) ([]string, error) { c.ensureMu() norm := map[string]string{} for k, v := range patch { nk := normalizeRuntimeKey(k) if !runtimeFields[nk] { return nil, fmt.Errorf("不支持的运行时配置键: %s", k) } if secretKeys[nk] && isMaskPlaceholder(v) { continue } norm[nk] = strings.TrimSpace(v) } c.mu.Lock() defer c.mu.Unlock() changed := []string{} old := map[string]string{} for k, v := range norm { old[k] = c.overrides[k] if old[k] != v { changed = append(changed, k) } if v == "" { delete(c.overrides, k) // revert field to env/default immediately c.revertFieldLocked(k) } else { c.overrides[k] = v } } c.applyOverridesLocked() if err := c.ValidateDistributors(); err != nil { for k := range norm { if ov, ok := old[k]; ok { c.overrides[k] = ov } else { delete(c.overrides, k) } } c.applyOverridesLocked() return nil, err } if v, ok := norm["CF_STUN_URL"]; ok && v != "" && !strings.Contains(v, ":") { for k := range norm { if ov, ok := old[k]; ok { c.overrides[k] = ov } else { delete(c.overrides, k) } } c.applyOverridesLocked() return nil, fmt.Errorf("CF_STUN_URL 格式错误") } return changed, nil } func normalizeRuntimeKey(k string) string { k = strings.TrimSpace(k) k = strings.ToUpper(k) k = strings.ReplaceAll(k, "-", "_") return k } func isMaskPlaceholder(s string) bool { if s == "" { return false } for _, ch := range s { if ch != '•' && ch != '*' && ch != '·' { return false } } return true } func maskSecret(s string) string { if s == "" { return "" } return "••••••••" } func (c *Config) fieldValue(key string) string { switch key { case "SFU_PROVIDER": return c.ProviderOrder case "DISTRIBUTORS": return c.Distributors case "CDN_RTMP_URL": return c.CDNRTMPURL case "CDN_NAME": return c.CDNName case "SRS_API_BASE": return c.SRSBaseURL case "SRS_HTTP_BASE": return c.SRSHttpURL case "SRS_APP": return c.SRSApp case "SRS_SECRET": return c.SRSSecret case "SRS_CANDIDATE": return c.SRSCandidate case "CF_APP_ID": return c.CFAppID case "CF_APP_SECRET": return c.CFAppSecret case "CF_BASE_URL": return c.CFBaseURL case "CF_STUN_URL": return c.CFStunURL case "SFU_TOKEN_REQUIRED": if c.TokenRequired { return "1" } return "0" case "ALLOW_REGISTER": if c.AllowRegister { return "1" } return "0" case "JWT_TTL": return c.JWTTTL.String() case "SMTP_HOST": return c.SMTPHost case "SMTP_PORT": return fmt.Sprintf("%d", c.SMTPPort) case "SMTP_USER": return c.SMTPUser case "SMTP_PASS": return c.SMTPPass case "SMTP_FROM": return c.SMTPFrom case "SMTP_FROM_NAME": return c.SMTPFromName case "SMTP_TLS": if c.SMTPTLS { return "1" } return "0" case "SMTP_ENABLED": if c.SMTPEnabled { return "1" } return "0" case "EMAIL_VERIFY_REQUIRED": if c.EmailVerifyRequired { return "1" } return "0" case "EMAIL_VERIFY_TTL": return c.EmailVerifyTTL.String() case "EMAIL_VERIFY_BASE_URL": return c.EmailVerifyBaseURL case "OAUTH_ENABLED": if c.OAuthEnabled { return "1" } return "0" case "OAUTH_CALLBACK_BASE": return c.OAuthCallbackBase case "OAUTH_GOOGLE_CLIENT_ID": return c.OAuthGoogleID case "OAUTH_GOOGLE_CLIENT_SECRET": return c.OAuthGoogleSecret case "OAUTH_GITHUB_CLIENT_ID": return c.OAuthGithubID case "OAUTH_GITHUB_CLIENT_SECRET": return c.OAuthGithubSecret case "OAUTH_GITLAB_CLIENT_ID": return c.OAuthGitlabID case "OAUTH_GITLAB_CLIENT_SECRET": return c.OAuthGitlabSecret case "OAUTH_GENERIC_CLIENT_ID": return c.OAuthGenericID case "OAUTH_GENERIC_CLIENT_SECRET": return c.OAuthGenericSecret case "OAUTH_GENERIC_URL": return c.OAuthGenericURL case "OAUTH_GENERIC_NAME": return c.OAuthGenericName default: return "" } } func (c *Config) applyOverridesLocked() { for k, v := range c.overrides { switch k { case "SFU_PROVIDER": c.ProviderOrder = envOr(v, c.ProviderOrder) case "DISTRIBUTORS": c.Distributors = envOr(v, c.Distributors) case "CDN_RTMP_URL": c.CDNRTMPURL = v case "CDN_NAME": if v != "" { c.CDNName = v } else { c.CDNName = "cdn" } case "SRS_API_BASE": if v != "" { c.SRSBaseURL = v } case "SRS_HTTP_BASE": if v != "" { c.SRSHttpURL = v } case "SRS_APP": if v != "" { c.SRSApp = v } case "SRS_SECRET": c.SRSSecret = v case "SRS_CANDIDATE": if v != "" { c.SRSCandidate = v } case "CF_APP_ID": c.CFAppID = v case "CF_APP_SECRET": c.CFAppSecret = v case "CF_BASE_URL": if v != "" { c.CFBaseURL = v } case "CF_STUN_URL": if v != "" { c.CFStunURL = v } case "SFU_TOKEN_REQUIRED": c.TokenRequired = v == "1" || strings.EqualFold(v, "true") case "ALLOW_REGISTER": c.AllowRegister = v == "1" || strings.EqualFold(v, "true") case "JWT_TTL": if d := parseDuration(v, 0); d != 0 { c.JWTTTL = d } case "SMTP_HOST": c.SMTPHost = v case "SMTP_PORT": if v != "" { c.SMTPPort = parseInt(v, c.SMTPPort) } case "SMTP_USER": c.SMTPUser = v case "SMTP_PASS": c.SMTPPass = v case "SMTP_FROM": c.SMTPFrom = v case "SMTP_FROM_NAME": if v != "" { c.SMTPFromName = v } case "SMTP_TLS": c.SMTPTLS = v == "1" || strings.EqualFold(v, "true") case "SMTP_ENABLED": c.SMTPEnabled = v == "1" || strings.EqualFold(v, "true") case "EMAIL_VERIFY_REQUIRED": c.EmailVerifyRequired = v == "1" || strings.EqualFold(v, "true") case "EMAIL_VERIFY_TTL": if d := parseDuration(v, 0); d != 0 { c.EmailVerifyTTL = d } case "EMAIL_VERIFY_BASE_URL": if v != "" { c.EmailVerifyBaseURL = v } case "OAUTH_ENABLED": c.OAuthEnabled = v == "1" || strings.EqualFold(v, "true") case "OAUTH_CALLBACK_BASE": if v != "" { c.OAuthCallbackBase = v } case "OAUTH_GOOGLE_CLIENT_ID": c.OAuthGoogleID = v case "OAUTH_GOOGLE_CLIENT_SECRET": c.OAuthGoogleSecret = v case "OAUTH_GITHUB_CLIENT_ID": c.OAuthGithubID = v case "OAUTH_GITHUB_CLIENT_SECRET": c.OAuthGithubSecret = v case "OAUTH_GITLAB_CLIENT_ID": c.OAuthGitlabID = v case "OAUTH_GITLAB_CLIENT_SECRET": c.OAuthGitlabSecret = v case "OAUTH_GENERIC_CLIENT_ID": c.OAuthGenericID = v case "OAUTH_GENERIC_CLIENT_SECRET": c.OAuthGenericSecret = v case "OAUTH_GENERIC_URL": c.OAuthGenericURL = v case "OAUTH_GENERIC_NAME": if v != "" { c.OAuthGenericName = v } } } if c.SMTPFrom == "" && c.SMTPUser != "" { c.SMTPFrom = c.SMTPUser } if c.EmailVerifyBaseURL == "" { c.EmailVerifyBaseURL = "http://localhost:" + c.HTTPPort } if c.OAuthCallbackBase == "" { c.OAuthCallbackBase = "http://localhost:" + c.HTTPPort } } func envOr(v, fallback string) string { if v != "" { return v } return fallback } func revertToEnv(key, fallback string) string { envKey := key if v := getenv(envKey, ""); v != "" { return v } // hardcoded defaults for critical keys switch key { case "DISTRIBUTORS": return "srs-hls,cf,cdn" case "SFU_PROVIDER": return "cloudflare,srs" case "SRS_API_BASE": return "http://localhost:1985" case "SRS_HTTP_BASE": return "http://localhost:8080" case "SRS_APP": return "live" case "SRS_CANDIDATE": return "127.0.0.1" case "CF_BASE_URL": return "https://rtc.live.cloudflare.com/v1" case "CF_STUN_URL": return "stun:stun.cloudflare.com:3478" case "CDN_NAME": return "cdn" } return fallback } func (c *Config) GetOverrides() map[string]string { c.ensureMu(); return c.overrides } func (c *Config) revertFieldLocked(key string) { switch key { case "SFU_PROVIDER": c.ProviderOrder = revertToEnv(key, c.ProviderOrder) case "DISTRIBUTORS": c.Distributors = revertToEnv(key, c.Distributors) case "CDN_RTMP_URL": c.CDNRTMPURL = "" case "CDN_NAME": c.CDNName = revertToEnv(key, c.CDNName) case "SRS_API_BASE": c.SRSBaseURL = revertToEnv(key, c.SRSBaseURL) case "SRS_HTTP_BASE": c.SRSHttpURL = revertToEnv(key, c.SRSHttpURL) case "SRS_APP": c.SRSApp = revertToEnv(key, c.SRSApp) case "SRS_SECRET": c.SRSSecret = "" case "SRS_CANDIDATE": c.SRSCandidate = revertToEnv(key, c.SRSCandidate) case "CF_APP_ID": c.CFAppID = "" case "CF_APP_SECRET": c.CFAppSecret = "" case "CF_BASE_URL": c.CFBaseURL = revertToEnv(key, c.CFBaseURL) case "CF_STUN_URL": c.CFStunURL = revertToEnv(key, c.CFStunURL) case "SFU_TOKEN_REQUIRED": c.TokenRequired = getenv("SFU_TOKEN_REQUIRED", "0") == "1" case "ALLOW_REGISTER": c.AllowRegister = getenv("ALLOW_REGISTER", "1") == "1" case "JWT_TTL": c.JWTTTL = parseDuration(getenv("JWT_TTL", "2h"), 2*time.Hour) case "SMTP_HOST": c.SMTPHost = "" case "SMTP_PORT": c.SMTPPort = parseInt(getenv("SMTP_PORT", "587"), 587) case "SMTP_USER": c.SMTPUser = "" case "SMTP_PASS": c.SMTPPass = "" case "SMTP_FROM": c.SMTPFrom = "" case "SMTP_FROM_NAME": c.SMTPFromName = revertToEnv(key, "SyncLive") case "SMTP_TLS": c.SMTPTLS = getenv("SMTP_TLS", "1") == "1" case "SMTP_ENABLED": c.SMTPEnabled = getenv("SMTP_ENABLED", "0") == "1" case "EMAIL_VERIFY_REQUIRED": c.EmailVerifyRequired = getenv("EMAIL_VERIFY_REQUIRED", "0") == "1" case "EMAIL_VERIFY_TTL": c.EmailVerifyTTL = parseDuration(getenv("EMAIL_VERIFY_TTL", "24h"), 24*time.Hour) case "EMAIL_VERIFY_BASE_URL": c.EmailVerifyBaseURL = revertToEnv(key, "http://localhost:"+c.HTTPPort) case "OAUTH_ENABLED": c.OAuthEnabled = getenv("OAUTH_ENABLED", "0") == "1" case "OAUTH_CALLBACK_BASE": c.OAuthCallbackBase = revertToEnv(key, "http://localhost:"+c.HTTPPort) case "OAUTH_GOOGLE_CLIENT_ID", "OAUTH_GOOGLE_CLIENT_SECRET", "OAUTH_GITHUB_CLIENT_ID", "OAUTH_GITHUB_CLIENT_SECRET", "OAUTH_GITLAB_CLIENT_ID", "OAUTH_GITLAB_CLIENT_SECRET", "OAUTH_GENERIC_CLIENT_ID", "OAUTH_GENERIC_CLIENT_SECRET", "OAUTH_GENERIC_URL", "OAUTH_GENERIC_NAME": // generic string clear switch key { case "OAUTH_GOOGLE_CLIENT_ID": c.OAuthGoogleID = "" case "OAUTH_GOOGLE_CLIENT_SECRET": c.OAuthGoogleSecret = "" case "OAUTH_GITHUB_CLIENT_ID": c.OAuthGithubID = "" case "OAUTH_GITHUB_CLIENT_SECRET": c.OAuthGithubSecret = "" case "OAUTH_GITLAB_CLIENT_ID": c.OAuthGitlabID = "" case "OAUTH_GITLAB_CLIENT_SECRET": c.OAuthGitlabSecret = "" case "OAUTH_GENERIC_CLIENT_ID": c.OAuthGenericID = "" case "OAUTH_GENERIC_CLIENT_SECRET": c.OAuthGenericSecret = "" case "OAUTH_GENERIC_URL": c.OAuthGenericURL = "" case "OAUTH_GENERIC_NAME": c.OAuthGenericName = revertToEnv(key, "generic") } } }