package config import ( "fmt" "os" "strconv" "strings" "time" ) const ( DefaultListenAddr = "127.0.0.1:8080" DefaultDataDir = "/opt/glchat/data" DefaultLogLevel = "info" ) type Config struct { Domain string FilesDomain string InstanceName string ListenAddr string DataDir string WebRoot string DatabasePath string SQLiteCache string SQLiteMmap string LogLevel string LogFormat string ReadPoolSize int MaxUploadSize int64 TLSEnabled bool // Секреты инстанса (генерируются установщиком, AGENT.md §10.3). SessionPepper string MasterKey string TOTPEncryptionKey string SessionTTLHours int Argon2MemoryKiB int Argon2Iterations int Argon2Parallelism int // LiveKit: голос, видео и шаринг экрана (AGENT.md 7.14). LiveKitAPIKey string LiveKitAPISecret string LiveKitURL string Version string Commit string BuildDate string ShutdownTTL time.Duration Maintenance time.Duration } func Load() (Config, error) { cfg := Config{ Domain: env("DOMAIN", "localhost"), FilesDomain: env("FILES_DOMAIN", ""), InstanceName: env("INSTANCE_NAME", "glchat"), ListenAddr: env("LISTEN_ADDR", DefaultListenAddr), DataDir: env("DATA_DIR", DefaultDataDir), WebRoot: env("WEB_ROOT", "/app/web"), DatabasePath: env("DATABASE_PATH", ""), SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"), SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"), LogLevel: strings.ToLower(env("LOG_LEVEL", DefaultLogLevel)), LogFormat: strings.ToLower(env("LOG_FORMAT", "json")), ReadPoolSize: envInt("SQLITE_READ_POOL", 4), MaxUploadSize: envInt64("MAX_UPLOAD_SIZE", 26214400), TLSEnabled: envBool("TLS_ENABLED", true), SessionPepper: env("SESSION_PEPPER", ""), MasterKey: env("MASTER_KEY", ""), TOTPEncryptionKey: env("TOTP_ENCRYPTION_KEY", ""), SessionTTLHours: envInt("SESSION_TTL_HOURS", 720), Argon2MemoryKiB: envInt("ARGON2_MEMORY_KIB", 19456), Argon2Iterations: envInt("ARGON2_ITERATIONS", 2), Argon2Parallelism: envInt("ARGON2_PARALLELISM", 1), LiveKitAPIKey: env("LIVEKIT_API_KEY", ""), LiveKitAPISecret: env("LIVEKIT_API_SECRET", ""), LiveKitURL: env("LIVEKIT_URL", ""), Version: env("APP_VERSION", "dev"), Commit: env("APP_COMMIT", "none"), BuildDate: env("APP_BUILD_DATE", "unknown"), ShutdownTTL: time.Duration(envInt("SHUTDOWN_TIMEOUT_SECONDS", 20)) * time.Second, Maintenance: time.Duration(envInt("DB_MAINTENANCE_INTERVAL_SECONDS", 21600)) * time.Second, } if cfg.FilesDomain == "" { cfg.FilesDomain = "files." + cfg.Domain } if cfg.DatabasePath == "" { cfg.DatabasePath = cfg.DataDir + "/db/glchat.db" } if cfg.ReadPoolSize < 1 { return Config{}, fmt.Errorf("SQLITE_READ_POOL must be >= 1, got %d", cfg.ReadPoolSize) } if _, err := parseLogLevel(cfg.LogLevel); err != nil { return Config{}, err } if cfg.LogFormat != "json" && cfg.LogFormat != "text" { return Config{}, fmt.Errorf("LOG_FORMAT must be json or text, got %q", cfg.LogFormat) } return cfg, nil } // Scheme выбирается установщиком: при установке без TLS (--skip-tls, // например стенд без домена) клиент должен получать http/ws, а не https/wss. func (c Config) Scheme() string { if c.TLSEnabled { return "https" } return "http" } // AllowedOrigins возвращает домены, с которых разрешены WebSocket и // state-changing запросы (AGENT.md 9.7: точный allowlist, никаких *). func (c Config) AllowedOrigins() []string { origins := []string{ "https://" + c.Domain, "http://" + c.Domain, } if c.FilesDomain != "" && c.FilesDomain != c.Domain { origins = append(origins, "https://"+c.FilesDomain, "http://"+c.FilesDomain) } return origins } // VoiceEnabled сообщает, настроен ли LiveKit: без ключей голос выключен. func (c Config) VoiceEnabled() bool { return c.LiveKitAPIKey != "" && c.LiveKitAPISecret != "" && c.LiveKitURL != "" } func (c Config) BaseURL() string { return c.Scheme() + "://" + c.Domain } func (c Config) FilesURL() string { return c.Scheme() + "://" + c.FilesDomain } func (c Config) GatewayURL() string { scheme := "ws" if c.TLSEnabled { scheme = "wss" } return scheme + "://" + c.Domain + "/gateway" } func (c Config) LogLevelNumeric() int { level, err := parseLogLevel(c.LogLevel) if err != nil { return 0 } return level } func parseLogLevel(level string) (int, error) { switch level { case "debug": return -4, nil case "info": return 0, nil case "warn", "warning": return 4, nil case "error": return 8, nil default: return 0, fmt.Errorf("unknown LOG_LEVEL %q", level) } } func env(key, fallback string) string { if v, ok := os.LookupEnv(key); ok && v != "" { return v } return fallback } func envInt(key string, fallback int) int { v, ok := os.LookupEnv(key) if !ok || v == "" { return fallback } n, err := strconv.Atoi(v) if err != nil { return fallback } return n } func envBool(key string, fallback bool) bool { v, ok := os.LookupEnv(key) if !ok || v == "" { return fallback } parsed, err := strconv.ParseBool(v) if err != nil { return fallback } return parsed } func envInt64(key string, fallback int64) int64 { v, ok := os.LookupEnv(key) if !ok || v == "" { return fallback } n, err := strconv.ParseInt(v, 10, 64) if err != nil { return fallback } return n }