#!/bin/bash /usr/scripts/python.sh
# -*- coding: utf-8 -*-
"""
    Génération de la configuration d'un switch.

    procédure de configuration initiale :
        * mot de passe admin (password manager user-name <username>)
        * activation du ssh (crypto key generate ssh)
        * copie fichier de conf
    pour les reconfiguration copier le fichier de conf
    dans /cfg/startup-config

    Dans tous les cas FAIRE LE SNMP A LA MAIN (script hptools)
"""

from __future__ import print_function

import sys, os
import datetime
import jinja2
import itertools
from socket import gethostbyname
import netaddr
import argparse

if '/usr/scripts' not in sys.path:
    sys.path.append('/usr/scripts')
import gestion.secrets_new as secrets
from lc_ldap.shortcuts import lc_ldap_admin as make_ldap_conn
import gestion.annuaires_pg as annuaire
import gestion.config as config
import lc_ldap.objets as ldap_classes
import gestion.config.encoding as enc

GIGABIT_MODELS = ['J9021A', 'J9145A']

MIB_PRISE_VLAN = 'SNMPv2-SMI::enterprises.11.2.14.11.5.1.7.1.15.3.1.1'
MIB_PRISE_MAC = 'SNMPv2-SMI::enterprises.11.2.14.11.5.1.9.4.2'

# Blocs verticaux pour ascii art des prises d'un switch
START_BLOCK = [u' ', u'┌', u'│', u'│', u'├', u'│', u'│', u'└', u' ']
MIDDLE_BLOCK = [u' ', u'┬', u'│', u'│', u'┼', u'│', u'│', u'┴', u' ']
END_BLOCK = [u' ', u'┐', u'│', u'│', u'┤', u'│', u'│', u'┘', u' ']

ldap = make_ldap_conn()

# états possibles
V_TAGGED = 1
V_UNTAGGED = 2
V_NO = 3

# Vlans disponibles
ENABLED_VLANS = ['adherent', 'adm', 'wifi', 'v6only', 'accueil', 'isolement',
    'appts', 'event', 'federez']

def vlan_id(name):
    """Vlan id of a name (filtre jinja)"""
    return config.vlans[name]

def net_of_vlan_name(name):
    """Renvoie le nom du réseau (ip) d'un vlan donné. C'est utile pour
    lister les IPs de serveurs dhcp à autoriser (entre autres)"""
    net_name = {
        'adherent': 'fil',
        'appts': 'personnel-ens',
        'v6only': 'gratuit',
        'wifi': 'bornes',
    }.get(name, name)
    return config.NETs[net_name]

class Port(object):
    """Un port de switch"""
    num = None

    # : uplink: None ou str
    uplink = None

    # : Liste de serveurs
    servers = None

    # : Liste de bornes
    bornes = None

    # : Liste de noms de chambres
    chambres = None

    # : Liste des macs vues
    seen_macs = None

    # : Liste des vlans vus
    seen_vlans = None


    def __init__(self, num):
        self.num = num
        self.servers = list()
        self.bornes = list()
        self.chambres = list()
        self.seen_macs = list()
        self.seen_vlans = list()

    def __str__(self):
        if self.uplink:
            return self.uplink
        else:
            labels = []
            if self.servers:
                labels.append('Srv_' +
                    ','.join(s['host'][0].value.split('.', 1)[0]
                             for s in self.servers))
            if self.chambres:
                labels.append('Ch_' + ','.join(self.chambres))
            if self.bornes:
                labels.append('Wifi_' +
                    ','.join(b['host'][0].value.split('.', 1)[0]
                             for b in self.bornes))
            return ",".join(labels) or "Inconnu"

    def brief(self):
        """Description brève de la prise"""
        if self.uplink:
            return unicode(self.uplink).replace(u'uplink->', u'')
        else:
            labels = self.servers + self.bornes
            labels = map(lambda s: s['host'][0].value.split('.', 1)[0], labels)
            labels += map(unicode, self.chambres)
            return u",".join(labels)

    def __int__(self):
        return self.num

    def speed(self):
        """Full speed or 100Mb ?"""
        if any("cl" in nom for nom in self.chambres) or self.uplink or \
            self.servers or self.bornes:
            return ''
        if any( adh.get('droits', None) for adh in self.adherents()):
            return ''
        return 'speed-duplex auto-10-100'

    def flowcontrol(self):
        """Est-ce que le flowcontrol est activé sur ce port ?"""
        if self.uplink or self.servers:
            return 'no flow-control'
        else:
            return 'flow-control'

    def is_trusted(self):
        """Est-ce une prise que l'on maîtrise ?"""
        return self.uplink or self.servers

    def vlan_member(self, vlan):
        """Renvoie V_TAGGED, V_UNTAGGED ou V_NO
        suivant le ``vlan`` (str) demandé"""
        if self.servers:
            hosts = [unicode(h) for serv in self.servers for h in serv['host']]
            if all(host.endswith('.adm.crans.org') for host in hosts):
                if vlan == 'adm':
                    return V_UNTAGGED
                else:
                    return V_NO
            else:
                if vlan == 'adherent':
                    return V_UNTAGGED
                else:
                    return V_TAGGED
        if self.uplink:
            # TODO retirer ce hack dégueux: tous les switchs devraient tout
            # tagguer, même le vlan adhérent
            if vlan == 'adherent':
                return V_UNTAGGED
            else:
                return V_TAGGED
        # Précisons tout de suite qu'adm ne va pas plus loin
        elif vlan == 'adm':
            return V_NO
        elif self.bornes:
            if vlan in ['wifi', 'accueil', 'isolement', 'v6only', 'appts',
                        'event', 'federez']:
                return V_TAGGED
            # Cas d'une borne dans une chambre: l'adherent doit pouvoir
            # se connecter
            elif vlan == 'adherent' and self.chambres:
                return V_UNTAGGED
            else:
                return V_NO
        # C'est donc une chambre d'adherent sans borne, il y aura
        # l'auth radius
        else:
            return V_NO

    def radius_auth(self):
        """Doit-on faire de l'auth radius ?"""
        return not self.uplink and not self.servers and not self.bornes

    def adherents(self):
        """Adhérents sur la prise"""
        filtre = u'(|%s)' % (''.join('(chbre=%s)' % c for c in self.chambres))
        return ldap.search(filtre)

    def num_mac(self):
        """Renvoie le nombre de macs autorisées.
        Ne devrait pas être appelée si c'est une prise d'uplink ou de bornes
        """
        assert(not self.bornes and not self.uplink)
        num = 2
        # Si c'est un club, on peut supposer qu'il a besoin de beaucoup
        # de machines (krobot etc)
        if any("cl" in nom for nom in self.chambres):
            num += 6
        else:
            # Si c'est une chambre d'adhérent, on rajoute le nombre de machines
            # filaires
            # TODO cette config serait à faire régulièrement
            # TODO serait-ce plus rapide de tout chercher d'abord ?
            for adh in self.adherents():
                num += len(adh.machines())
        return num

class PortList(list):
    """Liste de ports"""
    def __str__(self):
        """ transforme une liste de prises en une chaine pour le switch
            exemple : 1, 3, 4, 5, 6, 7, 9, 10, 11, 12 => 1,3-7,9-12
        """
        liste = list(int(x) for x in self)
        liste.sort()

        sortie = []
        groupe = [-99999, -99999]
        for x in itertools.chain(liste, [99999]):
            if x > groupe[1]+1: # Nouveau groupe !
                if groupe[0] == groupe[1]:
                    sortie.append('%d' % groupe[1])
                else:
                    sortie.append('%d-%d' % tuple(groupe))
                groupe[0] = x
            groupe[1] = x
        return ','.join(sortie[1:])

    def filter_vlan(self, vlan):
        """Prend un ``vlan`` et renvoie deux PortList, la première
        avec les ports taggués, la seconde pour les ports untaggués"""
        tagged = PortList()
        untagged = PortList()
        for port in self:
            assign = port.vlan_member(vlan)
            if assign == V_TAGGED:
                tagged.append(port)
            elif assign == V_UNTAGGED:
                untagged.append(port)
        return (tagged, untagged)

def get_port_dict(switch):
    """Renvoie le dictionnaire prise->objet Port"""
    # Build ports !
    ports = {}
    for num in xrange(1, 1+int(switch['nombrePrises'][0].value)):
        ports[num] = Port(num)

    bat, sw_num = get_bat_num(unicode(switch['host'][0]))
    # Remplit les machines ayant une prise spécifiée
    # (normalement uniquement les serveurs et les bornes)
    for machine in ldap.search(u'prise=%s%i*' % (bat, sw_num)):
        port = ports[int(machine['prise'][0].value[2:])]
        classe = machine['objectClass'][0].value
        if classe == 'machineCrans':
            port.servers.append(machine)
        elif classe == 'borneWifi':
            port.bornes.append(machine)

    # On remplit les chambres
    for prise, chbres in annuaire.reverse(bat).iteritems():
        # TODO rajouter un arg à reverse
        if int(prise[0]) != int(sw_num):
            continue
        port = ports[int(prise[1:])]
        # (below) beware: type(num) == str (ex: 302g)
        port.chambres += [bat.upper() + num for num in chbres]

    # Remplit les uplinks
    for num_prise, label in annuaire.uplink_prises[bat].iteritems():
        if num_prise/100 != int(sw_num):
            continue
        port = ports[num_prise % 100]
        port.uplink = label
    return ports


def fill_port_infos(hostname, port_dict):
    """Rajoute des infos sur les ports d'un switch"""

    from gestion.hptools import snmp
    conn = snmp(hostname, version='1', community='public')

    prise_vlan = conn.walk(MIB_PRISE_VLAN, bin_comp=True)
    for res in prise_vlan:
        res = res.split('.')
        port = int(res[-2])
        vlan = int(res[-3])
        port_dict[port].seen_vlans.append(vlan)

    prise_mac = conn.walk(MIB_PRISE_MAC, bin_comp=True)
    for mib in prise_mac:
        port = int(mib.split('.')[-8])
        mib = mib.split('.')
        mac = ':'.join('%02x' % int(mib[i]) for i in xrange(-7, -1))
        port_dict[port].seen_macs.append(mac)

def check_conf_ldap(hostname):
    """Vérifie la conf du switch, la base ldap et les macs/prises associées"""
    bat, sw_num = get_bat_num(hostname)
    switch = ldap.search(u'host=bat%s-%d.adm.crans.org' % (bat, sw_num))[0]

    port_dict = get_port_dict(switch)
    fill_port_infos(hostname, port_dict)

    for port in port_dict.itervalues():
        print("* Checking port %d (%s)" % (port.num, port))
        # Nombres de vlans
        pr_nb_vlans = len(port.seen_vlans)
        th_nb_vlans = sum( port.vlan_member(vname) != V_NO for vname in ENABLED_VLANS )
        if not th_nb_vlans and port.radius_auth():
            th_nb_vlans = 1
        if port.bornes and port.vlan_member('adherent') == V_NO:
            th_nb_vlans += 1
        if th_nb_vlans != pr_nb_vlans:
            print("  Wrong vlan number (%d,%d)" % (th_nb_vlans, pr_nb_vlans))
            print(port.seen_vlans)
            print(list( vname for vname in ENABLED_VLANS if port.vlan_member(vname) != V_NO ))

        # Les macs
        if port.uplink:
            if len(port.seen_macs) < 20:
                print("  Uplink but few macs (%d)" % len(port.seen_macs))
        elif port.radius_auth(): # On vérifie que c'est la bonne chambre
            th_prises_set = set()
            for mac in set(port.seen_macs):
                res = ldap.search(u'macAddress=%s' % mac)
                if not res:
                    continue
                chbre = unicode(res[0].proprio().get('chbre', ['EXT'])[0])
                th_prise = chbre[0].lower()
                try:
                    th_prise += annuaire.chbre_prises(chbre[0], chbre[1:])
                except annuaire.ChbreNotFound:
                    # La chambre est inconnue -> drop
                    continue
                th_prises_set.add(th_prise)

            pr_prise = bat.lower() + '%d%02d' % (sw_num, port.num)
            if th_prises_set and pr_prise not in th_prises_set:
                print("   Aucune machine de chbre. Candidats: %r" % th_prises_set)
        else:
            machines = []
            for mac in set(port.seen_macs):
                res = ldap.search(u'macAddress=%s' % mac)
                if not res:
                    print("  Unknown mac %s" % mac)
                    continue
                machines += res
            for machine in machines:
                owner = machine.proprio()
                if isinstance(owner, ldap_classes.AssociationCrans):
                    the = unicode(machine.get('prise', ['N/A'])[0])
                    the = the.lower()
                    pra = bat.lower() + '%d%02d' % (sw_num, port.num)
                    if the != pra:
                        print("   Machine %s sur mauvaise prise (%s,%s)" %
                            (machine, the, pra))
                        fix_prise(machine, pra)

                elif isinstance(machine, ldap_classes.machineWifi):
                    if not port.bornes:
                        print("   Machine %s sur prise sans borne ?" % machine)

def fix_prise(machine, prise):
    """Répare la base en remplaçant la prise de la machine par ce qui est
    conseillé en paramètre"""
    opt = "yN"
    old_prise = unicode(machine.get('prise', ['N/A'])[0])
    print("Remplacer prise de %s par %s (ancienne valeur: %s) ?" %
        (machine, prise, old_prise), "[%s]" % opt)
    while True:
        char = raw_input()
        if char in opt.lower():
            break
        print("[%s]" % opt)
    if char == 'y':
        if 'w' not in machine.mode:
            machine = ldap.search(u'%s' % machine.dn.split(',')[0], mode='rw')[0]
        with machine:
            machine['prise'] = unicode(prise)
            machine.history_gen()
            machine.save()
            print("Done !")

def format_prises_group(data, first, last):
    """Affiche sous forme d'un groupe de prise, en ascii-art, les noms des
    prises. Entre first et last"""
    first = (first-1)/2*2+1
    last = (-last/2)*-2

    def align5(txt, right=False):
        """Aligne le texte en limitant à 5 char"""
        if len(txt) > 5:
            return txt[0:4] + u'*'
        if right:
            return (5-len(txt))*u' ' + txt
        return txt + (5-len(txt))*u' '

    def align2x5(txt):
        """Aligne le texte sur deux lignes de 5 char"""
        return (align5(txt[0:5]), align5(txt[5:]))

    lines = list(START_BLOCK)
    def fill_prise(prise, i_info, i_prise):
        """Remplis le contenu d'une prise, sur deux lignes"""
        if prise in data:
            txt = data[prise].brief()
        else:
            txt = u""
        (txt1, txt2) = align2x5(txt)
        lines[i_info] += txt1
        lines[i_info+1] += txt2
        lines[i_prise] += align5(u"%d" % prise, right=(i_prise!=0))

    sep = MIDDLE_BLOCK
    for prise in xrange(first, last, 2):
        for li in [1, 4, 7]:
            lines[li] += u'─'*5
        fill_prise(prise, 2, 0)
        fill_prise(prise+1, -4, -1)
        if prise == last -1:
            sep = END_BLOCK
        for line_i in xrange(0, 9):
            lines[line_i] += sep[line_i]

    return lines

def pretty_print(hostname):
    """Affiche joliement le plan de connexion d'un switch"""
    bat, sw_num = get_bat_num(hostname)
    switch = ldap.search(u'host=bat%s-%d.adm.crans.org' % (bat, sw_num))[0]

    port_dict = get_port_dict(switch)
    total = max(port_dict.keys())

    first = 1
    while first < total:
        for line in format_prises_group(port_dict, first, min(first+60, total)):
            print(line.encode(enc.out_encoding))
        first += 61

def get_bat_num(hostname):
    """Renvoie un tuple (bat, num) où bat est la lettre du bâtiment et
    num l'entier numéro du switch"""
    return (hostname[3].lower(), int(hostname[5]))

def conf_switch(hostname):
    """Affiche la configuration d'un switch"""
    bat, sw_num = get_bat_num(hostname)

    try:
        switch = ldap.search(u'host=bat%s-%d.adm.crans.org' % (bat, sw_num))[0]
    except IndexError:
        switch = ldap.search(u'host=bat%s-%d.crans.org' % (bat, sw_num))[0]


    tpl_env = jinja2.Environment(loader=jinja2.FileSystemLoader(os.path.dirname(__file__)))
    ##for info:
    tpl_env.filters['vlan_id'] = vlan_id

    data = {
        'switch': switch,
        'hostname': 'bat%s-%d' % (bat, sw_num),
        'bat': bat.upper(),

        'date_gen': datetime.datetime.now(),
        # TODO fill that depuis bcfg2 ou whatever
        'radius_servers': [
            '10.231.136.72',
            '10.231.136.11',
        ],
        'radius_key': secrets.get('radius_key'),

        # dhcp et isc (secondaire) sont les deux seuls serveurs
        'dhcp_rid_servers': [34, 160],

        # vlans activés (cf data.vlans)
        'vlan_names': ENABLED_VLANS,

        # réseaux où on fait du dhcp snooping (cf data.NETs)
        'dhcp_snooping_vlan_names': ['adherent', 'wifi', 'accueil',
            'isolement', 'v6only', 'appts', 'federez'],
    }

    for com in switch['info']:
        if com.value.startswith(';'):
            data['config_header'] = com.value.encode('utf-8')
            break
    else:
        print(((u'Impossible de déterminer le header à utiliser pour %s;' +
            u"Utilisation d'une valeur par défaut (remplir ldap)")
            % switch).encode('utf-8'), file=sys.stderr)
        data['config_header']= '; J4899A Configuration Editor; Created on release #H.10.50'

    imodel = data['config_header'].split(' ', 2)[1]
    if imodel == "J9145A":
        data['module_type'] = 'module 1 type J9145A'

    # Pas de snooping pour les 2810
    if "2810" in switch['info'][0].value:
        data['dhcp_snooping_vlan_names'] = []
    else:
        data['dhcp_servers'] = []
        for vname in data['dhcp_snooping_vlan_names']:
            for rid in data['dhcp_rid_servers']:
                first = netaddr.IPNetwork(net_of_vlan_name(vname)[0]).first
                data['dhcp_servers'].append(str(netaddr.IPAddress(first + rid)))

    # Si le switch n'est pas en .adm, il n'est pas publique (ex : batk-0)
    # (désactivation de radius etc)
    # On règle les logs, ntp, suivant si le switch est public ou privé (adm)
    if u"adm" in unicode(switch['host']):
        data['public'] = False
        data['ntp_servers'] = ['10.231.136.98']
        data['log_servers'] = ['10.231.136.38']
        data['gateway'] = '10.231.136.4'
        data['network_id'] = '10.231.136.0'
        data['subnet'] = '255.255.255.0'
    else:
        data['public'] = True
        data['ntp_servers'] = ['138.231.136.98']
        data['log_servers'] = ['138.231.136.38']
        data['gateway'] = '138.231.136.4'
        data['network_id'] = '138.231.136.0'
        data['subnet'] = '255.255.248.0'

    # Ra gards ne concerne que les 2620
    if "2620" in switch['info'][0].value:
       data['ra_filter'] = True
    else:
       data['ra_filter'] = False

    # Switch avec des ports gigabit uniquement
    if imodel in GIGABIT_MODELS:
        data['gigabit'] = True

    # Build ports !
    ports_list = PortList(get_port_dict(switch).itervalues())
    data['ports'] = ports_list
    data['vlans'] = []
    # On remplit les objets vlans (nom, id, tagged, untagged etc)
    vlans = {}
    for name in data['vlan_names']:
        vlan = {
            'name': name,
            'id': config.vlans[name],
        }
        for assign, p in itertools.groupby(ports_list, lambda p: p.vlan_member(name)):
            attr = {
                V_TAGGED: 'tagged',
                V_UNTAGGED: 'untagged',
                V_NO: 'no'}[assign]
            vlan.setdefault(attr, PortList())
            vlan[attr].extend(p)
        if name == 'adm' and not data['public']:
            vlan['ip_cfg'] = (gethostbyname(hostname), '255.255.255.0')
        if name == 'adherent':
            # TODO : proprifier cela
            # Si le switch est publique, adh en non tagué partout
            if data['public']:
                vlan['untagged'] = u'1-' + unicode(switch['nombrePrises'][0])
                vlan['ip_cfg'] = (gethostbyname(hostname), '255.255.248.0')
            # igmp snooping (multicast) mais nous ne sommes pas querier
            vlan['extra'] = 'ip igmp\nno ip igmp querier'
        vlans[name] = vlan
        data['vlans'].append(vlan)

    # Quelles sont les prises de confiance ?
    data['trusted'] = str(PortList(p for p in ports_list if p.is_trusted()))
    data['non_trusted'] = str(PortList(p for p in ports_list if not p.is_trusted()))

    # On désactive le multicast mdns
    if "2910" in switch['info'][0].value:
        data['drop_mdns'] = True
    else:
        data['drop_mdns'] = False

    # On render :
    return tpl_env.get_template('switch_conf.tpl').render(**data).encode('utf-8')

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Génération de la conf d'un "+
        "switch.")
    parser.add_argument('hostname', help="Nom du switch à regénérer " +
        "(ex: batg-4)")
    parser.add_argument('-c', '--check', action='store_true', default=False,
       help="Vérifie la conf par rapport aux macs et vlans effectivement" +\
       "présents sur le switch")
    parser.add_argument('--pretty', action='store_true', default=False,
        help="Affiche un tableau ascii du plan de connexion du switch")

    options = parser.parse_args(sys.argv[1:])

    if options.check:
        check_conf_ldap(options.hostname)
    elif options.pretty:
        pretty_print(options.hostname)
    else:
        print(conf_switch(options.hostname))

