// Package config loads and persists GoWebMail configuration from data/gowebmail.conf package config import ( "bufio" "crypto/rand" "encoding/hex" "fmt" "net" "net/http" "os" "strconv" "strings" ) // Config holds all application configuration. type Config struct { // Server ListenAddr string // e.g. ":8080" or "0.0.0.0:8080" ListenPort string // derived from ListenAddr, e.g. "8080" Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly // Security EncryptionKey []byte // 32 bytes / AES-256 SessionSecret []byte SecureCookie bool SessionMaxAge int TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers // Notification SMTP (outbound alerts — separate from user mail accounts) NotifyEnabled bool NotifySMTPHost string NotifySMTPPort int NotifyFrom string NotifyUser string // optional — leave blank for unauthenticated relay NotifyPass string // optional // Brute force protection BruteEnabled bool BruteMaxAttempts int BruteWindowMins int BruteBanHours int BruteWhitelist []net.IP // IPs exempt from blocking GeoBlockCountries []string // 2-letter codes to deny (deny-list mode) GeoAllowCountries []string // 2-letter codes to allow (allow-list mode, empty=allow all) // Storage DBPath string // Google OAuth2 GoogleClientID string GoogleClientSecret string GoogleRedirectURL string // auto-derived from BaseURL if blank // Microsoft OAuth2 MicrosoftClientID string MicrosoftClientSecret string MicrosoftTenantID string MicrosoftRedirectURL string // auto-derived from BaseURL if blank } const configPath = "./data/gowebmail.conf" type configField struct { key string defVal string comments []string } // allFields is the single source of truth for config keys. // Adding a field here causes it to automatically appear in gowebmail.conf on next startup. var allFields = []configField{ { key: "HOSTNAME", defVal: "localhost", comments: []string{ "--- Server ---", "Public hostname of this GoWebMail instance (no port, no protocol).", "Examples: localhost | mail.example.com | 192.168.1.10", "Used to build BASE_URL and OAuth redirect URIs automatically.", "Also used in security checks to reject requests with unexpected Host headers.", }, }, { key: "LISTEN_ADDR", defVal: ":8080", comments: []string{ "Address and port to listen on. Format: [host]:port", " :8080 — all interfaces, port 8080", " 0.0.0.0:8080 — all interfaces (explicit)", " 127.0.0.1:8080 — localhost only", }, }, { key: "BASE_URL", defVal: "", comments: []string{ "Public URL of this instance (no trailing slash). Leave blank to auto-build", "from HOSTNAME and LISTEN_ADDR port (recommended).", " Auto-build examples:", " HOSTNAME=localhost + :8080 → http://localhost:8080", " HOSTNAME=mail.example.com + :443 → https://mail.example.com", " HOSTNAME=mail.example.com + :8080 → http://mail.example.com:8080", "Override here only if you need a custom path prefix or your proxy rewrites the URL.", }, }, { key: "SECURE_COOKIE", defVal: "false", comments: []string{ "Set to true when GoWebMail is served over HTTPS (directly or via proxy).", "Marks session cookies as Secure so browsers only send them over TLS.", }, }, { key: "SESSION_MAX_AGE", defVal: "604800", comments: []string{ "How long a login session lasts, in seconds. Default: 604800 (7 days).", }, }, { key: "TRUSTED_PROXIES", defVal: "", comments: []string{ "Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.", "Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,", "which GoWebMail uses to determine the real client IP and whether TLS is in use.", " Examples:", " 127.0.0.1 (loopback only — Nginx/Traefik on same host)", " 10.0.0.0/8,172.16.0.0/12 (private networks)", " 192.168.1.50,192.168.1.51 (specific IPs)", " Leave blank to disable proxy trust (requests are taken at face value).", " NOTE: Do not add untrusted IPs — clients could spoof their source address.", }, }, { key: "NOTIFY_ENABLED", defVal: "true", comments: []string{ "--- Security Notifications ---", "Send email alerts to users when their account is targeted by brute-force attacks.", "Set to false to disable all security notification emails.", }, }, { key: "NOTIFY_SMTP_HOST", defVal: "", comments: []string{ "SMTP server hostname for sending security notification emails.", "Example: smtp.example.com", }, }, { key: "NOTIFY_SMTP_PORT", defVal: "587", comments: []string{ "SMTP server port. Common values: 587 (STARTTLS), 465 (TLS), 25 (relay, no auth).", }, }, { key: "NOTIFY_FROM", defVal: "", comments: []string{ "Sender address for security notification emails. Example: security@example.com", }, }, { key: "NOTIFY_USER", defVal: "", comments: []string{ "SMTP username for authenticated relay. Leave blank for unauthenticated relay.", }, }, { key: "NOTIFY_PASS", defVal: "", comments: []string{ "SMTP password for authenticated relay. Leave blank for unauthenticated relay.", }, }, { key: "BRUTE_ENABLED", defVal: "true", comments: []string{ "--- Brute Force Protection ---", "Enable automatic IP blocking after repeated failed logins.", "Set to false to disable entirely.", }, }, { key: "BRUTE_MAX_ATTEMPTS", defVal: "5", comments: []string{ "Number of failed login attempts within BRUTE_WINDOW_MINUTES that triggers a ban.", }, }, { key: "BRUTE_WINDOW_MINUTES", defVal: "30", comments: []string{ "Time window in minutes for counting failed login attempts.", }, }, { key: "BRUTE_BAN_HOURS", defVal: "12", comments: []string{ "How many hours to ban an offending IP. Set to 0 for permanent ban (admin must unban manually).", }, }, { key: "BRUTE_WHITELIST_IPS", defVal: "", comments: []string{ "Comma-separated IPv4/IPv6 addresses that are never blocked by brute force protection.", "Example: 192.168.1.1,10.0.0.1", }, }, { key: "GEO_BLOCK_COUNTRIES", defVal: "", comments: []string{ "--- Geo Blocking (uses ip-api.com, requires internet access) ---", "Comma-separated 2-letter ISO country codes to DENY access from.", "Example: CN,RU,KP", "Leave blank to disable deny-list. Takes precedence over GEO_ALLOW_COUNTRIES.", }, }, { key: "GEO_ALLOW_COUNTRIES", defVal: "", comments: []string{ "Comma-separated 2-letter ISO country codes to ALLOW (all others are denied).", "Example: SK,CZ,DE", "Leave blank to allow all countries. Only active if GEO_BLOCK_COUNTRIES is also blank.", }, }, { key: "DB_PATH", defVal: "./data/gowebmail.db", comments: []string{ "--- Storage ---", "Path to the SQLite database file.", }, }, { key: "ENCRYPTION_KEY", defVal: "", comments: []string{ "AES-256 key protecting all sensitive data at rest (emails, tokens, MFA secrets).", "Must be exactly 64 hex characters (= 32 bytes). Auto-generated on first run.", "NOTE: Back this up. Losing it makes the entire database permanently unreadable.", }, }, { key: "SESSION_SECRET", defVal: "", comments: []string{ "Secret used to sign session cookies. Auto-generated on first run.", "Changing this invalidates all active sessions (everyone gets logged out).", }, }, { key: "GOOGLE_CLIENT_ID", defVal: "", comments: []string{ "--- Gmail / Google OAuth2 ---", "Create at: https://console.cloud.google.com/apis/credentials", " Application type : Web application", " Required scope : https://mail.google.com/", " Redirect URI : /auth/gmail/callback", }, }, { key: "GOOGLE_CLIENT_SECRET", defVal: "", comments: []string{}, }, { key: "GOOGLE_REDIRECT_URL", defVal: "", comments: []string{ "Override the Gmail OAuth redirect URL. Leave blank to auto-derive from BASE_URL.", "Must exactly match what is registered in Google Cloud Console.", }, }, { key: "MICROSOFT_CLIENT_ID", defVal: "", comments: []string{ "--- Outlook / Microsoft 365 OAuth2 ---", "Register at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps", " Required API permissions : IMAP.AccessAsUser.All, SMTP.Send, offline_access, openid, email", " Redirect URI : /auth/outlook/callback", }, }, { key: "MICROSOFT_CLIENT_SECRET", defVal: "", comments: []string{}, }, { key: "MICROSOFT_TENANT_ID", defVal: "common", comments: []string{ "Use 'common' to allow any Microsoft account,", "or your Azure tenant ID to restrict to one organisation.", }, }, { key: "MICROSOFT_REDIRECT_URL", defVal: "", comments: []string{ "Override the Outlook OAuth redirect URL. Leave blank to auto-derive from BASE_URL.", "Must exactly match what is registered in Azure.", }, }, } // Load reads/creates data/gowebmail.conf, fills in missing keys, then returns Config. // Environment variables override file values when set. func Load() (*Config, error) { if err := os.MkdirAll("./data", 0700); err != nil { return nil, fmt.Errorf("create data dir: %w", err) } existing, err := readConfigFile(configPath) if err != nil { return nil, err } // Auto-generate secrets if missing if existing["ENCRYPTION_KEY"] == "" { existing["ENCRYPTION_KEY"] = mustHex(32) fmt.Println("WARNING: Generated new ENCRYPTION_KEY — it is saved in data/gowebmail.conf — back it up!") } if existing["SESSION_SECRET"] == "" { existing["SESSION_SECRET"] = mustHex(32) } // Write back (preserves existing, adds any new fields from allFields) if err := writeConfigFile(configPath, existing); err != nil { return nil, fmt.Errorf("write config: %w", err) } // get returns env var if set, else file value, else "" get := func(key string) string { // Only check env vars that are explicitly GoWebMail-namespaced or well-known. // We deliberately do NOT fall back to generic vars like PORT to avoid // picking up cloud-platform env vars unintentionally. if v := os.Getenv("GOMAIL_" + key); v != "" { return v } if v := os.Getenv(key); v != "" { return v } return existing[key] } // ---- Resolve listen address ---- listenAddr := get("LISTEN_ADDR") if listenAddr == "" { listenAddr = ":8080" } // Ensure it has a port if !strings.Contains(listenAddr, ":") { listenAddr = ":" + listenAddr } _, listenPort, err := net.SplitHostPort(listenAddr) if err != nil { return nil, fmt.Errorf("invalid LISTEN_ADDR %q: %w", listenAddr, err) } // ---- Resolve hostname ---- hostname := get("HOSTNAME") if hostname == "" { hostname = "localhost" } // Strip any accidental protocol or port from hostname hostname = strings.TrimPrefix(hostname, "http://") hostname = strings.TrimPrefix(hostname, "https://") hostname = strings.Split(hostname, ":")[0] hostname = strings.TrimRight(hostname, "/") // ---- Build BASE_URL ---- baseURL := get("BASE_URL") if baseURL == "" { baseURL = buildBaseURL(hostname, listenPort) } // Strip trailing slash baseURL = strings.TrimRight(baseURL, "/") // ---- OAuth redirect URLs (auto-derive if blank) ---- googleRedirect := get("GOOGLE_REDIRECT_URL") if googleRedirect == "" { googleRedirect = baseURL + "/auth/gmail/callback" } outlookRedirect := get("MICROSOFT_REDIRECT_URL") if outlookRedirect == "" { outlookRedirect = baseURL + "/auth/outlook/callback" } // ---- Decode secrets ---- encHex := get("ENCRYPTION_KEY") encKey, err := hex.DecodeString(encHex) if err != nil || len(encKey) != 32 { return nil, fmt.Errorf("ENCRYPTION_KEY must be 64 hex chars (32 bytes), got %d chars", len(encHex)) } sessSecret := get("SESSION_SECRET") if sessSecret == "" { return nil, fmt.Errorf("SESSION_SECRET is empty — this should not happen") } // ---- Trusted proxies ---- trustedProxies, err := parseCIDRList(get("TRUSTED_PROXIES")) if err != nil { return nil, fmt.Errorf("invalid TRUSTED_PROXIES: %w", err) } cfg := &Config{ ListenAddr: listenAddr, ListenPort: listenPort, Hostname: hostname, BaseURL: baseURL, DBPath: get("DB_PATH"), EncryptionKey: encKey, SessionSecret: []byte(sessSecret), SecureCookie: atobool(get("SECURE_COOKIE"), false), SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800), TrustedProxies: trustedProxies, BruteEnabled: atobool(get("BRUTE_ENABLED"), true), BruteMaxAttempts: atoi(get("BRUTE_MAX_ATTEMPTS"), 5), BruteWindowMins: atoi(get("BRUTE_WINDOW_MINUTES"), 30), BruteBanHours: atoi(get("BRUTE_BAN_HOURS"), 12), BruteWhitelist: parseIPList(get("BRUTE_WHITELIST_IPS")), GeoBlockCountries: parseCountryList(get("GEO_BLOCK_COUNTRIES")), GeoAllowCountries: parseCountryList(get("GEO_ALLOW_COUNTRIES")), NotifyEnabled: atobool(get("NOTIFY_ENABLED"), true), NotifySMTPHost: get("NOTIFY_SMTP_HOST"), NotifySMTPPort: atoi(get("NOTIFY_SMTP_PORT"), 587), NotifyFrom: get("NOTIFY_FROM"), NotifyUser: get("NOTIFY_USER"), NotifyPass: get("NOTIFY_PASS"), GoogleClientID: get("GOOGLE_CLIENT_ID"), GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"), GoogleRedirectURL: googleRedirect, MicrosoftClientID: get("MICROSOFT_CLIENT_ID"), MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"), MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "common"), MicrosoftRedirectURL: outlookRedirect, } // Derive SECURE_COOKIE automatically if BASE_URL uses https if strings.HasPrefix(baseURL, "https://") && !cfg.SecureCookie { cfg.SecureCookie = true } logStartupInfo(cfg) return cfg, nil } // buildBaseURL constructs the public URL from hostname and port. // Port 443 → https://, port 80 → http://, // anything else → http://: func buildBaseURL(hostname, port string) string { switch port { case "443": return "https://" + hostname case "80": return "http://" + hostname default: return "http://" + hostname + ":" + port } } // IsIPWhitelisted returns true if the IP is in the brute force whitelist. func (c *Config) IsIPWhitelisted(ipStr string) bool { ip := net.ParseIP(ipStr) if ip == nil { return false } for _, w := range c.BruteWhitelist { if w.Equal(ip) { return true } } return false } // IsCountryAllowed returns true if traffic from the given 2-letter country code is permitted. // Logic: deny-list takes precedence; then allow-list if non-empty; otherwise allow all. func (c *Config) IsCountryAllowed(code string) bool { code = strings.ToUpper(code) if len(c.GeoBlockCountries) > 0 { for _, bc := range c.GeoBlockCountries { if bc == code { return false } } } if len(c.GeoAllowCountries) > 0 { for _, ac := range c.GeoAllowCountries { if ac == code { return true } } return false } return true } // IsAllowedHost returns true if the request Host header matches our expected hostname. // Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode). func (c *Config) IsAllowedHost(requestHost string) bool { if c.Hostname == "localhost" { return true // dev mode — permissive } // Strip port from request Host header h := requestHost if host, _, err := net.SplitHostPort(requestHost); err == nil { h = host } return strings.EqualFold(h, c.Hostname) } // RealIP extracts the genuine client IP from the request, honouring X-Forwarded-For // only when the request comes from a trusted proxy. func (c *Config) RealIP(remoteAddr string, xForwardedFor string) string { // Parse remote addr remoteIP, _, err := net.SplitHostPort(remoteAddr) if err != nil { remoteIP = remoteAddr } if xForwardedFor == "" || !c.isTrustedProxy(remoteIP) { return remoteIP } // Take the left-most (client) IP from X-Forwarded-For parts := strings.Split(xForwardedFor, ",") if len(parts) > 0 { ip := strings.TrimSpace(parts[0]) if net.ParseIP(ip) != nil { return ip } } return remoteIP } // IsHTTPS returns true if the request arrived over TLS, either directly // or as indicated by X-Forwarded-Proto from a trusted proxy. func (c *Config) IsHTTPS(remoteAddr string, xForwardedProto string) bool { if xForwardedProto != "" { remoteIP, _, err := net.SplitHostPort(remoteAddr) if err != nil { remoteIP = remoteAddr } if c.isTrustedProxy(remoteIP) { return strings.EqualFold(xForwardedProto, "https") } } return strings.HasPrefix(c.BaseURL, "https://") } func (c *Config) isTrustedProxy(ipStr string) bool { ip := net.ParseIP(ipStr) if ip == nil { return false } for _, cidr := range c.TrustedProxies { if cidr.Contains(ip) { return true } } return false } // ---- Config file I/O ---- func readConfigFile(path string) (map[string]string, error) { values := make(map[string]string) f, err := os.Open(path) if os.IsNotExist(err) { return values, nil } if err != nil { return nil, fmt.Errorf("open config: %w", err) } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } idx := strings.IndexByte(line, '=') if idx < 0 { continue } key := strings.TrimSpace(line[:idx]) val := strings.TrimSpace(line[idx+1:]) values[key] = val } return values, scanner.Err() } func writeConfigFile(path string, values map[string]string) error { var sb strings.Builder sb.WriteString("# GoWebMail Configuration\n") sb.WriteString("# =====================\n") sb.WriteString("# Auto-generated and updated on each startup.\n") sb.WriteString("# Edit freely — your values are always preserved.\n") sb.WriteString("# Environment variables (or GOMAIL_) override values here.\n") sb.WriteString("#\n\n") for _, field := range allFields { for _, c := range field.comments { if c == "" { sb.WriteString("#\n") } else { sb.WriteString("# " + c + "\n") } } val := values[field.key] if val == "" { val = field.defVal } sb.WriteString(field.key + " = " + val + "\n\n") } return os.WriteFile(path, []byte(sb.String()), 0600) } // ---- Admin settings API ---- // EditableKeys lists config keys that may be changed via the admin UI. // SESSION_SECRET and ENCRYPTION_KEY are intentionally excluded. var EditableKeys = func() map[string]bool { excluded := map[string]bool{ "SESSION_SECRET": true, "ENCRYPTION_KEY": true, } m := map[string]bool{} for _, f := range allFields { if !excluded[f.key] { m[f.key] = true } } return m }() // GetSettings returns the current raw config file values for all editable keys. func GetSettings() (map[string]string, error) { raw, err := readConfigFile(configPath) if err != nil { return nil, err } result := make(map[string]string, len(allFields)) for _, f := range allFields { if EditableKeys[f.key] { if v, ok := raw[f.key]; ok { result[f.key] = v } else { result[f.key] = f.defVal } } } return result, nil } // SetSettings merges the provided map into the config file. // Only EditableKeys are accepted; unknown or protected keys are silently ignored. // Returns the list of keys that were actually changed. func SetSettings(updates map[string]string) ([]string, error) { raw, err := readConfigFile(configPath) if err != nil { return nil, err } var changed []string for k, v := range updates { if !EditableKeys[k] { continue } if raw[k] != v { raw[k] = v changed = append(changed, k) } } if len(changed) == 0 { return nil, nil } return changed, writeConfigFile(configPath, raw) } // ---- Host validation middleware helper ---- // HostCheck returns an HTTP middleware that rejects requests with unexpected Host headers. // Skipped in dev mode (hostname == "localhost"). func (c *Config) HostCheckMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !c.IsAllowedHost(r.Host) { http.Error(w, "Invalid host header", http.StatusBadRequest) return } next.ServeHTTP(w, r) }) } // ---- Helpers ---- func parseCIDRList(s string) ([]net.IPNet, error) { var nets []net.IPNet if s == "" { return nets, nil } for _, raw := range strings.Split(s, ",") { raw = strings.TrimSpace(raw) if raw == "" { continue } // Allow bare IPs (treat as /32 or /128) if !strings.Contains(raw, "/") { ip := net.ParseIP(raw) if ip == nil { return nil, fmt.Errorf("invalid IP %q", raw) } bits := 32 if ip.To4() == nil { bits = 128 } raw = fmt.Sprintf("%s/%d", ip.String(), bits) } _, ipNet, err := net.ParseCIDR(raw) if err != nil { return nil, fmt.Errorf("invalid CIDR %q: %w", raw, err) } nets = append(nets, *ipNet) } return nets, nil } func logStartupInfo(cfg *Config) { fmt.Printf("GoWebMail starting:\n") fmt.Printf(" Listen : %s\n", cfg.ListenAddr) fmt.Printf(" Base URL: %s\n", cfg.BaseURL) fmt.Printf(" Hostname: %s\n", cfg.Hostname) if len(cfg.TrustedProxies) > 0 { cidrs := make([]string, len(cfg.TrustedProxies)) for i, n := range cfg.TrustedProxies { cidrs[i] = n.String() } fmt.Printf(" Proxies : %s\n", strings.Join(cidrs, ", ")) } } func parseIPList(s string) []net.IP { var ips []net.IP for _, raw := range strings.Split(s, ",") { raw = strings.TrimSpace(raw) if raw == "" { continue } if ip := net.ParseIP(raw); ip != nil { ips = append(ips, ip) } } return ips } func parseCountryList(s string) []string { var codes []string for _, raw := range strings.Split(s, ",") { raw = strings.TrimSpace(strings.ToUpper(raw)) if len(raw) == 2 { codes = append(codes, raw) } } return codes } func mustHex(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { panic("crypto/rand unavailable: " + err.Error()) } return hex.EncodeToString(b) } func orDefault(s, def string) string { if s == "" { return def } return s } func atoi(s string, fallback int) int { if v, err := strconv.Atoi(s); err == nil { return v } return fallback } func atobool(s string, fallback bool) bool { if v, err := strconv.ParseBool(s); err == nil { return v } return fallback } // Needed for HostCheckMiddleware