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:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsTrustedProxy(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
networks, err := parseCIDRs("10.0.0.0/8,127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.TrustedProxies = networks
|
||||
|
||||
if !cfg.IsTrustedProxy(net.ParseIP("10.1.2.3")) {
|
||||
t.Fatal("expected 10.1.2.3 trusted")
|
||||
}
|
||||
if !cfg.IsTrustedProxy(net.ParseIP("127.0.0.1")) {
|
||||
t.Fatal("expected 127.0.0.1 trusted")
|
||||
}
|
||||
if cfg.IsTrustedProxy(net.ParseIP("192.168.1.1")) {
|
||||
t.Fatal("expected 192.168.1.1 untrusted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user