From 3e2df420cf60ac1390afb85b05e43c252e914ede Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Tue, 18 Aug 2020 20:32:18 -0700 Subject: [PATCH] bepasty: Simplify configuration file handling - Stick to a subset of allowed configuration file syntax (full syntax). Only KEY = VALUE statements are allowed. Values can be full JSON (valid python). - Use augeas to read as key/value pairs and then parse the values in JSON. - Add convenience methods to read and write configuration files. - Read the entire configuration file in a single action. - Internationalize the permission strings displayed to the user. - Pass password during remove-password operation via stdin instead of command line. Signed-off-by: Sunil Mohan Adapa Reviewed-by: Joseph Nuthalapati --- actions/bepasty | 225 +++++++++++++---------------- plinth/modules/bepasty/__init__.py | 19 +-- plinth/modules/bepasty/views.py | 39 ++++- 3 files changed, 140 insertions(+), 143 deletions(-) diff --git a/actions/bepasty b/actions/bepasty index cc2ec80dd..f6bc5eb46 100755 --- a/actions/bepasty +++ b/actions/bepasty @@ -5,37 +5,29 @@ Configuration helper for bepasty. """ import argparse -import json +import collections import grp +import json import os +import pathlib import pwd -import re import secrets import shutil import string import subprocess +import sys + +import augeas from plinth import action_utils from plinth.modules import bepasty DATA_DIR = '/var/lib/bepasty' -CONF_FILE = '/etc/bepasty-freedombox.conf' - -CONF_CONTENTS = """ -SITENAME = '{}' -STORAGE_FILESYSTEM_DIRECTORY = '/var/lib/bepasty' -SECRET_KEY = '{}' -PERMISSIONS = {{ - '{}': 'admin,list,create,read,delete', # admin - '{}': 'list,create,read,delete', # editor - '{}': 'list,read', # viewer -}} -DEFAULT_PERMISSIONS = '' -""" - PASSWORD_LENGTH = 20 +CONF_FILE = pathlib.Path('/etc/bepasty-freedombox.conf') + def parse_arguments(): """Return parsed command line arguments as dictionary.""" @@ -47,9 +39,7 @@ def parse_arguments(): setup.add_argument('--domain-name', required=True, help='The domain name that will be used by bepasty') - subparsers.add_parser( - 'list-passwords', - help='Get a list of passwords, their permissions and comments') + subparsers.add_parser('get-configuration', help='Get all configuration') add_password = subparsers.add_parser( 'add-password', help='Generate a password with given permissions') @@ -61,12 +51,8 @@ def parse_arguments(): '--comment', required=False, help='A comment for the password and its permissions') - remove_password = subparsers.add_parser( - 'remove-password', help='Remove a password and its permissions') - remove_password.add_argument('--password', required=True, - help='The password to be removed') - - subparsers.add_parser('get-default', help='Get default permissions') + subparsers.add_parser('remove-password', + help='Remove a password and its permissions') set_default = subparsers.add_parser('set-default', help='Set default permissions') @@ -79,6 +65,45 @@ def parse_arguments(): return parser.parse_args() +def _augeas_load(): + """Initialize Augeas.""" + aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD + + augeas.Augeas.NO_MODL_AUTOLOAD) + aug.set('/augeas/load/Simplevars/lens', 'Simplevars.lns') + aug.set('/augeas/load/Simplevars/incl[last() + 1]', str(CONF_FILE)) + aug.load() + return aug + + +def _key_path(key): + """Return the augeas path for the key.""" + return '/files' + str(CONF_FILE) + '/' + key + + +def conf_file_read(): + """Read and return the configuration.""" + aug = _augeas_load() + conf = collections.OrderedDict() + for path in aug.match(_key_path('*')): + key = path.rsplit('/', 1)[-1] + if key[0] != '#': + conf[key] = json.loads(aug.get(path)) + + return conf + + +def conf_file_write(conf): + """Write configuration to the file.""" + aug = _augeas_load() + for key, value in conf.items(): + if not key.startswith('#'): + value = json.dumps(value) + + aug.set(_key_path(key), value) + + aug.save() + + def subcommand_setup(arguments): """Post installation actions for bepasty.""" # Create bepasty group if needed. @@ -102,126 +127,70 @@ def subcommand_setup(arguments): shutil.chown(DATA_DIR, user='bepasty', group='bepasty') # Create configuration file if needed. - if not os.path.isfile(CONF_FILE): - # Generate secrets - secret_key = secrets.token_hex(64) - passwords = [] - for i in range(3): - passwords.append(_generate_password()) - - with open(CONF_FILE, 'w') as conf_file: - conf_file.write( - CONF_CONTENTS.format(arguments.domain_name, secret_key, - *passwords)) - - os.chmod(CONF_FILE, 0o640) + if not CONF_FILE.is_file(): + passwords = [_generate_password() for _ in range(3)] + conf = { + '#comment': + 'This file is managed by FreedomBox. Only a small subset of ' + 'the original configuration format is supported. Each line ' + 'should be in KEY = VALUE format. VALUE must be a JSON ' + 'encoded string.', + 'SITENAME': arguments.domain_name, + 'STORAGE_FILESYSTEM_DIRECTORY': '/var/lib/bepasty', + 'SECRET_KEY': secrets.token_hex(64), + 'PERMISSIONS': { + passwords[0]: 'admin,list,create,read,delete', + passwords[1]: 'list,create,read,delete', + passwords[2]: 'list,read', + }, + 'PERMISSION_COMMENTS': { + passwords[0]: 'admin', + passwords[1]: 'editor', + passwords[2]: 'viewer', + }, + 'DEFAULT_PERMISSIONS': '', + } + conf_file_write(conf) + CONF_FILE.chmod(0o640) shutil.chown(CONF_FILE, user='bepasty', group='bepasty') -def subcommand_list_passwords(_): - """Get a list of passwords, their permissions and comments""" - with open(CONF_FILE, 'r') as conf_file: - lines = conf_file.readlines() - - passwords = [] - in_permissions = False - for line in lines: - if line.startswith('PERMISSIONS'): - in_permissions = True - elif in_permissions: - if line.startswith('}'): - in_permissions = False - else: - match = re.match(r"\s*'(.*)': '(.*)',\s*(#\s*.*)?", line) - if match: - password = match.group(1) - permissions = match.group(2).split(',') - comment = match.group(3) or '' - comment = comment.lstrip('#').strip() - - passwords.append({ - 'password': password, - 'permissions': ', '.join(permissions), - 'comment': comment - }) - - print(json.dumps(passwords)) +def subcommand_get_configuration(_): + """Get default permissions, passwords, permissions and comments.""" + conf = conf_file_read() + print(json.dumps(conf)) def subcommand_add_password(arguments): - """Generate a password with given permissions""" + """Generate a password with given permissions.""" + conf = conf_file_read() permissions = _format_permissions(arguments.permissions) password = _generate_password() - with open(CONF_FILE, 'r') as conf_file: - lines = conf_file.readlines() - - with open(CONF_FILE, 'w') as conf_file: - in_permissions = False - for line in lines: - if line.startswith('PERMISSIONS'): - in_permissions = True - elif in_permissions: - if line.startswith('}'): - in_permissions = False - conf_file.write(" '{}': '{}',".format( - password, permissions)) - if arguments.comment: - conf_file.write(' # {}'.format(arguments.comment)) - - conf_file.write('\n') - - conf_file.write(line) + conf['PERMISSIONS'][password] = permissions + if arguments.comment: + conf['PERMISSION_COMMENTS'][password] = arguments.comment + conf_file_write(conf) action_utils.service_try_restart('uwsgi') -def subcommand_remove_password(arguments): - """Remove a password and its permissions""" - with open(CONF_FILE, 'r') as conf_file: - lines = conf_file.readlines() - - with open(CONF_FILE, 'w') as conf_file: - in_permissions = False - for line in lines: - if line.startswith('PERMISSIONS'): - in_permissions = True - elif in_permissions: - if line.startswith('}'): - in_permissions = False - elif arguments.password in line: - continue - - conf_file.write(line) +def subcommand_remove_password(_arguments): + """Remove a password and its permissions.""" + conf = conf_file_read() + password = ''.join(sys.stdin) + if password in conf['PERMISSIONS']: + del conf['PERMISSIONS'][password] + if password in conf['PERMISSION_COMMENTS']: + del conf['PERMISSION_COMMENTS'][password] + conf_file_write(conf) action_utils.service_try_restart('uwsgi') -def subcommand_get_default(_): - """Get default permissions""" - with open(CONF_FILE, 'r') as conf_file: - lines = conf_file.readlines() - - for line in lines: - match = re.match(r"DEFAULT_PERMISSIONS = '(.*)'", line) - if match: - print(match.group(1).replace(',', ' ')) - return - - def subcommand_set_default(arguments): - """Set default permissions""" - permissions = _format_permissions(arguments.permissions) - with open(CONF_FILE, 'r') as conf_file: - lines = conf_file.readlines() - - with open(CONF_FILE, 'w') as conf_file: - for line in lines: - if line.startswith('DEFAULT_PERMISSIONS'): - conf_file.write( - "DEFAULT_PERMISSIONS = '{}'\n".format(permissions)) - else: - conf_file.write(line) - + """Set default permissions.""" + conf = {'DEFAULT_PERMISSIONS': _format_permissions(arguments.permissions)} + conf_file_write(conf) action_utils.service_try_restart('uwsgi') diff --git a/plinth/modules/bepasty/__init__.py b/plinth/modules/bepasty/__init__.py index ba08b13af..597d87513 100644 --- a/plinth/modules/bepasty/__init__.py +++ b/plinth/modules/bepasty/__init__.py @@ -100,9 +100,9 @@ def setup(helper, old_version=None): helper.call('post', app.enable) -def list_passwords(): - """Get a list of passwords, their permissions and comments""" - output = actions.superuser_run('bepasty', ['list-passwords']) +def get_configuration(): + """Get a full configuration including passwords and defaults.""" + output = actions.superuser_run('bepasty', ['get-configuration']) return json.loads(output) @@ -116,16 +116,9 @@ def add_password(permissions, comment=None): def remove_password(password): - """Remove a password and its permissions""" - actions.superuser_run('bepasty', - ['remove-password', '--password', password]) - - -def get_default_permissions(): - """Get default permissions""" - output = actions.superuser_run('bepasty', ['get-default']).strip() - output = 'read list' if output == 'list read' else output - return output.strip() + """Remove a password and its permissions.""" + actions.superuser_run('bepasty', ['remove-password'], + input=password.encode()) def set_default_permissions(permissions): diff --git a/plinth/modules/bepasty/views.py b/plinth/modules/bepasty/views.py index be5e4580f..e0bf68192 100644 --- a/plinth/modules/bepasty/views.py +++ b/plinth/modules/bepasty/views.py @@ -24,16 +24,51 @@ class BepastyView(AppView): form_class = SetDefaultPermissionsForm template_name = 'bepasty.html' + def __init__(self, *args, **kwargs): + """Initialize the view.""" + super().__init__(*args, **kwargs) + self.conf = None + + def _get_configuration(self): + """Return the current configuration.""" + if not self.conf: + self.conf = bepasty.get_configuration() + + return self.conf + def get_context_data(self, **kwargs): """Return additional context for rendering the template.""" + permissions_short_text = { + 'read': _('Read'), + 'create': _('Create'), + 'list': _('List'), + 'delete': _('Delete'), + 'admin': _('Admin') + } context = super().get_context_data(**kwargs) - context['passwords'] = bepasty.list_passwords() + conf = self._get_configuration() + passwords = [] + for password, permissions in conf['PERMISSIONS'].items(): + permissions = permissions.split(',') + permissions = [ + str(permissions_short_text[permission]) + for permission in permissions if permission + ] + passwords.append({ + 'password': password, + 'permissions': ', '.join(permissions), + 'comment': conf['PERMISSION_COMMENTS'].get(password) or '' + }) + context['passwords'] = passwords return context def get_initial(self): """Return the status of the service to fill in the form.""" initial = super().get_initial() - initial['default_permissions'] = bepasty.get_default_permissions() + default = self._get_configuration().get('DEFAULT_PERMISSIONS', '') + default = ' '.join(default.split(',')) + default = 'read list' if default == 'list read' else default + initial['default_permissions'] = default return initial def form_valid(self, form):