summaryrefslogtreecommitdiffstats
path: root/internal/config
diff options
context:
space:
mode:
Diffstat (limited to 'internal/config')
-rw-r--r--internal/config/config.go260
-rw-r--r--internal/config/config_test.go113
2 files changed, 373 insertions, 0 deletions
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
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..c35ee61
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,113 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestSaveLoadRoundTrip(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "nested", "config.json")
+ want := Settings{
+ Host: "news.example.org",
+ Port: "563",
+ UseTLS: true,
+ SMTPHost: "qee4i7sags6phsvb2yodwecfj7noimfhhalsjktsvikrwotxzis3raad.onion",
+ SMTPPort: "25",
+ SMTPRecipient: "mail2news@mail2news.tcpreset.net",
+ Username: "reader",
+ ProxyType: "SOCKS5",
+ ProxyAddress: "127.0.0.1:9050",
+ DisplayName: "Aegis User",
+ Email: "reader@example.org",
+ Subscriptions: []string{"comp.lang.go", "sci.crypt"},
+ }
+ if err := Save(path, want); err != nil {
+ t.Fatalf("Save() error = %v", err)
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := info.Mode().Perm(); got != 0o600 {
+ t.Fatalf("mode = %o, want 600", got)
+ }
+ got, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("Load() = %#v, want %#v", got, want)
+ }
+}
+
+func TestPasswordCannotBePersisted(t *testing.T) {
+ settingsType := reflect.TypeOf(Settings{})
+ if _, ok := settingsType.FieldByName("Password"); ok {
+ t.Fatal("Settings must not contain a persistent Password field")
+ }
+ path := filepath.Join(t.TempDir(), "config.json")
+ if err := Save(path, Default()); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(strings.ToLower(string(data)), "password") {
+ t.Fatal("saved settings unexpectedly mention a password")
+ }
+}
+
+func TestValidateRejectsProtocolInjection(t *testing.T) {
+ settings := Default()
+ settings.Username = "user\r\nQUIT"
+ if err := settings.Validate(); err == nil {
+ t.Fatal("Validate() accepted a username with protocol injection")
+ }
+ if err := ValidateGroupName("comp.lang.go\r\nPOST"); err == nil {
+ t.Fatal("ValidateGroupName() accepted protocol injection")
+ }
+}
+
+func TestValidateRejectsAuthenticationWithoutTLS(t *testing.T) {
+ settings := Default()
+ settings.UseTLS = false
+ settings.Username = "reader"
+ if err := settings.Validate(); err == nil {
+ t.Fatal("Validate() accepted authentication without TLS")
+ }
+}
+
+func TestValidateRejectsCompressionWithTLS(t *testing.T) {
+ settings := Default()
+ settings.UseCompression = true
+ if err := settings.Validate(); err == nil {
+ t.Fatal("Validate() accepted compression over TLS")
+ }
+}
+
+func TestValidateRequiresProxyForOnionSMTP(t *testing.T) {
+ settings := Default()
+ settings.SMTPHost = "mail.example.onion"
+ settings.SMTPPort = "465"
+ settings.SMTPMode = ""
+ settings.ProxyType = "DIRECT"
+ if err := settings.Validate(); err == nil {
+ t.Fatal("Validate() accepted direct .onion SMTP")
+ }
+}
+
+func TestValidateAllowsCleartextOnionSMTPThroughSharedProxy(t *testing.T) {
+ settings := Default()
+ settings.SMTPHost = "mail.example.onion"
+ settings.SMTPPort = "25"
+ settings.SMTPMode = ""
+ settings.ProxyType = "SOCKS5"
+ settings.ProxyAddress = "127.0.0.1:9050"
+ if err := settings.Validate(); err != nil {
+ t.Fatalf("Validate() rejected cleartext onion SMTP through SOCKS5: %v", err)
+ }
+}