comptes_inactifs.py 10.9 KB
Newer Older
1
#! /usr/bin/env python
2
# -*- coding: utf-8 -*-
3

glondu's avatar
glondu committed
4
"""DESCRIPTION
5 6
        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
glondu's avatar
glondu committed
7 8 9
        derniereConnexion dans la base LDAP.

UTILISATION
10
        %(prog)s [action...]
glondu's avatar
glondu committed
11 12 13

ACTIONS POSSIBLES
%(acts)s"""
14

15
# Copyright (C) 2006 Stéphane Glondu
16 17 18
# Licence : GPLv2


19 20 21 22 23
import sys
import os
import re
import time
import cPickle
24 25
from time import mktime, time, localtime, strptime, strftime
from socket import gethostname
26
from smtplib import SMTP
27 28

host = gethostname()
29
debug = None
glondu's avatar
glondu committed
30
mail_report = u'disconnect@crans.org'
31
mail_sender = u"Comptes inactifs <disconnect@crans.org>"
32
template_path = '/usr/scripts/surveillance/comptes_inactifs/comptes_inactifs.%d.txt'
glondu's avatar
glondu committed
33 34
actions = ('log', 'dump', 'summary', 'spam')

35
sys.path.append('/usr/scripts/gestion')
glondu's avatar
glondu committed
36
from affich_tools import tableau, cprint
37
from email_tools import send_email, parse_mail_template
38 39 40 41
from ldap_crans import crans_ldap
from config import ann_scol
db = crans_ldap()

42 43
import syslog
syslog.openlog('comptes_inactifs')
glondu's avatar
glondu committed
44

45

46 47
def nb_mails_non_lus(login):
    """
48 49
    Renvoie le nombre de mails non lus de login, ou None si impossible à
    déterminer.
50 51 52 53 54 55 56 57
    """
    try:
        maildir = '/var/mail/%s/new' % login
        if os.path.isdir(maildir):
            return len(os.listdir(maildir))
        else:
            return 0
    except:
glondu's avatar
glondu committed
58
        # arrive quand le script n'a pas les bons droits pour lire /var/mail
59 60 61
        return None


62
class ComptesInactifs(object):
63 64
    # 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
65
    compiled_regex = [re.compile(r'^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}).*(?:'
glondu's avatar
glondu committed
66 67 68
                      r'dovecot.*Login: user=<|'
                      r'sshd.*Accepted.*for '
                      r')([^ >]+).*$'),
69
          re.compile(r'^.*comptes_inactifs.*derniereConnexion=<([^>]+)>, '
glondu's avatar
glondu committed
70 71 72 73 74 75 76
                      r'login=<([^>]+)>')]

    def __init__(self):
        """ Initialisation """
        self.dic = {}

    def search(self, query, mode=''):
77
        """ CransLdap.search allégé """
glondu's avatar
glondu committed
78 79 80 81 82 83 84 85
        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.
86
        Renvoie le nombre d'entrées mises à jour.
glondu's avatar
glondu committed
87 88 89 90 91
        """
        total = 0
        for (login, timestamp) in self.dic.items():
            a = self.search('uid=%s' % login, 'w')
            if not a:
92
                # Probablement un adhérent récemment parti
glondu's avatar
glondu committed
93 94 95 96 97 98 99
                continue
            a = a[0]
            if a._modifiable == 'w':
                a.derniereConnexion(timestamp)
                if a.modifs: total += 1
                a.save()
            else:
100
                # on loggue on espérant que les logs seront réinjectés
glondu's avatar
glondu committed
101
                # plus tard
102 103
                syslog.syslog("LDAP(lock): derniereConnexion=<%s>, login=<%s>" %
                       (strftime("%Y-%m-%dT%H:%M:%S", localtime(timestamp)), login))
glondu's avatar
glondu committed
104
        return total
105

106 107
    def update(self, login, timestamp):
        """
108
        Met à jour l'entrée correspondant au login donné.
109 110 111 112 113
        """
        dic = self.dic
        timestamp = int(timestamp)
        if not dic.has_key(login) or timestamp > dic[login]:
            dic[login] = timestamp
114

115
    def update_from_syslog(self, loglines):
glondu's avatar
glondu committed
116
        """
117 118
        Met à jour le dico avec les lignes de syslog données.
        Renvoie le nombre de lignes traitées.
glondu's avatar
glondu committed
119
        """
120 121 122 123
        annee = localtime(time())[0]
        now = time() + 600
        nombre = 0
        for line in loglines:
124
            for r in self.compiled_regex:
glondu's avatar
glondu committed
125 126
                m = r.match(line)
                if m: break
127
            if not m: continue
128
            date = list(strptime(m.group(1), "%Y-%m-%dT%H:%M:%S"))
129
            t = mktime(date)
glondu's avatar
glondu committed
130
            self.update(m.group(2).lower(), t)
131
            nombre += 1
glondu's avatar
glondu committed
132
        return nombre
133

134
    def do_log(self):
glondu's avatar
glondu committed
135
        """
136
        Lit des lignes de log sur l'entrée std et met à jour la base LDAP.
glondu's avatar
glondu committed
137
        """
glondu's avatar
glondu committed
138 139
        parsed_lines = self.update_from_syslog(sys.stdin)
        updated_entries = self.commit_to_ldap()
140 141
        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())
glondu's avatar
glondu committed
142
        if parsed_lines == 0 or updated_entries == 0:
143 144 145
            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
glondu's avatar
glondu committed
146
""" % locals())
147 148

    def do_dump(self):
glondu's avatar
glondu committed
149
        """
150
        Affiche la liste des dernières connexions, triées par date.
glondu's avatar
glondu committed
151 152 153 154 155 156 157
        """
        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,
158
                       titre = (u'Login', u'Dernière connexion'),
glondu's avatar
glondu committed
159 160
                       largeur = (20, 20)))
        cprint(u"Total : %d" % len(liste))
161 162 163

    def get_idle_accounts(self, since=32*24*3600):
        """
glondu's avatar
glondu committed
164
        Renvoie la liste des objets Adherent de ceux qui ne se sont pas
165 166
        connectés depuis since secondes, par défaut un mois (32 jours,
        pour être sûr).
167 168
        """
        limit = int(time()) - since
169 170 171 172 173
        liste = self.search("derniereConnexion<=%d" % limit)
        for x in self.search("!(derniereConnexion=*)"):
            if x.dateInscription() <= limit:
                liste.append(x)
        return liste
174 175

    def do_summary(self):
176
        """
177
        Envoie à disconnect un résume des comptes inactifs depuis plus d'un
178 179
        mois.
        """
180
        modele = u"""*Membres inscrits ne s'étant pas connectés depuis plus d'un mois*
181 182 183 184

%(inscrits)s
Total : %(inscrits_total)d

185
*Anciens membres ne s'étant pas connectés depuis plus d'un mois*
186 187 188 189

%(anciens)s
Total : %(anciens_total)d

190
Légende :
191 192 193
 - F : existence d'un .forward
 - M : existence de mails non lus

194
--
195 196 197 198
comptes_inactifs.py
"""
        inscrits = []
        anciens = []
glondu's avatar
glondu committed
199 200 201 202

        liste = self.get_idle_accounts()
        # on trie par login
        liste.sort(lambda x, y: cmp(x.compte(), y.compte()))
203

glondu's avatar
glondu committed
204 205 206
        for a in liste:
            login = a.compte()
            date = a.derniereConnexion()
207 208 209 210
            if date:
                date = strftime(u'%d/%m/%Y %H:%M', localtime(date))
            else:
                date = u'Jamais'
211
            forward = os.path.isfile(os.path.join(a.home(), '.forward')) and u'X' or u''
glondu's avatar
glondu committed
212
            mail = nb_mails_non_lus(login)
213
            mail = mail == None and u'?' or mail > 0 and u'X' or u' '
glondu's avatar
glondu committed
214
            ligne = (a.id(), login, a.Nom(), date, forward, mail)
215
            if ann_scol in a.paiement() or a.adhesion() > time():
216 217 218 219
                inscrits.append(ligne)
            else:
                anciens.append(ligne)

220
        titres = (u'aid', u'Login', u'Nom', u'Dernière connexion', u'F', u'M')
221 222 223 224 225 226 227
        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)
228

229
        send_email(mail_sender,
glondu's avatar
glondu committed
230
                   mail_report,
231
                   u'Comptes inactifs',
glondu's avatar
glondu committed
232 233
                   modele % locals(),
                   debug = debug)
234

235
    def do_spam(self):
glondu's avatar
glondu committed
236 237
        """
        Envoie un mail explicatif aux possesseurs de compte inactif
238
        (doit être exécuté en tant que root).
glondu's avatar
glondu committed
239
        """
240
        # Nombre de personnes concernées, en expansant de droite à gauche :
241
        # inscrit/ancien, avec/sans .forward, avec/sans mail non lu
242 243 244 245 246 247 248
        # Voir aussi template_path
        stats = [0, 0, 0, 0, 0, 0, 0, 0]

        # On factorise la connexion
        smtp = SMTP()
        smtp.connect()

glondu's avatar
glondu committed
249 250 251 252 253
        for a in self.get_idle_accounts():
            # initialisation des champs
            login = a.compte()
            mail = nb_mails_non_lus(login)
            nom = a.Nom()
254
            date = a.derniereConnexion() or a.dateInscription()
glondu's avatar
glondu committed
255 256 257
            date = strftime(u'%d/%m/%Y %H:%M', localtime(date))
            i = 0
            # est-ce un membre inscrit ?
258
            if not a.paiement_ok(): i += 4
glondu's avatar
glondu committed
259
            # a-t-il un .forward ?
260
            if not os.path.isfile(os.path.join(a.home(), '.forward')): i += 2
glondu's avatar
glondu committed
261 262
            # a-il-des mails non lus ?
            if not mail: i += 1
263
            # on incrémente
glondu's avatar
glondu committed
264 265 266 267 268 269 270 271 272 273
            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
274

glondu's avatar
glondu committed
275 276 277 278 279
            send_email(mail_sender,
                       u"%s <%s@crans.org>" % (nom, login),
                       sujet,
                       corps,
                       server = smtp,
280
                       cc = debug,
glondu's avatar
glondu committed
281 282 283 284 285 286 287 288 289 290 291 292
                       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,
293 294
                                titre = (u"Inscrits", u"Forward", u"Mails", u"Nombre"),
                                largeur = (10, 9, 7, 8),
glondu's avatar
glondu committed
295 296 297 298
                                alignement = ('c', 'c', 'c', 'd'))
        recapitulatif += u"""
Total : %d

299
--
glondu's avatar
glondu committed
300 301 302 303
comptes_inactifs.py
""" % total
        send_email(mail_sender,
                   mail_report,
304
                   u"Récapitulatif des comptes inactifs",
glondu's avatar
glondu committed
305 306 307
                   recapitulatif,
                   server = smtp,
                   debug = debug)
308

309
        smtp.quit()
310

311

glondu's avatar
glondu committed
312 313 314
def usage():
    """ Afficher l'aide. """
    prog = sys.argv[0]
Daniel STAN's avatar
Daniel STAN committed
315
    acts = [x + '\n' + '\n'.join(getattr(ComptesInactifs, 'do_' + x).__doc__.split('\n')[1:-1])
glondu's avatar
glondu committed
316 317 318 319 320
            for x in actions]
    acts = '\n'.join(acts)
    print __doc__ % locals()


321 322
if __name__ == '__main__':
    args = sys.argv[1:]
323

324
    if len(args) == 0:
glondu's avatar
glondu committed
325
        usage()
326
        sys.exit(0)
327

328 329 330 331 332
    for commande in args:
        if commande not in actions:
            sys.stderr.write("Commande incorrecte : %s\n" % commande)
            usage()
            sys.exit(2)
333

glondu's avatar
glondu committed
334
    ci = ComptesInactifs()
335
    for commande in args:
Daniel STAN's avatar
Daniel STAN committed
336
        getattr(ci, 'do_' + commande)()