diff options
| author | Gab <24553253+gabrix73@users.noreply.github.com> | 2025-12-18 01:21:00 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-18 01:21:00 +0100 |
| commit | d8f005287be8886f780a6a3accec091552c2d4e9 (patch) | |
| tree | e9ea64b6ccbaa17a40d78652fa84755cd45f2e75 | |
| parent | fe915b6fc672e2172d97e972a921839d81b4f3c2 (diff) | |
| download | fog-d8f005287be8886f780a6a3accec091552c2d4e9.tar.gz fog-d8f005287be8886f780a6a3accec091552c2d4e9.tar.xz fog-d8f005287be8886f780a6a3accec091552c2d4e9.zip | |
Update fmt.Println message from 'Hello' to 'Goodbye'
| -rw-r--r-- | fog.go | 2490 |
1 files changed, 1465 insertions, 1025 deletions
@@ -1,5 +1,14 @@ -// fog v2.1.1 - Anonymous SMTP Relay with Sphinx Mixnet + Header Sanitization -// Features: delay pool, exit node header sanitization (RFC compliant) +// fog v3.0.4 - Anonymous SMTP Relay with Sphinx Mixnet +// v3.0.4 fixes: +// - Fixed Sphinx routing: each hop gets independent ephemeral key pair +// - Removed broken key blinding, using per-hop fresh keys instead +// - Simplified packet processing +// Previous fixes (v3.0.3): +// - Attempted key blinding fix (had issues with curve25519 clamping) +// Features: +// - PKI Gossip: fully decentralized node discovery +// - Threshold Batching: pool mixing with configurable threshold +// - Realistic Cover Traffic: low volume, irregular timing // Copyright 2025 - fog Project package main @@ -7,12 +16,12 @@ package main import ( "bufio" "bytes" + "context" "crypto/aes" "crypto/cipher" "crypto/hmac" "crypto/rand" "crypto/sha256" - "database/sql" "encoding/base64" "encoding/binary" "encoding/hex" @@ -20,920 +29,1167 @@ import ( "errors" "flag" "fmt" + "io" "log" - "math" "math/big" "net" "net/smtp" - "net/textproto" "os" "os/signal" - "path/filepath" "strings" "sync" "sync/atomic" "syscall" "time" - _ "github.com/mattn/go-sqlite3" "golang.org/x/crypto/curve25519" "golang.org/x/crypto/hkdf" "golang.org/x/net/proxy" ) const ( - Version = "2.1.1" + Version = "3.0.8" TorSocks = "127.0.0.1:9050" - DefaultPort = "2525" - NodePort = "9999" - - MinDelay = 500 * time.Millisecond - MaxDelay = 5 * time.Second - - BatchWindow = 30 * time.Second - BatchSize = 10 + DefaultSMTP = "127.0.0.1:2525" + DefaultNode = "127.0.0.1:9999" + // Timing HealthInterval = 3 * time.Minute + GossipInterval = 5 * time.Minute StatsInterval = 60 * time.Second - PoolInterval = 1 * time.Minute - - MaxMsgSize = 10 << 20 - MaxRecipient = 50 - QueueSize = 500 - Workers = 3 - CacheSize = 10000 - CacheTTL = 24 * time.Hour + // Threshold Batching + BatchThresholdMin = 5 // Minimum packets before release + BatchThresholdMax = 15 // Maximum before forced release + BatchTimeout = 5 * time.Minute // Max wait time - SphinxHops = 3 - HeaderSize = 256 - AESKeySize = 32 - NonceSize = 12 - HMACSize = 32 + // Cover Traffic - realistic small server pattern + CoverMinInterval = 30 * time.Minute // Minimum between cover msgs + CoverMaxInterval = 4 * time.Hour // Maximum between cover msgs + CoverMaxPerHour = 3 // Never exceed this per hour + CoverBurstChance = 0.1 // 10% chance of 2-3 msg burst - PaddedPayloadSize = 64 * 1024 + // Sphinx + MinHops = 3 + MaxHops = 6 + HeaderSize = 176 // 32 (ephPub) + 128 (routing) + 16 (MAC) + PayloadMax = 64 * 1024 - DefaultMinPoolDelay = 1 * time.Hour - DefaultMaxPoolDelay = 24 * time.Hour + // Limits + MaxMsgSize = 10 << 20 + QueueSize = 500 + Workers = 3 + CacheSize = 10000 + CacheTTL = 24 * time.Hour ) -// ============================================================================ -// DELAY STRATEGIES -// ============================================================================ - -type DelayStrategy int - -const ( - DelayExponential DelayStrategy = iota - DelayConstant - DelayPoisson -) +// ============================================================================= +// TYPES +// ============================================================================= -func (d DelayStrategy) String() string { - switch d { - case DelayExponential: - return "exponential" - case DelayConstant: - return "constant" - case DelayPoisson: - return "poisson" - default: - return "unknown" - } +type Node struct { + ID string `json:"id"` + PublicKey []byte `json:"public_key"` + Address string `json:"address"` + Name string `json:"name"` + Version string `json:"version"` + LastSeen time.Time `json:"last_seen"` + Healthy bool `json:"healthy"` } -func parseDelayStrategy(s string) DelayStrategy { - switch strings.ToLower(s) { - case "constant": - return DelayConstant - case "poisson": - return DelayPoisson - default: - return DelayExponential - } +type LocalNode struct { + ID string + Public []byte + Private []byte + Address string + Name string } -// ============================================================================ -// TYPES -// ============================================================================ - type Message struct { - ID string - From string - To []string - Data []byte - Time time.Time + ID string + From string + To []string + Data []byte + ReceivedAt time.Time } -type QueuedMessage struct { - ID string - From string - To string - Data []byte - EnqueueTime time.Time - SendAfter time.Time - Attempts int -} - -type Node struct { - ID string `json:"node_id"` - PubKey string `json:"public_key"` - Address string `json:"address"` - Name string `json:"name"` - Version string `json:"version"` - Healthy bool `json:"-"` - LastOK time.Time `json:"-"` +type SphinxPacket struct { + Header []byte + Payload []byte } -func (n *Node) GetPubKey() ([]byte, error) { - return base64.StdEncoding.DecodeString(n.PubKey) +type Stats struct { + Start time.Time + Received int64 + Delivered int64 + Failed int64 + SphinxRouted int64 + DirectRelay int64 + CoverSent int64 + GossipExch int64 + mu sync.Mutex } -type PKI struct { - Version string `json:"version"` - Updated string `json:"updated"` - Nodes []*Node `json:"nodes"` - mu sync.RWMutex -} +// ============================================================================= +// GLOBALS +// ============================================================================= -type LocalNode struct { - ID string - Private []byte - Public []byte - Address string - ShortName string - mu sync.RWMutex -} +var ( + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + torDialer proxy.Dialer + + local LocalNode + hostname string + pkiFile string + keyFile string + + pki *PKI + pool *BatchPool + replay *ReplayCache + queue chan *Message + stats *Stats + cover *CoverTraffic + + useSphinx atomic.Bool + debugMode bool +) -type Stats struct { - Start time.Time - Recv int64 - Sent int64 - Failed int64 - Sphinx int64 - Direct int64 - MixRecv int64 - MixFwd int64 - Queued int64 - Delayed int64 -} +// ============================================================================= +// PKI WITH GOSSIP PROTOCOL +// ============================================================================= -type ReplayCache struct { - cache map[string]time.Time +type PKI struct { + nodes map[string]*Node mu sync.RWMutex } -type Batch struct { - packets []*SphinxPacket - start time.Time - mu sync.Mutex +func newPKI() *PKI { + return &PKI{nodes: make(map[string]*Node)} } -type SphinxHeader struct { - Version byte - EphKey [32]byte - Routing []byte - MAC [32]byte -} +func (p *PKI) Add(n *Node) { + p.mu.Lock() + defer p.mu.Unlock() -type SphinxPacket struct { - Header *SphinxHeader - Payload []byte + existing, ok := p.nodes[n.ID] + if !ok || n.LastSeen.After(existing.LastSeen) { + p.nodes[n.ID] = n + if debugMode { + log.Printf("[PKI] Added/updated node %s (%s)", n.Name, n.ID[:16]) + } + } } -type RoutingInfo struct { - NextHop string - IsExit bool +func (p *PKI) Remove(id string) { + p.mu.Lock() + defer p.mu.Unlock() + delete(p.nodes, id) } -type DelayPool struct { - db *sql.DB - minDelay time.Duration - maxDelay time.Duration - strategy DelayStrategy - mu sync.RWMutex +func (p *PKI) Get(id string) *Node { + p.mu.RLock() + defer p.mu.RUnlock() + return p.nodes[id] } -// ============================================================================ -// GLOBALS -// ============================================================================ - -var ( - pki = &PKI{Nodes: make([]*Node, 0)} - localNode = &LocalNode{} - stats = &Stats{Start: time.Now()} - replayCache = &ReplayCache{cache: make(map[string]time.Time)} - batch = &Batch{packets: make([]*SphinxPacket, 0)} - delayPool *DelayPool - - enableSphinx bool - enableDelay bool - debug bool -) - -// ============================================================================ -// DELAY POOL - SQLITE DATABASE -// ============================================================================ - -func NewDelayPool(dbPath string, minDelay, maxDelay time.Duration, strategy DelayStrategy) (*DelayPool, error) { - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - return nil, fmt.Errorf("open db: %w", err) - } - - schema := ` - CREATE TABLE IF NOT EXISTS message_queue ( - id TEXT PRIMARY KEY, - from_addr TEXT NOT NULL, - to_addr TEXT NOT NULL, - data BLOB NOT NULL, - enqueue_time INTEGER NOT NULL, - send_after INTEGER NOT NULL, - attempts INTEGER DEFAULT 0, - created_at INTEGER DEFAULT (strftime('%s', 'now')) - ); - CREATE INDEX IF NOT EXISTS idx_send_after ON message_queue(send_after); - ` - - if _, err := db.Exec(schema); err != nil { - db.Close() - return nil, fmt.Errorf("create schema: %w", err) +func (p *PKI) GetAll() []*Node { + p.mu.RLock() + defer p.mu.RUnlock() + result := make([]*Node, 0, len(p.nodes)) + for _, n := range p.nodes { + result = append(result, n) } + return result +} - pool := &DelayPool{ - db: db, - minDelay: minDelay, - maxDelay: maxDelay, - strategy: strategy, +func (p *PKI) GetHealthy() []*Node { + p.mu.RLock() + defer p.mu.RUnlock() + result := make([]*Node, 0) + for _, n := range p.nodes { + if n.Healthy && n.ID != local.ID { + result = append(result, n) + } } - - return pool, nil + return result } -func (p *DelayPool) Close() error { - return p.db.Close() +func (p *PKI) GetOthers() []*Node { + p.mu.RLock() + defer p.mu.RUnlock() + result := make([]*Node, 0) + for _, n := range p.nodes { + if n.ID != local.ID { + result = append(result, n) + } + } + return result } -func (p *DelayPool) calculateDelay() time.Duration { +func (p *PKI) HealthyCount() int { p.mu.RLock() defer p.mu.RUnlock() + count := 0 + for _, n := range p.nodes { + if n.Healthy && n.ID != local.ID { + count++ + } + } + return count +} - switch p.strategy { - case DelayConstant: - return p.minDelay + time.Duration(secureRandInt64(int64(p.maxDelay-p.minDelay))) - - case DelayPoisson: - lambda := float64(p.minDelay+p.maxDelay) / 2.0 - delay := time.Duration(poissonRandom(lambda)) - if delay < p.minDelay { - delay = p.minDelay +// CleanupDuplicates removes duplicate nodes with same address or name, keeping only the most recent +func (p *PKI) CleanupDuplicates() int { + p.mu.Lock() + defer p.mu.Unlock() + + // Group by address + byAddress := make(map[string][]*Node) + for _, n := range p.nodes { + byAddress[n.Address] = append(byAddress[n.Address], n) + } + + removed := 0 + for addr, nodes := range byAddress { + if len(nodes) <= 1 { + continue } - if delay > p.maxDelay { - delay = p.maxDelay + + // Find the one with most recent LastSeen + var newest *Node + for _, n := range nodes { + if newest == nil || n.LastSeen.After(newest.LastSeen) { + newest = n + } } - return delay - - case DelayExponential: - fallthrough - default: - mean := float64(p.minDelay+p.maxDelay) / 2.0 - lambda := 1.0 / mean - - u := secureRandFloat64() - delay := time.Duration(-math.Log(1.0-u) / lambda) - - if delay < p.minDelay { - delay = p.minDelay + + // Remove all others + for _, n := range nodes { + if n.ID != newest.ID { + delete(p.nodes, n.ID) + removed++ + if debugMode { + log.Printf("[PKI] Removed duplicate node %s (addr: %s, kept: %s)", n.ID[:16], addr, newest.ID[:16]) + } + } } - if delay > p.maxDelay { - delay = p.maxDelay + } + + // Also group by name (for .onion hostnames) + byName := make(map[string][]*Node) + for _, n := range p.nodes { + if n.Name != "" { + byName[n.Name] = append(byName[n.Name], n) } - return delay } -} -func (p *DelayPool) Enqueue(msg *Message) error { - delay := p.calculateDelay() - sendAfter := time.Now().Add(delay) - - for _, to := range msg.To { - _, err := p.db.Exec(` - INSERT INTO message_queue (id, from_addr, to_addr, data, enqueue_time, send_after) - VALUES (?, ?, ?, ?, ?, ?)`, - msg.ID+"-"+to, - msg.From, - to, - msg.Data, - msg.Time.Unix(), - sendAfter.Unix(), - ) - if err != nil { - return fmt.Errorf("enqueue: %w", err) + for name, nodes := range byName { + if len(nodes) <= 1 { + continue + } + + var newest *Node + for _, n := range nodes { + if newest == nil || n.LastSeen.After(newest.LastSeen) { + newest = n + } + } + + for _, n := range nodes { + if n.ID != newest.ID { + delete(p.nodes, n.ID) + removed++ + if debugMode { + log.Printf("[PKI] Removed duplicate node %s (name: %s, kept: %s)", n.ID[:16], name, newest.ID[:16]) + } + } } } - atomic.AddInt64(&stats.Queued, int64(len(msg.To))) - - if debug { - log.Printf("[POOL] Enqueued %s: %d recipients, delay=%v, send_after=%s", - msg.ID, len(msg.To), delay, sendAfter.Format("15:04:05")) + return removed +} + +func (p *PKI) SetHealth(id string, healthy bool) { + p.mu.Lock() + defer p.mu.Unlock() + if n, ok := p.nodes[id]; ok { + n.Healthy = healthy + n.LastSeen = time.Now() } - - return nil } -func (p *DelayPool) GetReady() ([]*QueuedMessage, error) { - now := time.Now().Unix() - - rows, err := p.db.Query(` - SELECT id, from_addr, to_addr, data, enqueue_time, send_after, attempts - FROM message_queue - WHERE send_after <= ? - ORDER BY send_after - LIMIT 100`, - now, - ) +func (p *PKI) Load(path string) error { + data, err := os.ReadFile(path) if err != nil { - return nil, err + return err } - defer rows.Close() - var messages []*QueuedMessage - for rows.Next() { - var m QueuedMessage - var enqT, sendT int64 - - err := rows.Scan(&m.ID, &m.From, &m.To, &m.Data, &enqT, &sendT, &m.Attempts) - if err != nil { - log.Printf("[POOL] Scan error: %v", err) - continue - } - - m.EnqueueTime = time.Unix(enqT, 0) - m.SendAfter = time.Unix(sendT, 0) - messages = append(messages, &m) + var nodes map[string]*Node + if err := json.Unmarshal(data, &nodes); err != nil { + return err } - return messages, nil -} + p.mu.Lock() + defer p.mu.Unlock() -func (p *DelayPool) Delete(id string) error { - _, err := p.db.Exec("DELETE FROM message_queue WHERE id = ?", id) - if err != nil { - return fmt.Errorf("delete: %w", err) + for id, n := range nodes { + n.ID = id + p.nodes[id] = n } - atomic.AddInt64(&stats.Queued, -1) - atomic.AddInt64(&stats.Delayed, 1) - return nil -} -func (p *DelayPool) IncrementAttempts(id string) error { - _, err := p.db.Exec("UPDATE message_queue SET attempts = attempts + 1 WHERE id = ?", id) - return err + log.Printf("[PKI] Loaded %d nodes from %s", len(nodes), path) + return nil } -func (p *DelayPool) Count() (int64, error) { - var count int64 - err := p.db.QueryRow("SELECT COUNT(*) FROM message_queue").Scan(&count) - return count, err -} +func (p *PKI) Save(path string) error { + p.mu.RLock() + defer p.mu.RUnlock() -func (p *DelayPool) Stats() (total, ready int64, oldest time.Time, err error) { - err = p.db.QueryRow("SELECT COUNT(*), MIN(send_after) FROM message_queue").Scan(&total, &oldest) + data, err := json.MarshalIndent(p.nodes, "", " ") if err != nil { - return + return err } - - now := time.Now().Unix() - err = p.db.QueryRow("SELECT COUNT(*) FROM message_queue WHERE send_after <= ?", now).Scan(&ready) - return + + return os.WriteFile(path, data, 0600) } -// ============================================================================ -// SECURE RANDOM UTILITIES -// ============================================================================ +// Gossip: export our node list for sharing +func (p *PKI) ExportForGossip() []byte { + p.mu.RLock() + defer p.mu.RUnlock() + + data, _ := json.Marshal(p.nodes) + return data +} -func secureRandInt64(max int64) int64 { - if max <= 0 { +// Gossip: merge received node list +func (p *PKI) MergeFromGossip(data []byte) int { + var received map[string]*Node + if err := json.Unmarshal(data, &received); err != nil { return 0 } - n, err := rand.Int(rand.Reader, big.NewInt(max)) - if err != nil { - return 0 + + added := 0 + p.mu.Lock() + + for id, n := range received { + if id == local.ID { + continue // Skip ourselves + } + n.ID = id + existing, ok := p.nodes[id] + if !ok { + p.nodes[id] = n + added++ + log.Printf("[GOSSIP] Discovered new node: %s (%s)", n.Name, id[:16]) + } else if n.LastSeen.After(existing.LastSeen) { + p.nodes[id] = n + } } - return n.Int64() -} -func secureRandFloat64() float64 { - n := secureRandInt64(1 << 53) - return float64(n) / float64(1<<53) + p.mu.Unlock() + + // Cleanup duplicates after merge + p.CleanupDuplicates() + + return added } -func poissonRandom(lambda float64) float64 { - L := math.Exp(-lambda) - k := 0.0 - p := 1.0 - - for p > L { - k++ - p *= secureRandFloat64() +// ============================================================================= +// GOSSIP PROTOCOL +// ============================================================================= + +func gossipWorker() { + defer wg.Done() + + // Initial delay to let things settle + select { + case <-ctx.Done(): + return + case <-time.After(30 * time.Second): } - - return k - 1.0 -} -func secureRandDelay(min, max time.Duration) time.Duration { - if max <= min { - return min + ticker := time.NewTicker(GossipInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + doGossipRound() + } } - delta := int64(max - min) - return min + time.Duration(secureRandInt64(delta)) } -func secureRandBytes(n int) ([]byte, error) { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - return nil, err +func doGossipRound() { + others := pki.GetOthers() + if len(others) == 0 { + return } - return b, nil -} -func secureRandHex(n int) string { - b, _ := secureRandBytes(n) - return hex.EncodeToString(b) + // Shuffle and pick up to 3 random nodes to gossip with + shuffleNodes(others) + count := 3 + if len(others) < count { + count = len(others) + } + + myData := pki.ExportForGossip() + + for i := 0; i < count; i++ { + node := others[i] + go gossipWith(node, myData) + } } -// ============================================================================ -// HEADER SANITIZATION (v2.1.1) -// ============================================================================ +func gossipWith(node *Node, myData []byte) { + conn, err := dialTor(node.Address) + if err != nil { + if debugMode { + log.Printf("[GOSSIP] Failed to connect to %s: %v", node.Name, err) + } + return + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(30 * time.Second)) + + // Send GOSSIP command + fmt.Fprintf(conn, "GOSSIP %d\r\n", len(myData)) + conn.Write(myData) + conn.Write([]byte("\r\n")) -func sanitizeHeaders(payload []byte) []byte { - // Parse message - msg, err := textproto.NewReader(bufio.NewReader(bytes.NewReader(payload))).ReadMIMEHeader() + // Read response + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') if err != nil { - return payload // Can't parse, return as-is + return } - // Find headers/body boundary - headerEnd := bytes.Index(payload, []byte("\r\n\r\n")) - if headerEnd == -1 { - headerEnd = bytes.Index(payload, []byte("\n\n")) - if headerEnd == -1 { - return payload + if strings.HasPrefix(line, "GOSSIP ") { + var size int + fmt.Sscanf(line, "GOSSIP %d", &size) + if size > 0 && size < 1<<20 { + data := make([]byte, size) + io.ReadFull(reader, data) + added := pki.MergeFromGossip(data) + if added > 0 { + atomic.AddInt64(&stats.GossipExch, int64(added)) + } } - headerEnd += 2 - } else { - headerEnd += 4 } - - body := payload[headerEnd:] - // Build sanitized headers - sanitized := make(textproto.MIMEHeader) + if debugMode { + log.Printf("[GOSSIP] Exchanged with %s", node.Name) + } +} - // REPLACE: From - sanitized.Set("From", fmt.Sprintf("Anonymous <anonymous@%s.fog>", localNode.ShortName)) +// ============================================================================= +// THRESHOLD BATCH POOL +// ============================================================================= - // REPLACE: Message-ID - sanitized.Set("Message-ID", fmt.Sprintf("<%s@%s.fog>", secureRandHex(32), localNode.ShortName)) +type BatchPool struct { + packets []*SphinxPacket + addedAt []time.Time + mu sync.Mutex + threshold int +} - // REPLACE: Date (randomize ±1-2h) - if origDate := msg.Get("Date"); origDate != "" { - if t, err := time.Parse(time.RFC1123Z, origDate); err == nil { - offset := secureRandInt64(7200) - 3600 // ±1h in seconds - newDate := t.Add(time.Duration(offset) * time.Second) - sanitized.Set("Date", newDate.Format(time.RFC1123Z)) - } else { - sanitized.Set("Date", time.Now().Format(time.RFC1123Z)) - } - } else { - sanitized.Set("Date", time.Now().Format(time.RFC1123Z)) +func newBatchPool() *BatchPool { + // Random threshold between min and max + threshold := BatchThresholdMin + cryptoRandInt(BatchThresholdMax-BatchThresholdMin+1) + return &BatchPool{ + packets: make([]*SphinxPacket, 0), + addedAt: make([]time.Time, 0), + threshold: threshold, } +} - // KEEP: Essential headers - keepHeaders := []string{ - "To", "Newsgroups", "Subject", - "Content-Type", "Content-Transfer-Encoding", "MIME-Version", - "References", "In-Reply-To", // Threading (RFC 5536) - } +func (b *BatchPool) Add(p *SphinxPacket) { + b.mu.Lock() + defer b.mu.Unlock() + b.packets = append(b.packets, p) + b.addedAt = append(b.addedAt, time.Now()) +} - for _, h := range keepHeaders { - if v := msg.Get(h); v != "" { - sanitized.Set(h, v) - } +func (b *BatchPool) Size() int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.packets) +} + +func (b *BatchPool) ShouldFlush() bool { + b.mu.Lock() + defer b.mu.Unlock() + + if len(b.packets) == 0 { + return false } - // Rebuild message - var buf bytes.Buffer - for key, values := range sanitized { - for _, value := range values { - buf.WriteString(fmt.Sprintf("%s: %s\r\n", key, value)) - } + // Flush if threshold reached + if len(b.packets) >= b.threshold { + return true } - buf.WriteString("\r\n") - buf.Write(body) - if debug { - log.Printf("[SANITIZE] Headers cleaned: From=anonymous@%s.fog", localNode.ShortName) + // Flush if oldest packet exceeded timeout + if len(b.addedAt) > 0 && time.Since(b.addedAt[0]) > BatchTimeout { + return true } - return buf.Bytes() + return false } -// ============================================================================ -// PKI MANAGEMENT -// ============================================================================ +func (b *BatchPool) Flush() []*SphinxPacket { + b.mu.Lock() + defer b.mu.Unlock() -func (p *PKI) Load(path string) error { - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("read pki: %w", err) + if len(b.packets) == 0 { + return nil } - if err := json.Unmarshal(data, p); err != nil { - return fmt.Errorf("parse pki: %w", err) - } + // Take all packets + result := b.packets + b.packets = make([]*SphinxPacket, 0) + b.addedAt = make([]time.Time, 0) - p.mu.Lock() - for _, node := range p.Nodes { - node.Healthy = false - } - p.mu.Unlock() + // Shuffle for unlinkability + shufflePackets(result) - log.Printf("[PKI] Loaded %d nodes from %s", len(p.Nodes), path) - return nil + // New random threshold for next batch + b.threshold = BatchThresholdMin + cryptoRandInt(BatchThresholdMax-BatchThresholdMin+1) + + log.Printf("[POOL] Flushing %d packets (next threshold: %d)", len(result), b.threshold) + return result } -func (p *PKI) GetHealthy() []*Node { - p.mu.RLock() - defer p.mu.RUnlock() +func batchWorker() { + defer wg.Done() - var healthy []*Node - for _, n := range p.Nodes { - if n.Healthy && n.ID != localNode.ID { - healthy = append(healthy, n) + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if pool.ShouldFlush() { + packets := pool.Flush() + for _, p := range packets { + go processSphinxPacket(p) + } + } } } - return healthy } -func (p *PKI) GetNode(id string) *Node { - p.mu.RLock() - defer p.mu.RUnlock() +// ============================================================================= +// COVER TRAFFIC - REALISTIC SMALL SERVER PATTERN +// ============================================================================= - for _, n := range p.Nodes { - if n.ID == id { - return n - } +type CoverTraffic struct { + lastSent time.Time + sentThisHour int + hourStart time.Time + mu sync.Mutex +} + +func newCoverTraffic() *CoverTraffic { + return &CoverTraffic{ + lastSent: time.Now(), + hourStart: time.Now().Truncate(time.Hour), } - return nil } -func (p *PKI) RandomPath(hops int) ([]*Node, error) { - healthy := p.GetHealthy() - - if len(healthy) < hops { - return nil, fmt.Errorf("insufficient nodes: need %d, have %d", hops, len(healthy)) +func (c *CoverTraffic) shouldSend() bool { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + + // Reset hourly counter + currentHour := now.Truncate(time.Hour) + if currentHour.After(c.hourStart) { + c.sentThisHour = 0 + c.hourStart = currentHour + } + + // Never exceed max per hour + if c.sentThisHour >= CoverMaxPerHour { + return false } - shuffled := make([]*Node, len(healthy)) - copy(shuffled, healthy) + // Check minimum interval + if time.Since(c.lastSent) < CoverMinInterval { + return false + } + + // Random chance based on time since last send + elapsed := time.Since(c.lastSent) - for i := len(shuffled) - 1; i > 0; i-- { - j := int(secureRandInt64(int64(i + 1))) - shuffled[i], shuffled[j] = shuffled[j], shuffled[i] + // Probability increases with time, but stays low + // At MinInterval: ~5% chance per check + // At MaxInterval: ~50% chance per check + maxWait := float64(CoverMaxInterval) + elapsedF := float64(elapsed) + probability := 0.05 + 0.45*(elapsedF/maxWait) + if probability > 0.5 { + probability = 0.5 + } + + if cryptoRandFloat() < probability { + c.lastSent = now + c.sentThisHour++ + return true } - return shuffled[:hops], nil + return false } -// ============================================================================ -// LOCAL NODE -// ============================================================================ +func (c *CoverTraffic) shouldBurst() bool { + return cryptoRandFloat() < CoverBurstChance +} -func (n *LocalNode) Init(name, shortName string) error { - n.mu.Lock() - defer n.mu.Unlock() +func coverWorker() { + defer wg.Done() - private := make([]byte, 32) - if _, err := rand.Read(private); err != nil { - return err + // Random initial delay (1-10 minutes) + initialDelay := time.Duration(60+cryptoRandInt(540)) * time.Second + select { + case <-ctx.Done(): + return + case <-time.After(initialDelay): } - public, err := curve25519.X25519(private, curve25519.Basepoint) - if err != nil { - return err + // Check every 5-15 minutes (random interval each time) + for { + interval := time.Duration(5+cryptoRandInt(10)) * time.Minute + select { + case <-ctx.Done(): + return + case <-time.After(interval): + if cover.shouldSend() { + sendCoverMessage() + + // Possible burst (2-3 messages close together) + if cover.shouldBurst() { + burstCount := 1 + cryptoRandInt(2) // 1-2 extra messages + for i := 0; i < burstCount; i++ { + // Small delay between burst messages (10-60 seconds) + burstDelay := time.Duration(10+cryptoRandInt(50)) * time.Second + select { + case <-ctx.Done(): + return + case <-time.After(burstDelay): + if cover.shouldSend() { + sendCoverMessage() + } + } + } + } + } + } } +} - hash := sha256.Sum256(public) - n.ID = base64.RawURLEncoding.EncodeToString(hash[:]) +func sendCoverMessage() { + healthy := pki.GetHealthy() + if len(healthy) < MinHops { + return + } - n.Private = private - n.Public = public - n.Address = name - n.ShortName = shortName + // Create dummy message with realistic size + size := 500 + cryptoRandInt(2000) // 500-2500 bytes + dummy := make([]byte, size) + rand.Read(dummy) - log.Printf("[NODE] ID=%s Address=%s Name=%s", n.ID[:16], name, shortName) - return nil -} - -func (n *LocalNode) ExportInfo(name, shortName string) error { - n.mu.RLock() - defer n.mu.RUnlock() + // Select random route + hopCount := MinHops + cryptoRandInt(MaxHops-MinHops+1) + route := selectRoute(healthy, hopCount) + if route == nil { + return + } - info := map[string]interface{}{ - "version": Version, - "updated": time.Now().UTC().Format(time.RFC3339), - "nodes": []map[string]string{ - { - "node_id": n.ID, - "public_key": base64.StdEncoding.EncodeToString(n.Public), - "address": name + ":" + NodePort, - "name": shortName, - "version": Version, - }, - }, + // Create and send Sphinx packet + packet := createSphinxPacket(dummy, route, true) // isDummy = true + if packet == nil { + return } - data, err := json.MarshalIndent(info, "", " ") - if err != nil { - return err + // Send to first hop + if err := sendToNode(route[0], packet); err != nil { + if debugMode { + log.Printf("[COVER] Failed to send: %v", err) + } + return } - return os.WriteFile("nodes.json", data, 0644) + atomic.AddInt64(&stats.CoverSent, 1) + if debugMode { + log.Printf("[COVER] Sent dummy message via %d hops", hopCount) + } } -// ============================================================================ +// ============================================================================= // REPLAY CACHE -// ============================================================================ +// ============================================================================= + +type ReplayCache struct { + items map[string]time.Time + mu sync.RWMutex +} + +func newReplayCache() *ReplayCache { + return &ReplayCache{items: make(map[string]time.Time)} +} func (r *ReplayCache) Check(id string) bool { r.mu.RLock() - defer r.mu.RUnlock() - _, exists := r.cache[id] + _, exists := r.items[id] + r.mu.RUnlock() return exists } func (r *ReplayCache) Add(id string) { r.mu.Lock() - defer r.mu.Unlock() - r.cache[id] = time.Now() + r.items[id] = time.Now() + r.mu.Unlock() } func (r *ReplayCache) Cleanup() { + r.mu.Lock() + defer r.mu.Unlock() + cutoff := time.Now().Add(-CacheTTL) + for id, t := range r.items { + if t.Before(cutoff) { + delete(r.items, id) + } + } +} + +func cacheCleanupWorker() { + defer wg.Done() ticker := time.NewTicker(time.Hour) defer ticker.Stop() - for range ticker.C { - r.mu.Lock() - now := time.Now() - for id, t := range r.cache { - if now.Sub(t) > CacheTTL { - delete(r.cache, id) - } + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + replay.Cleanup() } - r.mu.Unlock() } } -// ============================================================================ -// SPHINX PACKET FORMAT -// ============================================================================ +// ============================================================================= +// CRYPTO HELPERS +// ============================================================================= -func buildSphinxPacket(path []*Node, payload []byte) (*SphinxPacket, error) { - if len(path) == 0 { - return nil, errors.New("empty path") +func cryptoRandInt(max int) int { + if max <= 0 { + return 0 } + n, _ := rand.Int(rand.Reader, big.NewInt(int64(max))) + return int(n.Int64()) +} - paddedPayload := make([]byte, PaddedPayloadSize) - binary.BigEndian.PutUint32(paddedPayload[0:4], uint32(len(payload))) - copy(paddedPayload[4:], payload) - - if len(payload)+4 < PaddedPayloadSize { - rand.Read(paddedPayload[4+len(payload):]) +func cryptoRandFloat() float64 { + var b [8]byte + rand.Read(b[:]) + return float64(binary.BigEndian.Uint64(b[:])&0x1FFFFFFFFFFFFF) / float64(0x20000000000000) +} + +func cryptoRandBytes(n int) []byte { + b := make([]byte, n) + rand.Read(b) + return b +} + +func generateKeyPair() (pub, priv []byte) { + priv = make([]byte, 32) + rand.Read(priv) + pub = make([]byte, 32) + curve25519.ScalarBaseMult((*[32]byte)(pub), (*[32]byte)(priv)) + return +} + +func sharedSecret(priv, pub []byte) []byte { + shared := make([]byte, 32) + curve25519.ScalarMult((*[32]byte)(shared), (*[32]byte)(priv), (*[32]byte)(pub)) + return shared +} + +func deriveKeys(secret []byte) (encKey, macKey []byte) { + hkdfReader := hkdf.New(sha256.New, secret, nil, []byte("fog-sphinx")) + encKey = make([]byte, 32) + macKey = make([]byte, 32) + io.ReadFull(hkdfReader, encKey) + io.ReadFull(hkdfReader, macKey) + return +} + +func computeMAC(key, data []byte) []byte { + mac := hmac.New(sha256.New, key) + mac.Write(data) + return mac.Sum(nil) +} + +func verifyMAC(key, data, expected []byte) bool { + computed := computeMAC(key, data) + // Truncate to same length as expected (16 bytes in Sphinx) + if len(expected) < len(computed) { + computed = computed[:len(expected)] } + return hmac.Equal(computed, expected) +} - routing := make([]byte, 0, len(path)*64) - for i, node := range path { - isExit := (i == len(path)-1) - - info := &RoutingInfo{ - NextHop: node.Address, - IsExit: isExit, - } - - infoBytes, _ := json.Marshal(info) - routing = append(routing, infoBytes...) - routing = append(routing, 0) +func aesEncrypt(key, plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err } + nonce := cryptoRandBytes(gcm.NonceSize()) + return gcm.Seal(nonce, nonce, plaintext, nil), nil +} + +func aesDecrypt(key, ciphertext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + if len(ciphertext) < gcm.NonceSize() { + return nil, errors.New("ciphertext too short") + } + nonce := ciphertext[:gcm.NonceSize()] + return gcm.Open(nil, nonce, ciphertext[gcm.NonceSize():], nil) +} - ephPrivate := make([]byte, 32) - rand.Read(ephPrivate) - ephPublic, _ := curve25519.X25519(ephPrivate, curve25519.Basepoint) +// ============================================================================= +// SPHINX PACKET +// ============================================================================= - encrypted := paddedPayload - sharedSecrets := make([][]byte, 0) +func selectRoute(healthy []*Node, hopCount int) []*Node { + if len(healthy) < hopCount { + return nil + } - for i := len(path) - 1; i >= 0; i-- { - nodePubKey, err := path[i].GetPubKey() - if err != nil { - return nil, err + shuffleNodes(healthy) + return healthy[:hopCount] +} + +func createSphinxPacket(payload []byte, route []*Node, isDummy bool) *SphinxPacket { + if len(route) == 0 { + return nil + } + + // Pad payload to fixed size + padded := padPayload(payload) + + // Generate independent ephemeral key pairs for each hop + // Each hop gets its own fresh key pair + type hopInfo struct { + ephPub []byte + ephPriv []byte + encKey []byte + macKey []byte + } + + hops := make([]hopInfo, len(route)) + + for i := 0; i < len(route); i++ { + // Generate fresh ephemeral key pair for each hop + ephPub, ephPriv := generateKeyPair() + node := route[i] + + // Compute shared secret with this node's public key + secret := sharedSecret(ephPriv, node.PublicKey) + encKey, macKey := deriveKeys(secret) + + hops[i] = hopInfo{ + ephPub: ephPub, + ephPriv: ephPriv, + encKey: encKey, + macKey: macKey, } - shared, err := curve25519.X25519(ephPrivate, nodePubKey) - if err != nil { - return nil, err + if debugMode { + log.Printf("[SPHINX-CREATE] Hop %d (%s): ephPub=%s nodePub=%s secret=%s macKey=%s", + i, node.Name, + base64.StdEncoding.EncodeToString(ephPub)[:16], + base64.StdEncoding.EncodeToString(node.PublicKey)[:16], + base64.StdEncoding.EncodeToString(secret)[:16], + base64.StdEncoding.EncodeToString(macKey)[:16]) } - sharedSecrets = append([][]byte{shared}, sharedSecrets...) + } + + // Build layers from exit to entry (reverse order) + // Each layer wraps the previous one + currentPayload := padded - kdf := hkdf.New(sha256.New, shared, nil, []byte("fog-sphinx-v2")) - key := make([]byte, AESKeySize) - kdf.Read(key) + for i := len(route) - 1; i >= 0; i-- { + hop := hops[i] - block, err := aes.NewCipher(key) + // Encrypt the current payload (which includes the next layer's header) + encrypted, err := aesEncrypt(hop.encKey, currentPayload) if err != nil { - return nil, err + return nil } - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err + + // Build routing info - just the next hop address + var nextHop string + isExit := (i == len(route)-1) + if isExit { + if isDummy { + nextHop = "DUMMY" + } else { + nextHop = "EXIT" + } + } else { + nextHop = route[i+1].Address } - nonce := make([]byte, NonceSize) - rand.Read(nonce) + routingPadded := make([]byte, 128) + copy(routingPadded, []byte(nextHop)) - encrypted = gcm.Seal(nonce, nonce, encrypted, nil) - } + // MAC over routing info + mac := computeMAC(hop.macKey, routingPadded) - header := &SphinxHeader{ - Version: 2, - Routing: routing, + // Header = ephemeral pubkey + routing + mac (176 bytes) + header := make([]byte, 0, 176) + header = append(header, hop.ephPub...) // 32 bytes + header = append(header, routingPadded...) // 128 bytes + header = append(header, mac[:16]...) // 16 bytes + + // New payload = header + encrypted previous payload + currentPayload = append(header, encrypted...) + + if debugMode { + log.Printf("[SPHINX-CREATE] Layer %d: header=%d encrypted=%d total=%d", + i, len(header), len(encrypted), len(currentPayload)) + } } - copy(header.EphKey[:], ephPublic) - mac := hmac.New(sha256.New, sharedSecrets[0]) - mac.Write(encrypted) - copy(header.MAC[:], mac.Sum(nil)) + if debugMode { + log.Printf("[SPHINX-CREATE] Final: Header=%d Payload=%d", + len(currentPayload[:HeaderSize]), len(currentPayload[HeaderSize:])) + } return &SphinxPacket{ - Header: header, - Payload: encrypted, - }, nil + Header: currentPayload[:HeaderSize], + Payload: currentPayload[HeaderSize:], + } } -func processSphinxPacket(packet *SphinxPacket) (*RoutingInfo, []byte, error) { - shared, err := curve25519.X25519(localNode.Private, packet.Header.EphKey[:]) - if err != nil { - return nil, nil, err +func processSphinxPacket(packet *SphinxPacket) { + if len(packet.Header) < 176 { + log.Printf("[SPHINX] Header too short: %d bytes", len(packet.Header)) + return } - kdf := hkdf.New(sha256.New, shared, nil, []byte("fog-sphinx-v2")) - key := make([]byte, AESKeySize) - kdf.Read(key) + // Extract ephemeral public key + ephPub := packet.Header[:32] - mac := hmac.New(sha256.New, shared) - mac.Write(packet.Payload) - if !hmac.Equal(packet.Header.MAC[:], mac.Sum(nil)[:HMACSize]) { - return nil, nil, errors.New("HMAC verification failed") - } + // Compute shared secret with our private key + secret := sharedSecret(local.Private, ephPub) + encKey, macKey := deriveKeys(secret) - block, err := aes.NewCipher(key) - if err != nil { - return nil, nil, err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, nil, err - } + // Extract and verify routing info + routingInfo := packet.Header[32:160] + receivedMAC := packet.Header[160:176] - if len(packet.Payload) < NonceSize { - return nil, nil, errors.New("payload too short") + if debugMode { + log.Printf("[SPHINX-RECV] ephPub=%s localPub=%s secret=%s macKey=%s", + base64.StdEncoding.EncodeToString(ephPub)[:16], + base64.StdEncoding.EncodeToString(local.Public)[:16], + base64.StdEncoding.EncodeToString(secret)[:16], + base64.StdEncoding.EncodeToString(macKey)[:16]) + expectedMAC := computeMAC(macKey, routingInfo) + log.Printf("[SPHINX-RECV] receivedMAC=%s expectedMAC=%s", + base64.StdEncoding.EncodeToString(receivedMAC), + base64.StdEncoding.EncodeToString(expectedMAC[:16])) + log.Printf("[SPHINX-RECV] Header=%d Payload=%d", len(packet.Header), len(packet.Payload)) } - nonce := packet.Payload[:NonceSize] - ciphertext := packet.Payload[NonceSize:] + if !verifyMAC(macKey, routingInfo, receivedMAC) { + log.Printf("[SPHINX] MAC verification failed") + return + } - decrypted, err := gcm.Open(nil, nonce, ciphertext, nil) + // Decrypt payload + if debugMode { + log.Printf("[SPHINX-RECV] Decrypting payload of %d bytes with encKey=%s", + len(packet.Payload), base64.StdEncoding.EncodeToString(encKey)[:16]) + } + decrypted, err := aesDecrypt(encKey, packet.Payload) if err != nil { - return nil, nil, err + log.Printf("[SPHINX] Decryption failed: %v", err) + return + } + + // Parse routing info to find next hop + // Format: "address\0nextEphPub..." or just "EXIT\0..." or "DUMMY\0..." + nullIdx := bytes.IndexByte(routingInfo, 0) + var nextHopAddr string + if nullIdx == -1 { + nextHopAddr = string(routingInfo) + } else { + nextHopAddr = string(routingInfo[:nullIdx]) } + nextHopAddr = strings.TrimSpace(nextHopAddr) - routingParts := bytes.Split(packet.Header.Routing, []byte{0}) - if len(routingParts) == 0 { - return nil, nil, errors.New("no routing info") + if nextHopAddr == "DUMMY" { + if debugMode { + log.Printf("[SPHINX] Discarded dummy message") + } + return } - var info RoutingInfo - if err := json.Unmarshal(routingParts[0], &info); err != nil { - return nil, nil, err + if nextHopAddr == "EXIT" { + deliverMessage(decrypted) + return } - if info.IsExit { - if len(decrypted) < 4 { - return nil, nil, errors.New("invalid exit payload") + // Forward to next hop - decrypted payload contains the complete next packet + if len(decrypted) > HeaderSize { + nextPacket := &SphinxPacket{ + Header: decrypted[:HeaderSize], + Payload: decrypted[HeaderSize:], } - - originalLen := binary.BigEndian.Uint32(decrypted[0:4]) - if originalLen > uint32(len(decrypted)-4) { - return nil, nil, errors.New("invalid length") + + node := pki.Get(findNodeByAddress(nextHopAddr)) + if node != nil { + // Add delay before forwarding + delay := time.Duration(500+cryptoRandInt(2000)) * time.Millisecond + time.Sleep(delay) + + if err := sendToNode(node, nextPacket); err != nil { + log.Printf("[SPHINX] Forward failed: %v", err) + } else if debugMode { + log.Printf("[SPHINX] Forwarded to %s", nextHopAddr) + } + } else { + log.Printf("[SPHINX] Unknown next hop: %s", nextHopAddr) } - - original := decrypted[4 : 4+originalLen] - - if debug { - log.Printf("[SPHINX] Exit node: extracted %d bytes from %d padded", - originalLen, len(decrypted)) + } +} + +func findNodeByAddress(addr string) string { + for _, n := range pki.GetAll() { + if n.Address == addr { + return n.ID } - - return &info, original, nil } + return "" +} - newHeader := &SphinxHeader{ - Version: packet.Header.Version, - EphKey: packet.Header.EphKey, - Routing: bytes.Join(routingParts[1:], []byte{0}), +func sendToNode(node *Node, packet *SphinxPacket) error { + conn, err := dialTor(node.Address) + if err != nil { + return err } - copy(newHeader.MAC[:], packet.Header.MAC[:]) + defer conn.Close() + conn.SetDeadline(time.Now().Add(30 * time.Second)) + + // Send SPHINX command + data := append(packet.Header, packet.Payload...) + fmt.Fprintf(conn, "SPHINX %d\r\n", len(data)) + conn.Write(data) + conn.Write([]byte("\r\n")) - newPacket := &SphinxPacket{ - Header: newHeader, - Payload: decrypted, + // Read response + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + return err } - var buf bytes.Buffer - json.NewEncoder(&buf).Encode(newPacket) + if !strings.HasPrefix(line, "OK") { + return errors.New("node rejected packet") + } - return &info, buf.Bytes(), nil + return nil } -// ============================================================================ -// BATCH MIXING -// ============================================================================ - -func (b *Batch) Add(packet *SphinxPacket) { - b.mu.Lock() - defer b.mu.Unlock() +func padPayload(data []byte) []byte { + // Format: [4 bytes length][data][random padding to PayloadMax] + result := make([]byte, PayloadMax) + binary.BigEndian.PutUint32(result[:4], uint32(len(data))) + copy(result[4:], data) + rand.Read(result[4+len(data):]) + return result +} - if len(b.packets) == 0 { - b.start = time.Now() +func unpadPayload(padded []byte) ([]byte, error) { + if len(padded) < 4 { + return nil, errors.New("payload too short") } - - b.packets = append(b.packets, packet) - - if len(b.packets) >= BatchSize { - go b.Flush() + length := binary.BigEndian.Uint32(padded[:4]) + if int(length) > len(padded)-4 { + return nil, errors.New("invalid length") } + return padded[4 : 4+length], nil } -func (b *Batch) Flush() { - b.mu.Lock() - if len(b.packets) == 0 { - b.mu.Unlock() +func deliverMessage(padded []byte) { + data, err := unpadPayload(padded) + if err != nil { + log.Printf("[EXIT] Unpad failed: %v", err) return } - toSend := b.packets - b.packets = make([]*SphinxPacket, 0) - b.mu.Unlock() - - for i := len(toSend) - 1; i > 0; i-- { - j := int(secureRandInt64(int64(i + 1))) - toSend[i], toSend[j] = toSend[j], toSend[i] + // Parse message + msg := parseMessage(data) + if msg == nil { + log.Printf("[EXIT] Parse failed") + return } - for _, packet := range toSend { - go forwardSphinxPacket(packet) + // Deliver via SMTP + if err := deliverSMTP(msg); err != nil { + log.Printf("[EXIT] Delivery failed: %v", err) + atomic.AddInt64(&stats.Failed, 1) + return } - atomic.AddInt64(&stats.MixFwd, int64(len(toSend))) -} - -func (b *Batch) AutoFlush() { - ticker := time.NewTicker(BatchWindow) - defer ticker.Stop() - - for range ticker.C { - b.Flush() - } + atomic.AddInt64(&stats.Delivered, 1) + log.Printf("[EXIT] Delivered to %v", msg.To) } -// ============================================================================ +// ============================================================================= // SMTP SERVER -// ============================================================================ +// ============================================================================= -func smtpServer(addr string) { - ln, err := net.Listen("tcp", addr) +func startSMTP(addr string) error { + listener, err := net.Listen("tcp", addr) if err != nil { - log.Fatal(err) + return err } - defer ln.Close() log.Printf("[SMTP] Listening on %s", addr) + go func() { + <-ctx.Done() + listener.Close() + }() + for { - conn, err := ln.Accept() + conn, err := listener.Accept() if err != nil { + if ctx.Err() != nil { + return nil + } continue } go handleSMTP(conn) @@ -942,220 +1198,313 @@ func smtpServer(addr string) { func handleSMTP(conn net.Conn) { defer conn.Close() - - remote := conn.RemoteAddr().String() - log.Printf("[SMTP] Connection from %s", remote) + conn.SetDeadline(time.Now().Add(5 * time.Minute)) reader := bufio.NewReader(conn) writer := bufio.NewWriter(conn) - writer.WriteString("220 fog SMTP Ready\r\n") - writer.Flush() + write := func(s string) { + writer.WriteString(s + "\r\n") + writer.Flush() + } + + write(fmt.Sprintf("220 %s fog/%s", hostname, Version)) var from string - var recipients []string + var to []string var data bytes.Buffer + inData := false for { line, err := reader.ReadString('\n') if err != nil { return } - line = strings.TrimSpace(line) - cmd := strings.ToUpper(strings.Fields(line)[0]) - if debug { - log.Printf("[SMTP] <- %s: %s", remote, line) - } - - switch cmd { - case "EHLO", "HELO": - writer.WriteString("250 Hello\r\n") - - case "MAIL": - from = extractEmail(line) - writer.WriteString("250 OK\r\n") + if inData { + if line == "." { + inData = false + write("250 OK queued") - case "RCPT": - to := extractEmail(line) - recipients = append(recipients, to) - writer.WriteString("250 OK\r\n") - - case "DATA": - writer.WriteString("354 End data with <CR><LF>.<CR><LF>\r\n") - writer.Flush() + msg := &Message{ + ID: hex.EncodeToString(cryptoRandBytes(8)), + From: from, + To: to, + Data: data.Bytes(), + ReceivedAt: time.Now(), + } - for { - line, err := reader.ReadString('\n') - if err != nil { - return + select { + case queue <- msg: + atomic.AddInt64(&stats.Received, 1) + log.Printf("[SMTP] Queued %s from %s to %v", msg.ID, from, to) + default: + log.Printf("[SMTP] Queue full, dropping message") } - if line == ".\r\n" { - break + + from = "" + to = nil + data.Reset() + } else { + if strings.HasPrefix(line, ".") { + line = line[1:] } - data.WriteString(line) + data.WriteString(line + "\r\n") } + continue + } - msg := &Message{ - ID: generateMessageID(), - From: from, - To: recipients, - Data: data.Bytes(), - Time: time.Now(), - } + upper := strings.ToUpper(line) - if enableDelay { - if err := delayPool.Enqueue(msg); err != nil { - log.Printf("[POOL] Enqueue error: %v", err) - writer.WriteString("451 Queue error\r\n") - } else { - writer.WriteString("250 Queued for delayed delivery\r\n") - atomic.AddInt64(&stats.Recv, 1) - } - } else { - go sendMessage(msg) - writer.WriteString("250 OK\r\n") - atomic.AddInt64(&stats.Recv, 1) - } + switch { + case strings.HasPrefix(upper, "HELO"), strings.HasPrefix(upper, "EHLO"): + write(fmt.Sprintf("250 %s", hostname)) + + case strings.HasPrefix(upper, "MAIL FROM:"): + from = extractAddress(line[10:]) + write("250 OK") + + case strings.HasPrefix(upper, "RCPT TO:"): + to = append(to, extractAddress(line[8:])) + write("250 OK") + + case upper == "DATA": + write("354 Start mail input") + inData = true + case upper == "QUIT": + write("221 Bye") + return + + case upper == "RSET": from = "" - recipients = nil + to = nil data.Reset() + write("250 OK") - case "QUIT": - writer.WriteString("221 Bye\r\n") - writer.Flush() - return + case upper == "NOOP": + write("250 OK") default: - writer.WriteString("500 Unknown command\r\n") + write("500 Unknown command") } - - writer.Flush() } } -func extractEmail(line string) string { - start := strings.Index(line, "<") - end := strings.Index(line, ">") - if start >= 0 && end > start { - return line[start+1 : end] +func extractAddress(s string) string { + s = strings.TrimSpace(s) + // Handle "Display Name <email@domain>" format + if start := strings.Index(s, "<"); start != -1 { + if end := strings.Index(s, ">"); end > start { + return s[start+1 : end] + } } - parts := strings.Fields(line) - if len(parts) >= 2 { - return parts[1] + // Handle "<email@domain>" format + if strings.HasPrefix(s, "<") && strings.HasSuffix(s, ">") { + return s[1 : len(s)-1] } - return "" + return s } -func generateMessageID() string { - return fmt.Sprintf("%s@fog", secureRandHex(16)) +// ============================================================================= +// NODE SERVER (receives Sphinx packets and Gossip) +// ============================================================================= + +func startNodeServer(addr string) error { + listener, err := net.Listen("tcp", addr) + if err != nil { + return err + } + + log.Printf("[NODE] Listening on %s", addr) + + go func() { + <-ctx.Done() + listener.Close() + }() + + go func() { + defer wg.Done() + for { + conn, err := listener.Accept() + if err != nil { + if ctx.Err() != nil { + return + } + continue + } + go handleNode(conn) + } + }() + + return nil } -// ============================================================================ -// MESSAGE SENDING -// ============================================================================ +func handleNode(conn net.Conn) { + defer conn.Close() + conn.SetDeadline(time.Now().Add(60 * time.Second)) -func sendMessage(msg *Message) { - if enableSphinx { - sendViaSphinx(msg) - } else { - sendDirect(msg) + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + return + } + line = strings.TrimSpace(line) + + switch { + case strings.HasPrefix(line, "SPHINX "): + var size int + fmt.Sscanf(line, "SPHINX %d", &size) + if size > 0 && size < 1<<20 { + data := make([]byte, size) + io.ReadFull(reader, data) + + if len(data) > HeaderSize { + packet := &SphinxPacket{ + Header: data[:HeaderSize], + Payload: data[HeaderSize:], + } + pool.Add(packet) + conn.Write([]byte("OK\r\n")) + } + } + + case strings.HasPrefix(line, "GOSSIP "): + var size int + fmt.Sscanf(line, "GOSSIP %d", &size) + if size > 0 && size < 1<<20 { + data := make([]byte, size) + io.ReadFull(reader, data) + pki.MergeFromGossip(data) + + // Respond with our node list + myData := pki.ExportForGossip() + fmt.Fprintf(conn, "GOSSIP %d\r\n", len(myData)) + conn.Write(myData) + conn.Write([]byte("\r\n")) + } + + case line == "PING": + conn.Write([]byte("PONG\r\n")) + + case line == "INFO": + info := fmt.Sprintf("fog/%s %s %d nodes\r\n", + Version, local.Name, pki.HealthyCount()) + conn.Write([]byte(info)) } } -func sendViaSphinx(msg *Message) { - path, err := pki.RandomPath(SphinxHops) - if err != nil { - log.Printf("[SPHINX] Path error: %v", err) - atomic.AddInt64(&stats.Failed, 1) +// ============================================================================= +// RELAY WORKER +// ============================================================================= + +func relayWorker(id int) { + defer wg.Done() + + for { + select { + case <-ctx.Done(): + return + case msg := <-queue: + processMessage(msg, id) + } + } +} + +func processMessage(msg *Message, workerID int) { + // Check replay + msgHash := hex.EncodeToString(computeMAC([]byte("replay"), msg.Data)[:16]) + if replay.Check(msgHash) { + log.Printf("[WORKER %d] Replay detected: %s", workerID, msg.ID) return } + replay.Add(msgHash) - packet, err := buildSphinxPacket(path, msg.Data) - if err != nil { - log.Printf("[SPHINX] Build error: %v", err) + // Random delay + delay := time.Duration(100+cryptoRandInt(2000)) * time.Millisecond + time.Sleep(delay) + + // Always use Sphinx routing (no direct relay fallback) + if !useSphinx.Load() { + log.Printf("[WORKER %d] Sphinx disabled, cannot route %s", workerID, msg.ID) atomic.AddInt64(&stats.Failed, 1) return } - time.Sleep(secureRandDelay(MinDelay, MaxDelay)) - - batch.Add(packet) - atomic.AddInt64(&stats.Sphinx, 1) + healthy := pki.GetHealthy() + if len(healthy) < MinHops { + log.Printf("[WORKER %d] Not enough healthy nodes (%d < %d) for %s", + workerID, len(healthy), MinHops, msg.ID) + atomic.AddInt64(&stats.Failed, 1) + return + } - if debug { - log.Printf("[SPHINX] Queued %s via %s → %s → %s", - msg.ID, path[0].Name, path[1].Name, path[2].Name) + hopCount := MinHops + cryptoRandInt(MaxHops-MinHops+1) + route := selectRoute(healthy, hopCount) + if route == nil { + log.Printf("[WORKER %d] Failed to select route for %s", workerID, msg.ID) + atomic.AddInt64(&stats.Failed, 1) + return } -} -func forwardSphinxPacket(packet *SphinxPacket) { - info, payload, err := processSphinxPacket(packet) - if err != nil { - log.Printf("[SPHINX] Process error: %v", err) + packet := createSphinxPacket(msg.Data, route, false) + if packet == nil { + log.Printf("[WORKER %d] Failed to create Sphinx packet for %s", workerID, msg.ID) atomic.AddInt64(&stats.Failed, 1) return } - if info.IsExit { - // Sanitize headers before delivery - sanitizedPayload := sanitizeHeaders(payload) - - for _, to := range extractRecipients(sanitizedPayload) { - if err := deliverSMTP(to, sanitizedPayload); err != nil { - log.Printf("[SPHINX] Delivery failed %s: %v", to, err) - atomic.AddInt64(&stats.Failed, 1) - } else { - if debug { - log.Printf("[SPHINX] Delivered to %s", to) - } - atomic.AddInt64(&stats.Sent, 1) - } - } - } else { - forwardToNode(info.NextHop, payload) + if err := sendToNode(route[0], packet); err != nil { + log.Printf("[WORKER %d] Failed to send to first hop for %s: %v", workerID, msg.ID, err) + atomic.AddInt64(&stats.Failed, 1) + return } + + atomic.AddInt64(&stats.SphinxRouted, 1) + log.Printf("[WORKER %d] Sphinx routed %s via %d hops", workerID, msg.ID, hopCount) } -func sendDirect(msg *Message) { - for _, to := range msg.To { - if err := deliverSMTP(to, msg.Data); err != nil { - log.Printf("[DIRECT] Delivery failed %s: %v", to, err) - atomic.AddInt64(&stats.Failed, 1) - } else { - if debug { - log.Printf("[DIRECT] Delivered to %s", to) - } - atomic.AddInt64(&stats.Sent, 1) +func directRelay(msg *Message) error { + for _, rcpt := range msg.To { + if err := deliverSMTP(&Message{ + From: msg.From, + To: []string{rcpt}, + Data: msg.Data, + }); err != nil { + return err } } - atomic.AddInt64(&stats.Direct, 1) + return nil } -func deliverSMTP(to string, data []byte) error { - parts := strings.SplitN(to, "@", 2) +func deliverSMTP(msg *Message) error { + if len(msg.To) == 0 { + return errors.New("no recipients") + } + + rcpt := msg.To[0] + parts := strings.Split(rcpt, "@") if len(parts) != 2 { return errors.New("invalid recipient") } domain := parts[1] - host := domain + ":25" - - var conn net.Conn - var err error - + // Determine SMTP server + var smtpAddr string if strings.HasSuffix(domain, ".onion") { - dialer, err := proxy.SOCKS5("tcp", TorSocks, nil, proxy.Direct) - if err != nil { - return err - } - conn, err = dialer.Dial("tcp", host) + smtpAddr = domain + ":25" } else { - conn, err = net.DialTimeout("tcp", host, 30*time.Second) + mx, err := net.LookupMX(domain) + if err != nil || len(mx) == 0 { + smtpAddr = domain + ":25" + } else { + smtpAddr = mx[0].Host + ":25" + } } + // Connect via Tor + conn, err := dialTor(smtpAddr) if err != nil { return err } @@ -1165,319 +1514,410 @@ func deliverSMTP(to string, data []byte) error { if err != nil { return err } - defer client.Quit() + defer client.Close() - if err := client.Mail("fog@anonymous.invalid"); err != nil { + // Extract bare email address from From (remove display name if present) + fromAddr := extractAddress(msg.From) + if fromAddr == "" { + fromAddr = msg.From + } + + if err := client.Mail(fromAddr); err != nil { + return err + } + if err := client.Rcpt(rcpt); err != nil { return err } - if err := client.Rcpt(to); err != nil { + + wc, err := client.Data() + if err != nil { return err } - w, err := client.Data() + _, err = wc.Write(msg.Data) if err != nil { + wc.Close() return err } - defer w.Close() - _, err = w.Write(data) - return err + return wc.Close() } -func extractRecipients(data []byte) []string { - lines := bytes.Split(data, []byte("\r\n")) +func parseMessage(data []byte) *Message { + // Simple parser - extract From and To from headers + lines := strings.Split(string(data), "\n") + msg := &Message{Data: data} + for _, line := range lines { - if bytes.HasPrefix(bytes.ToLower(line), []byte("to:")) { - addr := strings.TrimSpace(string(line[3:])) - return []string{addr} + line = strings.TrimSpace(line) + if line == "" { + break // End of headers } + lower := strings.ToLower(line) + if strings.HasPrefix(lower, "from:") { + msg.From = extractAddress(line[5:]) + } else if strings.HasPrefix(lower, "to:") { + msg.To = append(msg.To, extractAddress(line[3:])) + } + } + + if msg.From == "" { + msg.From = "anonymous@fog.local" } - return []string{} + + return msg } -// ============================================================================ -// SPHINX NODE SERVER -// ============================================================================ +// ============================================================================= +// HEALTH CHECKER +// ============================================================================= -func sphinxNodeServer(addr string) { - ln, err := net.Listen("tcp", addr) - if err != nil { - log.Fatal(err) - } - defer ln.Close() +func healthChecker() { + defer wg.Done() - log.Printf("[NODE] Listening on %s", addr) + ticker := time.NewTicker(HealthInterval) + defer ticker.Stop() for { - conn, err := ln.Accept() - if err != nil { - continue + select { + case <-ctx.Done(): + return + case <-ticker.C: + checkAllNodes() } - go handleSphinxNode(conn) } } -func handleSphinxNode(conn net.Conn) { - defer conn.Close() - - var packet SphinxPacket - if err := json.NewDecoder(conn).Decode(&packet); err != nil { - return +func checkAllNodes() { + others := pki.GetOthers() + for _, node := range others { + go checkNode(node) } - - atomic.AddInt64(&stats.MixRecv, 1) - - batch.Add(&packet) } -func forwardToNode(addr string, payload []byte) { - dialer, err := proxy.SOCKS5("tcp", TorSocks, nil, proxy.Direct) +func checkNode(node *Node) { + conn, err := dialTor(node.Address) if err != nil { + pki.SetHealth(node.ID, false) return } + defer conn.Close() + conn.SetDeadline(time.Now().Add(15 * time.Second)) - conn, err := dialer.Dial("tcp", addr) - if err != nil { + fmt.Fprintf(conn, "PING\r\n") + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil || !strings.HasPrefix(line, "PONG") { + pki.SetHealth(node.ID, false) return } - defer conn.Close() - conn.Write(payload) + pki.SetHealth(node.ID, true) } -// ============================================================================ -// HEALTH CHECKER -// ============================================================================ - -func healthChecker() { - ticker := time.NewTicker(HealthInterval) - defer ticker.Stop() +// ============================================================================= +// STATS +// ============================================================================= - for range ticker.C { - healthy := pki.GetHealthy() - log.Printf("[HEALTH] Checking %d nodes", len(pki.Nodes)-1) +func statsMonitor() { + defer wg.Done() - for _, node := range pki.Nodes { - if node.ID == localNode.ID { - continue - } + ticker := time.NewTicker(StatsInterval) + defer ticker.Stop() - go func(n *Node) { - if checkNode(n.Address) { - pki.mu.Lock() - n.Healthy = true - n.LastOK = time.Now() - pki.mu.Unlock() - - if debug { - log.Printf("[HEALTH] %s OK", n.Name) - } - } else { - pki.mu.Lock() - n.Healthy = false - pki.mu.Unlock() - - log.Printf("[HEALTH] %s FAILED", n.Name) - } - }(node) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + uptime := time.Since(stats.Start).Truncate(time.Second) + log.Printf("[STATS] Up:%v | R:%d D:%d F:%d | Sphinx:%d Direct:%d | Cover:%d Gossip:%d | Pool:%d Nodes:%d", + uptime, + atomic.LoadInt64(&stats.Received), + atomic.LoadInt64(&stats.Delivered), + atomic.LoadInt64(&stats.Failed), + atomic.LoadInt64(&stats.SphinxRouted), + atomic.LoadInt64(&stats.DirectRelay), + atomic.LoadInt64(&stats.CoverSent), + atomic.LoadInt64(&stats.GossipExch), + pool.Size(), + pki.HealthyCount()) } - - time.Sleep(5 * time.Second) - healthy = pki.GetHealthy() - log.Printf("[HEALTH] Done. %d nodes healthy", len(healthy)) } } -func checkNode(addr string) bool { - dialer, err := proxy.SOCKS5("tcp", TorSocks, nil, proxy.Direct) - if err != nil { - return false - } +// ============================================================================= +// HELPERS +// ============================================================================= - conn, err := dialer.Dial("tcp", addr) - if err != nil { - return false - } - conn.Close() - return true +func dialTor(addr string) (net.Conn, error) { + return torDialer.Dial("tcp", addr) } -// ============================================================================ -// DELAY POOL SCHEDULER -// ============================================================================ - -func delayPoolScheduler() { - ticker := time.NewTicker(PoolInterval) - defer ticker.Stop() - - log.Printf("[POOL] Scheduler started (check every %v)", PoolInterval) - - for range ticker.C { - messages, err := delayPool.GetReady() - if err != nil { - log.Printf("[POOL] GetReady error: %v", err) - continue - } - - if len(messages) == 0 { - continue - } +func shuffleNodes(nodes []*Node) { + for i := len(nodes) - 1; i > 0; i-- { + j := cryptoRandInt(i + 1) + nodes[i], nodes[j] = nodes[j], nodes[i] + } +} - log.Printf("[POOL] Processing %d ready messages", len(messages)) +func shufflePackets(packets []*SphinxPacket) { + for i := len(packets) - 1; i > 0; i-- { + j := cryptoRandInt(i + 1) + packets[i], packets[j] = packets[j], packets[i] + } +} - for _, qmsg := range messages { - go func(m *QueuedMessage) { - msg := &Message{ - ID: m.ID, - From: m.From, - To: []string{m.To}, - Data: m.Data, - Time: m.EnqueueTime, +func initNode(addr string) { + var pub, priv []byte + var id string + + // Try to load existing key + if keyFile != "" { + if data, err := os.ReadFile(keyFile); err == nil { + var saved struct { + ID string `json:"id"` + Public string `json:"public_key"` + Private string `json:"private_key"` + } + if err := json.Unmarshal(data, &saved); err == nil { + pub, _ = base64.StdEncoding.DecodeString(saved.Public) + priv, _ = base64.StdEncoding.DecodeString(saved.Private) + id = saved.ID + if len(pub) == 32 && len(priv) == 32 && id != "" { + log.Printf("[NODE] Loaded existing keypair from %s", keyFile) + } else { + pub, priv, id = nil, nil, "" } + } + } + } - sendMessage(msg) - - if err := delayPool.Delete(m.ID); err != nil { - log.Printf("[POOL] Delete error: %v", err) + // Generate new key if not loaded + if pub == nil || priv == nil { + pub, priv = generateKeyPair() + id = hex.EncodeToString(computeMAC(pub, []byte("node-id"))[:16]) + log.Printf("[NODE] Generated new keypair") + + // Save new key + if keyFile != "" { + saved := struct { + ID string `json:"id"` + Public string `json:"public_key"` + Private string `json:"private_key"` + }{ + ID: id, + Public: base64.StdEncoding.EncodeToString(pub), + Private: base64.StdEncoding.EncodeToString(priv), + } + if data, err := json.MarshalIndent(saved, "", " "); err == nil { + if err := os.WriteFile(keyFile, data, 0400); err == nil { + log.Printf("[NODE] Saved keypair to %s", keyFile) } else { - if debug { - waited := time.Since(m.EnqueueTime) - log.Printf("[POOL] Sent %s after %v delay", m.ID, waited.Round(time.Second)) - } + log.Printf("[NODE] Warning: failed to save keypair: %v", err) } - }(qmsg) + } } } -} -// ============================================================================ -// STATS REPORTER -// ============================================================================ - -func statsReporter() { - ticker := time.NewTicker(StatsInterval) - defer ticker.Stop() + local = LocalNode{ + ID: id, + Public: pub, + Private: priv, + Address: addr, + Name: hostname, + } - for range ticker.C { - uptime := time.Since(stats.Start) - healthy := len(pki.GetHealthy()) - - var queued int64 - if enableDelay { - queued = atomic.LoadInt64(&stats.Queued) + // Determine public address for PKI (use hostname, not local bind address) + publicAddr := addr + if hostname != "" && hostname != "fog.onion" { + // Extract port from addr + port := "9999" + if _, p, err := net.SplitHostPort(addr); err == nil { + port = p } - - log.Printf("[STATS] Up:%v R:%d S:%d F:%d | Sphinx:%d Direct:%d | Mix R:%d F:%d | Q:%d D:%d | Healthy:%d", - uptime.Round(time.Second), - atomic.LoadInt64(&stats.Recv), - atomic.LoadInt64(&stats.Sent), - atomic.LoadInt64(&stats.Failed), - atomic.LoadInt64(&stats.Sphinx), - atomic.LoadInt64(&stats.Direct), - atomic.LoadInt64(&stats.MixRecv), - atomic.LoadInt64(&stats.MixFwd), - queued, - atomic.LoadInt64(&stats.Delayed), - healthy, - ) - } + publicAddr = hostname + ":" + port + } + + // Add ourselves to PKI + pki.Add(&Node{ + ID: local.ID, + PublicKey: local.Public, + Address: publicAddr, + Name: local.Name, + Version: Version, + LastSeen: time.Now(), + Healthy: true, + }) } -// ============================================================================ +// ============================================================================= // MAIN -// ============================================================================ +// ============================================================================= func main() { - var ( - name = flag.String("name", "", "Node address (.onion:port)") - shortName = flag.String("short-name", "", "Short name for logs") - smtpAddr = flag.String("smtp", "127.0.0.1:"+DefaultPort, "SMTP listen address") - nodeAddr = flag.String("node", "127.0.0.1:"+NodePort, "Sphinx node address") - pkiFile = flag.String("pki-file", "", "PKI nodes.json file") - dataDir = flag.String("data-dir", "fog-data", "Data directory") - - minPoolDelay = flag.Duration("min-delay", DefaultMinPoolDelay, "Minimum delay pool time") - maxPoolDelay = flag.Duration("max-delay", DefaultMaxPoolDelay, "Maximum delay pool time") - delayStrat = flag.String("delay-strategy", "exponential", "Delay strategy: exponential, constant, poisson") - - sphinx = flag.Bool("sphinx", false, "Enable Sphinx routing") - delay = flag.Bool("delay", false, "Enable delay pool") - showDebug = flag.Bool("debug", false, "Debug logging") - exportNode = flag.Bool("export-node-info", false, "Export node info and exit") - ) - - flag.Parse() + smtpAddr := flag.String("smtp", DefaultSMTP, "SMTP listen address") + nodeAddr := flag.String("node", DefaultNode, "Node listen address") + name := flag.String("name", "fog.onion", "Server hostname") + sphinx := flag.Bool("sphinx", false, "Enable Sphinx routing") + pkiFlag := flag.String("pki", "", "PKI file path") + keyFlag := flag.String("key", "", "Node key file path (for persistent identity)") + debug := flag.Bool("debug", false, "Enable debug logging") + exportInfo := flag.Bool("export-node-info", false, "Export node info and exit") + version := flag.Bool("version", false, "Show version") - enableSphinx = *sphinx - enableDelay = *delay - debug = *showDebug + flag.Parse() - if *name == "" { - log.Fatal("Error: -name required") + if *version { + fmt.Printf("fog v%s\n\n", Version) + fmt.Println("Features:") + fmt.Println(" - Sphinx multi-hop routing (3-6 hops)") + fmt.Println(" - PKI Gossip protocol (fully decentralized)") + fmt.Println(" - Threshold batch mixing") + fmt.Println(" - Realistic cover traffic") + fmt.Println(" - AES-256-GCM encryption") + fmt.Println(" - Forward secrecy (Curve25519 ECDH)") + os.Exit(0) } - if *shortName == "" { - log.Fatal("Error: -short-name required") - } + debugMode = *debug - if err := localNode.Init(*name, *shortName); err != nil { - log.Fatal(err) - } + // Initialize + pki = newPKI() + pool = newBatchPool() + replay = newReplayCache() + queue = make(chan *Message, QueueSize) + stats = &Stats{Start: time.Now()} + cover = newCoverTraffic() - if *exportNode { - if err := localNode.ExportInfo(*name, *shortName); err != nil { - log.Fatal(err) + hostname = *name + pkiFile = *pkiFlag + keyFile = *keyFlag + + // Load PKI first (before initNode, so we know other nodes) + if pkiFile != "" { + if err := pki.Load(pkiFile); err != nil { + log.Printf("[PKI] Load failed: %v", err) + } else { + removed := pki.CleanupDuplicates() + if removed > 0 { + log.Printf("[PKI] Cleaned up %d duplicate nodes", removed) + } } - log.Printf("[EXPORT] Written to nodes.json") - return } - os.MkdirAll(*dataDir, 0700) + initNode(*nodeAddr) - if enableDelay { - strategy := parseDelayStrategy(*delayStrat) - dbPath := filepath.Join(*dataDir, "messages.db") + if *exportInfo { + // Need to initialize for export + hostname = *name + keyFile = *keyFlag + pki = newPKI() + initNode(*nodeAddr) - var err error - delayPool, err = NewDelayPool(dbPath, *minPoolDelay, *maxPoolDelay, strategy) - if err != nil { - log.Fatalf("[POOL] Init error: %v", err) + info := map[string]interface{}{ + "id": local.ID, + "public_key": base64.StdEncoding.EncodeToString(local.Public), + "address": fmt.Sprintf("%s:9999", *name), + "name": *name, + "version": Version, } - defer delayPool.Close() - - log.Printf("[POOL] Enabled: min=%v max=%v strategy=%s", - *minPoolDelay, *maxPoolDelay, strategy) - - go delayPoolScheduler() + data, _ := json.MarshalIndent(info, "", " ") + fmt.Println(string(data)) + os.Exit(0) } - if enableSphinx { - if *pkiFile == "" { - log.Fatal("Error: -pki-file required with -sphinx") - } - - if err := pki.Load(*pkiFile); err != nil { - log.Fatal(err) - } + // Tor + dialer, err := proxy.SOCKS5("tcp", TorSocks, nil, proxy.Direct) + if err != nil { + log.Fatalf("[TOR] Connection failed: %v", err) + } + torDialer = dialer + + ctx, cancel = context.WithCancel(context.Background()) + defer cancel() + log.Printf("[FOG] Starting v%s", Version) + log.Printf("[FOG] Hostname: %s", hostname) + + useSphinx.Store(*sphinx) + + if *sphinx { + log.Printf("[FOG] Sphinx mode ENABLED") + log.Printf("[FOG] Batch threshold: %d-%d, Cover: %d-%.0fh interval", + BatchThresholdMin, BatchThresholdMax, + int(CoverMinInterval.Minutes()), CoverMaxInterval.Hours()) + + log.Printf("[PKI] Loaded %d nodes", len(pki.GetAll())) + + wg.Add(1) go healthChecker() - go batch.AutoFlush() - go sphinxNodeServer(*nodeAddr) + + wg.Add(1) + if err := startNodeServer(*nodeAddr); err != nil { + log.Fatalf("[NODE] Failed: %v", err) + } + + wg.Add(1) + go batchWorker() + + wg.Add(1) + go gossipWorker() + + wg.Add(1) + go coverWorker() + } else { + log.Printf("[FOG] Direct relay mode") } - go replayCache.Cleanup() - go statsReporter() + // Workers + for i := 0; i < Workers; i++ { + wg.Add(1) + go relayWorker(i) + } + + wg.Add(1) + go statsMonitor() + + wg.Add(1) + go cacheCleanupWorker() - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + // Signals + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) go func() { - <-sigCh - log.Println("[SHUTDOWN] Flushing batch...") - batch.Flush() - time.Sleep(2 * time.Second) - os.Exit(0) + <-sig + log.Printf("[FOG] Shutdown signal received") + cancel() }() - smtpServer(*smtpAddr) + // Save PKI periodically + if pkiFile != "" { + go func() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + pki.Save(pkiFile) + return + case <-ticker.C: + pki.Save(pkiFile) + } + } + }() + } + + if err := startSMTP(*smtpAddr); err != nil { + log.Fatalf("[SMTP] Failed: %v", err) + } + + wg.Wait() + + if pkiFile != "" { + pki.Save(pkiFile) + } + + log.Printf("[FOG] Shutdown complete") } |
