Repository: freedomofpress/securedrop

Issue:
refactor securedrop-admin sdconfig in python
# Feature request

## Description

securedrop-admin sdconfig is implemented in python via install_files/ansible-base/securedrop-configure.yml

ansible is contorted and not user friendly in this context. What we want is to display a prompt to the user with a default or the previous value read from site_specific. And have immediate validation instead of post-submission validation.

The [click](http://click.pocoo.org/5/) library is already a SecureDrop dependency and could be used for that purpose.

See also https://github.com/freedomofpress/securedrop/issues/2266 and https://github.com/freedomofpress/securedrop/issues/2095

## User Stories

As an admin I want to get immediate validation when `securedrop-admin sdconfig` prompts me for a valid when configuring securedrop for the first time.

As an admin I want `securedrop-admin sdconfig` to display the values from `install_files/ansible-base/group_vars/all/site-specific` when running it the second time so that I can modify them instead of editing the file manually.




Discussion:
Quoting @redshiftzero 
> i think replacing everything before "Validate site-specific information." in python makes sense and would lead to a much nicer user experience (one could still do some additional validation in python for some immediate user feedback but replacing the entire validate role seems like a big task)
Quoting @msheiny 
> i'm totally on-board with you swapping that out -- i also had reservations about doing that code logic in ansible. ansible is a terrible replacement for doing that in python with proper argument parsing validation

Resolving patch:
diff --git a/admin/bootstrap.py b/admin/bootstrap.py
new file mode 100755
--- /dev/null
+++ b/admin/bootstrap.py
@@ -0,0 +1,220 @@
+# -*- mode: python; coding: utf-8 -*-
+#
+# Copyright (C) 2013-2018 Freedom of the Press Foundation & al
+# Copyright (C) 2018 Loic Dachary <loic@dachary.org>
+#
+#    This program is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License as published by
+#    the Free Software Foundation, either version 3 of the License, or
+#    (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+import argparse
+import logging
+import os
+import subprocess
+import sys
+
+sdlog = logging.getLogger(__name__)
+
+DIR = os.path.dirname(os.path.realpath(__file__))
+VENV_DIR = os.path.join(DIR, ".venv")
+
+
+def setup_logger(verbose=False):
+    """ Configure logging handler """
+    # Set default level on parent
+    sdlog.setLevel(logging.DEBUG)
+    level = logging.DEBUG if verbose else logging.INFO
+
+    stdout = logging.StreamHandler(sys.stdout)
+    stdout.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
+    stdout.setLevel(level)
+    sdlog.addHandler(stdout)
+
+
+def run_command(command):
+    """
+    Wrapper function to display stdout for running command,
+    similar to how shelling out in a Bash script displays rolling output.
+
+    Yields a list of the stdout from the `command`, and raises a
+    CalledProcessError if `command` returns non-zero.
+    """
+    popen = subprocess.Popen(command,
+                             stdout=subprocess.PIPE,
+                             stderr=subprocess.STDOUT)
+    for stdout_line in iter(popen.stdout.readline, ""):
+        yield stdout_line
+    popen.stdout.close()
+    return_code = popen.wait()
+    if return_code:
+        raise subprocess.CalledProcessError(return_code, command)
+
+
+def is_tails():
+    try:
+        id = subprocess.check_output('lsb_release --id --short',
+                                     shell=True).strip()
+    except subprocess.CalledProcessError:
+        id = None
+    return id == 'Tails'
+
+
+def maybe_torify():
+    if is_tails():
+        return ['torify']
+    else:
+        return []
+
+
+def install_apt_dependencies(args):
+    """
+    Install apt dependencies in Tails. In order to install Ansible in
+    a virtualenv, first there are a number of Python prerequisites.
+    """
+    sdlog.info("Installing SecureDrop Admin dependencies")
+    sdlog.info(("You'll be prompted for the temporary Tails admin password,"
+                " which was set on Tails login screen"))
+
+    apt_command = ['sudo', 'su', '-c',
+                   "apt-get update && \
+                   apt-get -q -o=Dpkg::Use-Pty=0 install -y \
+                   python-virtualenv \
+                   python-yaml \
+                   python-pip \
+                   ccontrol \
+                   virtualenv \
+                   libffi-dev \
+                   libssl-dev \
+                   libpython2.7-dev",
+                   ]
+
+    try:
+        # Print command results in real-time, to keep Admin apprised
+        # of progress during long-running command.
+        for output_line in run_command(apt_command):
+            print(output_line.rstrip())
+    except subprocess.CalledProcessError:
+        # Tails supports apt persistence, which was used by SecureDrop
+        # under Tails 2.x. If updates are being applied, don't try to pile
+        # on with more apt requests.
+        sdlog.error(("Failed to install apt dependencies. Check network"
+                     " connection and try again."))
+        raise
+
+
+def envsetup(args):
+    """Installs Admin tooling required for managing SecureDrop. Specifically:
+
+        * updates apt-cache
+        * installs apt packages for Python virtualenv
+        * creates virtualenv
+        * installs pip packages inside virtualenv
+
+    The virtualenv is created within the Persistence volume in Tails, so that
+    Ansible is available to the Admin on subsequent boots without requiring
+    installation of packages again.
+    """
+    # virtualenv doesnt exist? Install dependencies and create
+    if not os.path.exists(VENV_DIR):
+
+        install_apt_dependencies(args)
+
+        # Technically you can create a virtualenv from within python
+        # but pip can only be run over tor on tails, and debugging that
+        # along with instaling a third-party dependency is not worth
+        # the effort here.
+        sdlog.info("Setting up virtualenv")
+        try:
+            sdlog.debug(subprocess.check_output(
+                maybe_torify() + ['virtualenv', VENV_DIR],
+                stderr=subprocess.STDOUT))
+        except subprocess.CalledProcessError as e:
+            sdlog.debug(e.output)
+            sdlog.error(("Unable to create virtualenv. Check network settings"
+                         " and try again."))
+            raise
+    else:
+        sdlog.info("Virtualenv already exists, not creating")
+
+    install_pip_dependencies(args)
+    if os.path.exists(os.path.join(DIR, 'setup.py')):
+        install_pip_self(args)
+
+    sdlog.info("Finished installing SecureDrop dependencies")
+
+
+def install_pip_self(args):
+    pip_install_cmd = [
+        os.path.join(VENV_DIR, 'bin', 'pip'),
+        'install', '-e', DIR
+    ]
+    try:
+        subprocess.check_output(maybe_torify() + pip_install_cmd,
+                                stderr=subprocess.STDOUT)
+    except subprocess.CalledProcessError as e:
+        sdlog.debug(e.output)
+        sdlog.error("Unable to install self, run with -v for more information")
+        raise
+
+
+def install_pip_dependencies(args, pip_install_cmd=[
+        os.path.join(VENV_DIR, 'bin', 'pip'),
+        'install',
+        # Specify requirements file.
+        '-r', os.path.join(DIR, 'requirements.txt'),
+        '--require-hashes',
+        # Make sure to upgrade packages only if necessary.
+        '-U', '--upgrade-strategy', 'only-if-needed',
+]):
+    """
+    Install Python dependencies via pip into virtualenv.
+    """
+
+    sdlog.info("Checking Python dependencies for securedrop-admin")
+    try:
+        pip_output = subprocess.check_output(maybe_torify() + pip_install_cmd,
+                                             stderr=subprocess.STDOUT)
+    except subprocess.CalledProcessError as e:
+        sdlog.debug(e.output)
+        sdlog.error(("Failed to install pip dependencies. Check network"
+                     " connection and try again."))
+        raise
+
+    sdlog.debug(pip_output)
+    if "Successfully installed" in pip_output:
+        sdlog.info("Python dependencies for securedrop-admin upgraded")
+    else:
+        sdlog.info("Python dependencies for securedrop-admin are up-to-date")
+
+
+def parse_argv(argv):
+    parser = argparse.ArgumentParser()
+    parser.add_argument('-v', action='store_true', default=False,
+                        help="Increase verbosity on output")
+    parser.set_defaults(func=envsetup)
+
+    return parser.parse_args(argv)
+
+
+if __name__ == "__main__":
+    args = parse_argv(sys.argv[1:])
+    setup_logger(args.v)
+    if args.v:
+        args.func(args)
+    else:
+        try:
+            args.func(args)
+        except Exception:
+            sys.exit(1)
+        else:
+            sys.exit(0)
diff --git a/admin/securedrop_admin/__init__.py b/admin/securedrop_admin/__init__.py
new file mode 100755
--- /dev/null
+++ b/admin/securedrop_admin/__init__.py
@@ -0,0 +1,556 @@
+# -*- mode: python; coding: utf-8 -*-
+#
+# Copyright (C) 2013-2018 Freedom of the Press Foundation & al
+# Copyright (C) 2018 Loic Dachary <loic@dachary.org>
+#
+#    This program is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License as published by
+#    the Free Software Foundation, either version 3 of the License, or
+#    (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+"""
+SecureDrop Admin Toolkit.
+
+For use by administrators to install, maintain, and manage their SD
+instances.
+"""
+
+import argparse
+import logging
+import os
+import re
+import string
+import subprocess
+import sys
+import types
+import prompt_toolkit
+from prompt_toolkit.validation import Validator, ValidationError
+import yaml
+
+sdlog = logging.getLogger(__name__)
+
+
+class FingerprintException(Exception):
+    pass
+
+
+class SiteConfig(object):
+
+    class ValidateNotEmpty(Validator):
+        def validate(self, document):
+            if document.text != '':
+                return True
+            raise ValidationError(
+                message="Must not be an empty string")
+
+    class ValidateUser(Validator):
+        def validate(self, document):
+            text = document.text
+            if text != '' and text != 'root' and text != 'amnesia':
+                return True
+            raise ValidationError(
+                message="Must not be root, amnesia or an empty string")
+
+    class ValidateIP(Validator):
+        def validate(self, document):
+            if re.match('((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.|$)){4}$',
+                        document.text):
+                return True
+            raise ValidationError(
+                message="An IP address must be something like 10.240.20.83")
+
+    class ValidateDNS(Validator):
+        def validate(self):
+            raise Exception()  # pragma: no cover
+
+        def is_tails(self):
+            try:
+                id = subprocess.check_output('lsb_release --id --short',
+                                             shell=True).strip()
+            except subprocess.CalledProcessError:
+                id = None
+            return id == 'Tails'
+
+        def lookup_fqdn(self, fqdn, dns=None):
+            cmd = 'host -W=10 -T -4 ' + fqdn
+            if self.is_tails():
+                cmd = 'torify ' + cmd
+            cmd += ' ' + (dns and dns or '8.8.8.8')
+            try:
+                result = subprocess.check_output(cmd, shell=True,
+                                                 stderr=subprocess.STDOUT)
+            except subprocess.CalledProcessError as e:
+                result = e.output
+            sdlog.debug(cmd + ' => ' + result)
+            return 'has address' in result
+
+    class ValidateDNSServer(ValidateDNS):
+        def validate(self, document):
+            if self.lookup_fqdn('gnu.org', document.text):
+                return True
+            raise ValidationError(
+                message='Unable to resolve gnu.org using this DNS')
+
+    class ValidateFQDN(ValidateDNS):
+        def validate(self, document):
+            if self.lookup_fqdn(document.text):
+                return True
+            raise ValidationError(
+                message='Unable to resolve ' + document.text)
+
+    class ValidatePath(Validator):
+        def __init__(self, basedir):
+            self.basedir = basedir
+            super(SiteConfig.ValidatePath, self).__init__()
+
+        def validate(self, document):
+            if document.text == '':
+                raise ValidationError(
+                    message='an existing file name is required')
+            path = os.path.join(self.basedir, document.text)
+            if os.path.exists(path):
+                return True
+            raise ValidationError(
+                message=path + ' file does not exist')
+
+    class ValidateYesNo(Validator):
+        def validate(self, document):
+            text = document.text.lower()
+            if text == 'yes' or text == 'no':
+                return True
+            raise ValidationError(message="Must be either yes or no")
+
+    class ValidateFingerprint(Validator):
+        def validate(self, document):
+            text = document.text.replace(' ', '')
+            if text == '65A1B5FF195B56353CC63DFFCC40EF1228271441':
+                raise ValidationError(
+                    message='This is the TEST journalist fingerprint')
+            if text == '600BC6D5142C68F35DDBCEA87B597104EDDDC102':
+                raise ValidationError(
+                    message='This is the TEST admin fingerprint')
+            if not re.match('[a-fA-F0-9]{40}$', text):
+                raise ValidationError(
+                    message='fingerprints must be 40 hexadecimal characters')
+            return True
+
+    class ValidateInt(Validator):
+        def validate(self, document):
+            if re.match('\d+$', document.text):
+                return True
+            raise ValidationError(message="Must be an integer")
+
+    class Locales(object):
+        def __init__(self, appdir):
+            self.translation_dir = os.path.realpath(
+                os.path.join(appdir, 'translations'))
+
+        def get_translations(self):
+            translations = set(['en', 'en_US'])
+            for dirname in os.listdir(self.translation_dir):
+                if dirname != 'messages.pot':
+                    translations.add(dirname)
+            return translations
+
+    class ValidateLocales(Validator):
+        def __init__(self, basedir):
+            self.basedir = basedir
+            super(SiteConfig.ValidateLocales, self).__init__()
+
+        def validate(self, document):
+            desired = document.text.split()
+            existing = SiteConfig.Locales(self.basedir).get_translations()
+            missing = set(desired) - set(existing)
+            if not missing:
+                return True
+            raise ValidationError(
+                message="The following locales do not exist " + " ".join(
+                    missing))
+
+    class ValidateOSSECUsername(Validator):
+        def validate(self, document):
+            text = document.text
+            if text and '@' not in text and 'test' != text:
+                return True
+            raise ValidationError(
+                message="The SASL username should not include the domain name")
+
+    class ValidateOSSECPassword(Validator):
+        def validate(self, document):
+            text = document.text
+            if len(text) >= 8 and 'password123' != text:
+                return True
+            raise ValidationError(
+                message="Password for OSSEC email account must be strong")
+
+    class ValidateOSSECEmail(Validator):
+        def validate(self, document):
+            text = document.text
+            if text and '@' in text and 'ossec@ossec.test' != text:
+                return True
+            raise ValidationError(
+                message=("Must contain a @ and be set to "
+                         "something other than ossec@ossec.test"))
+
+    def __init__(self, args):
+        self.args = args
+        translations = SiteConfig.Locales(
+            self.args.app_path).get_translations()
+        translations = " ".join(translations)
+        self.desc = [
+            ['ssh_users', 'sd', str,
+             u'Username for SSH access to the servers',
+             SiteConfig.ValidateUser(),
+             None],
+            ['app_ip', '10.20.2.2', str,
+             u'Local IPv4 address for the Application Server',
+             SiteConfig.ValidateIP(),
+             None],
+            ['monitor_ip', '10.20.3.2', str,
+             u'Local IPv4 address for the Monitor Server',
+             SiteConfig.ValidateIP(),
+             None],
+            ['app_hostname', 'app', str,
+             u'Hostname for Application Server',
+             SiteConfig.ValidateNotEmpty(),
+             None],
+            ['monitor_hostname', 'mon', str,
+             u'Hostname for Monitor Server',
+             SiteConfig.ValidateNotEmpty(),
+             None],
+            ['dns_server', '8.8.8.8', str,
+             u'DNS server specified during installation',
+             SiteConfig.ValidateNotEmpty(),
+             None],
+            ['securedrop_app_https_on_source_interface', False, bool,
+             u'Whether HTTPS should be enabled on '
+             'Source Interface (requires EV cert)',
+             SiteConfig.ValidateYesNo(),
+             lambda x: x.lower() == 'yes'],
+            ['securedrop_app_gpg_public_key', 'SecureDrop.asc', str,
+             u'Local filepath to public key for '
+             'SecureDrop Application GPG public key',
+             SiteConfig.ValidatePath(self.args.ansible_path),
+             None],
+            ['securedrop_app_gpg_fingerprint', '', str,
+             u'Full fingerprint for the SecureDrop Application GPG Key',
+             SiteConfig.ValidateFingerprint(),
+             self.sanitize_fingerprint],
+            ['ossec_alert_gpg_public_key', 'ossec.pub', str,
+             u'Local filepath to OSSEC alerts GPG public key',
+             SiteConfig.ValidatePath(self.args.ansible_path),
+             None],
+            ['ossec_gpg_fpr', '', str,
+             u'Full fingerprint for the OSSEC alerts GPG public key',
+             SiteConfig.ValidateFingerprint(),
+             self.sanitize_fingerprint],
+            ['ossec_alert_email', '', str,
+             u'Admin email address for receiving OSSEC alerts',
+             SiteConfig.ValidateOSSECEmail(),
+             None],
+            ['smtp_relay', "smtp.gmail.com", str,
+             u'SMTP relay for sending OSSEC alerts',
+             SiteConfig.ValidateNotEmpty(),
+             None],
+            ['smtp_relay_port', 587, int,
+             u'SMTP port for sending OSSEC alerts',
+             SiteConfig.ValidateInt(),
+             int],
+            ['sasl_domain', "gmail.com", str,
+             u'SASL domain for sending OSSEC alerts',
+             SiteConfig.ValidateNotEmpty(),
+             None],
+            ['sasl_username', '', str,
+             u'SASL username for sending OSSEC alerts',
+             SiteConfig.ValidateOSSECUsername(),
+             None],
+            ['sasl_password', '', str,
+             u'SASL password for sending OSSEC alerts',
+             SiteConfig.ValidateOSSECPassword(),
+             None],
+            ['securedrop_supported_locales', [], types.ListType,
+             u'Space separated list of additional locales to support '
+             '(' + translations + ')',
+             SiteConfig.ValidateLocales(self.args.app_path),
+             string.split],
+        ]
+
+    def load_and_update_config(self):
+        if self.exists():
+            self.config = self.load()
+        else:
+            self.config = None
+        return self.update_config()
+
+    def update_config(self):
+        self.config = self.user_prompt_config()
+        self.save()
+        self.validate_gpg_keys()
+        return True
+
+    def user_prompt_config(self):
+        config = {}
+        self_config = self.config or {}
+        for desc in self.desc:
+            (var, default, type, prompt, validator, transform) = desc
+            config[var] = self.user_prompt_config_one(desc,
+                                                      self_config.get(var))
+        return config
+
+    def user_prompt_config_one(self, desc, from_config):
+        (var, default, type, prompt, validator, transform) = desc
+        if from_config is not None:
+            default = from_config
+        prompt += ': '
+        return self.validated_input(prompt, default, validator, transform)
+
+    def validated_input(self, prompt, default, validator, transform):
+        if type(default) is bool:
+            default = default and 'yes' or 'no'
+        if type(default) is int:
+            default = str(default)
+        if isinstance(default, types.ListType):
+            default = " ".join(default)
+        if type(default) is not str:
+            default = str(default)
+        kwargs = {}
+        if validator:
+            kwargs['validator'] = validator
+        value = prompt_toolkit.prompt(prompt,
+                                      default=unicode(default, 'utf-8'),
+                                      **kwargs)
+        if transform:
+            return transform(value)
+        else:
+            return value
+
+    def sanitize_fingerprint(self, value):
+        return value.upper().replace(' ', '')
+
+    def validate_gpg_keys(self):
+        keys = (('securedrop_app_gpg_public_key',
+                 'securedrop_app_gpg_fingerprint'),
+
+                ('ossec_alert_gpg_public_key',
+                 'ossec_gpg_fpr'))
+        for (public_key, fingerprint) in keys:
+            validate = os.path.join(
+                os.path.dirname(__file__), '..', 'bin',
+                'validate-gpg-key.sh')
+            public_key = os.path.join(self.args.ansible_path,
+                                      self.config[public_key])
+            fingerprint = self.config[fingerprint]
+            try:
+                sdlog.debug(subprocess.check_output(
+                    [validate, public_key, fingerprint],
+                    stderr=subprocess.STDOUT))
+            except subprocess.CalledProcessError as e:
+                sdlog.debug(e.output)
+                raise FingerprintException(
+                    "fingerprint {} ".format(fingerprint) +
+                    "does not match " +
+                    "the public key {}".format(public_key))
+        return True
+
+    def exists(self):
+        return os.path.exists(self.args.site_config)
+
+    def save(self):
+        with open(self.args.site_config, 'w') as site_config_file:
+            yaml.safe_dump(self.config,
+                           site_config_file,
+                           default_flow_style=False)
+
+    def load(self):
+        try:
+            with open(self.args.site_config) as site_config_file:
+                return yaml.safe_load(site_config_file)
+        except IOError:
+            sdlog.error("Config file missing, re-run with sdconfig")
+            raise
+        except yaml.YAMLError:
+            sdlog.error("There was an issue processing {}".format(
+                self.args.site_config))
+            raise
+
+
+def setup_logger(verbose=False):
+    """ Configure logging handler """
+    # Set default level on parent
+    sdlog.setLevel(logging.DEBUG)
+    level = logging.DEBUG if verbose else logging.INFO
+
+    stdout = logging.StreamHandler(sys.stdout)
+    stdout.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
+    stdout.setLevel(level)
+    sdlog.addHandler(stdout)
+
+
+def sdconfig(args):
+    """Configure SD site settings"""
+    SiteConfig(args).load_and_update_config()
+
+
+def install_securedrop(args):
+    """Install/Update SecureDrop"""
+    SiteConfig(args).load()
+
+    sdlog.info("Now installing SecureDrop on remote servers.")
+    sdlog.info("You will be prompted for the sudo password on the "
+               "servers.")
+    sdlog.info("The sudo password is only necessary during initial "
+               "installation.")
+    subprocess.check_call([os.path.join(args.ansible_path,
+                                        'securedrop-prod.yml'),
+                          '--ask-become-pass'], cwd=args.ansible_path)
+
+
+def backup_securedrop(args):
+    """Perform backup of the SecureDrop Application Server.
+    Creates a tarball of submissions and server config, and fetches
+    back to the Admin Workstation. Future `restore` actions can be performed
+    with the backup tarball."""
+    sdlog.info("Backing up the SecureDrop Application Server")
+    ansible_cmd = [
+        'ansible-playbook',
+        os.path.join(args.ansible_path, 'securedrop-backup.yml'),
+    ]
+    subprocess.check_call(ansible_cmd, cwd=args.ansible_path)
+
+
+def restore_securedrop(args):
+    """Perform restore of the SecureDrop Application Server.
+    Requires a tarball of submissions and server config, created via
+    the `backup` action."""
+    sdlog.info("Restoring the SecureDrop Application Server from backup")
+    # Canonicalize filepath to backup tarball, so Ansible sees only the
+    # basename. The files must live in args.ansible_path,
+    # but the securedrop-admin
+    # script will be invoked from the repo root, so preceding dirs are likely.
+    restore_file_basename = os.path.basename(args.restore_file)
+    ansible_cmd = [
+        'ansible-playbook',
+        os.path.join(args.ansible_path, 'securedrop-restore.yml'),
+        '-e',
+        "restore_file='{}'".format(restore_file_basename),
+    ]
+    subprocess.check_call(ansible_cmd, cwd=args.ansible_path)
+
+
+def run_tails_config(args):
+    """Configure Tails environment post SD install"""
+    sdlog.info("Configuring Tails workstation environment")
+    sdlog.info(("You'll be prompted for the temporary Tails admin password,"
+                " which was set on Tails login screen"))
+    ansible_cmd = [
+        os.path.join(args.ansible_path, 'securedrop-tails.yml'),
+        "--ask-become-pass",
+        # Passing an empty inventory file to override the automatic dynamic
+        # inventory script, which fails if no site vars are configured.
+        '-i', '/dev/null',
+    ]
+    subprocess.check_call(ansible_cmd,
+                          cwd=args.ansible_path)
+
+
+def get_logs(args):
+    """Get logs for forensics and debugging purposes"""
+    sdlog.info("Gathering logs for forensics and debugging")
+    ansible_cmd = [
+        'ansible-playbook',
+        os.path.join(args.ansible_path, 'securedrop-logs.yml'),
+    ]
+    subprocess.check_call(ansible_cmd, cwd=args.ansible_path)
+    sdlog.info("Encrypt logs and send to securedrop@freedom.press or upload "
+               "to the SecureDrop support portal.")
+
+
+def set_default_paths(args):
+    if not args.ansible_path:
+        args.ansible_path = args.root + "/install_files/ansible-base"
+    args.ansible_path = os.path.realpath(args.ansible_path)
+    if not args.site_config:
+        args.site_config = args.ansible_path + "/group_vars/all/site-specific"
+    args.site_config = os.path.realpath(args.site_config)
+    if not args.app_path:
+        args.app_path = args.root + "/securedrop"
+    args.app_path = os.path.realpath(args.app_path)
+    return args
+
+
+def parse_argv(argv):
+    class ArgParseFormatterCombo(argparse.ArgumentDefaultsHelpFormatter,
+                                 argparse.RawTextHelpFormatter):
+        """Needed to combine formatting classes for help output"""
+        pass
+
+    parser = argparse.ArgumentParser(description=__doc__,
+                                     formatter_class=ArgParseFormatterCombo)
+    parser.add_argument('-v', action='store_true', default=False,
+                        help="Increase verbosity on output")
+    parser.add_argument('-d', action='store_true', default=False,
+                        help="Developer mode. Not to be used in production.")
+    parser.add_argument('--root', required=True,
+                        help="path to the root of the SecureDrop repository")
+    parser.add_argument('--site-config',
+                        help="path to the YAML site configuration file")
+    parser.add_argument('--ansible-path',
+                        help="path to the Ansible root")
+    parser.add_argument('--app-path',
+                        help="path to the SecureDrop application root")
+    subparsers = parser.add_subparsers()
+
+    parse_sdconfig = subparsers.add_parser('sdconfig', help=sdconfig.__doc__)
+    parse_sdconfig.set_defaults(func=sdconfig)
+
+    parse_install = subparsers.add_parser('install',
+                                          help=install_securedrop.__doc__)
+    parse_install.set_defaults(func=install_securedrop)
+
+    parse_tailsconfig = subparsers.add_parser('tailsconfig',
+                                              help=run_tails_config.__doc__)
+    parse_tailsconfig.set_defaults(func=run_tails_config)
+
+    parse_backup = subparsers.add_parser('backup',
+                                         help=backup_securedrop.__doc__)
+    parse_backup.set_defaults(func=backup_securedrop)
+
+    parse_restore = subparsers.add_parser('restore',
+                                          help=restore_securedrop.__doc__)
+    parse_restore.set_defaults(func=restore_securedrop)
+    parse_restore.add_argument("restore_file")
+
+    parse_logs = subparsers.add_parser('logs',
+                                       help=get_logs.__doc__)
+    parse_logs.set_defaults(func=get_logs)
+
+    return set_default_paths(parser.parse_args(argv))
+
+
+def main(argv):
+    args = parse_argv(argv)
+    setup_logger(args.v)
+    if args.v:
+        args.func(args)
+    else:
+        try:
+            args.func(args)
+        except KeyboardInterrupt:
+            sys.exit(0)
+        except Exception as e:
+            raise SystemExit(
+                'ERROR (run with -v for more): {msg}'.format(msg=e))
+        else:
+            sys.exit(0)
+
+
+if __name__ == "__main__":
+    main(sys.argv[1:])
diff --git a/admin/setup.py b/admin/setup.py
new file mode 100644
--- /dev/null
+++ b/admin/setup.py
@@ -0,0 +1,22 @@
+#
+# Copyright (C) 2013-2018 Freedom of the Press Foundation & al
+# Copyright (C) 2018 Loic Dachary <loic@dachary.org>
+#
+#    This program is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License as published by
+#    the Free Software Foundation, either version 3 of the License, or
+#    (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+import setuptools
+
+setuptools.setup(
+    setup_requires=['d2to1', 'pbr'],
+    d2to1=True)


Test patch:
diff --git a/admin/tests/files/SecureDrop.asc b/admin/tests/files/SecureDrop.asc
new file mode 100644
--- /dev/null
+++ b/admin/tests/files/SecureDrop.asc
@@ -0,0 +1,52 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: GnuPG v2.0.19 (GNU/Linux)
+
+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
+Vu4MPiQNn1h4dk1RonfV/aJ+de1+qjA8
+=XVz8
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/admin/tests/files/corrupted b/admin/tests/files/corrupted
new file mode 100644
--- /dev/null
+++ b/admin/tests/files/corrupted
@@ -0,0 +1,2 @@
+app_hostname: app
+app_ip: [10.20.2.2
diff --git a/admin/tests/files/key.asc b/admin/tests/files/key.asc
new file mode 100644
--- /dev/null
+++ b/admin/tests/files/key.asc
@@ -0,0 +1,281 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+=WumQ
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/admin/tests/files/ossec.pub b/admin/tests/files/ossec.pub
new file mode 100644
--- /dev/null
+++ b/admin/tests/files/ossec.pub
@@ -0,0 +1,52 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: GnuPG v2.0.19 (GNU/Linux)
+
+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
+Vu4MPiQNn1h4dk1RonfV/aJ+de1+qjA8
+=XVz8
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/admin/tests/files/site-specific b/admin/tests/files/site-specific
new file mode 100644
--- /dev/null
+++ b/admin/tests/files/site-specific
@@ -0,0 +1,19 @@
+app_hostname: app
+app_ip: 10.20.2.2
+dns_server: 8.8.8.8
+monitor_hostname: mon
+monitor_ip: 10.20.3.2
+ossec_alert_email: la@foo.com
+ossec_alert_gpg_public_key: key.asc
+ossec_gpg_fpr: E99FFE83DF73E72FB6B264ED992D23B392F9E4F2
+sasl_domain: gnu.com
+sasl_password: passowrdok
+sasl_username: usernameok
+securedrop_app_gpg_fingerprint: E99FFE83DF73E72FB6B264ED992D23B392F9E4F2
+securedrop_app_gpg_public_key: key.asc
+securedrop_app_https_on_source_interface: false
+securedrop_supported_locales:
+- en
+smtp_relay: smtp.gmail.com
+smtp_relay_port: 587
+ssh_users: sd
diff --git a/admin/tests/files/site-specific-missing-entries b/admin/tests/files/site-specific-missing-entries
new file mode 100644
--- /dev/null
+++ b/admin/tests/files/site-specific-missing-entries
@@ -0,0 +1,14 @@
+app_hostname: app
+app_ip: 10.20.2.2
+dns_server: 8.8.8.8
+monitor_hostname: mon
+monitor_ip: 10.20.3.2
+ossec_alert_email: la@foo.com
+ossec_alert_gpg_public_key: key.asc
+ossec_gpg_fpr: E99FFE83DF73E72FB6B264ED992D23B392F9E4F2
+sasl_domain: gnu.com
+sasl_password: passowrdok
+sasl_username: usernameok
+securedrop_app_gpg_fingerprint: E99FFE83DF73E72FB6B264ED992D23B392F9E4F2
+securedrop_app_gpg_public_key: key.asc
+smtp_relay: smtp.gmail.com
diff --git a/admin/tests/files/test_journalist_key.pub b/admin/tests/files/test_journalist_key.pub
new file mode 100644
--- /dev/null
+++ b/admin/tests/files/test_journalist_key.pub
@@ -0,0 +1,52 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: GnuPG v2.0.19 (GNU/Linux)
+
+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
+Vu4MPiQNn1h4dk1RonfV/aJ+de1+qjA8
+=XVz8
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/securedrop/tests/test_securedrop-admin.py b/admin/tests/test_securedrop-admin-setup.py
similarity index 74%
rename from securedrop/tests/test_securedrop-admin.py
rename to admin/tests/test_securedrop-admin-setup.py
--- a/securedrop/tests/test_securedrop-admin.py
+++ b/admin/tests/test_securedrop-admin-setup.py
@@ -18,39 +18,36 @@
 #
 
 import argparse
-import imp
-from os.path import abspath, dirname, join, realpath
 import pytest
 import subprocess
 
-here = abspath(join(dirname(realpath(__file__))))
-securedrop_admin = imp.load_source('sa', here + '/../securedrop-admin')
+import bootstrap
 
 
 class TestSecureDropAdmin(object):
 
     def test_verbose(self, capsys):
-        securedrop_admin.setup_logger(verbose=True)
-        securedrop_admin.sdlog.debug('VISIBLE')
+        bootstrap.setup_logger(verbose=True)
+        bootstrap.sdlog.debug('VISIBLE')
         out, err = capsys.readouterr()
         assert 'VISIBLE' in out
 
     def test_not_verbose(self, capsys):
-        securedrop_admin.setup_logger(verbose=False)
-        securedrop_admin.sdlog.debug('HIDDEN')
-        securedrop_admin.sdlog.info('VISIBLE')
+        bootstrap.setup_logger(verbose=False)
+        bootstrap.sdlog.debug('HIDDEN')
+        bootstrap.sdlog.info('VISIBLE')
         out, err = capsys.readouterr()
         assert 'HIDDEN' not in out
         assert 'VISIBLE' in out
 
     def test_run_command(self):
-        for output_line in securedrop_admin.run_command(
+        for output_line in bootstrap.run_command(
                 ['/bin/echo', 'something']):
             assert output_line.strip() == 'something'
 
         lines = []
         with pytest.raises(subprocess.CalledProcessError):
-            for output_line in securedrop_admin.run_command(
+            for output_line in bootstrap.run_command(
                     ['sh', '-c',
                      'echo in stdout ; echo in stderr >&2 ; false']):
                 lines.append(output_line.strip())
@@ -59,19 +56,19 @@ def test_run_command(self):
 
     def test_install_pip_dependencies_up_to_date(self, caplog):
         args = argparse.Namespace()
-        securedrop_admin.install_pip_dependencies(args, ['/bin/echo'])
+        bootstrap.install_pip_dependencies(args, ['/bin/echo'])
         assert 'securedrop-admin are up-to-date' in caplog.text
 
     def test_install_pip_dependencies_upgraded(self, caplog):
         args = argparse.Namespace()
-        securedrop_admin.install_pip_dependencies(
+        bootstrap.install_pip_dependencies(
             args, ['/bin/echo', 'Successfully installed'])
         assert 'securedrop-admin upgraded' in caplog.text
 
     def test_install_pip_dependencies_fail(self, caplog):
         args = argparse.Namespace()
-        with pytest.raises(SystemExit):
-            securedrop_admin.install_pip_dependencies(
+        with pytest.raises(subprocess.CalledProcessError):
+            bootstrap.install_pip_dependencies(
                 args, ['/bin/sh', '-c',
                        'echo in stdout ; echo in stderr >&2 ; false'])
         assert 'Failed to install' in caplog.text
diff --git a/admin/tests/test_securedrop-admin.py b/admin/tests/test_securedrop-admin.py
new file mode 100644
--- /dev/null
+++ b/admin/tests/test_securedrop-admin.py
@@ -0,0 +1,459 @@
+# -*- coding: utf-8 -*-
+#
+# SecureDrop whistleblower submission system
+# Copyright (C) 2017 Loic Dachary <loic@dachary.org>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+import argparse
+from os.path import dirname, join, basename
+import mock
+from prompt_toolkit.validation import ValidationError
+import pytest
+import string
+import subprocess
+import textwrap
+import yaml
+
+import securedrop_admin
+
+
+class Document(object):
+    def __init__(self, text):
+        self.text = text
+
+
+class TestSecureDropAdmin(object):
+
+    def test_verbose(self, capsys):
+        securedrop_admin.setup_logger(verbose=True)
+        securedrop_admin.sdlog.debug('VISIBLE')
+        out, err = capsys.readouterr()
+        assert 'VISIBLE' in out
+
+    def test_not_verbose(self, capsys):
+        securedrop_admin.setup_logger(verbose=False)
+        securedrop_admin.sdlog.debug('HIDDEN')
+        securedrop_admin.sdlog.info('VISIBLE')
+        out, err = capsys.readouterr()
+        assert 'HIDDEN' not in out
+        assert 'VISIBLE' in out
+
+
+class TestSiteConfig(object):
+
+    def test_exists(self):
+        args = argparse.Namespace(site_config='DOES_NOT_EXIST',
+                                  ansible_path='.',
+                                  app_path=dirname(__file__))
+        assert not securedrop_admin.SiteConfig(args).exists()
+        args = argparse.Namespace(site_config=__file__,
+                                  ansible_path='.',
+                                  app_path=dirname(__file__))
+        assert securedrop_admin.SiteConfig(args).exists()
+
+    def test_validate_not_empty(self):
+        validator = securedrop_admin.SiteConfig.ValidateNotEmpty()
+
+        assert validator.validate(Document('something'))
+        with pytest.raises(ValidationError):
+            validator.validate(Document(''))
+
+    def test_validate_ossec_username(self):
+        validator = securedrop_admin.SiteConfig.ValidateOSSECUsername()
+
+        assert validator.validate(Document('username'))
+        with pytest.raises(ValidationError):
+            validator.validate(Document('bad@user'))
+        with pytest.raises(ValidationError):
+            validator.validate(Document('test'))
+
+    def test_validate_ossec_password(self):
+        validator = securedrop_admin.SiteConfig.ValidateOSSECPassword()
+
+        assert validator.validate(Document('goodpassword'))
+        with pytest.raises(ValidationError):
+            validator.validate(Document('password123'))
+        with pytest.raises(ValidationError):
+            validator.validate(Document(''))
+        with pytest.raises(ValidationError):
+            validator.validate(Document('short'))
+
+    def test_validate_ossec_email(self):
+        validator = securedrop_admin.SiteConfig.ValidateOSSECEmail()
+
+        assert validator.validate(Document('good@mail.com'))
+        with pytest.raises(ValidationError):
+            validator.validate(Document('badmail'))
+        with pytest.raises(ValidationError):
+            validator.validate(Document(''))
+        with pytest.raises(ValidationError) as e:
+            validator.validate(Document('ossec@ossec.test'))
+        assert 'something other than ossec@ossec.test' in e.value.message
+
+    def test_is_tails(self):
+        validator = securedrop_admin.SiteConfig.ValidateDNS()
+        with mock.patch('subprocess.check_output', return_value='Tails'):
+            assert validator.is_tails()
+        with mock.patch('subprocess.check_output', return_value='Debian'):
+            assert validator.is_tails() is False
+        with mock.patch('subprocess.check_output',
+                        side_effect=subprocess.CalledProcessError(
+                            1, 'cmd', 'BANG')):
+            assert validator.is_tails() is False
+
+    def test_lookup_dns(self, caplog):
+        validator = securedrop_admin.SiteConfig.ValidateDNS()
+        with mock.patch('securedrop_admin.SiteConfig.ValidateDNS.is_tails',
+                        return_value=True):
+            with mock.patch('subprocess.check_output',
+                            return_value='has address') as check_output:
+                assert validator.lookup_fqdn('gnu.org', '8.8.8.8')
+                assert check_output.call_args[0][0].startswith('torify')
+                assert check_output.call_args[0][0].endswith('8.8.8.8')
+
+        with mock.patch('securedrop_admin.SiteConfig.ValidateDNS.is_tails',
+                        return_value=False):
+            with mock.patch('subprocess.check_output',
+                            return_value='failed') as check_output:
+                assert validator.lookup_fqdn('gnu.org') is False
+                assert not check_output.call_args[0][0].startswith('torify')
+                assert 'failed' in caplog.text
+
+        with mock.patch('securedrop_admin.SiteConfig.ValidateDNS.is_tails',
+                        return_value=False):
+            with mock.patch('subprocess.check_output',
+                            side_effect=subprocess.CalledProcessError(
+                                1, 'cmd', 'BANG')):
+                assert validator.lookup_fqdn('gnu.org') is False
+                assert 'BANG' in caplog.text
+
+    def test_validate_dns_server(self, caplog):
+        validator = securedrop_admin.SiteConfig.ValidateDNSServer()
+        with mock.patch('securedrop_admin.SiteConfig.ValidateDNS.lookup_fqdn',
+                        return_value=True):
+            assert validator.validate(Document('8.8.8.8'))
+        with mock.patch('securedrop_admin.SiteConfig.ValidateDNS.lookup_fqdn',
+                        return_value=False):
+            with pytest.raises(ValidationError):
+                validator.validate(Document('8.8.8.8'))
+
+    def test_lookup_fqdn(self, caplog):
+        validator = securedrop_admin.SiteConfig.ValidateFQDN()
+        with mock.patch('securedrop_admin.SiteConfig.ValidateDNS.lookup_fqdn',
+                        return_value=True):
+            assert validator.validate(Document('gnu.org'))
+        with mock.patch('securedrop_admin.SiteConfig.ValidateDNS.lookup_fqdn',
+                        return_value=False):
+            with pytest.raises(ValidationError):
+                assert validator.validate(Document('gnu.org'))
+
+    def test_validate_user(self):
+        validator = securedrop_admin.SiteConfig.ValidateUser()
+        with pytest.raises(ValidationError):
+            validator.validate(Document("amnesia"))
+        with pytest.raises(ValidationError):
+            validator.validate(Document("root"))
+        with pytest.raises(ValidationError):
+            validator.validate(Document(""))
+        assert validator.validate(Document("gooduser"))
+
+    def test_validate_ip(self):
+        validator = securedrop_admin.SiteConfig.ValidateIP()
+        with pytest.raises(ValidationError):
+            validator.validate(Document("599.20"))
+        assert validator.validate(Document("192.168.1.1"))
+
+    def test_validate_path(self):
+        mydir = dirname(__file__)
+        myfile = basename(__file__)
+        validator = securedrop_admin.SiteConfig.ValidatePath(mydir)
+        assert validator.validate(Document(myfile))
+        with pytest.raises(ValidationError):
+            validator.validate(Document("NONEXIST"))
+        with pytest.raises(ValidationError):
+            validator.validate(Document(""))
+
+    def test_validate_yes_no(self):
+        validator = securedrop_admin.SiteConfig.ValidateYesNo()
+        with pytest.raises(ValidationError):
+            validator.validate(Document("something"))
+        assert validator.validate(Document("yes"))
+        assert validator.validate(Document("YES"))
+        assert validator.validate(Document("no"))
+        assert validator.validate(Document("NO"))
+
+    def test_validate_fingerprint(self):
+        validator = securedrop_admin.SiteConfig.ValidateFingerprint()
+        assert validator.validate(Document(
+            "012345678901234567890123456789ABCDEFABCD"))
+        assert validator.validate(Document(
+            "01234 5678901234567890123456789ABCDE   FABCD"))
+
+        with pytest.raises(ValidationError) as e:
+            validator.validate(Document(
+                "65A1B5FF195B56353CC63DFFCC40EF1228271441"))
+        assert 'TEST journalist' in e.value.message
+
+        with pytest.raises(ValidationError) as e:
+            validator.validate(Document(
+                "600BC6D5142C68F35DDBCEA87B597104EDDDC102"))
+        assert 'TEST admin' in e.value.message
+
+        with pytest.raises(ValidationError) as e:
+            validator.validate(Document(
+                "0000"))
+        assert '40 hexadecimal' in e.value.message
+
+        with pytest.raises(ValidationError) as e:
+            validator.validate(Document(
+                "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"))
+        assert '40 hexadecimal' in e.value.message
+
+    def test_sanitize_fingerprint(self):
+        args = argparse.Namespace(site_config='DOES_NOT_EXIST',
+                                  ansible_path='.',
+                                  app_path=dirname(__file__))
+        site_config = securedrop_admin.SiteConfig(args)
+        assert "ABC" == site_config.sanitize_fingerprint("    A bc")
+
+    def test_validate_int(self):
+        validator = securedrop_admin.SiteConfig.ValidateInt()
+        with pytest.raises(ValidationError):
+            validator.validate(Document("123X"))
+        assert validator.validate(Document("192"))
+
+    def test_locales(self):
+        locales = securedrop_admin.SiteConfig.Locales(dirname(__file__))
+        translations = locales.get_translations()
+        assert 'en_US' in translations
+        assert 'fr_FR' in translations
+
+    def test_validate_locales(self):
+        validator = securedrop_admin.SiteConfig.ValidateLocales(
+            dirname(__file__))
+        assert validator.validate(Document('en_US  fr_FR '))
+        with pytest.raises(ValidationError) as e:
+            validator.validate(Document('BAD'))
+        assert 'BAD' in e.value.message
+
+    def test_save(self, tmpdir):
+        site_config_path = join(str(tmpdir), 'site_config')
+        args = argparse.Namespace(site_config=site_config_path,
+                                  ansible_path='.',
+                                  app_path=dirname(__file__))
+        site_config = securedrop_admin.SiteConfig(args)
+        site_config.config = {'var1': u'val1', 'var2': u'val2'}
+        site_config.save()
+        expected = textwrap.dedent("""\
+        var1: val1
+        var2: val2
+        """)
+        assert expected == open(site_config_path).read()
+
+    def test_validate_gpg_key(self, caplog):
+        args = argparse.Namespace(site_config='INVALID',
+                                  ansible_path='tests/files',
+                                  app_path=dirname(__file__))
+        site_config = securedrop_admin.SiteConfig(args)
+        site_config.config = {
+            'securedrop_app_gpg_public_key':
+            'test_journalist_key.pub',
+
+            'securedrop_app_gpg_fingerprint':
+            '65A1B5FF195B56353CC63DFFCC40EF1228271441',
+
+            'ossec_alert_gpg_public_key':
+            'test_journalist_key.pub',
+
+            'ossec_gpg_fpr':
+            '65A1B5FF195B56353CC63DFFCC40EF1228271441',
+        }
+        assert site_config.validate_gpg_keys()
+        site_config.config['ossec_gpg_fpr'] = 'FAIL'
+        with pytest.raises(securedrop_admin.FingerprintException) as e:
+            site_config.validate_gpg_keys()
+        assert 'FAIL does not match' in e.value.message
+
+    @mock.patch('securedrop_admin.SiteConfig.validated_input',
+                side_effect=lambda p, d, v, t: d)
+    @mock.patch('securedrop_admin.SiteConfig.save')
+    def test_update_config(self, mock_save, mock_validate_input):
+        args = argparse.Namespace(site_config='tests/files/site-specific',
+                                  ansible_path='tests/files',
+                                  app_path=dirname(__file__))
+        site_config = securedrop_admin.SiteConfig(args)
+
+        assert site_config.load_and_update_config()
+        mock_save.assert_called_once()
+        mock_validate_input.assert_called()
+
+    def test_load_and_update_config(self):
+        args = argparse.Namespace(site_config='tests/files/site-specific',
+                                  ansible_path='tests/files',
+                                  app_path=dirname(__file__))
+        site_config = securedrop_admin.SiteConfig(args)
+        with mock.patch('securedrop_admin.SiteConfig.update_config'):
+            site_config.load_and_update_config()
+            assert site_config.config is not None
+
+        args = argparse.Namespace(
+            site_config='tests/files/site-specific-missing-entries',
+            ansible_path='tests/files',
+            app_path=dirname(__file__))
+        site_config = securedrop_admin.SiteConfig(args)
+        with mock.patch('securedrop_admin.SiteConfig.update_config'):
+            site_config.load_and_update_config()
+            assert site_config.config is not None
+
+        args = argparse.Namespace(site_config='UNKNOWN',
+                                  ansible_path='tests/files',
+                                  app_path=dirname(__file__))
+        site_config = securedrop_admin.SiteConfig(args)
+        with mock.patch('securedrop_admin.SiteConfig.update_config'):
+            site_config.load_and_update_config()
+            assert site_config.config is None
+
+    def get_desc(self, site_config, var):
+        for desc in site_config.desc:
+            if desc[0] == var:
+                return desc
+
+    def verify_desc_consistency(self, site_config, desc):
+        (var, default, etype, prompt, validator, transform) = desc
+        # verify the default passes validation
+        assert site_config.user_prompt_config_one(desc, None) == default
+        assert type(default) == etype
+        with pytest.raises(ValidationError):
+            site_config.user_prompt_config_one(desc, '')
+
+    verify_prompt_ssh_users = verify_desc_consistency
+    verify_prompt_app_ip = verify_desc_consistency
+    verify_prompt_monitor_ip = verify_desc_consistency
+    verify_prompt_app_hostname = verify_desc_consistency
+    verify_prompt_monitor_hostname = verify_desc_consistency
+    verify_prompt_dns_server = verify_desc_consistency
+
+    def verify_prompt_securedrop_app_https_on_source_interface(
+            self, site_config, desc):
+        self.verify_desc_consistency(site_config, desc)
+        (var, default, etype, prompt, validator, transform) = desc
+        assert site_config.user_prompt_config_one(desc, True) is True
+        assert site_config.user_prompt_config_one(desc, False) is False
+        assert site_config.user_prompt_config_one(desc, 'YES') is True
+        assert site_config.user_prompt_config_one(desc, 'NO') is False
+
+    verify_prompt_securedrop_app_gpg_public_key = verify_desc_consistency
+
+    def verify_prompt_not_empty(self, site_config, desc):
+        with pytest.raises(ValidationError):
+            site_config.user_prompt_config_one(desc, '')
+
+    def verify_prompt_fingerprint(self, site_config, desc):
+        self.verify_prompt_not_empty(site_config, desc)
+        fpr = "0123456 789012 34567890123456789ABCDEFABCD"
+        clean_fpr = site_config.sanitize_fingerprint(fpr)
+        assert site_config.user_prompt_config_one(desc, fpr) == clean_fpr
+
+    verify_prompt_securedrop_app_gpg_fingerprint = verify_prompt_fingerprint
+    verify_prompt_ossec_alert_gpg_public_key = verify_desc_consistency
+    verify_prompt_ossec_gpg_fpr = verify_prompt_fingerprint
+    verify_prompt_ossec_alert_email = verify_prompt_not_empty
+    verify_prompt_smtp_relay = verify_prompt_not_empty
+    verify_prompt_smtp_relay_port = verify_desc_consistency
+    verify_prompt_sasl_domain = verify_desc_consistency
+    verify_prompt_sasl_username = verify_prompt_not_empty
+    verify_prompt_sasl_password = verify_prompt_not_empty
+
+    def verify_prompt_securedrop_supported_locales(self, site_config, desc):
+        (var, default, etype, prompt, validator, transform) = desc
+        # verify the default passes validation
+        assert site_config.user_prompt_config_one(desc, None) == default
+        assert type(default) == etype
+        assert site_config.user_prompt_config_one(
+            desc, 'en en_US') == ['en', 'en_US']
+        assert site_config.user_prompt_config_one(
+            desc, ['en', 'en_US']) == ['en', 'en_US']
+        assert site_config.user_prompt_config_one(desc, '') == []
+        with pytest.raises(ValidationError):
+            site_config.user_prompt_config_one(desc, 'wrong')
+
+    def test_user_prompt_config_one(self):
+        args = argparse.Namespace(site_config='UNKNOWN',
+                                  ansible_path='tests/files',
+                                  app_path=dirname(__file__))
+        site_config = securedrop_admin.SiteConfig(args)
+
+        def auto_prompt(prompt, default, **kwargs):
+            if 'validator' in kwargs:
+                assert kwargs['validator'].validate(Document(default))
+            return default
+
+        with mock.patch('prompt_toolkit.prompt', side_effect=auto_prompt):
+            for desc in site_config.desc:
+                (var, default, etype, prompt, validator, transform) = desc
+                method = 'verify_prompt_' + var
+                print("checking " + method)
+                getattr(self, method)(site_config, desc)
+
+    def test_validated_input(self):
+        args = argparse.Namespace(site_config='UNKNOWN',
+                                  ansible_path='tests/files',
+                                  app_path=dirname(__file__))
+        site_config = securedrop_admin.SiteConfig(args)
+
+        def auto_prompt(prompt, default, **kwargs):
+            return default
+
+        with mock.patch('prompt_toolkit.prompt', side_effect=auto_prompt):
+            value = 'VALUE'
+            assert value == site_config.validated_input(
+                '', value, lambda: True, None)
+            assert value.lower() == site_config.validated_input(
+                '', value, lambda: True, string.lower)
+            assert 'yes' == site_config.validated_input(
+                '', True, lambda: True, None)
+            assert 'no' == site_config.validated_input(
+                '', False, lambda: True, None)
+            assert '1234' == site_config.validated_input(
+                '', 1234, lambda: True, None)
+            assert "a b" == site_config.validated_input(
+                '', ['a', 'b'], lambda: True, None)
+            assert "{}" == site_config.validated_input(
+                '', {}, lambda: True, None)
+
+    def test_load(self, caplog):
+        args = argparse.Namespace(site_config='tests/files/site-specific',
+                                  ansible_path='tests/files',
+                                  app_path=dirname(__file__))
+        site_config = securedrop_admin.SiteConfig(args)
+        assert 'app_hostname' in site_config.load()
+
+        args = argparse.Namespace(site_config='UNKNOWN',
+                                  ansible_path='tests/files',
+                                  app_path=dirname(__file__))
+        site_config = securedrop_admin.SiteConfig(args)
+        with pytest.raises(IOError) as e:
+            site_config.load()
+        assert 'No such file' in e.value.strerror
+        assert 'Config file missing' in caplog.text
+
+        args = argparse.Namespace(site_config='tests/files/corrupted',
+                                  ansible_path='tests/files',
+                                  app_path=dirname(__file__))
+        site_config = securedrop_admin.SiteConfig(args)
+        with pytest.raises(yaml.YAMLError) as e:
+            site_config.load()
+        assert 'issue processing' in caplog.text
diff --git a/admin/tests/translations/fr_FR b/admin/tests/translations/fr_FR
new file mode 100644
diff --git a/securedrop/bin/test b/securedrop/bin/test
--- a/securedrop/bin/test
+++ b/securedrop/bin/test
@@ -20,6 +20,7 @@ pytest \
   --page-layout \
   --durations 10 \
   --junitxml=/tmp/test-results/junit.xml \
+  --cov-report term-missing \
   --cov-report html:/tmp/test-results/cov_html \
   --cov-report xml:/tmp/test-results/cov.xml \
   --cov-report annotate:/tmp/test-results/cov_annotate \