From d220fa370f0c7b6b4d3531b45e570219b51be2b8 Mon Sep 17 00:00:00 2001 From: Daniel STAN <daniel.stan@crans.org> Date: Sun, 4 Aug 2013 13:33:59 +0200 Subject: [PATCH] [keep-alive] feature connexion unique au serveur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C'était plus pratique que de faire un merge de la branche devel-keep-alive. La feature n'est pas activée automatiquement pour les gens qui ont déjà un fichier de config, mais elle est présente pour ceux qui en crée un. --- client.py | 76 ++++++++++++++++----- clientconfig.example.py | 1 + server.py | 145 ++++++++++++++++++++++++++-------------- 3 files changed, 156 insertions(+), 66 deletions(-) diff --git a/client.py b/client.py index 17df3d5..ecc025b 100755 --- a/client.py +++ b/client.py @@ -239,7 +239,12 @@ class simple_memoize(object): mais il faudra s'en préoccuper si un jour on veut changer le comportement.""" if self.val == None: self.val = self.f(*args, **kwargs) - return copy.deepcopy(self.val) + # On évite de tout deepcopier. Typiquement, un subprocess.Popen + # ne devrait pas l'être (comme dans get_keep_alive_connection) + if type(self.val) in [dict, list]: + return copy.deepcopy(self.val) + else: + return self.val ###### @@ -258,6 +263,10 @@ def remote_proc(options, command, arg=None): full_command.append(command) if arg: full_command.append(arg) + + if options.verbose and not options.quiet: + print("Running command %s ..." % " ".join(full_command)) + proc = subprocess.Popen(full_command, stdin = subprocess.PIPE, stdout = subprocess.PIPE, @@ -265,34 +274,67 @@ def remote_proc(options, command, arg=None): close_fds = True) return proc +@simple_memoize +def get_keep_alive_connection(options): + """Fabrique un process parlant avec le serveur suivant la commande + 'keep-alive'. On utilise une fonction séparée pour cela afin + de memoizer le résultat, et ainsi utiliser une seule connexion""" + proc = remote_proc(options, 'keep-alive', None) + atexit.register(proc.stdin.close) + return proc + def remote_command(options, command, arg=None, stdin_contents=None): """Exécute la commande distante, et retourne la sortie de cette commande""" detail = options.verbose and not options.quiet + keep_alive = options.serverdata.get('keep-alive', False) - proc = remote_proc(options, command, arg) - if stdin_contents is not None: - proc.stdin.write(json.dumps(stdin_contents)) - proc.stdin.close() - ret = proc.wait() - raw_out = proc.stdout.read() - if ret != 0: - if not options.quiet: - print((u"Mauvais code retour côté serveur, voir erreur " + - u"ci-dessus").encode('utf-8'), - file=sys.stderr) - if options.verbose: - print("raw_output: %s" % raw_out) - sys.exit(ret) + if keep_alive: + conn = get_keep_alive_connection(options) + args = filter(None, [arg, stdin_contents]) + msg = {u'action': unicode(command), u'args': args } + conn.stdin.write('%s\n' % json.dumps(msg)) + conn.stdin.flush() + raw_out = conn.stdout.readline() + else: + proc = remote_proc(options, command, arg) + if stdin_contents is not None: + proc.stdin.write(json.dumps(stdin_contents)) + proc.stdin.close() + ret = proc.wait() + raw_out = proc.stdout.read() + if ret != 0: + if not options.quiet: + print((u"Mauvais code retour côté serveur, voir erreur " + + u"ci-dessus").encode('utf-8'), + file=sys.stderr) + if detail: + print("raw_output: %s" % raw_out) + sys.exit(ret) try: - return json.loads(raw_out) + answer = json.loads(raw_out.strip()) except ValueError: if not options.quiet: print(u"Impossible de parser le résultat".encode('utf-8'), file=sys.stderr) - if options.verbose: + if detail: print("raw_output: %s" % raw_out) sys.exit(42) + if not keep_alive: + return answer + else: + try: + if answer[u'status'] != u'ok': + raise KeyError('Bad answer status') + return answer[u'content'] + except KeyError: + if not options.quiet: + print(u"Réponse erronée du serveur".encode('utf-8'), + file=sys.stderr) + if detail: + print("answer: %s" % repr(answer)) + sys.exit(-1) + @simple_memoize def all_keys(options): diff --git a/clientconfig.example.py b/clientconfig.example.py index c61f3eb..0021209 100755 --- a/clientconfig.example.py +++ b/clientconfig.example.py @@ -30,6 +30,7 @@ servers = { # Utile pour tester 'localhost': { 'server_cmd': [ssh_path, 'localhost'] + distant_cmd, + 'keep-alive': True, # <-- experimental, n'ouvre qu'une connexion }, 'ovh': { 'server_cmd': [ssh_path, 'ovh.crans.org'] + distant_cmd, diff --git a/server.py b/server.py index de234d1..e0bd6e0 100755 --- a/server.py +++ b/server.py @@ -25,6 +25,8 @@ 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 Si mode mode='w', recherche un rôle en écriture @@ -47,6 +49,73 @@ def writefile(filename, contents): f.write(contents.encode("utf-8")) 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') 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é.""" @@ -56,10 +125,12 @@ def listroles(): d["whoami"] = MYUID return d +@server_command('listkeys') def listkeys(): """Liste les usernames et les (mail, fingerprint) correspondants""" return serverconfig.KEYS +@server_command('listfiles') def listfiles(): """Liste les fichiers dans l'espace de stockage, et les roles qui peuvent y accéder""" os.chdir(serverconfig.STORE) @@ -70,7 +141,8 @@ def listfiles(): file_dict = json.loads(open(filename).read()) files[filename[:-5]] = file_dict["roles"] return files - + +@server_command('getfile') def getfile(filename): """Récupère le fichier ``filename``""" filepath = getpath(filename) @@ -83,13 +155,12 @@ def getfile(filename): except IOError: return [False, u"Le fichier %s n'existe pas." % filename] - -def getfiles(): +@server_command('getfiles', stdin_input=True) +def getfiles(filenames): """Récupère plusieurs fichiers, lit la liste des filenames demandés sur stdin""" - stdin = sys.stdin.read() - filenames = json.loads(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) @@ -109,10 +180,9 @@ def _putfile(filename, roles, contents): writefile(filepath, json.dumps({'roles': roles, 'contents': contents})) return [True, u"Modification effectuée."] -def putfile(filename): +@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.""" - stdin = sys.stdin.read() - parsed_stdin = json.loads(stdin) try: roles = parsed_stdin['roles'] contents = parsed_stdin['contents'] @@ -120,10 +190,9 @@ def putfile(filename): return [False, u"Entrée invalide"] return _putfile(filename, roles, contents) -def putfiles(): +@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.""" - stdin = sys.stdin.read() - parsed_stdin = json.loads(stdin) results = [] for fichier in parsed_stdin: try: @@ -136,7 +205,7 @@ def putfiles(): results.append(_putfile(filename, roles, contents)) return results - +@server_command('rmfile', write=True) def rmfile(filename): """Supprime le fichier filename après avoir vérifié les droits sur le fichier""" gotit, old = getfile(filename) @@ -153,6 +222,7 @@ def rmfile(filename): return u"Suppression effectuée" +# TODO monter plus haut def backup(corps, fname, old): """Backupe l'ancienne version du fichier""" os.umask(0077) @@ -162,6 +232,7 @@ def backup(corps, fname, old): back.write((u'* %s: %s\n' % (str(datetime.datetime.now()), corps)).encode("utf-8")) back.close() +# TODO monter plus haut def notification(subject, corps, fname, old): """Envoie par mail une notification de changement de fichier""" conn = smtplib.SMTP('localhost') @@ -180,43 +251,19 @@ def notification(subject, corps, fname, old): conn.sendmail(frommail, tomail, msg.as_string()) conn.quit() -WRITE_COMMANDS = ["putfile", "rmfile"] - if __name__ == "__main__": - argv = sys.argv[1:] - if len(argv) not in [1, 2]: - sys.exit(1) - command = argv[0] - if serverconfig.READONLY and command in WRITE_COMMANDS: + 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.") - filename = None - try: - filename = argv[1] - except IndexError: - pass - - answer = None - if command == "listroles": - answer = listroles() - elif command == "listkeys": - answer = listkeys() - elif command == "listfiles": - answer = listfiles() - elif command == "getfiles": - answer = getfiles() - elif command == "putfiles": - answer = putfiles() - else: - if not filename: - print("filename nécessaire pour cette opération", file=sys.stderr) - sys.exit(1) - if command == "getfile": - answer = getfile(filename) - elif command == "putfile": - answer = putfile(filename) - elif command == "rmfile": - answer = rmfile(filename) - else: - sys.exit(1) - if not answer is None: + + 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)) -- GitLab