diff --git a/surveillance/comptes_inactifs.py b/surveillance/comptes_inactifs.py index 2e437152..6fc4f7e9 100755 --- a/surveillance/comptes_inactifs.py +++ b/surveillance/comptes_inactifs.py @@ -1,10 +1,16 @@ #! /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. -""" +"""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 @@ -16,17 +22,25 @@ from socket import gethostname from smtplib import SMTP host = gethostname() -mail_address = u'disconnect@crans.org' +debug = u'disconnect@crans.org' +mail_report = u'disconnect@crans.org' mail_sender = u"Comptes inactifs " -template_path = '/usr/scripts/templates/comptes_inactifs.%d' +template_path = '/usr/scripts/templates/comptes_inactifs.%d.txt' +actions = ('log', 'dump', 'summary', 'spam') + +# Date de début d'analyse des logs : 19/03/2006 05:27 GMT +oldest_log = 1142746043 sys.path.append('/usr/scripts/gestion') -from affich_tools import tableau +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() +from syslog import * +openlog('comptes_inactifs') + def nb_mails_non_lus(login): """ @@ -40,110 +54,117 @@ def nb_mails_non_lus(login): else: return 0 except: - # Arrive quand le script n'a pas les bons droits pour lire /var/mail + # 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')([^ >]+).*$') + # 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 + re = [sre.compile(r'^(\w+\s+\d+\s+\d+:\d+:\d+).*(?:' + r'dovecot.*Login: user=<|' + r'sshd.*Accepted.*for ' + r')([^ >]+).*$'), + sre.compile(r'^.*comptes_inactifs.*derniereConnexion=<([^>]+)>, ' + r'login=<([^>]+)>')] - def __init__(self, filename): - ''' Lecture du dico et acquisition du lock ''' - self.filename = filename + def __init__(self): + """ Initialisation """ + self.dic = {} - # 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 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("LDAP(lock): derniereConnexion=<%s>, login=<%s>" % + (strftime("%b %d %H:%M:%S", localtime(timestamp)), login)) + return total + 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. + Met à jour l'entrée correspondant au login donné. """ 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 """ + """ + 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: - m = self.re.match(line) + for r in self.re: + m = r.match(line) + if m: break if not m: continue date = list(strptime(m.group(1), "%b %d %H:%M:%S")) date[0] = annee t = mktime(date) + # les lignes de syslog n'indiquent pas l'année + # on suppose qu'une date dans le futur est en fait l'année dernière if t > now: date[0] = annee - 1 t = mktime(date) - self.update(m.group(2), t) + self.update(m.group(2).lower(), t) nombre += 1 - print '%d ligne(s) pertinente(s)' % nombre - + return 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' + """ + Lit des lignes de log sur l'entrée std et met à jour la base LDAP. + """ + print '%s ligne(s) traitée(s)' % self.update_from_syslog(sys.stdin) + print '%s entrée(s) mise(s) à jour dans la base LDAP' % self.commit_to_ldap() 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)) + """ + 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 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). + 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). """ - 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 + return self.search('|(!(derniereConnexion=*))(derniereConnexion<=%d)' % limit) def do_summary(self): """ @@ -171,17 +192,22 @@ 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 (x, a) in self.get_idle_accounts(): - date = self.dic.get(x) + 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('/home/%s/.forward' % x) and u'X' or u'' - mail = nb_mails_non_lus(x) + forward = os.path.isfile('/home/%s/.forward' % login) 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(), x, a.Nom(), date, forward, mail) + ligne = (a.id(), login, a.Nom(), date, forward, mail) if ann_scol in a.paiement(): inscrits.append(ligne) else: @@ -195,16 +221,19 @@ comptes_inactifs.py 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()))) + oldest = strftime(u'%d/%m/%Y %H:%M', localtime(oldest_log)) send_email(mail_sender, - mail_address, + mail_report, u'Comptes inactifs', - modele % locals()) + modele % locals(), + debug = debug) def do_spam(self): - """ Envoie un mail explicatif aux possesseurs de compte inactif """ + """ + 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 procmail, avec/sans mail non lu # Voir aussi template_path @@ -214,24 +243,90 @@ comptes_inactifs.py smtp = SMTP() smtp.connect() - for (x, a) in self.get_idle_accounts(): - pass + 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 oldest_log + date = strftime(u'%d/%m/%Y %H:%M', localtime(date)) + i = 0 + # est-ce un membre inscrit ? + if ann_scol not in a.paiement(): i += 4 + # a-t-il un .forward ? + if not os.path.isfile('/home/%s/.forward' % login): 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, + 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"Procmail", u"Mails", u"Nombre"), + largeur = (10, 10, 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) != 1: sys.stderr.write("Arguments incorrects\n") + usage() sys.exit(1) commande = args[0] - if commande not in ('log', 'dump', 'summary'): + if commande not in actions: sys.stderr.write("Commande incorrecte : %s\n" % commande) - sys.exit(1) + usage() + sys.exit(2) - ci_db = ComptesInactifs('/usr/scripts/var/comptes_inactifs.dict') - eval('ci_db.do_%s()' % commande) - ci_db.close() + ci = ComptesInactifs() + eval('ci.do_%s()' % commande)