2026-09-19 18:20:56 +03:00
|
|
|
|
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
|
2026-09-19 19:58:32 +03:00
|
|
|
|
TLSEnabled bool
|
2026-09-19 21:29:07 +03:00
|
|
|
|
// Секреты инстанса (генерируются установщиком, AGENT.md §10.3).
|
|
|
|
|
|
SessionPepper string
|
|
|
|
|
|
MasterKey string
|
|
|
|
|
|
TOTPEncryptionKey string
|
|
|
|
|
|
SessionTTLHours int
|
|
|
|
|
|
Argon2MemoryKiB int
|
|
|
|
|
|
Argon2Iterations int
|
|
|
|
|
|
Argon2Parallelism int
|
|
|
|
|
|
Version string
|
|
|
|
|
|
Commit string
|
|
|
|
|
|
BuildDate string
|
|
|
|
|
|
ShutdownTTL time.Duration
|
|
|
|
|
|
Maintenance time.Duration
|
2026-09-19 18:20:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func Load() (Config, error) {
|
|
|
|
|
|
cfg := Config{
|
2026-09-19 21:29:07 +03:00
|
|
|
|
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),
|
|
|
|
|
|
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,
|
2026-09-19 18:20:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 19:58:32 +03:00
|
|
|
|
// Scheme выбирается установщиком: при установке без TLS (--skip-tls,
|
|
|
|
|
|
// например стенд без домена) клиент должен получать http/ws, а не https/wss.
|
|
|
|
|
|
func (c Config) Scheme() string {
|
|
|
|
|
|
if c.TLSEnabled {
|
|
|
|
|
|
return "https"
|
|
|
|
|
|
}
|
|
|
|
|
|
return "http"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 21:42:41 +03:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 19:58:32 +03:00
|
|
|
|
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"
|
|
|
|
|
|
}
|
2026-09-19 18:20:56 +03:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 19:58:32 +03:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 18:20:56 +03:00
|
|
|
|
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
|
|
|
|
|
|
}
|