diff --git a/certbot-postfix/LICENSE.txt b/certbot-postfix/LICENSE.txt new file mode 100644 index 000000000..c8314fd1c --- /dev/null +++ b/certbot-postfix/LICENSE.txt @@ -0,0 +1,190 @@ + Copyright 2017 Electronic Frontier Foundation and others + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/certbot-postfix/MANIFEST.in b/certbot-postfix/MANIFEST.in new file mode 100644 index 000000000..97e2ad3df --- /dev/null +++ b/certbot-postfix/MANIFEST.in @@ -0,0 +1,2 @@ +include LICENSE.txt +include README.rst diff --git a/certbot-postfix/README.rst b/certbot-postfix/README.rst new file mode 100644 index 000000000..ee88648d3 --- /dev/null +++ b/certbot-postfix/README.rst @@ -0,0 +1 @@ +Postfix plugin for Certbot diff --git a/certbot-postfix/certbot_postfix/__init__.py b/certbot-postfix/certbot_postfix/__init__.py new file mode 100644 index 000000000..122c54bc6 --- /dev/null +++ b/certbot-postfix/certbot_postfix/__init__.py @@ -0,0 +1,3 @@ +"""Certbot Postfix plugin.""" + +from certbot_postfix.installer import Installer diff --git a/certbot-postfix/certbot_postfix/installer.py b/certbot-postfix/certbot_postfix/installer.py new file mode 100644 index 000000000..09e1cc18b --- /dev/null +++ b/certbot-postfix/certbot_postfix/installer.py @@ -0,0 +1,389 @@ +"""Certbot installer plugin for Postfix.""" +import logging +import os +import string +import subprocess +import sys + +import zope.interface + +from certbot import errors +from certbot import interfaces +from certbot import util as certbot_util +from certbot.plugins import common as plugins_common +from certbot.plugins import util as plugins_util + +from certbot_postfix import util + + +logger = logging.getLogger(__name__) + + +@zope.interface.implementer(interfaces.IInstaller) +@zope.interface.provider(interfaces.IPluginFactory) +class Installer(plugins_common.Installer): + """Certbot installer plugin for Postfix.""" + + description = "Configure TLS with the Postfix MTA" + + @classmethod + def add_parser_arguments(cls, add): + add("ctl", default="postfix", + help="Path to the 'postfix' control program.") + add("config-dir", help="Path to the directory containing the " + "Postfix main.cf file to modify instead of using the " + "default configuration paths.") + add("config-utility", default="postconf", + help="Path to the 'postconf' executable.") + + def __init__(self, *args, **kwargs): + super(Installer, self).__init__(*args, **kwargs) + self.config_dir = None + self.proposed_changes = {} + self.save_notes = [] + + def prepare(self): + """Prepare the installer. + + Finish up any additional initialization. + + :raises errors.PluginError: when an unexpected error occurs + :raises errors.MisconfigurationError: when the config is invalid + :raises errors.NoInstallationError: when can't find installation + :raises errors.NotSupportedError: when version is not supported + + """ + for param in ("ctl", "config_dir",): + self._verify_executable_is_available(param) + self._set_config_dir() + self._check_version() + self.config_test() + self._lock_config_dir() + + def _verify_executable_is_available(self, config_name): + """Asserts the program in the specified config param is found. + + :param str config_name: name of the config param + + :raises .NoInstallationError: when the executable isn't found + + """ + if not certbot_util.exe_exists(self.conf(config_name)): + if not plugins_util.path_surgery(self.conf(config_name)): + raise errors.NoInstallationError( + "Cannot find executable '{0}'. You can provide the " + "path to this command with --{1}".format( + self.conf(config_name), + self.option_name(config_name))) + + def _set_config_dir(self): + """Ensure self.config_dir is set to the correct path. + + If the configuration directory to use was set by the user, we'll + use that value, otherwise, we'll find the default path using + 'postconf'. + + """ + if self.conf("config-dir") is None: + self.config_dir = self.get_config_var("config_directory") + else: + self.config_dir = self.conf("config-dir") + + def _check_version(self): + """Verifies that the installed Postfix version is supported. + + :raises errors.NotSupportedError: if the version is unsupported + + """ + if self._get_version() < (2, 11, 0): + raise errors.NotSupportedError('Postfix version is too old') + + def _lock_config_dir(self): + """Stop two Postfix plugins from modifying the config at once. + + :raises .PluginError: if unable to acquire the lock + + """ + try: + certbot_util.lock_dir_until_exit(self.config_dir) + except (OSError, errors.LockError): + logger.debug("Encountered error:", exc_info=True) + raise errors.PluginError( + "Unable to lock %s", self.config_dir) + + def more_info(self): + """Human-readable string to help the user. + Should describe the steps taken and any relevant info to help the user + decide which plugin to use. + :rtype str: + """ + return ( + "Configures Postfix to try to authenticate mail servers, use " + "installed certificates and disable weak ciphers and protocols.{0}" + "Server root: {root}{0}" + "Version: {version}".format( + os.linesep, + root=self.config_dir, + version='.'.join([str(i) for i in self._get_version()])) + ) + + def _get_version(self): + """Return the mail version of Postfix. + + Version is returned as a tuple. (e.g. '2.11.3' is (2, 11, 3)) + + :returns: version + :rtype: tuple + + :raises .PluginError: Unable to find Postfix version. + + """ + mail_version = self.get_config_var("mail_version", default=True) + return tuple(int(i) for i in mail_version.split('.')) + + def get_all_names(self): + """Returns all names that may be authenticated. + + :rtype: `set` of `str` + + """ + return set(self.get_config_var(var) + for var in ('mydomain', 'myhostname', 'myorigin',)) + + def deploy_cert(self, domain, cert_path, + key_path, chain_path, fullchain_path): + """Configure the Postfix SMTP server to use the given TLS cert. + + :param str domain: domain to deploy certificate file + :param str cert_path: absolute path to the certificate file + :param str key_path: absolute path to the private key file + :param str chain_path: absolute path to the certificate chain file + :param str fullchain_path: absolute path to the certificate fullchain + file (cert plus chain) + + :raises .PluginError: when cert cannot be deployed + + """ + self.save_notes.append("Configuring TLS for {0}".format(domain)) + self._set_config_var("smtpd_tls_cert_file", fullchain_path) + self._set_config_var("smtpd_tls_key_file", key_path) + self._set_config_var("smtpd_tls_mandatory_protocols", "!SSLv2, !SSLv3") + self._set_config_var("smtpd_tls_protocols", "!SSLv2, !SSLv3") + self._set_config_var("smtpd_use_tls", "yes") + + def enhance(self, domain, enhancement, options=None): + """Raises an exception for request for unsupported enhancement. + + :raises .PluginError: this is always raised as no enhancements + are currently supported + + """ + raise errors.PluginError( + "Unsupported enhancement: {0}".format(enhancement)) + + def supported_enhancements(self): + """Returns a list of supported enhancements. + + :rtype: list + + """ + return [] + + def save(self, title=None, temporary=False): + """Creates backups and writes changes to configuration files. + + :param str title: The title of the save. If a title is given, the + configuration will be saved as a new checkpoint and put in a + timestamped directory. `title` has no effect if temporary is true. + + :param bool temporary: Indicates whether the changes made will + be quickly reversed in the future (challenges) + + :raises errors.PluginError: when save is unsuccessful + + """ + if not self.proposed_changes: + return + + self.add_to_checkpoint(os.path.join(self.config_dir, "main.cf"), + "\n".join(self.save_notes), temporary) + self._write_config_changes() + + self.proposed_changes.clear() + del self.save_notes[:] + + if title and not temporary: + self.finalize_checkpoint(title) + + def config_test(self): + """Make sure the configuration is valid. + + :raises .MisconfigurationError: if the config is invalid + + """ + try: + self._run_postfix_subcommand("check") + except subprocess.CalledProcessError: + raise errors.MisconfigurationError( + "Postfix failed internal configuration check.") + + def restart(self): + """Restart or refresh the server content. + + :raises .PluginError: when server cannot be restarted + + """ + logger.info("Reloading Postfix configuration...") + if self._is_postfix_running(): + self._reload() + else: + self._start() + + def _is_postfix_running(self): + """Is Postfix currently running? + + Uses the 'postfix status' command to determine if Postfix is + currently running using the specified configuration files. + + :returns: True if Postfix is running, otherwise, False + :rtype: bool + + """ + try: + self._run_postfix_subcommand("status") + except subprocess.CalledProcessError: + return False + return True + + def _reload(self): + """Instructions Postfix to reload its configuration. + + If Postfix isn't currently running, this method will fail. + + :raises .PluginError: when Postfix cannot reload + + """ + try: + self._run_postfix_subcommand("reload") + except subprocess.CalledProcessError: + raise errors.PluginError( + "Postfix failed to reload its configuration.") + + def _start(self): + """Instructions Postfix to start running. + + :raises .PluginError: when Postfix cannot start + + """ + try: + self._run_postfix_subcommand("start") + except subprocess.CalledProcessError: + raise errors.PluginError("Postfix failed to start") + + def _run_postfix_subcommand(self, subcommand): + """Runs a subcommand of the 'postfix' control program. + + If the command fails, the exception is logged at the DEBUG + level. + + :param str subcommand: subcommand to run + + :raises subprocess.CalledProcessError: if the command fails + + """ + cmd = [self.conf("ctl")] + if self.conf("config-dir") is not None: + cmd.extend(("-c", self.conf("config-dir"),)) + cmd.append(subcommand) + + util.check_call(cmd) + + def get_config_var(self, name, default=False): + """Return the value of the specified Postfix config parameter. + + :param str name: name of the Postfix config parameter to return + :param bool default: whether or not to return the default value + instead of the actual value + + :returns: value of the specified configuration parameter + :rtype: str + + """ + cmd = self._build_cmd_for_config_var(name, default) + + try: + output = util.check_output(cmd) + except subprocess.CalledProcessError: + logger.debug("Encountered an error when running 'postconf'", + exc_info=True) + raise errors.PluginError( + "Unable to determine the value " + "of Postfix parameter {0}".format(name)) + + expected_prefix = name + " =" + if not output.startswith(expected_prefix): + raise errors.PluginError( + "Unexpected output '{0}' from '{1}'".format(output, + ' '.join(cmd))) + + return output[len(expected_prefix):].strip() + + def _build_cmd_for_config_var(self, name, default): + """Return a command to run to get a Postfix config parameter. + + :param str name: name of the Postfix config parameter to return + :param bool default: whether or not to return the default value + instead of the actual value + + :returns: command to run + :rtype: list + + """ + cmd = self._postconf_command_base() + + if default: + cmd.append("-d") + + cmd.append(name) + + return cmd + + def _set_config_var(self, name, value): + """Set the Postfix config parameter name to value. + + This method only stores the requested change in memory. The + Postfix configuration is not modified until save() is called. + + :param str name: name of the Postfix config parameter + :param str value: value to set the Postfix config parameter to + + """ + assert isinstance(name, str), "Invalid name value" + assert isinstance(value, str), "Invalid key value" + self.proposed_changes[name] = value + self.save_notes.append("\t* Set {0} to {1}".format(name, value)) + + def _write_config_changes(self): + """Write proposed changes to the Postfix config. + + :raises errors.PluginError: if an error occurs + + """ + cmd = self._postconf_command_base() + cmd.extend("{0}={1}".format(name, value) + for name, value in self.proposed_changes.items()) + + try: + util.check_call(cmd) + except subprocess.CalledProcessError: + raise errors.PluginError( + "An error occurred while updating your Postfix config.") + + def _postconf_command_base(self): + """Builds start of a postconf command using the selected config.""" + cmd = [self.conf("config-utility")] + + if self.conf("config-dir") is not None: + cmd.extend(("-c", self.conf("config-dir"),)) + + return cmd diff --git a/certbot-postfix/certbot_postfix/installer_test.py b/certbot-postfix/certbot_postfix/installer_test.py new file mode 100644 index 000000000..69d999b31 --- /dev/null +++ b/certbot-postfix/certbot_postfix/installer_test.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import functools +import logging +import os +import subprocess +import unittest + +import mock +import six + +from certbot import errors +from certbot.tests import util as certbot_test_util + +# Fake Postfix Configs +names_only_config = """mydomain = fubard.org +myhostname = mail.fubard.org +myorigin = fubard.org""" + + +class InstallerTest(certbot_test_util.ConfigTestCase): + + def setUp(self): + super(InstallerTest, self).setUp() + self.config.postfix_ctl = "postfix" + self.config.postfix_config_dir = self.tempdir + self.config.postfix_config_utility = "postconf" + + def test_add_parser_arguments(self): + options = set(('ctl', 'config-dir', 'config-utility',)) + mock_add = mock.MagicMock() + + from certbot_postfix import installer + installer.Installer.add_parser_arguments(mock_add) + + for call in mock_add.call_args_list: + self.assertTrue(call[0][0] in options) + + def test_no_postconf_prepare(self): + installer = self._create_installer() + + installer_path = "certbot_postfix.installer" + exe_exists_path = installer_path + ".certbot_util.exe_exists" + path_surgery_path = installer_path + ".plugins_util.path_surgery" + + with mock.patch(path_surgery_path, return_value=False): + with mock.patch(exe_exists_path, return_value=False): + self.assertRaises(errors.NoInstallationError, installer.prepare) + + def test_set_config_dir(self): + self.config.postfix_config_dir = os.path.join(self.tempdir, "subdir") + os.mkdir(self.config.postfix_config_dir) + installer = self._create_installer() + + expected = self.config.postfix_config_dir + self.config.postfix_config_dir = None + + check_call_path = "certbot_postfix.installer.subprocess.check_call" + check_output_path = "certbot_postfix.installer.util.check_output" + exe_exists_path = "certbot_postfix.installer.certbot_util.exe_exists" + with mock.patch(check_output_path) as mock_check_output: + mock_check_output.side_effect = [ + "config_directory = " + expected, "mail_version = 3.1.4" + ] + with mock.patch(exe_exists_path, return_value=True): + with mock.patch(check_call_path): + installer.prepare() + self.assertEqual(installer.config_dir, expected) + + def test_lock_error(self): + assert_raises = functools.partial(self.assertRaises, + errors.PluginError, + self._create_prepared_installer) + certbot_test_util.lock_and_call(assert_raises, self.tempdir) + + @mock.patch("certbot_postfix.installer.util.check_output") + def test_get_all_names(self, mock_check_output): + installer = self._create_prepared_installer() + mock_check_output.side_effect = names_only_config.splitlines() + + result = installer.get_all_names() + self.assertTrue("fubard.org" in result) + self.assertTrue("mail.fubard.org" in result) + + def test_enhance(self): + self.assertRaises(errors.PluginError, + self._create_prepared_installer().enhance, + "example.org", "redirect") + + def test_supported_enhancements(self): + self.assertEqual( + self._create_prepared_installer().supported_enhancements(), []) + + @mock.patch("certbot_postfix.installer.subprocess.check_call") + def test_config_test_failure(self, mock_check_call): + installer = self._create_prepared_installer() + mock_check_call.side_effect = subprocess.CalledProcessError(42, "foo") + self.assertRaises(errors.MisconfigurationError, installer.config_test) + + @mock.patch("certbot_postfix.installer.subprocess.check_call") + def test_postfix_reload_failure(self, mock_check_call): + installer = self._create_prepared_installer() + mock_check_call.side_effect = [ + None, subprocess.CalledProcessError(42, "foo") + ] + self.assertRaises(errors.PluginError, installer.restart) + + @mock.patch("certbot_postfix.installer.subprocess.check_call") + def test_postfix_reload_success(self, mock_check_call): + installer = self._create_prepared_installer() + installer.restart() + + @mock.patch("certbot_postfix.installer.subprocess.check_call") + def test_postfix_start_failure(self, mock_check_call): + installer = self._create_prepared_installer() + mock_check_call.side_effect = subprocess.CalledProcessError(42, "foo") + self.assertRaises(errors.PluginError, installer.restart) + + @mock.patch("certbot_postfix.installer.subprocess.check_call") + def test_postfix_start_success(self, mock_check_call): + installer = self._create_prepared_installer() + mock_check_call.side_effect = [ + subprocess.CalledProcessError(42, "foo"), None + ] + installer.restart() + + def test_get_config_var_success(self): + self.config.postfix_config_dir = None + + command = self._test_get_config_var_success_common('foo', False) + self.assertFalse("-c" in command) + self.assertFalse("-d" in command) + + def test_get_config_var_success_with_config(self): + command = self._test_get_config_var_success_common('foo', False) + self.assertTrue("-c" in command) + self.assertFalse("-d" in command) + + def test_get_config_var_success_with_default(self): + self.config.postfix_config_dir = None + + command = self._test_get_config_var_success_common('foo', True) + self.assertFalse("-c" in command) + self.assertTrue("-d" in command) + + @mock.patch("certbot_postfix.installer.logger") + @mock.patch("certbot_postfix.installer.util.check_output") + def test_get_config_var_failure(self, mock_check_output, mock_logger): + mock_check_output.side_effect = subprocess.CalledProcessError(42, "foo") + installer = self._create_installer() + self.assertRaises(errors.PluginError, installer.get_config_var, "foo") + self.assertTrue(mock_logger.debug.call_args[1]["exc_info"]) + + @mock.patch("certbot_postfix.installer.util.check_output") + def test_get_config_var_unexpected_output(self, mock_check_output): + self.config.postfix_config_dir = None + mock_check_output.return_value = "foo" + + installer = self._create_installer() + self.assertRaises(errors.PluginError, installer.get_config_var, "foo") + + def _test_get_config_var_success_common(self, name, default): + installer = self._create_installer() + + check_output_path = "certbot_postfix.installer.util.check_output" + with mock.patch(check_output_path) as mock_check_output: + value = "bar" + mock_check_output.return_value = name + " = " + value + self.assertEqual(installer.get_config_var(name, default), value) + + return mock_check_output.call_args[0][0] + + def _create_prepared_installer(self): + """Creates and returns a new prepared Postfix Installer. + + Calls in prepare() are mocked out so the Postfix version check + is successful. + + :returns: a prepared Postfix installer + :rtype: certbot_postfix.installer.Installer + + """ + installer = self._create_installer() + + check_call_path = "certbot_postfix.installer.subprocess.check_call" + check_output_path = "certbot_postfix.installer.util.check_output" + exe_exists_path = "certbot_postfix.installer.certbot_util.exe_exists" + with mock.patch(check_output_path) as mock_check_output: + with mock.patch(exe_exists_path, return_value=True): + with mock.patch(check_call_path): + mock_check_output.return_value = "mail_version = 3.1.4" + installer.prepare() + + return installer + + def _create_installer(self): + """Creates and returns a new Postfix Installer. + + :returns: a new Postfix installer + :rtype: certbot_postfix.installer.Installer + + """ + name = "postfix" + + from certbot_postfix import installer + return installer.Installer(self.config, name) + + +if __name__ == '__main__': + unittest.main() diff --git a/certbot-postfix/certbot_postfix/util.py b/certbot-postfix/certbot_postfix/util.py new file mode 100644 index 000000000..b65e4231e --- /dev/null +++ b/certbot-postfix/certbot_postfix/util.py @@ -0,0 +1,79 @@ +"""Utility functions for use in the Postfix installer.""" + +import logging +import subprocess + + +logger = logging.getLogger(__name__) + + +def check_call(*args, **kwargs): + """A simple wrapper of subprocess.check_call that logs errors. + + :param tuple args: positional arguments to subprocess.check_call + :param dict kargs: keyword arguments to subprocess.check_call + + :raises subprocess.CalledProcessError: if the call fails + + """ + try: + subprocess.check_call(*args, **kwargs) + except subprocess.CalledProcessError: + cmd = _get_cmd(*args, **kwargs) + logger.debug("%s exited with a non-zero status.", + "".join(cmd), exc_info=True) + raise + + +def check_output(*args, **kwargs): + """Backported version of subprocess.check_output for Python 2.6+. + + This is the same as subprocess.check_output from newer versions of + Python, except: + + 1. The return value is a string rather than a byte string. To + accomplish this, the caller cannot set the parameter + universal_newlines. + 2. If the command exits with a nonzero status, output is not + included in the raised subprocess.CalledProcessError because + subprocess.CalledProcessError on Python 2.6 does not support this. + Instead, the failure including the output is logged. + + :param tuple args: positional arguments for Popen + :param dict kwargs: keyword arguments for Popen + + :returns: data printed to stdout + :rtype: str + + :raises ValueError: if arguments are invalid + :raises subprocess.CalledProcessError: if the command fails + + """ + for keyword in ('stdout', 'universal_newlines',): + if keyword in kwargs: + raise ValueError( + keyword + ' argument not allowed, it will be overridden.') + + kwargs['stdout'] = subprocess.PIPE + kwargs['universal_newlines'] = True + + process = subprocess.Popen(*args, **kwargs) + output, unused_err = process.communicate() + retcode = process.poll() + if retcode: + cmd = _get_cmd(*args, **kwargs) + logger.debug( + "'%s' exited with %d. Output was:\n%s", cmd, retcode, output) + raise subprocess.CalledProcessError(retcode, cmd) + return output + + +def _get_cmd(*args, **kwargs): + """Return the command from Popen args. + + :param tuple args: Popen args + :param dict kwargs: Popen kwargs + + """ + cmd = kwargs.get('args') + return args[0] if cmd is None else cmd diff --git a/certbot-postfix/certbot_postfix/util_test.py b/certbot-postfix/certbot_postfix/util_test.py new file mode 100644 index 000000000..95253e1fd --- /dev/null +++ b/certbot-postfix/certbot_postfix/util_test.py @@ -0,0 +1,73 @@ +"""Tests for certbot_postfix.util.""" + +import subprocess +import unittest + +import mock + +class CheckCallTest(unittest.TestCase): + """Tests for certbot_postfix.util.check_call.""" + + @classmethod + def _call(cls, *args, **kwargs): + from certbot_postfix.util import check_call + return check_call(*args, **kwargs) + + @mock.patch('certbot_postfix.util.logger') + @mock.patch('certbot_postfix.util.subprocess.check_call') + def test_failure(self, mock_check_call, mock_logger): + cmd = "postconf smtpd_use_tls=yes".split() + mock_check_call.side_effect = subprocess.CalledProcessError(42, cmd) + self.assertRaises(subprocess.CalledProcessError, self._call, cmd) + self.assertTrue(mock_logger.method_calls) + + @mock.patch('certbot_postfix.util.subprocess.check_call') + def test_success(self, mock_check_call): + cmd = "postconf smtpd_use_tls=yes".split() + self._call(cmd) + mock_check_call.assert_called_once_with(cmd) + + +class CheckOutputTest(unittest.TestCase): + """Tests for certbot_postfix.util.check_output.""" + + @classmethod + def _call(cls, *args, **kwargs): + from certbot_postfix.util import check_output + return check_output(*args, **kwargs) + + @mock.patch('certbot_postfix.util.logger') + @mock.patch('certbot_postfix.util.subprocess.Popen') + def test_command_error(self, mock_popen, mock_logger): + command = 'foo' + retcode = 42 + output = 'bar' + + mock_popen().communicate.return_value = (output, '') + mock_popen().poll.return_value = 42 + + self.assertRaises(subprocess.CalledProcessError, self._call, command) + + log_args = mock_logger.debug.call_args[0] + self.assertTrue(command in log_args) + self.assertTrue(retcode in log_args) + self.assertTrue(output in log_args) + + @mock.patch('certbot_postfix.util.subprocess.Popen') + def test_success(self, mock_popen): + command = 'foo' + output = 'bar' + mock_popen().communicate.return_value = (output, '') + mock_popen().poll.return_value = 0 + + self.assertEqual(self._call(command), output) + + def test_stdout_error(self): + self.assertRaises(ValueError, self._call, stdout=None) + + def test_universal_newlines_error(self): + self.assertRaises(ValueError, self._call, universal_newlines=False) + + +if __name__ == '__main__': # pragma: no cover + unittest.main() diff --git a/certbot-postfix/setup.cfg b/certbot-postfix/setup.cfg new file mode 100644 index 000000000..2a9acf13d --- /dev/null +++ b/certbot-postfix/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/certbot-postfix/setup.py b/certbot-postfix/setup.py new file mode 100644 index 000000000..2fdcd46aa --- /dev/null +++ b/certbot-postfix/setup.py @@ -0,0 +1,59 @@ +import sys + +from setuptools import setup +from setuptools import find_packages + + +version = '0.18.0.dev0' + +install_requires = [ + 'acme=={0}'.format(version), + 'certbot=={0}'.format(version), + # For pkg_resources. >=1.0 so pip resolves it to a version cryptography + # will tolerate; see #2599: + 'setuptools>=1.0', + 'six', + 'zope.interface', +] + +setup( + name='certbot-postfix', + version=version, + description="Postfix plugin for Certbot", + url='https://github.com/certbot/certbot', + author="Certbot Project", + author_email='client-dev@letsencrypt.org', + license='Apache License 2.0', + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Environment :: Plugins', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Topic :: Communications :: Email :: Mail Transport Agents', + 'Topic :: Security', + 'Topic :: System :: Installation/Setup', + 'Topic :: System :: Networking', + 'Topic :: System :: Systems Administration', + 'Topic :: Utilities', + ], + + packages=find_packages(), + include_package_data=True, + install_requires=install_requires, + entry_points={ + 'certbot.plugins': [ + 'postfix = certbot_postfix:Installer', + ], + }, + test_suite='certbot_postfix', +)