diff options
| author | Gab <24553253+gabrix73@users.noreply.github.com> | 2026-06-29 14:22:15 +0200 |
|---|---|---|
| committer | Gab <24553253+gabrix73@users.noreply.github.com> | 2026-06-29 14:22:15 +0200 |
| commit | 404b81223688be027629f60ec5b1a00203da1768 (patch) | |
| tree | c935d41aee7c65d497a13fc8a9f80a57738d2f62 | |
| parent | 2e2245abf290121b507b43ee37f4596ff3eeea7d (diff) | |
| download | m2usenet-and-mail2news-main.tar.gz m2usenet-and-mail2news-main.tar.xz m2usenet-and-mail2news-main.zip | |
Replace stale divergent version with the deployed diego source (2026-05-26).
Drops legacy padding/blacklist/encoding paths (unused: web flow pads in send.php).
| -rw-r--r-- | mail2news.go | 1749 |
1 files changed, 693 insertions, 1056 deletions
diff --git a/mail2news.go b/mail2news.go index 8354071..7ff1366 100644 --- a/mail2news.go +++ b/mail2news.go @@ -1,61 +1,61 @@ +// mail2news v1.0.5 - Privacy-Enhanced Mail to Usenet Gateway +// Accepts both: +// - mail2news-YYYYMMDD-newsgroup@domain (classic format) +// - mail2news@domain (reads Newsgroups: from header) +// Copyright 2025 +// +// v1.0.5 changelog: +// - Removed adaptive padding (pointless on cleartext NNTP traffic) +// - Removed StripPadding (was dead code, never called) +// - Cleaned padding-related constants and config fields + package main import ( "bufio" + "bytes" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" - "encoding/binary" + "encoding/hex" "fmt" "io" "log" + "math/big" + "mime" "mime/quotedprintable" "net" "net/mail" + "net/textproto" "os" "path/filepath" "regexp" "runtime" - "strconv" + "sort" "strings" "sync" - "syscall" "time" "github.com/spf13/viper" "golang.org/x/net/proxy" - "golang.org/x/text/encoding" "golang.org/x/text/encoding/charmap" - "golang.org/x/text/encoding/htmlindex" - "golang.org/x/text/transform" ) -// Version - major release with privacy hardening -const VERSION = "1.0.0" - -// Exit codes const ( - ExitSuccess = 0 + MaxMessageSize = 1024 * 1024 * 10 // 10MB + CacheExpiration = 24 * time.Hour + MinDelayMs = 50 + MaxDelayMs = 500 + JitterMs = 100 + + ExitOK = 0 ExitError = 1 - ExitRejected = 2 + ExitReject = 2 ExitDuplicate = 3 ) -// Privacy constants -const ( - MinPaddingSize = 512 - MaxPaddingSize = 4096 - PaddingBlockSize = 64 - MinDelayMs = 50 - MaxDelayMs = 500 - JitterMs = 100 - MaxMessageSize = 1048576 // 1MB - CacheExpiration = 10 * time.Minute - CleanupInterval = 5 * time.Minute -) - -// Config holds all configuration +// Config structures type Config struct { Paths PathsConfig `mapstructure:"paths"` NNTP NNTPConfig `mapstructure:"nntp"` @@ -63,6 +63,7 @@ type Config struct { Logging LoggingConfig `mapstructure:"logging"` Encoding EncodingConfig `mapstructure:"encoding"` Privacy PrivacyConfig `mapstructure:"privacy"` + Headers HeadersConfig `mapstructure:"headers"` } type PathsConfig struct { @@ -73,17 +74,15 @@ type PathsConfig struct { } type NNTPConfig struct { - Path string `mapstructure:"path_header"` - InjectionHost string `mapstructure:"injection_host"` - Contact string `mapstructure:"contact"` - MessageID string `mapstructure:"messageid"` - DefaultFrom string `mapstructure:"default_from"` - PrimaryOnion string `mapstructure:"primary_onion"` - FallbackServer string `mapstructure:"fallback_server"` - TorProxy string `mapstructure:"tor_proxy"` - AlwaysUseTor bool `mapstructure:"always_use_tor"` - OnionServers []string `mapstructure:"onion_servers"` + PathHeader string `mapstructure:"path_header"` + InjectionHost string `mapstructure:"injection_host"` + Contact string `mapstructure:"contact"` + MessageIDDomain string `mapstructure:"messageid_domain"` + DefaultFrom string `mapstructure:"default_from"` + OnionServers []string `mapstructure:"onion_servers"` ClearnetServers []string `mapstructure:"clearnet_servers"` + AlwaysUseTor bool `mapstructure:"always_use_tor"` + TorProxy string `mapstructure:"tor_proxy"` } type ThresholdsConfig struct { @@ -102,61 +101,66 @@ type LoggingConfig struct { } type EncodingConfig struct { - ForceUtf8 bool `mapstructure:"force_utf8"` FallbackCharset string `mapstructure:"fallback_charset"` } type PrivacyConfig struct { - EnablePadding bool `mapstructure:"enable_padding"` EnableDelays bool `mapstructure:"enable_delays"` + EnableCoverTraffic bool `mapstructure:"enable_cover_traffic"` StripAllMetadata bool `mapstructure:"strip_all_metadata"` + RandomizeOrder bool `mapstructure:"randomize_order"` + MultiHopRouting bool `mapstructure:"multi_hop_routing"` } -var ( - config Config - lockFilePath string - lockFile *os.File - messageCache *MessageIDCache - logMutex sync.Mutex -) +type HeadersConfig struct { + StripFile string `mapstructure:"strip_file"` + StripList []string `mapstructure:"strip_list"` + AddNoArchive bool `mapstructure:"add_no_archive"` + AddGateway bool `mapstructure:"add_gateway_info"` + GatewayInfo string `mapstructure:"gateway_info"` +} -// MessageIDCache with secure operations +// Message ID Cache type MessageIDCache struct { cache map[string]time.Time - mutex sync.RWMutex + mu sync.RWMutex maxAge time.Duration cacheDir string } -// SecureRandom generates cryptographically secure random bytes -func SecureRandom(n int) ([]byte, error) { - b := make([]byte, n) - _, err := rand.Read(b) - if err != nil { - return nil, err - } - return b, nil -} +var ( + config Config + messageCache *MessageIDCache + logFile *os.File + headersToStrip map[string]bool +) + +// ============================================================================ +// SECURE RANDOM FUNCTIONS +// ============================================================================ -// SecureRandomInt generates a cryptographically secure random integer in range [0, max) func SecureRandomInt(max int) int { if max <= 0 { return 0 } - var b [8]byte - _, err := rand.Read(b[:]) + n, err := rand.Int(rand.Reader, big.NewInt(int64(max))) if err != nil { return 0 } - return int(binary.BigEndian.Uint64(b[:]) % uint64(max)) + return int(n.Int64()) +} + +func SecureRandom(size int) ([]byte, error) { + b := make([]byte, size) + _, err := rand.Read(b) + return b, err } -// SecureRandomDelay adds a random delay for timing attack mitigation func SecureRandomDelay() { if !config.Privacy.EnableDelays { return } - delay := MinDelayMs + SecureRandomInt(MaxDelayMs-MinDelayMs) + delay := SecureRandomInt(MaxDelayMs-MinDelayMs) + MinDelayMs jitter := SecureRandomInt(JitterMs*2) - JitterMs totalDelay := delay + jitter if totalDelay < 0 { @@ -165,7 +169,6 @@ func SecureRandomDelay() { time.Sleep(time.Duration(totalDelay) * time.Millisecond) } -// SecureZeroMemory overwrites sensitive data in memory func SecureZeroMemory(data []byte) { for i := range data { data[i] = 0 @@ -173,117 +176,74 @@ func SecureZeroMemory(data []byte) { runtime.KeepAlive(data) } -// SecureCompare performs constant-time comparison func SecureCompare(a, b string) bool { return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 } -// GenerateAdaptivePadding creates padding to prevent size correlation attacks -func GenerateAdaptivePadding(currentSize int) []byte { - if !config.Privacy.EnablePadding { - return nil - } - - // Calculate target size (round up to next block boundary) - targetSize := ((currentSize / PaddingBlockSize) + 1) * PaddingBlockSize - - // Add random additional padding within bounds - additionalPadding := SecureRandomInt(MaxPaddingSize-MinPaddingSize) + MinPaddingSize - targetSize += additionalPadding - - paddingSize := targetSize - currentSize - if paddingSize <= 0 { - paddingSize = MinPaddingSize - } - - // Generate random padding - padding, err := SecureRandom(paddingSize) - if err != nil { - return nil - } - - // Create padding header with size info (for receiver to strip) - header := fmt.Sprintf("\r\n-- PADDING:%d --\r\n", paddingSize) - - // Encode padding as base64 to ensure safe transmission - encodedPadding := base64.StdEncoding.EncodeToString(padding) - - return []byte(header + encodedPadding + "\r\n-- END PADDING --") -} - -// StripPadding removes padding from received message -func StripPadding(message string) string { - paddingStart := strings.Index(message, "\r\n-- PADDING:") - if paddingStart == -1 { - return message - } - - paddingEnd := strings.Index(message, "-- END PADDING --") - if paddingEnd == -1 { - return message - } - - return message[:paddingStart] + message[paddingEnd+17:] -} +// ============================================================================ +// MESSAGE ID CACHE +// ============================================================================ -// NewMessageIDCache creates a new message ID cache func NewMessageIDCache(maxAge time.Duration, cacheDir string) *MessageIDCache { if cacheDir != "" { - os.MkdirAll(cacheDir, 0700) // Restrictive permissions + os.MkdirAll(cacheDir, 0700) } - + cache := &MessageIDCache{ cache: make(map[string]time.Time), maxAge: maxAge, cacheDir: cacheDir, } - + cache.loadCache() go cache.cleanupLoop() - + return cache } +func hashMessageID(mid string) string { + h := sha256.Sum256([]byte(mid)) + return hex.EncodeToString(h[:]) +} + +func (c *MessageIDCache) Check(mid string) bool { + c.mu.RLock() + hash := hashMessageID(mid) + _, exists := c.cache[hash] + c.mu.RUnlock() + return exists +} + +func (c *MessageIDCache) Add(mid string) { + c.mu.Lock() + hash := hashMessageID(mid) + c.cache[hash] = time.Now() + c.mu.Unlock() + c.saveCache() +} + func (c *MessageIDCache) loadCache() { if c.cacheDir == "" { return } - - cacheFile := filepath.Join(c.cacheDir, "message_cache.dat") + cacheFile := filepath.Join(c.cacheDir, "msgid.cache") data, err := os.ReadFile(cacheFile) if err != nil { return } - defer SecureZeroMemory(data) - - c.mutex.Lock() - defer c.mutex.Unlock() - - c.cache = make(map[string]time.Time) - now := time.Now() - + + c.mu.Lock() + defer c.mu.Unlock() + lines := strings.Split(string(data), "\n") for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - parts := strings.SplitN(line, " ", 2) - if len(parts) != 2 { - continue - } - - // Use hash of message ID for storage (privacy) - hashedID := parts[0] - timestamp, err := strconv.ParseInt(parts[1], 10, 64) - if err != nil { - continue - } - - processTime := time.Unix(timestamp, 0) - if now.Sub(processTime) < c.maxAge { - c.cache[hashedID] = processTime + parts := strings.SplitN(line, "|", 2) + if len(parts) == 2 { + if t, err := time.Parse(time.RFC3339, parts[1]); err == nil { + if time.Since(t) < c.maxAge { + c.cache[parts[0]] = t + } + } } } } @@ -292,1071 +252,748 @@ func (c *MessageIDCache) saveCache() { if c.cacheDir == "" { return } - - c.mutex.RLock() - defer c.mutex.RUnlock() - - var builder strings.Builder - for hashedID, timestamp := range c.cache { - builder.WriteString(fmt.Sprintf("%s %d\n", hashedID, timestamp.Unix())) - } - - content := []byte(builder.String()) - defer SecureZeroMemory(content) - - cacheFile := filepath.Join(c.cacheDir, "message_cache.dat") - os.WriteFile(cacheFile, content, 0600) // Restrictive permissions + cacheFile := filepath.Join(c.cacheDir, "msgid.cache") + + c.mu.RLock() + var lines []string + for hash, t := range c.cache { + lines = append(lines, fmt.Sprintf("%s|%s", hash, t.Format(time.RFC3339))) + } + c.mu.RUnlock() + + os.WriteFile(cacheFile, []byte(strings.Join(lines, "\n")), 0600) } func (c *MessageIDCache) cleanupLoop() { - ticker := time.NewTicker(CleanupInterval) - defer ticker.Stop() - + ticker := time.NewTicker(time.Hour) for range ticker.C { - c.cleanup() + c.mu.Lock() + now := time.Now() + for hash, t := range c.cache { + if now.Sub(t) > c.maxAge { + delete(c.cache, hash) + } + } + c.mu.Unlock() c.saveCache() } } -func (c *MessageIDCache) cleanup() { - c.mutex.Lock() - defer c.mutex.Unlock() - - now := time.Now() - for id, timestamp := range c.cache { - if now.Sub(timestamp) > c.maxAge { - delete(c.cache, id) - } - } -} +// ============================================================================ +// HEADERS STRIPPING +// ============================================================================ -// hashMessageID creates a privacy-preserving hash of message ID -func hashMessageID(messageID string) string { - messageID = strings.Trim(messageID, "<>") - hash := sha256.Sum256([]byte(messageID)) - return fmt.Sprintf("%x", hash[:16]) -} +func loadHeadersToStrip() { + headersToStrip = make(map[string]bool) -func (c *MessageIDCache) IsProcessed(messageID string) bool { - hashedID := hashMessageID(messageID) - - c.mutex.RLock() - defer c.mutex.RUnlock() - - _, exists := c.cache[hashedID] - return exists -} + // Default headers to always strip for privacy + defaultStrip := []string{ + "received", + "x-originating-ip", + "x-mailer", + "x-mimeole", + "x-msmail-priority", + "x-ms-has-attach", + "x-ms-tnef-correlator", + "x-auto-response-suppress", + "return-path", + "delivered-to", + "dkim-signature", + "domainkey-signature", + "arc-seal", + "arc-message-signature", + "arc-authentication-results", + "authentication-results", + "x-google-dkim-signature", + "x-gm-message-state", + "x-received", + "x-google-smtp-source", + } -func (c *MessageIDCache) MarkProcessed(messageID string) { - hashedID := hashMessageID(messageID) - - c.mutex.Lock() - defer c.mutex.Unlock() - - c.cache[hashedID] = time.Now() - - if len(c.cache)%50 == 0 { - go c.saveCache() + for _, h := range defaultStrip { + headersToStrip[strings.ToLower(h)] = true } -} -// acquireLock creates a lock file with secure permissions -func acquireLock() bool { - lockDir := "/var/lock/mail2news" - if err := os.MkdirAll(lockDir, 0700); err != nil { - lockDir = os.TempDir() + // Load from config strip_list + for _, h := range config.Headers.StripList { + headersToStrip[strings.ToLower(strings.TrimSpace(h))] = true } - - lockFilePath = filepath.Join(lockDir, "mail2news.lock") - - file, err := os.OpenFile(lockFilePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) - if err != nil { - if os.IsExist(err) { - existingFile, err := os.OpenFile(lockFilePath, os.O_RDWR, 0600) - if err != nil { - return false - } - - if err := syscall.Flock(int(existingFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - existingFile.Close() - return false + + // Load from file if specified + stripFile := config.Headers.StripFile + if stripFile == "" { + stripFile = "/etc/mail2news/headers_strip" + } + + if data, err := os.ReadFile(stripFile); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + headersToStrip[strings.ToLower(line)] = true } - - lockFile = existingFile - lockFile.Truncate(0) - lockFile.Seek(0, 0) - - // Write minimal info (no PID for privacy) - timestamp := time.Now().Unix() - lockFile.WriteString(fmt.Sprintf("%d\n", timestamp)) - - return true } - return false - } - - lockFile = file - - if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - lockFile.Close() - os.Remove(lockFilePath) - return false + logMessage("Loaded additional headers to strip from file", "INFO") } - - timestamp := time.Now().Unix() - lockFile.WriteString(fmt.Sprintf("%d\n", timestamp)) - - return true + + logMessage("Headers strip list loaded", "INFO") } -func releaseLock() { - if lockFile != nil { - syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) - lockFile.Close() - lockFile = nil - os.Remove(lockFilePath) - } +func shouldStripHeader(headerName string) bool { + return headersToStrip[strings.ToLower(headerName)] } +// ============================================================================ +// LOGGING +// ============================================================================ + func initLogging() { - if config.Paths.Log == "" { - config.Paths.Log = "/var/log/mail2news/mail2news.log" - } - - logDir := filepath.Dir(config.Paths.Log) - if err := os.MkdirAll(logDir, 0700); err != nil { - log.SetOutput(os.Stdout) - log.SetFlags(log.Ldate | log.Ltime) - return + logPath := config.Paths.Log + if logPath == "" { + logPath = "/var/log/mail2news/mail2news.log" } - - logFile, err := os.OpenFile(config.Paths.Log, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + + os.MkdirAll(filepath.Dir(logPath), 0700) + + var err error + logFile, err = os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err != nil { - log.SetOutput(os.Stdout) - log.SetFlags(log.Ldate | log.Ltime) + log.Printf("Cannot open log file: %v", err) return } - - log.SetOutput(logFile) + + log.SetOutput(io.MultiWriter(os.Stderr, logFile)) log.SetFlags(log.Ldate | log.Ltime) } -func logMessage(message string, level string) { - logMutex.Lock() - defer logMutex.Unlock() - - if level == "" { - level = "INFO" - } - - configLevel := strings.ToUpper(config.Logging.Level) - if configLevel == "" { - configLevel = "WARNING" - } - - messageLevel := strings.ToUpper(level) - shouldLog := false - - switch configLevel { - case "DEBUG": - shouldLog = true - case "INFO": - shouldLog = messageLevel != "DEBUG" - case "WARNING": - shouldLog = messageLevel == "WARNING" || messageLevel == "ERROR" - case "ERROR": - shouldLog = messageLevel == "ERROR" - } - - if shouldLog { - // Sanitize message to prevent log injection - message = strings.ReplaceAll(message, "\n", " ") - message = strings.ReplaceAll(message, "\r", " ") - log.Printf("[%s] %s", messageLevel, message) - } +func logMessage(msg, level string) { + // Sanitize log message to prevent injection + msg = strings.ReplaceAll(msg, "\n", " ") + msg = strings.ReplaceAll(msg, "\r", " ") + log.Printf("[%s] %s", level, msg) } -func convertEncoding(input string, sourceEnc string, targetEnc string) (string, error) { - if strings.EqualFold(sourceEnc, targetEnc) { - return input, nil - } - - var enc encoding.Encoding - var err error - - switch strings.ToLower(sourceEnc) { - case "utf-8", "us-ascii": - return input, nil - case "iso-8859-1", "latin1": - enc = charmap.ISO8859_1 - case "iso-8859-2", "latin2": - enc = charmap.ISO8859_2 - case "iso-8859-15", "latin9": - enc = charmap.ISO8859_15 - case "windows-1252": - enc = charmap.Windows1252 - case "windows-1250": - enc = charmap.Windows1250 - case "koi8-r": - enc = charmap.KOI8R - default: - enc, err = htmlindex.Get(sourceEnc) - if err != nil { - fallback := config.Encoding.FallbackCharset - if fallback == "" { - fallback = "iso-8859-1" - } - enc, _ = htmlindex.Get(fallback) - } - } - - reader := transform.NewReader(strings.NewReader(input), enc.NewDecoder()) - result, err := io.ReadAll(reader) - if err != nil { - return input, err +// ============================================================================ +// RECIPIENT PARSING +// ============================================================================ + +func parseRecipient(user string) (stamp string, newsgroups string, nospam bool) { + if idx := strings.Index(user, "@"); idx != -1 { + user = user[:idx] } - - return string(result), nil -} -func decodeTransferEncoding(content string, enc string) (string, error) { - enc = strings.ToLower(strings.TrimSpace(enc)) - - switch enc { - case "quoted-printable": - reader := quotedprintable.NewReader(strings.NewReader(content)) - decoded, err := io.ReadAll(reader) - if err != nil { - return content, err - } - return string(decoded), nil - - case "base64": - content = regexp.MustCompile(`\s+`).ReplaceAllString(content, "") - decoded, err := base64.StdEncoding.DecodeString(content) - if err != nil { - return content, err - } - return string(decoded), nil - - case "7bit", "8bit", "binary", "": - return content, nil - - default: - return content, nil + // Classic format: mail2news[_nospam]-YYYYMMDD-newsgroup1=newsgroup2 + re := regexp.MustCompile(`^(mail2news|mail2news_nospam)-([0-9]{8})-(.+)$`) + matches := re.FindStringSubmatch(user) + + if matches == nil { + return "", "", false } + + nospam = (matches[1] == "mail2news_nospam") + stamp = matches[2] + newsgroups = strings.ReplaceAll(matches[3], "=", ",") + + return stamp, newsgroups, nospam } -func normalizeEmailFormat(message string) string { - // Detect encoding - contentTypeRegex := regexp.MustCompile(`(?i)Content-Type:[^\n]*charset=["']?([^"'\r\n;]+)`) - matches := contentTypeRegex.FindStringSubmatch(message) - - sourceEncoding := "utf-8" - if len(matches) > 1 { - sourceEncoding = strings.ToLower(matches[1]) - } - - // Find transfer encoding - transferEncodingRegex := regexp.MustCompile(`(?i)Content-Transfer-Encoding:\s*([^\r\n]+)`) - transferEncoding := "7bit" - if m := transferEncodingRegex.FindStringSubmatch(message); len(m) > 1 { - transferEncoding = strings.TrimSpace(m[1]) - } - - // Process header-body separation - headerEnd := strings.Index(message, "\r\n\r\n") - if headerEnd == -1 { - headerEnd = strings.Index(message, "\n\n") - } - - if headerEnd != -1 { - headers := message[:headerEnd] - body := message[headerEnd:] - - body = regexp.MustCompile(`^\r?\n\r?\n+`).ReplaceAllString(body, "\r\n\r\n") - - if len(body) > 4 { - decodedBody, err := decodeTransferEncoding(body[4:], transferEncoding) - if err == nil { - decodedBody = strings.TrimLeft(decodedBody, "\r\n \t") - body = "\r\n\r\n" + decodedBody - } - } - - message = headers + body - } - - // Convert to UTF-8 - if sourceEncoding != "utf-8" && sourceEncoding != "us-ascii" { - if converted, err := convertEncoding(message, sourceEncoding, "utf-8"); err == nil { - message = converted - } +func isSimpleRecipient(recipient string) (valid bool, nospam bool) { + local := recipient + if idx := strings.Index(recipient, "@"); idx != -1 { + local = recipient[:idx] } - - // Replace problematic Unicode characters - replacements := map[string]string{ - "\u2018": "'", "\u2019": "'", - "\u201C": "\"", "\u201D": "\"", - "\u2026": "...", - "\u2013": "-", "\u2014": "-", - } - for old, new := range replacements { - message = strings.ReplaceAll(message, old, new) - } - - // Ensure MIME-Version - mimeVersionRegex := regexp.MustCompile(`(?i)MIME-Version:.*\r?\n`) - message = mimeVersionRegex.ReplaceAllString(message, "") - - headerEnd = strings.Index(message, "\r\n\r\n") - if headerEnd == -1 { - headerEnd = strings.Index(message, "\n\n") - } - - if headerEnd != -1 { - message = message[:headerEnd] + "\r\nMIME-Version: 1.0\r\n" + message[headerEnd:] - } - - // Ensure Content-Type with UTF-8 - if !regexp.MustCompile(`(?i)Content-Type:`).MatchString(message) { - headerEnd := strings.Index(message, "\r\n\r\n") - if headerEnd == -1 { - headerEnd = strings.Index(message, "\n\n") - } - if headerEnd != -1 { - message = message[:headerEnd] + "\r\nContent-Type: text/plain; charset=utf-8\r\n" + message[headerEnd:] - } - } else { - message = regexp.MustCompile(`(?i)(Content-Type:[^\n]*charset=)["']?[^"'\r\n;]+`). - ReplaceAllString(message, "${1}utf-8") - } - - // Normalize line endings - message = strings.ReplaceAll(message, "\r\n", "\n") - message = strings.ReplaceAll(message, "\n", "\r\n") - - // Handle dot stuffing - lines := strings.Split(message, "\r\n") - for i, line := range lines { - if line == "." { - lines[i] = ".." - } else if strings.HasPrefix(line, ".") && line != ".." { - lines[i] = "." + line - } - } - - return strings.Join(lines, "\r\n") -} + local = strings.ToLower(local) -func parseRecipient(user string) (string, string, bool) { - if idx := strings.Index(user, "@"); idx != -1 { - user = user[:idx] + if local == "mail2news" { + return true, false } - - re := regexp.MustCompile(`(mail2news|mail2news_nospam)-([0-9]{8})-(.*)`) - matches := re.FindStringSubmatch(user) - - if matches == nil { - logMessage("Invalid recipient format", "ERROR") - os.Exit(ExitRejected) - } - - recipient := matches[1] - timestamp := matches[2] - newsgroups := strings.ReplaceAll(matches[3], "=", ",") - - nospam := recipient == "mail2news_nospam" - - return timestamp, newsgroups, nospam + if local == "mail2news_nospam" { + return true, true + } + return false, false } func validateStamp(stamp string) bool { layout := "20060102" parsedTime, err := time.Parse(layout, stamp) if err != nil { - logMessage("Invalid date format", "ERROR") - os.Exit(ExitRejected) + logMessage("Malformed date in recipient", "ERROR") + return false } - + now := time.Now().UTC() beforeTime := now.Add(-time.Duration(config.Thresholds.HoursPast) * time.Hour) afterTime := now.Add(time.Duration(config.Thresholds.HoursFuture) * time.Hour) - + if parsedTime.After(beforeTime) && parsedTime.Before(afterTime) { + logMessage("Timestamp valid", "INFO") return true } - - logMessage("Timestamp out of bounds", "ERROR") - os.Exit(ExitRejected) + + logMessage("Timestamp out of bounds", "WARNING") return false } +// ============================================================================ +// NEWSGROUP VALIDATION +// ============================================================================ + func ngvalidate(newsgroups string) string { newsgroups = strings.TrimRight(newsgroups, ",") groups := strings.Split(newsgroups, ",") - - var validGroups []string - seen := make(map[string]bool) - - re := regexp.MustCompile(`^[a-z][a-z0-9]+(\.[0-9a-z-+_]+)+$`) - + + var goodng []string + re := regexp.MustCompile(`^[a-z][a-z0-9]+(\.[0-9a-z\-+_]+)+$`) + for _, ng := range groups { - ng = strings.TrimSpace(ng) - - if !re.MatchString(ng) { - continue - } - - if seen[ng] { - continue + ng = strings.TrimSpace(strings.ToLower(ng)) + + if re.MatchString(ng) { + isDuplicate := false + for _, existing := range goodng { + if existing == ng { + isDuplicate = true + break + } + } + + if !isDuplicate { + goodng = append(goodng, ng) + } } - - seen[ng] = true - validGroups = append(validGroups, ng) - } - - if len(validGroups) == 0 { - logMessage("No valid newsgroups", "ERROR") - os.Exit(ExitRejected) - } - - if len(validGroups) > config.Thresholds.MaxCrossposts { - logMessage("Too many crossposts", "ERROR") - os.Exit(ExitRejected) - } - - return strings.Join(validGroups, ",") -} + } -func generateMessageID(domain string, bodyContent string) string { - if domain == "" { - domain = "anon.invalid" + if len(goodng) == 0 { + logMessage("No valid newsgroups found", "ERROR") + os.Exit(ExitReject) } - - now := time.Now().UTC() - - // Generate random component - randomBytes, _ := SecureRandom(16) - randomPart := fmt.Sprintf("%x", randomBytes)[:12] - - // Content hash for uniqueness - contentHash := sha256.Sum256([]byte(bodyContent)) - contentHashStr := fmt.Sprintf("%x", contentHash)[:8] - - leftPart := fmt.Sprintf("%s.%d.%s.%s", - now.Format("20060102150405"), - now.UnixNano()%1000000, - randomPart, - contentHashStr) - - return "<" + leftPart + "@" + domain + ">" -} -func blacklistCheck(badFile string, text string) string { - filename := filepath.Join(config.Paths.Etc, badFile) - badList := file2list(filename) - - if len(badList) == 0 { - return "" + if len(goodng) > config.Thresholds.MaxCrossposts { + logMessage("Crosspost limit exceeded", "ERROR") + os.Exit(ExitReject) } - - pattern := strings.Join(badList, "|") - re, err := regexp.Compile(pattern) + + return strings.Join(goodng, ",") +} + +// ============================================================================ +// FROM PARSING +// ============================================================================ + +func fromParse(from string) (name, addr string) { + address, err := mail.ParseAddress(from) if err != nil { - return "" + re := regexp.MustCompile(`<?([^\s<>]+@[^\s<>]+)>?`) + if matches := re.FindStringSubmatch(from); matches != nil { + return "", matches[1] + } + return from, "" } - - return re.FindString(text) + return address.Name, address.Address } -func file2list(filename string) []string { - var items []string - - if _, err := os.Stat(filename); os.IsNotExist(err) { - return items +func mungeFrom(from string) string { + name, addr := fromParse(from) + if addr == "" { + return from } - - file, err := os.Open(filename) - if err != nil { - return items - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - if idx := strings.Index(line, "#"); idx != -1 { - line = line[:idx] - } - line = strings.TrimSpace(line) - if line != "" { - items = append(items, line) - } + + obfuscated := strings.ReplaceAll(addr, "@", "<AT>") + obfuscated = strings.ReplaceAll(obfuscated, ".", "<DOT>") + + if name != "" { + return fmt.Sprintf("%s <%s>", name, obfuscated) } - - return items + return fmt.Sprintf("<%s>", obfuscated) } -func fromParse(fromHdr string) (string, string) { - var name, addy string - - // Pattern: "Name <user@example.com>" - if re := regexp.MustCompile(`([^<>]*)<([^<>\s]+@[^<>\s]+)>`); true { - if m := re.FindStringSubmatch(fromHdr); m != nil { - name = strings.TrimSpace(m[1]) - addy = m[2] - } +// ============================================================================ +// MESSAGE PARSING - PRESERVE HEADERS +// ============================================================================ + +func msgParse(rawMessage string) (string, string) { + SecureRandomDelay() + + msg, err := mail.ReadMessage(strings.NewReader(rawMessage)) + if err != nil { + logMessage(fmt.Sprintf("Parse error: %v", err), "ERROR") + os.Exit(ExitError) } - - // Pattern: "user@example.com (Name)" - if addy == "" { - if re := regexp.MustCompile(`([^<>\s]+@[^<>\s]+)\s+\(([^\(\)]*)\)`); true { - if m := re.FindStringSubmatch(fromHdr); m != nil { - addy = m[1] - name = strings.TrimSpace(m[2]) - } + + // Get recipient from available headers + recipient := "" + if to := msg.Header.Get("To"); to != "" { + recipient = to + } + if delivered := msg.Header.Get("Delivered-To"); delivered != "" { + if recipient == "" { + recipient = delivered } } - - // Pattern: "user@example.com" - if addy == "" { - if re := regexp.MustCompile(`([^<>\s]+@[^<>\s]+)`); true { - if m := re.FindStringSubmatch(fromHdr); m != nil { - addy = m[1] - } + if xOrigTo := msg.Header.Get("X-Original-To"); xOrigTo != "" { + if recipient == "" { + recipient = xOrigTo } } - - if addy != "" { - addy = strings.ReplaceAll(addy, ".", "<DOT>") - addy = strings.ReplaceAll(addy, "@", "<AT>") + + if recipient == "" { + logMessage("No recipient found", "ERROR") + os.Exit(ExitReject) } - - return name, addy -} -func minInt(a, b int) int { - if a < b { - return a + logMessage("Recipient accepted", "INFO") + + // Check if recipient is for us + recipientLower := strings.ToLower(recipient) + if !strings.Contains(recipientLower, "mail2news") { + logMessage("Recipient not matching gateway prefix", "ERROR") + os.Exit(ExitReject) } - return b -} -func msgParse(message string) (string, string) { - SecureRandomDelay() - - // Remove mailbox "From " lines - lines := strings.Split(message, "\n") - var cleanedLines []string - - for _, line := range lines { - if strings.HasPrefix(line, "From ") && !strings.Contains(line, ":") { - continue + // Determine format and get newsgroups + var newsgroups string + var nospam bool + + if isSimple, isNospam := isSimpleRecipient(recipient); isSimple { + logMessage("Simple recipient format detected", "INFO") + nospam = isNospam + + newsgroups = msg.Header.Get("Newsgroups") + if newsgroups == "" { + logMessage("Simple format requires Newsgroups: header", "ERROR") + os.Exit(ExitReject) + } + logMessage("Newsgroups found in header", "INFO") + } else { + if ng := msg.Header.Get("Newsgroups"); ng != "" { + newsgroups = ng + logMessage("Newsgroups found in header", "INFO") + nospam = strings.Contains(strings.ToLower(recipient), "nospam") + } else { + stamp, ng, ns := parseRecipient(recipient) + if stamp == "" || ng == "" { + logMessage("Invalid recipient format and no Newsgroups header", "ERROR") + os.Exit(ExitReject) + } + + if !validateStamp(stamp) { + logMessage("Invalid timestamp", "ERROR") + os.Exit(ExitReject) + } + + newsgroups = ng + nospam = ns } - cleanedLines = append(cleanedLines, line) - } - message = strings.Join(cleanedLines, "\n") - - message = normalizeEmailFormat(message) - - msg, err := mail.ReadMessage(strings.NewReader(message)) - if err != nil { - logMessage("Parse error", "ERROR") - os.Exit(ExitError) } - + + newsgroups = ngvalidate(newsgroups) + logMessage("Newsgroups validated", "INFO") + + // Get or generate Message-ID + // SECURITY: Always regenerate Message-ID for anonymity (ignore client's) + // Format: standard Postfix-like pattern (timestamp.random@domain.invalid) + randomBytes, _ := SecureRandom(8) + messageID := fmt.Sprintf("<%d.%s@%s>", + time.Now().Unix(), + strings.ToUpper(hex.EncodeToString(randomBytes)), + config.NNTP.MessageIDDomain) + logMessage("Regenerated Message-ID for anonymity", "INFO") + // Check for duplicate - messageId := msg.Header.Get("Message-ID") - if messageId != "" && messageCache != nil { - if messageCache.IsProcessed(messageId) { - logMessage("Duplicate message", "INFO") - os.Exit(ExitDuplicate) - } + if messageCache.Check(messageID) { + logMessage("Duplicate message detected", "WARNING") + os.Exit(ExitDuplicate) } - + messageCache.Add(messageID) + + // Read body body, err := io.ReadAll(msg.Body) if err != nil { - logMessage("Body read error", "ERROR") + logMessage(fmt.Sprintf("Body read error: %v", err), "ERROR") os.Exit(ExitError) } - - bodyContent := string(body) - if len(strings.TrimSpace(bodyContent)) == 0 { - bodyContent = "." - body = []byte(bodyContent) - } - - // Generate Message-ID if missing - if messageId == "" { - messageId = generateMessageID(config.NNTP.MessageID, bodyContent) - msg.Header["Message-ID"] = []string{messageId} - } - - // Ensure Date header - if msg.Header.Get("Date") == "" { - msg.Header["Date"] = []string{time.Now().UTC().Format(time.RFC1123Z)} - } - - // Process From header - fromHeader := msg.Header.Get("From") - if fromHeader != "" { - if match := blacklistCheck("bad_from", fromHeader); match != "" { - logMessage("Blacklisted sender", "ERROR") - os.Exit(ExitRejected) - } - } else { - msg.Header["From"] = []string{config.NNTP.DefaultFrom} - } - - // Handle References for threading - if refs := msg.Header.Get("References"); refs != "" { - if !strings.Contains(refs, "<") { - refs = "<" + refs - } - if !strings.Contains(refs, ">") { - refs = refs + ">" - } - msg.Header["References"] = []string{refs} - msg.Header["In-Reply-To"] = []string{refs} - } - - // Check poison headers - poisonFile := filepath.Join(config.Paths.Etc, "headers_poison") - poisonHeaders := file2list(poisonFile) - for _, header := range poisonHeaders { - if msg.Header.Get(header) != "" { - logMessage("Poison header detected", "ERROR") - os.Exit(ExitRejected) - } + + bodyStr := decodeBody(msg, body) + + // ======================================================================== + // BUILD HEADERS - Preserve original, strip unwanted, add required + // ======================================================================== + + var newHeaders strings.Builder + processedHeaders := make(map[string]bool) + + // Process From header (with optional nospam munging) + from := msg.Header.Get("From") + if from == "" { + from = config.NNTP.DefaultFrom } - - // Get recipient - var recipient string - if to := msg.Header.Get("X-Original-To"); to != "" { - recipient = to - } else if to := msg.Header.Get("To"); to != "" { - recipient = to - } else { - recipient = "mail2news@m2n.mixmin.net" - } - - if !strings.HasPrefix(recipient, "mail2news") { - logMessage("Invalid recipient", "ERROR") - os.Exit(ExitRejected) - } - - // Process newsgroups - nospam := false - var dest string - - if ng := msg.Header.Get("Newsgroups"); ng != "" { - dest = ng - delete(msg.Header, "Newsgroups") - - if strings.HasPrefix(recipient, "mail2news_nospam") { - nospam = true + if nospam { + from = mungeFrom(from) + logMessage("From address processed", "INFO") + } + newHeaders.WriteString(fmt.Sprintf("From: %s\r\n", from)) + processedHeaders["from"] = true + + // Newsgroups (validated) + newHeaders.WriteString(fmt.Sprintf("Newsgroups: %s\r\n", newsgroups)) + processedHeaders["newsgroups"] = true + + // Subject + subject := msg.Header.Get("Subject") + if subject == "" { + subject = "(no subject)" + } + newHeaders.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + processedHeaders["subject"] = true + + // Date + date := msg.Header.Get("Date") + if date == "" { + date = time.Now().UTC().Format(time.RFC1123Z) + } + newHeaders.WriteString(fmt.Sprintf("Date: %s\r\n", date)) + processedHeaders["date"] = true + + // Message-ID + newHeaders.WriteString(fmt.Sprintf("Message-ID: %s\r\n", messageID)) + processedHeaders["message-id"] = true + + // Path + newHeaders.WriteString(fmt.Sprintf("Path: %s\r\n", config.NNTP.PathHeader)) + processedHeaders["path"] = true + + // Strip all recipient-related headers - prevent routing metadata leak + processedHeaders["x-original-to"] = true + processedHeaders["to"] = true + processedHeaders["delivered-to"] = true + + // MIME headers - must reflect decoded body state + newHeaders.WriteString("MIME-Version: 1.0\r\n") + processedHeaders["mime-version"] = true + + // Determine correct Content-Type after charset conversion + originalCT := msg.Header.Get("Content-Type") + if originalCT != "" { + mediaType, params, err := mime.ParseMediaType(originalCT) + if err == nil && strings.HasPrefix(mediaType, "text/") { + charset := "" + if c, ok := params["charset"]; ok { + charset = strings.ToLower(c) + } + // If charset was converted to UTF-8, update it + if charset == "iso-8859-1" || charset == "iso-8859-15" || charset == "windows-1252" || charset == "" { + newHeaders.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + } else if charset == "utf-8" || charset == "us-ascii" { + newHeaders.WriteString(fmt.Sprintf("Content-Type: %s; charset=%s\r\n", mediaType, charset)) + } else { + newHeaders.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + } + } else { + // Non-text content type, preserve as-is + newHeaders.WriteString(fmt.Sprintf("Content-Type: %s\r\n", originalCT)) } } else { - var stamp string - stamp, dest, nospam = parseRecipient(recipient) - - if !validateStamp(stamp) { - logMessage("Invalid timestamp", "ERROR") - os.Exit(ExitRejected) - } + newHeaders.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") } - - validatedGroups := ngvalidate(dest) - msg.Header["Newsgroups"] = []string{validatedGroups} - - if match := blacklistCheck("bad_groups", validatedGroups); match != "" { - logMessage("Blacklisted newsgroup", "ERROR") - os.Exit(ExitRejected) - } - - // Handle nospam mode - if nospam { - name, addy := fromParse(msg.Header.Get("From")) - if addy != "" { - delete(msg.Header, "Author-Supplied-Address") - delete(msg.Header, "From") - msg.Header["Author-Supplied-Address"] = []string{addy} - msg.Header["From"] = []string{name + "<Use-Author-Supplied-Address-Header@[127.1]>"} + processedHeaders["content-type"] = true + + // Body was decoded from base64/qp, so transfer encoding is now 8bit + newHeaders.WriteString("Content-Transfer-Encoding: 8bit\r\n") + processedHeaders["content-transfer-encoding"] = true + + // X-No-Archive + if config.Headers.AddNoArchive { + if existing := msg.Header.Get("X-No-Archive"); existing == "" { + newHeaders.WriteString("X-No-Archive: Yes\r\n") } } - - // Ensure Subject - if msg.Header.Get("Subject") == "" { - msg.Header["Subject"] = []string{"(none)"} - } - - // Strip headers - stripFile := filepath.Join(config.Paths.Etc, "headers_strip") - stripHeaders := file2list(stripFile) - - preserveHeaders := map[string]bool{ - "X-Hashcash": true, - "X-Ed25519-Pub": true, - "X-Ed25519-Sig": true, - } - - for _, header := range stripHeaders { - if !preserveHeaders[header] { - delete(msg.Header, header) + processedHeaders["x-no-archive"] = true + + // X-Gateway-Info + if config.Headers.AddGateway { + gatewayInfo := config.Headers.GatewayInfo + if gatewayInfo == "" { + gatewayInfo = "mail2news" } + newHeaders.WriteString(fmt.Sprintf("X-Gateway-Info: %s\r\n", gatewayInfo)) } - - // Strip all metadata if privacy mode enabled - if config.Privacy.StripAllMetadata { - metadataHeaders := []string{ - "Received", "X-Originating-IP", "X-Mailer", - "User-Agent", "X-MimeOLE", "X-Priority", - } - for _, h := range metadataHeaders { - delete(msg.Header, h) - } + + // Now process ALL remaining original headers, preserving them unless stripped + // Get all header keys and sort them for consistent output + var headerKeys []string + for key := range msg.Header { + headerKeys = append(headerKeys, key) } - - // Add gateway headers - msg.Header["Path"] = []string{config.NNTP.Path} - msg.Header["Organization"] = []string{"Anonymous Gateway"} - msg.Header["X-Gateway-Info"] = []string{config.NNTP.InjectionHost} - - delete(msg.Header, "User-Agent") - msg.Header["User-Agent"] = []string{fmt.Sprintf("mail2news v%s", VERSION)} - - delete(msg.Header, "MIME-Version") - delete(msg.Header, "Mime-Version") - msg.Header["MIME-Version"] = []string{"1.0"} - - // Build message - var txtMsg strings.Builder - - for k, vv := range msg.Header { - for _, v := range vv { - v = strings.TrimSpace(v) - txtMsg.WriteString(k + ": " + v + "\r\n") + sort.Strings(headerKeys) + + for _, key := range headerKeys { + keyLower := strings.ToLower(key) + + // Skip if already processed + if processedHeaders[keyLower] { + continue } - } - - txtMsg.WriteString("\r\n") - - bodyStr := string(body) - bodyStr = strings.TrimLeft(bodyStr, "\r\n \t") - bodyStr = strings.ReplaceAll(bodyStr, "\r\n", "\n") - bodyStr = strings.ReplaceAll(bodyStr, "\n", "\r\n") - - // Dot stuffing - bodyLines := strings.Split(bodyStr, "\r\n") - for i, line := range bodyLines { - if line == "." { - bodyLines[i] = ".." - } else if strings.HasPrefix(line, ".") && line != ".." { - bodyLines[i] = "." + line + + // Skip if in strip list + if shouldStripHeader(key) { + continue } - } - bodyStr = strings.Join(bodyLines, "\r\n") - - txtMsg.WriteString(bodyStr) - - if !strings.HasSuffix(txtMsg.String(), "\r\n") { - txtMsg.WriteString("\r\n") - } - - result := txtMsg.String() - - // Add adaptive padding if enabled - if config.Privacy.EnablePadding { - padding := GenerateAdaptivePadding(len(result)) - if padding != nil { - result = result + string(padding) + + // Preserve this header + values := msg.Header[key] + for _, value := range values { + newHeaders.WriteString(fmt.Sprintf("%s: %s\r\n", key, value)) } + processedHeaders[keyLower] = true } - - size := len(result) - if size > config.Thresholds.MaxBytes { - logMessage("Message too large", "ERROR") - os.Exit(ExitRejected) - } - - if messageCache != nil && messageId != "" { - messageCache.MarkProcessed(messageId) - } - - return messageId, result + + logMessage("Headers processed", "INFO") + + // End headers + newHeaders.WriteString("\r\n") + + // Combine headers and body + fullMessage := newHeaders.String() + bodyStr + + return messageID, fullMessage } -func newsSend(mid string, content string) { - SecureRandomDelay() - - torProxyAddr := "127.0.0.1:9050" - if config.NNTP.TorProxy != "" { - torProxyAddr = config.NNTP.TorProxy - } - - torDialer, err := proxy.SOCKS5("tcp", torProxyAddr, nil, proxy.Direct) - if err != nil { - logMessage("Tor dialer error", "ERROR") - return - } - - successfulDelivery := false - attemptedHosts := make(map[string]bool) - - // Build server list - var serverList []string - - // Add onion servers - if config.NNTP.PrimaryOnion != "" { - server := config.NNTP.PrimaryOnion - if !strings.Contains(server, ":") { - server += ":119" - } - serverList = append(serverList, server) - } - - for _, server := range config.NNTP.OnionServers { - if !strings.Contains(server, ":") { - server += ":119" - } - serverList = append(serverList, server) - } - - // Add fallback - if config.NNTP.FallbackServer != "" { - server := config.NNTP.FallbackServer - if !strings.Contains(server, ":") { - server += ":119" +func decodeBody(msg *mail.Message, body []byte) string { + contentType := msg.Header.Get("Content-Type") + encoding := msg.Header.Get("Content-Transfer-Encoding") + + var decoded []byte + + switch strings.ToLower(encoding) { + case "quoted-printable": + reader := quotedprintable.NewReader(bytes.NewReader(body)) + decoded, _ = io.ReadAll(reader) + case "base64": + decoded, _ = base64.StdEncoding.DecodeString(string(body)) + default: + decoded = body + } + + // Handle charset conversion + if contentType != "" { + mediaType, params, err := mime.ParseMediaType(contentType) + if err == nil && strings.HasPrefix(mediaType, "text/") { + if charset, ok := params["charset"]; ok { + charset = strings.ToLower(charset) + if charset == "iso-8859-1" || charset == "iso-8859-15" || charset == "windows-1252" { + decoder := charmap.ISO8859_1.NewDecoder() + result, err := decoder.Bytes(decoded) + if err == nil { + decoded = result + } + } + } } - serverList = append(serverList, server) } - - // Add clearnet servers - for _, server := range config.NNTP.ClearnetServers { - if !strings.Contains(server, ":") { - server += ":119" - } - serverList = append(serverList, server) - } - - // Add from nntphosts file - hostFile := filepath.Join(config.Paths.Etc, "nntphosts") - hosts := file2list(hostFile) - for _, host := range hosts { - if !strings.Contains(host, ":") { - host += ":119" - } - serverList = append(serverList, host) - } - - // Remove duplicates - seen := make(map[string]bool) - var uniqueServers []string - for _, server := range serverList { - if !seen[server] { - seen[server] = true - uniqueServers = append(uniqueServers, server) + + return string(decoded) +} + +// ============================================================================ +// NNTP SENDING +// ============================================================================ + +func newsSend(messageID, payload string) { + SecureRandomDelay() + + servers := make([]string, len(config.NNTP.OnionServers)) + copy(servers, config.NNTP.OnionServers) + + if config.Privacy.RandomizeOrder && len(servers) > 1 { + for i := len(servers) - 1; i > 0; i-- { + j := SecureRandomInt(i + 1) + servers[i], servers[j] = servers[j], servers[i] } } - - for _, host := range uniqueServers { - if attemptedHosts[host] { - continue - } - - attemptedHosts[host] = true - SecureRandomDelay() - - logMessage(fmt.Sprintf("Attempting delivery to %s", host), "INFO") - - err, isDuplicate := deliverViaTor(torDialer, host, mid, content) - - if err == nil { - logMessage(fmt.Sprintf("Delivered to %s", host), "INFO") - successfulDelivery = true - break - } else if isDuplicate { - logMessage(fmt.Sprintf("Already exists on %s", host), "INFO") - successfulDelivery = true + + for i, server := range servers { + if err := postToServer(server, payload, true); err == nil { + logMessage(fmt.Sprintf("Posted successfully (server %d)", i+1), "INFO") + return } else { - logMessage(fmt.Sprintf("Delivery failed to %s", host), "WARNING") + logMessage(fmt.Sprintf("Server %d failed", i+1), "WARNING") } } - - if successfulDelivery { - logMessage("Message delivered", "INFO") - } else { - logMessage("All deliveries failed", "WARNING") + + // Try clearnet fallback if configured + if len(config.NNTP.ClearnetServers) > 0 && !config.NNTP.AlwaysUseTor { + for i, server := range config.NNTP.ClearnetServers { + if err := postToServer(server, payload, false); err == nil { + logMessage(fmt.Sprintf("Posted successfully (clearnet %d)", i+1), "INFO") + return + } else { + logMessage(fmt.Sprintf("Clearnet server %d failed", i+1), "WARNING") + } + } } + + logMessage("All servers failed", "ERROR") + os.Exit(ExitError) } -func deliverViaTor(torDialer proxy.Dialer, host string, messageID string, content string) (error, bool) { - baseTimeout := config.Thresholds.SocketTimeout - if baseTimeout == 0 { - baseTimeout = 120 - } - - timeout := baseTimeout - if strings.Contains(host, ".onion") { - timeout = baseTimeout * 3 - } - - timeoutDialer := &net.Dialer{ - Timeout: time.Duration(timeout) * time.Second, - } - +func postToServer(server, payload string, useTor bool) error { var conn net.Conn var err error - - if strings.Contains(host, ".onion") || config.NNTP.AlwaysUseTor { - conn, err = torDialer.Dial("tcp", host) - } else { - conn, err = timeoutDialer.Dial("tcp", host) + + timeout := time.Duration(config.Thresholds.SocketTimeout) * time.Second + if timeout == 0 { + timeout = 60 * time.Second } - - if err != nil { - return fmt.Errorf("connection error"), false + + if strings.Contains(server, ".onion") || config.NNTP.AlwaysUseTor || useTor { + torProxy := config.NNTP.TorProxy + if torProxy == "" { + torProxy = "127.0.0.1:9050" + } + + dialer, err := proxy.SOCKS5("tcp", torProxy, nil, proxy.Direct) + if err != nil { + return fmt.Errorf("tor proxy error: %w", err) + } + + conn, err = dialer.Dial("tcp", server) + if err != nil { + return fmt.Errorf("tor connect error: %w", err) + } + } else { + conn, err = net.DialTimeout("tcp", server, timeout) + if err != nil { + return fmt.Errorf("connect error: %w", err) + } } defer conn.Close() - - deadline := time.Now().Add(time.Duration(timeout) * time.Second) - conn.SetDeadline(deadline) - + + conn.SetDeadline(time.Now().Add(timeout)) + reader := bufio.NewReader(conn) - writer := bufio.NewWriter(conn) - + tp := textproto.NewReader(reader) + // Read greeting - resp, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("greeting error"), false - } - - if !strings.HasPrefix(resp, "200 ") && !strings.HasPrefix(resp, "201 ") { - return fmt.Errorf("bad greeting"), false - } - - // MODE READER for onion servers - if strings.Contains(host, ".onion") { - writer.WriteString("MODE READER\r\n") - writer.Flush() - - resp, err = reader.ReadString('\n') - if err != nil { - return fmt.Errorf("mode reader error"), false - } + code, _, err := tp.ReadCodeLine(200) + if err != nil || code != 200 { + return fmt.Errorf("greeting error: %d %v", code, err) } - - // IHAVE - writer.WriteString(fmt.Sprintf("IHAVE %s\r\n", messageID)) - writer.Flush() - - resp, err = reader.ReadString('\n') - if err != nil { - return fmt.Errorf("ihave error"), false - } - - if strings.HasPrefix(resp, "335 ") { - // Strip padding before sending - cleanContent := StripPadding(content) - - writer.WriteString(cleanContent + "\r\n.\r\n") - writer.Flush() - - resp, err = reader.ReadString('\n') - if err != nil { - return fmt.Errorf("article error"), false - } - - if strings.HasPrefix(resp, "235 ") { - writer.WriteString("QUIT\r\n") - writer.Flush() - return nil, false + + // MODE READER + fmt.Fprintf(conn, "MODE READER\r\n") + tp.ReadCodeLine(200) + + // POST + fmt.Fprintf(conn, "POST\r\n") + code, _, err = tp.ReadCodeLine(340) + if err != nil || code != 340 { + return fmt.Errorf("POST rejected: %d %v", code, err) + } + + // Send message + lines := strings.Split(payload, "\n") + for _, line := range lines { + line = strings.TrimRight(line, "\r") + if strings.HasPrefix(line, ".") { + line = "." + line } - - writer.WriteString("QUIT\r\n") - writer.Flush() - return fmt.Errorf("article rejected"), false - - } else if strings.HasPrefix(resp, "435 ") { - writer.WriteString("QUIT\r\n") - writer.Flush() - return nil, true - - } else if strings.HasPrefix(resp, "436 ") { - writer.WriteString("QUIT\r\n") - writer.Flush() - return fmt.Errorf("transfer not possible"), false - } - - writer.WriteString("QUIT\r\n") - writer.Flush() - return fmt.Errorf("ihave not accepted"), false + fmt.Fprintf(conn, "%s\r\n", line) + } + fmt.Fprintf(conn, ".\r\n") + + // Read response + code, nntpMsg, err := tp.ReadCodeLine(240) + if err != nil || code != 240 { + return fmt.Errorf("post failed: %d %s %v", code, nntpMsg, err) + } + + // QUIT + fmt.Fprintf(conn, "QUIT\r\n") + + return nil } +// ============================================================================ +// MAIN +// ============================================================================ + func main() { - fmt.Printf("Mail2News v%s - Privacy Enhanced\n", VERSION) - - if !acquireLock() { - fmt.Println("Another instance running") - os.Exit(ExitSuccess) - } - defer releaseLock() - - // Initialize config + + // Load config viper.SetConfigName("config") viper.SetConfigType("yaml") viper.AddConfigPath("/etc/mail2news") - viper.AddConfigPath("$HOME/.mail2news") viper.AddConfigPath(".") - + if err := viper.ReadInConfig(); err != nil { log.Fatalf("Config error: %s", err) } - + if err := viper.Unmarshal(&config); err != nil { log.Fatalf("Config parse error: %s", err) } - + // Set defaults if config.Paths.Log == "" { config.Paths.Log = "/var/log/mail2news/mail2news.log" } - if config.Logging.Level == "" { - config.Logging.Level = "WARNING" + if config.Thresholds.MaxCrossposts == 0 { + config.Thresholds.MaxCrossposts = 3 + } + if config.Thresholds.HoursPast == 0 { + config.Thresholds.HoursPast = 48 + } + if config.Thresholds.HoursFuture == 0 { + config.Thresholds.HoursFuture = 24 + } + if config.NNTP.PathHeader == "" { + config.NNTP.PathHeader = "not-for-mail" } - if config.Encoding.FallbackCharset == "" { - config.Encoding.FallbackCharset = "iso-8859-1" + if config.NNTP.MessageIDDomain == "" { + config.NNTP.MessageIDDomain = "mail2news.local" } - if config.Thresholds.MaxBytes == 0 { - config.Thresholds.MaxBytes = MaxMessageSize + if config.NNTP.DefaultFrom == "" { + config.NNTP.DefaultFrom = "Anonymous <nobody@anonymous.invalid>" } - + // Enable privacy features by default - if !config.Privacy.EnablePadding && !config.Privacy.EnableDelays { - config.Privacy.EnablePadding = true + if !config.Privacy.EnableDelays { config.Privacy.EnableDelays = true - config.Privacy.StripAllMetadata = true + config.Privacy.RandomizeOrder = true + } + + // Enable gateway headers by default + if !config.Headers.AddNoArchive { + config.Headers.AddNoArchive = true + } + if !config.Headers.AddGateway { + config.Headers.AddGateway = true } - + initLogging() - + loadHeadersToStrip() + messageCache = NewMessageIDCache(CacheExpiration, "/var/lib/mail2news/cache") - - logMessage(fmt.Sprintf("Mail2news v%s initialized", VERSION), "INFO") - fmt.Println("Enter message (Ctrl-D to finish):") - + + logMessage("Gateway initialized", "INFO") + + // Read message from stdin message, err := io.ReadAll(os.Stdin) if err != nil { logMessage("Input error", "ERROR") os.Exit(ExitError) } - + + if len(message) > config.Thresholds.MaxBytes && config.Thresholds.MaxBytes > 0 { + logMessage("Message exceeds size limit", "ERROR") + os.Exit(ExitReject) + } + mid, payload := msgParse(string(message)) newsSend(mid, payload) - + // Secure cleanup SecureZeroMemory(message) - - logMessage("Completed", "INFO") + + logMessage("Completed successfully", "INFO") } |
