summaryrefslogtreecommitdiffstats
path: root/internal/config
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-08-24 17:34:45 +0200
committerGab Virebent <gabriel1@virebent.art>2026-08-24 17:34:45 +0200
commite9fbbe3373eb66a345f5e3829e2563b94dc92051 (patch)
tree950d75fda88574afb46b4aeab36e96f3c089a3bb /internal/config
parentfb83c4d70616ec23d8a5397409a5d31c70b70d66 (diff)
downloadn2usenet-e9fbbe3373eb66a345f5e3829e2563b94dc92051.tar.gz
n2usenet-e9fbbe3373eb66a345f5e3829e2563b94dc92051.tar.xz
n2usenet-e9fbbe3373eb66a345f5e3829e2563b94dc92051.zip
Harden transport and preserve profile identitiesHEADmain
Diffstat (limited to 'internal/config')
-rw-r--r--internal/config/config.go64
-rw-r--r--internal/config/config_test.go151
2 files changed, 215 insertions, 0 deletions
diff --git a/internal/config/config.go b/internal/config/config.go
index 5ee60ea..faf2a93 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"net"
+ "net/mail"
"os"
"strconv"
"strings"
@@ -23,11 +24,16 @@ type SMTPConfig struct {
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
}
@@ -55,6 +61,8 @@ type SecurityConfig struct {
MessageIDDomain string
IdenticonsCLI string
RequireFace bool
+ CSRFKeyFile string
+ CSRFKey []byte
}
func Load() (Config, error) {
@@ -66,11 +74,15 @@ func Load() (Config, error) {
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{
@@ -96,8 +108,23 @@ func Load() (Config, error) {
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
}
@@ -120,9 +147,22 @@ func (c Config) Validate() error {
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)
@@ -146,6 +186,9 @@ func (c Config) Validate() error {
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
}
@@ -157,6 +200,15 @@ func env(primary, legacy, fallback string) string {
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 {
@@ -207,3 +259,15 @@ func envValue(primary, legacy string) (string, bool) {
}
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
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..fdb02e4
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,151 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func validConfig() Config {
+ return Config{
+ Listen: "127.0.0.1:8095",
+ SMTP: SMTPConfig{
+ Host: "mail.virebent.art",
+ Port: 587,
+ Recipient: "mail2news@mail2news.tcpreset.net",
+ EnvelopeFrom: "n2usenet@virebent.art",
+ Sender: "n2usenet@virebent.art",
+ TLSServerName: "mail.virebent.art",
+ RequireTLS: true,
+ ProbeInterval: 15 * time.Minute,
+ },
+ Security: SecurityConfig{
+ MinHashcashBits: 20,
+ MinMessageBytes: 10,
+ MaxMessageBytes: 65536,
+ MaxNewsgroups: 3,
+ RateLimitCount: 10,
+ RateLimitWindow: time.Hour,
+ IdenticonsCLI: "/usr/local/bin/identicons-cli",
+ RequireFace: true,
+ },
+ }
+}
+
+func TestLoadAcceptsLegacyHeaderFromAsSender(t *testing.T) {
+ t.Setenv("N2U_SMTP_HEADER_FROM", "legacy-sender@virebent.art")
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load returned error: %v", err)
+ }
+ if got, want := cfg.SMTP.Sender, "legacy-sender@virebent.art"; got != want {
+ t.Fatalf("Sender = %q, want %q", got, want)
+ }
+}
+
+func TestLoadPrefersSenderOverLegacyHeaderFrom(t *testing.T) {
+ t.Setenv("N2U_SMTP_SENDER", "sender@virebent.art")
+ t.Setenv("N2U_SMTP_HEADER_FROM", "legacy-sender@virebent.art")
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load returned error: %v", err)
+ }
+ if got, want := cfg.SMTP.Sender, "sender@virebent.art"; got != want {
+ t.Fatalf("Sender = %q, want %q", got, want)
+ }
+}
+
+func TestValidateSMTPAuthentication(t *testing.T) {
+ tests := []struct {
+ name string
+ mutate func(*Config)
+ wantErr string
+ }{
+ {
+ name: "credentials with STARTTLS",
+ mutate: func(cfg *Config) {
+ cfg.SMTP.Username = "n2usenet@virebent.art"
+ cfg.SMTP.Password = "test-password"
+ },
+ },
+ {
+ name: "missing password",
+ mutate: func(cfg *Config) {
+ cfg.SMTP.Username = "n2usenet@virebent.art"
+ },
+ wantErr: "must be configured together",
+ },
+ {
+ name: "missing username",
+ mutate: func(cfg *Config) {
+ cfg.SMTP.Password = "test-password"
+ },
+ wantErr: "must be configured together",
+ },
+ {
+ name: "credentials without TLS",
+ mutate: func(cfg *Config) {
+ cfg.SMTP.Username = "n2usenet@virebent.art"
+ cfg.SMTP.Password = "test-password"
+ cfg.SMTP.RequireTLS = false
+ },
+ wantErr: "requires TLS",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := validConfig()
+ tt.mutate(&cfg)
+ err := cfg.Validate()
+ if tt.wantErr == "" {
+ if err != nil {
+ t.Fatalf("Validate returned error: %v", err)
+ }
+ return
+ }
+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("Validate error = %v, want substring %q", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestLoadReadsSMTPPasswordFile(t *testing.T) {
+ passwordPath := filepath.Join(t.TempDir(), "smtp-password")
+ if err := os.WriteFile(passwordPath, []byte("test-password\n"), 0600); err != nil {
+ t.Fatalf("write password file: %v", err)
+ }
+ t.Setenv("N2U_SMTP_USERNAME", "n2usenet@virebent.art")
+ t.Setenv("N2U_SMTP_PASSWORD_FILE", passwordPath)
+ t.Setenv("N2U_SMTP_REQUIRE_TLS", "true")
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load returned error: %v", err)
+ }
+ if got, want := cfg.SMTP.Password, "test-password"; got != want {
+ t.Fatalf("Password = %q, want %q", got, want)
+ }
+}
+
+func TestLoadReadsPersistentCSRFKey(t *testing.T) {
+ keyPath := filepath.Join(t.TempDir(), "csrf-key")
+ want := strings.Repeat("k", 32)
+ if err := os.WriteFile(keyPath, []byte(want+"\n"), 0600); err != nil {
+ t.Fatalf("write CSRF key file: %v", err)
+ }
+ t.Setenv("N2U_CSRF_KEY_FILE", keyPath)
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load returned error: %v", err)
+ }
+ if got := string(cfg.Security.CSRFKey); got != want {
+ t.Fatalf("CSRFKey = %q, want %q", got, want)
+ }
+}