#!/bin/bash /usr/scripts/python.sh
#-*- coding: utf-8 -*-
# ----------------------------------------------------------------------------
# Copyright (c) 2010, Matteo Bertozzi <theo.bertozzi@gmail.com>
# Copyright (c) 2014, Valentin Samir <valentin.samir@crans.org>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#    * Redistributions of source code must retain the above copyright
#      notice, this list of conditions and the following disclaimer.
#    * Redistributions in binary form must reproduce the above copyright
#      notice, this list of conditions and the following disclaimer in the
#      documentation and/or other materials provided with the distribution.
#    * Neither the name of the Matteo Bertozzi nor the
#      names of its contributors may be used to endorse or promote products
#      derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY Matteo Bertozzi ``AS IS'' AND ANY
# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL Matteo Bertozzi BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# ----------------------------------------------------------------------------
# How to mount the File-System:
#   python ldapcertfs mnt [ldap_filter]
#
# How to umount the File-System?
#   fusermount -u mnt

# dictionnnaire CommonName => certificat_dict pour construire la chaine de certificat
# certificat_dict est 'content' => PEM ou 'path' => chemin vers fichier contenent le PEM
# Il n'est utile ici que de renseigner des CN de CA intermédiaires
CHAIN = {
    'CAcert Class 3 Root' : {'path':'/etc/ssl/certs/cacert.org.pem'},
}

import os
import sys
import ssl
import stat
import ldap
import time
import fuse
import errno
import pickle
import hashlib
import getpass

from OpenSSL import crypto

import lc_ldap.shortcuts
import gestion.secrets_new as secrets

# Specify what Fuse API use: 0.2
fuse.fuse_python_api = (0, 2)

import logging
import logging.handlers
logger = logging.getLogger('ldapcertfs')
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(name)s: [%(levelname)s] %(message)s')
handler = logging.handlers.SysLogHandler(address = '/dev/log')
try:
    handler.addFormatter(formatter)
except AttributeError:
    handler.formatter = formatter
logger.addHandler(handler)


CACHE = {}
CACHE_TIMEOUT = 60
CACHE_DIR = "/var/lib/ldapcertfs/"
CACHE_STATIC_UPDATE = 3600
CACHE_STATIC_EXPIRE = 3600 * 24 * 30
LAST_CLEAN = 0

try:
    os.mkdir(CACHE_DIR, 0700)
except OSError as e:
    if e.errno != 17: # File exists
        raise
    os.chmod(CACHE_DIR, 0700)

def clean_static_cache(storage={}):
    global LAST_CLEAN
    if time.time() - LAST_CLEAN > CACHE_STATIC_UPDATE:
        LAST_CLEAN = time.time()
        for file in os.listdir(CACHE_DIR):
            path = os.path.join(CACHE_DIR, file)
            if time.time() - os.path.getmtime(path) > CACHE_STATIC_EXPIRE and not path in storage:
                os.remove(path)

clean_static_cache()

def get_from_static_cache(key):
    path = os.path.join(CACHE_DIR, hashlib.md5(key).hexdigest())
    if os.path.isfile(path):
        with open(path) as f:
            return (os.path.getmtime(path), pickle.load(f))
    else:
        raise ValueError("Not Found")

def set_to_static_cache(key, value):
    try:
        (mtime, old_value) = get_from_static_cache(key)
    except ValueError:
        old_value = None
    if old_value != value:
        path = os.path.join(CACHE_DIR, hashlib.md5(key).hexdigest())
        with open(path, 'w+') as f:
            pickle.dump(value, f)
        os.chmod(path, 0600)

def set_to_cache(keys, value, now=None, null=False):
    global CACHE
    if now is None:
        now = time.time()
    if not isinstance(keys, list):
        keys = [keys]
    for key in keys:
        if key or null:
            CACHE[key] = (now, value)

def get_from_cache(key, now=None, expire=CACHE_TIMEOUT):
    global CACHE
    if now is None:
        now = time.time()
    if key in CACHE:
        (ts, v) = CACHE[key]
        if now - ts < expire or expire < 0:
            return v
        else:
            raise ValueError("Expired")
    else:
        raise ValueError("Not Found")

class Pass:
    pass

class Item(object):
    """
    An Item is an Object on Disk, it can be a Directory, File, Symlink, ...
    """
    def __init__(self, mode, uid, gid):
        # ----------------------------------- Metadata --
        self.atime = time.time()   # time of last acces
        self.mtime = self.atime    # time of last modification
        self.ctime = self.atime    # time of last status change

        self.dev  = 0        # device ID (if special file)
        self.mode = mode     # protection and file-type
        self.uid  = uid      # user ID of owner
        self.gid  = gid      # group ID of owner

        # ------------------------ Extended Attributes --
        self.xattr = {}

        # --------------------------------------- Data --
        if stat.S_ISDIR(mode):
            self.data = set()
        else:
            self.data = ''

    def __eq__(self, obj):
        if isinstance(obj, Item):
            return self.dev == obj.dev and \
            self.mode == obj.mode and \
            self.uid == obj.uid and \
            self.gid == obj.gid and \
            self.xattr == obj.xattr and \
            self.data == obj.data and \
            abs(self.mtime - obj.mtime) < CACHE_STATIC_UPDATE
        else:
            return False

    def __ne__(self, obj):
        return not self == obj

    def read(self, offset, length):
        return self.data[offset:offset+length]

    def write(self, offset, data):
        length = len(data)
        self.data = self.data[:offset] + data + self.data[offset+length:]
        return length

    def truncate(self, length):
        if len(self.data) > length:
            self.data = self.data[:length]
        else:
            self.data += '\x00'# (length - len(self.data))

def zstat(stat):
    stat.st_mode  = 0
    stat.st_ino   = 0
    stat.st_dev   = 0
    stat.st_nlink = 2
    stat.st_uid   = 0
    stat.st_gid   = 0
    stat.st_size  = 0
    stat.st_atime = 0
    stat.st_mtime = 0
    stat.st_ctime = 0
    return stat

class LdapCertFS(fuse.Fuse):
    def __init__(self, ldap_filter, nopkey=False, decrypt=False, *args, **kwargs):
        fuse.Fuse.__init__(self, *args, **kwargs)

        self.uid = os.getuid()
        self.gid = os.getgid()

        self.nopkey=nopkey
        self.decrypt = decrypt
        self.ldap_filter = ldap_filter

        # dictionnnaire CN => certificat pour construire la chaine de certificat
        # Il n'est utile ici que de renseigner des CN de CA intermédiaires
        self.chain = CHAIN

        # Les fichers certificats que l'on veux créer. fil est une liste représentant
        # la concaténation des attributs ldap (bien formaté). chain est un joker pour
        # construire la chaine de certificat
        self.files = {
            'crt.pem' : {'file':['certificat'], 'mode':0444},
            'key.pem' : {'file':['privatekey'], 'mode':0400},
            'csr.pem' : {'file':['csr'], 'mode':0444},
            'chain.pem' : {'file':['chain'], 'mode':0444},
            'key_cert_chain.pem' : {'file':['privatekey', 'certificat', 'chain'], 'mode':0400},
            'cert_chain.pem' : {'file':['certificat', 'chain'], 'mode':0444},
        }
        self.conn = lc_ldap.shortcuts.lc_ldap_readonly()
        self._storage = {}
        self.passphrase = {}

    def _func_cache(self, func, *args, **kwargs):
        rec = kwargs.pop('rec', False)
        kwargs_l = ["%s=%s" % kv for kv in kwargs.items()]
        kwargs_l.sort()
        serial = "%s(%s,%s)" % (func.__name__, ", ".join(args), ", ".join(kwargs_l))
        try:
            return get_from_cache(serial)
        except ValueError:
            try:
                objects = func(*args, **kwargs)
                set_to_cache(serial, objects)
                return objects
            except ldap.SERVER_DOWN:
                try:
                    self.conn = lc_ldap.shortcuts.lc_ldap_readonly()
                    if not rec:
                        logger.warning("ldap down, retrying")
                        kwargs['rec'] = True
                        self.func_cache(func, *args, **kwargs)
                except Exception as e:
                    logger.error("uncaught exception %r" % e)
                # Si le serveur est down on essaye de fournir un ancienne valeur du cache
                try:
                    return get_from_cache(serial, expire=-1)
                except ValueError:
                    logger.critical("fail to return a valid result, I will probably crash next to this")
                    return []

    def search_cache(self, filter, **kwargs):
        return self._func_cache(self.conn.search, filter, **kwargs)

    def get_local_machines(self):
        return self._func_cache(self.conn.get_local_machines)

    def make_root(self):
        self._storage['/'] = Item(0755 | stat.S_IFDIR, self.uid, self.gid)
        if self.ldap_filter == 'self':
            machines = self.search_cache(u'mid=*', dn=self.conn.dn, scope=1)
        elif self.ldap_filter:
            machines = self.search_cache("(&(%s)(mid=*))" % self.ldap_filter, sizelimit=8000)
        else:
            machines = self.get_local_machines()
        for machine in machines:
            if not machine.certificats():
                continue
            if 'aid' in machine.dn and "cransAccount" in machine.proprio()['objectClass']:
                self.uid = int(machine.proprio()["uidNumber"][0])
                self.gid = int(machine.proprio()["gidNumber"][0])
            else:
                self.uid = 0
                self.gid = 0
            mpath = "/%s" % machine['host'][0]
            if not mpath in self._storage:
                self._storage[mpath]=Item(0755 | stat.S_IFDIR, self.uid, self.gid)
            self._add_to_parent_dir(mpath)

    def make_machine(self, hostname):
        machine = self.search_cache(u"host=%s" % hostname)[0]
        mpath = '/%s' % machine['host'][0]
        self._storage[mpath]=Item(0755 | stat.S_IFDIR, self.uid, self.gid)
        for cert in machine.certificats(refresh=True):
           xpath = '%s/xid=%s' % (mpath, cert["xid"][0])
           if not xpath in self._storage:
               self._storage[xpath]=Item(0755 | stat.S_IFDIR, self.uid, self.gid)
           # Si info on met un lien symbolique info => xid=id
           if cert['info']:
               item = Item(0644 | stat.S_IFLNK, self.uid, self.gid)
               item.data = 'xid=%s' % cert["xid"][0]
               spath = '%s/%s' % (mpath, cert['info'][0])
               indice = 1
               while spath in self._storage and self._storage[spath].data != item.data:
                   spath = '%s/%s (%s)' % (mpath, cert['info'][0], indice)
                   indice += 1
               self._storage[spath] = item
               self._add_to_parent_dir(spath)
           self._add_to_parent_dir(xpath)
        self._add_to_parent_dir(mpath)

    def make_cert(self, xid):
       cert = self.search_cache(u"xid=%s" % xid)[0]
       xpath = '/%s/xid=%s' % (cert.machine()['host'][0], cert["xid"][0])
       self._storage[xpath]=Item(0755 | stat.S_IFDIR, self.uid, self.gid)
       for file, file_data in self.files.items():
           self.make_file(xid, file)
       self._add_to_parent_dir(xpath)

    def make_file(self, xid, file):
       cert = self.search_cache(u"xid=%s" % xid)[0]
       xpath = '/%s/xid=%s' % (cert.machine()['host'][0], cert["xid"][0])
       file_data = self.files[file]
       data = self._file_data(cert.machine(), cert, file)
       if data:
           fpath = '%s/%s' % (xpath, file)
           self._storage[fpath]=Item(file_data['mode'] | stat.S_IFREG, self.uid, self.gid)
           self._storage[fpath].data = data
           self._add_to_parent_dir(fpath)

    def _file_data(self, machine, cert, file):
        """Construit le contenue du fichier file utilisant le certificat cert de machine"""
        data = ""
        for dtype in self.files[file]['file']:
            if dtype == "chain":
                if 'x509Cert' in cert['objectClass'] and cert['issuerCN'][0] in self.chain:
                    if 'path' in self.chain[str(cert['issuerCN'][0])]:
                        data += open(self.chain[str(cert['issuerCN'][0])]['path']).read()
                    elif 'content' in self.chain[str(cert['issuerCN'][0])]:
                        data += self.chain[str(cert['issuerCN'][0])]['content']
                else:
                    return None
            elif dtype == "certificat" and 'x509Cert' in cert['objectClass']:
                data+=ssl.DER_cert_to_PEM_cert(str(cert['certificat'][0]))
            elif dtype == "privatekey":
                if "privateKey" in cert['objectClass'] and cert['privatekey'] and not self.nopkey:
                    if self.decrypt:
                        while True:
                            if not cert['xid'][0] in self.passphrase:
                                if cert['encrypted'][0]:
                                    if "machineCrans" in machine["objectClass"]:
                                        passphrase=secrets.get('privatekey_passphrase')
                                    else:
                                        print "Passphrase de la clef %s de %s" % (cert['info'][0] if cert['info'] else ('xid=%s' % cert["xid"][0]), machine['host'][0])
                                        try:
                                            passphrase = getpass.getpass()
                                        except KeyboardInterrupt:
                                            print "On passe la clef"
                                            self.passphrase[cert['xid'][0]]=Pass()
                                            return None
                                else:
                                    passphrase=None
                                self.passphrase[cert['xid'][0]]=passphrase
                            elif isinstance(self.passphrase[cert['xid'][0]], Pass):
                                return None
                            try:
                                if self.passphrase[cert['xid'][0]]:
                                     pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, str(cert['privatekey'][0]), self.passphrase[cert['xid'][0]])
                                else:
                                     pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, str(cert['privatekey'][0]), "")
                                break
                            except crypto.Error:
                                print "mauvais mot de pass"
                                del(self.passphrase[cert['xid'][0]])
                        data+=str(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey))
                    else:
                         data+=str(cert['privatekey'][0])
                else:
                    return None
            elif cert[dtype]:
                data+=str(cert[dtype][0])
            else:
                return None
        return data

    def parse_path(self, path):
        machine_host = None
        xid = None
        file = None
        paths = path.split('/')[1:]
        if paths:
            machine_host = paths[0]
            paths = paths[1:]
            if paths:
                if paths[0].startswith("xid="):
                    xid = paths[0][4:]
                    paths = paths[1:]
                    if paths:
                        file = paths[0]
        return (machine_host, xid, file)

    def build_path(self, path):
        try:
            get_from_cache(path)
        except ValueError:
            if path in self._storage:
                del self._storage[path]
            if path == "/":
                self.make_root()
            else:
                (machine_host, xid, file) = self.parse_path(path)
                if machine_host and not xid and not file:
                    self.make_machine(machine_host)
                elif machine_host and xid and not file:
                    self.make_cert(xid)
                elif machine_host and xid and file:
                    self.make_file(xid, file)
            set_to_cache(path, True)
            set_to_static_cache(path, self._storage[path])
    # --- Metadata -----------------------------------------------------------
    def getattr(self, path):
        try:
            self.build_path(path)
            clean_static_cache(self._storage)
        except Exception as e:
            logger.warning("Erreur : %s\n%s" % (e, traceback.format_exc()))
            if not path in self._storage:
                try:
                    (ts, v) = get_from_static_cache(path)
                    self._storage[path] = v
                except ValueError:
                    pass
        if not path in self._storage:
            return -errno.ENOENT

        # Lookup Item and fill the stat struct
        item = self._storage[path]
        st = zstat(fuse.Stat())
        st.st_mode  = item.mode
        st.st_uid   = item.uid
        st.st_gid   = item.gid
        st.st_dev   = item.dev
        st.st_atime = item.atime
        st.st_mtime = item.mtime
        st.st_ctime = item.ctime
        st.st_size  = len(item.data)
        return st

    def chmod(self, path, mode):
        return -errno.EPERM

    def chown(self, path, uid, gid):
        return -errno.EPERM

    def utime(self, path, times):
        item = self._storage[path]
        item.ctime = item.mtime = times[0]

    # --- Namespace ----------------------------------------------------------
    def unlink(self, path):
        return -errno.EPERM

    def rename(self, oldpath, newpath):
        return -errno.EPERM

    # --- Links --------------------------------------------------------------
    def symlink(self, path, newpath):
        return -errno.EPERM

    def readlink(self, path):
        return self._storage[path].data

    # --- Extra Attributes ---------------------------------------------------
    def setxattr(self, path, name, value, flags):
        return -errno.EPERM

    def getxattr(self, path, name, size):
        value = self._storage[path].xattr.get(name, '')
        if size == 0:   # We are asked for size of the value
            return len(value)
        return value

    def listxattr(self, path, size):
        attrs = self._storage[path].xattr.keys()
        if size == 0:
            return len(attrs) + len(''.join(attrs))
        return attrs

    def removexattr(self, path, name):
        return -errno.EPERM

    # --- Files --------------------------------------------------------------
    def mknod(self, path, mode, dev):
        return -errno.EPERM

    def create(self, path, flags, mode):
        return -errno.EPERM

    def truncate(self, path, len):
        return -errno.EPERM

    def read(self, path, size, offset):
        return self._storage[path].read(offset, size)

    def write(self, path, buf, offset):
        return -errno.EPERM

    # --- Directories --------------------------------------------------------
    def mkdir(self, path, mode):
        return -errno.EPERM

    def rmdir(self, path):
        return -errno.EPERM

    def readdir(self, path, offset):
        dir_items = self._storage[path].data
        for item in dir_items:
            yield fuse.Direntry(item)

    def _add_to_parent_dir(self, path):
        parent_path = os.path.dirname(path)
        filename = os.path.basename(path)
        if parent_path in self._storage:
            self._storage[parent_path].data.add(filename)

    def _remove_from_parent_dir(self, path):
        parent_path = os.path.dirname(path)
        filename = os.path.basename(path)
        self._storage[parent_path].data.remove(filename)

def main(usage):
    # valeurs par defaut des options
    decrypt=False
    nopkey=False
    ldap_filter = 'self'

    # Récupération de l'option decrypt
    if '--decrypt' in sys.argv[1:]:
        decrypt=True
        del(sys.argv[sys.argv.index('--decrypt')])

    # Récupération de l'option nopkey
    if '--nopkey' in sys.argv[1:]:
        nopkey=True
        del(sys.argv[sys.argv.index('--nopkey')])

    # Récupération de l'option ldap-filter
    try:
        ldap_filter = unicode(sys.argv[sys.argv.index('--ldap-filter') + 1])
        if os.getuid() != 0:
            raise EnvironmentError("Il faut être root pour choisir le filtre ldap")
        del(sys.argv[sys.argv.index('--ldap-filter') + 1])
    except (IndexError, ValueError):
       if os.getuid() == 0:
            ldap_filter = None
    finally:
        try: del(sys.argv[sys.argv.index('--ldap-filter')])
        except (IndexError, ValueError): pass

    # Vérification que le point de montage est bien un dossier
    end_option = False
    for item in sys.argv[1:]:
        if end_option or not item.startswith('-'):
            if not os.path.isdir(item):
                raise EnvironmentError("%s is not a dir" % item)
            break
        if item == '--':
            end_option=True

    # Instanciation du FS
    server = LdapCertFS(ldap_filter, nopkey=nopkey, decrypt=decrypt, version="%prog " + fuse.__version__,
                     usage=usage,
                     dash_s_do='setsingle')

    server.parse(errex=1)
    server.main()

if __name__ == '__main__':
    usage="""
LdapCertFS - Ldap Certificate File System
    Les obtions spécifiques sont :
      *  --decrypt : pour déchiffrer les clefs privées (un prompt demande le
           mot de passe si nécessaire.
      *  --nopkey : exclure les clefs privées lors de la construction du FS.
      *  --ldap-filter filtre : selectionner les machines à utiliser pour
           construire le FS avec un filtre ldap. Nécéssite les droits root.

    Si --ldap-filter n'est pas spécifier :
       * Si le programme est appelé par root, on utilises les machines
         correspondants aux ips des interfaces de la machine physique.
       * Sinon, on utilise les machines de l'utilisateur dans la base
         de donnée.

""" + fuse.Fuse.fusage
    # On force à fornir au moint un paramètre (il faut au moins un point de montage)
    if len(sys.argv)<2:
        sys.stderr.write("%s\n" % usage.replace('%prog', sys.argv[0]))
        sys.exit(1)
    # On appel main et on affiche les exceptions EnvironmentError
    try:
        main(usage)
    except (EnvironmentError, fuse.FuseError) as e:
        sys.stderr.write("Error: %s\n" % e)
        sys.exit(1)

