Files
doormile_backend/config/config.go

165 lines
6.1 KiB
Go

package config
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"strings"
"doormile/utils"
)
type Config struct {
Env string
Port string
DBName string
DBUser string
DBPassword string
DBPort string
DBHost string
RedisHost string
RedisPort string
RedisUser string
RedisPassword string
JWTSecret string
NatsURL string
NatsUser string
NatsPassword string
AILayerBaseURL string // AI decision-engine service base URL (e.g. http://rider-api:8082)
// RouteOptimizerURL is the Route Optimization API that orders a rider's
// stops (Valhalla-backed road sequencing). Empty disables sequencing: stops
// stay unordered rather than assignment failing.
RouteOptimizerURL string
// GeocoderURL is the Nominatim-compatible geocoding service the customer
// app'''s place search and reverse geocode are proxied through. Proxied on
// purpose: the legacy rider app shipped a Google Maps key inside the
// binary and it had to be revoked, so the customer app is never handed a
// key at all — it asks this service and this service asks the geocoder.
GeocoderURL string
// GeocoderEmail is the contact address Nominatim'''s usage policy asks
// callers to identify themselves with. Sent as the User-Agent contact;
// requests without one are throttled or blocked.
GeocoderEmail string
// TrustedProxies is a comma-separated list of reverse-proxy IPs/CIDRs that
// are allowed to set X-Forwarded-For. Rate limiting keys on the client IP,
// so behind a proxy this MUST be set — otherwise every request appears to
// come from the proxy and the whole fleet shares one limit bucket.
// Empty means "no proxy": the socket peer address is used as-is.
TrustedProxies string
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPassword string
SMTPFrom string
}
func Load() *Config {
cfg := load()
cfg.hardenSecrets()
return cfg
}
// IsProduction reports whether this process is running as production. Used by
// the guards that must behave differently there — a fixed OTP, a missing JWT
// secret — rather than scattering string comparisons.
func (c *Config) IsProduction() bool {
return strings.EqualFold(strings.TrimSpace(c.Env), "production")
}
// hardenSecrets refuses to let the service run on a guessable signing key.
//
// JWT_SECRET_KEY used to default to a literal in this file. Anyone holding the
// repository could mint a token for any user id and any role, against any
// deployment that had not overridden it — which is the whole authorisation
// model, given away by a git clone.
//
// In production an unset secret is fatal: booting with a known key is worse
// than not booting, because nothing external shows that anything is wrong.
// Anywhere else it becomes a random per-process key, so local development
// works without configuration while tokens stop surviving a restart and can
// never be valid anywhere but this process.
func (c *Config) hardenSecrets() {
if strings.TrimSpace(c.JWTSecret) != "" {
return
}
// In production an absent secret is left absent, so Validate can refuse the
// boot with a clear message. Generating one here would be worse than the
// old default: every restart would invalidate every live session, and
// nothing would say why.
if c.IsProduction() {
return
}
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// Leave it empty; Validate turns this into a refusal to start.
return
}
c.JWTSecret = hex.EncodeToString(b)
utils.Warn("JWT_SECRET_KEY is not set — generated an ephemeral key for this process only. " +
"Tokens will not survive a restart. Set JWT_SECRET_KEY for a stable local session.")
}
// Validate reports configuration that must prevent the service from starting.
// Called by main; kept separate from Load so that loading stays free of side
// effects and the package remains testable.
func (c *Config) Validate() error {
if strings.TrimSpace(c.JWTSecret) == "" {
return fmt.Errorf("JWT_SECRET_KEY is not set (ENV=%s): refusing to start, because "+
"booting on a default or empty signing key lets anyone holding this repository "+
"mint a valid token for any account", c.Env)
}
return nil
}
func load() *Config {
return &Config{
Env: getEnv("ENV", "development"),
Port: getEnv("APP_PORT", "8081"),
DBName: getEnv("DB_NAME", "logistics"),
DBUser: getEnv("DB_USER", "admin"),
DBPassword: getEnv("DB_PASSWORD", ""),
DBPort: getEnv("DB_PORT", "5433"),
DBHost: getEnv("DB_HOST", "127.0.0.1"),
RedisHost: getEnv("REDIS_HOST", "127.0.0.1"),
RedisPort: getEnv("REDIS_PORT", "6379"),
RedisUser: getEnv("REDIS_USER", ""),
RedisPassword: getEnv("REDIS_PASSWORD", ""),
// No default. See hardenSecrets below — an unset secret is either a
// refusal to boot or an ephemeral per-process key, never a shared one
// baked into the source.
JWTSecret: getEnv("JWT_SECRET_KEY", ""),
// These defaulted to the real production hosts and credentials, which
// meant `go run .` on a laptop silently joined the live NATS stream and
// competed with the production workers for the same durable consumer.
// Empty now: InitNATS skips connecting, routing.BaseURL == "" disables
// sequencing, and the AI layer falls back to legacy scoring. Fail
// closed, so reaching production is something you opt into.
NatsURL: getEnv("NATS_URL", ""),
NatsUser: getEnv("NATS_USER", ""),
NatsPassword: getEnv("NATS_PASSWORD", ""),
AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", ""),
RouteOptimizerURL: getEnv("ROUTE_OPTIMIZER_URL", ""),
GeocoderURL: getEnv("GEOCODER_URL", "https://nominatim.openstreetmap.org"),
GeocoderEmail: getEnv("GEOCODER_EMAIL", ""),
TrustedProxies: getEnv("TRUSTED_PROXIES", ""),
SMTPHost: getEnv("SMTP_HOST", ""),
SMTPPort: getEnv("SMTP_PORT", "465"),
SMTPUser: getEnv("SMTP_USER", ""),
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
SMTPFrom: getEnv("SMTP_FROM", ""),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}