server.py 9.58 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
Daniel Stan's avatar
Daniel Stan committed
13
import datetime
14
import socket
15
import subprocess
16
import itertools
17 18
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
Daniel Stan's avatar
Daniel Stan committed
19

20 21 22 23 24
# 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
25

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

30 31
## Fonctions internes au serveur

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

43 44
def getpath(filename, backup=False):
    """Récupère le chemin du fichier ``filename``"""
45 46
    assert(isinstance(filename, unicode))
    filename = filename.encode('utf-8')
47
    return os.path.join(serverconfig.STORE, '%s.%s' % (filename, 'bak' if backup else 'json'))
Daniel Stan's avatar
Daniel Stan committed
48 49

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

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 120 121 122
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
123
def listroles():
124
    """Liste des roles existant et de leurs membres.
125 126
       Renvoie également un rôle particulier ``"whoami"``, contenant l'username
       de l'utilisateur qui s'est connecté."""
127 128 129 130 131
    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
132

133
@server_command('listkeys')
Daniel Stan's avatar
Daniel Stan committed
134
def listkeys():
135
    """Liste les usernames et les (mail, fingerprint) correspondants"""
136
    return serverconfig.KEYS
Daniel Stan's avatar
Daniel Stan committed
137

138
@server_command('listfiles')
Daniel Stan's avatar
Daniel Stan committed
139 140
def listfiles():
    """Liste les fichiers dans l'espace de stockage, et les roles qui peuvent y accéder"""
141
    os.chdir(serverconfig.STORE)
142

Daniel Stan's avatar
Daniel Stan committed
143 144 145 146
    filenames = glob.glob('*.json')
    files = {}
    for filename in filenames:
        file_dict = json.loads(open(filename).read())
147 148
        fname = filename[:-5].decode('utf-8')
        files[fname] = file_dict["roles"]
Daniel Stan's avatar
Daniel Stan committed
149
    return files
150 151

@server_command('getfile')
Daniel Stan's avatar
Daniel Stan committed
152
def getfile(filename):
153
    """Récupère le fichier ``filename``"""
Daniel Stan's avatar
Daniel Stan committed
154 155
    filepath = getpath(filename)
    try:
156 157
        obj = json.loads(open(filepath).read())
        if not validate(obj['roles']):
158
	        return [False, u"Vous n'avez pas les droits de lecture sur le fichier %s." % filename]
159
        obj["filename"] = filename
160
        return [True, obj]
Daniel Stan's avatar
Daniel Stan committed
161
    except IOError:
162
        return [False, u"Le fichier %s n'existe pas." % filename]
163

164 165
@server_command('getfiles', stdin_input=True)
def getfiles(filenames):
166 167 168
    """Récupère plusieurs fichiers, lit la liste des filenames demandés sur stdin"""
    return [getfile(f) for f in filenames]

169
# TODO ça n'a rien à faire là, à placer plus haut dans le code
170
def _putfile(filename, roles, contents):
171 172
    """Écrit ``contents`` avec les roles ``roles`` dans le fichier ``filename``
    """
173 174
    gotit, old = getfile(filename)
    if not gotit:
Vincent Le gallic's avatar
Vincent Le gallic committed
175
        old = u"[Création du fichier]"
Daniel Stan's avatar
Daniel Stan committed
176 177
        pass
    else:
178 179 180
        oldroles = old['roles']
        if not validate(oldroles, 'w'):
            return [False, u"Vous n'avez pas le droit d'écriture sur %s." % filename]
181

182 183
    corps = u"Le fichier %s a été modifié par %s." % (filename, MYUID)
    backup(corps, filename, old)
184
    notification(u"Modification", filename, MYUID)
185

186
    filepath = getpath(filename)
187
    if type(contents) != unicode:
188 189 190 191
        return [False, u"Erreur: merci de patcher votre cpasswords !"
             + "(contents should be encrypted str)"]
        # Or fuck yourself

Daniel Stan's avatar
Daniel Stan committed
192
    writefile(filepath, json.dumps({'roles': roles, 'contents': contents}))
193
    return [True, u"Modification effectuée."]
Daniel Stan's avatar
Daniel Stan committed
194

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

205 206
@server_command('putfiles', stdin_input=True, write=True)
def putfiles(parsed_stdin):
207 208
    """Écrit plusieurs fichiers. Lit les filenames sur l'entrée standard avec le
    reste."""
209 210 211 212 213 214 215 216 217 218 219 220
    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

221
@server_command('rmfile', write=True)
Daniel Stan's avatar
Daniel Stan committed
222 223
def rmfile(filename):
    """Supprime le fichier filename après avoir vérifié les droits sur le fichier"""
224 225 226 227 228 229 230
    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)
231
        notification(u"Suppression", fname, MYUID)
232
        os.remove(getpath(filename))
Daniel Stan's avatar
Daniel Stan committed
233
    else:
234 235
        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
236

237

238
# TODO monter plus haut
239
def backup(corps, fname, old):
240
    """Backupe l'ancienne version du fichier"""
Daniel Stan's avatar
Daniel Stan committed
241
    os.umask(0077)
242
    back = open(getpath(fname, backup=True), 'a')
Daniel Stan's avatar
Daniel Stan committed
243 244
    back.write(json.dumps(old))
    back.write('\n')
245
    back.write((u'* %s: %s\n' % (str(datetime.datetime.now()), corps)).encode("utf-8"))
Daniel Stan's avatar
Daniel Stan committed
246
    back.close()
247

248 249 250 251 252 253
_notif_todo = []
def notification(action, fname, actor):
    """Enregistre une notification"""
    _notif_todo.append((action, fname, actor))

def notification_mail():
254
    """Envoie par mail une notification de changement de fichier"""
255 256 257
    if not _notif_todo:
        return

258 259
    frommail = serverconfig.CRANSP_MAIL
    tomail = serverconfig.DEST_MAIL
260

261 262
    actions = set( task[1] for task in _notif_todo )

263
    msg = MIMEMultipart(_charset="utf-8")
264
    msg['Subject'] = u"Modification de la base (%s)" % (', '.join(actions))
265
    msg['X-Mailer'] = serverconfig.cmd_name.decode()
266 267
    msg['From'] = frommail
    msg['To'] = tomail
268
    msg.preamble = u"%s report" % (serverconfig.cmd_name.decode(),)
269

270 271 272 273 274
    liste = (u" * %s de %s par %s" % task for task in _notif_todo)

    info = MIMEText(u"Des modifications ont été faites:\n" +
	u"\n".join(liste) +
        u"\n\nDes sauvegardes ont été réalisées." +
275
        u"\n\nModification effectuée sur %s." % socket.gethostname() +
Vincent Le gallic's avatar
Vincent Le gallic committed
276
        u"\n\n-- \nCranspasswords.py", _charset="utf-8")
277
    msg.attach(info)
278 279
    mailProcess = subprocess.Popen([serverconfig.sendmail_cmd, "-t"], stdin=subprocess.PIPE)
    mailProcess.communicate(msg.as_string())
280

Daniel Stan's avatar
Daniel Stan committed
281
if __name__ == "__main__":
282 283 284 285 286
    argv = sys.argv[0:]
    command_name = argv[1]

    command = server_command.by_name[command_name]
    if serverconfig.READONLY and command.write:
287
        raise IOError("Ce serveur est read-only.")
288 289 290 291 292 293 294 295

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

    notification_mail()