#! /usr/bin/env python # -*- coding: utf-8 -*- """DESCRIPTION Ce script repère les comptes inactifs en parsant les logs de dernière connexion de sshd et dovecot, et en lisant et mettant à jour les champs derniereConnexion dans la base LDAP. UTILISATION %(prog)s [action...] ACTIONS POSSIBLES %(acts)s""" # Copyright (C) 2006 Stéphane Glondu # Licence : GPLv2 import sys, os, re, time, cPickle from time import mktime, time, localtime, strptime, strftime from socket import gethostname from smtplib import SMTP host = gethostname() debug = None mail_report = u'disconnect@crans.org' mail_sender = u"Comptes inactifs " template_path = '/usr/scripts/surveillance/comptes_inactifs/comptes_inactifs.%d.txt' actions = ('log', 'dump', 'summary', 'spam') sys.path.append('/usr/scripts/gestion') from affich_tools import tableau, cprint from email_tools import send_email, parse_mail_template from ldap_crans import crans_ldap from config import ann_scol db = crans_ldap() import syslog syslog.openlog('comptes_inactifs') 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(object): # liste d'expressions régulières qui seront testées sur les lignes de log # le premier groupe doit correspondre à la date, le second au login compiled_regex = [re.compile(r'^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}).*(?:' r'dovecot.*Login: user=<|' r'sshd.*Accepted.*for ' r')([^ >]+).*$'), re.compile(r'^.*comptes_inactifs.*derniereConnexion=<([^>]+)>, ' r'login=<([^>]+)>')] def __init__(self): """ Initialisation """ self.dic = {} def search(self, query, mode=''): """ CransLdap.search allégé """ query = '(&(objectClass=cransAccount)(objectClass=adherent)(%s))' % query result = db.conn.search_s(db.base_dn, db.scope['adherent'], query) result = [db.make(x, mode) for x in result] return result def commit_to_ldap(self): """ Sauvegarde du dico dans la base LDAP. Renvoie le nombre d'entrées mises à jour. """ total = 0 for (login, timestamp) in self.dic.items(): a = self.search('uid=%s' % login, 'w') if not a: # Probablement un adhérent récemment parti continue a = a[0] if a._modifiable == 'w': a.derniereConnexion(timestamp) if a.modifs: total += 1 a.save() else: # on loggue on espérant que les logs seront réinjectés # plus tard syslog.syslog("LDAP(lock): derniereConnexion=<%s>, login=<%s>" % (strftime("%Y-%m-%dT%H:%M:%S", localtime(timestamp)), login)) return total def update(self, login, timestamp): """ Met à jour l'entrée correspondant au login donné. """ dic = self.dic timestamp = int(timestamp) 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. Renvoie le nombre de lignes traitées. """ annee = localtime(time())[0] now = time() + 600 nombre = 0 for line in loglines: for r in self.compiled_regex: m = r.match(line) if m: break if not m: continue date = list(strptime(m.group(1), "%Y-%m-%dT%H:%M:%S")) t = mktime(date) self.update(m.group(2).lower(), t) nombre += 1 return nombre def do_log(self): """ Lit des lignes de log sur l'entrée std et met à jour la base LDAP. """ parsed_lines = self.update_from_syslog(sys.stdin) updated_entries = self.commit_to_ldap() syslog.syslog("%(parsed_lines)s ligne(s) traitée(s)" % locals()) syslog.syslog("%(updated_entries)s entrée(s) mise(s) à jour dans la base LDAP" % locals()) if parsed_lines == 0 or updated_entries == 0: sys.stderr.write("""Erreur lors de la mise à jour de la base LDAP : %(parsed_lines)s ligne(s) traitée(s) %(updated_entries)s entrée(s) mise(s) à jour dans la base LDAP """ % locals()) def do_dump(self): """ Affiche la liste des dernières connexions, triées par date. """ liste = self.search('derniereConnexion=*') liste = [(x.derniereConnexion(), x.compte()) for x in liste] liste.sort() liste = [(x[1], strftime('%d/%m/%Y %H:%M', localtime(x[0]))) for x in liste] cprint(tableau(liste, titre = (u'Login', u'Dernière connexion'), largeur = (20, 20))) cprint(u"Total : %d" % len(liste)) def get_idle_accounts(self, since=32*24*3600): """ Renvoie la liste des objets Adherent de ceux qui ne se sont pas connectés depuis since secondes, par défaut un mois (32 jours, pour être sûr). """ limit = int(time()) - since liste = self.search("derniereConnexion<=%d" % limit) for x in self.search("!(derniereConnexion=*)"): if x.dateInscription() <= limit: liste.append(x) 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 -- comptes_inactifs.py """ inscrits = [] anciens = [] liste = self.get_idle_accounts() # on trie par login liste.sort(lambda x, y: cmp(x.compte(), y.compte())) for a in liste: login = a.compte() date = a.derniereConnexion() if date: date = strftime(u'%d/%m/%Y %H:%M', localtime(date)) else: date = u'Jamais' forward = os.path.isfile(os.path.join(a.home(), '.forward')) and u'X' or u'' mail = nb_mails_non_lus(login) mail = mail == None and u'?' or mail > 0 and u'X' or u' ' ligne = (a.id(), login, 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) send_email(mail_sender, mail_report, u'Comptes inactifs', modele % locals(), debug = debug) def do_spam(self): """ Envoie un mail explicatif aux possesseurs de compte inactif (doit être exécuté en tant que root). """ # Nombre de personnes concernées, en expansant de droite à gauche : # inscrit/ancien, avec/sans .forward, 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 a in self.get_idle_accounts(): # initialisation des champs login = a.compte() mail = nb_mails_non_lus(login) nom = a.Nom() date = a.derniereConnexion() or a.dateInscription() date = strftime(u'%d/%m/%Y %H:%M', localtime(date)) i = 0 # est-ce un membre inscrit ? if not a.paiement_ok(): i += 4 # a-t-il un .forward ? if not os.path.isfile(os.path.join(a.home(), '.forward')): i += 2 # a-il-des mails non lus ? if not mail: i += 1 # on incrémente stats[i] += 1 if i == 1: # on laisse tranquilles les membres inscrits sans mails non # lus qui ont un .forward continue (sujet, corps) = parse_mail_template(template_path % i) corps = corps % locals() if debug: sujet = u"[Message de test %d] %s" % (i, sujet) if stats[i] > 1: continue send_email(mail_sender, u"%s <%s@crans.org>" % (nom, login), sujet, corps, server = smtp, cc = debug, debug = debug) recapitulatif = [] total = 0 for i in range(0, 8): total += stats[i] recapitulatif.append((((i & 4) and 'n' or 'o'), ((i & 2) and 'n' or 'o'), ((i & 1) and 'n' or 'o'), stats[i])) recapitulatif = tableau(recapitulatif, titre = (u"Inscrits", u"Forward", u"Mails", u"Nombre"), largeur = (10, 9, 7, 8), alignement = ('c', 'c', 'c', 'd')) recapitulatif += u""" Total : %d -- comptes_inactifs.py """ % total send_email(mail_sender, mail_report, u"Récapitulatif des comptes inactifs", recapitulatif, server = smtp, debug = debug) smtp.quit() def usage(): """ Afficher l'aide. """ prog = sys.argv[0] acts = [x + '\n' + '\n'.join(eval("ComptesInactifs.do_%s.__doc__" % x).split('\n')[1:-1]) for x in actions] acts = '\n'.join(acts) print __doc__ % locals() if __name__ == '__main__': args = sys.argv[1:] if len(args) == 0: usage() sys.exit(0) for commande in args: if commande not in actions: sys.stderr.write("Commande incorrecte : %s\n" % commande) usage() sys.exit(2) ci = ComptesInactifs() for commande in args: eval('ci.do_%s()' % commande)