Newer
Older
"""
Group password manager server
Copyright (C) 2010-2020 Cr@ns <roots@crans.org>
Authors : Daniel Stan <daniel.stan@crans.org>
Vincent Le Gallic <legallic@crans.org>
Alexandre Iooss <erdnaxe@crans.org>
SPDX-License-Identifier: GPL-3.0-or-later
"""
import glob
import os
import pwd
import sys
import json
import datetime
import socket
# Configuration loading
# Guess name as we do not have config
bootstrap_cmd_name = os.path.split(sys.argv[0])[1].replace("-server", "")
default_config_path = "/etc/" + bootstrap_cmd_name
config_path = os.getenv(
"CRANSPASSWORDS_SERVER_CONFIG_DIR", default_config_path)
sys.path.append(config_path)
import serverconfig
except ModuleNotFoundError:
# If config could not be imported, display an error if required
# Do not use logger as it has not been initialized yet
print("%s/serverconfig.py could not be found or read.\n"
"Please copy `docs/serverconfig.example.py` from the source "
"repository and customize." % config_path)
exit(1)
# Local logger
log = logging.getLogger(__name__)
# Get user name that launch the server
MYUID = pwd.getpwuid(os.getuid())[0]
if MYUID == 'root':
MYUID = os.environ['SUDO_USER']
def validate(roles, mode='r'):
"""Vérifie que l'appelant appartient bien aux roles précisés
Si mode mode='w', recherche un rôle en écriture
"""
for role in roles:
if mode == 'w':
role += '-w'
if role in serverconfig.ROLES.keys() and MYUID in serverconfig.ROLES[role]:
def getpath(filename, backup=False):
"""Récupère le chemin du fichier ``filename``"""
return os.path.join(serverconfig.STORE, '%s.%s' % (filename, 'bak' if backup else 'json'))
def writefile(filename, contents):
"""Écrit le fichier avec les bons droits UNIX"""
"""
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
def __call__(self, fun):
self.decorated = fun
return fun
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 = ServerCommand.by_name[action].decorated(*data['args'])
status = u'ok'
except Exception as e:
status = u'error'
content = repr(e)
out = {
'status': status,
'content': content,
}
def listroles():
"""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
raise ValueError('La rôle "whoami" ne devrait pas exister')
d["whoami"] = MYUID
return d
def listkeys():
"""Liste les usernames et les (mail, fingerprint) correspondants"""
return serverconfig.KEYS
def listfiles():
"""Liste les fichiers dans l'espace de stockage, et les roles qui peuvent y accéder"""
os.chdir(serverconfig.STORE)
filenames = glob.glob('*.json')
files = {}
for filename in filenames:
file_dict = json.loads(open(filename).read())
files[fname] = file_dict["roles"]
return files
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"]):
with open(fname+'.bak') as f:
line = f.readline()
backup = ''
if ('-----BEGIN PGP MESSAGE-----' in line_dict["contents"]):
backup = line
except:
pass
line = f.readline()
if not (backup == ''):
files[fname] = 'restored'
f2.write(backup)
else:
files[fname] = 'not restored'
return files
def getfile(filename):
"""Récupère le fichier ``filename``"""
filepath = getpath(filename)
try:
obj = json.loads(open(filepath).read())
if not validate(obj['roles']):
return [False, u"Vous n'avez pas les droits de lecture sur le fichier %s." % filename]
obj["filename"] = filename
return [True, obj]
except IOError:
return [False, u"Le fichier %s n'existe pas." % filename]
def getfiles(filenames):
"""Récupère plusieurs fichiers, lit la liste des filenames demandés sur stdin"""
return [getfile(f) for f in filenames]
# TODO ça n'a rien à faire là, à placer plus haut dans le code
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
def _putfile(filename, roles, contents):
"""Écrit ``contents`` avec les roles ``roles`` dans le fichier ``filename``
"""
gotit, old = getfile(filename)
if not gotit:
old = u"[Création du fichier]"
pass
else:
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", filename, MYUID)
filepath = getpath(filename)
writefile(filepath, json.dumps({'roles': roles, 'contents': contents}))
data = {'filename': filename, 'roles': roles, 'contents': contents}
for client in _list_to_replicate(data):
client.put_file(data)
return [True, u"Modification effectuée."]
@ServerCommand('putfile', stdin_input=True, write=True)
def putfile(filename, parsed_stdin):
"""É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)
@ServerCommand('putfiles', stdin_input=True, write=True)
def putfiles(parsed_stdin):
"""É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
def rmfile(filename):
"""Supprime le fichier filename après avoir vérifié les droits sur le fichier"""
gotit, old = getfile(filename)
if not gotit:
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", filename, MYUID)
os.remove(getpath(filename))
else:
return u"Vous n'avez pas les droits d'écriture sur le fichier %s." % filename
return u"Suppression effectuée"
# TODO monter plus haut
def backup(corps, fname, old):
"""Backupe l'ancienne version du fichier"""
back = open(getpath(fname, backup=True), 'a')
back.write(json.dumps(old))
back.write('\n')
back.write(f"* {datetime.datetime.now()}: {corps}\n")
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]
def notification(action, fname, actor):
"""Enregistre une notification"""
_notif_todo.append((action, fname, actor))
def notification_mail(notifications):
"""
Send notifications by mail
"""
# Build message
actions = set(task[1] for task in notifications)
subject = "Modification de la base (%s)" % ', '.join(actions)
liste = "\r\n".join(" * %s de %s par %s" % task for task in notifications)
hostname = socket.gethostname()
msg = (f"From: { serverconfig.FROM_MAIL }\r\n"
f"To: { serverconfig.TO_MAIL }\r\n"
f"Subject: { subject }\r\n"
f"X-Mailer: { serverconfig.cmd_name }\r\n"
f"Des modifications ont été faites:\r\n"
f"{ liste }\r\n"
f"Des sauvegardes ont été réalisées.\r\n"
f"-- \r\n{ serverconfig.cmd_name } sur { hostname }")
# Send
with SMTP(serverconfig.SMTP_HOST) as s:
s.sendmail(msg)
argv = sys.argv[0:]
command_name = argv[1]
if serverconfig.READONLY and command.write:
raise IOError("Ce serveur est read-only.")
args = argv[2:]
if command.stdin_input:
args.append(json.loads(sys.stdin.read()))
answer = command.decorated(*args)
if answer is not None:
print(json.dumps(answer))
if _notif_todo:
# if notifications, then send email
notification_mail(_notif_todo)
if __name__ == "__main__":
main()