Build the Go API and React UI for managing domain denylist/allowlist/recipient deny and spam settings, plus shared lists that import/apply across domains with wildcard compaction, entry verification, and Migadu-friendly list encoding (including per-domain recipient filtering and rejection bisect on apply).
166 lines
4.1 KiB
Go
166 lines
4.1 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/joho/godotenv"
|
|
)
|
|
|
|
type AuthMode string
|
|
|
|
const (
|
|
AuthModeLocal AuthMode = "local"
|
|
AuthModePangolin AuthMode = "pangolin"
|
|
AuthModeBoth AuthMode = "both"
|
|
)
|
|
|
|
type Config struct {
|
|
ListenAddr string
|
|
MigaduUser string
|
|
MigaduAPIKey string
|
|
MigaduBaseURL string
|
|
SessionSecret string
|
|
DatabasePath string
|
|
AuthMode AuthMode
|
|
TrustedProxies []*net.IPNet
|
|
RegistrationOpen bool
|
|
SSOAutoProvision bool
|
|
CookieSecure bool
|
|
}
|
|
|
|
func Load() (*Config, error) {
|
|
// Prefer project .env over stale exported shell vars (common after editing the key).
|
|
if err := godotenv.Overload(); err != nil {
|
|
// Missing .env is fine when vars are provided by the environment.
|
|
if !os.IsNotExist(err) {
|
|
// godotenv returns a plain error for missing file; ignore only that case.
|
|
if !strings.Contains(err.Error(), "no such file") {
|
|
return nil, fmt.Errorf("load .env: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
authMode := AuthMode(strings.ToLower(strings.TrimSpace(envOr("AUTH_MODE", "both"))))
|
|
switch authMode {
|
|
case AuthModeLocal, AuthModePangolin, AuthModeBoth:
|
|
default:
|
|
return nil, fmt.Errorf("invalid AUTH_MODE %q (use local, pangolin, or both)", authMode)
|
|
}
|
|
|
|
trusted, err := parseCIDRs(envOr("TRUSTED_PROXIES", "127.0.0.1/32,::1/128"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("TRUSTED_PROXIES: %w", err)
|
|
}
|
|
|
|
cfg := &Config{
|
|
ListenAddr: envOr("LISTEN_ADDR", ":8080"),
|
|
MigaduUser: cleanEnv(os.Getenv("MIGADU_USER")),
|
|
MigaduAPIKey: cleanEnv(os.Getenv("MIGADU_API_KEY")),
|
|
MigaduBaseURL: strings.TrimRight(envOr("MIGADU_BASE_URL", "https://api.migadu.com/v1"), "/"),
|
|
SessionSecret: cleanEnv(os.Getenv("SESSION_SECRET")),
|
|
DatabasePath: envOr("DATABASE_PATH", "data/migaduadmin.db"),
|
|
AuthMode: authMode,
|
|
TrustedProxies: trusted,
|
|
RegistrationOpen: envBool("REGISTRATION_OPEN", false),
|
|
SSOAutoProvision: envBool("SSO_AUTO_PROVISION", false),
|
|
CookieSecure: envBool("COOKIE_SECURE", false),
|
|
}
|
|
|
|
if cfg.MigaduUser == "" {
|
|
return nil, fmt.Errorf("MIGADU_USER is required")
|
|
}
|
|
if cfg.MigaduAPIKey == "" {
|
|
return nil, fmt.Errorf("MIGADU_API_KEY is required")
|
|
}
|
|
if strings.ContainsAny(cfg.MigaduAPIKey, " \t\n\r") {
|
|
return nil, fmt.Errorf("MIGADU_API_KEY contains whitespace; check .env quoting")
|
|
}
|
|
if cfg.SessionSecret == "" || len(cfg.SessionSecret) < 32 {
|
|
return nil, fmt.Errorf("SESSION_SECRET is required and must be at least 32 characters")
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// MaskedMigaduKey returns a safe fingerprint for logs.
|
|
func (c *Config) MaskedMigaduKey() string {
|
|
key := c.MigaduAPIKey
|
|
if key == "" {
|
|
return "(empty)"
|
|
}
|
|
if len(key) <= 8 {
|
|
return "****"
|
|
}
|
|
return fmt.Sprintf("%s…%s (len=%d)", key[:4], key[len(key)-4:], len(key))
|
|
}
|
|
|
|
func cleanEnv(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if len(value) >= 2 {
|
|
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
|
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
|
value = strings.TrimSpace(value[1 : len(value)-1])
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
func (c *Config) IsTrustedProxy(ip net.IP) bool {
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
for _, network := range c.TrustedProxies {
|
|
if network.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func envOr(key, fallback string) string {
|
|
if value := cleanEnv(os.Getenv(key)); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func envBool(key string, fallback bool) bool {
|
|
raw := cleanEnv(os.Getenv(key))
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
value, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func parseCIDRs(raw string) ([]*net.IPNet, error) {
|
|
parts := strings.Split(raw, ",")
|
|
networks := make([]*net.IPNet, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
if !strings.Contains(part, "/") {
|
|
if strings.Contains(part, ":") {
|
|
part += "/128"
|
|
} else {
|
|
part += "/32"
|
|
}
|
|
}
|
|
_, network, err := net.ParseCIDR(part)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid CIDR %q: %w", part, err)
|
|
}
|
|
networks = append(networks, network)
|
|
}
|
|
return networks, nil
|
|
}
|