From c1decadb590c4d79d92bcc4df7772119a5c91244 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Sat, 22 Aug 2026 20:36:56 +0200 Subject: Initial Aegis Usenet client release --- internal/config/config.go | 260 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 internal/config/config.go (limited to 'internal/config/config.go') diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..19269a9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,260 @@ +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 +} -- cgit v1.2.3