dakkar
Graylister - proviamo a fermare lo "SPAM"
http://www.perl.it/documenti/articoli/2008/01/graylister-prov.html
© Perl Mongers Italia. Tutti i diritti riservati.

Un po' di storia

Non provo neppure a spiegare cosa si intende con "spam" parlando di posta elettronica. Se non lo sapete, vuol dire che non avete usato la posta elettronica negli ultimi <troppi> anni, per cui questo articolo non vi interessa :-).

Ci sono svariati metodi per limitare la quantità di spazzatura che arriva nella nostra casella postale. Molti, tra cui SpamAssassin che è scritto in Perl ed è molto diffuso, analizzano il contenuto di ciascun messaggio, cercando di indovinare la probabilità che sia da cestinare; funzionano piuttosto bene, ma sono un po' pesanti dal punto di vista computazionale: finché avete un solo account di cui preoccuparvi, vanno bene, ma se dovete gestire un intero dominio, potrebbe essere il caso di aggiungere qualche altro sistema di filtraggio.

Un sistema che ha avuto discreta popolarità fino a non molto tempo fa è il black listing: si prendono delle liste di indirizzi IP considerati "inaffidabili", e si rifiutano tutti i messaggi provenienti da essi. Semplice, ma un po' troppo drastico, specialmente perché è molto più facile finire per sbaglio in una di quelle liste che farsi togliere.

Di recente è stato inventato il gray listing, come forma meno drastica del black listing: invece che rifiutare il messaggio, si segnala un errore temporaneo, e se il mittente riprova l'invio, si accetta. Il senso può non essere molto ovvio, per cui è bene spiegare meglio.

Un server di posta serio gestisce una coda di messaggi da inviare. Per ciascun messaggio in coda, tenta l'invio contattando il gestore posta responsabile per l'indirizzo del destinatario. Se tale gestore accetta il messaggio, bene, fine del lavoro. Se lo rifiuta, si può segnalare il problema al mittente. Ma, in alcuni casi, il gestore destinatario potrebbe essere non raggiungibile, o segnalare qualche errore temporaneo (ovvero che, nell'opinione del gestore destinatario, dovrebbe risolversi tra un po' tempo). In questi ultimi casi, il gestore mittente rimette il messaggio in coda, e ritenta l'invio dopo qualche tempo. Però (e qui sta tutto il "succo" del gray listing) molti spammer non usano gestori di posta seri, ma usano programmini specializzati nell'inviare il maggior numero possibile di messaggi nel minor tempo possibile. Questi sistemi non gestiscono una coda, per cui trattano un errore temporaneo come un errore permanente: ignorando il problema, e non ritentando l'invio.

Per cui, se il nostro gestore destinatario costringe ciascun mittente a tentare l'invio due volte, non accetterà mai messaggi da sistemi di invio stupidi, e eliminerà così la maggior parte dello spam. D'altra parte, se qualcuno ha un gestore posta serio che esce da un indirizzo "sospetto" secondo qualche black list, un sistema di gray listing accetterà i suoi messaggi (l'utilità di ciò è discutibile, ma almeno abbiamo la possibilità di scegliere come trattare ciascun caso).

Il contorno

Avendo di recente spostato il mio dominio su un server dedicato, mi sono trovato a dover configurare il server di posta elettronica (oltre a tutto il resto). Ho scelto netqmail, principalmente perché l'avevo già usato e quindi ho un'idea di come si configuri. Dopo aver notato la non trascurabile quantità di spam che entrava, mi sono messo a cercare un sistema di gray listing da incastrare nel server. Ne ho trovati parecchi, ma nessuno faceva proprio quel che volevo, per cui mi sono ispirato alle caratteristiche migliori di ciascuno, e ne ho scritto uno a modo mio (ah, il bello del software libero!).

Per incastrarlo dentro netqmail, ho usato il meccanismo detto qmail-spp, che permette di chiedere a qmail-smtpd di invocare un programma esterno in certe fasi del protocollo SMTP. In particolare, mi farebbe comodo agire con più informazioni possibile; siccome non voglio leggermi l'intero messaggio, mi limiterò a usare le informazioni presenti sulla "busta": indirizzo IP della macchina mittente, indirizzo di posta del mittente, e indirizzo di posta del destinatario.

A modo mio

Ma cos'è, esattamente, che voglio ottenere?

  1. Gray listing sulla maggior parte dei messaggi
  2. Se un server si dimostra capace di passare il gray listing e non compare in alcuna black list, viene aggiunto a una white list e non subirà più filtraggio

Detto così sembra anche semplice, ma come lo spieghiamo alla macchina? Questa è la subroutine principale del mio "graylister":

sub check {
   my ($host,$from,$to)=@_;

   remove_old_attempts();
   if (is_whitelisted($host)) {
       accept_message();return;
   }
   if (is_second_attempt($host,$from,$to)) {
       if (is_blacklisted($host)) {
           cleanup_attempt($host,$from,$to);
           accept_message();return;
       }
       else {
           add_to_whitelist($host);
           accept_message();return;
       }
   }
   record_first_attempt($host,$from,$to);
   reject_temporarily($from);
   return;
}

Andiamo riga per riga. La sub prende 3 parametri: l'indirizzo IP del server che sta cercando di mandarci un messaggio, l'indirizzo di posta da cui dice di provenire il messaggio, e l'indirizzo di posta a cui sarebbe destinato:

sub check {
   my ($host,$from,$to)=@_;

remove_old_attempts serve per tenere pulito il piccolo database che uso per tenere traccia dei tentativi di invio.

Se il server mittente è nella white list, accetto il messaggio e termino l'elaborazione:

if (is_whitelisted($host)) {
    accept_message();return;
}

Se è la seconda volta che ricevo lo stesso messaggio, e il server mittente sta in qualche black list, pulisco la traccia del tentativo e accetto il messaggio; se il server non sta in nessuna black list, lo considero definitivamente affidabile, e comunque accetto il messaggio:

if (is_second_attempt($host,$from,$to)) {
    if (is_blacklisted($host)) {
        cleanup_attempt($host,$from,$to);
        accept_message();return;
    }
    else {
        add_to_whitelist($host);
        accept_message();return;
    }
}

Se arrivo a questo punto, vuol dire che il messaggio è un primo tentativo da parte di un server non dichiarato affidabile: tengo traccia del tentativo, e segnalo errore temporaneo:

   record_first_attempt($host,$from,$to);
   reject_temporarily($from);
   return;
}

Come si vede, con un minimo di occhio allo stile, si può scrivere codice Perl chiaro e leggibile.

I dettagli

Ovviamente quella subroutine, da sola, non ha speranza di funzionare. Pur senza mostrare tutto il codice, è opportuno scendere un po' nei dettagli delle varie funzioni usate.

Il database

Per tenere traccia dei tentativi, e della white list, ho usato un database SQLite, tramite DBI e DBD::SQLite. Contiene 3 tabelle:

version(version integer)
non strettamente necessaria, ma la metto per segnare la versione del programma che ha creato il database, in modo da poter prevedere cambiamenti strutturali in future versioni con aggiornamento automatico dei dati
whitelist(host varchar)
la lista dei server dichiarati affidabili; ogni record contiene un indirizzo IP in forma "dotted decimal"
attempts(host varchar,smtpfrom varchar,smtprcpt varchar,time integer)
in questa tabella vengono inseriti i tentativi di invio; il timestamp viene usato per pulire i tentativi vecchi per cui abbiamo perso ogni speranza (ovvero, se dopo un giorno non ci ha riprovato, non ci riproverà più)

Le funzioni add_to_whitelist, is_whitelisted, record_first_attempt, is_second_attempt, cleanup_attempt e remove_old_attempts usano semplici comandi SQL per manipolare il database. Per rendere trasparente l'uso del database alla funzione chiamante, ciascuna di queste funzioni invoca _init_dbh, la quale si preoccupa di aprire il database (se non è già stato fatto in questa sessione) e eventualmente crearvi le tabelle (ovviamente solo al momento della creazione, usando _prepare_db).

cleanup_attempt ha una riga di logica in più: invece di cancellare il record del tentativo di invio, si limita ad aggiornare il timestamp. In questo modo, se il server mittente (sospetto) spedisce spesso per la stessa coppia mittente-destinatario, si evita di rallentare tutti gli invii (nota: questo potrebbe però aiutare gli spammer, forse eliminerò questo comportamento). In alcuni casi il mittente "sulla busta" potrebbe essere vuoto (in caso di bounce, ad esempio): in questi casi si cancella il record del tentativo, in quanto la terna <host,'',to> non identifica bene un messaggio.

Le black list

Per controllare se l'indirizzo IP del mittente stia in qualche black list ho usato il modulo Net::DNSBLLookup. Siccome però ha qualche stranezza (l'elenco delle liste non è molto aggiornato, non restituisce tutte le informazioni che potrebbe), ne ho fatto un branch locale, rinominandolo DAKKAR::Net::DNSBLLookup (sì, prima o poi manderò una patch all'autore).

Interazioni con l'esterno

Infine, dobbiamo preoccuparci dell'input e dell'output del programma rispetto a netqmail.

Per quanto riguarda l'output, abbiamo due funzioni:

accept_message
non fa niente: se il programma non scrive nulla su STDOUT, qmail-smtpd accetterà il messaggio
reject_temporarily
chiede a qmail-smtpd di rispondere con codice 451 ("temporary failure", appunto); lo fa subito nella maggior parte dei casi, ma se il mittente è vuoto o comincia per postmaster@ (ovvero, se sembra essere un altro server di posta), segnala l'errore solo dopo aver ricevuto il messaggio (altrimenti il mittente potrebbe offendersi e farci finire in qualche black list).

Per l'input, basterebbe leggere qualche variabile di ambiente (vedi get_from_env):

TCPREMOTEIP
indirizzo IP del server mittente, impostato da tcpserver
SMTPMAILFROM
indirizzo di posta del mittente sulla busta, impostato da qmail-smtpd
SMTPRCPTTO
indirizzo di posta del destinatario sulla busta, impostato da qmail-smtpd
DAKGL_DBNAME
path al database da usare, impostato in qualche modo (nel mio caso, da tcpserver tramite apposita configurazione)

Se però si leggono con attenzione altri programmi di gray listing, si scopre che serve un minimo di codice in più: EZMLM, noto e diffuso programma per la gestione delle mailing list, cambia il mittente ad ogni invio, inserendoci un numero variabile (usato per scopi interni). Siccome non vogliamo ritardare ciascun messaggio di una mailing list (specie se, come me, siete iscritti a parecchie), sostituiamo il numero con un marcatore costante (in _cleanup_data).

Gli script

Per invocare il tutto, ho scritto un piccolo script:

#!/usr/bin/perl
use DAKKAR::Graylister;

exit 0 unless (defined $ENV{GRAYLISTING}) and (!defined $ENV{RELAYCLIENT});

DAKKAR::Graylister::check(DAKKAR::Graylister::get_from_env());

La riga che comincia con exit permette di escludere l'elaborazione tramite apposite variabili di ambiente. In particolare, GRAYLISTING deve essere definita, e il server mittente non deve essere già abilitato al relay (ovvero a inviare posta a chiunque, non solo agli indirizzi gestiti direttamente da questo server): se è abilitato al relay, si suppone che sia totalmente fidato, per cui è inutile filtrarlo (se permettete il relay a macchine non fidate, avete ben altri problemi, e meritate tutto il male che ve ne può derivare).

Siccome, per questioni di pulizia e robustezza, dedico a ciascuna applicazione Perl la sua directory con i moduli che servono installati appositamente, serve un altro script che imposti PERL5LIB per permettere al compilatore di trovare i moduli:

#!/bin/bash

export PERL5LIB='/usr/local/graylist/lib/perl5:/usr/local/graylist/lib/perl5/x86_64-linux-thread-multi'

exec /usr/local/graylist/bin/dakkar-graylister

Per costruire queste "librerie dedicate", consiglio l'uso di local::lib.