Repository: certbot/certbot

Issue:
Update options-ssl-nginx.conf
Add a mechanism for updating it, preferably taking into account that some people manually modify that file.


Discussion:
This will fix #921. More information at https://github.com/certbot/certbot/pull/4557#issuecomment-297845186.
Current idea: put information into docs/packaging.rst, so that package managers (which are Very Good At This Specific Problem) will handle it. But keep the backup install-on-run mechanism for certbot-auto and pip/master.

Note that this means that certbot-auto doesn't get updates.
If the package manager installs it to `/etc/letsencrypt`, we should check there before checking `config_dir`, so users with `--config_dir` specified get the benefits of updates.
I think we should think about what we do here carefully. Silently excluding large segments of the Certbot user base from TLS security updates without very loudly warning them about it first and giving them an alternative is probably a bad idea. Despite our frustration with it, I think this means we can't exclude `certbot-auto` as it is still the largest portion of Certbot users. Users of other packaging methods we may use such as `pyinstaller`  or distros we either don't communicate with or won't make this change (properly) will also be left out of security updates with this approach. Is this what we want?

Having distros solve this problem is the conventional approach, however, I personally expect getting everyone to do this will be more work than just solving the problem ourselves with a consistent approach across systems that supports whatever alternative packaging method such `certbot-auto`, `pip`, `pyinstaller`, `docker`, etc.

After thinking about this over the weekend, I'm personally in favor of adding a simple version of what distros do with modified configuration files ourselves. This could look something like maintaining a list of files/hashes of previous versions of the file, overwriting the file with the new version if it's in the list and creating a `options-ssl-nginx.conf.new` with a warning otherwise. This warning/behavior could be turned off with something like a `--no-tls-updates` flag. There's a couple of additional minor details to work out here, but I think it'd be fairly simple to implement.

@ohemorange, I'm happy to talk to you about this more or have a design meeting with everyone tomorrow or soonish.
Currently we also solve this group for a small subset of users. Making this change will expand that set. The only reason I can think not to make it would be that it precludes us from making the changes we need to make for the set to be as many users as possible (non-updating certbot-auto users, for example, just won't ever get the update, so I don't want to say "all users").

I think it would not be so simple to implement, because when does that happen? The point is for this to happen on renew, which is not a good time to be asking users if they want to overwrite a custom file. This is the sort of thing that should happen on installing a new version. I'm fine with writing a version of this for certbot-auto and also doing the method in the PR, but I don't think we should try for something universal.
The main reason I wanted us to pause and think about things here is to plan upfront how we expect this update mechanism to work for different installation methods.

My thought was we'd do what I described above during `prepare()` where we currently copy the file. My plan was to never prompt the user, but either silently update the file if there was no changes or print a warning about the `.conf.new` file. If there's a warning, we could easily print the warning on every run or just once.

My preference for a universal solution was because I think it'll take less time to implement and we'd have instructions that apply to all systems rather than having instructions be unique to the distro. With that said, if we have a somewhat simple and robust solution for the vast majority of our users, I don't care too much what we do here.

Let me know what you want to do.

By the way, what we're doing/talking about here apply to the Apache plugin in the same way. It'd be nice if we could make sure the problem is resolved there too either in the same or parallel PRs.
I think if we go the "only update if it hasn't changed and print a warning message otherwise" route, users who have manually modified the file will *never* get the updates. I don't know what the default is for running updates, but I'd personally rather see the file get overwritten by default and then people have to go in and make the manual changes again. I think that's safer than otherwise.

Which, I suppose we could achieve that by having it automatically update to the new one and saving a backup of the old one. I'd be fine with doing that.
(and good point about Apache. it should probably be straightforward and parallel to do the same thing there that we do here, but if I'm wrong because of some strange things that apache does of a sort similar to what you were telling me about for the parsing stuff the other day, let me know.)
I personally don't like the idea of overwriting changes that a user made manually. Do we know better than the sysadmin? Maybe, but I think assuming that is a good way to frustrate users and break things. What if they made a necessary change that to make Certbot work for their use case? If we overwrite the file, we may break their server. I also think a very small minority will ever edit the file in `/etc/letsencrypt`.

For reference, I looked into how Debian/Ubuntu and Arch deal with this problem being the two systems I'm most familiar with. Debian/Ubuntu will prompt you but if it's unable to do so (because it's being run through something like [UnattendedUpgrades](https://wiki.debian.org/UnattendedUpgrades)) it will just refuse to install the new package by default. A user can configure alternative behavior though. Arch Linux creates a [.pacnew](https://wiki.archlinux.org/index.php/Pacman/Pacnew_and_Pacsave) file containing the new contents which the user can configure manually if they so desire.

> (and good point about Apache. it should probably be straightforward and parallel to do the same thing there that we do here, but if I'm wrong because of some strange things that apache does of a sort similar to what you were telling me about for the parsing stuff the other day, let me know.)

Apache should be identical (other than the file being named `options-ssl-apache.conf`). I'll definitely help with any strange Apacheisms if they come up.
ok I suppose that's fair, literally breaking people's servers because they're insecure isn't quite fashionable yet. maybe in a few years.
I still think we could break fewer people by sticking the interactivity into the upgrade, where people are infinitely more likely to see it than interactivity in renew. I think putting it in `prepare` will just mean that if you've manually edited the file, you don't get security updates. We could stick a note in there to that effect, but that's not ideal.
lol what if we put it in both places?
> I still think we could break fewer people by sticking the interactivity into the upgrade, where people are infinitely more likely to see it than interactivity in renew.

To be clear, I wasn't proposing making the `prepare` code interactive. I supposed we could do this if we wanted but we'd have to use the fallbacks to being non-interactive.

> We could stick a note in there to that effect, but that's not ideal.

That's not a bad idea. We can put a loud warning in the file explaining how/when they will/won't get updates so people who modify the file are aware of the problem. We probably shouldn't do this (or be much more vague) if we're having OS package maintainers handle this as how the file is updated will vary quite a bit.

> lol what if we put it in both places?

If it's designed carefully we could. I just wouldn't want what OS packagers do and Certbot's code interacting in strange or obnoxious ways.
want to hear a terrible idea?

we could use git to check if there are merge conflicts, and if there aren't (even if it's edited), just update it silently.
We'd have to add a new dependency on `git` and/or a `git` Python library. Is your instinct we should try to silently update the file even if the user updated it? My instinct is this is something we shouldn't do and we should attempt to notify the user about the changes we wanted to make instead.
Yeah, that's the idea. I just super don't believe that notifying people will actually cause any change, and if we don't try as hard as we can to force people to update, they won't. Even if we put a comment -- if I had to delete a line to get it to work, I am never changing that because it will be super annoying and it works perfectly fine right now.

The dependency is not ideal, but we could do a homebrew "check for merge conflict" thing. Which just thinking about it does not sound pretty.
I suspect we're talking about a small minority of users here as I expect most don't even know the file exists and even fewer decide that modifying the TLS settings provided by Certbot is a good idea.

> if I had to delete a line to get it to work, I am never changing that because it will be super annoying and it works perfectly fine right now.

If you're the user who had a headache trying to get things to work and are happy with things, do you want us to force a change on you? Again, I don't think we always know better than the user about their specific needs and use case. I think we should try to help them and guide them towards the Right Thing™, but if they want or need to deviate for some reason, I think they should be able to without continually fighting us.

I also think merging config files has the possibility to cause problems. In a simple case, the user adds:
```
some_option_that_cant_be_duplicated on;
```
to the top of the file. Later we add the same to the bottom, the merge is fine, and Nginx blows up complaining about duplicate directives.

Additionally, while I don't have an example of a problem, a mix and match of TLS settings created by a general purpose merge tool scares me a bit. Given all the possible settings and values they can take, could mixing them with our own settings cause a problem? I think they could.
Ok I agree that this solution is even worse than not doing anything.


Do we have a warning queue sitting around in a dark corner of the codebase? Like, if an error comes up when we don't have a terminal, we print it the first time that we do?
That is (luckily?) a dark corner that Certbot doesn't have. We could write such a monster, but it'd be more of the "does stdout look like a terminal?" code.

I'm personally still in favor of putting scary warnings in comments of the files explaining that if you modify them you won't get automatic security updates. Again, I think we're talking about very few users here and there's only so much we can do to stop them from shooting themselves in the foot. I think this approach is likely to cause the fewest angry bug reports, break the fewest things, and follows conventions established by package managers that sysadmins are used to. We still can and should make noise about their being TLS updates available, but I don't think any user will be frustrated that Certbot didn't do more than this approach. I think it's as doing as much as we can without potentially going against the user's wishes and breaking things.
I still don't like doing it this way, because it means we'll be leaving out a whole category of people from getting security updates.

But at this point, I think it's the least terrible way we're likely to find.
My instinct is we did all we reasonably could to warn them that the actions they were taking opted them out of security updates.

I had another idea too that I left out of my last post for brevity and because I don't like it as much, but we could add a warning saying that the contents of the file will be overwritten unless you add `nginx-no-tls-updates = True` to add a `cli.ini` file in the same directory as the options. The problems I see with this approach are:

1. What about people who already modified the file?
2. Adding `--nginx-no-tls-updates` in any other way is extremely error prone. Running `certbot` just once, even if the Nginx plugin isn't used, may cause the file to be overwritten.
3. It's less conventional for sysadmins.
4. It's more aggressive and likely to break things.
5. For people that ignore and don't read our warnings, my instinct is to leave them alone and not force upgrades on them. This flips that behavior but may be what you want.

I personally like relying on distros and doing something special in `certbot-auto` better than that, but could maybe go down this route if problem 1 was reasonably addressed.
Yeah, I don't think adding another thing to configure is a solution to this problem.

Resolving patch:
diff --git a/certbot-nginx/certbot_nginx/configurator.py b/certbot-nginx/certbot_nginx/configurator.py
--- a/certbot-nginx/certbot_nginx/configurator.py
+++ b/certbot-nginx/certbot_nginx/configurator.py
@@ -139,6 +139,11 @@ def mod_ssl_conf(self):
         """Full absolute path to SSL configuration file."""
         return os.path.join(self.config.config_dir, constants.MOD_SSL_CONF_DEST)
 
+    @property
+    def updated_mod_ssl_conf_digest(self):
+        """Full absolute path to digest of updated SSL configuration file."""
+        return os.path.join(self.config.config_dir, constants.UPDATED_MOD_SSL_CONF_DIGEST)
+
     # This is called in determine_authenticator and determine_installer
     def prepare(self):
         """Prepare the authenticator/installer.
@@ -156,7 +161,7 @@ def prepare(self):
 
         self.parser = parser.NginxParser(self.conf('server-root'))
 
-        install_ssl_options_conf(self.mod_ssl_conf)
+        install_ssl_options_conf(self.mod_ssl_conf, self.updated_mod_ssl_conf_digest)
 
         # Set Version
         if self.version is None:
@@ -862,8 +867,38 @@ def nginx_restart(nginx_ctl, nginx_conf):
     time.sleep(1)
 
 
-def install_ssl_options_conf(options_ssl):
+def install_ssl_options_conf(options_ssl, options_ssl_digest):
     """Copy Certbot's SSL options file into the system's config dir if required."""
+    def _write_current_hash():
+        with open(options_ssl_digest, "w") as f:
+            f.write(constants.CURRENT_SSL_OPTIONS_HASH)
+
+    def _install_current_file():
+        shutil.copyfile(constants.MOD_SSL_CONF_SRC, options_ssl)
+        _write_current_hash()
+
     # Check to make sure options-ssl.conf is installed
     if not os.path.isfile(options_ssl):
-        shutil.copyfile(constants.MOD_SSL_CONF_SRC, options_ssl)
+        _install_current_file()
+        return
+    # there's already a file there. if it exactly matches a previous file hash,
+    # we can update it. otherwise, print a warning once per new version.
+    active_file_digest = crypto_util.sha256sum(options_ssl)
+    if active_file_digest in constants.PREVIOUS_SSL_OPTIONS_HASHES: # safe to update
+        _install_current_file()
+    elif active_file_digest == constants.CURRENT_SSL_OPTIONS_HASH: # already up to date
+        return
+    else: # has been manually modified, not safe to update
+        # did they modify the current version or an old version?
+        if os.path.isfile(options_ssl_digest):
+            with open(options_ssl_digest, "r") as f:
+                saved_digest = f.read()
+            # they modified it after we either installed or told them about this version, so return
+            if saved_digest == constants.CURRENT_SSL_OPTIONS_HASH:
+                return
+        # there's a new version but we couldn't update the file, or they deleted the digest.
+        # save the current digest so we only print this once, and print a warning
+        _write_current_hash()
+        logger.warning("%s has been manually modified; updated ssl configuration options "
+            "saved to %s. We recommend updating %s for security purposes.",
+            options_ssl, constants.MOD_SSL_CONF_SRC, options_ssl)
diff --git a/certbot-nginx/certbot_nginx/constants.py b/certbot-nginx/certbot_nginx/constants.py
--- a/certbot-nginx/certbot_nginx/constants.py
+++ b/certbot-nginx/certbot_nginx/constants.py
@@ -17,6 +17,21 @@
 """Path to the nginx mod_ssl config file found in the Certbot
 distribution."""
 
+UPDATED_MOD_SSL_CONF_DIGEST = ".updated-options-ssl-nginx-conf-digest.txt"
+"""Name of the hash of the updated or informed mod_ssl_conf as saved in `IConfig.config_dir`."""
+
+
+PREVIOUS_SSL_OPTIONS_HASHES = [
+    '0f81093a1465e3d4eaa8b0c14e77b2a2e93568b0fc1351c2b87893a95f0de87c',
+    '9a7b32c49001fed4cff8ad24353329472a50e86ade1ef9b2b9e43566a619612e',
+    'a6d9f1c7d6b36749b52ba061fff1421f9a0a3d2cfdafbd63c05d06f65b990937',
+    '7f95624dd95cf5afc708b9f967ee83a24b8025dc7c8d9df2b556bbc64256b3ff',
+]
+"""SHA256 hashes of the contents of previous versions of MOD_SSL_CONF_SRC"""
+
+CURRENT_SSL_OPTIONS_HASH = '394732f2bbe3e5e637c3fb5c6e980a1f1b90b01e2e8d6b7cff41dde16e2a756d'
+"""SHA256 hash of the current contents of MOD_SSL_CONF_SRC"""
+
 def os_constant(key):
     # XXX TODO: In the future, this could return different constants
     #           based on what OS we are running under.  To see an
diff --git a/certbot/crypto_util.py b/certbot/crypto_util.py
--- a/certbot/crypto_util.py
+++ b/certbot/crypto_util.py
@@ -4,6 +4,7 @@
     is capable of handling the signatures.
 
 """
+import hashlib
 import logging
 import os
 
@@ -353,3 +354,17 @@ def _notAfterBefore(cert_path, method):
     if six.PY3:
         timestamp_str = timestamp_str.decode('ascii')
     return pyrfc3339.parse(timestamp_str)
+
+
+def sha256sum(filename):
+    """Compute a sha256sum of a file.
+
+    :param str filename: path to the file whose hash will be computed
+
+    :returns: sha256 digest of the file in hexadecimal
+    :rtype: str
+    """
+    sha256 = hashlib.sha256()
+    with open(filename, 'rb') as f:
+        sha256.update(f.read())
+    return sha256.hexdigest()


Test patch:
diff --git a/certbot-nginx/certbot_nginx/tests/configurator_test.py b/certbot-nginx/certbot_nginx/tests/configurator_test.py
--- a/certbot-nginx/certbot_nginx/tests/configurator_test.py
+++ b/certbot-nginx/certbot_nginx/tests/configurator_test.py
@@ -11,9 +11,11 @@
 from acme import messages
 
 from certbot import achallenges
+from certbot import crypto_util
 from certbot import errors
 from certbot.tests import util as certbot_test_util
 
+from certbot_nginx import constants
 from certbot_nginx import obj
 from certbot_nginx import parser
 from certbot_nginx.tests import util
@@ -537,6 +539,79 @@ def test_staple_ocsp(self):
         self.assertTrue(util.contains_at_depth(
             generated_conf, ['ssl_stapling_verify', 'on'], 2))
 
+class InstallSslOptionsConfTest(util.NginxTest):
+    """Test that the options-ssl-nginx.conf file is installed and updated properly."""
+
+    def setUp(self):
+        super(InstallSslOptionsConfTest, self).setUp()
+
+        self.config = util.get_nginx_configurator(
+            self.config_path, self.config_dir, self.work_dir, self.logs_dir)
+
+    def _call(self):
+        from certbot_nginx.configurator import install_ssl_options_conf
+        install_ssl_options_conf(self.config.mod_ssl_conf, self.config.updated_mod_ssl_conf_digest)
+
+    def _assert_current_file(self):
+        """If this is failing, remember that constants.PREVIOUS_SSL_OPTIONS_HASHES and
+           constants.CURRENT_SSL_OPTIONS_HASH must be updated when self.config.mod_ssl_conf
+           is updated. Add CURRENT_SSL_OPTIONS_HASH to PREVIOUS_SSL_OPTIONS_HASHES and set
+           CURRENT_SSL_OPTIONS_HASH to the hash of the updated self.config.mod_ssl_conf."""
+        self.assertTrue(os.path.isfile(self.config.mod_ssl_conf))
+        from certbot_nginx.constants import CURRENT_SSL_OPTIONS_HASH
+        self.assertEqual(crypto_util.sha256sum(self.config.mod_ssl_conf), CURRENT_SSL_OPTIONS_HASH)
+
+    def test_no_file(self):
+        # prepare should have placed a file there
+        self._assert_current_file()
+        os.remove(self.config.mod_ssl_conf)
+        self.assertFalse(os.path.isfile(self.config.mod_ssl_conf))
+        self._call()
+        self._assert_current_file()
+
+    def test_current_file(self):
+        self._assert_current_file()
+        self._call()
+        self._assert_current_file()
+
+    def test_prev_file_updates_to_current(self):
+        from certbot_nginx.constants import PREVIOUS_SSL_OPTIONS_HASHES
+        with mock.patch('certbot.crypto_util.sha256sum') as mock_sha256:
+            mock_sha256.return_value = PREVIOUS_SSL_OPTIONS_HASHES[0]
+            self._call()
+        self._assert_current_file()
+
+    def test_manually_modified_current_file_does_not_update(self):
+        with open(self.config.mod_ssl_conf, "a") as mod_ssl_conf:
+            mod_ssl_conf.write("a new line for the wrong hash\n")
+        with mock.patch("certbot_nginx.configurator.logger") as mock_logger:
+            self._call()
+            self.assertFalse(mock_logger.warning.called)
+        self.assertTrue(os.path.isfile(self.config.mod_ssl_conf))
+        from certbot_nginx.constants import CURRENT_SSL_OPTIONS_HASH
+        self.assertEqual(crypto_util.sha256sum(constants.MOD_SSL_CONF_SRC),
+            CURRENT_SSL_OPTIONS_HASH)
+        self.assertNotEqual(crypto_util.sha256sum(self.config.mod_ssl_conf),
+            CURRENT_SSL_OPTIONS_HASH)
+
+    def test_manually_modified_past_file_warns(self):
+        with open(self.config.mod_ssl_conf, "a") as mod_ssl_conf:
+            mod_ssl_conf.write("a new line for the wrong hash\n")
+        with open(self.config.updated_mod_ssl_conf_digest, "w") as f:
+            f.write("hashofanoldversion")
+        with mock.patch("certbot_nginx.configurator.logger") as mock_logger:
+            self._call()
+            self.assertEqual(mock_logger.warning.call_args[0][0],
+                "%s has been manually modified; updated ssl configuration options "
+                "saved to %s. We recommend updating %s for security purposes.")
+        from certbot_nginx.constants import CURRENT_SSL_OPTIONS_HASH
+        self.assertEqual(crypto_util.sha256sum(constants.MOD_SSL_CONF_SRC),
+            CURRENT_SSL_OPTIONS_HASH)
+        # only print warning once
+        with mock.patch("certbot_nginx.configurator.logger") as mock_logger:
+            self._call()
+            self.assertFalse(mock_logger.warning.called)
+
 
 if __name__ == "__main__":
     unittest.main()  # pragma: no cover
diff --git a/certbot-nginx/certbot_nginx/tests/util.py b/certbot-nginx/certbot_nginx/tests/util.py
--- a/certbot-nginx/certbot_nginx/tests/util.py
+++ b/certbot-nginx/certbot_nginx/tests/util.py
@@ -16,7 +16,6 @@
 
 from certbot.plugins import common
 
-from certbot_nginx import constants
 from certbot_nginx import configurator
 from certbot_nginx import nginxparser
 
@@ -30,10 +29,6 @@ def setUp(self):
             "etc_nginx", "certbot_nginx.tests")
         self.logs_dir = tempfile.mkdtemp('logs')
 
-        self.ssl_options = common.setup_ssl_options(
-            self.config_dir, constants.MOD_SSL_CONF_SRC,
-            constants.MOD_SSL_CONF_DEST)
-
         self.config_path = os.path.join(self.temp_dir, "etc_nginx")
 
         self.rsa512jwk = jose.JWKRSA.load(test_util.load_vector(
diff --git a/certbot/tests/crypto_util_test.py b/certbot/tests/crypto_util_test.py
--- a/certbot/tests/crypto_util_test.py
+++ b/certbot/tests/crypto_util_test.py
@@ -293,5 +293,14 @@ def test_notAfter(self):
                          '2014-12-18T22:34:45+00:00')
 
 
+class Sha256sumTest(unittest.TestCase):
+    """Tests for certbot.crypto_util.notAfter"""
+
+    def test_sha256sum(self):
+        from certbot.crypto_util import sha256sum
+        self.assertEqual(sha256sum(CERT_PATH),
+            '914ffed8daf9e2c99d90ac95c77d54f32cbd556672facac380f0c063498df84e')
+
+
 if __name__ == '__main__':
     unittest.main()  # pragma: no cover