summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGab <24553253+gabrix73@users.noreply.github.com>2026-06-29 14:22:15 +0200
committerGab <24553253+gabrix73@users.noreply.github.com>2026-06-29 14:22:15 +0200
commit404b81223688be027629f60ec5b1a00203da1768 (patch)
treec935d41aee7c65d497a13fc8a9f80a57738d2f62
parent2e2245abf290121b507b43ee37f4596ff3eeea7d (diff)
downloadm2usenet-and-mail2news-404b81223688be027629f60ec5b1a00203da1768.tar.gz
m2usenet-and-mail2news-404b81223688be027629f60ec5b1a00203da1768.tar.xz
m2usenet-and-mail2news-404b81223688be027629f60ec5b1a00203da1768.zip
Sync mail2news.go with running gateway (header passthrough for Face/Ed25519)HEADmain
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.go1749
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")
}