server.py 8.93 KB
Newer Older
Daniel Stan's avatar
Daniel Stan committed
1 2
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
3 4

"""Serveur pour cranspasswords"""
Daniel Stan's avatar
Daniel Stan committed
5

Vincent Le gallic's avatar
Vincent Le gallic committed
6 7
from __future__ import print_function

Daniel Stan's avatar
Daniel Stan committed
8 9 10 11 12
import glob
import os
import pwd
import sys
import json
13
import smtplib
Daniel Stan's avatar
Daniel Stan committed
14
import datetime
15
import socket
16 17
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
Daniel Stan's avatar
Daniel Stan committed
18

19 20 21 22 23
# 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
Daniel Stan's avatar
Daniel Stan committed
24

Daniel Stan's avatar
Daniel Stan committed
25 26 27 28
MYUID = pwd.getpwuid(os.getuid())[0]
if MYUID == 'root':
    MYUID = os.environ['SUDO_USER']

29 30
## Fonctions internes au serveur

31 32
def validate(roles, mode='r'):
    """Vérifie que l'appelant appartient bien aux roles précisés
33 34
    Si mode mode='w', recherche un rôle en écriture
    """
Daniel Stan's avatar
Daniel Stan committed
35
    for role in roles:
36 37
        if mode == 'w':
            role += '-w'
38
        if serverconfig.ROLES.has_key(role) and MYUID in serverconfig.ROLES[role]:
Daniel Stan's avatar
Daniel Stan committed
39 40 41
            return True
    return False

42 43
def getpath(filename, backup=False):
    """Récupère le chemin du fichier ``filename``"""
44
    return os.path.join(serverconfig.STORE, '%s.%s' % (filename, 'bak' if backup else 'json'))
Daniel Stan's avatar
Daniel Stan committed
45 46

def writefile(filename, contents):
47
    """Écrit le fichier avec les bons droits UNIX"""
Daniel Stan's avatar
Daniel Stan committed
48 49
    os.umask(0077)
    f = open(filename, 'w')
Vincent Le gallic's avatar
Vincent Le gallic committed
50
    f.write(contents.encode("utf-8"))
Daniel Stan's avatar
Daniel Stan committed
51 52
    f.close()

53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
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')
Daniel Stan's avatar
Daniel Stan committed
120
def listroles():
121 122 123 124 125 126 127
    """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
Daniel Stan's avatar
Daniel Stan committed
128

129
@server_command('listkeys')
Daniel Stan's avatar
Daniel Stan committed
130
def listkeys():
131
    """Liste les usernames et les (mail, fingerprint) correspondants"""
132
    return serverconfig.KEYS
Daniel Stan's avatar
Daniel Stan committed
133

134
@server_command('listfiles')
Daniel Stan's avatar
Daniel Stan committed
135 136
def listfiles():
    """Liste les fichiers dans l'espace de stockage, et les roles qui peuvent y accéder"""
137
    os.chdir(serverconfig.STORE)
138
    
Daniel Stan's avatar
Daniel Stan committed
139 140 141 142 143 144
    filenames = glob.glob('*.json')
    files = {}
    for filename in filenames:
        file_dict = json.loads(open(filename).read())
        files[filename[:-5]] = file_dict["roles"]
    return files
145 146

@server_command('getfile')
Daniel Stan's avatar
Daniel Stan committed
147
def getfile(filename):
148
    """Récupère le fichier ``filename``"""
Daniel Stan's avatar
Daniel Stan committed
149 150
    filepath = getpath(filename)
    try:
151 152
        obj = json.loads(open(filepath).read())
        if not validate(obj['roles']):
153
	        return [False, u"Vous n'avez pas les droits de lecture sur le fichier %s." % filename]
154
        obj["filename"] = filename
155
        return [True, obj]
Daniel Stan's avatar
Daniel Stan committed
156
    except IOError:
157
        return [False, u"Le fichier %s n'existe pas." % filename]
Daniel Stan's avatar
Daniel Stan committed
158
     
159 160
@server_command('getfiles', stdin_input=True)
def getfiles(filenames):
161 162 163
    """Récupère plusieurs fichiers, lit la liste des filenames demandés sur stdin"""
    return [getfile(f) for f in filenames]

164
# TODO ça n'a rien à faire là, à placer plus haut dans le code
165 166
def _putfile(filename, roles, contents):
    """Écrit ``contents`` avec les roles ``roles`` dans le fichier ``filename``"""
167 168
    gotit, old = getfile(filename)
    if not gotit:
Vincent Le gallic's avatar
Vincent Le gallic committed
169
        old = u"[Création du fichier]"
Daniel Stan's avatar
Daniel Stan committed
170 171
        pass
    else:
172 173 174 175 176 177 178
        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)
179
    
180
    filepath = getpath(filename)
Daniel Stan's avatar
Daniel Stan committed
181
    writefile(filepath, json.dumps({'roles': roles, 'contents': contents}))
182
    return [True, u"Modification effectuée."]
Daniel Stan's avatar
Daniel Stan committed
183

184 185
@server_command('putfile', stdin_input=True, write=True)
def putfile(filename, parsed_stdin):
186 187 188 189 190 191 192 193
    """É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)

194 195
@server_command('putfiles', stdin_input=True, write=True)
def putfiles(parsed_stdin):
196 197 198 199 200 201 202 203 204 205 206 207 208
    """É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

209
@server_command('rmfile', write=True)
Daniel Stan's avatar
Daniel Stan committed
210 211
def rmfile(filename):
    """Supprime le fichier filename après avoir vérifié les droits sur le fichier"""
212 213 214 215 216 217 218 219 220
    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))
Daniel Stan's avatar
Daniel Stan committed
221
    else:
222 223
        return u"Vous n'avez pas les droits d'écriture sur le fichier %s." % filename
    return u"Suppression effectuée"
Daniel Stan's avatar
Daniel Stan committed
224

225

226
# TODO monter plus haut
227
def backup(corps, fname, old):
228
    """Backupe l'ancienne version du fichier"""
Daniel Stan's avatar
Daniel Stan committed
229
    os.umask(0077)
230
    back = open(getpath(fname, backup=True), 'a')
Daniel Stan's avatar
Daniel Stan committed
231 232
    back.write(json.dumps(old))
    back.write('\n')
233
    back.write((u'* %s: %s\n' % (str(datetime.datetime.now()), corps)).encode("utf-8"))
Daniel Stan's avatar
Daniel Stan committed
234
    back.close()
235

236
# TODO monter plus haut
237 238
def notification(subject, corps, fname, old):
    """Envoie par mail une notification de changement de fichier"""
239
    conn = smtplib.SMTP('localhost')
240 241
    frommail = serverconfig.CRANSP_MAIL
    tomail = serverconfig.DEST_MAIL
242 243
    msg = MIMEMultipart(_charset="utf-8")
    msg['Subject'] = subject
244 245 246 247
    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(),)
248
    info = MIMEText(corps +
Vincent Le gallic's avatar
Vincent Le gallic committed
249
        u"\nLa version précédente a été sauvegardée." +
250
        u"\n\nModification effectuée sur %s." % socket.gethostname() +
Vincent Le gallic's avatar
Vincent Le gallic committed
251
        u"\n\n-- \nCranspasswords.py", _charset="utf-8")
252
    msg.attach(info)
Vincent Le gallic's avatar
Vincent Le gallic committed
253
    conn.sendmail(frommail, tomail, msg.as_string())
254 255
    conn.quit()

Daniel Stan's avatar
Daniel Stan committed
256
if __name__ == "__main__":
257 258 259 260 261
    argv = sys.argv[0:]
    command_name = argv[1]

    command = server_command.by_name[command_name]
    if serverconfig.READONLY and command.write:
262
        raise IOError("Ce serveur est read-only.")
263 264 265 266 267 268 269 270

    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:
271
        print(json.dumps(answer))