Files
bot-pontos/backend/internal/config/config.go
2026-10-23 22:31:02 -03:00

430 lines
14 KiB
Go

// Package config carrega a configuração a partir de variáveis de ambiente.
//
// Segredos aceitam a variante <NOME>_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ê <key> ou, se vazio, o arquivo apontado por <key>_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))
}