// Package config carrega a configuração a partir de variáveis de ambiente. // // Segredos aceitam a variante _FILE, que aponta para um arquivo // (ex.: Docker secrets em /run/secrets/...). package config import ( "errors" "fmt" "log/slog" "net/netip" "net/url" "os" "strconv" "strings" "time" "botpontos/internal/domain" ) type Config struct { LogLevel string DefaultTimezone string DB DB HTTP HTTP Worker Worker Jobs Jobs PontoMais PontoMais Crypto Crypto Telegram Telegram Holidays Holidays Web Web DefaultWindow domain.QueryWindow // padrões gravados em app_settings na 1ª subida } type DB struct { Host string Port int Name string User string Password string MaxConns int32 } // DSN monta a URL de conexão. A rede entre containers é interna, por isso sslmode=disable. func (d DB) DSN() string { query := url.Values{} query.Set("sslmode", "disable") query.Set("application_name", "botpontos") u := url.URL{ Scheme: "postgres", User: url.UserPassword(d.User, d.Password), Host: fmt.Sprintf("%s:%d", d.Host, d.Port), Path: "/" + d.Name, RawQuery: query.Encode(), } return u.String() } type HTTP struct { Addr string } type Worker struct { HealthAddr string LeaderRetry time.Duration } // Jobs reúne os parâmetros dos ciclos do worker (ARQUITETURA.md, seções 8.2 e 9). // Os horários da janela de consulta ficam no banco (app_settings), não aqui. type Jobs struct { ReconciliationInterval time.Duration ReconciliationDeadline time.Duration ReconciliationWorkers int DispatchInterval time.Duration MaintenanceInterval time.Duration LateTolerance time.Duration SendingTimeout time.Duration SlowCheckInterval time.Duration ExitConfirmFrom time.Duration ExitConfirmTo time.Duration APIFailureThreshold int RetentionAudit time.Duration RetentionNotifications time.Duration RetentionSessions time.Duration RetentionHour int } type PontoMais struct { AuthURL string HistoryURL string Timeout time.Duration } type Crypto struct { Key string KeyVersion int PreviousKey string PreviousKeyVersion int } type Telegram struct { Token string PollTimeout int // segundos } type Holidays struct { BaseURL string APIKey string Location string } type Web struct { PublicURL string CookieSecure bool SessionDuration time.Duration SessionIdle time.Duration LoginLock time.Duration LoginMaxFailures int TrustedProxies []netip.Prefix } func Load() (Config, error) { return load(os.Getenv) } func load(getenv func(string) string) (Config, error) { r := &reader{getenv: getenv} cfg := Config{ LogLevel: r.str("BP_LOG_LEVEL", "info"), DefaultTimezone: r.str("BP_DEFAULT_TIMEZONE", domain.DefaultTimezone), DB: DB{ Host: r.str("BP_DB_HOST", "postgres"), Port: r.integer("BP_DB_PORT", 5432), Name: r.str("BP_DB_NAME", "botpontos"), User: r.str("BP_DB_USER", ""), Password: r.secret("BP_DB_PASSWORD"), MaxConns: int32(r.integer("BP_DB_MAX_CONNS", 10)), }, HTTP: HTTP{ Addr: r.str("BP_HTTP_ADDR", ":8080"), }, Worker: Worker{ HealthAddr: r.str("BP_WORKER_HEALTH_ADDR", ":8081"), LeaderRetry: r.duration("BP_WORKER_LEADER_RETRY", 10*time.Second), }, Jobs: Jobs{ ReconciliationInterval: r.duration("BP_RECONCILIATION_INTERVAL", 15*time.Minute), ReconciliationDeadline: r.duration("BP_RECONCILIATION_DEADLINE", 5*time.Minute), ReconciliationWorkers: r.integer("BP_RECONCILIATION_WORKERS", 4), DispatchInterval: r.duration("BP_DISPATCH_INTERVAL", 2*time.Minute), MaintenanceInterval: r.duration("BP_MAINTENANCE_INTERVAL", 5*time.Minute), LateTolerance: r.duration("BP_LATE_TOLERANCE", 10*time.Minute), SendingTimeout: r.duration("BP_SENDING_TIMEOUT", 5*time.Minute), SlowCheckInterval: r.duration("BP_SLOW_CHECK_INTERVAL", 60*time.Minute), APIFailureThreshold: r.integer("BP_API_FAILURE_THRESHOLD", 3), RetentionAudit: r.duration("BP_RETENTION_AUDIT", 365*24*time.Hour), RetentionNotifications: r.duration("BP_RETENTION_NOTIFICATIONS", 90*24*time.Hour), RetentionSessions: r.duration("BP_RETENTION_SESSIONS", 7*24*time.Hour), RetentionHour: r.integer("BP_RETENTION_HOUR", 3), }, PontoMais: PontoMais{ AuthURL: r.str("BP_PONTOMAIS_AUTH_URL", "https://api.pontomais.com.br/api/auth/sign_in"), HistoryURL: r.str("BP_PONTOMAIS_HISTORY_URL", "https://api.pontomais.com.br/api/time_cards/current/last_cached"), Timeout: r.duration("BP_PONTOMAIS_TIMEOUT", 10*time.Second), }, Crypto: Crypto{ Key: r.secret("BP_ENCRYPTION_KEY"), KeyVersion: r.integer("BP_ENCRYPTION_KEY_VERSION", 1), PreviousKey: r.secret("BP_ENCRYPTION_PREVIOUS_KEY"), PreviousKeyVersion: r.integer("BP_ENCRYPTION_PREVIOUS_KEY_VERSION", 0), }, Telegram: Telegram{ Token: r.secret("BP_TELEGRAM_TOKEN"), PollTimeout: r.integer("BP_TELEGRAM_POLL_TIMEOUT", 25), }, Holidays: Holidays{ BaseURL: r.str("BP_HOLIDAY_API_BASE_URL", "https://feriadosapi.com/api/v1"), APIKey: r.secret("BP_HOLIDAY_API_KEY"), Location: r.str("BP_HOLIDAY_LOCATION", "3106200"), }, Web: Web{ PublicURL: strings.TrimRight(r.str("BP_PUBLIC_URL", ""), "/"), CookieSecure: r.boolean("BP_COOKIE_SECURE", true), SessionDuration: r.duration("BP_SESSION_DURATION", 8*time.Hour), SessionIdle: r.duration("BP_SESSION_IDLE", 30*time.Minute), LoginLock: r.duration("BP_LOGIN_LOCK", 15*time.Minute), LoginMaxFailures: r.integer("BP_LOGIN_MAX_FAILURES", 5), TrustedProxies: r.prefixes("BP_TRUSTED_PROXIES", "127.0.0.0/8,172.16.0.0/12"), }, } from, to := r.durationRange("BP_EXIT_CONFIRM_WINDOW", 20*time.Minute, 5*time.Minute) cfg.Jobs.ExitConfirmFrom, cfg.Jobs.ExitConfirmTo = from, to cfg.DefaultWindow = domain.QueryWindow{ Start: r.clock("BP_QUERY_WINDOW_START", "11:30"), End: r.clock("BP_QUERY_WINDOW_END", "19:00"), DayCheckDeadline: r.clock("BP_DAY_CHECK_DEADLINE", "11:45"), NoPointsRecheckUntil: r.clock("BP_NO_POINTS_RECHECK_UNTIL", "15:00"), } errs := append(r.errs, cfg.validate()...) return cfg, errors.Join(errs...) } func (c Config) validate() []error { var errs []error var level slog.Level if err := level.UnmarshalText([]byte(c.LogLevel)); err != nil { errs = append(errs, fmt.Errorf("BP_LOG_LEVEL: valor inválido %q (use debug, info, warn ou error)", c.LogLevel)) } if _, err := time.LoadLocation(c.DefaultTimezone); err != nil { errs = append(errs, fmt.Errorf("BP_DEFAULT_TIMEZONE: fuso inválido %q", c.DefaultTimezone)) } if c.DB.User == "" { errs = append(errs, errors.New("BP_DB_USER: obrigatório")) } if c.DB.Password == "" { errs = append(errs, errors.New("BP_DB_PASSWORD ou BP_DB_PASSWORD_FILE: obrigatório")) } if c.DB.Port <= 0 || c.DB.Port > 65535 { errs = append(errs, fmt.Errorf("BP_DB_PORT: porta inválida %d", c.DB.Port)) } if c.DB.MaxConns <= 0 { errs = append(errs, errors.New("BP_DB_MAX_CONNS: precisa ser maior que zero")) } durations := []struct { name string value time.Duration }{ {"BP_WORKER_LEADER_RETRY", c.Worker.LeaderRetry}, {"BP_RECONCILIATION_INTERVAL", c.Jobs.ReconciliationInterval}, {"BP_RECONCILIATION_DEADLINE", c.Jobs.ReconciliationDeadline}, {"BP_DISPATCH_INTERVAL", c.Jobs.DispatchInterval}, {"BP_MAINTENANCE_INTERVAL", c.Jobs.MaintenanceInterval}, {"BP_LATE_TOLERANCE", c.Jobs.LateTolerance}, {"BP_SENDING_TIMEOUT", c.Jobs.SendingTimeout}, {"BP_SLOW_CHECK_INTERVAL", c.Jobs.SlowCheckInterval}, {"BP_RETENTION_AUDIT", c.Jobs.RetentionAudit}, {"BP_RETENTION_NOTIFICATIONS", c.Jobs.RetentionNotifications}, {"BP_RETENTION_SESSIONS", c.Jobs.RetentionSessions}, {"BP_PONTOMAIS_TIMEOUT", c.PontoMais.Timeout}, {"BP_SESSION_DURATION", c.Web.SessionDuration}, {"BP_SESSION_IDLE", c.Web.SessionIdle}, {"BP_LOGIN_LOCK", c.Web.LoginLock}, } for _, d := range durations { if d.value <= 0 { errs = append(errs, fmt.Errorf("%s: precisa ser maior que zero", d.name)) } } if c.Jobs.ReconciliationInterval > 0 && time.Hour%c.Jobs.ReconciliationInterval != 0 { errs = append(errs, errors.New("BP_RECONCILIATION_INTERVAL: precisa dividir 1 hora (ex.: 5m, 10m, 15m, 30m)")) } if c.Jobs.SlowCheckInterval > 0 && c.Jobs.ReconciliationInterval > 0 && c.Jobs.SlowCheckInterval%c.Jobs.ReconciliationInterval != 0 { errs = append(errs, errors.New("BP_SLOW_CHECK_INTERVAL: precisa ser múltiplo de BP_RECONCILIATION_INTERVAL")) } if c.Jobs.ExitConfirmFrom-c.Jobs.ExitConfirmTo < c.Jobs.ReconciliationInterval { errs = append(errs, errors.New("BP_EXIT_CONFIRM_WINDOW: a janela precisa ter pelo menos o intervalo de reconciliação")) } if c.Jobs.APIFailureThreshold < 1 { errs = append(errs, errors.New("BP_API_FAILURE_THRESHOLD: precisa ser pelo menos 1")) } if c.Jobs.ReconciliationWorkers < 1 { errs = append(errs, errors.New("BP_RECONCILIATION_WORKERS: precisa ser pelo menos 1")) } if c.Jobs.RetentionHour < 0 || c.Jobs.RetentionHour > 23 { errs = append(errs, errors.New("BP_RETENTION_HOUR: precisa estar entre 0 e 23")) } if c.Crypto.KeyVersion < 1 || c.Crypto.KeyVersion > 32767 { errs = append(errs, errors.New("BP_ENCRYPTION_KEY_VERSION: precisa estar entre 1 e 32767")) } if c.Web.LoginMaxFailures < 1 { errs = append(errs, errors.New("BP_LOGIN_MAX_FAILURES: precisa ser pelo menos 1")) } if c.Telegram.PollTimeout < 1 || c.Telegram.PollTimeout > 30 { errs = append(errs, errors.New("BP_TELEGRAM_POLL_TIMEOUT: precisa estar entre 1 e 30 segundos")) } if c.Crypto.PreviousKey != "" && (c.Crypto.PreviousKeyVersion < 1 || c.Crypto.PreviousKeyVersion > 32767) { errs = append(errs, errors.New("BP_ENCRYPTION_PREVIOUS_KEY_VERSION: precisa estar entre 1 e 32767")) } if c.Web.PublicURL != "" { if u, err := url.Parse(c.Web.PublicURL); err != nil || u.Scheme == "" || u.Host == "" { errs = append(errs, fmt.Errorf("BP_PUBLIC_URL: URL inválida %q (ex.: https://botpontos.lan)", c.Web.PublicURL)) } } return errs } // RequireCrypto confere a chave de criptografia (API, worker e CLI de credenciais). func (c Config) RequireCrypto() error { if len(c.Crypto.Key) < 32 { return errors.New("BP_ENCRYPTION_KEY ou BP_ENCRYPTION_KEY_FILE: obrigatório, com pelo menos 32 caracteres") } return nil } // RequireTelegram confere o token do bot (worker). func (c Config) RequireTelegram() error { if c.Telegram.Token == "" { return errors.New("BP_TELEGRAM_TOKEN ou BP_TELEGRAM_TOKEN_FILE: obrigatório para o worker") } return nil } type reader struct { getenv func(string) string errs []error } func (r *reader) str(key, fallback string) string { if v := strings.TrimSpace(r.getenv(key)); v != "" { return v } return fallback } func (r *reader) integer(key string, fallback int) int { v := strings.TrimSpace(r.getenv(key)) if v == "" { return fallback } n, err := strconv.Atoi(v) if err != nil { r.errs = append(r.errs, fmt.Errorf("%s: número inválido %q", key, v)) return fallback } return n } func (r *reader) boolean(key string, fallback bool) bool { v := strings.TrimSpace(r.getenv(key)) if v == "" { return fallback } b, err := strconv.ParseBool(v) if err != nil { r.errs = append(r.errs, fmt.Errorf("%s: use true ou false, veio %q", key, v)) return fallback } return b } func (r *reader) duration(key string, fallback time.Duration) time.Duration { v := strings.TrimSpace(r.getenv(key)) if v == "" { return fallback } d, err := time.ParseDuration(v) if err != nil { r.errs = append(r.errs, fmt.Errorf("%s: duração inválida %q (ex.: 15m, 2m, 10s)", key, v)) return fallback } return d } // durationRange lê "A..B" (ex.: "20m..5m"). func (r *reader) durationRange(key string, fromDefault, toDefault time.Duration) (time.Duration, time.Duration) { v := strings.TrimSpace(r.getenv(key)) if v == "" { return fromDefault, toDefault } parts := strings.Split(v, "..") if len(parts) != 2 { r.errs = append(r.errs, fmt.Errorf("%s: use o formato 20m..5m, veio %q", key, v)) return fromDefault, toDefault } from, err1 := time.ParseDuration(strings.TrimSpace(parts[0])) to, err2 := time.ParseDuration(strings.TrimSpace(parts[1])) if err1 != nil || err2 != nil || to < 0 || from <= to { r.errs = append(r.errs, fmt.Errorf("%s: intervalo inválido %q (o primeiro valor precisa ser maior)", key, v)) return fromDefault, toDefault } return from, to } func (r *reader) clock(key, fallback string) domain.ClockTime { v := r.str(key, fallback) c, err := domain.ParseClock(v) if err != nil { r.errs = append(r.errs, fmt.Errorf("%s: %w", key, err)) return domain.MustParseClock(fallback) } return c } // prefixes lê uma lista de CIDRs ou IPs separados por vírgula. func (r *reader) prefixes(key, fallback string) []netip.Prefix { var out []netip.Prefix for _, item := range strings.Split(r.str(key, fallback), ",") { item = strings.TrimSpace(item) if item == "" { continue } if p, err := netip.ParsePrefix(item); err == nil { out = append(out, p.Masked()) continue } if a, err := netip.ParseAddr(item); err == nil { out = append(out, netip.PrefixFrom(a, a.BitLen())) continue } r.errs = append(r.errs, fmt.Errorf("%s: endereço ou CIDR inválido %q", key, item)) } return out } // secret lê ou, se vazio, o arquivo apontado por _FILE. func (r *reader) secret(key string) string { if v := strings.TrimSpace(r.getenv(key)); v != "" { return v } path := strings.TrimSpace(r.getenv(key + "_FILE")) if path == "" { return "" } content, err := os.ReadFile(path) if err != nil { r.errs = append(r.errs, fmt.Errorf("%s_FILE: %w", key, err)) return "" } return strings.TrimSpace(string(content)) }