package config import ( "encoding/json" "errors" "fmt" "net" "os" "path/filepath" "sort" "strconv" "strings" ) const appDir = "aegis" type Settings struct { Host string `json:"host"` Port string `json:"port"` UseTLS bool `json:"use_tls"` StartTLS bool `json:"start_tls,omitempty"` SkipTLSVerify bool `json:"skip_tls_verify,omitempty"` Username string `json:"username,omitempty"` SASLMechanism string `json:"sasl_mechanism,omitempty"` UseCompression bool `json:"use_compression,omitempty"` SMTPHost string `json:"smtp_host,omitempty"` SMTPPort string `json:"smtp_port,omitempty"` SMTPMode string `json:"smtp_mode,omitempty"` SMTPUsername string `json:"smtp_username,omitempty"` SMTPEmail string `json:"smtp_email,omitempty"` SMTPRecipient string `json:"smtp_recipient,omitempty"` SMTPSkipVerify bool `json:"smtp_skip_tls_verify,omitempty"` ProxyType string `json:"proxy_type"` ProxyAddress string `json:"proxy_address,omitempty"` DisplayName string `json:"display_name,omitempty"` Email string `json:"email,omitempty"` Subscriptions []string `json:"subscriptions,omitempty"` } func Default() Settings { return Settings{ Host: "news.tcpreset.net", Port: "563", UseTLS: true, SMTPHost: "qee4i7sags6phsvb2yodwecfj7noimfhhalsjktsvikrwotxzis3raad.onion", SMTPPort: "25", SMTPRecipient: "mail2news@mail2news.tcpreset.net", ProxyType: "SOCKS5", ProxyAddress: "127.0.0.1:9050", } } func DefaultPath() (string, error) { dir, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("locate user configuration directory: %w", err) } return filepath.Join(dir, appDir, "config.json"), nil } func Load(path string) (Settings, error) { settings := Default() data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return settings, nil } if err != nil { return Settings{}, fmt.Errorf("read settings: %w", err) } if err := json.Unmarshal(data, &settings); err != nil { return Settings{}, fmt.Errorf("decode settings: %w", err) } settings.normalize() if err := settings.Validate(); err != nil { return Settings{}, fmt.Errorf("validate saved settings: %w", err) } return settings, nil } func Save(path string, settings Settings) error { settings.normalize() if err := settings.Validate(); err != nil { return err } data, err := json.MarshalIndent(settings, "", " ") if err != nil { return fmt.Errorf("encode settings: %w", err) } data = append(data, '\n') dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("create settings directory: %w", err) } if err := os.Chmod(dir, 0o700); err != nil { return fmt.Errorf("protect settings directory: %w", err) } tmp, err := os.CreateTemp(dir, ".config-*.tmp") if err != nil { return fmt.Errorf("create temporary settings file: %w", err) } tmpName := tmp.Name() defer os.Remove(tmpName) if err := tmp.Chmod(0o600); err != nil { tmp.Close() return fmt.Errorf("protect temporary settings file: %w", err) } if _, err := tmp.Write(data); err != nil { tmp.Close() return fmt.Errorf("write settings: %w", err) } if err := tmp.Sync(); err != nil { tmp.Close() return fmt.Errorf("sync settings: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("close settings: %w", err) } if err := os.Rename(tmpName, path); err != nil { return fmt.Errorf("commit settings: %w", err) } return nil } func (s Settings) Validate() error { if err := validateToken("host", s.Host); err != nil { return err } if net.ParseIP(s.Host) == nil && strings.ContainsAny(s.Host, " /\\") { return errors.New("host contains invalid characters") } port, err := strconv.Atoi(s.Port) if err != nil || port < 1 || port > 65535 { return errors.New("port must be between 1 and 65535") } if err := validateToken("username", s.Username); err != nil { return err } if s.Username != "" && !s.UseTLS { return errors.New("authentication requires TLS") } if s.StartTLS && !s.UseTLS { return errors.New("STARTTLS requires TLS") } if s.SASLMechanism != "" && !strings.EqualFold(s.SASLMechanism, "PLAIN") { return errors.New("unsupported SASL mechanism") } if s.SASLMechanism != "" && s.Username == "" { return errors.New("SASL requires a username") } if s.UseCompression && s.UseTLS { return errors.New("COMPRESS DEFLATE cannot be used with TLS") } if err := validateSMTP(s); err != nil { return err } switch s.ProxyType { case "DIRECT": case "SOCKS5": if _, _, err := net.SplitHostPort(s.ProxyAddress); err != nil { return fmt.Errorf("invalid SOCKS5 address: %w", err) } default: return errors.New("proxy type must be DIRECT or SOCKS5") } if strings.ContainsAny(s.DisplayName, "\r\n") || strings.ContainsAny(s.Email+s.SMTPEmail+s.SMTPRecipient, "\r\n") { return errors.New("posting identity must not contain line breaks") } for _, group := range s.Subscriptions { if err := ValidateGroupName(group); err != nil { return fmt.Errorf("invalid subscription %q: %w", group, err) } } return nil } func ValidateGroupName(group string) error { if err := validateToken("newsgroup", group); err != nil { return err } if strings.ContainsAny(group, " ,") { return errors.New("newsgroup contains invalid characters") } return nil } func (s *Settings) normalize() { s.Host = strings.TrimSpace(s.Host) s.Port = strings.TrimSpace(s.Port) s.Username = strings.TrimSpace(s.Username) s.SASLMechanism = strings.ToUpper(strings.TrimSpace(s.SASLMechanism)) s.SMTPHost = strings.TrimSpace(s.SMTPHost) s.SMTPPort = strings.TrimSpace(s.SMTPPort) s.SMTPMode = strings.ToUpper(strings.TrimSpace(s.SMTPMode)) if s.SMTPMode == "DISABLED" || s.SMTPMode == "CLEARTEXT" { s.SMTPMode = "" } s.SMTPUsername = strings.TrimSpace(s.SMTPUsername) s.SMTPEmail = strings.TrimSpace(s.SMTPEmail) s.SMTPRecipient = strings.TrimSpace(s.SMTPRecipient) s.ProxyType = strings.ToUpper(strings.TrimSpace(s.ProxyType)) s.ProxyAddress = strings.TrimSpace(s.ProxyAddress) s.DisplayName = strings.TrimSpace(s.DisplayName) s.Email = strings.TrimSpace(s.Email) seen := make(map[string]struct{}, len(s.Subscriptions)) groups := s.Subscriptions[:0] for _, group := range s.Subscriptions { group = strings.TrimSpace(group) if group == "" { continue } if _, ok := seen[group]; ok { continue } seen[group] = struct{}{} groups = append(groups, group) } sort.Strings(groups) s.Subscriptions = groups } func validateSMTP(s Settings) error { if s.SMTPHost == "" && s.SMTPMode == "" && s.SMTPPort == "" { return nil } if s.SMTPHost == "" || s.SMTPPort == "" { return errors.New("SMTP host and port are required when SMTP is configured") } if err := validateToken("SMTP host", s.SMTPHost); err != nil { return err } if net.ParseIP(s.SMTPHost) == nil && strings.ContainsAny(s.SMTPHost, " /\\") { return errors.New("SMTP host contains invalid characters") } port, err := strconv.Atoi(s.SMTPPort) if err != nil || port < 1 || port > 65535 { return errors.New("SMTP port must be between 1 and 65535") } switch s.SMTPMode { case "", "TLS", "STARTTLS": default: return errors.New("SMTP mode must be cleartext, TLS or STARTTLS") } if s.SMTPUsername != "" && s.SMTPMode == "" { return errors.New("SMTP authentication requires TLS or STARTTLS") } if strings.HasSuffix(strings.ToLower(s.SMTPHost), ".onion") && s.ProxyType != "SOCKS5" { return errors.New(".onion SMTP servers require a SOCKS5 proxy") } return nil } func validateToken(name, value string) error { if name == "host" && value == "" { return errors.New("host is required") } if strings.ContainsAny(value, "\x00\r\n\t") { return fmt.Errorf("%s contains control characters", name) } return nil }