package config import ( "fmt" "net" "net/mail" "os" "strconv" "strings" "time" ) type Config struct { Listen string PublicBaseURL string SMTP SMTPConfig Nym NymConfig Security SecurityConfig } type SMTPConfig struct { Host string Port int Recipient string EnvelopeFrom string Sender string Username string Password string PasswordFile string HELO string TLSServerName string RequireTLS bool ImplicitTLS bool Timeout time.Duration ProbeInterval time.Duration DryRun bool } type NymConfig struct { Enabled bool Managed bool Binary string Provider string HomeDir string ClientID string SocksAddr string AnonymousReplies bool StartupTimeout time.Duration } type SecurityConfig struct { MinHashcashBits int MinMessageBytes int MaxMessageBytes int MaxNewsgroups int RateLimitCount int RateLimitWindow time.Duration TrustProxy bool SecureCookies bool MessageIDDomain string IdenticonsCLI string RequireFace bool CSRFKeyFile string CSRFKey []byte } func Load() (Config, error) { cfg := Config{ Listen: env("N2U_LISTEN", "M2U_LISTEN", "127.0.0.1:8095"), PublicBaseURL: env("N2U_PUBLIC_BASE_URL", "M2U_PUBLIC_BASE_URL", "https://n2usenet.virebent.art"), SMTP: SMTPConfig{ Host: env("N2U_SMTP_HOST", "M2U_SMTP_HOST", "mail2news.tcpreset.net"), Port: envInt("N2U_SMTP_PORT", "M2U_SMTP_PORT", 25), Recipient: env("N2U_SMTP_RECIPIENT", "M2U_SMTP_RECIPIENT", "mail2news@mail2news.tcpreset.net"), EnvelopeFrom: env("N2U_SMTP_ENVELOPE_FROM", "M2U_SMTP_ENVELOPE_FROM", "n2usenet@virebent.art"), Sender: envAny("n2usenet@virebent.art", "N2U_SMTP_SENDER", "M2U_SMTP_SENDER", "N2U_SMTP_HEADER_FROM", "M2U_SMTP_HEADER_FROM"), Username: env("N2U_SMTP_USERNAME", "M2U_SMTP_USERNAME", ""), PasswordFile: env("N2U_SMTP_PASSWORD_FILE", "M2U_SMTP_PASSWORD_FILE", ""), HELO: env("N2U_SMTP_HELO", "M2U_SMTP_HELO", "n2usenet.virebent.art"), TLSServerName: env("N2U_SMTP_TLS_SERVER_NAME", "M2U_SMTP_TLS_SERVER_NAME", "mail.tcpreset.net"), RequireTLS: envBool("N2U_SMTP_REQUIRE_TLS", "M2U_SMTP_REQUIRE_TLS", true), ImplicitTLS: envBool("N2U_SMTP_IMPLICIT_TLS", "M2U_SMTP_IMPLICIT_TLS", false), Timeout: envDuration("N2U_SMTP_TIMEOUT", "M2U_SMTP_TIMEOUT", 90*time.Second), ProbeInterval: envDuration("N2U_TRANSPORT_PROBE_INTERVAL", "M2U_TRANSPORT_PROBE_INTERVAL", 15*time.Minute), DryRun: envBool("N2U_DRY_RUN", "M2U_DRY_RUN", false), }, Nym: NymConfig{ Enabled: envBool("N2U_NYM_ENABLED", "M2U_NYM_ENABLED", true), Managed: envBool("N2U_NYM_MANAGED", "M2U_NYM_MANAGED", false), Binary: env("N2U_NYM_BINARY", "M2U_NYM_BINARY", "nym-socks5-client"), Provider: env("N2U_NYM_PROVIDER", "M2U_NYM_PROVIDER", ""), HomeDir: env("N2U_NYM_HOME", "M2U_NYM_HOME", "./data/nym"), ClientID: env("N2U_NYM_CLIENT_ID", "M2U_NYM_CLIENT_ID", "n2usenet"), SocksAddr: env("N2U_NYM_SOCKS", "M2U_NYM_SOCKS", "127.0.0.1:11080"), AnonymousReplies: envBool("N2U_NYM_ANONYMOUS_REPLIES", "M2U_NYM_ANONYMOUS_REPLIES", true), StartupTimeout: envDuration("N2U_NYM_STARTUP_TIMEOUT", "M2U_NYM_STARTUP_TIMEOUT", 120*time.Second), }, Security: SecurityConfig{ MinHashcashBits: envInt("N2U_HASHCASH_MIN_BITS", "M2U_HASHCASH_MIN_BITS", 20), MinMessageBytes: envInt("N2U_MIN_MESSAGE_BYTES", "M2U_MIN_MESSAGE_BYTES", 10), MaxMessageBytes: envInt("N2U_MAX_MESSAGE_BYTES", "M2U_MAX_MESSAGE_BYTES", 65536), MaxNewsgroups: envInt("N2U_MAX_NEWSGROUPS", "M2U_MAX_NEWSGROUPS", 3), RateLimitCount: envInt("N2U_RATE_LIMIT_COUNT", "M2U_RATE_LIMIT_COUNT", 10), RateLimitWindow: envDuration("N2U_RATE_LIMIT_WINDOW", "M2U_RATE_LIMIT_WINDOW", time.Hour), TrustProxy: envBool("N2U_TRUST_PROXY", "M2U_TRUST_PROXY", true), SecureCookies: envBool("N2U_SECURE_COOKIES", "M2U_SECURE_COOKIES", true), MessageIDDomain: env("N2U_MESSAGE_ID_DOMAIN", "M2U_MESSAGE_ID_DOMAIN", "n2usenet.virebent.art"), IdenticonsCLI: env("N2U_IDENTICONS_CLI", "M2U_IDENTICONS_CLI", "/usr/local/bin/identicons-cli"), RequireFace: envBool("N2U_REQUIRE_FACE", "M2U_REQUIRE_FACE", true), CSRFKeyFile: env("N2U_CSRF_KEY_FILE", "M2U_CSRF_KEY_FILE", ""), }, } if cfg.SMTP.PasswordFile != "" { password, err := readSecretFile(cfg.SMTP.PasswordFile) if err != nil { return Config{}, fmt.Errorf("read SMTP password file: %w", err) } cfg.SMTP.Password = password } if cfg.Security.CSRFKeyFile != "" { key, err := readSecretFile(cfg.Security.CSRFKeyFile) if err != nil { return Config{}, fmt.Errorf("read CSRF key file: %w", err) } cfg.Security.CSRFKey = []byte(key) } if err := cfg.Validate(); err != nil { return Config{}, err } return cfg, nil } func (c Config) Validate() error { if _, _, err := net.SplitHostPort(c.Listen); err != nil { return fmt.Errorf("invalid N2U_LISTEN: %w", err) } if c.SMTP.Host == "" { return fmt.Errorf("N2U_SMTP_HOST is required") } if c.SMTP.Port < 1 || c.SMTP.Port > 65535 { return fmt.Errorf("invalid N2U_SMTP_PORT") } if c.SMTP.Recipient == "" { return fmt.Errorf("N2U_SMTP_RECIPIENT is required") } if c.SMTP.EnvelopeFrom == "" { return fmt.Errorf("N2U_SMTP_ENVELOPE_FROM is required") } sender, err := mail.ParseAddress(c.SMTP.Sender) if err != nil || sender.Address == "" { return fmt.Errorf("N2U_SMTP_SENDER must be a valid email address") } if (c.SMTP.Username == "") != (c.SMTP.Password == "") { return fmt.Errorf("N2U_SMTP_USERNAME and N2U_SMTP_PASSWORD_FILE must be configured together") } if c.SMTP.Username != "" && !c.SMTP.RequireTLS && !c.SMTP.ImplicitTLS { return fmt.Errorf("SMTP authentication requires TLS") } if c.SMTP.RequireTLS && c.SMTP.TLSServerName == "" { return fmt.Errorf("N2U_SMTP_TLS_SERVER_NAME is required when TLS is required") } if c.SMTP.ProbeInterval < time.Minute { return fmt.Errorf("N2U_TRANSPORT_PROBE_INTERVAL must be at least 1m") } if c.Nym.Enabled { if _, _, err := net.SplitHostPort(c.Nym.SocksAddr); err != nil { return fmt.Errorf("invalid N2U_NYM_SOCKS: %w", err) } if c.Nym.Managed && c.Nym.Provider == "" { return fmt.Errorf("N2U_NYM_PROVIDER is required when N2U_NYM_MANAGED=true") } } if c.Security.MinHashcashBits < 1 || c.Security.MinHashcashBits > 32 { return fmt.Errorf("invalid N2U_HASHCASH_MIN_BITS") } if c.Security.MinMessageBytes < 0 || c.Security.MaxMessageBytes <= c.Security.MinMessageBytes { return fmt.Errorf("invalid message size limits") } if c.Security.MaxNewsgroups < 1 || c.Security.MaxNewsgroups > 10 { return fmt.Errorf("invalid N2U_MAX_NEWSGROUPS") } if c.Security.RateLimitCount < 1 { return fmt.Errorf("invalid N2U_RATE_LIMIT_COUNT") } if c.Security.RequireFace && c.Security.IdenticonsCLI == "" { return fmt.Errorf("N2U_IDENTICONS_CLI is required when N2U_REQUIRE_FACE=true") } if c.Security.CSRFKeyFile != "" && len(c.Security.CSRFKey) < 32 { return fmt.Errorf("N2U_CSRF_KEY_FILE must contain at least 32 bytes") } return nil } func env(primary, legacy, fallback string) string { v, ok := envValue(primary, legacy) if !ok { return fallback } return v } func envAny(fallback string, keys ...string) string { for _, key := range keys { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } } return fallback } func envBool(primary, legacy string, fallback bool) bool { v, ok := envValue(primary, legacy) if !ok { return fallback } switch strings.ToLower(v) { case "1", "true", "yes", "on": return true case "0", "false", "no", "off": return false default: return fallback } } func envInt(primary, legacy string, fallback int) int { v, ok := envValue(primary, legacy) if !ok { return fallback } n, err := strconv.Atoi(v) if err != nil { return fallback } return n } func envDuration(primary, legacy string, fallback time.Duration) time.Duration { v, ok := envValue(primary, legacy) if !ok { return fallback } if d, err := time.ParseDuration(v); err == nil { return d } if n, err := strconv.Atoi(v); err == nil { return time.Duration(n) * time.Second } return fallback } func envValue(primary, legacy string) (string, bool) { for _, key := range []string{primary, legacy} { v := strings.TrimSpace(os.Getenv(key)) if v != "" { return v, true } } return "", false } func readSecretFile(path string) (string, error) { raw, err := os.ReadFile(path) if err != nil { return "", err } value := strings.TrimSpace(string(raw)) if value == "" { return "", fmt.Errorf("file is empty") } return value, nil }