Skip to content
Snippets Groups Projects
server.py 11.5 KiB
Newer Older
#!/usr/bin/env python2
Daniel STAN's avatar
Daniel STAN committed
# -*- encoding: utf-8 -*-

"""Serveur pour cranspasswords"""
Daniel STAN's avatar
Daniel STAN committed

Vincent Le gallic's avatar
Vincent Le gallic committed
from __future__ import print_function

Daniel STAN's avatar
Daniel STAN committed
import glob
import os
import pwd
import sys
import json
Daniel STAN's avatar
Daniel STAN committed
import datetime
import socket
import itertools
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
Daniel STAN's avatar
Daniel STAN committed

Daniel STAN's avatar
Daniel STAN committed
try:
    from cpasswords import clientlib
except ImportError:
    print("Couldn't import clientlib. Remote sync may not work", file=sys.stderr)
# Même problème que pour le client, il faut bootstraper le nom de la commande
# Pour accéder à la config
Daniel STAN's avatar
Daniel STAN committed
conf_path = os.getenv('CRANSPASSWORDS_SERVER_CONFIG_DIR', None)
if not conf_path:
    cmd_name = os.path.split(sys.argv[0])[1].replace("-server", "")
    conf_path = "/etc/%s/" % (cmd_name,)

sys.path.append(conf_path)
import serverconfig
Daniel STAN's avatar
Daniel STAN committed
MYUID = pwd.getpwuid(os.getuid())[0]
if MYUID == 'root':
    MYUID = os.environ['SUDO_USER']

## Fonctions internes au serveur

def validate(roles, mode='r'):
    """Vérifie que l'appelant appartient bien aux roles précisés
Daniel STAN's avatar
Daniel STAN committed
    Si mode mode='w', recherche un rôle en écriture
    """
Daniel STAN's avatar
Daniel STAN committed
    for role in roles:
        if mode == 'w':
            role += '-w'
        if serverconfig.ROLES.has_key(role) and MYUID in serverconfig.ROLES[role]:
Daniel STAN's avatar
Daniel STAN committed
            return True
    return False

def getpath(filename, backup=False):
    """Récupère le chemin du fichier ``filename``"""
Daniel STAN's avatar
Daniel STAN committed
    assert(isinstance(filename, unicode))
    filename = filename.encode('utf-8')
    return os.path.join(serverconfig.STORE, '%s.%s' % (filename, 'bak' if backup else 'json'))
Daniel STAN's avatar
Daniel STAN committed

def writefile(filename, contents):
    """Écrit le fichier avec les bons droits UNIX"""
Daniel STAN's avatar
Daniel STAN committed
    os.umask(0077)
    f = open(filename, 'w')
Vincent Le gallic's avatar
Vincent Le gallic committed
    f.write(contents.encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
    f.close()

class server_command(object):
    """
    Une instance est un décorateur pour la fonction servant de commande
    externe du même nom"""

    #: nom de la commande
    name = None

    #: fonction wrappée
    decorated = None

    #: (static) dictionnaire name => fonction
    by_name = {}

    #: rajoute un argument en fin de fonction à partir de stdin (si standalone)
    stdin_input = False

    #: Est-ce que ceci a besoin d'écrire ?
    write = False

    def __init__(self, name, stdin_input = False, write=False):
        """
         * ``name`` nom de l'action telle qu'appelée par le client
         * ``stdin_input`` si True, stdin sera lu en mode non-keepalive, et
                           remplira le dernier argument de la commande.
         * ``write`` s'agit-il d'une commande en écriture ?
        """
        self.name = name
        self.stdin_input = stdin_input
        self.write = write
        server_command.by_name[name] = self

    def __call__(self, fun):
        self.decorated = fun
        return fun

## Fonction exposées par le serveur
@server_command('keep-alive')
def keepalive():
    """ Commande permettant de réaliser un tunnel json (un datagramme par ligne)
    Un message entre le client et le serveur consiste en l'échange de dico

    Message du client: {'action': "nom_de_l'action",
                        'args': liste_arguments_passes_a_la_fonction}
    Réponse du serveur: {'status': 'ok',
        'content': retour_de_la_fonction,
    }

    """
    for line in iter(sys.stdin.readline, ''):
        data = json.loads(line.rstrip())
        try:
            # Une action du protocole = de l'ascii
            action = data['action'].encode('ascii')
            content = server_command.by_name[action].decorated(*data['args'])
            status = u'ok'
        except Exception as e:
            status = u'error'
            content = repr(e)
        out = {
            'status': status,
            'content': content,
        }
        print(json.dumps(out, encoding='utf-8'))
        sys.stdout.flush()

@server_command('listroles')
Daniel STAN's avatar
Daniel STAN committed
def listroles():
    """Liste des roles existant et de leurs membres.
       Renvoie également un rôle particulier ``"whoami"``, contenant l'username
       de l'utilisateur qui s'est connecté."""
    d = serverconfig.ROLES
    if d.has_key("whoami"):
        raise ValueError('La rôle "whoami" ne devrait pas exister')
    d["whoami"] = MYUID
    return d
Daniel STAN's avatar
Daniel STAN committed

@server_command('listkeys')
Daniel STAN's avatar
Daniel STAN committed
def listkeys():
    """Liste les usernames et les (mail, fingerprint) correspondants"""
    return serverconfig.KEYS
Daniel STAN's avatar
Daniel STAN committed

@server_command('listfiles')
Daniel STAN's avatar
Daniel STAN committed
def listfiles():
    """Liste les fichiers dans l'espace de stockage, et les roles qui peuvent y accéder"""
    os.chdir(serverconfig.STORE)
Daniel STAN's avatar
Daniel STAN committed
    filenames = glob.glob('*.json')
    files = {}
    for filename in filenames:
        file_dict = json.loads(open(filename).read())
Daniel STAN's avatar
Daniel STAN committed
        fname = filename[:-5].decode('utf-8')
        files[fname] = file_dict["roles"]
Daniel STAN's avatar
Daniel STAN committed
    return files
@server_command('restorefiles')
def restorefiles():
    """Si un fichier a été corrompu, on restore son dernier backup valide"""
    os.chdir(serverconfig.STORE)

    filenames = glob.glob('*.json')
    files = {}
    for filename in filenames:
        file_dict = json.loads(open(filename).read())
        if not ('-----BEGIN PGP MESSAGE-----' in file_dict["contents"]):
            fname = filename[:-5].decode('utf-8')
            with open(fname+'.bak') as f:
                line = f.readline()
                backup = ''
                while not (line==''):
                    try:
                        line_dict =  json.loads(line)
                        if ('-----BEGIN PGP MESSAGE-----' in line_dict["contents"]):
                            backup = line
                    except:
                        pass
                    line = f.readline()
                if not (backup == ''):
                    files[fname] = 'restored'
                    with open(fname+'.json','w') as f2:
                        f2.write(backup)
                else:
                    files[fname] = 'not restored'
    return files


@server_command('getfile')
Daniel STAN's avatar
Daniel STAN committed
def getfile(filename):
    """Récupère le fichier ``filename``"""
Daniel STAN's avatar
Daniel STAN committed
    filepath = getpath(filename)
    try:
        obj = json.loads(open(filepath).read())
        if not validate(obj['roles']):
	        return [False, u"Vous n'avez pas les droits de lecture sur le fichier %s." % filename]
        return [True, obj]
Daniel STAN's avatar
Daniel STAN committed
    except IOError:
        return [False, u"Le fichier %s n'existe pas." % filename]
@server_command('getfiles', stdin_input=True)
def getfiles(filenames):
    """Récupère plusieurs fichiers, lit la liste des filenames demandés sur stdin"""
    return [getfile(f) for f in filenames]

# TODO ça n'a rien à faire là, à placer plus haut dans le code
def _putfile(filename, roles, contents):
    """Écrit ``contents`` avec les roles ``roles`` dans le fichier ``filename``
    """
    gotit, old = getfile(filename)
    if not gotit:
Vincent Le gallic's avatar
Vincent Le gallic committed
        old = u"[Création du fichier]"
Daniel STAN's avatar
Daniel STAN committed
        pass
    else:
        oldroles = old['roles']
        if not validate(oldroles, 'w'):
            return [False, u"Vous n'avez pas le droit d'écriture sur %s." % filename]
    corps = u"Le fichier %s a été modifié par %s." % (filename, MYUID)
    backup(corps, filename, old)
    notification(u"Modification", filename, MYUID)
    filepath = getpath(filename)
    if type(contents) != unicode:
        return [False, u"Erreur: merci de patcher votre cpasswords !"
             + "(contents should be encrypted str)"]
        # Or fuck yourself

Daniel STAN's avatar
Daniel STAN committed
    writefile(filepath, json.dumps({'roles': roles, 'contents': contents}))
Daniel STAN's avatar
Daniel STAN committed

    data = {'filename': filename, 'roles': roles, 'contents': contents}
    for client in _list_to_replicate(data):
        client.put_file(data)

    return [True, u"Modification effectuée."]
Daniel STAN's avatar
Daniel STAN committed

@server_command('putfile', stdin_input=True, write=True)
def putfile(filename, parsed_stdin):
    """Écrit le fichier ``filename`` avec les données reçues sur stdin."""
    try:
        roles = parsed_stdin['roles']
        contents = parsed_stdin['contents']
    except KeyError:
        return [False, u"Entrée invalide"]
    return _putfile(filename, roles, contents)

@server_command('putfiles', stdin_input=True, write=True)
def putfiles(parsed_stdin):
    """Écrit plusieurs fichiers. Lit les filenames sur l'entrée standard avec le
    reste."""
    results = []
    for fichier in parsed_stdin:
        try:
            filename = fichier['filename']
            roles = fichier['roles']
            contents = fichier['contents']
        except KeyError:
            results.append([False, u"Entrée invalide"])
        else:
            results.append(_putfile(filename, roles, contents))
    return results

@server_command('rmfile', write=True)
Daniel STAN's avatar
Daniel STAN committed
def rmfile(filename):
    """Supprime le fichier filename après avoir vérifié les droits sur le fichier"""
    gotit, old = getfile(filename)
    if not gotit:
        return old # contient le message d'erreur
    roles = old['roles']
    if validate(roles, 'w'):
        corps = u"Le fichier %s a été supprimé par %s." % (filename, MYUID)
        backup(corps, filename, old)
        notification(u"Suppression", filename, MYUID)
        os.remove(getpath(filename))
Daniel STAN's avatar
Daniel STAN committed
    else:
        return u"Vous n'avez pas les droits d'écriture sur le fichier %s." % filename
    return u"Suppression effectuée"
Daniel STAN's avatar
Daniel STAN committed

# TODO monter plus haut
def backup(corps, fname, old):
    """Backupe l'ancienne version du fichier"""
Daniel STAN's avatar
Daniel STAN committed
    os.umask(0077)
    back = open(getpath(fname, backup=True), 'a')
Daniel STAN's avatar
Daniel STAN committed
    back.write(json.dumps(old))
    back.write('\n')
    back.write((u'* %s: %s\n' % (str(datetime.datetime.now()), corps)).encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
    back.close()
Daniel STAN's avatar
Daniel STAN committed
def _list_to_replicate(data):
    """Renvoie une liste d'options clients sur lesquels appliquer relancer
    la procédure (pour réplication auto)"""
    roles = data.get('roles', [])
    backups = getattr(serverconfig, 'BACKUP_ROLES', {})
    servers = getattr(serverconfig, 'BACKUP_SERVERS', {})

    configs = set(name for role in roles for name in backups.get(role, []))
    return [ clientlib.Client(servers[name]) for name in configs ]

_notif_todo = []
def notification(action, fname, actor):
    """Enregistre une notification"""
    _notif_todo.append((action, fname, actor))

def notification_mail():
    """Envoie par mail une notification de changement de fichier"""
    if not _notif_todo:
        return

    frommail = serverconfig.CRANSP_MAIL
    tomail = serverconfig.DEST_MAIL
    actions = set( task[1] for task in _notif_todo )

    msg = MIMEMultipart(_charset="utf-8")
    msg['Subject'] = u"Modification de la base (%s)" % (', '.join(actions))
    msg['X-Mailer'] = serverconfig.cmd_name.decode()
    msg['From'] = frommail
    msg['To'] = tomail
    msg.preamble = u"%s report" % (serverconfig.cmd_name.decode(),)
    liste = (u" * %s de %s par %s" % task for task in _notif_todo)

    info = MIMEText(u"Des modifications ont été faites:\n" +
	u"\n".join(liste) +
        u"\n\nDes sauvegardes ont été réalisées." +
        u"\n\nModification effectuée sur %s." % socket.gethostname() +
Vincent Le gallic's avatar
Vincent Le gallic committed
        u"\n\n-- \nCranspasswords.py", _charset="utf-8")
    msg.attach(info)
    mailProcess = subprocess.Popen([serverconfig.sendmail_cmd, "-t"], stdin=subprocess.PIPE)
    mailProcess.communicate(msg.as_string())
Daniel STAN's avatar
Daniel STAN committed
if __name__ == "__main__":
    argv = sys.argv[0:]
    command_name = argv[1]

    command = server_command.by_name[command_name]
    if serverconfig.READONLY and command.write:
        raise IOError("Ce serveur est read-only.")

    args = argv[2:]
    # On veut des unicode partout
    args = [ s.decode('utf-8') for s in args ]
    if command.stdin_input:
        args.append(json.loads(sys.stdin.read()))
    answer = command.decorated(*args)
    if answer is not None:
        print(json.dumps(answer))

    notification_mail()