Repository: certbot/certbot

Issue:
Fedora apachectl 2.4.39 no longer supports passing arguments to httpd
## My operating system is (include version):
Fedora 29

## I installed Certbot with (certbot-auto, OS package manager, pip, etc):
OS package manager
Additionally: httpd-2.4.39

## I ran this command and it produced this output:
certbot renew --apache
apachectl: The "-v" option is not supported.

## Certbot's behavior differed from what I expected because:
certbot gets invalid output from apachectl

## Here is a Certbot log showing the issue (if available):
/usr/sbin/apachectl doesn't support -v, nor -t -D DUMP_RUN_CFG, DUMP_INCLUDES, DUMP_MODULES

Hacking apachectl (or a different one in the path) to specifically allow these options makes it work.

Running httpd -v or -t -D DUMP_RUN_CFG does work, apachectl doesn't.

## Here is the relevant nginx server block or Apache virtualhost for the domain I am configuring:
not relevant, change to /usr/sbin/apachectl, probably in 2.4.39.  httpd (and config) is fine, apachectl is not.

not sure if I should file a bug here, or with Fedora

Issue #5439 seems very similar, but appears to be for restart of apache only.  This issue occurs before the certificate -- certbot is still verifying if apache is installed.


Discussion:
Yes same problem. I tried replacing apachectl with a link to httpd but that did not work. Change was made with recent apache 2.4.39 update.

Never knew the hassle updating certificates manually would be. I cannot renew the old certificate either as certbot does not ask me if I wanna spin up a temporary webserver when I want to renew, only when using certbot certonly.
@joohoi looks like something's changed with Apache.
https://src.fedoraproject.org/rpms/httpd/c/b86b48c4a2a3293c0e9a8cb74e01f1827c3be904?branch=master
perhaps?
Same problem here (Fedora 29 - Apache: httpd-2.4.39-2.fc29.x86_64):
```
Cert is due for renewal, auto-renewing...
Error while running apachectl -v.

apachectl: The "-v" option is not supported.
```
Workaround:
```
dnf downgrade httpd -y
certbot renew
dnf upgrade httpd -y
```
I ran into the same issue in my environment and fixed it with the following hack steps **while maintaining the current apache version**:
1: In override_centos.py, I changed line 21 to ctl="httpd"
2: In override_centos.py, I changed line 22 to version_cmd=['httpd', '-v']
3: In configurator.py, I commented out lines 135, 136, and 137

I then re-ran letsencrypt (aka certbot) and everything was successful! Hopefully this helps someone who understands all this code better than I do make a real fix, but at least there is a hack solution in the meantime, and this should continue to work for future versions of apache.

Resolving patch:
diff --git a/certbot-apache/certbot_apache/entrypoint.py b/certbot-apache/certbot_apache/entrypoint.py
--- a/certbot-apache/certbot_apache/entrypoint.py
+++ b/certbot-apache/certbot_apache/entrypoint.py
@@ -1,8 +1,13 @@
 """ Entry point for Apache Plugin """
+# Pylint does not like disutils.version when running inside a venv.
+# See: https://github.com/PyCQA/pylint/issues/73
+from distutils.version import LooseVersion  # pylint: disable=no-name-in-module,import-error
+
 from certbot import util
 
 from certbot_apache import configurator
 from certbot_apache import override_arch
+from certbot_apache import override_fedora
 from certbot_apache import override_darwin
 from certbot_apache import override_debian
 from certbot_apache import override_centos
@@ -16,7 +21,8 @@
     "ubuntu": override_debian.DebianConfigurator,
     "centos": override_centos.CentOSConfigurator,
     "centos linux": override_centos.CentOSConfigurator,
-    "fedora": override_centos.CentOSConfigurator,
+    "fedora_old": override_centos.CentOSConfigurator,
+    "fedora": override_fedora.FedoraConfigurator,
     "ol": override_centos.CentOSConfigurator,
     "red hat enterprise linux server": override_centos.CentOSConfigurator,
     "rhel": override_centos.CentOSConfigurator,
@@ -27,12 +33,19 @@
     "suse": override_suse.OpenSUSEConfigurator,
 }
 
+
 def get_configurator():
     """ Get correct configurator class based on the OS fingerprint """
-    os_info = util.get_os_info()
+    os_name, os_version = util.get_os_info()
+    os_name = os_name.lower()
     override_class = None
+
+    # Special case for older Fedora versions
+    if os_name == 'fedora' and LooseVersion(os_version) < LooseVersion('29'):
+        os_name = 'fedora_old'
+
     try:
-        override_class = OVERRIDE_CLASSES[os_info[0].lower()]
+        override_class = OVERRIDE_CLASSES[os_name]
     except KeyError:
         # OS not found in the list
         os_like = util.get_systemd_os_like()
@@ -45,4 +58,5 @@ def get_configurator():
             override_class = configurator.ApacheConfigurator
     return override_class
 
+
 ENTRYPOINT = get_configurator()
diff --git a/certbot-apache/certbot_apache/override_fedora.py b/certbot-apache/certbot_apache/override_fedora.py
new file mode 100644
--- /dev/null
+++ b/certbot-apache/certbot_apache/override_fedora.py
@@ -0,0 +1,98 @@
+""" Distribution specific override class for Fedora 29+ """
+import pkg_resources
+import zope.interface
+
+from certbot import errors
+from certbot import interfaces
+from certbot import util
+
+from certbot_apache import apache_util
+from certbot_apache import configurator
+from certbot_apache import parser
+
+
+@zope.interface.provider(interfaces.IPluginFactory)
+class FedoraConfigurator(configurator.ApacheConfigurator):
+    """Fedora 29+ specific ApacheConfigurator override class"""
+
+    OS_DEFAULTS = dict(
+        server_root="/etc/httpd",
+        vhost_root="/etc/httpd/conf.d",
+        vhost_files="*.conf",
+        logs_root="/var/log/httpd",
+        ctl="httpd",
+        version_cmd=['httpd', '-v'],
+        restart_cmd=['apachectl', 'graceful'],
+        restart_cmd_alt=['apachectl', 'restart'],
+        conftest_cmd=['apachectl', 'configtest'],
+        enmod=None,
+        dismod=None,
+        le_vhost_ext="-le-ssl.conf",
+        handle_modules=False,
+        handle_sites=False,
+        challenge_location="/etc/httpd/conf.d",
+        MOD_SSL_CONF_SRC=pkg_resources.resource_filename(
+            # TODO: eventually newest version of Fedora will need their own config
+            "certbot_apache", "centos-options-ssl-apache.conf")
+    )
+
+    def config_test(self):
+        """
+        Override config_test to mitigate configtest error in vanilla installation
+        of mod_ssl in Fedora. The error is caused by non-existent self-signed
+        certificates referenced by the configuration, that would be autogenerated
+        during the first (re)start of httpd.
+        """
+        try:
+            super(FedoraConfigurator, self).config_test()
+        except errors.MisconfigurationError:
+            self._try_restart_fedora()
+
+    def get_parser(self):
+        """Initializes the ApacheParser"""
+        return FedoraParser(
+            self.aug, self.option("server_root"), self.option("vhost_root"),
+            self.version, configurator=self)
+
+    def _try_restart_fedora(self):
+        """
+        Tries to restart httpd using systemctl to generate the self signed keypair.
+        """
+        try:
+            util.run_script(['systemctl', 'restart', 'httpd'])
+        except errors.SubprocessError as err:
+            raise errors.MisconfigurationError(str(err))
+
+        # Finish with actual config check to see if systemctl restart helped
+        super(FedoraConfigurator, self).config_test()
+
+    def _prepare_options(self):
+        """
+        Override the options dictionary initialization to keep using apachectl
+        instead of httpd and so take advantages of this new bash script in newer versions
+        of Fedora to restart httpd.
+        """
+        super(FedoraConfigurator, self)._prepare_options()
+        self.options["restart_cmd"][0] = 'apachectl'
+        self.options["restart_cmd_alt"][0] = 'apachectl'
+        self.options["conftest_cmd"][0] = 'apachectl'
+
+
+class FedoraParser(parser.ApacheParser):
+    """Fedora 29+ specific ApacheParser override class"""
+    def __init__(self, *args, **kwargs):
+        # Fedora 29+ specific configuration file for Apache
+        self.sysconfig_filep = "/etc/sysconfig/httpd"
+        super(FedoraParser, self).__init__(*args, **kwargs)
+
+    def update_runtime_variables(self):
+        """ Override for update_runtime_variables for custom parsing """
+        # Opportunistic, works if SELinux not enforced
+        super(FedoraParser, self).update_runtime_variables()
+        self._parse_sysconfig_var()
+
+    def _parse_sysconfig_var(self):
+        """ Parses Apache CLI options from Fedora configuration file """
+        defines = apache_util.parse_define_file(self.sysconfig_filep, "OPTIONS")
+        for k in defines:
+            self.variables[k] = defines[k]
diff --git a/certbot/util.py b/certbot/util.py
--- a/certbot/util.py
+++ b/certbot/util.py
@@ -323,7 +323,7 @@ def get_os_info(filepath="/etc/os-release"):
         # Systemd os-release parsing might be viable
         os_name, os_version = get_systemd_os_info(filepath=filepath)
         if os_name:
-            return (os_name, os_version)
+            return os_name, os_version
 
     # Fallback to platform module
     return get_python_os_info()


Test patch:
diff --git a/certbot-apache/certbot_apache/tests/centos_test.py b/certbot-apache/certbot_apache/tests/centos_test.py
--- a/certbot-apache/certbot_apache/tests/centos_test.py
+++ b/certbot-apache/certbot_apache/tests/centos_test.py
@@ -43,13 +43,14 @@ def setUp(self):  # pylint: disable=arguments-differ
                                              vhost_root=vhost_root)
         self.config = util.get_apache_configurator(
             self.config_path, self.vhost_path, self.config_dir, self.work_dir,
-            os_info="fedora")
+            os_info="fedora_old")
         self.vh_truth = get_vh_truth(
             self.temp_dir, "centos7_apache/apache")
 
     def _run_fedora_test(self):
+        self.assertIsInstance(self.config, override_centos.CentOSConfigurator)
         with mock.patch("certbot.util.get_os_info") as mock_info:
-            mock_info.return_value = ["fedora"]
+            mock_info.return_value = ["fedora", "28"]
             self.config.config_test()
 
     def test_non_fedora_error(self):
@@ -103,8 +104,7 @@ def setUp(self):  # pylint: disable=arguments-differ
             self.temp_dir, "centos7_apache/apache")
 
     def test_get_parser(self):
-        self.assertTrue(isinstance(self.config.parser,
-                                   override_centos.CentOSParser))
+        self.assertIsInstance(self.config.parser, override_centos.CentOSParser)
 
     @mock.patch("certbot_apache.parser.ApacheParser._get_runtime_cfg")
     def test_opportunistic_httpd_runtime_parsing(self, mock_get):
diff --git a/certbot-apache/certbot_apache/tests/entrypoint_test.py b/certbot-apache/certbot_apache/tests/entrypoint_test.py
--- a/certbot-apache/certbot_apache/tests/entrypoint_test.py
+++ b/certbot-apache/certbot_apache/tests/entrypoint_test.py
@@ -6,6 +6,7 @@
 from certbot_apache import configurator
 from certbot_apache import entrypoint
 
+
 class EntryPointTest(unittest.TestCase):
     """Entrypoint tests"""
 
@@ -15,7 +16,12 @@ def test_get_configurator(self):
 
         with mock.patch("certbot.util.get_os_info") as mock_info:
             for distro in entrypoint.OVERRIDE_CLASSES:
-                mock_info.return_value = (distro, "whatever")
+                return_value = (distro, "whatever")
+                if distro == 'fedora_old':
+                    return_value = ('fedora', '28')
+                elif distro == 'fedora':
+                    return_value = ('fedora', '29')
+                mock_info.return_value = return_value
                 self.assertEqual(entrypoint.get_configurator(),
                                  entrypoint.OVERRIDE_CLASSES[distro])
 
diff --git a/certbot-apache/certbot_apache/tests/fedora_test.py b/certbot-apache/certbot_apache/tests/fedora_test.py
new file mode 100644
--- /dev/null
+++ b/certbot-apache/certbot_apache/tests/fedora_test.py
@@ -0,0 +1,194 @@
+"""Test for certbot_apache.configurator for Fedora 29+ overrides"""
+import unittest
+
+import mock
+
+from certbot import errors
+from certbot.compat import os
+
+from certbot_apache import obj
+from certbot_apache import override_fedora
+from certbot_apache.tests import util
+
+
+def get_vh_truth(temp_dir, config_name):
+    """Return the ground truth for the specified directory."""
+    prefix = os.path.join(
+        temp_dir, config_name, "httpd/conf.d")
+
+    aug_pre = "/files" + prefix
+    # TODO: eventually, these tests should have a dedicated configuration instead
+    #  of reusing the ones from centos_test
+    vh_truth = [
+        obj.VirtualHost(
+            os.path.join(prefix, "centos.example.com.conf"),
+            os.path.join(aug_pre, "centos.example.com.conf/VirtualHost"),
+            {obj.Addr.fromstring("*:80")},
+            False, True, "centos.example.com"),
+        obj.VirtualHost(
+            os.path.join(prefix, "ssl.conf"),
+            os.path.join(aug_pre, "ssl.conf/VirtualHost"),
+            {obj.Addr.fromstring("_default_:443")},
+            True, True, None)
+    ]
+    return vh_truth
+
+
+class FedoraRestartTest(util.ApacheTest):
+    """Tests for Fedora specific self-signed certificate override"""
+
+    # TODO: eventually, these tests should have a dedicated configuration instead
+    #  of reusing the ones from centos_test
+    def setUp(self):  # pylint: disable=arguments-differ
+        test_dir = "centos7_apache/apache"
+        config_root = "centos7_apache/apache/httpd"
+        vhost_root = "centos7_apache/apache/httpd/conf.d"
+        super(FedoraRestartTest, self).setUp(test_dir=test_dir,
+                                             config_root=config_root,
+                                             vhost_root=vhost_root)
+        self.config = util.get_apache_configurator(
+            self.config_path, self.vhost_path, self.config_dir, self.work_dir,
+            os_info="fedora")
+        self.vh_truth = get_vh_truth(
+            self.temp_dir, "centos7_apache/apache")
+
+    def _run_fedora_test(self):
+        self.assertIsInstance(self.config, override_fedora.FedoraConfigurator)
+        self.config.config_test()
+
+    def test_fedora_restart_error(self):
+        c_test = "certbot_apache.configurator.ApacheConfigurator.config_test"
+        with mock.patch(c_test) as mock_test:
+            # First call raises error, second doesn't
+            mock_test.side_effect = [errors.MisconfigurationError, '']
+            with mock.patch("certbot.util.run_script") as mock_run:
+                mock_run.side_effect = errors.SubprocessError
+                self.assertRaises(errors.MisconfigurationError,
+                                  self._run_fedora_test)
+
+    def test_fedora_restart(self):
+        c_test = "certbot_apache.configurator.ApacheConfigurator.config_test"
+        with mock.patch(c_test) as mock_test:
+            with mock.patch("certbot.util.run_script") as mock_run:
+                # First call raises error, second doesn't
+                mock_test.side_effect = [errors.MisconfigurationError, '']
+                self._run_fedora_test()
+                self.assertEqual(mock_test.call_count, 2)
+                self.assertEqual(mock_run.call_args[0][0],
+                                ['systemctl', 'restart', 'httpd'])
+
+
+class MultipleVhostsTestFedora(util.ApacheTest):
+    """Multiple vhost tests for CentOS / RHEL family of distros"""
+
+    _multiprocess_can_split_ = True
+
+    def setUp(self):  # pylint: disable=arguments-differ
+        test_dir = "centos7_apache/apache"
+        config_root = "centos7_apache/apache/httpd"
+        vhost_root = "centos7_apache/apache/httpd/conf.d"
+        super(MultipleVhostsTestFedora, self).setUp(test_dir=test_dir,
+                                                    config_root=config_root,
+                                                    vhost_root=vhost_root)
+
+        self.config = util.get_apache_configurator(
+            self.config_path, self.vhost_path, self.config_dir, self.work_dir,
+            os_info="fedora")
+        self.vh_truth = get_vh_truth(
+            self.temp_dir, "centos7_apache/apache")
+
+    def test_get_parser(self):
+        self.assertIsInstance(self.config.parser, override_fedora.FedoraParser)
+
+    @mock.patch("certbot_apache.parser.ApacheParser._get_runtime_cfg")
+    def test_opportunistic_httpd_runtime_parsing(self, mock_get):
+        define_val = (
+            'Define: TEST1\n'
+            'Define: TEST2\n'
+            'Define: DUMP_RUN_CFG\n'
+        )
+        mod_val = (
+            'Loaded Modules:\n'
+            ' mock_module (static)\n'
+            ' another_module (static)\n'
+        )
+        def mock_get_cfg(command):
+            """Mock httpd process stdout"""
+            if command == ['httpd', '-t', '-D', 'DUMP_RUN_CFG']:
+                return define_val
+            elif command == ['httpd', '-t', '-D', 'DUMP_MODULES']:
+                return mod_val
+            return ""
+        mock_get.side_effect = mock_get_cfg
+        self.config.parser.modules = set()
+        self.config.parser.variables = {}
+
+        with mock.patch("certbot.util.get_os_info") as mock_osi:
+            # Make sure we have the have the CentOS httpd constants
+            mock_osi.return_value = ("fedora", "29")
+            self.config.parser.update_runtime_variables()
+
+        self.assertEqual(mock_get.call_count, 3)
+        self.assertEqual(len(self.config.parser.modules), 4)
+        self.assertEqual(len(self.config.parser.variables), 2)
+        self.assertTrue("TEST2" in self.config.parser.variables.keys())
+        self.assertTrue("mod_another.c" in self.config.parser.modules)
+
+    @mock.patch("certbot_apache.configurator.util.run_script")
+    def test_get_version(self, mock_run_script):
+        mock_run_script.return_value = ('', None)
+        self.assertRaises(errors.PluginError, self.config.get_version)
+        self.assertEqual(mock_run_script.call_args[0][0][0], 'httpd')
+
+    def test_get_virtual_hosts(self):
+        """Make sure all vhosts are being properly found."""
+        vhs = self.config.get_virtual_hosts()
+        self.assertEqual(len(vhs), 2)
+        found = 0
+
+        for vhost in vhs:
+            for centos_truth in self.vh_truth:
+                if vhost == centos_truth:
+                    found += 1
+                    break
+            else:
+                raise Exception("Missed: %s" % vhost)  # pragma: no cover
+        self.assertEqual(found, 2)
+
+    @mock.patch("certbot_apache.parser.ApacheParser._get_runtime_cfg")
+    def test_get_sysconfig_vars(self, mock_cfg):
+        """Make sure we read the sysconfig OPTIONS variable correctly"""
+        # Return nothing for the process calls
+        mock_cfg.return_value = ""
+        self.config.parser.sysconfig_filep = os.path.realpath(
+            os.path.join(self.config.parser.root, "../sysconfig/httpd"))
+        self.config.parser.variables = {}
+
+        with mock.patch("certbot.util.get_os_info") as mock_osi:
+            # Make sure we have the have the CentOS httpd constants
+            mock_osi.return_value = ("fedora", "29")
+            self.config.parser.update_runtime_variables()
+
+        self.assertTrue("mock_define" in self.config.parser.variables.keys())
+        self.assertTrue("mock_define_too" in self.config.parser.variables.keys())
+        self.assertTrue("mock_value" in self.config.parser.variables.keys())
+        self.assertEqual("TRUE", self.config.parser.variables["mock_value"])
+        self.assertTrue("MOCK_NOSEP" in self.config.parser.variables.keys())
+        self.assertEqual("NOSEP_VAL", self.config.parser.variables["NOSEP_TWO"])
+
+    @mock.patch("certbot_apache.configurator.util.run_script")
+    def test_alt_restart_works(self, mock_run_script):
+        mock_run_script.side_effect = [None, errors.SubprocessError, None]
+        self.config.restart()
+        self.assertEqual(mock_run_script.call_count, 3)
+
+    @mock.patch("certbot_apache.configurator.util.run_script")
+    def test_alt_restart_errors(self, mock_run_script):
+        mock_run_script.side_effect = [None,
+                                       errors.SubprocessError,
+                                       errors.SubprocessError]
+        self.assertRaises(errors.MisconfigurationError, self.config.restart)
+
+
+if __name__ == "__main__":
+    unittest.main()  # pragma: no cover