Repository: freedomofpress/securedrop

Issue:
./securedrop-admin sdconfig does not ask if we should enable v3 or not
## Description

Right now if we run `./securedrop-admin sdconfig` it enables v3 onion address along wtih v2 (if already existing installation). It should ask the admin to enable v3 like any other configuration option.

## Steps to Reproduce

`./securedrop-admin sdconfig`

## Expected Behavior

It should ask if we want to enable v3 onion address or not.

## Actual Behavior

It does not any question about version of the onion addresses.

One thing to remember: we should not allow someone to disable both v2 and v3 services.



Discussion:

Resolving patch:
diff --git a/admin/securedrop_admin/__init__.py b/admin/securedrop_admin/__init__.py
--- a/admin/securedrop_admin/__init__.py
+++ b/admin/securedrop_admin/__init__.py
@@ -160,6 +160,23 @@ def validate(self, document):
                 return True
             raise ValidationError(message="Must be either yes or no")
 
+    class ValidateYesNoForV3(Validator):
+
+        def __init__(self, *args, **kwargs):
+            Validator.__init__(*args, **kwargs)
+            self.caller = args[0]
+
+        def validate(self, document):
+            text = document.text.lower()
+            # Raise error if admin tries to disable v3 when v2
+            # is already disabled.
+            if text == 'no' and \
+                    not self.caller._config_in_progress.get("v2_onion_services"):  # noqa: E501
+                raise ValidationError(message="Since you disabled v2 onion services, you must enable v3 onion services.")  # noqa: E501
+            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(' ', '')
@@ -261,6 +278,9 @@ def validate(self, document):
     def __init__(self, args):
         self.args = args
         self.config = {}
+        # Hold runtime configuration before save, to support
+        # referencing other responses during validation
+        self._config_in_progress = {}
         translations = SiteConfig.Locales(
             self.args.app_path).get_translations()
         translations = " ".join(translations)
@@ -403,6 +423,16 @@ def __init__(self, args):
              SiteConfig.ValidateLocales(self.args.app_path),
              string.split,
              lambda config: True],
+            ['v2_onion_services', self.check_for_v2_onion(), bool,
+             u'Do you want to enable v2 onion services (recommended only for SecureDrop instances installed before 1.0.0)?',  # noqa: E501
+             SiteConfig.ValidateYesNo(),
+             lambda x: x.lower() == 'yes',
+             lambda config: True],
+            ['v3_onion_services', self.check_for_v3_onion, bool,
+             u'Do you want to enable v3 onion services (recommended)?',
+             SiteConfig.ValidateYesNoForV3(self),
+             lambda x: x.lower() == 'yes',
+             lambda config: True],
         ]
 
     def load_and_update_config(self):
@@ -413,18 +443,15 @@ def load_and_update_config(self):
 
     def update_config(self):
         self.config.update(self.user_prompt_config())
-        self.update_onion_version_config()
         self.save()
         self.validate_gpg_keys()
         self.validate_journalist_alert_email()
         return True
 
-    def update_onion_version_config(self):
+    def check_for_v2_onion(self):
         """
-        This method updates onion service related configurations.
+        Check if v2 onion services are already enabled or not.
         """
-        v2 = False
-        v3 = True
         source_ths = os.path.join(self.args.ansible_path, "app-source-ths")
         if os.path.exists(source_ths):  # Means old installation
             data = ""
@@ -433,30 +460,46 @@ def update_onion_version_config(self):
 
             data = data.strip()
             if len(data) < 56:  # Old v2 onion address
-                v2 = True
+                return True
+        return False
 
-        # Now update the configuration
-        config = {"v2_onion_services": v2,
-                  "v3_onion_services": v3}
-        self.config.update(config)
+    def check_for_v3_onion(self):
+        """
+        Check if v3 onion services should be enabled by default or not.
+        """
+        v2_value = self._config_in_progress.get("v2_onion_services", False)
+        # We need to see the value in the configuration file
+        # for v3_onion_services
+        v3_value = self.config.get("v3_onion_services", True)
+        return v3_value or not v2_value
 
     def user_prompt_config(self):
-        config = {}
+        self._config_in_progress = {}
         for desc in self.desc:
             (var, default, type, prompt, validator, transform,
                 condition) = desc
-            if not condition(config):
-                config[var] = ''
+            if not condition(self._config_in_progress):
+                self._config_in_progress[var] = ''
                 continue
-            config[var] = self.user_prompt_config_one(desc,
-                                                      self.config.get(var))
-        return config
+            self._config_in_progress[var] = self.user_prompt_config_one(desc,
+                                                            self.config.get(var))  # noqa: E501
+        return self._config_in_progress
 
     def user_prompt_config_one(self, desc, from_config):
         (var, default, type, prompt, validator, transform, condition) = desc
-        if from_config is not None:
+        if from_config is not None and var != "v3_onion_services":
+            # v3_onion_services must be true if v2 is disabled by the admin
+            # otherwise, we may end up in a situation where both v2 and v3
+            # are disabled by the admin (by mistake).
             default = from_config
         prompt += ': '
+
+        # The following is for the dynamic check of the user input
+        # for the previous question, as we are calling the default value
+        # function dynamically, we can get the right value based on the
+        # previous user input.
+        if callable(default):
+            default = default()
         return self.validated_input(prompt, default, validator, transform)
 
     def validated_input(self, prompt, default, validator, transform):


Test patch:
diff --git a/admin/tests/test_integration.py b/admin/tests/test_integration.py
--- a/admin/tests/test_integration.py
+++ b/admin/tests/test_integration.py
@@ -48,6 +48,70 @@
 v3_onion_services: true
 '''
 
+WHEN_BOTH_TRUE = '''app_hostname: app
+app_ip: 10.20.2.2
+daily_reboot_time: 5
+dns_server: 8.8.8.8
+enable_ssh_over_tor: true
+journalist_alert_email: ''
+journalist_alert_gpg_public_key: ''
+journalist_gpg_fpr: ''
+monitor_hostname: mon
+monitor_ip: 10.20.3.2
+ossec_alert_email: test@gmail.com
+ossec_alert_gpg_public_key: sd_admin_test.pub
+ossec_gpg_fpr: 1F544B31C845D698EB31F2FF364F1162D32E7E58
+sasl_domain: gmail.com
+sasl_password: testpassword
+sasl_username: testuser
+securedrop_app_gpg_fingerprint: 1F544B31C845D698EB31F2FF364F1162D32E7E58
+securedrop_app_gpg_public_key: sd_admin_test.pub
+securedrop_app_https_certificate_cert_src: ''
+securedrop_app_https_certificate_chain_src: ''
+securedrop_app_https_certificate_key_src: ''
+securedrop_app_https_on_source_interface: false
+securedrop_supported_locales:
+- de_DE
+- es_ES
+smtp_relay: smtp.gmail.com
+smtp_relay_port: 587
+ssh_users: sd
+v2_onion_services: true
+v3_onion_services: true
+'''
+
+WHEN_ONLY_V2 = '''app_hostname: app
+app_ip: 10.20.2.2
+daily_reboot_time: 5
+dns_server: 8.8.8.8
+enable_ssh_over_tor: true
+journalist_alert_email: ''
+journalist_alert_gpg_public_key: ''
+journalist_gpg_fpr: ''
+monitor_hostname: mon
+monitor_ip: 10.20.3.2
+ossec_alert_email: test@gmail.com
+ossec_alert_gpg_public_key: sd_admin_test.pub
+ossec_gpg_fpr: 1F544B31C845D698EB31F2FF364F1162D32E7E58
+sasl_domain: gmail.com
+sasl_password: testpassword
+sasl_username: testuser
+securedrop_app_gpg_fingerprint: 1F544B31C845D698EB31F2FF364F1162D32E7E58
+securedrop_app_gpg_public_key: sd_admin_test.pub
+securedrop_app_https_certificate_cert_src: ''
+securedrop_app_https_certificate_chain_src: ''
+securedrop_app_https_certificate_key_src: ''
+securedrop_app_https_on_source_interface: false
+securedrop_supported_locales:
+- de_DE
+- es_ES
+smtp_relay: smtp.gmail.com
+smtp_relay_port: 587
+ssh_users: sd
+v2_onion_services: true
+v3_onion_services: false
+'''
+
 JOURNALIST_ALERT_OUTPUT = '''app_hostname: app
 app_ip: 10.20.2.2
 daily_reboot_time: 5
@@ -251,6 +315,21 @@ def verify_locales_prompt(child):
     child.expect('Space separated list of additional locales to support')  # noqa: E501
 
 
+def verify_v2_onion_for_first_time(child):
+    child.expect(r' installed before 1.0.0\)\?\:', timeout=2)  # noqa: E501
+    assert ANSI_ESCAPE.sub('', child.buffer) == ' no'  # Expected default
+
+
+def verify_v3_onion_for_first_time(child):
+    child.expect(r'Do you want to enable v3 onion services \(recommended\)\?\:', timeout=2)  # noqa: E501
+    assert ANSI_ESCAPE.sub('', child.buffer) == ' yes'  # Expected default
+
+
+def verify_v3_onion_when_v2_is_enabled(child):
+    child.expect(r'Do you want to enable v3 onion services \(recommended\)\?\:', timeout=2)  # noqa: E501
+    assert ANSI_ESCAPE.sub('', child.buffer) == ' yes'  # Expected default
+
+
 def test_sdconfig_on_first_run():
     cmd = os.path.join(os.path.dirname(CURRENT_DIR),
                        'securedrop_admin/__init__.py')
@@ -298,6 +377,10 @@ def test_sdconfig_on_first_run():
     child.sendline('')
     verify_locales_prompt(child)
     child.sendline('de_DE es_ES')
+    verify_v2_onion_for_first_time(child)
+    child.sendline('\b' * 3 + 'no')
+    verify_v3_onion_for_first_time(child)
+    child.sendline('\b' * 4 + 'yes')
 
     child.expect(pexpect.EOF, timeout=10)  # Wait for validation to occur
     child.close()
@@ -309,6 +392,130 @@ def test_sdconfig_on_first_run():
     assert data == OUTPUT1
 
 
+def test_sdconfig_both_v2_v3_true():
+    cmd = os.path.join(os.path.dirname(CURRENT_DIR),
+                       'securedrop_admin/__init__.py')
+    child = pexpect.spawn('python {0} --root {1} sdconfig'.format(cmd, SD_DIR))
+    verify_username_prompt(child)
+    child.sendline('')
+    verify_reboot_prompt(child)
+    child.sendline('\b5')  # backspace and put 5
+    verify_ipv4_appserver_prompt(child)
+    child.sendline('')
+    verify_ipv4_monserver_prompt(child)
+    child.sendline('')
+    verify_hostname_app_prompt(child)
+    child.sendline('')
+    verify_hostname_mon_prompt(child)
+    child.sendline('')
+    verify_dns_prompt(child)
+    child.sendline('')
+    verify_app_gpg_key_prompt(child)
+    child.sendline('\b' * 14 + 'sd_admin_test.pub')
+    verify_https_prompt(child)
+    # Default answer is no
+    child.sendline('')
+    verify_app_gpg_fingerprint_prompt(child)
+    child.sendline('1F544B31C845D698EB31F2FF364F1162D32E7E58')
+    verify_ossec_gpg_key_prompt(child)
+    child.sendline('\b' * 9 + 'sd_admin_test.pub')
+    verify_ossec_gpg_fingerprint_prompt(child)
+    child.sendline('1F544B31C845D698EB31F2FF364F1162D32E7E58')
+    verify_admin_email_prompt(child)
+    child.sendline('test@gmail.com')
+    verify_journalist_gpg_key_prompt(child)
+    child.sendline('')
+    verify_smtp_relay_prompt(child)
+    child.sendline('')
+    verify_smtp_port_prompt(child)
+    child.sendline('')
+    verify_sasl_domain_prompt(child)
+    child.sendline('')
+    verify_sasl_username_prompt(child)
+    child.sendline('testuser')
+    verify_sasl_password_prompt(child)
+    child.sendline('testpassword')
+    verify_ssh_over_lan_prompt(child)
+    child.sendline('')
+    verify_locales_prompt(child)
+    child.sendline('de_DE es_ES')
+    verify_v2_onion_for_first_time(child)
+    child.sendline('\b' * 3 + 'yes')
+    verify_v3_onion_when_v2_is_enabled(child)
+    child.sendline('\b' * 3 + 'yes')
+
+    child.expect(pexpect.EOF, timeout=10)  # Wait for validation to occur
+    child.close()
+    assert child.exitstatus == 0
+    assert child.signalstatus is None
+
+    with open(os.path.join(SD_DIR, 'install_files/ansible-base/group_vars/all/site-specific')) as fobj:    # noqa: E501
+        data = fobj.read()
+    assert data == WHEN_BOTH_TRUE
+
+
+def test_sdconfig_only_v2_true():
+    cmd = os.path.join(os.path.dirname(CURRENT_DIR),
+                       'securedrop_admin/__init__.py')
+    child = pexpect.spawn('python {0} --root {1} sdconfig'.format(cmd, SD_DIR))
+    verify_username_prompt(child)
+    child.sendline('')
+    verify_reboot_prompt(child)
+    child.sendline('\b5')  # backspace and put 5
+    verify_ipv4_appserver_prompt(child)
+    child.sendline('')
+    verify_ipv4_monserver_prompt(child)
+    child.sendline('')
+    verify_hostname_app_prompt(child)
+    child.sendline('')
+    verify_hostname_mon_prompt(child)
+    child.sendline('')
+    verify_dns_prompt(child)
+    child.sendline('')
+    verify_app_gpg_key_prompt(child)
+    child.sendline('\b' * 14 + 'sd_admin_test.pub')
+    verify_https_prompt(child)
+    # Default answer is no
+    child.sendline('')
+    verify_app_gpg_fingerprint_prompt(child)
+    child.sendline('1F544B31C845D698EB31F2FF364F1162D32E7E58')
+    verify_ossec_gpg_key_prompt(child)
+    child.sendline('\b' * 9 + 'sd_admin_test.pub')
+    verify_ossec_gpg_fingerprint_prompt(child)
+    child.sendline('1F544B31C845D698EB31F2FF364F1162D32E7E58')
+    verify_admin_email_prompt(child)
+    child.sendline('test@gmail.com')
+    verify_journalist_gpg_key_prompt(child)
+    child.sendline('')
+    verify_smtp_relay_prompt(child)
+    child.sendline('')
+    verify_smtp_port_prompt(child)
+    child.sendline('')
+    verify_sasl_domain_prompt(child)
+    child.sendline('')
+    verify_sasl_username_prompt(child)
+    child.sendline('testuser')
+    verify_sasl_password_prompt(child)
+    child.sendline('testpassword')
+    verify_ssh_over_lan_prompt(child)
+    child.sendline('')
+    verify_locales_prompt(child)
+    child.sendline('de_DE es_ES')
+    verify_v2_onion_for_first_time(child)
+    child.sendline('\b' * 3 + 'yes')
+    verify_v3_onion_when_v2_is_enabled(child)
+    child.sendline('\b' * 3 + 'no')
+
+    child.expect(pexpect.EOF, timeout=10)  # Wait for validation to occur
+    child.close()
+    assert child.exitstatus == 0
+    assert child.signalstatus is None
+
+    with open(os.path.join(SD_DIR, 'install_files/ansible-base/group_vars/all/site-specific')) as fobj:    # noqa: E501
+        data = fobj.read()
+    assert data == WHEN_ONLY_V2
+
+
 def test_sdconfig_enable_journalist_alerts():
     cmd = os.path.join(os.path.dirname(CURRENT_DIR),
                        'securedrop_admin/__init__.py')
@@ -361,6 +568,10 @@ def test_sdconfig_enable_journalist_alerts():
     child.sendline('')
     verify_locales_prompt(child)
     child.sendline('de_DE es_ES')
+    verify_v2_onion_for_first_time(child)
+    child.sendline('\b' * 3 + 'no')
+    verify_v3_onion_for_first_time(child)
+    child.sendline('\b' * 4 + 'yes')
 
     child.expect(pexpect.EOF, timeout=10)  # Wait for validation to occur
     child.close()
@@ -431,6 +642,10 @@ def test_sdconfig_enable_https_on_source_interface():
     child.sendline('')
     verify_locales_prompt(child)
     child.sendline('de_DE es_ES')
+    verify_v2_onion_for_first_time(child)
+    child.sendline('\b' * 3 + 'no')
+    verify_v3_onion_for_first_time(child)
+    child.sendline('\b' * 4 + 'yes')
 
     child.expect(pexpect.EOF, timeout=10)  # Wait for validation to occur
     child.close()
diff --git a/admin/tests/test_securedrop-admin.py b/admin/tests/test_securedrop-admin.py
--- a/admin/tests/test_securedrop-admin.py
+++ b/admin/tests/test_securedrop-admin.py
@@ -619,66 +619,6 @@ def test_save(self, tmpdir):
         """)
         assert expected == io.open(site_config_path).read()
 
-    def test_old_v2_onion_services(self, tmpdir):
-        "Checks for exitsing v2 source address"
-        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)
-        with open("app-source-ths", "w") as fobj:
-            fobj.write("aaaaaaaaaaaaaaaa.onion\n")
-        site_config.update_onion_version_config()
-        site_config.save()
-        data = ""
-        with open(site_config_path) as fobj:
-            data = fobj.read()
-        expected = textwrap.dedent("""\
-        v2_onion_services: true
-        v3_onion_services: true
-        """)
-        os.remove("app-source-ths")
-        assert expected == data
-
-    def test_no_v2_onion_services(self, tmpdir):
-        "Checks for new installation for only v3"
-        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.update_onion_version_config()
-        site_config.save()
-        data = ""
-        with open(site_config_path) as fobj:
-            data = fobj.read()
-        expected = textwrap.dedent("""\
-        v2_onion_services: false
-        v3_onion_services: true
-        """)
-        assert expected == data
-
-    def test_only_v3_onion_services(self, tmpdir):
-        "Checks for new installation for only v3 ths file"
-        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)
-        with open("app-sourcev3-ths", "w") as fobj:
-            fobj.write("a" * 56 + ".onion\n")
-        site_config.update_onion_version_config()
-        site_config.save()
-        data = ""
-        with open(site_config_path) as fobj:
-            data = fobj.read()
-        expected = textwrap.dedent("""\
-        v2_onion_services: false
-        v3_onion_services: true
-        """)
-        os.remove("app-sourcev3-ths")
-        assert expected == data
-
     def test_validate_gpg_key(self, caplog):
         args = argparse.Namespace(site_config='INVALID',
                                   ansible_path='tests/files',
@@ -817,6 +757,8 @@ def get_desc(self, site_config, var):
     def verify_desc_consistency_optional(self, site_config, desc):
         (var, default, etype, prompt, validator, transform, condition) = desc
         # verify the default passes validation
+        if callable(default):
+            default = default()
         assert site_config.user_prompt_config_one(desc, None) == default
         assert type(default) == etype
 
@@ -825,6 +767,12 @@ def verify_desc_consistency(self, site_config, desc):
         (var, default, etype, prompt, validator, transform, condition) = desc
         with pytest.raises(ValidationError):
             site_config.user_prompt_config_one(desc, '')
+            # If we are testing v3_onion_services, that will create a default
+            # value of 'yes', means it will not raise the ValidationError. We
+            # are generating it below for test to behave properly with all
+            # other test cases.
+            if var == "v3_onion_services":
+                raise ValidationError()
 
     def verify_prompt_boolean(
             self, site_config, desc):
@@ -835,6 +783,34 @@ def verify_prompt_boolean(
         assert site_config.user_prompt_config_one(desc, 'YES') is True
         assert site_config.user_prompt_config_one(desc, 'NO') is False
 
+    def verify_prompt_boolean_for_v3(
+            self, site_config, desc):
+        """As v3_onion_services input depends on input of
+           v2_onion_service, the answers will change.
+        """
+        self.verify_desc_consistency(site_config, desc)
+        (var, default, etype, prompt, validator, transform, condition) = desc
+        assert site_config.user_prompt_config_one(desc, True) is True
+        # Because if no v2_onion_service, v3 will become True
+        assert site_config.user_prompt_config_one(desc, False) is True
+        assert site_config.user_prompt_config_one(desc, 'YES') is True
+        # Because if no v2_onion_service, v3 will become True
+        assert site_config.user_prompt_config_one(desc, 'NO') is True
+
+        # Now we will set v2_onion_services as True so that we
+        # can set v3_onion_service as False. This is the case
+        # when an admin particularly marked v3 as False.
+        site_config._config_in_progress = {"v2_onion_services": True}
+        site_config.config = {"v3_onion_services": False}
+
+        # The next two tests should use the default from the above line,
+        # means it will return False value.
+        assert site_config.user_prompt_config_one(desc, True) is False
+        assert site_config.user_prompt_config_one(desc, 'YES') is False
+
+        assert site_config.user_prompt_config_one(desc, False) is False
+        assert site_config.user_prompt_config_one(desc, 'NO') is False
+
     def test_desc_conditional(self):
         """Ensure that conditional prompts behave correctly.
 
@@ -887,6 +863,8 @@ def auto_prompt(prompt, default, **kwargs):
     verify_prompt_enable_ssh_over_tor = verify_prompt_boolean
 
     verify_prompt_securedrop_app_gpg_public_key = verify_desc_consistency
+    verify_prompt_v2_onion_services = verify_prompt_boolean
+    verify_prompt_v3_onion_services = verify_prompt_boolean_for_v3
 
     def verify_prompt_not_empty(self, site_config, desc):
         with pytest.raises(ValidationError):