Skip to content
Snippets Groups Projects
server.py 11.3 KiB
Newer Older
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
#!python
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
"""
Group password manager server

Copyright (C) 2010-2020 Cr@ns <roots@crans.org>
Authors : Daniel Stan <daniel.stan@crans.org>
          Vincent Le Gallic <legallic@crans.org>
          Alexandre Iooss <erdnaxe@crans.org>
SPDX-License-Identifier: GPL-3.0-or-later
"""

import glob
import os
import pwd
import sys
import json
import datetime
import socket
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
import logging
from smtplib import SMTP
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
# Configuration loading
# Guess name as we do not have config
bootstrap_cmd_name = os.path.split(sys.argv[0])[1].replace("-server", "")
default_config_path = "/etc/" + bootstrap_cmd_name
config_path = os.getenv(
    "CRANSPASSWORDS_SERVER_CONFIG_DIR", default_config_path)
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
    sys.path.append(config_path)
    import serverconfig
except ModuleNotFoundError:
    # If config could not be imported, display an error if required
    # Do not use logger as it has not been initialized yet
    print("%s/serverconfig.py could not be found or read.\n"
          "Please copy `docs/serverconfig.example.py` from the source "
          "repository and customize." % config_path)
    exit(1)

# Local logger
log = logging.getLogger(__name__)

# Get user name that launch the server
MYUID = pwd.getpwuid(os.getuid())[0]
if MYUID == 'root':
    MYUID = os.environ['SUDO_USER']


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

me5na7qbjqbrp's avatar
me5na7qbjqbrp committed

def getpath(filename, backup=False):
    """Récupère le chemin du fichier ``filename``"""
    return os.path.join(serverconfig.STORE, '%s.%s' % (filename, 'bak' if backup else 'json'))

me5na7qbjqbrp's avatar
me5na7qbjqbrp committed

def writefile(filename, contents):
    """Écrit le fichier avec les bons droits UNIX"""
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
    os.umask(0o077)
    f = open(filename, 'w')
    f.write(contents)
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed

me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
class ServerCommand(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

me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
    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
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
        ServerCommand.by_name[name] = self

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

me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
# Fonction exposées par le serveur
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
@ServerCommand('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')
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
            content = ServerCommand.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))
        sys.stdout.flush()

me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
@ServerCommand('listroles')
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
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
    if "whoami" in d.keys():
        raise ValueError('La rôle "whoami" ne devrait pas exister')
    d["whoami"] = MYUID
    return d

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

me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
@ServerCommand('listfiles')
def listfiles():
    """Liste les fichiers dans l'espace de stockage, et les roles qui peuvent y accéder"""
    os.chdir(serverconfig.STORE)

    filenames = glob.glob('*.json')
    files = {}
    for filename in filenames:
        file_dict = json.loads(open(filename).read())
        fname = filename[:-5]
        files[fname] = file_dict["roles"]
    return files

me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
@ServerCommand('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]
            with open(fname+'.bak') as f:
                line = f.readline()
                backup = ''
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
                while not (line == ''):
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
                        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'
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
                    with open(fname+'.json', 'w') as f2:
                        f2.write(backup)
                else:
                    files[fname] = 'not restored'
    return files


me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
@ServerCommand('getfile')
def getfile(filename):
    """Récupère le fichier ``filename``"""
    filepath = getpath(filename)
    try:
        obj = json.loads(open(filepath).read())
        if not validate(obj['roles']):
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
            return [False, u"Vous n'avez pas les droits de lecture sur le fichier %s." % filename]
        obj["filename"] = filename
        return [True, obj]
    except IOError:
        return [False, u"Le fichier %s n'existe pas." % filename]

me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
@ServerCommand('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:
        old = u"[Création du fichier]"
        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)

    writefile(filepath, json.dumps({'roles': roles, 'contents': contents}))

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

    return [True, u"Modification effectuée."]

me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
@ServerCommand('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)

me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
@ServerCommand('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

me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
@ServerCommand('rmfile', write=True)
def rmfile(filename):
    """Supprime le fichier filename après avoir vérifié les droits sur le fichier"""
    gotit, old = getfile(filename)
    if not gotit:
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
        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))
    else:
        return u"Vous n'avez pas les droits d'écriture sur le fichier %s." % filename
    return u"Suppression effectuée"


# TODO monter plus haut
def backup(corps, fname, old):
    """Backupe l'ancienne version du fichier"""
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
    os.umask(0o077)
    back = open(getpath(fname, backup=True), 'a')
    back.write(json.dumps(old))
    back.write('\n')
    back.write(f"* {datetime.datetime.now()}: {corps}\n")
    back.close()

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, []))
me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
    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(notifications):
    """
    Send notifications by mail
    """
    # Build message
    actions = set(task[1] for task in notifications)
    subject = "Modification de la base (%s)" % ', '.join(actions)
    liste = "\r\n".join(" * %s de %s par %s" % task for task in notifications)
    hostname = socket.gethostname()
    msg = (f"From: { serverconfig.FROM_MAIL }\r\n"
           f"To: { serverconfig.TO_MAIL }\r\n"
           f"Subject: { subject }\r\n"
           f"X-Mailer: { serverconfig.cmd_name }\r\n"
           f"Des modifications ont été faites:\r\n"
           f"{ liste }\r\n"
           f"Des sauvegardes ont été réalisées.\r\n"
           f"-- \r\n{ serverconfig.cmd_name } sur { hostname }")

    # Send
    with SMTP(serverconfig.SMTP_HOST) as s:
        s.sendmail(msg)
def main():
    argv = sys.argv[0:]
    command_name = argv[1]

me5na7qbjqbrp's avatar
me5na7qbjqbrp committed
    command = ServerCommand.by_name[command_name]
    if serverconfig.READONLY and command.write:
        raise IOError("Ce serveur est read-only.")

    args = argv[2:]
    if command.stdin_input:
        args.append(json.loads(sys.stdin.read()))
    answer = command.decorated(*args)
    if answer is not None:
        print(json.dumps(answer))

    if _notif_todo:
        # if notifications, then send email
        notification_mail(_notif_todo)


if __name__ == "__main__":
    main()