430 lines
14 KiB
Go
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))
|
|
}
|