diff options
| author | Gab <24553253+gabrix73@users.noreply.github.com> | 2025-04-17 11:22:58 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-04-17 11:22:58 +0200 |
| commit | 9aa9a6246857903b7a58ed8a5ef260bd1f92536e (patch) | |
| tree | e223a74b3aae07a975ceb8f5905872d1fb9890e9 | |
| parent | 3f9fd64e82c954799609329653645062ad0b318e (diff) | |
| download | m2usenet-and-mail2news-9aa9a6246857903b7a58ed8a5ef260bd1f92536e.tar.gz m2usenet-and-mail2news-9aa9a6246857903b7a58ed8a5ef260bd1f92536e.tar.xz m2usenet-and-mail2news-9aa9a6246857903b7a58ed8a5ef260bd1f92536e.zip | |
Create m2usenet.go
| -rw-r--r-- | m2usenet.go | 332 |
1 files changed, 332 insertions, 0 deletions
diff --git a/m2usenet.go b/m2usenet.go new file mode 100644 index 0000000..ea71c30 --- /dev/null +++ b/m2usenet.go @@ -0,0 +1,332 @@ +// /home/m2usenet/m2usenet.go +// Versione production-ready di Mail2Usenet in Go. +// Legge un'email da STDIN, verifica il token hashcash (inclusi controlli di formattazione, data, resource e hash), +// registra il token usato in un database JSON e invia il messaggio a un server NNTP tramite Tor (utilizzando un dialer SOCKS5). + +package main + +import ( + "bufio" + "bytes" + "crypto/sha1" + "encoding/json" + "errors" + "fmt" + "golang.org/x/net/proxy" + "io" + "log" + "math/rand" + "net/mail" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +var ( + // Configurazioni prelevate dalle variabili di ambiente (con valori di default per produzione) + NNTPServer = getEnv("NNTP_SERVER", "peannyjkqwqfynd24p6dszvtchkq7hfkwymi5by5y332wmosy5dwfaqd.onion") + nntpPort, _ = strconv.Atoi(getEnv("NNTP_PORT", "119")) + torProxyHost = getEnv("TOR_PROXY_HOST", "127.0.0.1") + torProxyPort, _ = strconv.Atoi(getEnv("TOR_PROXY_PORT", "9050")) + maxPostSize, _ = strconv.Atoi(getEnv("MAX_POST_SIZE", "10240")) + delayCrossPost, _ = strconv.Atoi(getEnv("DELAY_CROSSPOST", "2")) + timeWindowSec, _ = strconv.Atoi(getEnv("TIME_WINDOW_SEC", "1800")) // 30 minuti + hashcashMinBits, _ = strconv.Atoi(getEnv("HASHCASH_MIN_BITS", "24")) + dbPath = getEnv("DB_PATH", "/home/m2usenet/hashcash.json") + + tokenMutex sync.Mutex +) + +// getEnv restituisce il valore della variabile d'ambiente o il default se non impostata. +func getEnv(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return def +} + +// TokenDB rappresenta la struttura JSON per la memorizzazione dei token usati. +type TokenDB map[string]string + +// tokenAlreadySpent verifica se il token è già stato registrato. +func tokenAlreadySpent(token string) bool { + tokenMutex.Lock() + defer tokenMutex.Unlock() + + db, err := loadTokenDB() + if err != nil { + return false + } + _, exists := db[token] + return exists +} + +// markTokenSpent registra il token nel file JSON, salvando anche il timestamp. +func markTokenSpent(token string) error { + tokenMutex.Lock() + defer tokenMutex.Unlock() + + db, err := loadTokenDB() + if err != nil { + db = make(TokenDB) + } + db[token] = time.Now().UTC().Format(time.RFC3339) + return saveTokenDB(db) +} + +// loadTokenDB carica il database dei token, se esistente. +func loadTokenDB() (TokenDB, error) { + db := make(TokenDB) + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return db, nil + } + data, err := os.ReadFile(dbPath) + if err != nil { + return nil, err + } + if len(data) == 0 { + return db, nil + } + if err := json.Unmarshal(data, &db); err != nil { + return nil, err + } + return db, nil +} + +// saveTokenDB salva il database dei token sul file system. +func saveTokenDB(db TokenDB) error { + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + data, err := json.MarshalIndent(db, "", " ") + if err != nil { + return err + } + return os.WriteFile(dbPath, data, 0644) +} + +// parseHashcashDate interpreta la data dal token, accettando formati a 10 o 12 cifre. +func parseHashcashDate(dateStr string) (time.Time, error) { + layouts := []string{"0601021504", "060102150405"} + var t time.Time + var err error + for _, layout := range layouts { + t, err = time.Parse(layout, dateStr) + if err == nil { + return t, nil + } + } + return t, errors.New("formato data non valido") +} + +// verifyHashcashToken effettua i controlli sul token: +// - Formato (7 campi) +// - Versione "1" +// - Numero di bit almeno hashcashMinBits e multiplo di 4 +// - La resource deve corrispondere all'indirizzo del mittente (case-insensitive) +// - La data deve essere entro la finestra temporale ±timeWindowSec +// - Il digest SHA-1 deve avere i necessari zero iniziali +func verifyHashcashToken(token, fromAddr string) bool { + parts := strings.Split(token, ":") + if len(parts) != 7 { + log.Printf("Formato token non valido: %s", token) + return false + } + version, bitsStr, dateStr, resource, ext, randPart, counter := parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6] + if version != "1" { + log.Printf("Versione token non valida: %s", version) + return false + } + bits, err := strconv.Atoi(bitsStr) + if err != nil || bits < hashcashMinBits || bits%4 != 0 { + log.Printf("Bits non validi: %s", bitsStr) + return false + } + if strings.TrimSpace(strings.ToLower(resource)) != strings.TrimSpace(strings.ToLower(fromAddr)) { + log.Printf("Resource mismacth: %s vs %s", resource, fromAddr) + return false + } + tokenTime, err := parseHashcashDate(dateStr) + if err != nil { + log.Printf("Errore nella data del token: %v", err) + return false + } + now := time.Now().UTC() + if now.Sub(tokenTime) > time.Duration(timeWindowSec)*time.Second || + tokenTime.Sub(now) > time.Duration(timeWindowSec)*time.Second { + log.Printf("Token fuori finestra temporale: %v", now.Sub(tokenTime)) + return false + } + assembled := fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s", version, bitsStr, dateStr, resource, ext, randPart, counter) + hash := sha1.Sum([]byte(assembled)) + shaHex := fmt.Sprintf("%x", hash) + target := strings.Repeat("0", bits/4) + if !strings.HasPrefix(shaHex, target) { + log.Printf("Verifica hash fallita: %s non inizia con %s", shaHex, target) + return false + } + return true +} + +// sendViaTor stabilisce una connessione al server NNTP tramite Tor usando un dialer SOCKS5, invia il comando IHAVE e il messaggio. +func sendViaTor(server string, port int, message, messageID string) (bool, error) { + torAddr := fmt.Sprintf("%s:%d", torProxyHost, torProxyPort) + dialer, err := proxy.SOCKS5("tcp", torAddr, nil, proxy.Direct) + if err != nil { + return false, fmt.Errorf("Errore nel dialer SOCKS5: %v", err) + } + conn, err := dialer.Dial("tcp", fmt.Sprintf("%s:%d", server, port)) + if err != nil { + return false, fmt.Errorf("Connessione NNTP fallita: %v", err) + } + defer conn.Close() + + reader := bufio.NewReader(conn) + welcome, err := reader.ReadString('\n') + if err != nil { + return false, err + } + log.Printf("Connesso a NNTP: %s", strings.TrimSpace(welcome)) + + ihaveCmd := fmt.Sprintf("IHAVE %s\r\n", messageID) + if _, err := conn.Write([]byte(ihaveCmd)); err != nil { + return false, err + } + ihaveResp, err := reader.ReadString('\n') + if err != nil { + return false, err + } + log.Printf("Risposta IHAVE: %s", strings.TrimSpace(ihaveResp)) + if !strings.HasPrefix(ihaveResp, "335") { + return false, errors.New("IHAVE non accettato") + } + fullMessage := message + "\r\n.\r\n" + if _, err := conn.Write([]byte(fullMessage)); err != nil { + return false, err + } + postResp, err := reader.ReadString('\n') + if err != nil { + return false, err + } + log.Printf("Risposta posting: %s", strings.TrimSpace(postResp)) + conn.Write([]byte("QUIT\r\n")) + return strings.HasPrefix(postResp, "235"), nil +} + +func main() { + // Lettura dell'intera email da STDIN + input, err := io.ReadAll(os.Stdin) + if err != nil { + log.Fatalf("Errore lettura STDIN: %v", err) + } + msg, err := mail.ReadMessage(bytes.NewReader(input)) + if err != nil { + log.Fatalf("Errore nel parsing dell'email: %v", err) + } + header := msg.Header + + fromHeader := header.Get("From") + if fromHeader == "" { + log.Fatalf("Header From mancante") + } + fromAddr := fromHeader + if strings.Contains(fromHeader, "<") && strings.Contains(fromHeader, ">") { + fromAddr = strings.TrimSpace(strings.Split(strings.Split(fromHeader, "<")[1], ">")[0]) + } + newsgroups := header.Get("Newsgroups") + if newsgroups == "" { + log.Fatalf("Header Newsgroups mancante") + } + subject := header.Get("Subject") + if subject == "" { + subject = "(No subject)" + } + xhashcash := header.Get("X-Hashcash") + if xhashcash == "" { + log.Fatalf("Header X-Hashcash mancante") + } + + bodyBuf := new(bytes.Buffer) + _, err = io.Copy(bodyBuf, msg.Body) + if err != nil { + log.Fatalf("Errore lettura corpo email: %v", err) + } + body := strings.TrimSpace(bodyBuf.String()) + if body == "" { + log.Fatalf("Corpo del messaggio vuoto") + } + + // Prevenzione riutilizzo token + if tokenAlreadySpent(xhashcash) { + log.Fatalf("Token Hashcash già utilizzato") + } + // Verifica del token + if !verifyHashcashToken(xhashcash, fromAddr) { + log.Fatalf("Token Hashcash non valido") + } + if err := markTokenSpent(xhashcash); err != nil { + log.Printf("Errore nella registrazione del token: %v", err) + } + + // Gestione dei newsgroup: limita a 3 gruppi + groups := strings.Split(newsgroups, ",") + if len(groups) > 3 { + groups = groups[:3] + log.Printf("Limite newsgroups raggiunto, utilizzati: %v", groups) + } + limitedGroups := strings.Join(groups, ", ") + + // Ritardo per cross-posting, se necessario + if len(groups) > 1 { + time.Sleep(time.Duration(delayCrossPost) * time.Second) + } + + // Generazione di un Message-ID univoco e formattazione data + messageID := fmt.Sprintf("<%d.%d@mail2usenet.local>", time.Now().Unix(), randInt(1000, 9999)) + dateHeader := time.Now().UTC().Format(time.RFC1123Z) + + // Composizione del messaggio Usenet in formato RFC-compliant + headers := []string{ + fmt.Sprintf("Message-ID: %s", messageID), + fmt.Sprintf("Date: %s", dateHeader), + fmt.Sprintf("From: %s", fromHeader), + fmt.Sprintf("Newsgroups: %s", limitedGroups), + fmt.Sprintf("Subject: %s", subject), + "Path: mail2usenet", + "Organization: Victor Hostile Communication Center 1312", + fmt.Sprintf("X-Hashcash: %s", xhashcash), + "X-No-Archive: Yes", + "Mime-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "Content-Transfer-Encoding: 7bit", + "User-Agent: m2usenet-go v0.1.0", + } + if ref := header.Get("References"); ref != "" { + headers = append(headers, fmt.Sprintf("References: %s", ref)) + } + usenetPost := strings.Join(headers, "\r\n") + "\r\n\r\n" + body + if len([]byte(usenetPost)) > maxPostSize { + log.Fatalf("Messaggio supera il limite di %d byte", maxPostSize) + } + + success, err := sendViaTor(NNTPServer, nntpPort, usenetPost, messageID) + if err != nil { + log.Fatalf("Errore nell'invio del messaggio: %v", err) + } + if success { + log.Println("Messaggio inviato correttamente") + os.Exit(0) + } else { + log.Fatalln("Invio del messaggio fallito") + } +} + +// randInt genera un intero casuale nel range [min, max]. +func randInt(min, max int) int { + rand.Seed(time.Now().UnixNano()) + return rand.Intn(max-min+1) + min +} |
