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
from email.message import EmailMessage
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)
    msg = EmailMessage()
    liste = "\r\n".join(" * %s de %s par %s" % task for task in notifications)
    hostname = socket.gethostname()
    msg['From'] = f"{ serverconfig.FROM_MAIL }"
    msg['To'] =  f"{ serverconfig.TO_MAIL }"
    msg['Subject'] = "Modification de la base (%s)" % ', '.join(actions)
    msg['X-Mailer'] = f"{ serverconfig.cmd_name }"
    msg.set_content(
        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.send_message(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()