#! /usr/bin/env python # -*- coding: iso-8859-15 -*- """ Repère les comptes inactifs en parsant les logs de dernière connexion de sshd et dovecot. """ # Copyright (C) 2006 Stéphane Glondu # Licence : GPLv2 import sys, os, sre, time, cPickle from time import mktime, time, localtime, strptime, strftime from socket import gethostname from smtplib import SMTP host = gethostname() mail_address = u'disconnect@crans.org' mail_sender = u"Comptes inactifs " template_path = '/usr/scripts/templates/comptes_inactifs.%d' sys.path.append('/usr/scripts/gestion') from affich_tools import tableau from email_tools import send_email, parse_mail_template from ldap_crans import crans_ldap from config import ann_scol db = crans_ldap() def nb_mails_non_lus(login): """ Renvoie le nombre de mails non lus de login, ou None si impossible à déterminer. """ try: maildir = '/var/mail/%s/new' % login if os.path.isdir(maildir): return len(os.listdir(maildir)) else: return 0 except: # Arrive quand le script n'a pas les bons droits pour lire /var/mail return None class ComptesInactifs: re = sre.compile(r'^(\w+\s+\d+\s+\d+:\d+:\d+).*(?:' r'dovecot.*Login: user=<|' r'sshd.*Accepted.*for ' r')([^ >]+).*$') def __init__(self, filename): ''' Lecture du dico et acquisition du lock ''' self.filename = filename # Systeme de lock sommaire self.lockfile = filename + '.lock' if os.path.isfile(self.lockfile): f = file(self.lockfile) (lhost, pid) = f.read().strip().split() f.close() if lhost != host or os.system('ps %s >/dev/null 2>&1') == 0: # Le lock est actif ou sur une autre machine raise RuntimeError('Lock actif') f = file(self.lockfile, 'w') f.write('%s %d\n' % (host, os.getpid())) f.close() # À partir de là, on a le lock if os.path.isfile(filename): self.dic = cPickle.load(file(filename)) else: self.dic = {} def close(self): ''' Sauvegarde du dico et suppression du lock ''' cPickle.dump(self.dic, file(self.filename, 'w')) os.remove(self.lockfile) def update(self, login, timestamp): """ Met à jour l'entrée correspondant au login donné, ainsi que l'entrée '!', qui correspond à la plus vieille entrée. """ dic = self.dic timestamp = int(timestamp) # Mise à jour de l'entrée la plus vieille if not dic.has_key('!') or timestamp < dic['!']: dic['!'] = timestamp # Mise à jour de l'entrée correspondant au login if not dic.has_key(login) or timestamp > dic[login]: dic[login] = timestamp def update_from_syslog(self, loglines): """ Met à jour le dico avec les lignes de syslog données """ annee = localtime(time())[0] now = time() + 600 nombre = 0 for line in loglines: m = self.re.match(line) if not m: continue date = list(strptime(m.group(1), "%b %d %H:%M:%S")) date[0] = annee t = mktime(date) if t > now: date[0] = annee - 1 t = mktime(date) self.update(m.group(2), t) nombre += 1 print '%d ligne(s) pertinente(s)' % nombre def do_log(self): """ Lit des lignes de log sur l'entrée std et met à jour le dico """ self.update_from_syslog(sys.stdin) print 'Lecture des logs terminée' def do_dump(self): """ Affiche le contenu du dico """ liste = self.dic.items() liste.sort(lambda x, y: cmp(x[1], y[1])) data = [(x[0], strftime('%d/%m/%Y %H:%M', localtime(x[1]))) for x in liste] print tableau(data, largeur = (20, 18)) def get_idle_accounts(self, since=32*24*3600): """ Renvoie la liste des couples (login, objet Adherent) de ceux qui ne se sont pas connectés depuis since secondes, par défaut un mois (32 jours, pour etre sûr). """ oldest = self.dic.get('!', int(time())) limit = int(time()) - since liste = [] for x in os.listdir('/home'): if os.path.isdir('/var/mail/' + x) and self.dic.get(x, oldest) < limit: a = db.search('uid=%s' % x)['adherent'] if a: liste.append((x, a[0])) else: print 'uid=%s introuvable' % x liste.sort() return liste def do_summary(self): """ Envoie à disconnect un résume des comptes inactifs depuis plus d'un mois. """ modele = u"""*Membres inscrits ne s'étant pas connectés depuis plus d'un mois* %(inscrits)s Total : %(inscrits_total)d *Anciens membres ne s'étant pas connectés depuis plus d'un mois* %(anciens)s Total : %(anciens_total)d Légende : - F : existence d'un .forward - M : existence de mails non lus L'analyse des logs remonte au %(oldest)s. -- comptes_inactifs.py """ inscrits = [] anciens = [] for (x, a) in self.get_idle_accounts(): date = self.dic.get(x) if date: date = strftime(u'%d/%m/%Y %H:%M', localtime(date)) else: date = u'Jamais' forward = os.path.isfile('/home/%s/.forward' % x) and u'X' or u'' mail = nb_mails_non_lus(x) mail = mail == None and u'?' or mail > 0 and u'X' or u' ' ligne = (a.id(), x, a.Nom(), date, forward, mail) if ann_scol in a.paiement(): inscrits.append(ligne) else: anciens.append(ligne) titres = (u'aid', u'Login', u'Nom', u'Dernière connexion', u'F', u'M') largeurs = (6, 15, 20, 20, 1, 1) alignements = ('d', 'g', 'c', 'c', 'c', 'c') inscrits_total = len(inscrits) inscrits = tableau(inscrits, titres, largeurs, alignements) anciens_total = len(anciens) anciens = tableau(anciens, titres, largeurs, alignements) oldest = strftime(u'%d/%m/%Y %H:%M', localtime(self.dic.get('!', time()))) send_email(mail_sender, mail_address, u'Comptes inactifs', modele % locals()) def do_spam(self): """ Envoie un mail explicatif aux possesseurs de compte inactif """ # Nombre de personnes concernées, en expansant de droite à gauche : # inscrit/ancien, avec/sans procmail, avec/sans mail non lu # Voir aussi template_path stats = [0, 0, 0, 0, 0, 0, 0, 0] # On factorise la connexion smtp = SMTP() smtp.connect() for (x, a) in self.get_idle_accounts(): pass smtp.quit() if __name__ == '__main__': args = sys.argv[1:] if len(args) != 1: sys.stderr.write("Arguments incorrects\n") sys.exit(1) commande = args[0] if commande not in ('log', 'dump', 'summary'): sys.stderr.write("Commande incorrecte : %s\n" % commande) sys.exit(1) ci_db = ComptesInactifs('/usr/scripts/var/comptes_inactifs.dict') eval('ci_db.do_%s()' % commande) ci_db.close()