aurorepass/server.py
2013-08-18 20:23:04 +02:00

270 lines
8.9 KiB
Python
Executable file

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
"""Serveur pour cranspasswords"""
from __future__ import print_function
import glob
import os
import pwd
import sys
import json
import smtplib
import datetime
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
# Même problème que pour le client, il faut bootstraper le nom de la commande
# Pour accéder à la config
cmd_name = os.path.split(sys.argv[0])[1].replace("-server", "")
sys.path.append("/etc/%s/" % (cmd_name,))
import serverconfig
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
"""
for role in roles:
if mode == 'w':
role += '-w'
if serverconfig.ROLES.has_key(role) 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(0077)
f = open(filename, 'w')
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é."""
d = serverconfig.ROLES
if d.has_key("whoami"):
raise ValueError('La rôle "whoami" ne devrait pas exister')
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)
filenames = glob.glob('*.json')
files = {}
for filename in filenames:
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)
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]
@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:
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 de %s" % filename, corps, filename, old)
filepath = getpath(filename)
writefile(filepath, json.dumps({'roles': roles, 'contents': contents}))
return [True, u"Modification effectuée."]
@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)
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 de %s" % filename, corps, filename, old)
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(0077)
back = open(getpath(fname, backup=True), 'a')
back.write(json.dumps(old))
back.write('\n')
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')
frommail = serverconfig.CRANSP_MAIL
tomail = serverconfig.DEST_MAIL
msg = MIMEMultipart(_charset="utf-8")
msg['Subject'] = subject
msg['X-Mailer'] = serverconfig.cmd_name.decode()
msg['From'] = serverconfig.CRANSP_MAIL
msg['To'] = serverconfig.DEST_MAIL
msg.preamble = u"%s report" % (serverconfig.cmd_name.decode(),)
info = MIMEText(corps +
u"\nLa version précédente a été sauvegardée." +
u"\n\nModification effectuée sur %s." % socket.gethostname() +
u"\n\n-- \nCranspasswords.py", _charset="utf-8")
msg.attach(info)
conn.sendmail(frommail, tomail, msg.as_string())
conn.quit()
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))