server.py 11.5 KB
Newer Older
1
#!/usr/bin/env python2
Daniel Stan's avatar
Daniel Stan committed
2
# -*- 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

Daniel Stan's avatar
Daniel Stan committed
20 21 22
try:
    from cpasswords import clientlib
except ImportError:
23
    print("Couldn't import clientlib. Remote sync may not work", file=sys.stderr)
Daniel Stan's avatar
Daniel Stan committed
24

25 26
# Même problème que pour le client, il faut bootstraper le nom de la commande
# Pour accéder à la config
Daniel Stan's avatar
Daniel Stan committed
27 28 29 30 31 32
conf_path = os.getenv('CRANSPASSWORDS_SERVER_CONFIG_DIR', None)
if not conf_path:
    cmd_name = os.path.split(sys.argv[0])[1].replace("-server", "")
    conf_path = "/etc/%s/" % (cmd_name,)

sys.path.append(conf_path)
33
import serverconfig
Daniel Stan's avatar
Daniel Stan committed
34

Daniel Stan's avatar
Daniel Stan committed
35 36 37 38
MYUID = pwd.getpwuid(os.getuid())[0]
if MYUID == 'root':
    MYUID = os.environ['SUDO_USER']

39 40
## Fonctions internes au serveur

41 42
def validate(roles, mode='r'):
    """Vérifie que l'appelant appartient bien aux roles précisés
43 44
    Si mode mode='w', recherche un rôle en écriture
    """
Daniel Stan's avatar
Daniel Stan committed
45
    for role in roles:
46 47
        if mode == 'w':
            role += '-w'
48
        if serverconfig.ROLES.has_key(role) and MYUID in serverconfig.ROLES[role]:
Daniel Stan's avatar
Daniel Stan committed
49 50 51
            return True
    return False

52 53
def getpath(filename, backup=False):
    """Récupère le chemin du fichier ``filename``"""
54 55
    assert(isinstance(filename, unicode))
    filename = filename.encode('utf-8')
56
    return os.path.join(serverconfig.STORE, '%s.%s' % (filename, 'bak' if backup else 'json'))
Daniel Stan's avatar
Daniel Stan committed
57 58

def writefile(filename, contents):
59
    """Écrit le fichier avec les bons droits UNIX"""
Daniel Stan's avatar
Daniel Stan committed
60 61
    os.umask(0077)
    f = open(filename, 'w')
Vincent Le gallic's avatar
Vincent Le gallic committed
62
    f.write(contents.encode("utf-8"))
Daniel Stan's avatar
Daniel Stan committed
63 64
    f.close()

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

142
@server_command('listkeys')
Daniel Stan's avatar
Daniel Stan committed
143
def listkeys():
144
    """Liste les usernames et les (mail, fingerprint) correspondants"""
145
    return serverconfig.KEYS
Daniel Stan's avatar
Daniel Stan committed
146

147
@server_command('listfiles')
Daniel Stan's avatar
Daniel Stan committed
148 149
def listfiles():
    """Liste les fichiers dans l'espace de stockage, et les roles qui peuvent y accéder"""
150
    os.chdir(serverconfig.STORE)
151

Daniel Stan's avatar
Daniel Stan committed
152 153 154 155
    filenames = glob.glob('*.json')
    files = {}
    for filename in filenames:
        file_dict = json.loads(open(filename).read())
156 157
        fname = filename[:-5].decode('utf-8')
        files[fname] = file_dict["roles"]
Daniel Stan's avatar
Daniel Stan committed
158
    return files
159

160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190
@server_command('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].decode('utf-8')
            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:
                        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


191
@server_command('getfile')
Daniel Stan's avatar
Daniel Stan committed
192
def getfile(filename):
193
    """Récupère le fichier ``filename``"""
Daniel Stan's avatar
Daniel Stan committed
194 195
    filepath = getpath(filename)
    try:
196 197
        obj = json.loads(open(filepath).read())
        if not validate(obj['roles']):
198
	        return [False, u"Vous n'avez pas les droits de lecture sur le fichier %s." % filename]
199
        obj["filename"] = filename
200
        return [True, obj]
Daniel Stan's avatar
Daniel Stan committed
201
    except IOError:
202
        return [False, u"Le fichier %s n'existe pas." % filename]
203

204 205
@server_command('getfiles', stdin_input=True)
def getfiles(filenames):
206 207 208
    """Récupère plusieurs fichiers, lit la liste des filenames demandés sur stdin"""
    return [getfile(f) for f in filenames]

209
# TODO ça n'a rien à faire là, à placer plus haut dans le code
210
def _putfile(filename, roles, contents):
211 212
    """Écrit ``contents`` avec les roles ``roles`` dans le fichier ``filename``
    """
213 214
    gotit, old = getfile(filename)
    if not gotit:
Vincent Le gallic's avatar
Vincent Le gallic committed
215
        old = u"[Création du fichier]"
Daniel Stan's avatar
Daniel Stan committed
216 217
        pass
    else:
218 219 220
        oldroles = old['roles']
        if not validate(oldroles, 'w'):
            return [False, u"Vous n'avez pas le droit d'écriture sur %s." % filename]
221

222 223
    corps = u"Le fichier %s a été modifié par %s." % (filename, MYUID)
    backup(corps, filename, old)
224
    notification(u"Modification", filename, MYUID)
225

226
    filepath = getpath(filename)
227
    if type(contents) != unicode:
228 229 230 231
        return [False, u"Erreur: merci de patcher votre cpasswords !"
             + "(contents should be encrypted str)"]
        # Or fuck yourself

Daniel Stan's avatar
Daniel Stan committed
232
    writefile(filepath, json.dumps({'roles': roles, 'contents': contents}))
Daniel Stan's avatar
Daniel Stan committed
233 234 235 236 237

    data = {'filename': filename, 'roles': roles, 'contents': contents}
    for client in _list_to_replicate(data):
        client.put_file(data)

238
    return [True, u"Modification effectuée."]
Daniel Stan's avatar
Daniel Stan committed
239

240 241
@server_command('putfile', stdin_input=True, write=True)
def putfile(filename, parsed_stdin):
242 243 244 245 246 247 248 249
    """É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)

250 251
@server_command('putfiles', stdin_input=True, write=True)
def putfiles(parsed_stdin):
252 253
    """Écrit plusieurs fichiers. Lit les filenames sur l'entrée standard avec le
    reste."""
254 255 256 257 258 259 260 261 262 263 264 265
    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

266
@server_command('rmfile', write=True)
Daniel Stan's avatar
Daniel Stan committed
267 268
def rmfile(filename):
    """Supprime le fichier filename après avoir vérifié les droits sur le fichier"""
269 270 271 272 273 274 275
    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)
276
        notification(u"Suppression", filename, MYUID)
277
        os.remove(getpath(filename))
Daniel Stan's avatar
Daniel Stan committed
278
    else:
279 280
        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
281

282

283
# TODO monter plus haut
284
def backup(corps, fname, old):
285
    """Backupe l'ancienne version du fichier"""
Daniel Stan's avatar
Daniel Stan committed
286
    os.umask(0077)
287
    back = open(getpath(fname, backup=True), 'a')
Daniel Stan's avatar
Daniel Stan committed
288 289
    back.write(json.dumps(old))
    back.write('\n')
290
    back.write((u'* %s: %s\n' % (str(datetime.datetime.now()), corps)).encode("utf-8"))
Daniel Stan's avatar
Daniel Stan committed
291
    back.close()
292

Daniel Stan's avatar
Daniel Stan committed
293 294 295 296 297 298 299 300 301 302
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 ]

303 304 305 306 307 308
_notif_todo = []
def notification(action, fname, actor):
    """Enregistre une notification"""
    _notif_todo.append((action, fname, actor))

def notification_mail():
309
    """Envoie par mail une notification de changement de fichier"""
310 311 312
    if not _notif_todo:
        return

313 314
    frommail = serverconfig.CRANSP_MAIL
    tomail = serverconfig.DEST_MAIL
315

316 317
    actions = set( task[1] for task in _notif_todo )

318
    msg = MIMEMultipart(_charset="utf-8")
319
    msg['Subject'] = u"Modification de la base (%s)" % (', '.join(actions))
320
    msg['X-Mailer'] = serverconfig.cmd_name.decode()
321 322
    msg['From'] = frommail
    msg['To'] = tomail
323
    msg.preamble = u"%s report" % (serverconfig.cmd_name.decode(),)
324

325 326 327 328 329
    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." +
330
        u"\n\nModification effectuée sur %s." % socket.gethostname() +
Vincent Le gallic's avatar
Vincent Le gallic committed
331
        u"\n\n-- \nCranspasswords.py", _charset="utf-8")
332
    msg.attach(info)
333 334
    mailProcess = subprocess.Popen([serverconfig.sendmail_cmd, "-t"], stdin=subprocess.PIPE)
    mailProcess.communicate(msg.as_string())
335

Daniel Stan's avatar
Daniel Stan committed
336
if __name__ == "__main__":
337 338 339 340 341
    argv = sys.argv[0:]
    command_name = argv[1]

    command = server_command.by_name[command_name]
    if serverconfig.READONLY and command.write:
342
        raise IOError("Ce serveur est read-only.")
343 344 345 346 347 348 349 350

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

    notification_mail()