Add Migadu domain admin with shared lists, compaction, and safer apply.

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).
This commit is contained in:
2026-07-26 21:50:26 +02:00
parent 930f1f7b69
commit a79cb61104
55 changed files with 7600 additions and 1 deletions
+165
View File
@@ -0,0 +1,165 @@
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
}