server.py 10.4 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

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

@server_command('getfile')
Daniel Stan's avatar
Daniel Stan committed
161
def getfile(filename):
162
    """Récupère le fichier ``filename``"""
Daniel Stan's avatar
Daniel Stan committed
163 164
    filepath = getpath(filename)
    try:
165 166
        obj = json.loads(open(filepath).read())
        if not validate(obj['roles']):
167
	        return [False, u"Vous n'avez pas les droits de lecture sur le fichier %s." % filename]
168
        obj["filename"] = filename
169
        return [True, obj]
Daniel Stan's avatar
Daniel Stan committed
170
    except IOError:
171
        return [False, u"Le fichier %s n'existe pas." % filename]
172

173 174
@server_command('getfiles', stdin_input=True)
def getfiles(filenames):
175 176 177
    """Récupère plusieurs fichiers, lit la liste des filenames demandés sur stdin"""
    return [getfile(f) for f in filenames]

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

191 192
    corps = u"Le fichier %s a été modifié par %s." % (filename, MYUID)
    backup(corps, filename, old)
193
    notification(u"Modification", filename, MYUID)
194

195
    filepath = getpath(filename)
196
    if type(contents) != unicode:
197 198 199 200
        return [False, u"Erreur: merci de patcher votre cpasswords !"
             + "(contents should be encrypted str)"]
        # Or fuck yourself

Daniel Stan's avatar
Daniel Stan committed
201
    writefile(filepath, json.dumps({'roles': roles, 'contents': contents}))
Daniel Stan's avatar
Daniel Stan committed
202 203 204 205 206

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

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

209 210
@server_command('putfile', stdin_input=True, write=True)
def putfile(filename, parsed_stdin):
211 212 213 214 215 216 217 218
    """É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)

219 220
@server_command('putfiles', stdin_input=True, write=True)
def putfiles(parsed_stdin):
221 222
    """Écrit plusieurs fichiers. Lit les filenames sur l'entrée standard avec le
    reste."""
223 224 225 226 227 228 229 230 231 232 233 234
    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

235
@server_command('rmfile', write=True)
Daniel Stan's avatar
Daniel Stan committed
236 237
def rmfile(filename):
    """Supprime le fichier filename après avoir vérifié les droits sur le fichier"""
238 239 240 241 242 243 244
    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)
245
        notification(u"Suppression", filename, MYUID)
246
        os.remove(getpath(filename))
Daniel Stan's avatar
Daniel Stan committed
247
    else:
248 249
        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
250

251

252
# TODO monter plus haut
253
def backup(corps, fname, old):
254
    """Backupe l'ancienne version du fichier"""
Daniel Stan's avatar
Daniel Stan committed
255
    os.umask(0077)
256
    back = open(getpath(fname, backup=True), 'a')
Daniel Stan's avatar
Daniel Stan committed
257 258
    back.write(json.dumps(old))
    back.write('\n')
259
    back.write((u'* %s: %s\n' % (str(datetime.datetime.now()), corps)).encode("utf-8"))
Daniel Stan's avatar
Daniel Stan committed
260
    back.close()
261

Daniel Stan's avatar
Daniel Stan committed
262 263 264 265 266 267 268 269 270 271
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 ]

272 273 274 275 276 277
_notif_todo = []
def notification(action, fname, actor):
    """Enregistre une notification"""
    _notif_todo.append((action, fname, actor))

def notification_mail():
278
    """Envoie par mail une notification de changement de fichier"""
279 280 281
    if not _notif_todo:
        return

282 283
    frommail = serverconfig.CRANSP_MAIL
    tomail = serverconfig.DEST_MAIL
284

285 286
    actions = set( task[1] for task in _notif_todo )

287
    msg = MIMEMultipart(_charset="utf-8")
288
    msg['Subject'] = u"Modification de la base (%s)" % (', '.join(actions))
289
    msg['X-Mailer'] = serverconfig.cmd_name.decode()
290 291
    msg['From'] = frommail
    msg['To'] = tomail
292
    msg.preamble = u"%s report" % (serverconfig.cmd_name.decode(),)
293

294 295 296 297 298
    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." +
299
        u"\n\nModification effectuée sur %s." % socket.gethostname() +
Vincent Le gallic's avatar
Vincent Le gallic committed
300
        u"\n\n-- \nCranspasswords.py", _charset="utf-8")
301
    msg.attach(info)
302 303
    mailProcess = subprocess.Popen([serverconfig.sendmail_cmd, "-t"], stdin=subprocess.PIPE)
    mailProcess.communicate(msg.as_string())
304

Daniel Stan's avatar
Daniel Stan committed
305
if __name__ == "__main__":
306 307 308 309 310
    argv = sys.argv[0:]
    command_name = argv[1]

    command = server_command.by_name[command_name]
    if serverconfig.READONLY and command.write:
311
        raise IOError("Ce serveur est read-only.")
312 313 314 315 316 317 318 319

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

    notification_mail()