package config import ( "fmt" "os" "strings" "sync" "time" ) // Config 承载直播 SFU 分流服务的运行参数。 // 优先级:环境变量 > 默认值。Cloudflare Realtime 为主 SFU,SRS 为本地对照后端。 // 默认 DB 使用 libSQL 嵌入式 (file) 持久化房间拓扑;同时支持 JWT/Casbin 登录。 type Config struct { HTTPPort string GRPCPort string ProviderOrder string // 逗号分隔的后端顺序,如 "cloudflare,srs" SRSBaseURL string SRSHttpURL string SRSApp string SRSSecret string SRSCandidate string CFAppID string CFAppSecret string CFBaseURL string CFStunURL string TokenSecret string // 推流 JWT 签名密钥(SRS 入口鉴权) TokenRequired bool JWTSecret string JWTTTL time.Duration AuthModel string AuthPolicy string AuthUserFile string AllowRegister bool SMTPHost string SMTPPort int SMTPUser string SMTPPass string SMTPFrom string SMTPFromName string SMTPTLS bool SMTPEnabled bool EmailVerifyRequired bool EmailVerifyTTL time.Duration EmailVerifyBaseURL string OAuthEnabled bool OAuthCallbackBase string OAuthGoogleID string OAuthGoogleSecret string OAuthGithubID string OAuthGithubSecret string OAuthGitlabID string OAuthGitlabSecret string OAuthGenericID string OAuthGenericSecret string OAuthGenericURL string OAuthGenericName string AuthBossEnabled bool Distributors string // 逗号分隔的分发方式,如 "srs-hls,cf,cdn" CDNRTMPURL string // 第三方 CDN RTMP 推流地址模板,如 rtmp://cdn.example.com/live CDNName string DatabaseURL string // 嵌入式 DB DSN(TURSO_DATABASE_URL / DATABASE_URL),默认 file:./data/sync-live.db AuthToken string // 远程 libSQL 凭证,嵌入模式可为空 mu *sync.RWMutex `json:"-"` overrides map[string]string `json:"-"` runtimeFile string `json:"-"` } func Load() *Config { c := &Config{ HTTPPort: getenv("HTTP_PORT", "8088"), GRPCPort: getenv("GRPC_PORT", "9090"), ProviderOrder: getenv("SFU_PROVIDER", "cloudflare,srs"), SRSBaseURL: getenv("SRS_API_BASE", "http://localhost:1985"), SRSHttpURL: getenv("SRS_HTTP_BASE", "http://localhost:8080"), SRSApp: getenv("SRS_APP", "live"), SRSSecret: getenv("SRS_SECRET", ""), SRSCandidate: getenv("SRS_CANDIDATE", "127.0.0.1"), CFAppID: getenv("CF_APP_ID", ""), CFAppSecret: getenv("CF_APP_SECRET", ""), CFBaseURL: getenv("CF_BASE_URL", "https://rtc.live.cloudflare.com/v1"), CFStunURL: getenv("CF_STUN_URL", "stun:stun.cloudflare.com:3478"), TokenSecret: getenv("SFU_TOKEN_SECRET", ""), TokenRequired: getenv("SFU_TOKEN_REQUIRED", "0") == "1", JWTSecret: getenv("JWT_SECRET", ""), JWTTTL: parseDuration(getenv("JWT_TTL", "2h"), 2*time.Hour), AuthModel: getenv("CASBIN_MODEL", "internal/auth/model.conf"), AuthPolicy: getenv("CASBIN_POLICY", "internal/auth/policy.csv"), AuthUserFile: getenv("AUTH_USER_FILE", "data/users.json"), AllowRegister: getenv("ALLOW_REGISTER", "1") == "1", SMTPHost: getenv("SMTP_HOST", ""), SMTPPort: parseInt(getenv("SMTP_PORT", "587"), 587), SMTPUser: getenv("SMTP_USER", ""), SMTPPass: getenv("SMTP_PASS", ""), SMTPFrom: getenv("SMTP_FROM", ""), SMTPFromName: getenv("SMTP_FROM_NAME", "SyncLive"), SMTPTLS: getenv("SMTP_TLS", "1") == "1", SMTPEnabled: getenv("SMTP_ENABLED", "0") == "1", EmailVerifyRequired: getenv("EMAIL_VERIFY_REQUIRED", "0") == "1", EmailVerifyTTL: parseDuration(getenv("EMAIL_VERIFY_TTL", "24h"), 24*time.Hour), EmailVerifyBaseURL: getenv("EMAIL_VERIFY_BASE_URL", ""), OAuthEnabled: getenv("OAUTH_ENABLED", "0") == "1", OAuthCallbackBase: getenv("OAUTH_CALLBACK_BASE", ""), OAuthGoogleID: getenv("OAUTH_GOOGLE_CLIENT_ID", ""), OAuthGoogleSecret: getenv("OAUTH_GOOGLE_CLIENT_SECRET", ""), OAuthGithubID: getenv("OAUTH_GITHUB_CLIENT_ID", ""), OAuthGithubSecret: getenv("OAUTH_GITHUB_CLIENT_SECRET", ""), OAuthGitlabID: getenv("OAUTH_GITLAB_CLIENT_ID", ""), OAuthGitlabSecret: getenv("OAUTH_GITLAB_CLIENT_SECRET", ""), OAuthGenericID: getenv("OAUTH_GENERIC_CLIENT_ID", ""), OAuthGenericSecret: getenv("OAUTH_GENERIC_CLIENT_SECRET", ""), OAuthGenericURL: getenv("OAUTH_GENERIC_URL", ""), OAuthGenericName: getenv("OAUTH_GENERIC_NAME", "generic"), AuthBossEnabled: getenv("AUTHBOSS_ENABLED", "0") == "1", Distributors: getenv("DISTRIBUTORS", "srs-hls,cf,cdn"), CDNRTMPURL: getenv("CDN_RTMP_URL", ""), CDNName: getenv("CDN_NAME", "cdn"), DatabaseURL: getenv("TURSO_DATABASE_URL", "file:./data/sync-live.db?cache=shared&_journal_mode=WAL"), AuthToken: getenv("TURSO_AUTH_TOKEN", ""), mu: &sync.RWMutex{}, overrides: map[string]string{}, runtimeFile: "data/runtime_config.json", } // JWT 密钥优先使用专用配置;否则回退到推流密钥(两者都应显式配置)。 if c.JWTSecret == "" { c.JWTSecret = c.TokenSecret } if c.DatabaseURL == "file:./data/sync-live.db?cache=shared&_journal_mode=WAL" { if v := os.Getenv("DATABASE_URL"); v != "" { c.DatabaseURL = v } else if v := os.Getenv("LIBSQL_URL"); v != "" { c.DatabaseURL = v } } if c.AuthToken == "" { if v := os.Getenv("TURSO_AUTH_TOKEN"); v != "" { c.AuthToken = v } else if v := os.Getenv("LIBSQL_AUTH_TOKEN"); v != "" { c.AuthToken = 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 } _ = c.LoadRuntime() return c } // Validate 校验正式运行所需的敏感配置,缺省会返回错误,避免误以不安全默认密钥上线。 func (c *Config) Validate() error { if c.JWTSecret == "" { return fmt.Errorf("JWT_SECRET 必须显式配置,禁止依赖不安全默认密钥") } if c.TokenRequired && c.TokenSecret == "" { return fmt.Errorf("已启用 SFU_TOKEN_REQUIRED,但 SFU_TOKEN_SECRET 未配置") } return nil } func (c *Config) ValidateDistributors() error { // 运行时允许 cdn 列表占位,即使未配置 RTMP URL 也仅影响分发就绪状态,不阻断配置保存 return nil } func getenv(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } func parseDuration(s string, def time.Duration) time.Duration { if s == "" { return def } if d, err := time.ParseDuration(s); err == nil { return d } return def } func parseInt(s string, def int) int { if s == "" { return def } var v int if _, err := fmt.Sscanf(s, "%d", &v); err == nil { return v } return def } func (c *Config) ProviderList() []string { parts := strings.Split(c.ProviderOrder, ",") out := make([]string, 0, len(parts)) seen := make(map[string]struct{}, len(parts)) for _, p := range parts { p = strings.TrimSpace(strings.ToLower(p)) if p == "" || p == "both" || p == "all" { if p == "both" || p == "all" { for _, b := range []string{"cloudflare", "srs"} { if _, ok := seen[b]; !ok { seen[b] = struct{}{} out = append(out, b) } } } continue } if _, ok := seen[p]; !ok { seen[p] = struct{}{} out = append(out, p) } } return out } func (c *Config) DistributorList() []string { return ParseDistributors(c.Distributors) } // ParseDistributors parses and normalizes distributors string. func ParseDistributors(raw string) []string { parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) seen := make(map[string]struct{}, len(parts)) for _, p := range parts { p = strings.TrimSpace(strings.ToLower(p)) if p == "" { continue } // normalize aliases if p == "srs" || p == "hls" { p = "srs-hls" } if p == "cloudflare" || p == "cf-sfu" { p = "cf" } // only keep known kinds if p != "srs-hls" && p != "cf" && p != "cdn" { continue } if _, ok := seen[p]; !ok { seen[p] = struct{}{} out = append(out, p) } } return out } // EffectiveDistributorsForRoom returns per-room distributors if override present, else global. func (c *Config) EffectiveDistributorsForRoom(roomDistributors *string) []string { if roomDistributors != nil { return ParseDistributors(*roomDistributors) } return c.DistributorList() } func NormalizeDistributorsString(raw string) string { return strings.Join(ParseDistributors(raw), ",") } func ValidateDistributorsString(raw string) error { // use ParseDistributors then re-validate known set; unknown silently dropped, so check if input contains unknown non-empty token parts := strings.Split(raw, ",") for _, p := range parts { p = strings.TrimSpace(strings.ToLower(p)) if p == "" { continue } normalized := p if p == "srs" || p == "hls" { normalized = "srs-hls" } if p == "cloudflare" || p == "cf-sfu" { normalized = "cf" } if normalized != "srs-hls" && normalized != "cf" && normalized != "cdn" { return fmt.Errorf("unsupported distributor %q", p) } } return nil } func (c *Config) CDNEnabled() bool { return c.CDNRTMPURL != "" } func (c *Config) DSN() string { if c.AuthToken != "" && strings.HasPrefix(c.DatabaseURL, "libsql://") && !strings.Contains(c.DatabaseURL, "authToken") { sep := "?" if strings.Contains(c.DatabaseURL, "?") { sep = "&" } return c.DatabaseURL + sep + "authToken=" + c.AuthToken } return c.DatabaseURL } func (c *Config) IsRemoteTurso() bool { return strings.HasPrefix(c.DatabaseURL, "libsql://") || strings.HasPrefix(c.DatabaseURL, "https://") } func (c *Config) SMTPConfigured() bool { return c.SMTPHost != "" && c.SMTPUser != "" && c.SMTPPass != "" } func (c *Config) OAuthConfigured() bool { return c.OAuthGoogleID != "" || c.OAuthGithubID != "" || c.OAuthGitlabID != "" } func (c *Config) RuntimeFilePath() string { if c.runtimeFile != "" { return c.runtimeFile } return "data/runtime_config.json" } func (c *Config) RLock() { c.ensureMu(); c.mu.RLock() } func (c *Config) RUnlock() { c.mu.RUnlock() } func (c *Config) Lock() { c.ensureMu(); c.mu.Lock() } func (c *Config) Unlock() { c.mu.Unlock() }