server.py 9.19 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 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 45
    assert(isinstance(filename, unicode))
    filename = filename.encode('utf-8')
46
    return os.path.join(serverconfig.STORE, '%s.%s' % (filename, 'bak' if backup else 'json'))
Daniel Stan's avatar
Daniel Stan committed
47 48

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

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 120 121
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
122
def listroles():
123 124 125 126 127 128 129
    """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
130

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

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

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

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

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

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

179 180 181
    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)
182

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

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

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

202 203
@server_command('putfiles', stdin_input=True, write=True)
def putfiles(parsed_stdin):
204 205 206 207 208 209 210 211 212 213 214 215 216
    """É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

217
@server_command('rmfile', write=True)
Daniel Stan's avatar
Daniel Stan committed
218 219
def rmfile(filename):
    """Supprime le fichier filename après avoir vérifié les droits sur le fichier"""
220 221 222 223 224 225 226 227 228
    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
229
    else:
230 231
        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
232

233

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

244 245
def notification(subject, corps, fname, old):
    """Envoie par mail une notification de changement de fichier"""
246 247
    frommail = serverconfig.CRANSP_MAIL
    tomail = serverconfig.DEST_MAIL
248

249 250
    msg = MIMEMultipart(_charset="utf-8")
    msg['Subject'] = subject
251
    msg['X-Mailer'] = serverconfig.cmd_name.decode()
252 253
    msg['From'] = frommail
    msg['To'] = tomail
254
    msg.preamble = u"%s report" % (serverconfig.cmd_name.decode(),)
255

256
    info = MIMEText(corps +
Vincent Le gallic's avatar
Vincent Le gallic committed
257
        u"\nLa version précédente a été sauvegardée." +
258
        u"\n\nModification effectuée sur %s." % socket.gethostname() +
Vincent Le gallic's avatar
Vincent Le gallic committed
259
        u"\n\n-- \nCranspasswords.py", _charset="utf-8")
260
    msg.attach(info)
261 262
    mailProcess = subprocess.Popen([serverconfig.sendmail_cmd, "-t"], stdin=subprocess.PIPE)
    mailProcess.communicate(msg.as_string())
263

Daniel Stan's avatar
Daniel Stan committed
264
if __name__ == "__main__":
265 266 267 268 269
    argv = sys.argv[0:]
    command_name = argv[1]

    command = server_command.by_name[command_name]
    if serverconfig.READONLY and command.write:
270
        raise IOError("Ce serveur est read-only.")
271 272 273 274 275 276 277 278

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