summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGab <24553253+gabrix73@users.noreply.github.com>2025-10-09 13:44:59 +0200
committerGitHub <noreply@github.com>2025-10-09 13:44:59 +0200
commit553942f60b0395aa42313401f881b20826dd8dfd (patch)
tree522ce31a1e396bc3d2680567e5aef22d92a0b93f
parent755aa84e49ab4fe19a8643a7193bf29d051a94ba (diff)
downloadfog-553942f60b0395aa42313401f881b20826dd8dfd.tar.gz
fog-553942f60b0395aa42313401f881b20826dd8dfd.tar.xz
fog-553942f60b0395aa42313401f881b20826dd8dfd.zip
Create pluto2.go
-rw-r--r--pluto2.go851
1 files changed, 851 insertions, 0 deletions
diff --git a/pluto2.go b/pluto2.go
new file mode 100644
index 0000000..137c8e0
--- /dev/null
+++ b/pluto2.go
@@ -0,0 +1,851 @@
+package main
+
+import (
+ "bytes"
+ "crypto/rand"
+ "crypto/sha512"
+ "encoding/base64"
+ "encoding/binary"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "math"
+ "math/big"
+ "net"
+ "net/smtp"
+ "net/textproto"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "golang.org/x/net/proxy"
+)
+
+const (
+ TorSocksProxyAddr = "127.0.0.1:9050"
+ RelayWorkerCount = 5
+ DeliveryTimeout = 30 * time.Second
+ MixnetBatchWindow = 30 * time.Second
+ CoverTrafficInterval = 15 * time.Second
+ MessageIDCacheDuration = 24 * time.Hour
+ PaddingSizeUnit = 8 * 1024
+ MinDelay = 100 * time.Millisecond
+ MaxDelay = 2 * time.Second
+ RateLimitPerIP = 10
+ RateLimitWindow = 1 * time.Minute
+ MXLookupTimeout = 5 * time.Second
+)
+
+var (
+ emailRegExp *regexp.Regexp
+ localPartRegex *regexp.Regexp
+ domainRegex *regexp.Regexp
+)
+
+func init() {
+ emailRegExp = regexp.MustCompile(`^[a-zA-Z0-9._%+=\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
+ localPartRegex = regexp.MustCompile(`^[a-zA-Z0-9._+=\-]+$`)
+ domainRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
+}
+
+func validateLocalPart(localPart string) error {
+ if len(localPart) == 0 || len(localPart) > 64 {
+ return errors.New("local part must be 1-64 characters")
+ }
+ if strings.HasPrefix(localPart, ".") || strings.HasSuffix(localPart, ".") {
+ return errors.New("local part cannot start or end with a dot")
+ }
+ if strings.Contains(localPart, "..") {
+ return errors.New("local part cannot contain consecutive dots")
+ }
+ if !localPartRegex.MatchString(localPart) {
+ return errors.New("local part contains invalid characters")
+ }
+ return nil
+}
+
+func validateDomain(domain string) error {
+ domain = strings.ToLower(strings.TrimSpace(domain))
+ if strings.HasSuffix(domain, ".") {
+ domain = domain[:len(domain)-1]
+ }
+ if len(domain) == 0 || len(domain) > 253 {
+ return errors.New("domain must be 1-253 characters")
+ }
+ if isOnionDomain(domain) {
+ onionName := strings.TrimSuffix(domain, ".onion")
+ if len(onionName) != 16 && len(onionName) != 56 {
+ return errors.New("invalid .onion domain length")
+ }
+ validOnion := regexp.MustCompile(`^[a-z2-7]+$`)
+ if !validOnion.MatchString(onionName) {
+ return errors.New("invalid .onion domain format")
+ }
+ return nil
+ }
+ if !domainRegex.MatchString(domain) {
+ return errors.New("invalid domain format")
+ }
+ labels := strings.Split(domain, ".")
+ if len(labels) < 2 {
+ return errors.New("domain must have at least two labels")
+ }
+ for _, label := range labels {
+ if len(label) == 0 || len(label) > 63 {
+ return errors.New("domain label must be 1-63 characters")
+ }
+ }
+ return nil
+}
+
+func ValidateEmailAddress(address string) (string, string, error) {
+ address = strings.TrimSpace(address)
+ address = strings.Trim(address, "<>")
+ if !strings.Contains(address, "@") {
+ return "", "", errors.New("invalid email address: missing '@'")
+ }
+ sepInd := strings.LastIndex(address, "@")
+ if sepInd == 0 || sepInd == len(address)-1 {
+ return "", "", errors.New("invalid email address: empty local part or domain")
+ }
+ localPart := address[:sepInd]
+ domain := address[sepInd+1:]
+ if err := validateLocalPart(localPart); err != nil {
+ return "", "", fmt.Errorf("invalid local part: %w", err)
+ }
+ if err := validateDomain(domain); err != nil {
+ return "", "", fmt.Errorf("invalid domain: %w", err)
+ }
+ return localPart, domain, nil
+}
+
+func validateServerName(name string) error {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return errors.New("server name cannot be empty")
+ }
+ if !isOnionDomain(name) {
+ return errors.New("server name must be a .onion domain")
+ }
+ onionName := strings.TrimSuffix(strings.ToLower(name), ".onion")
+ if len(onionName) != 56 {
+ return errors.New("server name must be a v3 .onion address (56 characters)")
+ }
+ validOnionV3 := regexp.MustCompile(`^[a-z2-7]{56}$`)
+ if !validOnionV3.MatchString(onionName) {
+ return errors.New("invalid v3 .onion address format")
+ }
+ return nil
+}
+
+func hasMXRecords(domain string) bool {
+ domain = strings.ToLower(strings.TrimSpace(domain))
+ if strings.HasSuffix(domain, ".") {
+ domain = domain[:len(domain)-1]
+ }
+ done := make(chan bool, 1)
+ var hasMX bool
+ go func() {
+ mxRecords, err := net.LookupMX(domain)
+ hasMX = err == nil && len(mxRecords) > 0
+ done <- true
+ }()
+ select {
+ case <-done:
+ return hasMX
+ case <-time.After(MXLookupTimeout):
+ return false
+ }
+}
+
+type MessageIDCache struct {
+ cache map[string]time.Time
+ mu sync.RWMutex
+}
+
+func NewMessageIDCache() *MessageIDCache {
+ cache := &MessageIDCache{cache: make(map[string]time.Time)}
+ go cache.cleanupLoop()
+ return cache
+}
+
+func (c *MessageIDCache) Has(messageID string) bool {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ expiry, exists := c.cache[messageID]
+ if !exists {
+ return false
+ }
+ return time.Now().Before(expiry)
+}
+
+func (c *MessageIDCache) Add(messageID string) bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if _, exists := c.cache[messageID]; exists {
+ return false
+ }
+ c.cache[messageID] = time.Now().Add(MessageIDCacheDuration)
+ return true
+}
+
+func (c *MessageIDCache) cleanupLoop() {
+ ticker := time.NewTicker(1 * time.Hour)
+ defer ticker.Stop()
+ for range ticker.C {
+ c.mu.Lock()
+ now := time.Now()
+ for id, expiry := range c.cache {
+ if now.After(expiry) {
+ delete(c.cache, id)
+ }
+ }
+ c.mu.Unlock()
+ }
+}
+
+type RateLimiter struct {
+ requests map[string][]time.Time
+ mu sync.RWMutex
+}
+
+func NewRateLimiter() *RateLimiter {
+ limiter := &RateLimiter{requests: make(map[string][]time.Time)}
+ go limiter.cleanupLoop()
+ return limiter
+}
+
+func (rl *RateLimiter) Allow(clientIP string) bool {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ now := time.Now()
+ cutoff := now.Add(-RateLimitWindow)
+ requests := rl.requests[clientIP]
+ var validRequests []time.Time
+ for _, t := range requests {
+ if t.After(cutoff) {
+ validRequests = append(validRequests, t)
+ }
+ }
+ if len(validRequests) >= RateLimitPerIP {
+ rl.requests[clientIP] = validRequests
+ return false
+ }
+ validRequests = append(validRequests, now)
+ rl.requests[clientIP] = validRequests
+ return true
+}
+
+func (rl *RateLimiter) cleanupLoop() {
+ ticker := time.NewTicker(5 * time.Minute)
+ defer ticker.Stop()
+ for range ticker.C {
+ rl.mu.Lock()
+ now := time.Now()
+ cutoff := now.Add(-RateLimitWindow)
+ for ip, requests := range rl.requests {
+ var validRequests []time.Time
+ for _, t := range requests {
+ if t.After(cutoff) {
+ validRequests = append(validRequests, t)
+ }
+ }
+ if len(validRequests) == 0 {
+ delete(rl.requests, ip)
+ } else {
+ rl.requests[ip] = validRequests
+ }
+ }
+ rl.mu.Unlock()
+ }
+}
+
+func ApplyAdaptivePadding(data []byte) []byte {
+ originalLen := len(data)
+ targetSize := ((originalLen / PaddingSizeUnit) + 1) * PaddingSizeUnit
+ if originalLen >= targetSize {
+ return data
+ }
+ padded := make([]byte, targetSize)
+ copy(padded, data)
+ paddingLen := targetSize - originalLen
+ padding := make([]byte, paddingLen)
+ rand.Read(padding)
+ copy(padded[originalLen:], padding)
+ return padded
+}
+
+func RandomDelay() {
+ delayNs := MinDelay.Nanoseconds() + int64(cryptoRandInt63n(MaxDelay.Nanoseconds()-MinDelay.Nanoseconds()))
+ time.Sleep(time.Duration(delayNs))
+}
+
+func cryptoRandInt63n(n int64) int64 {
+ if n <= 0 {
+ return 0
+ }
+ max := big.NewInt(n)
+ result, _ := rand.Int(rand.Reader, max)
+ return result.Int64()
+}
+
+type MixnetBatcher struct {
+ batch []*Envelope
+ batchMu sync.Mutex
+ outputQueue chan *Envelope
+ stopChan chan struct{}
+}
+
+func NewMixnetBatcher(outputQueue chan *Envelope) *MixnetBatcher {
+ batcher := &MixnetBatcher{
+ batch: make([]*Envelope, 0),
+ outputQueue: outputQueue,
+ stopChan: make(chan struct{}),
+ }
+ go batcher.batchLoop()
+ return batcher
+}
+
+func (mb *MixnetBatcher) Add(envelope *Envelope) {
+ mb.batchMu.Lock()
+ mb.batch = append(mb.batch, envelope)
+ mb.batchMu.Unlock()
+}
+
+func (mb *MixnetBatcher) batchLoop() {
+ ticker := time.NewTicker(MixnetBatchWindow)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ mb.processBatch()
+ case <-mb.stopChan:
+ return
+ }
+ }
+}
+
+func (mb *MixnetBatcher) processBatch() {
+ mb.batchMu.Lock()
+ if len(mb.batch) == 0 {
+ mb.batchMu.Unlock()
+ return
+ }
+ batch := make([]*Envelope, len(mb.batch))
+ copy(batch, mb.batch)
+ mb.batch = make([]*Envelope, 0)
+ mb.batchMu.Unlock()
+ shuffleBatch(batch)
+ for _, env := range batch {
+ RandomDelay()
+ select {
+ case mb.outputQueue <- env:
+ default:
+ }
+ }
+}
+
+func shuffleBatch(batch []*Envelope) {
+ for i := len(batch) - 1; i > 0; i-- {
+ j := int(cryptoRandInt63n(int64(i + 1)))
+ batch[i], batch[j] = batch[j], batch[i]
+ }
+}
+
+func (mb *MixnetBatcher) Stop() {
+ close(mb.stopChan)
+}
+
+type CoverTrafficGenerator struct {
+ outputQueue chan *Envelope
+ stopChan chan struct{}
+}
+
+func NewCoverTrafficGenerator(outputQueue chan *Envelope) *CoverTrafficGenerator {
+ gen := &CoverTrafficGenerator{
+ outputQueue: outputQueue,
+ stopChan: make(chan struct{}),
+ }
+ go gen.generateLoop()
+ return gen
+}
+
+func (ctg *CoverTrafficGenerator) generateLoop() {
+ ticker := time.NewTicker(CoverTrafficInterval)
+ defer ticker.Stop()
+ firstNames := []string{"john", "mary", "david", "sarah", "mike", "emma", "james", "lisa", "robert", "anna"}
+ lastNames := []string{"smith", "johnson", "williams", "brown", "jones", "garcia", "miller", "davis", "rodriguez", "martinez"}
+ for {
+ select {
+ case <-ticker.C:
+ firstName := firstNames[cryptoRandInt63n(int64(len(firstNames)))]
+ lastName := lastNames[cryptoRandInt63n(int64(len(lastNames)))]
+ randomNum := cryptoRandInt63n(9999)
+ var dummyAddr string
+ switch cryptoRandInt63n(3) {
+ case 0:
+ dummyAddr = fmt.Sprintf("%s.%s%d@gmail.com", firstName, lastName, randomNum)
+ case 1:
+ dummyAddr = fmt.Sprintf("%s%s%d@gmail.com", firstName, lastName, randomNum)
+ default:
+ dummyAddr = fmt.Sprintf("%s.%s@gmail.com", firstName, lastName)
+ }
+ dummyData := make([]byte, 1024+int(cryptoRandInt63n(2048)))
+ rand.Read(dummyData)
+ envelope := &Envelope{
+ MessageFrom: fmt.Sprintf("noreply%d@notifications.com", cryptoRandInt63n(999)),
+ MessageTo: dummyAddr,
+ MessageData: bytes.NewReader(dummyData),
+ ReceivedAt: time.Now(),
+ RetryCount: 0,
+ IsCoverTraffic: true,
+ }
+ select {
+ case ctg.outputQueue <- envelope:
+ default:
+ }
+ case <-ctg.stopChan:
+ return
+ }
+ }
+}
+
+func (ctg *CoverTrafficGenerator) Stop() {
+ close(ctg.stopChan)
+}
+
+type Server struct {
+ Name string
+ Addr string
+ Handler Handler
+ MessageIDCache *MessageIDCache
+ RateLimiter *RateLimiter
+ MixnetBatcher *MixnetBatcher
+ CoverTraffic *CoverTrafficGenerator
+}
+
+type conn struct {
+ remoteAddr string
+ server *Server
+ rwc net.Conn
+ text *textproto.Conn
+ fromAgent string
+ mailFrom string
+ mailTo []string
+ mailData *bytes.Buffer
+ helloReceived bool
+ quitSent bool
+ mu sync.Mutex
+}
+
+type Envelope struct {
+ MessageFrom string
+ MessageTo string
+ MessageData io.Reader
+ ReceivedAt time.Time
+ RetryCount int
+ MessageID string
+ IsCoverTraffic bool
+}
+
+type HandlerFunc func(envelope *Envelope) error
+
+func (f HandlerFunc) ServeSMTP(envelope *Envelope) error {
+ return f(envelope)
+}
+
+type Handler interface {
+ ServeSMTP(envelope *Envelope) error
+}
+
+var (
+ mailQueue chan *Envelope
+ mailQueueMutex sync.Mutex
+)
+
+func (srv *Server) newConn(rwc net.Conn) (*conn, error) {
+ c := &conn{
+ remoteAddr: rwc.RemoteAddr().String(),
+ server: srv,
+ rwc: rwc,
+ text: textproto.NewConn(rwc),
+ mailTo: make([]string, 0),
+ }
+ return c, nil
+}
+
+func (srv *Server) ListenAndServe() error {
+ if srv.Name == "" {
+ srv.Name = "localhost"
+ }
+ addr := srv.Addr
+ if addr == "" {
+ addr = ":2525"
+ }
+ ln, err := net.Listen("tcp", addr)
+ if err != nil {
+ return err
+ }
+ return srv.Serve(ln)
+}
+
+func (srv *Server) Serve(l net.Listener) error {
+ defer l.Close()
+ var tempDelay time.Duration
+ for {
+ rw, e := l.Accept()
+ if e != nil {
+ if ne, ok := e.(net.Error); ok && ne.Temporary() {
+ if tempDelay == 0 {
+ tempDelay = 5 * time.Millisecond
+ } else {
+ tempDelay *= 2
+ }
+ if max := 1 * time.Second; tempDelay > max {
+ tempDelay = max
+ }
+ time.Sleep(tempDelay)
+ continue
+ }
+ return e
+ }
+ tempDelay = 0
+ c, err := srv.newConn(rw)
+ if err != nil {
+ continue
+ }
+ go c.serve()
+ }
+}
+
+func (c *conn) serve() {
+ clientIP := extractIP(c.remoteAddr)
+ if !c.server.RateLimiter.Allow(clientIP) {
+ RandomDelay()
+ c.text.Close()
+ c.rwc.Close()
+ return
+ }
+ RandomDelay()
+ err := c.text.PrintfLine("%d %s ESMTP", 220, c.server.Name)
+ if err != nil {
+ return
+ }
+ for !c.quitSent && err == nil {
+ err = c.readCommand()
+ }
+ c.text.Close()
+ c.rwc.Close()
+}
+
+func extractIP(addr string) string {
+ host, _, err := net.SplitHostPort(addr)
+ if err != nil {
+ return addr
+ }
+ return host
+}
+
+func (c *conn) resetSession() {
+ c.mailFrom = ""
+ c.mailTo = make([]string, 0)
+ c.mailData = nil
+}
+
+func isOnionDomain(domain string) bool {
+ domain = strings.ToLower(strings.TrimSpace(domain))
+ if domain == "" {
+ return false
+ }
+ if strings.HasSuffix(domain, ".") {
+ domain = domain[:len(domain)-1]
+ }
+ return strings.HasSuffix(domain, ".onion")
+}
+
+func SplitAddress(address string) (string, string, error) {
+ localPart, domain, err := ValidateEmailAddress(address)
+ if err != nil {
+ return "", "", err
+ }
+ if !isOnionDomain(domain) {
+ return "", "", errors.New("only .onion domains are allowed for relay")
+ }
+ return localPart, domain, nil
+}
+
+func extractDomainFromAddress(address string) string {
+ sepInd := strings.LastIndex(address, "@")
+ if sepInd == -1 {
+ return ""
+ }
+ return address[sepInd+1:]
+}
+
+func (c *conn) readCommand() error {
+ s, err := c.text.ReadLine()
+ if err != nil {
+ return err
+ }
+ RandomDelay()
+ parts := strings.Split(s, " ")
+ if len(parts) <= 0 {
+ return c.text.PrintfLine("%d %s", 500, "Command not recognized")
+ }
+ parts[0] = strings.ToUpper(parts[0])
+ switch parts[0] {
+ case "HELO", "EHLO":
+ if len(parts) < 2 {
+ return c.text.PrintfLine("%d %s", 501, "Not enough arguments")
+ }
+ c.fromAgent = parts[1]
+ c.resetSession()
+ c.helloReceived = true
+ responses := []string{
+ fmt.Sprintf("%d-%s", 250, c.server.Name),
+ fmt.Sprintf("%d-%s", 250, "PIPELINING"),
+ fmt.Sprintf("%d %s", 250, "SMTPUTF8"),
+ }
+ for i, resp := range responses {
+ if i == len(responses)-1 {
+ resp = strings.Replace(resp, "-", " ", 1)
+ }
+ if err := c.text.PrintfLine(resp); err != nil {
+ return err
+ }
+ }
+ return nil
+ case "MAIL":
+ if c.mailFrom != "" {
+ return c.text.PrintfLine("%d %s", 503, "MAIL command already received")
+ }
+ if len(parts) < 2 {
+ return c.text.PrintfLine("%d %s", 501, "Not enough arguments")
+ }
+ if !strings.HasPrefix(strings.ToUpper(parts[1]), "FROM:") {
+ return c.text.PrintfLine("%d %s", 501, "MAIL command must be immediately succeeded by 'FROM:'")
+ }
+ from := strings.TrimPrefix(parts[1], "FROM:")
+ from = strings.TrimPrefix(from, "from:")
+ from = strings.Trim(from, "<>")
+ if !emailRegExp.MatchString(from) {
+ return c.text.PrintfLine("%d %s", 501, "MAIL command contained invalid address")
+ }
+ _, _, err := ValidateEmailAddress(from)
+ if err != nil {
+ return c.text.PrintfLine("%d %s", 501, "Invalid email address format")
+ }
+ c.mailFrom = from
+ return c.text.PrintfLine("%d %s", 250, "Ok")
+ case "RCPT":
+ if c.mailFrom == "" {
+ return c.text.PrintfLine("%d %s", 503, "Bad sequence of commands")
+ }
+ if len(parts) < 2 {
+ return c.text.PrintfLine("%d %s", 501, "Not enough arguments")
+ }
+ if !strings.HasPrefix(strings.ToUpper(parts[1]), "TO:") {
+ return c.text.PrintfLine("%d %s", 501, "RCPT command must be immediately succeeded by 'TO:'")
+ }
+ to := strings.TrimPrefix(parts[1], "TO:")
+ to = strings.TrimPrefix(to, "to:")
+ to = strings.Trim(to, "<>")
+ if !emailRegExp.MatchString(to) {
+ return c.text.PrintfLine("%d %s", 501, "RCPT command contained invalid address")
+ }
+ _, domain, err := ValidateEmailAddress(to)
+ if err != nil {
+ return c.text.PrintfLine("%d %s", 501, "Invalid email address format")
+ }
+ if !isOnionDomain(domain) {
+ if !hasMXRecords(domain) {
+ return c.text.PrintfLine("%d %s", 550, "Mailbox unavailable")
+ }
+ }
+ c.mailTo = append(c.mailTo, to)
+ return c.text.PrintfLine("%d %s", 250, "Ok")
+ case "DATA":
+ if len(c.mailTo) == 0 || c.mailFrom == "" {
+ return c.text.PrintfLine("%d %s", 503, "Bad sequence of commands")
+ }
+ if err := c.text.PrintfLine("%d %s", 354, "End data with <CR><LF>.<CR><LF>"); err != nil {
+ return err
+ }
+ data, err := c.text.ReadDotBytes()
+ if err != nil {
+ return err
+ }
+ c.mailData = bytes.NewBuffer(data)
+ messageID := generateMessageID()
+ if c.server.MessageIDCache.Has(messageID) {
+ return c.text.PrintfLine("%d %s", 554, "Duplicate message detected")
+ }
+ c.server.MessageIDCache.Add(messageID)
+ for _, recipient := range c.mailTo {
+ env := &Envelope{
+ MessageFrom: c.mailFrom,
+ MessageTo: recipient,
+ MessageData: bytes.NewReader(c.mailData.Bytes()),
+ ReceivedAt: time.Now(),
+ RetryCount: 0,
+ MessageID: messageID,
+ IsCoverTraffic: false,
+ }
+ if err := c.server.Handler.ServeSMTP(env); err != nil {
+ return c.text.PrintfLine("%d %s", 554, "Transaction failed")
+ }
+ }
+ c.resetSession()
+ return c.text.PrintfLine("%d %s", 250, "OK")
+ case "RSET":
+ c.resetSession()
+ return c.text.PrintfLine("%d %s", 250, "Ok")
+ case "VRFY", "EXPN", "HELP", "NOOP":
+ return c.text.PrintfLine("%d %s", 250, "OK")
+ case "QUIT":
+ c.quitSent = true
+ return c.text.PrintfLine("%d %s", 221, "Bye")
+ default:
+ return c.text.PrintfLine("%d %s", 500, "Command not recognized")
+ }
+}
+
+func generateMessageID() string {
+ randomBytes := make([]byte, 32)
+ rand.Read(randomBytes)
+ timestamp := make([]byte, 8)
+ binary.BigEndian.PutUint64(timestamp, uint64(time.Now().UnixNano()))
+ combined := append(randomBytes, timestamp...)
+ h := sha512.Sum512(combined)
+ return base64.URLEncoding.EncodeToString(h[:32])
+}
+
+type SimpleHandler struct {
+ batcher *MixnetBatcher
+}
+
+func (h *SimpleHandler) ServeSMTP(envelope *Envelope) error {
+ h.batcher.Add(envelope)
+ return nil
+}
+
+func smtpRelay(envelope *Envelope) error {
+ if envelope.IsCoverTraffic {
+ return nil
+ }
+ _, domain, err := SplitAddress(envelope.MessageTo)
+ if err != nil {
+ return fmt.Errorf("invalid recipient address: %w", err)
+ }
+ targetAddr := net.JoinHostPort(domain, "25")
+ jitter := time.Duration(cryptoRandInt63n(int64(5 * time.Second)))
+ time.Sleep(jitter)
+ dialer := &net.Dialer{Timeout: DeliveryTimeout}
+ torDialer, err := proxy.SOCKS5("tcp", TorSocksProxyAddr, nil, dialer)
+ if err != nil {
+ return fmt.Errorf("failed to create Tor dialer: %w", err)
+ }
+ conn, err := torDialer.Dial("tcp", targetAddr)
+ if err != nil {
+ return fmt.Errorf("failed to connect to relay target: %w", err)
+ }
+ defer conn.Close()
+ conn.SetDeadline(time.Now().Add(DeliveryTimeout))
+ client, err := smtp.NewClient(conn, domain)
+ if err != nil {
+ return fmt.Errorf("failed to create SMTP client: %w", err)
+ }
+ defer client.Close()
+ if err := client.Mail(envelope.MessageFrom); err != nil {
+ return fmt.Errorf("MAIL FROM failed: %w", err)
+ }
+ if err := client.Rcpt(envelope.MessageTo); err != nil {
+ return fmt.Errorf("RCPT TO failed: %w", err)
+ }
+ wc, err := client.Data()
+ if err != nil {
+ return fmt.Errorf("DATA command failed: %w", err)
+ }
+ defer wc.Close()
+ if _, err := io.Copy(wc, envelope.MessageData); err != nil {
+ return fmt.Errorf("message transfer failed: %w", err)
+ }
+ return nil
+}
+
+func queueEnvelope(envelope *Envelope) bool {
+ mailQueueMutex.Lock()
+ defer mailQueueMutex.Unlock()
+ select {
+ case mailQueue <- envelope:
+ return true
+ default:
+ return false
+ }
+}
+
+func StartRelayWorkers(queue chan *Envelope, workerCount int) {
+ for i := 0; i < workerCount; i++ {
+ go func(id int) {
+ log.Printf("Relay worker %d started", id)
+ for env := range queue {
+ err := smtpRelay(env)
+ if err != nil {
+ domain := extractDomainFromAddress(env.MessageTo)
+ if isOnionDomain(domain) && env.RetryCount < 9 {
+ env.RetryCount++
+ backoff := time.Duration(math.Pow(2, float64(env.RetryCount))) * time.Second
+ jitter := time.Duration(cryptoRandInt63n(int64(backoff / 2)))
+ time.Sleep(backoff + jitter)
+ queueEnvelope(env)
+ }
+ }
+ }
+ }(i)
+ }
+}
+
+func main() {
+ listenAddr := flag.String("addr", "127.0.0.1:2525", "Listen address")
+ serverName := flag.String("name", "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion", "Server v3 .onion hostname")
+ flag.Parse()
+ if err := validateServerName(*serverName); err != nil {
+ log.Fatalf("Invalid server name: %v", err)
+ }
+ mailQueue = make(chan *Envelope, 100)
+ StartRelayWorkers(mailQueue, RelayWorkerCount)
+ messageIDCache := NewMessageIDCache()
+ rateLimiter := NewRateLimiter()
+ mixnetBatcher := NewMixnetBatcher(mailQueue)
+ coverTraffic := NewCoverTrafficGenerator(mailQueue)
+ server := &Server{
+ Name: *serverName,
+ Addr: *listenAddr,
+ MessageIDCache: messageIDCache,
+ RateLimiter: rateLimiter,
+ MixnetBatcher: mixnetBatcher,
+ CoverTraffic: coverTraffic,
+ }
+ handler := &SimpleHandler{batcher: mixnetBatcher}
+ server.Handler = handler
+ log.Printf("Pluto2 SMTP Relay Server")
+ log.Printf("========================")
+ log.Printf("Server: %s", server.Name)
+ log.Printf("Listen: %s", server.Addr)
+ log.Printf("Tor proxy: %s", TorSocksProxyAddr)
+ log.Printf("")
+ log.Printf("Security: Replay protection, Rate limiting, Timing attacks, Size correlation, Mixnet batching, Cover traffic")
+ log.Printf("Validation: RFC-compliant email, v3 .onion server only")
+ log.Printf("Policy: FROM any valid, TO .onion + clearnet(MX), RELAY .onion only")
+ log.Printf("")
+ listener, err := net.Listen("tcp", server.Addr)
+ if err != nil {
+ log.Fatalf("Failed to create listener: %v", err)
+ }
+ if err := server.Serve(listener); err != nil {
+ log.Fatalf("Server failed: %v", err)
+ }
+}