Merge remote-tracking branch 'starttls-everywhere/certbotify' into postfix

This commit is contained in:
Brad Warren 2017-08-29 10:44:07 -07:00
commit 66953435c9
10 changed files with 1014 additions and 0 deletions

190
certbot-postfix/LICENSE.txt Normal file
View file

@ -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

View file

@ -0,0 +1,2 @@
include LICENSE.txt
include README.rst

View file

@ -0,0 +1 @@
Postfix plugin for Certbot

View file

@ -0,0 +1,3 @@
"""Certbot Postfix plugin."""
from certbot_postfix.installer import Installer

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -0,0 +1,2 @@
[bdist_wheel]
universal = 1

59
certbot-postfix/setup.py Normal file
View file

@ -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',
)