#!python """ 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 import logging from smtplib import SMTP from email.message import EmailMessage # 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) try: 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' if role in serverconfig.ROLES.keys() and MYUID in serverconfig.ROLES[role]: return True return False 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')) def writefile(filename, contents): """Écrit le fichier avec les bons droits UNIX""" os.umask(0o077) f = open(filename, 'w') f.write(contents) f.close() 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 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 ServerCommand.by_name[name] = self def __call__(self, fun): self.decorated = fun return fun # Fonction exposées par le serveur @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') 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() @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 if "whoami" in d.keys(): raise ValueError('La rôle "whoami" ne devrait pas exister') d["whoami"] = MYUID return d @ServerCommand('listkeys') def listkeys(): """Liste les usernames et les (mail, fingerprint) correspondants""" return serverconfig.KEYS @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 @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 = '' while not (line == ''): try: line_dict = json.loads(line) if ('-----BEGIN PGP MESSAGE-----' in line_dict["contents"]): backup = line except Exception: 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 @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']): 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] @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."] @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) @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 @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: 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""" 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, [])) # return [clientlib.Client(servers[name]) for name in configs] return [] # FIXME _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 ({})".format(', '.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] 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()