diff --git a/.travis.yml b/.travis.yml
index a42e41352..3a9a994a9 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -17,12 +17,7 @@ env:
matrix:
include:
- python: "2.7"
- env: TOXENV=cover BOULDER_INTEGRATION=1
- sudo: required
- after_failure:
- - sudo cat /var/log/mysql/error.log
- - ps aux | grep mysql
- services: docker
+ env: TOXENV=cover
- python: "2.7"
env: TOXENV=lint
- python: "2.7"
@@ -46,6 +41,13 @@ matrix:
- sudo cat /var/log/mysql/error.log
- ps aux | grep mysql
services: docker
+ - python: "2.7"
+ env: TOXENV=py27_install BOULDER_INTEGRATION=1
+ sudo: required
+ after_failure:
+ - sudo cat /var/log/mysql/error.log
+ - ps aux | grep mysql
+ services: docker
- sudo: required
env: TOXENV=apache_compat
services: docker
diff --git a/certbot-apache/certbot_apache/configurator.py b/certbot-apache/certbot_apache/configurator.py
index 1bb0a1e1a..27e214362 100644
--- a/certbot-apache/certbot_apache/configurator.py
+++ b/certbot-apache/certbot_apache/configurator.py
@@ -1,6 +1,7 @@
"""Apache Configuration based off of Augeas Configurator."""
# pylint: disable=too-many-lines
import filecmp
+import fnmatch
import logging
import os
import re
@@ -362,18 +363,24 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
return vhost
def included_in_wildcard(self, names, target_name):
- """Helper function to see if alias is covered by wildcard"""
- target_name = target_name.split(".")[::-1]
- wildcards = [domain.split(".")[1:] for domain in
- names if domain.startswith("*")]
- for wildcard in wildcards:
- if len(wildcard) > len(target_name):
- continue
- for idx, segment in enumerate(wildcard[::-1]):
- if segment != target_name[idx]:
- break
- else:
- # https://docs.python.org/2/tutorial/controlflow.html#break-and-continue-statements-and-else-clauses-on-loops
+ """Is target_name covered by a wildcard?
+
+ :param names: server aliases
+ :type names: `collections.Iterable` of `str`
+ :param str target_name: name to compare with wildcards
+
+ :returns: True if target_name is covered by a wildcard,
+ otherwise, False
+ :rtype: bool
+
+ """
+ # use lowercase strings because fnmatch can be case sensitive
+ target_name = target_name.lower()
+ for name in names:
+ name = name.lower()
+ # fnmatch treats "[seq]" specially and [ or ] characters aren't
+ # valid in Apache but Apache doesn't error out if they are present
+ if "[" not in name and fnmatch.fnmatch(target_name, name):
return True
return False
@@ -463,7 +470,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
zope.component.getUtility(interfaces.IDisplay).notification(
"Apache mod_macro seems to be in use in file(s):\n{0}"
"\n\nUnfortunately mod_macro is not yet supported".format(
- "\n ".join(vhost_macro)))
+ "\n ".join(vhost_macro)), force_interactive=True)
return all_names
@@ -1012,6 +1019,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
self.parser.find_dir("ServerAlias", target_name,
start=vh_path, exclude=False)):
return
+ if self._has_matching_wildcard(vh_path, target_name):
+ return
if not self.parser.find_dir("ServerName", None,
start=vh_path, exclude=False):
self.parser.add_dir(vh_path, "ServerName", target_name)
@@ -1019,6 +1028,22 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
self.parser.add_dir(vh_path, "ServerAlias", target_name)
self._add_servernames(vhost)
+ def _has_matching_wildcard(self, vh_path, target_name):
+ """Is target_name already included in a wildcard in the vhost?
+
+ :param str vh_path: Augeas path to the vhost
+ :param str target_name: name to compare with wildcards
+
+ :returns: True if there is a wildcard covering target_name in
+ the vhost in vhost_path, otherwise, False
+ :rtype: bool
+
+ """
+ matches = self.parser.find_dir(
+ "ServerAlias", start=vh_path, exclude=False)
+ aliases = (self.aug.get(match) for match in matches)
+ return self.included_in_wildcard(aliases, target_name)
+
def _add_name_vhost_if_necessary(self, vhost):
"""Add NameVirtualHost Directives if necessary for new vhost.
diff --git a/certbot-apache/certbot_apache/display_ops.py b/certbot-apache/certbot_apache/display_ops.py
index 527de1001..22aafc0fe 100644
--- a/certbot-apache/certbot_apache/display_ops.py
+++ b/certbot-apache/certbot_apache/display_ops.py
@@ -85,7 +85,8 @@ def _vhost_menu(domain, vhosts):
"or Address of {0}.{1}Which virtual host would you "
"like to choose?\n(note: conf files with multiple "
"vhosts are not yet supported)".format(domain, os.linesep),
- choices, help_label="More Info", ok_label="Select")
+ choices, help_label="More Info",
+ ok_label="Select", force_interactive=True)
except errors.MissingCommandlineFlag:
msg = ("Encountered vhost ambiguity but unable to ask for user guidance in "
"non-interactive mode. Currently Certbot needs each vhost to be "
@@ -100,4 +101,5 @@ def _vhost_menu(domain, vhosts):
def _more_info_vhost(vhost):
zope.component.getUtility(interfaces.IDisplay).notification(
"Virtual Host Information:{0}{1}{0}{2}".format(
- os.linesep, "-" * (display_util.WIDTH - 4), str(vhost)))
+ os.linesep, "-" * (display_util.WIDTH - 4), str(vhost)),
+ force_interactive=True)
diff --git a/certbot-apache/certbot_apache/tests/configurator_test.py b/certbot-apache/certbot_apache/tests/configurator_test.py
index 2f1d01315..1af425824 100644
--- a/certbot-apache/certbot_apache/tests/configurator_test.py
+++ b/certbot-apache/certbot_apache/tests/configurator_test.py
@@ -220,10 +220,6 @@ class MultipleVhostsTest(util.ApacheTest):
self.assertRaises(
errors.PluginError, self.config.choose_vhost, "none.com")
- def test_choosevhost_select_vhost_with_wildcard(self):
- chosen_vhost = self.config.choose_vhost("blue.purple.com", temp=True)
- self.assertEqual(self.vh_truth[6], chosen_vhost)
-
def test_findbest_continues_on_short_domain(self):
# pylint: disable=protected-access
chosen_vhost = self.config._find_best_vhost("purple.com")
@@ -1255,8 +1251,6 @@ class AugeasVhostsTest(util.ApacheTest):
self.config = util.get_apache_configurator(
self.config_path, self.vhost_path, self.config_dir, self.work_dir)
- self.vh_truth = util.get_vh_truth(
- self.temp_dir, "debian_apache_2_4/augeas_vhosts")
def tearDown(self):
shutil.rmtree(self.temp_dir)
@@ -1281,5 +1275,41 @@ class AugeasVhostsTest(util.ApacheTest):
vhs = self.config.get_virtual_hosts()
self.assertEqual([], vhs)
+ def test_choose_vhost_with_matching_wildcard(self):
+ names = (
+ "an.example.net", "another.example.net", "an.other.example.net")
+ for name in names:
+ self.assertFalse(name in self.config.choose_vhost(name).aliases)
+
+ def test_choose_vhost_without_matching_wildcard(self):
+ mock_path = "certbot_apache.display_ops.select_vhost"
+ with mock.patch(mock_path, lambda _, vhosts: vhosts[0]):
+ for name in ("a.example.net", "other.example.net"):
+ self.assertTrue(name in self.config.choose_vhost(name).aliases)
+
+ def test_choose_vhost_wildcard_not_found(self):
+ mock_path = "certbot_apache.display_ops.select_vhost"
+ names = (
+ "abc.example.net", "not.there.tld", "aa.wildcard.tld"
+ )
+ with mock.patch(mock_path) as mock_select:
+ mock_select.return_value = self.config.vhosts[0]
+ for name in names:
+ orig_cc = mock_select.call_count
+ self.config.choose_vhost(name)
+ self.assertEqual(mock_select.call_count - orig_cc, 1)
+
+ def test_choose_vhost_wildcard_found(self):
+ mock_path = "certbot_apache.display_ops.select_vhost"
+ names = (
+ "ab.example.net", "a.wildcard.tld", "yetanother.example.net"
+ )
+ with mock.patch(mock_path) as mock_select:
+ mock_select.return_value = self.config.vhosts[0]
+ for name in names:
+ self.config.choose_vhost(name)
+ self.assertEqual(mock_select.call_count, 0)
+
+
if __name__ == "__main__":
unittest.main() # pragma: no cover
diff --git a/certbot-apache/certbot_apache/tests/display_ops_test.py b/certbot-apache/certbot_apache/tests/display_ops_test.py
index 585661c7f..dea1e4433 100644
--- a/certbot-apache/certbot_apache/tests/display_ops_test.py
+++ b/certbot-apache/certbot_apache/tests/display_ops_test.py
@@ -17,7 +17,8 @@ class SelectVhostTest(unittest.TestCase):
"""Tests for certbot_apache.display_ops.select_vhost."""
def setUp(self):
- zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
+ zope.component.provideUtility(display_util.FileDisplay(sys.stdout,
+ False))
self.base_dir = "/example_path"
self.vhosts = util.get_vh_truth(
self.base_dir, "debian_apache_2_4/multiple_vhosts")
diff --git a/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/augeas_vhosts/apache2/sites-available/another_wildcard.conf b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/augeas_vhosts/apache2/sites-available/another_wildcard.conf
new file mode 100644
index 000000000..1a5b7de47
--- /dev/null
+++ b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/augeas_vhosts/apache2/sites-available/another_wildcard.conf
@@ -0,0 +1,11 @@
+
+ ServerName wildcard.tld
+ ServerAlias ?.wildcard.tld
+ ServerAdmin webmaster@localhost
+ DocumentRoot /var/www/html
+
+ ErrorLog ${APACHE_LOG_DIR}/error.log
+ CustomLog ${APACHE_LOG_DIR}/access.log combined
+
+
+# vim: syntax=apache ts=4 sw=4 sts=4 sr noet
diff --git a/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/augeas_vhosts/apache2/sites-available/wildcard.conf b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/augeas_vhosts/apache2/sites-available/wildcard.conf
new file mode 100644
index 000000000..b8046e6c9
--- /dev/null
+++ b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/augeas_vhosts/apache2/sites-available/wildcard.conf
@@ -0,0 +1,11 @@
+
+ ServerName example.net
+ ServerAlias ??.example.net *.other.example.net *another.example.net
+ ServerAdmin webmaster@localhost
+ DocumentRoot /var/www/html
+
+ ErrorLog ${APACHE_LOG_DIR}/error.log
+ CustomLog ${APACHE_LOG_DIR}/access.log combined
+
+
+# vim: syntax=apache ts=4 sw=4 sts=4 sr noet
diff --git a/certbot-apache/certbot_apache/tests/util.py b/certbot-apache/certbot_apache/tests/util.py
index 6a0a83615..3c33a0e19 100644
--- a/certbot-apache/certbot_apache/tests/util.py
+++ b/certbot-apache/certbot_apache/tests/util.py
@@ -64,7 +64,8 @@ class ParserTest(ApacheTest): # pytlint: disable=too-few-public-methods
vhost_root="debian_apache_2_4/multiple_vhosts/apache2/sites-available"):
super(ParserTest, self).setUp(test_dir, config_root, vhost_root)
- zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
+ zope.component.provideUtility(display_util.FileDisplay(sys.stdout,
+ False))
from certbot_apache.parser import ApacheParser
self.aug = augeas.Augeas(
diff --git a/certbot-nginx/certbot_nginx/obj.py b/certbot-nginx/certbot_nginx/obj.py
index 98bf86f5c..29fa976f3 100644
--- a/certbot-nginx/certbot_nginx/obj.py
+++ b/certbot-nginx/certbot_nginx/obj.py
@@ -29,10 +29,14 @@ class Addr(common.Addr):
:param bool default: Whether the directive includes 'default_server'
"""
+ UNSPECIFIED_IPV4_ADDRESSES = ('', '*', '0.0.0.0')
+ CANONICAL_UNSPECIFIED_ADDRESS = UNSPECIFIED_IPV4_ADDRESSES[0]
+
def __init__(self, host, port, ssl, default):
super(Addr, self).__init__((host, port))
self.ssl = ssl
self.default = default
+ self.unspecified_address = host in self.UNSPECIFIED_IPV4_ADDRESSES
@classmethod
def fromstring(cls, str_addr):
@@ -96,6 +100,13 @@ class Addr(common.Addr):
def super_eq(self, other):
"""Check ip/port equality, with IPv6 support.
"""
+ # If both addresses got an unspecified address, then make sure the
+ # host representation in each match when doing the comparison.
+ if self.unspecified_address and other.unspecified_address:
+ return common.Addr((self.CANONICAL_UNSPECIFIED_ADDRESS,
+ self.tup[1]), self.ipv6) == \
+ common.Addr((other.CANONICAL_UNSPECIFIED_ADDRESS,
+ other.tup[1]), other.ipv6)
# Nginx plugin currently doesn't support IPv6 but this will
# future-proof it
return super(Addr, self).__eq__(other)
diff --git a/certbot-nginx/certbot_nginx/tests/obj_test.py b/certbot-nginx/certbot_nginx/tests/obj_test.py
index b153db8d4..b0a2d5ad8 100644
--- a/certbot-nginx/certbot_nginx/tests/obj_test.py
+++ b/certbot-nginx/certbot_nginx/tests/obj_test.py
@@ -1,5 +1,6 @@
"""Test the helper objects in certbot_nginx.obj."""
import unittest
+import itertools
class AddrTest(unittest.TestCase):
@@ -72,6 +73,24 @@ class AddrTest(unittest.TestCase):
self.assertNotEqual(self.addr1, self.addr2)
self.assertFalse(self.addr1 == 3333)
+ def test_equivalent_any_addresses(self):
+ from certbot_nginx.obj import Addr
+ any_addresses = ("0.0.0.0:80 default_server ssl",
+ "80 default_server ssl",
+ "*:80 default_server ssl")
+ for first, second in itertools.combinations(any_addresses, 2):
+ self.assertEqual(Addr.fromstring(first), Addr.fromstring(second))
+
+ # Also, make sure ports are checked.
+ self.assertNotEqual(Addr.fromstring(any_addresses[0]),
+ Addr.fromstring("0.0.0.0:443 default_server ssl"))
+
+ # And they aren't equivalent to a specified address.
+ for any_address in any_addresses:
+ self.assertNotEqual(
+ Addr.fromstring("192.168.1.2:80 default_server ssl"),
+ Addr.fromstring(any_address))
+
def test_set_inclusion(self):
from certbot_nginx.obj import Addr
set_a = set([self.addr1, self.addr2])
diff --git a/certbot/cert_manager.py b/certbot/cert_manager.py
index 0f6f4c730..09798e3bc 100644
--- a/certbot/cert_manager.py
+++ b/certbot/cert_manager.py
@@ -6,10 +6,9 @@ import pytz
import traceback
import zope.component
-from certbot import configuration
from certbot import errors
from certbot import interfaces
-from certbot import renewal
+from certbot import ocsp
from certbot import storage
from certbot import util
@@ -17,6 +16,10 @@ from certbot.display import util as display_util
logger = logging.getLogger(__name__)
+###################
+# Commands
+###################
+
def update_live_symlinks(config):
"""Update the certificate file family symlinks to use archive_dir.
@@ -26,41 +29,28 @@ def update_live_symlinks(config):
.. note:: This assumes that the installation is using a Reverter object.
:param config: Configuration.
- :type config: :class:`certbot.interfaces.IConfig`
+ :type config: :class:`certbot.configuration.NamespaceConfig`
"""
- renewer_config = configuration.RenewerConfiguration(config)
- for renewal_file in renewal.renewal_conf_files(renewer_config):
- storage.RenewableCert(renewal_file,
- configuration.RenewerConfiguration(renewer_config),
- update_symlinks=True)
+ for renewal_file in storage.renewal_conf_files(config):
+ storage.RenewableCert(renewal_file, config, update_symlinks=True)
def rename_lineage(config):
"""Rename the specified lineage to the new name.
:param config: Configuration.
- :type config: :class:`certbot.interfaces.IConfig`
+ :type config: :class:`certbot.configuration.NamespaceConfig`
"""
disp = zope.component.getUtility(interfaces.IDisplay)
- renewer_config = configuration.RenewerConfiguration(config)
- certname = config.certname
- if not certname:
- filenames = renewal.renewal_conf_files(renewer_config)
- choices = [storage.lineagename_for_filename(name) for name in filenames]
- if not choices:
- raise errors.Error("No existing certificates found.")
- code, index = disp.menu("Which certificate would you like to rename?",
- choices, ok_label="Select", flag="--cert-name")
- if code != display_util.OK or not index in range(0, len(choices)):
- raise errors.Error("User ended interaction.")
- certname = choices[index]
+ certname = _get_certname(config, "rename")
new_certname = config.new_certname
if not new_certname:
- code, new_certname = disp.input("Enter the new name for certificate {0}"
- .format(certname), flag="--updated-cert-name")
+ code, new_certname = disp.input(
+ "Enter the new name for certificate {0}".format(certname),
+ flag="--updated-cert-name", force_interactive=True)
if code != display_util.OK or not new_certname:
raise errors.Error("User ended interaction.")
@@ -68,77 +58,21 @@ def rename_lineage(config):
if not lineage:
raise errors.ConfigurationError("No existing certificate with name "
"{0} found.".format(certname))
- storage.rename_renewal_config(certname, new_certname, renewer_config)
+ storage.rename_renewal_config(certname, new_certname, config)
disp.notification("Successfully renamed {0} to {1}."
.format(certname, new_certname), pause=False)
-def _report_lines(msgs):
- """Format a results report for a category of single-line renewal outcomes"""
- return " " + "\n ".join(str(msg) for msg in msgs)
-
-def _report_human_readable(parsed_certs):
- """Format a results report for a parsed cert"""
- certinfo = []
- for cert in parsed_certs:
- now = pytz.UTC.fromutc(datetime.datetime.utcnow())
- if cert.is_test_cert:
- expiration_text = "INVALID: TEST CERT"
- elif cert.target_expiry <= now:
- expiration_text = "INVALID: EXPIRED"
- else:
- diff = cert.target_expiry - now
- if diff.days == 1:
- expiration_text = "VALID: 1 day"
- elif diff.days < 1:
- expiration_text = "VALID: {0} hour(s)".format(diff.seconds // 3600)
- else:
- expiration_text = "VALID: {0} days".format(diff.days)
- valid_string = "{0} ({1})".format(cert.target_expiry, expiration_text)
- certinfo.append(" Certificate Name: {0}\n"
- " Domains: {1}\n"
- " Expiry Date: {2}\n"
- " Certificate Path: {3}\n"
- " Private Key Path: {4}".format(
- cert.lineagename,
- " ".join(cert.names()),
- valid_string,
- cert.fullchain,
- cert.privkey))
- return "\n".join(certinfo)
-
-def _describe_certs(parsed_certs, parse_failures):
- """Print information about the certs we know about"""
- out = []
-
- notify = out.append
-
- if not parsed_certs and not parse_failures:
- notify("No certs found.")
- else:
- if parsed_certs:
- notify("Found the following certs:")
- notify(_report_human_readable(parsed_certs))
- if parse_failures:
- notify("\nThe following renewal configuration files "
- "were invalid:")
- notify(_report_lines(parse_failures))
-
- disp = zope.component.getUtility(interfaces.IDisplay)
- disp.notification("\n".join(out), pause=False, wrap=False)
-
def certificates(config):
"""Display information about certs configured with Certbot
:param config: Configuration.
- :type config: :class:`certbot.interfaces.IConfig`
+ :type config: :class:`certbot.configuration.NamespaceConfig`
"""
- renewer_config = configuration.RenewerConfiguration(config)
parsed_certs = []
parse_failures = []
- for renewal_file in renewal.renewal_conf_files(renewer_config):
+ for renewal_file in storage.renewal_conf_files(config):
try:
- renewal_candidate = storage.RenewableCert(renewal_file,
- configuration.RenewerConfiguration(config))
+ renewal_candidate = storage.RenewableCert(renewal_file, config)
parsed_certs.append(renewal_candidate)
except Exception as e: # pylint: disable=broad-except
logger.warning("Renewal configuration file %s produced an "
@@ -147,29 +81,19 @@ def certificates(config):
parse_failures.append(renewal_file)
# Describe all the certs
- _describe_certs(parsed_certs, parse_failures)
+ _describe_certs(config, parsed_certs, parse_failures)
-def _search_lineages(config, func, initial_rv):
- """Iterate func over unbroken lineages, allowing custom return conditions.
+def delete(config):
+ """Delete Certbot files associated with a certificate lineage."""
+ certname = _get_certname(config, "delete")
+ storage.delete_files(config, certname)
+ disp = zope.component.getUtility(interfaces.IDisplay)
+ disp.notification("Deleted all files relating to certificate {0}."
+ .format(certname), pause=False)
- Allows flexible customization of return values, including multiple
- return values and complex checks.
- """
- cli_config = configuration.RenewerConfiguration(config)
- configs_dir = cli_config.renewal_configs_dir
- # Verify the directory is there
- util.make_or_verify_dir(configs_dir, mode=0o755, uid=os.geteuid())
-
- rv = initial_rv
- for renewal_file in renewal.renewal_conf_files(cli_config):
- try:
- candidate_lineage = storage.RenewableCert(renewal_file, cli_config)
- except (errors.CertStorageError, IOError):
- logger.debug("Renewal conf file %s is broken. Skipping.", renewal_file)
- logger.debug("Traceback was:\n%s", traceback.format_exc())
- continue
- rv = func(candidate_lineage, rv)
- return rv
+###################
+# Public Helpers
+###################
def lineage_for_certname(config, certname):
"""Find a lineage object with name certname."""
@@ -215,3 +139,114 @@ def find_duplicative_certs(config, domains):
return (identical_names_cert, subset_names_cert)
return _search_lineages(config, update_certs_for_domain_matches, (None, None))
+
+
+###################
+# Private Helpers
+###################
+
+def _get_certname(config, verb):
+ """Get certname from flag, interactively, or error out.
+ """
+ certname = config.certname
+ if not certname:
+ disp = zope.component.getUtility(interfaces.IDisplay)
+ filenames = storage.renewal_conf_files(config)
+ choices = [storage.lineagename_for_filename(name) for name in filenames]
+ if not choices:
+ raise errors.Error("No existing certificates found.")
+ code, index = disp.menu("Which certificate would you like to {0}?".format(verb),
+ choices, ok_label="Select", flag="--cert-name")
+ if code != display_util.OK or not index in range(0, len(choices)):
+ raise errors.Error("User ended interaction.")
+ certname = choices[index]
+ return certname
+
+def _report_lines(msgs):
+ """Format a results report for a category of single-line renewal outcomes"""
+ return " " + "\n ".join(str(msg) for msg in msgs)
+
+def _report_human_readable(config, parsed_certs):
+ """Format a results report for a parsed cert"""
+ certinfo = []
+ checker = ocsp.RevocationChecker()
+ for cert in parsed_certs:
+ if config.certname and cert.lineagename != config.certname:
+ continue
+ if config.domains and not set(config.domains).issubset(cert.names()):
+ continue
+ now = pytz.UTC.fromutc(datetime.datetime.utcnow())
+
+ reasons = []
+ if cert.is_test_cert:
+ reasons.append('TEST_CERT')
+ if cert.target_expiry <= now:
+ reasons.append('EXPIRED')
+ if checker.ocsp_revoked(cert.cert, cert.chain):
+ reasons.append('REVOKED')
+
+ if reasons:
+ status = "INVALID: " + ", ".join(reasons)
+ else:
+ diff = cert.target_expiry - now
+ if diff.days == 1:
+ status = "VALID: 1 day"
+ elif diff.days < 1:
+ status = "VALID: {0} hour(s)".format(diff.seconds // 3600)
+ else:
+ status = "VALID: {0} days".format(diff.days)
+
+ valid_string = "{0} ({1})".format(cert.target_expiry, status)
+ certinfo.append(" Certificate Name: {0}\n"
+ " Domains: {1}\n"
+ " Expiry Date: {2}\n"
+ " Certificate Path: {3}\n"
+ " Private Key Path: {4}".format(
+ cert.lineagename,
+ " ".join(cert.names()),
+ valid_string,
+ cert.fullchain,
+ cert.privkey))
+ return "\n".join(certinfo)
+
+def _describe_certs(config, parsed_certs, parse_failures):
+ """Print information about the certs we know about"""
+ out = []
+
+ notify = out.append
+
+ if not parsed_certs and not parse_failures:
+ notify("No certs found.")
+ else:
+ if parsed_certs:
+ match = "matching " if config.certname or config.domains else ""
+ notify("Found the following {0}certs:".format(match))
+ notify(_report_human_readable(config, parsed_certs))
+ if parse_failures:
+ notify("\nThe following renewal configuration files "
+ "were invalid:")
+ notify(_report_lines(parse_failures))
+
+ disp = zope.component.getUtility(interfaces.IDisplay)
+ disp.notification("\n".join(out), pause=False, wrap=False)
+
+def _search_lineages(cli_config, func, initial_rv):
+ """Iterate func over unbroken lineages, allowing custom return conditions.
+
+ Allows flexible customization of return values, including multiple
+ return values and complex checks.
+ """
+ configs_dir = cli_config.renewal_configs_dir
+ # Verify the directory is there
+ util.make_or_verify_dir(configs_dir, mode=0o755, uid=os.geteuid())
+
+ rv = initial_rv
+ for renewal_file in storage.renewal_conf_files(cli_config):
+ try:
+ candidate_lineage = storage.RenewableCert(renewal_file, cli_config)
+ except (errors.CertStorageError, IOError):
+ logger.debug("Renewal conf file %s is broken. Skipping.", renewal_file)
+ logger.debug("Traceback was:\n%s", traceback.format_exc())
+ continue
+ rv = func(candidate_lineage, rv)
+ return rv
diff --git a/certbot/cli.py b/certbot/cli.py
index 9c861dad6..f2b5bd21d 100644
--- a/certbot/cli.py
+++ b/certbot/cli.py
@@ -50,51 +50,56 @@ cli_command = LEAUTO if fragment in sys.argv[0] else "certbot"
# to replace as much of it as we can...
# This is the stub to include in help generated by argparse
-
SHORT_USAGE = """
- {0} [SUBCOMMAND] [options] [-d domain] [-d domain] ...
+ {0} [SUBCOMMAND] [options] [-d DOMAIN] [-d DOMAIN] ...
Certbot can obtain and install HTTPS/TLS/SSL certificates. By default,
it will attempt to use a webserver both for obtaining and installing the
-cert. Major SUBCOMMANDS are:
+cert. """.format(cli_command)
- (default) run Obtain & install a cert in your current webserver
- certonly Obtain cert, but do not install it (aka "auth")
- install Install a previously obtained cert in a server
- renew Renew previously obtained certs that are near expiry
- revoke Revoke a previously obtained certificate
- register Perform tasks related to registering with the CA
- rollback Rollback server configuration changes made during install
- config_changes Show changes made to server config during installation
- update_symlinks Update cert symlinks based on renewal config file
- rename Update a certificate's name
- plugins Display information about installed plugins
- certificates Display information about certs configured with Certbot
+# This section is used for --help and --help all ; it needs information
+# about installed plugins to be fully formatted
+COMMAND_OVERVIEW = """The most common SUBCOMMANDS and flags are:
-""".format(cli_command)
-
-# This is the short help for certbot --help, where we disable argparse
-# altogether
-USAGE = SHORT_USAGE + """Choice of server plugins for obtaining and installing cert:
+obtain, install, and renew certificates:
+ (default) run Obtain & install a cert in your current webserver
+ certonly Obtain or renew a cert, but do not install it
+ renew Renew all previously obtained certs that are near expiry
+ -d DOMAINS Comma-separated list of domains to obtain a cert for
%s
--standalone Run a standalone webserver for authentication
%s
--webroot Place files in a server's webroot folder for authentication
- --script User provided shell scripts for authentication
+ --manual Obtain certs interactively, or using shell script hooks
-OR use different plugins to obtain (authenticate) the cert and then install it:
+ -n Run non-interactively
+ --test-cert Obtain a test cert from a staging server
+ --dry-run Test "renew" or "certonly" without saving any certs to disk
- --authenticator standalone --installer apache
+manage certificates:
+ certificates Display information about certs you have from Certbot
+ revoke Revoke a certificate (supply --cert-path)
+ rename Rename a certificate
+ delete Delete a certificate
+manage your account with Let's Encrypt:
+ register Create a Let's Encrypt ACME account
+ --agree-tos Agree to the ACME server's Subscriber Agreement
+ -m EMAIL Email address for important account notifications
+"""
+
+# This is the short help for certbot --help, where we disable argparse
+# altogether
+HELP_USAGE = """
More detailed help:
- -h, --help [topic] print this message, or detailed help on a topic;
- the available topics are:
+ -h, --help [TOPIC] print this message, or detailed help on a topic;
+ the available TOPICS are:
- all, automation, paths, security, testing, or any of the subcommands or
- plugins (certonly, renew, install, register, nginx, apache, standalone,
- webroot, script, etc.)
+ all, automation, commands, paths, security, testing, or any of the
+ subcommands or plugins (certonly, renew, install, register, nginx,
+ apache, standalone, webroot, etc.)
"""
@@ -140,19 +145,6 @@ def report_config_interaction(modified, modifiers):
VAR_MODIFIERS.setdefault(var, set()).update(modifiers)
-def usage_strings(plugins):
- """Make usage strings late so that plugins can be initialised late"""
- if "nginx" in plugins:
- nginx_doc = "--nginx Use the Nginx plugin for authentication & installation"
- else:
- nginx_doc = "(nginx support is experimental, buggy, and not installed by default)"
- if "apache" in plugins:
- apache_doc = "--apache Use the Apache plugin for authentication & installation"
- else:
- apache_doc = "(the apache plugin is not installed)"
- return USAGE % (apache_doc, nginx_doc), SHORT_USAGE
-
-
def possible_deprecation_warning(config):
"A deprecation warning for users with the old, not-self-upgrading letsencrypt-auto."
if cli_command != LEAUTO:
@@ -308,6 +300,104 @@ class HelpfulArgumentGroup(object):
"""Add a new command line argument to the argument group."""
self._parser.add(self._topic, *args, **kwargs)
+class CustomHelpFormatter(argparse.HelpFormatter):
+ """This is a clone of ArgumentDefaultsHelpFormatter, with bugfixes.
+
+ In particular we fix https://bugs.python.org/issue28742
+ """
+
+ def _get_help_string(self, action):
+ helpstr = action.help
+ if '%(default)' not in action.help and '(default:' not in action.help:
+ if action.default != argparse.SUPPRESS:
+ defaulting_nargs = [argparse.OPTIONAL, argparse.ZERO_OR_MORE]
+ if action.option_strings or action.nargs in defaulting_nargs:
+ helpstr += ' (default: %(default)s)'
+ return helpstr
+
+# The attributes here are:
+# short: a string that will be displayed by "certbot -h commands"
+# opts: a string that heads the section of flags with which this command is documented,
+# both for "cerbot -h SUBCOMMAND" and "certbot -h all"
+# usage: an optional string that overrides the header of "certbot -h SUBCOMMAND"
+VERB_HELP = [
+ ("run (default)", {
+ "short": "Obtain/renew a certificate, and install it",
+ "opts": "Options for obtaining & installing certs",
+ "usage": SHORT_USAGE.replace("[SUBCOMMAND]", ""),
+ "realname": "run"
+ }),
+ ("certonly", {
+ "short": "Obtain or renew a certificate, but do not install it",
+ "opts": "Options for modifying how a cert is obtained",
+ "usage": ("\n\n certbot certonly [options] [-d DOMAIN] [-d DOMAIN] ...\n\n"
+ "This command obtains a TLS/SSL certificate without installing it anywhere.")
+ }),
+ ("renew", {
+ "short": "Renew all certificates (or one specifed with --cert-name)",
+ "opts": ("The 'renew' subcommand will attempt to renew all"
+ " certificates (or more precisely, certificate lineages) you have"
+ " previously obtained if they are close to expiry, and print a"
+ " summary of the results. By default, 'renew' will reuse the options"
+ " used to create obtain or most recently successfully renew each"
+ " certificate lineage. You can try it with `--dry-run` first. For"
+ " more fine-grained control, you can renew individual lineages with"
+ " the `certonly` subcommand. Hooks are available to run commands"
+ " before and after renewal; see"
+ " https://certbot.eff.org/docs/using.html#renewal for more"
+ " information on these."),
+ "usage": "\n\n certbot renew [--cert-name NAME] [options]\n\n"
+ }),
+ ("certificates", {
+ "short": "List certificates managed by Certbot",
+ "opts": "List certificates managed by Certbot",
+ "usage": ("\n\n certbot certificates [options] ...\n\n"
+ "Print information about the status of certificates managed by Certbot.")
+ }),
+ ("delete", {
+ "short": "Clean up all files related to a certificate",
+ "opts": "Options for deleting a certificate"
+ }),
+ ("revoke", {
+ "short": "Revoke a certificate specified with --cert-path",
+ "opts": "Options for revocation of certs",
+ "usage": "\n\n certbot revoke --cert-path /path/to/fullchain.pem [options]\n\n"
+ }),
+ ("rename", {
+ "short": "Change a certificate's name (for management purposes)",
+ "opts": "Options for changing certificate names"
+ }),
+ ("register", {
+ "short": "Register for account with Let's Encrypt / other ACME server",
+ "opts": "Options for account registration & modification"
+ }),
+ ("install", {
+ "short": "Install an arbitrary cert in a server",
+ "opts": "Options for modifying how a cert is deployed"
+ }),
+ ("config_changes", {
+ "short": "Show changes that Certbot has made to server configurations",
+ "opts": "Options for controlling which changes are displayed"
+ }),
+ ("rollback", {
+ "short": "Roll back server conf changes made during cert installation",
+ "opts": "Options for rolling back server configuration changes"
+ }),
+ ("plugins", {
+ "short": "List plugins that are installed and available on your system",
+ "opts": 'Options for for the "plugins" subcommand'
+ }),
+ ("update_symlinks", {
+ "short": "Recreate symlinks in your /live/ directory",
+ "opts": ("Recreates cert and key symlinks in {0}, if you changed them by hand "
+ "or edited a renewal configuration file".format(
+ os.path.join(flag_default("config_dir"), "live")))
+ }),
+
+]
+# VERB_HELP is a list in order to preserve order, but a dict is sometimes useful
+VERB_HELP_MAP = dict(VERB_HELP)
+
class HelpfulArgumentParser(object):
"""Argparse Wrapper.
@@ -318,6 +408,7 @@ class HelpfulArgumentParser(object):
"""
+
def __init__(self, args, plugins, detect_defaults=False):
from certbot import main
self.VERBS = {"auth": main.obtain_cert, "certonly": main.obtain_cert,
@@ -326,26 +417,17 @@ class HelpfulArgumentParser(object):
"register": main.register, "renew": main.renew,
"revoke": main.revoke, "rollback": main.rollback,
"everything": main.run, "update_symlinks": main.update_symlinks,
- "certificates": main.certificates, "rename": main.rename}
+ "certificates": main.certificates, "rename": main.rename,
+ "delete": main.delete}
# List of topics for which additional help can be provided
HELP_TOPICS = ["all", "security", "paths", "automation", "testing"] + list(self.VERBS)
+ HELP_TOPICS += self.COMMANDS_TOPICS + ["manage"]
plugin_names = list(plugins)
self.help_topics = HELP_TOPICS + plugin_names + [None]
- usage, short_usage = usage_strings(plugins)
- self.parser = configargparse.ArgParser(
- prog="certbot",
- usage=short_usage,
- formatter_class=argparse.ArgumentDefaultsHelpFormatter,
- args_for_setting_config_path=["-c", "--config"],
- default_config_files=flag_default("config_files"))
-
- # This is the only way to turn off overly verbose config flag documentation
- self.parser._add_config_file_help = False # pylint: disable=protected-access
self.detect_defaults = detect_defaults
-
self.args = args
self.determine_verb()
help1 = self.prescan_for_flag("-h", self.help_topics)
@@ -354,13 +436,72 @@ class HelpfulArgumentParser(object):
self.help_arg = help1 or help2
else:
self.help_arg = help1 if isinstance(help1, str) else help2
- if self.help_arg is True:
- # just --help with no topic; avoid argparse altogether
- print(usage)
- sys.exit(0)
+
+ short_usage = self._usage_string(plugins, self.help_arg)
+
self.visible_topics = self.determine_help_topics(self.help_arg)
self.groups = {} # elements are added by .add_group()
- self.defaults = {} # elements are added by .parse_args()
+ self.defaults = {} # elements are added by .parse_args()
+
+ self.parser = configargparse.ArgParser(
+ prog="certbot",
+ usage=short_usage,
+ formatter_class=CustomHelpFormatter,
+ args_for_setting_config_path=["-c", "--config"],
+ default_config_files=flag_default("config_files"),
+ config_arg_help_message="path to config file (default: {0})".format(
+ " and ".join(flag_default("config_files"))))
+
+ # This is the only way to turn off overly verbose config flag documentation
+ self.parser._add_config_file_help = False # pylint: disable=protected-access
+
+ # Help that are synonyms for --help subcommands
+ COMMANDS_TOPICS = ["command", "commands", "subcommand", "subcommands", "verbs"]
+ def _list_subcommands(self):
+ longest = max(len(v) for v in VERB_HELP_MAP.keys())
+
+ text = "The full list of available SUBCOMMANDS is:\n\n"
+ for verb, props in sorted(VERB_HELP):
+ doc = props.get("short", "")
+ text += '{0:<{length}} {1}\n'.format(verb, doc, length=longest)
+
+ text += "\nYou can get more help on a specific subcommand with --help SUBCOMMAND\n"
+ return text
+
+ def _usage_string(self, plugins, help_arg):
+ """Make usage strings late so that plugins can be initialised late
+
+ :param plugins: all discovered plugins
+ :param help_arg: False for none; True for --help; "TOPIC" for --help TOPIC
+ :rtype: str
+ :returns: a short usage string for the top of --help TOPIC)
+ """
+ if "nginx" in plugins:
+ nginx_doc = "--nginx Use the Nginx plugin for authentication & installation"
+ else:
+ nginx_doc = "(the certbot nginx plugin is not installed)"
+ if "apache" in plugins:
+ apache_doc = "--apache Use the Apache plugin for authentication & installation"
+ else:
+ apache_doc = "(the cerbot apache plugin is not installed)"
+
+ usage = SHORT_USAGE
+ if help_arg == True:
+ print(usage + COMMAND_OVERVIEW % (apache_doc, nginx_doc) + HELP_USAGE)
+ sys.exit(0)
+ elif help_arg in self.COMMANDS_TOPICS:
+ print(usage + self._list_subcommands())
+ sys.exit(0)
+ elif help_arg == "all":
+ # if we're doing --help all, the OVERVIEW is part of the SHORT_USAGE at
+ # the top; if we're doing --help someothertopic, it's OT so it's not
+ usage += COMMAND_OVERVIEW % (apache_doc, nginx_doc)
+ else:
+ custom = VERB_HELP_MAP.get(help_arg, {}).get("usage", None)
+ usage = custom if custom else usage
+
+ return usage
+
def parse_args(self):
"""Parses command line arguments and returns the result.
@@ -382,8 +523,17 @@ class HelpfulArgumentParser(object):
# Do any post-parsing homework here
if self.verb == "renew":
+ if parsed_args.force_interactive:
+ raise errors.Error(
+ "{0} cannot be used with renew".format(
+ constants.FORCE_INTERACTIVE_FLAG))
parsed_args.noninteractive_mode = True
+ if parsed_args.force_interactive and parsed_args.noninteractive_mode:
+ raise errors.Error(
+ "Flag for non-interactive mode and {0} conflict".format(
+ constants.FORCE_INTERACTIVE_FLAG))
+
if parsed_args.staging or parsed_args.dry_run:
self.set_test_server(parsed_args)
@@ -562,7 +712,7 @@ class HelpfulArgumentParser(object):
util.add_deprecated_argument(
self.parser.add_argument, argument_name, num_args)
- def add_group(self, topic, **kwargs):
+ def add_group(self, topic, verbs=(), **kwargs):
"""Create a new argument group.
This method must be called once for every topic, however, calls
@@ -570,6 +720,8 @@ class HelpfulArgumentParser(object):
clarity.
:param str topic: Name of the new argument group.
+ :param str verbs: List of subcommands that should be documented as part of
+ this help group / topic
:returns: The new argument group.
:rtype: `HelpfulArgumentGroup`
@@ -577,6 +729,9 @@ class HelpfulArgumentParser(object):
"""
if self.visible_topics[topic]:
self.groups[topic] = self.parser.add_argument_group(topic, **kwargs)
+ if self.help_arg:
+ for v in verbs:
+ self.groups[topic].add_argument(v, help=VERB_HELP_MAP[v]["short"])
return HelpfulArgumentGroup(self, topic)
@@ -617,32 +772,17 @@ class HelpfulArgumentParser(object):
def _add_all_groups(helpful):
helpful.add_group("automation", description="Arguments for automating execution & other tweaks")
helpful.add_group("security", description="Security parameters & server settings")
- helpful.add_group(
- "testing", description="The following flags are meant for "
- "testing purposes only! Do NOT change them, unless you "
- "really know what you're doing!")
- # VERBS
- helpful.add_group(
- "renew", description="The 'renew' subcommand will attempt to renew all"
- " certificates (or more precisely, certificate lineages) you have"
- " previously obtained if they are close to expiry, and print a"
- " summary of the results. By default, 'renew' will reuse the options"
- " used to create obtain or most recently successfully renew each"
- " certificate lineage. You can try it with `--dry-run` first. For"
- " more fine-grained control, you can renew individual lineages with"
- " the `certonly` subcommand. Hooks are available to run commands"
- " before and after renewal; see"
- " https://certbot.eff.org/docs/using.html#renewal for more"
- " information on these.")
-
- helpful.add_group("certonly", description="Options for modifying how a cert is obtained")
- helpful.add_group("install", description="Options for modifying how a cert is deployed")
- helpful.add_group("revoke", description="Options for revocation of certs")
- helpful.add_group("rollback", description="Options for reverting config changes")
- helpful.add_group("plugins", description='Options for the "plugins" subcommand')
- helpful.add_group("config_changes",
- description="Options for showing a history of config changes")
+ helpful.add_group("testing",
+ description="The following flags are meant for testing and integration purposes only.")
helpful.add_group("paths", description="Arguments changing execution paths & servers")
+ helpful.add_group("manage",
+ description="Various subcommands and flags are available for managing your certificates:",
+ verbs=["certificates", "delete", "renew", "revoke", "rename"])
+
+ # VERBS
+ for verb, docs in VERB_HELP:
+ name = docs.get("realname", verb)
+ helpful.add_group(name, description=docs["opts"])
def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: disable=too-many-statements
@@ -671,28 +811,33 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
None, "-t", "--text", dest="text_mode", action="store_true",
help=argparse.SUPPRESS)
helpful.add(
- [None, "automation"], "-n", "--non-interactive", "--noninteractive",
+ [None, "automation", "run", "certonly"], "-n", "--non-interactive", "--noninteractive",
dest="noninteractive_mode", action="store_true",
help="Run without ever asking for user input. This may require "
"additional command line flags; the client will try to explain "
"which ones are required if it finds one missing")
helpful.add(
- [None, "run", "certonly"],
+ [None, "register", "run", "certonly"],
+ constants.FORCE_INTERACTIVE_FLAG, action="store_true",
+ help="Force Certbot to be interactive even if it detects it's not "
+ "being run in a terminal. This flag cannot be used with the "
+ "renew subcommand.")
+ helpful.add(
+ [None, "run", "certonly", "certificates"],
"-d", "--domains", "--domain", dest="domains",
metavar="DOMAIN", action=_DomainsAction, default=[],
help="Domain names to apply. For multiple domains you can use "
"multiple -d flags or enter a comma separated list of domains "
- "as a parameter.")
+ "as a parameter. (default: Ask)")
helpful.add(
- [None, "run", "certonly"],
+ [None, "run", "certonly", "manage", "rename", "delete", "certificates"],
"--cert-name", dest="certname",
metavar="CERTNAME", default=None,
help="Certificate name to apply. Only one certificate name can be used "
- "per Certbot run. To see certificate names, run 'certbot certificates'."
- "If there is no existing certificate with this name and "
- "domains are requested, create a new certificate with this name.")
+ "per Certbot run. To see certificate names, run 'certbot certificates'. "
+ "When creating a new certificate, specifies the new certificate's name.")
helpful.add(
- "rename",
+ ["rename", "manage"],
"--updated-cert-name", dest="new_certname",
metavar="NEW_CERTNAME", default=None,
help="New name for the certificate. Must be a valid filename.")
@@ -724,18 +869,18 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
help="With the register verb, indicates that details associated "
"with an existing registration, such as the e-mail address, "
"should be updated, rather than registering a new account.")
- helpful.add(None, "-m", "--email", help=config_help("email"))
+ helpful.add(["register", "automation"], "-m", "--email", help=config_help("email"))
helpful.add(
- ["automation", "renew", "certonly", "run"],
+ ["automation", "certonly", "run"],
"--keep-until-expiring", "--keep", "--reinstall",
dest="reinstall", action="store_true",
help="If the requested cert matches an existing cert, always keep the "
"existing one until it is due for renewal (for the "
- "'run' subcommand this means reinstall the existing cert)")
+ "'run' subcommand this means reinstall the existing cert). (default: Ask)")
helpful.add(
"automation", "--expand", action="store_true",
help="If an existing cert covers some subset of the requested names, "
- "always expand and replace it with the additional names.")
+ "always expand and replace it with the additional names. (default: Ask)")
helpful.add(
"automation", "--version", action="version",
version="%(prog)s {0}".format(certbot.__version__),
@@ -764,7 +909,7 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
"at this system. This option cannot be used with --csr.")
helpful.add(
"automation", "--agree-tos", dest="tos", action="store_true",
- help="Agree to the ACME Subscriber Agreement")
+ help="Agree to the ACME Subscriber Agreement (default: Ask)")
helpful.add(
"automation", "--account", metavar="ACCOUNT_ID",
help="Account ID to use")
@@ -778,15 +923,17 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
helpful.add(
"automation", "--no-self-upgrade", action="store_true",
help="(certbot-auto only) prevent the certbot-auto script from"
- " upgrading itself to newer released versions")
+ " upgrading itself to newer released versions (default: Upgrade"
+ " automatically)")
helpful.add(
- ["automation", "renew", "certonly"],
+ ["automation", "renew", "certonly", "run"],
"-q", "--quiet", dest="quiet", action="store_true",
help="Silence all output except errors. Useful for automation via cron."
" Implies --non-interactive.")
# overwrites server, handled in HelpfulArgumentParser.parse_args()
- helpful.add("testing", "--test-cert", "--staging", action='store_true', dest='staging',
- help='Use the staging server to obtain test (invalid) certs; equivalent'
+ helpful.add(["testing", "revoke", "run"], "--test-cert", "--staging",
+ action='store_true', dest='staging',
+ help='Use the staging server to obtain or revoke test (invalid) certs; equivalent'
' to --server ' + constants.STAGING_URI)
helpful.add(
"testing", "--debug", action="store_true",
@@ -797,11 +944,11 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
help=config_help("no_verify_ssl"),
default=flag_default("no_verify_ssl"))
helpful.add(
- ["certonly", "renew", "run"], "--tls-sni-01-port", type=int,
+ ["testing", "standalone", "apache", "nginx"], "--tls-sni-01-port", type=int,
default=flag_default("tls_sni_01_port"),
help=config_help("tls_sni_01_port"))
helpful.add(
- ["certonly", "renew", "run", "manual"], "--http-01-port", type=int,
+ ["testing", "standalone", "manual"], "--http-01-port", type=int,
dest="http01_port",
default=flag_default("http01_port"), help=config_help("http01_port"))
helpful.add(
@@ -817,11 +964,11 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
helpful.add(
"security", "--redirect", action="store_true",
help="Automatically redirect all HTTP traffic to HTTPS for the newly "
- "authenticated vhost.", dest="redirect", default=None)
+ "authenticated vhost. (default: Ask)", dest="redirect", default=None)
helpful.add(
"security", "--no-redirect", action="store_false",
help="Do not automatically redirect all HTTP traffic to HTTPS for the newly "
- "authenticated vhost.", dest="redirect", default=None)
+ "authenticated vhost. (default: Ask)", dest="redirect", default=None)
helpful.add(
"security", "--hsts", action="store_true",
help="Add the Strict-Transport-Security header to every HTTP response."
@@ -829,8 +976,7 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
" Defends against SSL Stripping.", dest="hsts", default=False)
helpful.add(
"security", "--no-hsts", action="store_false",
- help="Do not automatically add the Strict-Transport-Security header"
- " to every HTTP response.", dest="hsts", default=False)
+ help=argparse.SUPPRESS, dest="hsts", default=False)
helpful.add(
"security", "--uir", action="store_true",
help="Add the \"Content-Security-Policy: upgrade-insecure-requests\""
@@ -838,9 +984,7 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
" https:// for every http:// resource.", dest="uir", default=None)
helpful.add(
"security", "--no-uir", action="store_false",
- help="Do not automatically set the \"Content-Security-Policy:"
- " upgrade-insecure-requests\" header to every HTTP response.",
- dest="uir", default=None)
+ help=argparse.SUPPRESS, dest="uir", default=None)
helpful.add(
"security", "--staple-ocsp", action="store_true",
help="Enables OCSP Stapling. A valid OCSP response is stapled to"
@@ -848,14 +992,13 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
dest="staple", default=None)
helpful.add(
"security", "--no-staple-ocsp", action="store_false",
- help="Do not automatically enable OCSP Stapling.",
- dest="staple", default=None)
+ help=argparse.SUPPRESS, dest="staple", default=None)
helpful.add(
"security", "--strict-permissions", action="store_true",
help="Require that all configuration files are owned by the current "
"user; only needed if your config is somewhere unsafe like /tmp/")
helpful.add(
- ["manual", "standalone", "certonly", "renew", "run"],
+ ["manual", "standalone", "certonly", "renew"],
"--preferred-challenges", dest="pref_challs",
action=_PrefChallAction, default=[],
help='A sorted, comma delimited list of the preferred challenge to '
@@ -897,7 +1040,8 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
" see if the programs being run are in the $PATH, so that mistakes can"
" be caught early, even when the hooks aren't being run just yet. The"
" validation is rather simplistic and fails if you use more advanced"
- " shell constructs, so you can use this switch to disable it.")
+ " shell constructs, so you can use this switch to disable it."
+ " (default: False)")
helpful.add_deprecated_argument("--agree-dev-preview", 0)
helpful.add_deprecated_argument("--dialog", 0)
@@ -917,12 +1061,15 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
def _create_subparsers(helpful):
helpful.add("config_changes", "--num", type=int,
help="How many past revisions you want to be displayed")
+
+ from certbot.client import sample_user_agent # avoid import loops
helpful.add(
None, "--user-agent", default=None,
help="Set a custom user agent string for the client. User agent strings allow "
"the CA to collect high level statistics about success rates by OS and "
"plugin. If you wish to hide your server OS version from the Let's "
- 'Encrypt server, set this to "".')
+ 'Encrypt server, set this to "". '
+ '(default: {0})'.format(sample_user_agent()))
helpful.add("certonly",
"--csr", type=read_file,
help="Path to a Certificate Signing Request (CSR) in DER or PEM format."
@@ -949,10 +1096,8 @@ def _paths_parser(helpful):
if verb == "help":
verb = helpful.help_arg
- cph = "Path to where cert is saved (with auth --csr), installed from or revoked."
- section = "paths"
- if verb in ("install", "revoke", "certonly"):
- section = verb
+ cph = "Path to where cert is saved (with auth --csr), installed from, or revoked."
+ section = ["paths", "install", "revoke", "certonly", "manage"]
if verb == "certonly":
add(section, "--cert-path", type=os.path.abspath,
default=flag_default("auth_cert_path"), help=cph)
@@ -974,7 +1119,7 @@ def _paths_parser(helpful):
default_cp = None
if verb == "certonly":
default_cp = flag_default("auth_chain_path")
- add("paths", "--fullchain-path", default=default_cp, type=os.path.abspath,
+ add(["install", "paths"], "--fullchain-path", default=default_cp, type=os.path.abspath,
help="Accompanying path to a full certificate chain (cert plus chain).")
add("paths", "--chain-path", default=default_cp, type=os.path.abspath,
help="Accompanying path to a certificate chain.")
@@ -997,24 +1142,20 @@ def _plugins_parsing(helpful, plugins):
"a particular plugin by setting options provided below. Running "
"--help will list flags specific to that plugin.")
- helpful.add(
- "plugins", "-a", "--authenticator", help="Authenticator plugin name.")
- helpful.add(
- "plugins", "-i", "--installer", help="Installer plugin name (also used to find domains).")
- helpful.add(
- "plugins", "--configurator", help="Name of the plugin that is "
- "both an authenticator and an installer. Should not be used "
- "together with --authenticator or --installer.")
- helpful.add(["plugins", "certonly", "run", "install"],
+ helpful.add("plugins", "--configurator",
+ help="Name of the plugin that is both an authenticator and an installer."
+ " Should not be used together with --authenticator or --installer. "
+ "(default: Ask)")
+ helpful.add("plugins", "-a", "--authenticator", help="Authenticator plugin name.")
+ helpful.add("plugins", "-i", "--installer",
+ help="Installer plugin name (also used to find domains).")
+ helpful.add(["plugins", "certonly", "run", "install", "config_changes"],
"--apache", action="store_true",
help="Obtain and install certs using Apache")
- helpful.add(["plugins", "certonly", "run", "install"],
- "--nginx", action="store_true",
- help="Obtain and install certs using Nginx")
+ helpful.add(["plugins", "certonly", "run", "install", "config_changes"],
+ "--nginx", action="store_true", help="Obtain and install certs using Nginx")
helpful.add(["plugins", "certonly"], "--standalone", action="store_true",
help='Obtain certs using a "standalone" webserver.')
- helpful.add(["plugins", "certonly"], "--script", action="store_true",
- help='Obtain certs using shell script(s)')
helpful.add(["plugins", "certonly"], "--manual", action="store_true",
help='Provide laborious manual instructions for obtaining a cert')
helpful.add(["plugins", "certonly"], "--webroot", action="store_true",
diff --git a/certbot/client.py b/certbot/client.py
index d58f9457f..cfd2b8487 100644
--- a/certbot/client.py
+++ b/certbot/client.py
@@ -15,7 +15,6 @@ import certbot
from certbot import account
from certbot import auth_handler
-from certbot import configuration
from certbot import constants
from certbot import crypto_util
from certbot import errors
@@ -38,11 +37,11 @@ def acme_from_config_key(config, key):
"Wrangle ACME client construction"
# TODO: Allow for other alg types besides RS256
net = acme_client.ClientNetwork(key, verify_ssl=(not config.no_verify_ssl),
- user_agent=_determine_user_agent(config))
+ user_agent=determine_user_agent(config))
return acme_client.Client(config.server, key=key, net=net)
-def _determine_user_agent(config):
+def determine_user_agent(config):
"""
Set a user_agent string in the config based on the choice of plugins.
(this wasn't knowable at construction time)
@@ -59,6 +58,16 @@ def _determine_user_agent(config):
ua = config.user_agent
return ua
+def sample_user_agent():
+ "Document what this Certbot's user agent string will be like."
+ class DummyConfig(object):
+ "Shim for computing a sample user agent."
+ def __init__(self):
+ self.authenticator = "XXX"
+ self.installer = "YYY"
+ self.user_agent = None
+ return determine_user_agent(DummyConfig())
+
def register(config, account_storage, tos_cb=None):
"""Register new account with an ACME CA.
@@ -297,7 +306,7 @@ class Client(object):
new_name, OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, certr.body.wrapped),
key.pem, crypto_util.dump_pyopenssl_chain(chain),
- configuration.RenewerConfiguration(self.config.namespace))
+ self.config)
def save_certificate(self, certr, chain_cert,
cert_path, chain_path, fullchain_path):
diff --git a/certbot/configuration.py b/certbot/configuration.py
index 1d4243272..d25378922 100644
--- a/certbot/configuration.py
+++ b/certbot/configuration.py
@@ -25,9 +25,16 @@ class NamespaceConfig(object):
- `csr_dir`
- `in_progress_dir`
- `key_dir`
- - `renewer_config_file`
- `temp_checkpoint_dir`
+ And the following paths are dynamically resolved using
+ :attr:`~certbot.interfaces.IConfig.config_dir` and relative
+ paths defined in :py:mod:`certbot.constants`:
+
+ - `default_archive_dir`
+ - `live_dir`
+ - `renewal_configs_dir`
+
:ivar namespace: Namespace typically produced by
:meth:`argparse.ArgumentParser.parse_args`.
:type namespace: :class:`argparse.Namespace`
@@ -85,16 +92,6 @@ class NamespaceConfig(object):
new_ns = copy.deepcopy(self.namespace)
return type(self)(new_ns)
-
-class RenewerConfiguration(object):
- """Configuration wrapper for renewer."""
-
- def __init__(self, namespace):
- self.namespace = namespace
-
- def __getattr__(self, name):
- return getattr(self.namespace, name)
-
@property
def default_archive_dir(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.config_dir, constants.ARCHIVE_DIR)
@@ -108,11 +105,6 @@ class RenewerConfiguration(object):
return os.path.join(
self.namespace.config_dir, constants.RENEWAL_CONFIGS_DIR)
- @property
- def renewer_config_file(self): # pylint: disable=missing-docstring
- return os.path.join(
- self.namespace.config_dir, constants.RENEWER_CONFIG_FILENAME)
-
def check_config_sanity(config):
"""Validate command line options and display error message if
diff --git a/certbot/constants.py b/certbot/constants.py
index 117301380..7d713d29f 100644
--- a/certbot/constants.py
+++ b/certbot/constants.py
@@ -93,5 +93,5 @@ TEMP_CHECKPOINT_DIR = "temp_checkpoint"
RENEWAL_CONFIGS_DIR = "renewal"
"""Renewal configs directory, relative to `IConfig.config_dir`."""
-RENEWER_CONFIG_FILENAME = "renewer.conf"
-"""Renewer config file name (relative to `IConfig.config_dir`)."""
+FORCE_INTERACTIVE_FLAG = "--force-interactive"
+"""Flag to disable TTY checking in IDisplay."""
diff --git a/certbot/display/enhancements.py b/certbot/display/enhancements.py
index 3b128a874..d2ffe2e0d 100644
--- a/certbot/display/enhancements.py
+++ b/certbot/display/enhancements.py
@@ -48,7 +48,8 @@ def redirect_by_default():
code, selection = util(interfaces.IDisplay).menu(
"Please choose whether HTTPS access is required or optional.",
- choices, default=0, cli_flag="--redirect / --no-redirect")
+ choices, default=0,
+ cli_flag="--redirect / --no-redirect", force_interactive=True)
if code != display_util.OK:
return False
diff --git a/certbot/display/ops.py b/certbot/display/ops.py
index b9cd1e38c..85343fdc3 100644
--- a/certbot/display/ops.py
+++ b/certbot/display/ops.py
@@ -46,7 +46,8 @@ def get_email(invalid=False, optional=True):
while True:
try:
code, email = z_util(interfaces.IDisplay).input(
- invalid_prefix + msg if invalid else msg)
+ invalid_prefix + msg if invalid else msg,
+ force_interactive=True)
except errors.MissingCommandlineFlag:
msg = ("You should register before running non-interactively, "
"or provide --agree-tos and --email flags.")
@@ -79,7 +80,7 @@ def choose_account(accounts):
labels = [acc.slug for acc in accounts]
code, index = z_util(interfaces.IDisplay).menu(
- "Please choose an account", labels)
+ "Please choose an account", labels, force_interactive=True)
if code == display_util.OK:
return accounts[index]
else:
@@ -157,7 +158,7 @@ def _filter_names(names):
code, names = z_util(interfaces.IDisplay).checklist(
"Which names would you like to activate HTTPS for?",
- tags=sorted_names, cli_flag="--domains")
+ tags=sorted_names, cli_flag="--domains", force_interactive=True)
return code, [str(s) for s in names]
@@ -173,7 +174,7 @@ def _choose_names_manually(prompt_prefix=""):
code, input_ = z_util(interfaces.IDisplay).input(
prompt_prefix +
"Please enter in your domain name(s) (comma and/or space separated) ",
- cli_flag="--domains")
+ cli_flag="--domains", force_interactive=True)
if code == display_util.OK:
invalid_domains = dict()
@@ -211,7 +212,8 @@ def _choose_names_manually(prompt_prefix=""):
if retry_message:
# We had error in input
- retry = z_util(interfaces.IDisplay).yesno(retry_message)
+ retry = z_util(interfaces.IDisplay).yesno(retry_message,
+ force_interactive=True)
if retry:
return _choose_names_manually()
else:
diff --git a/certbot/display/util.py b/certbot/display/util.py
index 47bce87b4..502426626 100644
--- a/certbot/display/util.py
+++ b/certbot/display/util.py
@@ -1,14 +1,18 @@
"""Certbot display."""
+import logging
import os
import textwrap
+import sys
import six
import zope.interface
+from certbot import constants
from certbot import interfaces
from certbot import errors
from certbot.display import completer
+logger = logging.getLogger(__name__)
WIDTH = 72
@@ -50,19 +54,25 @@ def _wrap_lines(msg):
@zope.interface.implementer(interfaces.IDisplay)
class FileDisplay(object):
"""File-based display."""
+ # pylint: disable=too-many-arguments
+ # see https://github.com/certbot/certbot/issues/3915
- def __init__(self, outfile):
+ def __init__(self, outfile, force_interactive):
super(FileDisplay, self).__init__()
self.outfile = outfile
+ self.force_interactive = force_interactive
+ self.skipped_interaction = False
- def notification(self, message, pause=True, wrap=True):
- # pylint: disable=unused-argument
+ def notification(self, message, pause=True,
+ wrap=True, force_interactive=False):
"""Displays a notification and waits for user acceptance.
:param str message: Message to display
:param bool pause: Whether or not the program should pause for the
user's confirmation
:param bool wrap: Whether or not the application should wrap text
+ :param bool force_interactive: True if it's safe to prompt the user
+ because it won't cause any workflow regressions
"""
side_frame = "-" * 79
@@ -72,10 +82,14 @@ class FileDisplay(object):
"{line}{frame}{line}{msg}{line}{frame}{line}".format(
line=os.linesep, frame=side_frame, msg=message))
if pause:
- six.moves.input("Press Enter to Continue")
+ if self._can_interact(force_interactive):
+ six.moves.input("Press Enter to Continue")
+ else:
+ logger.debug("Not pausing for user confirmation")
def menu(self, message, choices, ok_label="", cancel_label="",
- help_label="", **unused_kwargs):
+ help_label="", default=None,
+ cli_flag=None, force_interactive=False, **unused_kwargs):
# pylint: disable=unused-argument
"""Display a menu.
@@ -86,7 +100,10 @@ class FileDisplay(object):
:param choices: Menu lines, len must be > 0
:type choices: list of tuples (tag, item) or
list of descriptions (tags will be enumerated)
- :param dict _kwargs: absorbs default / cli_args
+ :param default: default value to return (if one exists)
+ :param str cli_flag: option used to set this value with the CLI
+ :param bool force_interactive: True if it's safe to prompt the user
+ because it won't cause any workflow regressions
:returns: tuple of (`code`, `index`) where
`code` - str display exit code
@@ -95,18 +112,25 @@ class FileDisplay(object):
:rtype: tuple
"""
+ if self._return_default(message, default, cli_flag, force_interactive):
+ return OK, default
+
self._print_menu(message, choices)
code, selection = self._get_valid_int_ans(len(choices))
return code, selection - 1
- def input(self, message, **unused_kwargs):
+ def input(self, message, default=None,
+ cli_flag=None, force_interactive=False, **unused_kwargs):
# pylint: disable=no-self-use
"""Accept input from the user.
:param str message: message to display to the user
- :param dict _kwargs: absorbs default / cli_args
+ :param default: default value to return (if one exists)
+ :param str cli_flag: option used to set this value with the CLI
+ :param bool force_interactive: True if it's safe to prompt the user
+ because it won't cause any workflow regressions
:returns: tuple of (`code`, `input`) where
`code` - str display exit code
@@ -114,6 +138,9 @@ class FileDisplay(object):
:rtype: tuple
"""
+ if self._return_default(message, default, cli_flag, force_interactive):
+ return OK, default
+
ans = six.moves.input(
textwrap.fill(
"%s (Enter 'c' to cancel): " % message,
@@ -126,7 +153,8 @@ class FileDisplay(object):
else:
return OK, ans
- def yesno(self, message, yes_label="Yes", no_label="No", **unused_kwargs):
+ def yesno(self, message, yes_label="Yes", no_label="No", default=None,
+ cli_flag=None, force_interactive=False, **unused_kwargs):
"""Query the user with a yes/no question.
Yes and No label must begin with different letters, and must contain at
@@ -135,12 +163,18 @@ class FileDisplay(object):
:param str message: question for the user
:param str yes_label: Label of the "Yes" parameter
:param str no_label: Label of the "No" parameter
- :param dict _kwargs: absorbs default / cli_args
+ :param default: default value to return (if one exists)
+ :param str cli_flag: option used to set this value with the CLI
+ :param bool force_interactive: True if it's safe to prompt the user
+ because it won't cause any workflow regressions
:returns: True for "Yes", False for "No"
:rtype: bool
"""
+ if self._return_default(message, default, cli_flag, force_interactive):
+ return default
+
side_frame = ("-" * 79) + os.linesep
message = _wrap_lines(message)
@@ -162,14 +196,18 @@ class FileDisplay(object):
ans.startswith(no_label[0].upper())):
return False
- def checklist(self, message, tags, default_status=True, **unused_kwargs):
+ def checklist(self, message, tags, default_status=True, default=None,
+ cli_flag=None, force_interactive=False, **unused_kwargs):
# pylint: disable=unused-argument
"""Display a checklist.
:param str message: Message to display to user
:param list tags: `str` tags to select, len(tags) > 0
:param bool default_status: Not used for FileDisplay
- :param dict _kwargs: absorbs default / cli_args
+ :param default: default value to return (if one exists)
+ :param str cli_flag: option used to set this value with the CLI
+ :param bool force_interactive: True if it's safe to prompt the user
+ because it won't cause any workflow regressions
:returns: tuple of (`code`, `tags`) where
`code` - str display exit code
@@ -177,12 +215,16 @@ class FileDisplay(object):
:rtype: tuple
"""
+ if self._return_default(message, default, cli_flag, force_interactive):
+ return OK, default
+
while True:
self._print_menu(message, tags)
code, ans = self.input("Select the appropriate numbers separated "
"by commas and/or spaces, or leave input "
- "blank to select all options shown")
+ "blank to select all options shown",
+ force_interactive=True)
if code == OK:
if len(ans.strip()) == 0:
@@ -197,10 +239,65 @@ class FileDisplay(object):
else:
return code, []
- def directory_select(self, message, **unused_kwargs):
+ def _return_default(self, prompt, default, cli_flag, force_interactive):
+ """Should we return the default instead of prompting the user?
+
+ :param str prompt: prompt for the user
+ :param default: default answer to prompt
+ :param str cli_flag: command line option for setting an answer
+ to this question
+ :param bool force_interactive: if interactivity is forced by the
+ IDisplay call
+
+ :returns: True if we should return the default without prompting
+ :rtype: bool
+
+ """
+ msg = "Invalid IDisplay call for this prompt:\n{0}".format(prompt)
+ if cli_flag:
+ msg += ("\nYou can set an answer to "
+ "this prompt with the {0} flag".format(cli_flag))
+ assert default is not None or force_interactive, msg
+
+ if self._can_interact(force_interactive):
+ return False
+ else:
+ logger.debug(
+ "Falling back to default %s for the prompt:\n%s",
+ default, prompt)
+ return True
+
+ def _can_interact(self, force_interactive):
+ """Can we safely interact with the user?
+
+ :param bool force_interactive: if interactivity is forced by the
+ IDisplay call
+
+ :returns: True if the display can interact with the user
+ :rtype: bool
+
+ """
+ if (self.force_interactive or force_interactive or
+ sys.stdin.isatty() and self.outfile.isatty()):
+ return True
+ elif not self.skipped_interaction:
+ logger.warning(
+ "Skipped user interaction because Certbot doesn't appear to "
+ "be running in a terminal. You should probably include "
+ "--non-interactive or %s on the command line.",
+ constants.FORCE_INTERACTIVE_FLAG)
+ self.skipped_interaction = True
+ return False
+
+ def directory_select(self, message, default=None, cli_flag=None,
+ force_interactive=False, **unused_kwargs):
"""Display a directory selection screen.
:param str message: prompt to give the user
+ :param default: default value to return (if one exists)
+ :param str cli_flag: option used to set this value with the CLI
+ :param bool force_interactive: True if it's safe to prompt the user
+ because it won't cause any workflow regressions
:returns: tuple of the form (`code`, `string`) where
`code` - display exit code
@@ -208,7 +305,7 @@ class FileDisplay(object):
"""
with completer.Completer():
- return self.input(message)
+ return self.input(message, default, cli_flag, force_interactive)
def _scrub_checklist_input(self, indices, tags):
# pylint: disable=no-self-use
@@ -310,7 +407,7 @@ class FileDisplay(object):
class NoninteractiveDisplay(object):
"""An iDisplay implementation that never asks for interactive user input"""
- def __init__(self, outfile):
+ def __init__(self, outfile, *unused_args, **unused_kwargs):
super(NoninteractiveDisplay, self).__init__()
self.outfile = outfile
@@ -324,7 +421,7 @@ class NoninteractiveDisplay(object):
msg += "\n\n(You can set this with the {0} flag)".format(cli_flag)
raise errors.MissingCommandlineFlag(msg)
- def notification(self, message, pause=False, wrap=True):
+ def notification(self, message, pause=False, wrap=True, **unused_kwargs):
# pylint: disable=unused-argument
"""Displays a notification without waiting for user acceptance.
@@ -341,7 +438,7 @@ class NoninteractiveDisplay(object):
line=os.linesep, frame=side_frame, msg=message))
def menu(self, message, choices, ok_label=None, cancel_label=None,
- help_label=None, default=None, cli_flag=None):
+ help_label=None, default=None, cli_flag=None, **unused_kwargs):
# pylint: disable=unused-argument,too-many-arguments
"""Avoid displaying a menu.
@@ -364,7 +461,7 @@ class NoninteractiveDisplay(object):
return OK, default
- def input(self, message, default=None, cli_flag=None):
+ def input(self, message, default=None, cli_flag=None, **unused_kwargs):
"""Accept input from the user.
:param str message: message to display to the user
@@ -381,7 +478,8 @@ class NoninteractiveDisplay(object):
else:
return OK, default
- def yesno(self, message, yes_label=None, no_label=None, default=None, cli_flag=None):
+ def yesno(self, message, yes_label=None, no_label=None,
+ default=None, cli_flag=None, **unused_kwargs):
# pylint: disable=unused-argument
"""Decide Yes or No, without asking anybody
@@ -398,8 +496,8 @@ class NoninteractiveDisplay(object):
else:
return default
- def checklist(self, message, tags, default=None, cli_flag=None, **kwargs):
- # pylint: disable=unused-argument
+ def checklist(self, message, tags, default=None,
+ cli_flag=None, **unused_kwargs):
"""Display a checklist.
:param str message: Message to display to user
@@ -417,7 +515,8 @@ class NoninteractiveDisplay(object):
else:
return OK, default
- def directory_select(self, message, default=None, cli_flag=None):
+ def directory_select(self, message, default=None,
+ cli_flag=None, **unused_kwargs):
"""Simulate prompting the user for a directory.
This function returns default if it is not ``None``, otherwise,
diff --git a/certbot/hooks.py b/certbot/hooks.py
index 951d039b9..6757c5a72 100644
--- a/certbot/hooks.py
+++ b/certbot/hooks.py
@@ -108,7 +108,10 @@ def execute(shell_cmd):
:returns: `tuple` (`str` stderr, `str` stdout)"""
- cmd = Popen(shell_cmd, shell=True, stdout=PIPE, stderr=PIPE)
+ # universal_newlines causes Popen.communicate()
+ # to return str objects instead of bytes in Python 3
+ cmd = Popen(shell_cmd, shell=True, stdout=PIPE,
+ stderr=PIPE, universal_newlines=True)
out, err = cmd.communicate()
if cmd.returncode != 0:
logger.error('Hook command "%s" returned error code %d',
diff --git a/certbot/interfaces.py b/certbot/interfaces.py
index 8e7d887f0..2df2abfe8 100644
--- a/certbot/interfaces.py
+++ b/certbot/interfaces.py
@@ -138,15 +138,15 @@ class IAuthenticator(IPlugin):
"""
def get_chall_pref(domain):
- """Return list of challenge preferences.
+ """Return `collections.Iterable` of challenge preferences.
:param str domain: Domain for which challenge preferences are sought.
- :returns: List of challenge types (subclasses of
+ :returns: `collections.Iterable` of challenge types (subclasses of
:class:`acme.challenges.Challenge`) with the most
preferred challenges first. If a type is not specified, it means the
Authenticator cannot perform the challenge.
- :rtype: list
+ :rtype: `collections.Iterable`
"""
@@ -158,7 +158,7 @@ class IAuthenticator(IPlugin):
instances, such that it contains types found within
:func:`get_chall_pref` only.
- :returns: List of ACME
+ :returns: `collections.Iterable` of ACME
:class:`~acme.challenges.ChallengeResponse` instances
or if the :class:`~acme.challenges.Challenge` cannot
be fulfilled then:
@@ -168,7 +168,7 @@ class IAuthenticator(IPlugin):
``False``
Authenticator will never be able to perform (error).
- :rtype: :class:`list` of
+ :rtype: :class:`collections.Iterable` of
:class:`acme.challenges.ChallengeResponse`,
where responses are required to be returned in
the same order as corresponding input challenges
@@ -201,7 +201,7 @@ class IConfig(zope.interface.Interface):
"""
server = zope.interface.Attribute("ACME Directory Resource URI.")
email = zope.interface.Attribute(
- "Email used for registration and recovery contact.")
+ "Email used for registration and recovery contact. (default: Ask)")
rsa_key_size = zope.interface.Attribute("Size of the RSA key.")
must_staple = zope.interface.Attribute(
"Adds the OCSP Must Staple extension to the certificate. "
@@ -223,9 +223,6 @@ class IConfig(zope.interface.Interface):
temp_checkpoint_dir = zope.interface.Attribute(
"Temporary checkpoint directory.")
- renewer_config_file = zope.interface.Attribute(
- "Location of renewal configuration file.")
-
no_verify_ssl = zope.interface.Attribute(
"Disable verification of the ACME server's certificate.")
tls_sni_01_port = zope.interface.Attribute(
@@ -257,7 +254,7 @@ class IInstaller(IPlugin):
def get_all_names():
"""Returns all names that may be authenticated.
- :rtype: `list` of `str`
+ :rtype: `collections.Iterable` of `str`
"""
@@ -292,11 +289,11 @@ class IInstaller(IPlugin):
"""
def supported_enhancements():
- """Returns a list of supported enhancements.
+ """Returns a `collections.Iterable` of supported enhancements.
:returns: supported enhancements which should be a subset of
:const:`~certbot.constants.ENHANCEMENTS`
- :rtype: :class:`list` of :class:`str`
+ :rtype: :class:`collections.Iterable` of :class:`str`
"""
@@ -364,21 +361,29 @@ class IInstaller(IPlugin):
class IDisplay(zope.interface.Interface):
"""Generic display."""
+ # pylint: disable=too-many-arguments
+ # see https://github.com/certbot/certbot/issues/3915
- def notification(message, pause, wrap=True):
+ def notification(message, pause, wrap=True, force_interactive=False):
"""Displays a string message
:param str message: Message to display
:param bool pause: Whether or not the application should pause for
confirmation (if available)
:param bool wrap: Whether or not the application should wrap text
+ :param bool force_interactive: True if it's safe to prompt the user
+ because it won't cause any workflow regressions
"""
- def menu(message, choices, ok_label="OK", # pylint: disable=too-many-arguments
- cancel_label="Cancel", help_label="", default=None, cli_flag=None):
+ def menu(message, choices, ok_label="OK",
+ cancel_label="Cancel", help_label="",
+ default=None, cli_flag=None, force_interactive=False):
"""Displays a generic menu.
+ When not setting force_interactive=True, you must provide a
+ default value.
+
:param str message: message to display
:param choices: choices
@@ -389,6 +394,8 @@ class IDisplay(zope.interface.Interface):
:param str help_label: label for Help button
:param int default: default (non-interactive) choice from the menu
:param str cli_flag: to automate choice from the menu, eg "--keep"
+ :param bool force_interactive: True if it's safe to prompt the user
+ because it won't cause any workflow regressions
:returns: tuple of (`code`, `index`) where
`code` - str display exit code
@@ -399,10 +406,16 @@ class IDisplay(zope.interface.Interface):
"""
- def input(message, default=None, cli_args=None):
+ def input(message, default=None, cli_args=None, force_interactive=False):
"""Accept input from the user.
+ When not setting force_interactive=True, you must provide a
+ default value.
+
:param str message: message to display to the user
+ :param str default: default (non-interactive) response to prompt
+ :param bool force_interactive: True if it's safe to prompt the user
+ because it won't cause any workflow regressions
:returns: tuple of (`code`, `input`) where
`code` - str display exit code
@@ -415,14 +428,19 @@ class IDisplay(zope.interface.Interface):
"""
def yesno(message, yes_label="Yes", no_label="No", default=None,
- cli_args=None):
+ cli_args=None, force_interactive=False):
"""Query the user with a yes/no question.
Yes and No label must begin with different letters.
+ When not setting force_interactive=True, you must provide a
+ default value.
+
:param str message: question for the user
:param str default: default (non-interactive) choice from the menu
:param str cli_flag: to automate choice from the menu, eg "--redirect / --no-redirect"
+ :param bool force_interactive: True if it's safe to prompt the user
+ because it won't cause any workflow regressions
:returns: True for "Yes", False for "No"
:rtype: bool
@@ -432,14 +450,20 @@ class IDisplay(zope.interface.Interface):
"""
- def checklist(message, tags, default_state, default=None, cli_args=None):
+ def checklist(message, tags, default_state,
+ default=None, cli_args=None, force_interactive=False):
"""Allow for multiple selections from a menu.
+ When not setting force_interactive=True, you must provide a
+ default value.
+
:param str message: message to display to the user
:param list tags: where each is of type :class:`str` len(tags) > 0
:param bool default_status: If True, items are in a selected state by default.
:param str default: default (non-interactive) state of the checklist
:param str cli_flag: to automate choice from the menu, eg "--domains"
+ :param bool force_interactive: True if it's safe to prompt the user
+ because it won't cause any workflow regressions
:returns: tuple of the form (code, list_tags) where
`code` - int display exit code
@@ -451,15 +475,21 @@ class IDisplay(zope.interface.Interface):
"""
- def directory_select(self, message, default=None, cli_flag=None):
+ def directory_select(self, message, default=None,
+ cli_flag=None, force_interactive=False):
"""Display a directory selection screen.
+ When not setting force_interactive=True, you must provide a
+ default value.
+
:param str message: prompt to give the user
:param default: the default value to return, if one exists, when
using the NoninteractiveDisplay
:param str cli_flag: option used to set this value with the CLI,
if one exists, to be included in error messages given by
NoninteractiveDisplay
+ :param bool force_interactive: True if it's safe to prompt the user
+ because it won't cause any workflow regressions
:returns: tuple of the form (`code`, `string`) where
`code` - int display exit code
diff --git a/certbot/main.py b/certbot/main.py
index b48172677..7e2a999a4 100644
--- a/certbot/main.py
+++ b/certbot/main.py
@@ -139,7 +139,8 @@ def _handle_subset_cert_request(config, domains, cert):
br=os.linesep)
if config.expand or config.renew_by_default or zope.component.getUtility(
interfaces.IDisplay).yesno(question, "Expand", "Cancel",
- cli_flag="--expand"):
+ cli_flag="--expand",
+ force_interactive=True):
return "renew", cert
else:
reporter_util = zope.component.getUtility(interfaces.IReporter)
@@ -188,7 +189,8 @@ def _handle_identical_cert_request(config, lineage):
"Renew & replace the cert (limit ~5 per 7 days)"]
display = zope.component.getUtility(interfaces.IDisplay)
- response = display.menu(question, choices, "OK", "Cancel", default=0)
+ response = display.menu(question, choices, "OK", "Cancel",
+ default=0, force_interactive=True)
if response[0] == display_util.CANCEL:
# TODO: Add notification related to command-line options for
# skipping the menu for this case.
@@ -282,17 +284,26 @@ def _find_domains_or_certname(config, installer):
"""Retrieve domains and certname from config or user input.
"""
domains = None
+ certname = config.certname
+ # first, try to get domains from the config
if config.domains:
domains = config.domains
- elif not config.certname:
+ # if we can't do that but we have a certname, get the domains
+ # with that certname
+ elif certname:
+ domains = cert_manager.domains_for_certname(config, certname)
+
+ # that certname might not have existed, or there was a problem.
+ # try to get domains from the user.
+ if not domains:
domains = display_ops.choose_names(installer)
- if not domains and not config.certname:
+ if not domains and not certname:
raise errors.Error("Please specify --domains, or --installer that "
"will help in domain names autodiscovery, or "
"--cert-name for an existing certificate name.")
- return domains, config.certname
+ return domains, certname
def _report_new_cert(config, cert_path, fullchain_path):
@@ -365,7 +376,8 @@ def _determine_account(config):
"server at {1}".format(
regr.terms_of_service, config.server))
obj = zope.component.getUtility(interfaces.IDisplay)
- return obj.yesno(msg, "Agree", "Cancel", cli_flag="--agree-tos")
+ return obj.yesno(msg, "Agree", "Cancel",
+ cli_flag="--agree-tos", force_interactive=True)
try:
acc, acme = client.register(
@@ -511,6 +523,14 @@ def rename(config, unused_plugins):
"""
cert_manager.rename_lineage(config)
+def delete(config, unused_plugins):
+ """Delete a certificate
+
+ Use the information in the config file to delete an existing
+ lineage.
+ """
+ cert_manager.delete(config)
+
def certificates(config, unused_plugins):
"""Display information about certs configured with Certbot
"""
@@ -712,6 +732,7 @@ def _handle_exception(exc_type, exc_value, trace, config):
with open(logfile, "w") as logfd:
traceback.print_exception(
exc_type, exc_value, trace, file=logfd)
+ assert "--debug" not in sys.argv # config is None if this explodes
except: # pylint: disable=bare-except
sys.exit(tb_str)
if "--debug" in sys.argv:
@@ -779,7 +800,8 @@ def set_displayer(config):
elif config.noninteractive_mode:
displayer = display_util.NoninteractiveDisplay(sys.stdout)
else:
- displayer = display_util.FileDisplay(sys.stdout)
+ displayer = display_util.FileDisplay(sys.stdout,
+ config.force_interactive)
zope.component.provideUtility(displayer)
def _post_logging_setup(config, plugins, cli_args):
diff --git a/certbot/ocsp.py b/certbot/ocsp.py
new file mode 100644
index 000000000..2e0514a44
--- /dev/null
+++ b/certbot/ocsp.py
@@ -0,0 +1,109 @@
+"""Tools for checking certificate revocation."""
+import logging
+
+from subprocess import Popen, PIPE
+
+from certbot import errors
+from certbot import util
+
+logger = logging.getLogger(__name__)
+
+class RevocationChecker(object):
+ "This class figures out OCSP checking on this system, and performs it."
+
+ def __init__(self):
+ self.broken = False
+
+ if not util.exe_exists("openssl"):
+ logging.info("openssl not installed, can't check revocation")
+ self.broken = True
+ return
+
+ # New versions of openssl want -header var=val, old ones want -header var val
+ test_host_format = Popen(["openssl", "ocsp", "-header", "var", "val"],
+ stdout=PIPE, stderr=PIPE, universal_newlines=True)
+ _out, err = test_host_format.communicate()
+ if "Missing =" in err:
+ self.host_args = lambda host: ["Host=" + host]
+ else:
+ self.host_args = lambda host: ["Host", host]
+
+
+ def ocsp_revoked(self, cert_path, chain_path):
+ """Get revoked status for a particular cert version.
+
+ .. todo:: Make this a non-blocking call
+
+ :param str cert_path: Path to certificate
+ :param str chain_path: Path to intermediate cert
+ :rtype bool or None:
+ :returns: True if revoked; False if valid or the check failed
+
+ """
+ if self.broken:
+ return False
+
+
+ logger.debug("Querying OCSP for %s", cert_path)
+ url, host = self.determine_ocsp_server(cert_path)
+ if not host:
+ return False
+ # jdkasten thanks "Bulletproof SSL and TLS - Ivan Ristic" for documenting this!
+ cmd = ["openssl", "ocsp",
+ "-no_nonce",
+ "-issuer", chain_path,
+ "-cert", cert_path,
+ "-url", url,
+ "-CAfile", chain_path,
+ "-verify_other", chain_path,
+ "-header"] + self.host_args(host)
+ try:
+ output, err = util.run_script(cmd, log=logging.debug)
+ except errors.SubprocessError as e:
+ logger.info("OCSP check failed for %s (are we offline?)", cert_path)
+ logger.debug("Command was:\n%s\nError was:\n%s", " ".join(cmd), e)
+ return False
+
+ return _translate_ocsp_query(cert_path, output, err)
+
+
+ def determine_ocsp_server(self, cert_path):
+ """Extract the OCSP server host from a certificate.
+
+ :param str cert_path: Path to the cert we're checking OCSP for
+ :rtype tuple:
+ :returns: (OCSP server URL or None, OCSP server host or None)
+
+ """
+ try:
+ url, _err = util.run_script(
+ ["openssl", "x509", "-in", cert_path, "-noout", "-ocsp_uri"],
+ log=logging.debug)
+ except errors.SubprocessError as e:
+ logger.info("Cannot extract OCSP URI from %s", cert_path)
+ logger.debug("Error was:\n%s", e)
+ return None, None
+
+ url = url.rstrip()
+ host = url.partition("://")[2].rstrip("/")
+ if host:
+ return url, host
+ else:
+ logger.info("Cannot process OCSP host from URL (%s) in cert at %s", url, cert_path)
+ return None, None
+
+def _translate_ocsp_query(cert_path, ocsp_output, ocsp_errors):
+ """Parse openssl's weird output to work out what it means."""
+
+ if not "Response verify OK" in ocsp_errors:
+ logger.info("Revocation status for %s is unknown", cert_path)
+ logger.debug("Uncertain ouput:\n%s\nstderr:\n%s", ocsp_output, ocsp_errors)
+ return False
+ if cert_path + ": good" in ocsp_output:
+ return False
+ elif cert_path + ": revoked" in ocsp_output:
+ return True
+ else:
+ logger.warn("Unable to properly parse OCSP output: %s", ocsp_output)
+ return False
+
diff --git a/certbot/plugins/manual.py b/certbot/plugins/manual.py
index c124ce048..1163e7e7e 100644
--- a/certbot/plugins/manual.py
+++ b/certbot/plugins/manual.py
@@ -1,56 +1,49 @@
-"""Manual plugin."""
+"""Manual authenticator plugin"""
import os
-import logging
-import pipes
-import shutil
-import socket
-import subprocess
-import sys
-import tempfile
-import time
-import six
import zope.component
import zope.interface
from acme import challenges
-from acme import errors as acme_errors
-from certbot import errors
from certbot import interfaces
+from certbot import errors
+from certbot import hooks
from certbot.plugins import common
-logger = logging.getLogger(__name__)
-
-
@zope.interface.implementer(interfaces.IAuthenticator)
@zope.interface.provider(interfaces.IPluginFactory)
class Authenticator(common.Plugin):
- """Manual Authenticator.
+ """Manual authenticator
- This plugin requires user's manual intervention in setting up a HTTP
- server for solving http-01 challenges and thus does not need to be
- run as a privileged process. Alternatively shows instructions on how
- to use Python's built-in HTTP server.
-
- .. todo:: Support for `~.challenges.TLSSNI01`.
+ This plugin allows the user to perform the domain validation
+ challenge(s) themselves. This either be done manually by the user or
+ through shell scripts provided to Certbot.
"""
+
+ description = 'Manual configuration or run your own shell scripts'
hidden = True
-
- description = "Manually configure an HTTP server"
-
- MESSAGE_TEMPLATE = {
- "dns-01": """\
+ long_description = (
+ 'Authenticate through manual configuration or custom shell scripts. '
+ 'When using shell scripts, an authenticator script must be provided. '
+ 'The environment variables available to this script are '
+ '$CERTBOT_DOMAIN which contains the domain being authenticated, '
+ '$CERTBOT_VALIDATION which is the validation string, and '
+ '$CERTBOT_TOKEN which is the filename of the resource requested when '
+ 'performing an HTTP-01 challenge. An additional cleanup script can '
+ 'also be provided and can use the additional variable '
+ '$CERTBOT_AUTH_OUTPUT which contains the stdout output from the auth '
+ 'script.')
+ _DNS_INSTRUCTIONS = """\
Please deploy a DNS TXT record under the name
{domain} with the following value:
{validation}
-Once this is deployed,
-""",
- "http-01": """\
+Once this is deployed,"""
+ _HTTP_INSTRUCTIONS = """\
Make sure your web server displays the following content at
{uri} before continuing:
@@ -59,203 +52,114 @@ Make sure your web server displays the following content at
If you don't have HTTP server configured, you can run the following
command on the target server (as root):
-{command}
-"""}
-
- # a disclaimer about your current IP being transmitted to Let's Encrypt's servers.
- IP_DISCLAIMER = """\
-NOTE: The IP of this machine will be publicly logged as having requested this certificate. \
-If you're running certbot in manual mode on a machine that is not your server, \
-please ensure you're okay with that.
-
-Are you OK with your IP being logged?
-"""
-
- # "cd /tmp/certbot" makes sure user doesn't serve /root,
- # separate "public_html" ensures that cert.pem/key.pem are not
- # served and makes it more obvious that Python command will serve
- # anything recursively under the cwd
-
- CMD_TEMPLATE = """\
-mkdir -p {root}/public_html/{achall.URI_ROOT_PATH}
-cd {root}/public_html
+mkdir -p /tmp/certbot/public_html/{achall.URI_ROOT_PATH}
+cd /tmp/certbot/public_html
printf "%s" {validation} > {achall.URI_ROOT_PATH}/{encoded_token}
# run only once per server:
$(command -v python2 || command -v python2.7 || command -v python2.6) -c \\
"import BaseHTTPServer, SimpleHTTPServer; \\
s = BaseHTTPServer.HTTPServer(('', {port}), SimpleHTTPServer.SimpleHTTPRequestHandler); \\
s.serve_forever()" """
- """Command template."""
def __init__(self, *args, **kwargs):
super(Authenticator, self).__init__(*args, **kwargs)
- self._root = (tempfile.mkdtemp() if self.conf("test-mode")
- else "/tmp/certbot")
- self._httpd = None
+ self.env = dict()
@classmethod
def add_parser_arguments(cls, add):
- add("test-mode", action="store_true",
- help="Test mode. Executes the manual command in subprocess.")
- add("public-ip-logging-ok", action="store_true",
- help="Automatically allows public IP logging.")
+ add('auth-hook',
+ help='Path or command to execute for the authentication script')
+ add('cleanup-hook',
+ help='Path or command to execute for the cleanup script')
+ add('public-ip-logging-ok', action='store_true',
+ help='Automatically allows public IP logging (default: Ask)')
- def prepare(self): # pylint: disable=missing-docstring,no-self-use
- if self.config.noninteractive_mode and not self.conf("test-mode"):
- raise errors.PluginError("Running manual mode non-interactively is not supported")
+ def prepare(self): # pylint: disable=missing-docstring
+ if self.config.noninteractive_mode and not self.conf('auth-hook'):
+ raise errors.PluginError(
+ 'An authentication script must be provided with --{0} when '
+ 'using the manual plugin non-interactively.'.format(
+ self.option_name('auth-hook')))
+ self._validate_hooks()
+
+ def _validate_hooks(self):
+ if self.config.validate_hooks:
+ for name in ('auth-hook', 'cleanup-hook'):
+ hook = self.conf(name)
+ if hook is not None:
+ hook_prefix = self.option_name(name)[:-len('-hook')]
+ hooks.validate_hook(hook, hook_prefix)
def more_info(self): # pylint: disable=missing-docstring,no-self-use
- return ("This plugin requires user's manual intervention in setting "
- "up challenges to prove control of a domain and does not need "
- "to be run as a privileged process. When solving "
- "http-01 challenges, the user is responsible for setting up "
- "an HTTP server. Alternatively, instructions are shown on how "
- "to use Python's built-in HTTP server. The user is "
- "responsible for configuration of a domain's DNS when solving "
- "dns-01 challenges. The type of challenges used can be "
- "controlled through the --preferred-challenges flag.")
+ return (
+ 'This plugin allows the user to customize setup for domain '
+ 'validation challenges either through shell scripts provided by '
+ 'the user or by performing the setup manually.')
def get_chall_pref(self, domain):
# pylint: disable=missing-docstring,no-self-use,unused-argument
return [challenges.HTTP01, challenges.DNS01]
- def perform(self, achalls):
- # pylint: disable=missing-docstring
- self._get_ip_logging_permission()
- mapping = {"http-01": self._perform_http01_challenge,
- "dns-01": self._perform_dns01_challenge}
+ def perform(self, achalls): # pylint: disable=missing-docstring
+ self._verify_ip_logging_ok()
+
+ if self.conf('auth-hook'):
+ perform_achall = self._perform_achall_with_script
+ else:
+ perform_achall = self._perform_achall_manually
+
responses = []
- # TODO: group achalls by the same socket.gethostbyname(_ex)
- # and prompt only once per server (one "echo -n" per domain)
for achall in achalls:
- responses.append(mapping[achall.typ](achall))
+ perform_achall(achall)
+ responses.append(achall.response(achall.account_key))
return responses
- @classmethod
- def _test_mode_busy_wait(cls, port):
- while True:
- time.sleep(1)
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- try:
- sock.connect(("localhost", port))
- except socket.error: # pragma: no cover
- pass
+ def _verify_ip_logging_ok(self):
+ if not self.conf('public-ip-logging-ok'):
+ cli_flag = '--{0}'.format(self.option_name('public-ip-logging-ok'))
+ msg = ('NOTE: The IP of this machine will be publicly logged as '
+ "having requested this certificate. If you're running "
+ 'certbot in manual mode on a machine that is not your '
+ "server, please ensure you're okay with that.\n\n"
+ 'Are you OK with your IP being logged?')
+ display = zope.component.getUtility(interfaces.IDisplay)
+ if display.yesno(msg, cli_flag=cli_flag, force_interactive=True):
+ setattr(self.config, self.dest('public-ip-logging-ok'), True)
else:
- break
- finally:
- sock.close()
+ raise errors.PluginError('Must agree to IP logging to proceed')
- def cleanup(self, achalls):
- # pylint: disable=missing-docstring
- for achall in achalls:
- if isinstance(achall.chall, challenges.HTTP01):
- self._cleanup_http01_challenge(achall)
-
- def _perform_http01_challenge(self, achall):
- # same path for each challenge response would be easier for
- # users, but will not work if multiple domains point at the
- # same server: default command doesn't support virtual hosts
- response, validation = achall.response_and_validation()
-
- port = (response.port if self.config.http01_port is None
- else int(self.config.http01_port))
- command = self.CMD_TEMPLATE.format(
- root=self._root, achall=achall, response=response,
- # TODO(kuba): pipes still necessary?
- validation=pipes.quote(validation),
- encoded_token=achall.chall.encode("token"),
- port=port)
- if self.conf("test-mode"):
- logger.debug("Test mode. Executing the manual command: %s", command)
- # sh shipped with OS X does't support echo -n, but supports printf
- try:
- self._httpd = subprocess.Popen(
- command,
- # don't care about setting stdout and stderr,
- # we're in test mode anyway
- shell=True,
- executable=None,
- # "preexec_fn" is UNIX specific, but so is "command"
- preexec_fn=os.setsid)
- except OSError as error: # ValueError should not happen!
- logger.debug(
- "Couldn't execute manual command: %s", error, exc_info=True)
- return False
- logger.debug("Manual command running as PID %s.", self._httpd.pid)
- # give it some time to bootstrap, before we try to verify
- # (cert generation in case of simpleHttpS might take time)
- self._test_mode_busy_wait(port)
-
- if self._httpd.poll() is not None:
- raise errors.Error("Couldn't execute manual command")
+ def _perform_achall_with_script(self, achall):
+ env = dict(CERTBOT_DOMAIN=achall.domain,
+ CERTBOT_VALIDATION=achall.validation(achall.account_key))
+ if isinstance(achall.chall, challenges.HTTP01):
+ env['CERTBOT_TOKEN'] = achall.chall.encode('token')
else:
- self._notify_and_wait(
- self._get_message(achall).format(
- validation=validation,
- response=response,
- uri=achall.chall.uri(achall.domain),
- command=command))
+ os.environ.pop('CERTBOT_TOKEN', None)
+ os.environ.update(env)
+ _, out = hooks.execute(self.conf('auth-hook'))
+ env['CERTBOT_AUTH_OUTPUT'] = out.strip()
+ self.env[achall.domain] = env
- if not response.simple_verify(
- achall.chall, achall.domain,
- achall.account_key.public_key(), self.config.http01_port):
- logger.warning("Self-verify of challenge failed.")
-
- return response
-
- def _perform_dns01_challenge(self, achall):
- response, validation = achall.response_and_validation()
- if not self.conf("test-mode"):
- self._notify_and_wait(
- self._get_message(achall).format(
- validation=validation,
- domain=achall.validation_domain_name(achall.domain),
- response=response))
-
- try:
- verification_status = response.simple_verify(
- achall.chall, achall.domain,
- achall.account_key.public_key())
- except acme_errors.DependencyError:
- logger.warning("Self verification requires optional "
- "dependency `dnspython` to be installed.")
+ def _perform_achall_manually(self, achall):
+ validation = achall.validation(achall.account_key)
+ if isinstance(achall.chall, challenges.HTTP01):
+ msg = self._HTTP_INSTRUCTIONS.format(
+ achall=achall, encoded_token=achall.chall.encode('token'),
+ port=self.config.http01_port,
+ uri=achall.chall.uri(achall.domain), validation=validation)
else:
- if not verification_status:
- logger.warning("Self-verify of challenge failed.")
+ assert isinstance(achall.chall, challenges.DNS01)
+ msg = self._DNS_INSTRUCTIONS.format(
+ domain=achall.validation_domain_name(achall.domain),
+ validation=validation)
+ display = zope.component.getUtility(interfaces.IDisplay)
+ display.notification(msg, wrap=False, force_interactive=True)
- return response
-
- def _cleanup_http01_challenge(self, achall):
- # pylint: disable=missing-docstring,unused-argument
- if self.conf("test-mode"):
- assert self._httpd is not None, (
- "cleanup() must be called after perform()")
- if self._httpd.poll() is None:
- logger.debug("Terminating manual command process")
- self._httpd.terminate()
- else:
- logger.debug("Manual command process already terminated "
- "with %s code", self._httpd.returncode)
- shutil.rmtree(self._root)
-
- def _notify_and_wait(self, message):
- # pylint: disable=no-self-use
- # TODO: IDisplay wraps messages, breaking the command
- #answer = zope.component.getUtility(interfaces.IDisplay).notification(
- # message=message, pause=True)
- sys.stdout.write(message)
- six.moves.input("Press ENTER to continue")
-
- def _get_ip_logging_permission(self):
- # pylint: disable=missing-docstring
- if not (self.conf("test-mode") or self.conf("public-ip-logging-ok")):
- if not zope.component.getUtility(interfaces.IDisplay).yesno(
- self.IP_DISCLAIMER, "Yes", "No",
- cli_flag="--manual-public-ip-logging-ok"):
- raise errors.PluginError("Must agree to IP logging to proceed")
- else:
- self.config.namespace.manual_public_ip_logging_ok = True
-
- def _get_message(self, achall):
- # pylint: disable=missing-docstring,no-self-use,unused-argument
- return self.MESSAGE_TEMPLATE.get(achall.chall.typ, "")
+ def cleanup(self, achalls): # pylint: disable=missing-docstring
+ if self.conf('cleanup-hook'):
+ for achall in achalls:
+ env = self.env.pop(achall.domain)
+ if 'CERTBOT_TOKEN' not in env:
+ os.environ.pop('CERTBOT_TOKEN', None)
+ os.environ.update(env)
+ hooks.execute(self.conf('cleanup-hook'))
diff --git a/certbot/plugins/manual_test.py b/certbot/plugins/manual_test.py
index 154b0d729..247352256 100644
--- a/certbot/plugins/manual_test.py
+++ b/certbot/plugins/manual_test.py
@@ -1,134 +1,112 @@
-"""Tests for certbot.plugins.manual."""
+"""Tests for certbot.plugins.manual"""
+import os
import unittest
+import six
import mock
from acme import challenges
-from acme import errors as acme_errors
-from acme import jose
-from certbot import achallenges
from certbot import errors
-
from certbot.tests import acme_util
-from certbot.tests import util as test_util
-
-
-KEY = jose.JWKRSA.load(test_util.load_vector("rsa512_key.pem"))
class AuthenticatorTest(unittest.TestCase):
"""Tests for certbot.plugins.manual.Authenticator."""
def setUp(self):
- from certbot.plugins.manual import Authenticator
+ self.http_achall = acme_util.HTTP01_A
+ self.dns_achall = acme_util.DNS01_A
+ self.achalls = [self.http_achall, self.dns_achall]
self.config = mock.MagicMock(
- http01_port=8080, manual_test_mode=False,
- manual_public_ip_logging_ok=False, noninteractive_mode=True)
- self.auth = Authenticator(config=self.config, name="manual")
+ http01_port=0, manual_auth_hook=None, manual_cleanup_hook=None,
+ manual_public_ip_logging_ok=False, noninteractive_mode=False,
+ validate_hooks=False)
- self.http01 = achallenges.KeyAuthorizationAnnotatedChallenge(
- challb=acme_util.HTTP01_P, domain="foo.com", account_key=KEY)
- self.dns01 = achallenges.KeyAuthorizationAnnotatedChallenge(
- challb=acme_util.DNS01_P, domain="foo.com", account_key=KEY)
+ from certbot.plugins.manual import Authenticator
+ self.auth = Authenticator(self.config, name='manual')
- self.achalls = [self.http01, self.dns01]
-
- config_test_mode = mock.MagicMock(
- http01_port=8080, manual_test_mode=True, noninteractive_mode=True)
- self.auth_test_mode = Authenticator(
- config=config_test_mode, name="manual")
-
- def test_prepare(self):
+ def test_prepare_no_hook_noninteractive(self):
+ self.config.noninteractive_mode = True
self.assertRaises(errors.PluginError, self.auth.prepare)
- self.auth_test_mode.prepare() # error not raised
+
+ def test_prepare_bad_hook(self):
+ self.config.manual_auth_hook = os.path.abspath(os.sep) # is / on UNIX
+ self.config.validate_hooks = True
+ self.assertRaises(errors.HookCommandNotFound, self.auth.prepare)
def test_more_info(self):
- self.assertTrue(isinstance(self.auth.more_info(), str))
+ self.assertTrue(isinstance(self.auth.more_info(), six.string_types))
def test_get_chall_pref(self):
- self.assertTrue(all(issubclass(pref, challenges.Challenge)
- for pref in self.auth.get_chall_pref("foo.com")))
+ self.assertEqual(self.auth.get_chall_pref('example.org'),
+ [challenges.HTTP01, challenges.DNS01])
- @mock.patch("certbot.plugins.manual.zope.component.getUtility")
- def test_perform_empty(self, mock_interaction):
- mock_interaction().yesno.return_value = True
- self.assertEqual([], self.auth.perform([]))
+ @mock.patch('certbot.plugins.manual.zope.component.getUtility')
+ def test_ip_logging_not_ok(self, mock_get_utility):
+ mock_get_utility().yesno.return_value = False
+ self.assertRaises(errors.PluginError, self.auth.perform, [])
- @mock.patch("certbot.plugins.manual.zope.component.getUtility")
- @mock.patch("certbot.plugins.manual.sys.stdout")
- @mock.patch("acme.challenges.HTTP01Response.simple_verify")
- @mock.patch("six.moves.input")
- def test_perform(self, mock_raw_input, mock_verify, mock_stdout, mock_interaction):
- mock_verify.return_value = True
- mock_interaction().yesno.return_value = True
+ @mock.patch('certbot.plugins.manual.zope.component.getUtility')
+ def test_ip_logging_ok(self, mock_get_utility):
+ mock_get_utility().yesno.return_value = True
+ self.auth.perform([])
+ self.assertTrue(self.config.manual_public_ip_logging_ok)
- resp_http = self.http01.response(KEY)
- resp_dns = self.dns01.response(KEY)
+ def test_script_perform(self):
+ self.config.manual_public_ip_logging_ok = True
+ self.config.manual_auth_hook = (
+ 'echo $CERTBOT_DOMAIN; echo ${CERTBOT_TOKEN:-notoken}; '
+ 'echo $CERTBOT_VALIDATION;')
+ dns_expected = '{0}\n{1}\n{2}'.format(
+ self.dns_achall.domain, 'notoken',
+ self.dns_achall.validation(self.dns_achall.account_key))
+ http_expected = '{0}\n{1}\n{2}'.format(
+ self.http_achall.domain, self.http_achall.chall.encode('token'),
+ self.http_achall.validation(self.http_achall.account_key))
- self.assertEqual([resp_http, resp_dns], self.auth.perform(self.achalls))
- self.assertEqual(2, mock_raw_input.call_count)
- mock_verify.assert_called_with(
- self.http01.challb.chall, "foo.com", KEY.public_key(), 8080)
+ self.assertEqual(
+ self.auth.perform(self.achalls),
+ [achall.response(achall.account_key) for achall in self.achalls])
+ self.assertEqual(
+ self.auth.env[self.dns_achall.domain]['CERTBOT_AUTH_OUTPUT'],
+ dns_expected)
+ self.assertEqual(
+ self.auth.env[self.http_achall.domain]['CERTBOT_AUTH_OUTPUT'],
+ http_expected)
- message = mock_stdout.write.mock_calls[0][1][0]
- self.assertTrue(self.http01.chall.encode("token") in message)
+ @mock.patch('certbot.plugins.manual.zope.component.getUtility')
+ def test_manual_perform(self, mock_get_utility):
+ self.config.manual_public_ip_logging_ok = True
+ self.assertEqual(
+ self.auth.perform(self.achalls),
+ [achall.response(achall.account_key) for achall in self.achalls])
+ for i, (args, kwargs) in enumerate(mock_get_utility().notification.call_args_list):
+ achall = self.achalls[i]
+ self.assertTrue(achall.validation(achall.account_key) in args[0])
+ self.assertFalse(kwargs['wrap'])
- mock_verify.return_value = False
- with mock.patch("certbot.plugins.manual.logger") as mock_logger:
- self.auth.perform(self.achalls)
- self.assertEqual(2, mock_logger.warning.call_count)
+ def test_cleanup(self):
+ self.config.manual_public_ip_logging_ok = True
+ self.config.manual_auth_hook = 'echo foo;'
+ self.config.manual_cleanup_hook = '# cleanup'
+ self.auth.perform(self.achalls)
- @mock.patch("certbot.plugins.manual.zope.component.getUtility")
- @mock.patch("acme.challenges.DNS01Response.simple_verify")
- @mock.patch("six.moves.input")
- def test_perform_missing_dependency(self, mock_raw_input, mock_verify, mock_interaction):
- mock_interaction().yesno.return_value = True
- mock_verify.side_effect = acme_errors.DependencyError()
+ for achall in self.achalls:
+ self.auth.cleanup([achall])
+ self.assertEqual(os.environ['CERTBOT_AUTH_OUTPUT'], 'foo')
+ self.assertEqual(os.environ['CERTBOT_DOMAIN'], achall.domain)
+ self.assertEqual(
+ os.environ['CERTBOT_VALIDATION'],
+ achall.validation(achall.account_key))
- with mock.patch("certbot.plugins.manual.logger") as mock_logger:
- self.auth.perform([self.dns01])
- self.assertEqual(1, mock_logger.warning.call_count)
-
- mock_raw_input.assert_called_once_with("Press ENTER to continue")
-
- @mock.patch("certbot.plugins.manual.zope.component.getUtility")
- @mock.patch("certbot.plugins.manual.Authenticator._notify_and_wait")
- def test_disagree_with_ip_logging(self, mock_notify, mock_interaction):
- mock_interaction().yesno.return_value = False
- mock_notify.side_effect = errors.Error("Exception not raised, \
- continued execution even after disagreeing with IP logging")
-
- self.assertRaises(errors.PluginError, self.auth.perform, self.achalls)
-
- @mock.patch("certbot.plugins.manual.subprocess.Popen", autospec=True)
- def test_perform_test_command_oserror(self, mock_popen):
- mock_popen.side_effect = OSError
- self.assertEqual([False], self.auth_test_mode.perform([self.http01]))
-
- @mock.patch("certbot.plugins.manual.socket.socket")
- @mock.patch("certbot.plugins.manual.time.sleep", autospec=True)
- @mock.patch("certbot.plugins.manual.subprocess.Popen", autospec=True)
- def test_perform_test_command_run_failure(
- self, mock_popen, unused_mock_sleep, unused_mock_socket):
- mock_popen.poll.return_value = 10
- mock_popen.return_value.pid = 1234
- self.assertRaises(
- errors.Error, self.auth_test_mode.perform, self.achalls)
-
- def test_cleanup_test_mode_already_terminated(self):
- # pylint: disable=protected-access
- self.auth_test_mode._httpd = httpd = mock.Mock()
- httpd.poll.return_value = 0
- self.auth_test_mode.cleanup(self.achalls)
-
- def test_cleanup_test_mode_kills_still_running(self):
- # pylint: disable=protected-access
- self.auth_test_mode._httpd = httpd = mock.Mock(pid=1234)
- httpd.poll.return_value = None
- self.auth_test_mode.cleanup(self.achalls)
- httpd.terminate.assert_called_once_with()
+ if isinstance(achall.chall, challenges.HTTP01):
+ self.assertEqual(
+ os.environ['CERTBOT_TOKEN'],
+ achall.chall.encode('token'))
+ else:
+ self.assertFalse('CERTBOT_TOKEN' in os.environ)
-if __name__ == "__main__":
+if __name__ == '__main__':
unittest.main() # pragma: no cover
diff --git a/certbot/plugins/script.py b/certbot/plugins/script.py
deleted file mode 100644
index 049ee8c96..000000000
--- a/certbot/plugins/script.py
+++ /dev/null
@@ -1,161 +0,0 @@
-"""Script-based Authenticator."""
-import logging
-import os
-import sys
-
-import zope.interface
-
-from acme import challenges
-
-from certbot import errors
-from certbot import interfaces
-from certbot import hooks
-
-from certbot.plugins import common
-
-logger = logging.getLogger(__name__)
-
-
-CHALLENGES = ["http-01", "dns-01"]
-
-
-@zope.interface.implementer(interfaces.IAuthenticator)
-@zope.interface.provider(interfaces.IPluginFactory)
-class Authenticator(common.Plugin):
- """Script authenticator
-
- calls user defined script to perform authentication and
- optionally cleanup.
-
- """
-
- description = "Authenticate using user provided script(s)"
-
- long_description = ("Authenticate using user provided script(s). " +
- "Authenticator script has the following environment " +
- "variables available for it: " +
- "CERTBOT_DOMAIN - The domain being authenticated " +
- "CERTBOT_VALIDATION - The validation string " +
- "CERTBOT_TOKEN - Resource name part of HTTP-01 " +
- "challenge (HTTP-01 only). " +
- "Cleanup script has all the above, and additional " +
- "var: CERTBOT_AUTH_OUTPUT - stdout output from the " +
- "authenticator"
- )
-
- def __init__(self, *args, **kwargs):
- super(Authenticator, self).__init__(*args, **kwargs)
- self.cleanup_script = None
- self.auth_script = None
- self.challenges = []
-
- @classmethod
- def add_parser_arguments(cls, add):
- add("auth", default=None, required=False,
- help="path or command for the authentication script")
- add("cleanup", default=None, required=False,
- help="path or command for the cleanup script")
-
- @property
- def supported_challenges(self):
- """Challenges supported by this plugin."""
- return self.challenges
-
- def more_info(self): # pylint: disable=missing-docstring
- return("This authenticator enables user to perform authentication " +
- "using shell script(s).")
-
- def prepare(self):
- """Prepare script plugin, check challenge, scripts and register them"""
- pref_challenges = self.config.pref_challs
- for c in pref_challenges:
- if c.typ in CHALLENGES:
- self.challenges.append(c)
- if not self.challenges and len(pref_challenges):
- # Challenges requested, but not supported
- raise errors.PluginError(
- "Unfortunately script plugin doesn't yet support " +
- "the requested challenges")
-
- # Challenge not defined on cli, set default
- if not self.challenges:
- self.challenges.append(challenges.Challenge.TYPES["http-01"])
-
- if not self.conf("auth"):
- raise errors.PluginError("Parameter --script-auth is required " +
- "for script plugin")
- self._prepare_scripts()
-
- def _prepare_scripts(self):
- """Helper method for prepare, to take care of validating scripts"""
- script_path = self.conf("auth")
- cleanup_path = self.conf("cleanup")
- if self.config.validate_hooks:
- hooks.validate_hook(script_path, "script_auth")
- self.auth_script = script_path
- if cleanup_path:
- if self.config.validate_hooks:
- hooks.validate_hook(cleanup_path, "script_cleanup")
- self.cleanup_script = cleanup_path
-
- def get_chall_pref(self, domain):
- """Return challenge(s) we're answering to """
- # pylint: disable=unused-argument
- return self.challenges
-
- def perform(self, achalls):
- """Perform the authentication per challenge"""
- mapping = {"http-01": self._setup_env_http,
- "dns-01": self._setup_env_dns}
- responses = []
- for achall in achalls:
- response, validation = achall.response_and_validation()
- # Setup env vars
- mapping[achall.typ](achall, validation)
- output = self.execute(self.auth_script)
- if output:
- self._write_auth_output(output)
- responses.append(response)
- return responses
-
- def _setup_env_http(self, achall, validation):
- """Write environment variables for http challenge"""
- ev = dict()
- ev["CERTBOT_TOKEN"] = achall.chall.encode("token")
- ev["CERTBOT_VALIDATION"] = validation
- ev["CERTBOT_DOMAIN"] = achall.domain
- os.environ.update(ev)
-
- def _setup_env_dns(self, achall, validation):
- """Write environment variables for dns challenge"""
- ev = dict()
- ev["CERTBOT_VALIDATION"] = validation
- ev["CERTBOT_DOMAIN"] = achall.domain
- os.environ.update(ev)
-
- def _write_auth_output(self, out):
- """Write output from auth script to env var for
- cleanup to act upon"""
- os.environ.update({"CERTBOT_AUTH_OUTPUT": out.strip()})
-
- def _normalize_string(self, value):
- """Return string instead of bytestring for Python3.
- Helper function for writing env vars, as os.environ needs str"""
-
- if isinstance(value, bytes):
- value = value.decode(sys.getdefaultencoding())
- return str(value)
-
- def execute(self, shell_cmd):
- """Run a script.
-
- :param str shell_cmd: Command to run
- :returns: `str` stdout output"""
-
- _, out = hooks.execute(shell_cmd)
- return self._normalize_string(out)
-
- def cleanup(self, achalls): # pylint: disable=unused-argument
- """Run cleanup.sh """
- if self.cleanup_script:
- self.execute(self.cleanup_script)
diff --git a/certbot/plugins/script_test.py b/certbot/plugins/script_test.py
deleted file mode 100644
index 1fe57a8dc..000000000
--- a/certbot/plugins/script_test.py
+++ /dev/null
@@ -1,170 +0,0 @@
-"""Tests for certbot.plugins.manual."""
-import os
-import tempfile
-import unittest
-
-import mock
-
-from acme import challenges
-from acme import jose
-
-from certbot import achallenges
-from certbot import errors
-
-from certbot.tests import acme_util
-from certbot.tests import util as test_util
-
-
-KEY = jose.JWKRSA.load(test_util.load_vector("rsa512_key.pem"))
-
-
-class AuthenticatorTest(unittest.TestCase):
- """Tests for certbot.plugins.script.Authenticator."""
-
- def setUp(self):
- from certbot.plugins.script import Authenticator
- self.auth_return_value = "return from auth\n"
- self.script_nonexec = create_script(b'# empty')
- self.script_exec = create_script_exec(b'echo "return from auth\n"')
- self.config = mock.MagicMock(
- script_auth=self.script_exec,
- script_cleanup=self.script_exec,
- pref_challs=[challenges.Challenge.TYPES["http-01"],
- challenges.Challenge.TYPES["dns-01"],
- challenges.Challenge.TYPES["tls-sni-01"]])
-
- self.tlssni_config = mock.MagicMock(
- script_auth=self.script_exec,
- script_cleanup=self.script_exec,
- pref_challs=[challenges.Challenge.TYPES["tls-sni-01"]])
-
- self.nochall_config = mock.MagicMock(
- script_auth=self.script_exec,
- script_cleanup=self.script_exec,
- )
-
- self.default = Authenticator(config=self.config, name="script")
- self.onlytlssni = Authenticator(config=self.tlssni_config,
- name="script")
- self.nochall = Authenticator(config=self.nochall_config,
- name="script")
-
- self.http01 = achallenges.KeyAuthorizationAnnotatedChallenge(
- challb=acme_util.HTTP01_P, domain="foo.com", account_key=KEY)
- self.dns01 = achallenges.KeyAuthorizationAnnotatedChallenge(
- challb=acme_util.DNS01_P, domain="foo.com", account_key=KEY)
-
- self.achalls = [self.http01, self.dns01]
-
- def tearDown(self):
- os.remove(self.script_exec)
- os.remove(self.script_nonexec)
-
- def test_prepare_normal(self):
- """Test prepare with typical configuration"""
- from certbot.plugins.script import Authenticator
- # Erroring combinations in from of (auth_script, cleanup_script, error)
- for v in [("/NONEXISTENT/script.sh", "/NONEXISTENT/script.sh",
- errors.HookCommandNotFound),
- (self.script_nonexec, "/NONEXISTENT/script.sh",
- errors.HookCommandNotFound),
- (self.script_exec, "/NONEXISTENT/script.sh",
- errors.HookCommandNotFound),
- ("/NONEXISTENT/script.sh", self.script_nonexec,
- errors.HookCommandNotFound),
- ("/NONEXISTENT/script.sh", self.script_exec,
- errors.HookCommandNotFound),
- (None, self.script_exec,
- errors.PluginError)]:
- testconf = mock.MagicMock(
- script_auth=v[0],
- script_cleanup=v[1],
- pref_challs=[challenges.Challenge.TYPES["http-01"]])
- testauth = Authenticator(config=testconf, name="script")
- self.assertRaises(v[2], testauth.prepare)
-
- # This should not error
- self.default.prepare()
- self.assertEqual(len(self.default.challenges), 2)
-
- def test_prepare_tlssni(self):
- """Test for provided, but unsupported challenge type"""
- self.assertRaises(errors.PluginError, self.onlytlssni.prepare)
-
- def test_prepare_nochall(self):
- """Test for default challenge"""
- self.nochall.prepare()
- self.assertEqual(len(self.nochall.challenges), 1)
-
- def test_more_info(self):
- self.assertTrue(isinstance(self.default.more_info(), str))
-
- def test_get_chall_pref(self):
- self.default.prepare()
- self.assertTrue(all(issubclass(pref, challenges.Challenge)
- for pref in self.default.get_chall_pref(
- "foo.com")))
-
- def test_get_supported_challenges(self):
- self.default.prepare()
- self.assertTrue(all(issubclass(sup, challenges.Challenge)
- for sup in self.default.supported_challenges))
-
- def test_perform(self):
- resp_http = self.http01.response(KEY)
- resp_dns = self.dns01.response(KEY)
- self.default.prepare()
- # Check for the env vars prior to the run
- self.assertFalse("CERTBOT_VALIDATION" in os.environ.keys())
- self.assertFalse("CERTBOT_DOMAIN" in os.environ.keys())
- self.assertFalse("CERTBOT_AUTH_OUTPUT" in os.environ.keys())
-
- pref_resp = self.default.perform(self.achalls)
- self.assertEqual([resp_http, resp_dns], pref_resp)
- # Check for the env vars post run
- self.assertTrue("CERTBOT_VALIDATION" in os.environ.keys())
- self.assertTrue("CERTBOT_DOMAIN" in os.environ.keys())
- self.assertTrue("CERTBOT_AUTH_OUTPUT" in os.environ.keys())
- self.assertEqual(os.environ["CERTBOT_AUTH_OUTPUT"],
- self.auth_return_value.strip())
-
- @mock.patch('certbot.plugins.script.Authenticator.execute')
- def test_cleanup(self, mock_exec):
- mock_exec.return_value = (0, None, None)
- self.default.prepare()
- self.default.cleanup(self.achalls)
- self.assertEqual(mock_exec.call_count, 1)
-
- @mock.patch('certbot.hooks.Popen')
- def test_execute(self, mock_popen):
- proc = mock.Mock()
- # tuple values: stdout, stderr, errorcode, num_of_logger_calls
- for t in [("", "", 0, 0),
- (self.auth_return_value, "", 0, 0),
- (None, "stderr_output", 0, 1),
- ("whatever", "stderr_output", 1, 2),
- (b'bytestring outval', "", 0, 0)]:
- proc = mock.Mock()
- attrs = {'communicate.return_value': (t[0], t[1]),
- 'returncode': t[2]}
- proc.configure_mock(**attrs) # pylint: disable=star-args
- mock_popen.return_value = proc
- with mock.patch('certbot.hooks.logger.error') as mock_log:
- output = self.default.execute(self.script_exec)
- self.assertEqual(mock_log.call_count, t[3])
- self.assertTrue(isinstance(output, str))
-
-
-def create_script(contents):
- """ Helper to create temporary file """
- f = tempfile.NamedTemporaryFile(delete=False, prefix='.sh')
- f.write(contents)
- f.close()
- return f.name
-
-
-def create_script_exec(contents):
- """ Helper to create temporary file with exec permissions"""
- fname = create_script(contents)
- os.chmod(fname, 0o700)
- return fname
diff --git a/certbot/plugins/selection.py b/certbot/plugins/selection.py
index ed0991a89..81387c435 100644
--- a/certbot/plugins/selection.py
+++ b/certbot/plugins/selection.py
@@ -111,7 +111,8 @@ def choose_plugin(prepared, question):
while True:
disp = z_util(interfaces.IDisplay)
- code, index = disp.menu(question, opts, help_label="More Info")
+ code, index = disp.menu(
+ question, opts, help_label="More Info", force_interactive=True)
if code == display_util.OK:
plugin_ep = prepared[index]
@@ -127,11 +128,12 @@ def choose_plugin(prepared, question):
msg = "Reported Error: %s" % prepared[index].prepare()
else:
msg = prepared[index].init().more_info()
- z_util(interfaces.IDisplay).notification(msg)
+ z_util(interfaces.IDisplay).notification(msg,
+ force_interactive=True)
else:
return None
-noninstaller_plugins = ["webroot", "manual", "standalone", "script"]
+noninstaller_plugins = ["webroot", "manual", "standalone"]
def record_chosen_plugins(config, plugins, auth, inst):
"Update the config entries to reflect the plugins we actually selected."
@@ -236,8 +238,6 @@ def cli_plugin_requests(config):
req_auth = set_configurator(req_auth, "webroot")
if config.manual:
req_auth = set_configurator(req_auth, "manual")
- if config.script:
- req_auth = set_configurator(req_auth, "script")
logger.debug("Requested authenticator %s and installer %s", req_auth, req_inst)
return req_auth, req_inst
diff --git a/certbot/plugins/selection_test.py b/certbot/plugins/selection_test.py
index 001ca5cff..c0494e565 100644
--- a/certbot/plugins/selection_test.py
+++ b/certbot/plugins/selection_test.py
@@ -110,7 +110,8 @@ class ChoosePluginTest(unittest.TestCase):
"""Tests for certbot.plugins.selection.choose_plugin."""
def setUp(self):
- zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
+ zope.component.provideUtility(display_util.FileDisplay(sys.stdout,
+ False))
self.mock_apache = mock.Mock(
description_with_name="a", misconfigured=True)
self.mock_stand = mock.Mock(
diff --git a/certbot/plugins/standalone.py b/certbot/plugins/standalone.py
index e8c11a416..4fc52479f 100644
--- a/certbot/plugins/standalone.py
+++ b/certbot/plugins/standalone.py
@@ -243,13 +243,13 @@ class Authenticator(common.Plugin):
"Could not bind TCP port {0} because you don't have "
"the appropriate permissions (for example, you "
"aren't running this program as "
- "root).".format(error.port))
+ "root).".format(error.port), force_interactive=True)
elif error.socket_error.errno == socket.errno.EADDRINUSE:
display.notification(
"Could not bind TCP port {0} because it is already in "
"use by another process on this system (such as a web "
"server). Please stop the program in question and then "
- "try again.".format(error.port))
+ "try again.".format(error.port), force_interactive=True)
else:
raise # XXX: How to handle unknown errors in binding?
diff --git a/certbot/plugins/util.py b/certbot/plugins/util.py
index ceedbd39c..20b0fdce7 100644
--- a/certbot/plugins/util.py
+++ b/certbot/plugins/util.py
@@ -103,7 +103,7 @@ def already_listening_socket(port, renewer=False):
"Port {0} is already in use by another process. This will "
"prevent us from binding to that port. Please stop the "
"process that is populating the port in question and try "
- "again. {1}".format(port, extra))
+ "again. {1}".format(port, extra), force_interactive=True)
return True
finally:
testsocket.close()
@@ -151,7 +151,8 @@ def already_listening_psutil(port, renewer=False):
"The program {0} (process ID {1}) is already listening "
"on TCP port {2}. This will prevent us from binding to "
"that port. Please stop the {0} program temporarily "
- "and then try again.{3}".format(name, pid, port, extra))
+ "and then try again.{3}".format(name, pid, port, extra),
+ force_interactive=True)
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
# Perhaps the result of a race where the process could have
diff --git a/certbot/plugins/webroot.py b/certbot/plugins/webroot.py
index 2c449fdca..09671f989 100644
--- a/certbot/plugins/webroot.py
+++ b/certbot/plugins/webroot.py
@@ -45,7 +45,7 @@ to serve all files under specified web root ({0})."""
"times to handle different domains; each domain will have "
"the webroot path that preceded it. For instance: `-w "
"/var/www/example -d example.com -d www.example.com -w "
- "/var/www/thing -d thing.net -d m.thing.net`")
+ "/var/www/thing -d thing.net -d m.thing.net` (default: Ask)")
add("map", default={}, action=_WebrootMapAction,
help="JSON dictionary mapping domains to webroot paths; this "
"implies -d for each entry. You may need to escape this from "
@@ -129,7 +129,8 @@ to serve all files under specified web root ({0})."""
"public_html or webroot directory. The webroot "
"plugin works by temporarily saving necessary "
"resources in the HTTP server's webroot directory "
- "to pass domain validation challenges.")
+ "to pass domain validation challenges.",
+ force_interactive=True)
else: # code == display_util.OK
return None if index == 0 else known_webroots[index - 1]
@@ -138,7 +139,8 @@ to serve all files under specified web root ({0})."""
while True:
code, webroot = display.directory_select(
- "Input the webroot for {0}:".format(domain))
+ "Input the webroot for {0}:".format(domain),
+ force_interactive=True)
if code == display_util.HELP:
# Displaying help is not currently implemented
return None
diff --git a/certbot/renewal.py b/certbot/renewal.py
index 9c847a9be..0975566b6 100644
--- a/certbot/renewal.py
+++ b/certbot/renewal.py
@@ -1,7 +1,6 @@
"""Functionality for autorenewal and associated juggling of configurations"""
from __future__ import print_function
import copy
-import glob
import logging
import os
import traceback
@@ -11,7 +10,6 @@ import zope.component
import OpenSSL
-from certbot import configuration
from certbot import cli
from certbot import crypto_util
@@ -35,17 +33,6 @@ STR_CONFIG_ITEMS = ["config_dir", "logs_dir", "work_dir", "user_agent",
INT_CONFIG_ITEMS = ["rsa_key_size", "tls_sni_01_port", "http01_port"]
-def renewal_conf_files(config):
- """Return /path/to/*.conf in the renewal conf directory"""
- return glob.glob(os.path.join(config.renewal_configs_dir, "*.conf"))
-
-def renewal_file_for_certname(config, certname):
- """Return /path/to/certname.conf in the renewal conf directory"""
- path = os.path.join(config.renewal_configs_dir, "{0}.conf".format(certname))
- if not os.path.exists(path):
- raise errors.CertStorageError("No certificate found with name {0}.".format(certname))
- return path
-
def _reconstitute(config, full_path):
"""Try to instantiate a RenewableCert, updating config with relevant items.
@@ -64,8 +51,7 @@ def _reconstitute(config, full_path):
"""
try:
- renewal_candidate = storage.RenewableCert(
- full_path, configuration.RenewerConfiguration(config))
+ renewal_candidate = storage.RenewableCert(full_path, config)
except (errors.CertStorageError, IOError) as exc:
logger.warning(exc)
logger.warning("Renewal configuration file %s is broken. Skipping.", full_path)
@@ -247,9 +233,8 @@ def renew_cert(config, le_client, lineage):
new_cert = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, new_certr.body.wrapped)
new_chain = crypto_util.dump_pyopenssl_chain(new_chain)
- renewal_conf = configuration.RenewerConfiguration(config.namespace)
# TODO: Check return value of save_successor
- lineage.save_successor(prior_version, new_cert, new_key.pem, new_chain, renewal_conf)
+ lineage.save_successor(prior_version, new_cert, new_key.pem, new_chain, config)
lineage.update_all_links_to(lineage.latest_common_version())
hooks.renew_hook(config, lineage.names(), lineage.live_dir)
@@ -318,12 +303,10 @@ def handle_renewal_request(config):
"command. The renew verb may provide other options "
"for selecting certificates to renew in the future.")
- renewer_config = configuration.RenewerConfiguration(config)
-
if config.certname:
- conf_files = [renewal_file_for_certname(renewer_config, config.certname)]
+ conf_files = [storage.renewal_file_for_certname(config, config.certname)]
else:
- conf_files = renewal_conf_files(renewer_config)
+ conf_files = storage.renewal_conf_files(config)
renew_successes = []
renew_failures = []
diff --git a/certbot/reverter.py b/certbot/reverter.py
index 714a38b8b..32355782e 100644
--- a/certbot/reverter.py
+++ b/certbot/reverter.py
@@ -181,7 +181,7 @@ class Reverter(object):
if for_logging:
return os.linesep.join(output)
zope.component.getUtility(interfaces.IDisplay).notification(
- os.linesep.join(output))
+ os.linesep.join(output), force_interactive=True)
def add_to_temp_checkpoint(self, save_files, save_notes):
"""Add files to temporary checkpoint.
diff --git a/certbot/storage.py b/certbot/storage.py
index 61ab69ff7..e4fc21a85 100644
--- a/certbot/storage.py
+++ b/certbot/storage.py
@@ -1,5 +1,6 @@
"""Renewable certificates storage."""
import datetime
+import glob
import logging
import os
import re
@@ -7,6 +8,7 @@ import re
import configobj
import parsedatetime
import pytz
+import shutil
import six
import certbot
@@ -20,9 +22,22 @@ from certbot import util
logger = logging.getLogger(__name__)
ALL_FOUR = ("cert", "privkey", "chain", "fullchain")
+README = "README"
CURRENT_VERSION = util.get_strict_version(certbot.__version__)
+def renewal_conf_files(config):
+ """Return /path/to/*.conf in the renewal conf directory"""
+ return glob.glob(os.path.join(config.renewal_configs_dir, "*.conf"))
+
+def renewal_file_for_certname(config, certname):
+ """Return /path/to/certname.conf in the renewal conf directory"""
+ path = os.path.join(config.renewal_configs_dir, "{0}.conf".format(certname))
+ if not os.path.exists(path):
+ raise errors.CertStorageError("No certificate found with name {0} (expected "
+ "{1}).".format(certname, path))
+ return path
+
def config_with_defaults(config=None):
"""Merge supplied config, if provided, on top of builtin defaults."""
defaults_copy = configobj.ConfigObj(constants.RENEWER_DEFAULTS)
@@ -99,13 +114,11 @@ def write_renewal_config(o_filename, n_filename, archive_dir, target, relevant_d
def rename_renewal_config(prev_name, new_name, cli_config):
"""Renames cli_config.certname's config to cli_config.new_certname.
- :param .RenewerConfiguration cli_config: parsed command line
+ :param .NamespaceConfig cli_config: parsed command line
arguments
"""
- prev_filename = os.path.join(
- cli_config.renewal_configs_dir, prev_name) + ".conf"
- new_filename = os.path.join(
- cli_config.renewal_configs_dir, new_name) + ".conf"
+ prev_filename = renewal_filename_for_lineagename(cli_config, prev_name)
+ new_filename = renewal_filename_for_lineagename(cli_config, new_name)
if os.path.exists(new_filename):
raise errors.ConfigurationError("The new certificate name "
"is already in use.")
@@ -122,15 +135,14 @@ def update_configuration(lineagename, archive_dir, target, cli_config):
:param str lineagename: Name of the lineage being modified
:param str archive_dir: Absolute path to the archive directory
:param dict target: Maps ALL_FOUR to their symlink paths
- :param .RenewerConfiguration cli_config: parsed command line
+ :param .NamespaceConfig cli_config: parsed command line
arguments
:returns: Configuration object for the updated config file
:rtype: configobj.ConfigObj
"""
- config_filename = os.path.join(
- cli_config.renewal_configs_dir, lineagename) + ".conf"
+ config_filename = renewal_filename_for_lineagename(cli_config, lineagename)
temp_filename = config_filename + ".new"
# If an existing tempfile exists, delete it
@@ -198,6 +210,98 @@ def lineagename_for_filename(config_filename):
"renewal config file name must end in .conf")
return os.path.basename(config_filename[:-len(".conf")])
+def renewal_filename_for_lineagename(config, lineagename):
+ """Returns the lineagename for a configuration filename.
+ """
+ return os.path.join(config.renewal_configs_dir, lineagename) + ".conf"
+
+def _relpath_from_file(archive_dir, from_file):
+ """Path to a directory from a file"""
+ return os.path.relpath(archive_dir, os.path.dirname(from_file))
+
+def _full_archive_path(config_obj, cli_config, lineagename):
+ """Returns the full archive path for a lineagename
+
+ Uses cli_config to determine archive path if not available from config_obj.
+
+ :param configobj.ConfigObj config_obj: Renewal conf file contents (can be None)
+ :param configuration.NamespaceConfig cli_config: Main config file
+ :param str lineagename: Certificate name
+ """
+ if config_obj and "archive_dir" in config_obj:
+ return config_obj["archive_dir"]
+ else:
+ return os.path.join(cli_config.default_archive_dir, lineagename)
+
+def _full_live_path(cli_config, lineagename):
+ """Returns the full default live path for a lineagename"""
+ return os.path.join(cli_config.live_dir, lineagename)
+
+def delete_files(config, certname):
+ """Delete all files related to the certificate.
+
+ If some files are not found, ignore them and continue.
+ """
+ renewal_filename = renewal_file_for_certname(config, certname)
+ # file exists
+ full_default_archive_dir = _full_archive_path(None, config, certname)
+ full_default_live_dir = _full_live_path(config, certname)
+ try:
+ renewal_config = configobj.ConfigObj(renewal_filename)
+ except configobj.ConfigObjError:
+ # config is corrupted
+ logger.warning("Could not parse %s. You may wish to manually "
+ "delete the contents of %s and %s.", renewal_filename,
+ full_default_live_dir, full_default_archive_dir)
+ raise errors.CertStorageError(
+ "error parsing {0}".format(renewal_filename))
+ finally:
+ # we couldn't read it, but let's at least delete it
+ # if this was going to fail, it already would have.
+ os.remove(renewal_filename)
+ logger.debug("Removed %s", renewal_filename)
+
+ # cert files and (hopefully) live directory
+ # it's not guaranteed that the files are in our default storage
+ # structure. so, first delete the cert files.
+ directory_names = set()
+ for kind in ALL_FOUR:
+ link = renewal_config.get(kind)
+ try:
+ os.remove(link)
+ logger.debug("Removed %s", link)
+ except OSError:
+ logger.debug("Unable to delete %s", link)
+ directory = os.path.dirname(link)
+ directory_names.add(directory)
+
+ # if all four were in the same directory, and the only thing left
+ # is the README file (or nothing), delete that directory.
+ # this will be wrong in very few but some cases.
+ if len(directory_names) == 1:
+ # delete the README file
+ directory = directory_names.pop()
+ readme_path = os.path.join(directory, README)
+ try:
+ os.remove(readme_path)
+ logger.debug("Removed %s", readme_path)
+ except OSError:
+ logger.debug("Unable to delete %s", readme_path)
+ # if it's now empty, delete the directory
+ try:
+ os.rmdir(directory) # only removes empty directories
+ logger.debug("Removed %s", directory)
+ except OSError:
+ logger.debug("Unable to remove %s; may not be empty.", directory)
+
+ # archive directory
+ try:
+ archive_path = _full_archive_path(renewal_config, config, certname)
+ shutil.rmtree(archive_path)
+ logger.debug("Removed %s", archive_path)
+ except OSError:
+ logger.debug("Unable to remove %s", archive_path)
+
class RenewableCert(object):
# pylint: disable=too-many-instance-attributes,too-many-public-methods
@@ -240,7 +344,7 @@ class RenewableCert(object):
:param str config_filename: the path to the renewal config file
that defines this lineage.
- :param .RenewerConfiguration: parsed command line arguments
+ :param .NamespaceConfig: parsed command line arguments
:raises .CertStorageError: if the configuration file's name didn't end
in ".conf", or the file is missing or broken.
@@ -299,11 +403,15 @@ class RenewableCert(object):
@property
def archive_dir(self):
"""Returns the default or specified archive directory"""
- if "archive_dir" in self.configuration:
- return self.configuration["archive_dir"]
- else:
- return os.path.join(
- self.cli_config.default_archive_dir, self.lineagename)
+ return _full_archive_path(self.configuration,
+ self.cli_config, self.lineagename)
+
+ def relative_archive_dir(self, from_file):
+ """Returns the default or specified archive directory as a relative path
+
+ Used for creating symbolic links.
+ """
+ return _relpath_from_file(self.archive_dir, from_file)
@property
def is_test_cert(self):
@@ -331,7 +439,8 @@ class RenewableCert(object):
for kind in ALL_FOUR:
link = getattr(self, kind)
previous_link = get_link_target(link)
- new_link = os.path.join(self.archive_dir, os.path.basename(previous_link))
+ new_link = os.path.join(self.relative_archive_dir(link),
+ os.path.basename(previous_link))
os.unlink(link)
os.symlink(new_link, link)
@@ -815,7 +924,7 @@ class RenewableCert(object):
:param str cert: the initial certificate version in PEM format
:param str privkey: the private key in PEM format
:param str chain: the certificate chain in PEM format
- :param .RenewerConfiguration cli_config: parsed command line
+ :param .NamespaceConfig cli_config: parsed command line
arguments
:returns: the newly-created RenewalCert object
@@ -831,16 +940,13 @@ class RenewableCert(object):
logger.debug("Creating directory %s.", i)
config_file, config_filename = util.unique_lineage_name(
cli_config.renewal_configs_dir, lineagename)
- if not config_filename.endswith(".conf"):
- raise errors.CertStorageError(
- "renewal config file name must end in .conf")
# Determine where on disk everything will go
# lineagename will now potentially be modified based on which
# renewal configuration file could actually be created
- lineagename = os.path.basename(config_filename)[:-len(".conf")]
- archive = os.path.join(cli_config.default_archive_dir, lineagename)
- live_dir = os.path.join(cli_config.live_dir, lineagename)
+ lineagename = lineagename_for_filename(config_filename)
+ archive = _full_archive_path(None, cli_config, lineagename)
+ live_dir = _full_live_path(cli_config, lineagename)
if os.path.exists(archive):
raise errors.CertStorageError(
"archive directory exists for " + lineagename)
@@ -856,7 +962,7 @@ class RenewableCert(object):
target = dict([(kind, os.path.join(live_dir, kind + ".pem"))
for kind in ALL_FOUR])
for kind in ALL_FOUR:
- os.symlink(os.path.join(archive, kind + "1.pem"),
+ os.symlink(os.path.join(_relpath_from_file(archive, target[kind]), kind + "1.pem"),
target[kind])
with open(target["cert"], "wb") as f:
logger.debug("Writing certificate to %s.", target["cert"])
@@ -875,7 +981,7 @@ class RenewableCert(object):
f.write(cert + chain)
# Write a README file to the live directory
- readme_path = os.path.join(live_dir, "README")
+ readme_path = os.path.join(live_dir, README)
with open(readme_path, "w") as f:
logger.debug("Writing README to %s.", readme_path)
f.write("This directory contains your keys and certificates.\n\n"
@@ -916,7 +1022,7 @@ class RenewableCert(object):
:param str new_privkey: the new private key, in PEM format,
or ``None``, if the private key has not changed
:param str new_chain: the new chain, in PEM format
- :param .RenewerConfiguration cli_config: parsed command line
+ :param .NamespaceConfig cli_config: parsed command line
arguments
:returns: the new version number that was created
diff --git a/certbot/tests/acme_util.py b/certbot/tests/acme_util.py
index 3168349c9..5e6b190a7 100644
--- a/certbot/tests/acme_util.py
+++ b/certbot/tests/acme_util.py
@@ -7,9 +7,12 @@ from acme import challenges
from acme import jose
from acme import messages
+from certbot import auth_handler
+
from certbot.tests import util
+JWK = jose.JWK.load(util.load_vector('rsa512_key.pem'))
KEY = util.load_rsa_private_key('rsa512_key.pem')
# Challenges
@@ -50,6 +53,14 @@ DNS01_P = chall_to_challb(DNS01, messages.STATUS_PENDING)
CHALLENGES_P = [HTTP01_P, TLSSNI01_P, DNS01_P]
+# AnnotatedChallenge objects
+HTTP01_A = auth_handler.challb_to_achall(HTTP01_P, JWK, "example.com")
+TLSSNI01_A = auth_handler.challb_to_achall(TLSSNI01_P, JWK, "example.net")
+DNS01_A = auth_handler.challb_to_achall(DNS01_P, JWK, "example.org")
+
+ACHALLENGES = [HTTP01_A, TLSSNI01_A, DNS01_A]
+
+
def gen_authzr(authz_status, domain, challs, statuses, combos=True):
"""Generate an authorization resource.
diff --git a/certbot/tests/cert_manager_test.py b/certbot/tests/cert_manager_test.py
index f3569dc00..07f7cedaa 100644
--- a/certbot/tests/cert_manager_test.py
+++ b/certbot/tests/cert_manager_test.py
@@ -1,6 +1,7 @@
"""Tests for certbot.cert_manager."""
-# pylint disable=protected-access
+# pylint: disable=protected-access
import os
+import re
import shutil
import tempfile
import unittest
@@ -25,12 +26,12 @@ class BaseCertManagerTest(unittest.TestCase):
os.makedirs(os.path.join(self.tempdir, "renewal"))
- self.cli_config = mock.MagicMock(
+ self.cli_config = configuration.NamespaceConfig(mock.MagicMock(
config_dir=self.tempdir,
work_dir=self.tempdir,
logs_dir=self.tempdir,
quiet=False,
- )
+ ))
self.domains = {
"example.org": None,
@@ -47,7 +48,7 @@ class BaseCertManagerTest(unittest.TestCase):
junk.close()
def _set_up_config(self, domain, custom_archive):
- # TODO: maybe provide RenewerConfiguration.make_dirs?
+ # TODO: maybe provide NamespaceConfig.make_dirs?
# TODO: main() should create those dirs, c.f. #902
os.makedirs(os.path.join(self.tempdir, "live", domain))
config = configobj.ConfigObj()
@@ -99,10 +100,31 @@ class UpdateLiveSymlinksTest(BaseCertManagerTest):
cert_manager.update_live_symlinks(self.cli_config)
# check that symlinks go where they should
- for domain in self.domains:
- for kind in ALL_FOUR:
- self.assertEqual(os.readlink(self.configs[domain][kind]),
- archive_paths[domain][kind])
+ prev_dir = os.getcwd()
+ try:
+ for domain in self.domains:
+ for kind in ALL_FOUR:
+ os.chdir(os.path.dirname(self.configs[domain][kind]))
+ self.assertEqual(
+ os.path.realpath(os.readlink(self.configs[domain][kind])),
+ os.path.realpath(archive_paths[domain][kind]))
+ finally:
+ os.chdir(prev_dir)
+
+
+class DeleteTest(storage_test.BaseRenewableCertTest):
+ """Tests for certbot.cert_manager.delete
+ """
+ @mock.patch('zope.component.getUtility')
+ @mock.patch('certbot.cert_manager.lineage_for_certname')
+ @mock.patch('certbot.storage.delete_files')
+ def test_delete(self, mock_delete_files, mock_lineage_for_certname, unused_get_utility):
+ """Test delete"""
+ mock_lineage_for_certname.return_value = self.test_rc
+ self.cli_config.certname = "example.org"
+ from certbot import cert_manager
+ cert_manager.delete(self.cli_config)
+ self.assertTrue(mock_delete_files.called)
class CertificatesTest(BaseCertManagerTest):
@@ -145,12 +167,12 @@ class CertificatesTest(BaseCertManagerTest):
def test_certificates_no_files(self, mock_utility, mock_logger):
tempdir = tempfile.mkdtemp()
- cli_config = mock.MagicMock(
+ cli_config = configuration.NamespaceConfig(mock.MagicMock(
config_dir=tempdir,
work_dir=tempdir,
logs_dir=tempdir,
quiet=False,
- )
+ ))
os.makedirs(os.path.join(tempdir, "renewal"))
self._certificates(cli_config)
@@ -158,7 +180,9 @@ class CertificatesTest(BaseCertManagerTest):
self.assertTrue(mock_utility.called)
shutil.rmtree(tempdir)
- def test_report_human_readable(self):
+ @mock.patch('certbot.cert_manager.ocsp.RevocationChecker.ocsp_revoked')
+ def test_report_human_readable(self, mock_revoked):
+ mock_revoked.return_value = None
from certbot import cert_manager
import datetime, pytz
expiry = pytz.UTC.fromutc(datetime.datetime.utcnow())
@@ -168,118 +192,140 @@ class CertificatesTest(BaseCertManagerTest):
cert.names.return_value = ["nameone", "nametwo"]
cert.is_test_cert = False
parsed_certs = [cert]
+
# pylint: disable=protected-access
- out = cert_manager._report_human_readable(parsed_certs)
+ get_report = lambda: cert_manager._report_human_readable(mock_config, parsed_certs)
+
+ mock_config = mock.MagicMock(certname=None, lineagename=None)
+ # pylint: disable=protected-access
+ out = get_report()
self.assertTrue("INVALID: EXPIRED" in out)
cert.target_expiry += datetime.timedelta(hours=2)
# pylint: disable=protected-access
- out = cert_manager._report_human_readable(parsed_certs)
+ out = get_report()
self.assertTrue('1 hour(s)' in out)
self.assertTrue('VALID' in out and not 'INVALID' in out)
cert.target_expiry += datetime.timedelta(days=1)
# pylint: disable=protected-access
- out = cert_manager._report_human_readable(parsed_certs)
+ out = get_report()
self.assertTrue('1 day' in out)
self.assertFalse('under' in out)
self.assertTrue('VALID' in out and not 'INVALID' in out)
cert.target_expiry += datetime.timedelta(days=2)
# pylint: disable=protected-access
- out = cert_manager._report_human_readable(parsed_certs)
+ out = get_report()
self.assertTrue('3 days' in out)
self.assertTrue('VALID' in out and not 'INVALID' in out)
cert.is_test_cert = True
- out = cert_manager._report_human_readable(parsed_certs)
- self.assertTrue('INVALID: TEST CERT' in out)
+ mock_revoked.return_value = True
+ out = get_report()
+ self.assertTrue('INVALID: TEST_CERT, REVOKED' in out)
+
+ cert = mock.MagicMock(lineagename="indescribable")
+ cert.target_expiry = expiry
+ cert.names.return_value = ["nameone", "thrice.named"]
+ cert.is_test_cert = True
+ parsed_certs.append(cert)
+
+ out = get_report()
+ self.assertEqual(len(re.findall("INVALID:", out)), 2)
+ mock_config.domains = ["thrice.named"]
+ out = get_report()
+ self.assertEqual(len(re.findall("INVALID:", out)), 1)
+ mock_config.domains = ["nameone"]
+ out = get_report()
+ self.assertEqual(len(re.findall("INVALID:", out)), 2)
+ mock_config.certname = "indescribable"
+ out = get_report()
+ self.assertEqual(len(re.findall("INVALID:", out)), 1)
+ mock_config.certname = "horror"
+ out = get_report()
+ self.assertEqual(len(re.findall("INVALID:", out)), 0)
-class SearchLineagesTest(unittest.TestCase):
+class SearchLineagesTest(BaseCertManagerTest):
"""Tests for certbot.cert_manager._search_lineages."""
- @mock.patch('certbot.configuration.RenewerConfiguration')
@mock.patch('certbot.util.make_or_verify_dir')
- @mock.patch('certbot.renewal.renewal_conf_files')
+ @mock.patch('certbot.storage.renewal_conf_files')
@mock.patch('certbot.storage.RenewableCert')
def test_cert_storage_error(self, mock_renewable_cert, mock_renewal_conf_files,
- mock_make_or_verify_dir, mock_renewer_config):
+ mock_make_or_verify_dir):
mock_renewal_conf_files.return_value = ["badfile"]
mock_renewable_cert.side_effect = errors.CertStorageError
from certbot import cert_manager
# pylint: disable=protected-access
- self.assertEqual(cert_manager._search_lineages(None, lambda x: x, "check"), "check")
+ self.assertEqual(cert_manager._search_lineages(self.cli_config, lambda x: x, "check"),
+ "check")
self.assertTrue(mock_make_or_verify_dir.called)
- self.assertTrue(mock_renewer_config)
-class LineageForCertnameTest(unittest.TestCase):
+class LineageForCertnameTest(BaseCertManagerTest):
"""Tests for certbot.cert_manager.lineage_for_certname"""
- @mock.patch('certbot.configuration.RenewerConfiguration')
@mock.patch('certbot.util.make_or_verify_dir')
- @mock.patch('certbot.renewal.renewal_conf_files')
+ @mock.patch('certbot.storage.renewal_conf_files')
@mock.patch('certbot.storage.RenewableCert')
def test_found_match(self, mock_renewable_cert, mock_renewal_conf_files,
- mock_make_or_verify_dir, mock_renewer_config):
+ mock_make_or_verify_dir):
mock_renewal_conf_files.return_value = ["somefile.conf"]
mock_match = mock.Mock(lineagename="example.com")
mock_renewable_cert.return_value = mock_match
from certbot import cert_manager
- self.assertEqual(cert_manager.lineage_for_certname(None, "example.com"), mock_match)
+ self.assertEqual(cert_manager.lineage_for_certname(self.cli_config, "example.com"),
+ mock_match)
self.assertTrue(mock_make_or_verify_dir.called)
- self.assertTrue(mock_renewer_config)
- @mock.patch('certbot.configuration.RenewerConfiguration')
@mock.patch('certbot.util.make_or_verify_dir')
- @mock.patch('certbot.renewal.renewal_conf_files')
+ @mock.patch('certbot.storage.renewal_conf_files')
@mock.patch('certbot.storage.RenewableCert')
def test_no_match(self, mock_renewable_cert, mock_renewal_conf_files,
- mock_make_or_verify_dir, mock_renewer_config):
+ mock_make_or_verify_dir):
mock_renewal_conf_files.return_value = ["somefile.conf"]
mock_match = mock.Mock(lineagename="other.com")
mock_renewable_cert.return_value = mock_match
from certbot import cert_manager
- self.assertEqual(cert_manager.lineage_for_certname(None, "example.com"), None)
+ self.assertEqual(cert_manager.lineage_for_certname(self.cli_config, "example.com"),
+ None)
self.assertTrue(mock_make_or_verify_dir.called)
- self.assertTrue(mock_renewer_config)
-class DomainsForCertnameTest(unittest.TestCase):
+class DomainsForCertnameTest(BaseCertManagerTest):
"""Tests for certbot.cert_manager.domains_for_certname"""
- @mock.patch('certbot.configuration.RenewerConfiguration')
@mock.patch('certbot.util.make_or_verify_dir')
- @mock.patch('certbot.renewal.renewal_conf_files')
+ @mock.patch('certbot.storage.renewal_conf_files')
@mock.patch('certbot.storage.RenewableCert')
def test_found_match(self, mock_renewable_cert, mock_renewal_conf_files,
- mock_make_or_verify_dir, mock_renewer_config):
+ mock_make_or_verify_dir):
mock_renewal_conf_files.return_value = ["somefile.conf"]
mock_match = mock.Mock(lineagename="example.com")
domains = ["example.com", "example.org"]
mock_match.names.return_value = domains
mock_renewable_cert.return_value = mock_match
from certbot import cert_manager
- self.assertEqual(cert_manager.domains_for_certname(None, "example.com"), domains)
+ self.assertEqual(cert_manager.domains_for_certname(self.cli_config, "example.com"),
+ domains)
self.assertTrue(mock_make_or_verify_dir.called)
- self.assertTrue(mock_renewer_config)
- @mock.patch('certbot.configuration.RenewerConfiguration')
@mock.patch('certbot.util.make_or_verify_dir')
- @mock.patch('certbot.renewal.renewal_conf_files')
+ @mock.patch('certbot.storage.renewal_conf_files')
@mock.patch('certbot.storage.RenewableCert')
def test_no_match(self, mock_renewable_cert, mock_renewal_conf_files,
- mock_make_or_verify_dir, mock_renewer_config):
+ mock_make_or_verify_dir):
mock_renewal_conf_files.return_value = ["somefile.conf"]
mock_match = mock.Mock(lineagename="example.com")
domains = ["example.com", "example.org"]
mock_match.names.return_value = domains
mock_renewable_cert.return_value = mock_match
from certbot import cert_manager
- self.assertEqual(cert_manager.domains_for_certname(None, "other.com"), None)
+ self.assertEqual(cert_manager.domains_for_certname(self.cli_config, "other.com"),
+ None)
self.assertTrue(mock_make_or_verify_dir.called)
- self.assertTrue(mock_renewer_config)
class RenameLineageTest(BaseCertManagerTest):
@@ -287,7 +333,7 @@ class RenameLineageTest(BaseCertManagerTest):
def setUp(self):
super(RenameLineageTest, self).setUp()
- self.mock_config = configuration.RenewerConfiguration(
+ self.mock_config = configuration.NamespaceConfig(
namespace=mock.MagicMock(
config_dir=self.tempdir,
work_dir=self.tempdir,
@@ -301,7 +347,7 @@ class RenameLineageTest(BaseCertManagerTest):
from certbot import cert_manager
return cert_manager.rename_lineage(*args, **kwargs)
- @mock.patch('certbot.renewal.renewal_conf_files')
+ @mock.patch('certbot.storage.renewal_conf_files')
@mock.patch('certbot.main.zope.component.getUtility')
def test_no_certname(self, mock_get_utility, mock_renewal_conf_files):
mock_config = mock.Mock(certname=None, new_certname="two")
@@ -377,7 +423,7 @@ class RenameLineageTest(BaseCertManagerTest):
mock_config.new_certname = "example.org"
self.assertRaises(errors.ConfigurationError, self._call, mock_config)
- mock_config.new_certname = "one/two"
+ mock_config.new_certname = "one{0}two".format(os.path.sep)
self.assertRaises(errors.ConfigurationError, self._call, mock_config)
diff --git a/certbot/tests/cli_test.py b/certbot/tests/cli_test.py
index 72aea50ea..9404a8385 100644
--- a/certbot/tests/cli_test.py
+++ b/certbot/tests/cli_test.py
@@ -81,15 +81,17 @@ class ParseTest(unittest.TestCase):
out = self._help_output(['--help', 'all'])
self.assertTrue("--configurator" in out)
self.assertTrue("how a cert is deployed" in out)
- self.assertTrue("--manual-test-mode" in out)
+ self.assertTrue("--webroot-path" in out)
self.assertTrue("--text" not in out)
self.assertTrue("--dialog" not in out)
+ self.assertTrue("%s" not in out)
+ self.assertTrue("{0}" not in out)
out = self._help_output(['-h', 'nginx'])
if "nginx" in self.plugins:
# may be false while building distributions without plugins
self.assertTrue("--nginx-ctl" in out)
- self.assertTrue("--manual-test-mode" not in out)
+ self.assertTrue("--webroot-path" not in out)
self.assertTrue("--checkpoints" not in out)
out = self._help_output(['-h'])
@@ -97,10 +99,10 @@ class ParseTest(unittest.TestCase):
if "nginx" in self.plugins:
self.assertTrue("Use the Nginx plugin" in out)
else:
- self.assertTrue("(nginx support is experimental" in out)
+ self.assertTrue("(the certbot nginx plugin is not" in out)
out = self._help_output(['--help', 'plugins'])
- self.assertTrue("--manual-test-mode" not in out)
+ self.assertTrue("--webroot-path" not in out)
self.assertTrue("--prepare" in out)
self.assertTrue('"plugins" subcommand' in out)
@@ -125,8 +127,10 @@ class ParseTest(unittest.TestCase):
self.assertTrue("--key-path" not in out)
out = self._help_output(['-h'])
-
- self.assertTrue(cli.usage_strings(self.plugins)[0] in out)
+ self.assertTrue(cli.SHORT_USAGE in out)
+ self.assertTrue(cli.COMMAND_OVERVIEW[:100] in out)
+ self.assertTrue("%s" not in out)
+ self.assertTrue("{0}" not in out)
def test_parse_domains(self):
short_args = ['-d', 'example.com']
@@ -258,6 +262,12 @@ class ParseTest(unittest.TestCase):
self.assertFalse(cli.option_was_set(
config_dir_option, cli.flag_default(config_dir_option)))
+ def test_force_interactive(self):
+ self.assertRaises(
+ errors.Error, self.parse, "renew --force-interactive".split())
+ self.assertRaises(
+ errors.Error, self.parse, "-n --force-interactive".split())
+
class DefaultTest(unittest.TestCase):
"""Tests for certbot.cli._Default."""
@@ -295,22 +305,22 @@ class SetByCliTest(unittest.TestCase):
def test_report_config_interaction_str(self):
cli.report_config_interaction('manual_public_ip_logging_ok',
- 'manual_test_mode')
- cli.report_config_interaction('manual_test_mode', 'manual')
+ 'manual_auth_hook')
+ cli.report_config_interaction('manual_auth_hook', 'manual')
self._test_report_config_interaction_common()
def test_report_config_interaction_iterable(self):
cli.report_config_interaction(('manual_public_ip_logging_ok',),
- ('manual_test_mode',))
- cli.report_config_interaction(('manual_test_mode',), ('manual',))
+ ('manual_auth_hook',))
+ cli.report_config_interaction(('manual_auth_hook',), ('manual',))
self._test_report_config_interaction_common()
def _test_report_config_interaction_common(self):
"""Tests implied interaction between manual flags.
- --manual implies --manual-test-mode which implies
+ --manual implies --manual-auth-hook which implies
--manual-public-ip-logging-ok. These interactions don't actually
exist in the client, but are used here for testing purposes.
@@ -318,13 +328,13 @@ class SetByCliTest(unittest.TestCase):
args = ['--manual']
verb = 'renew'
- for v in ('manual', 'manual_test_mode', 'manual_public_ip_logging_ok'):
+ for v in ('manual', 'manual_auth_hook', 'manual_public_ip_logging_ok'):
self.assertTrue(_call_set_by_cli(v, args, verb))
cli.set_by_cli.detector = None
- args = ['--manual-test-mode']
- for v in ('manual_test_mode', 'manual_public_ip_logging_ok'):
+ args = ['--manual-auth-hook', 'command']
+ for v in ('manual_auth_hook', 'manual_public_ip_logging_ok'):
self.assertTrue(_call_set_by_cli(v, args, verb))
self.assertFalse(_call_set_by_cli('manual', args, verb))
diff --git a/certbot/tests/configuration_test.py b/certbot/tests/configuration_test.py
index 5e59d0b86..183d6a95c 100644
--- a/certbot/tests/configuration_test.py
+++ b/certbot/tests/configuration_test.py
@@ -88,31 +88,19 @@ class NamespaceConfigTest(unittest.TestCase):
self.assertTrue(os.path.isabs(config.key_dir))
self.assertTrue(os.path.isabs(config.temp_checkpoint_dir))
-
-class RenewerConfigurationTest(unittest.TestCase):
- """Test for certbot.configuration.RenewerConfiguration."""
-
- def setUp(self):
- self.namespace = mock.MagicMock(config_dir='/tmp/config')
- from certbot.configuration import RenewerConfiguration
- self.config = RenewerConfiguration(self.namespace)
-
@mock.patch('certbot.configuration.constants')
- def test_dynamic_dirs(self, constants):
+ def test_renewal_dynamic_dirs(self, constants):
constants.ARCHIVE_DIR = 'a'
constants.LIVE_DIR = 'l'
constants.RENEWAL_CONFIGS_DIR = 'renewal_configs'
- constants.RENEWER_CONFIG_FILENAME = 'r.conf'
self.assertEqual(self.config.default_archive_dir, '/tmp/config/a')
self.assertEqual(self.config.live_dir, '/tmp/config/l')
self.assertEqual(
self.config.renewal_configs_dir, '/tmp/config/renewal_configs')
- self.assertEqual(self.config.renewer_config_file, '/tmp/config/r.conf')
- def test_absolute_paths(self):
+ def test_renewal_absolute_paths(self):
from certbot.configuration import NamespaceConfig
- from certbot.configuration import RenewerConfiguration
config_base = "foo"
work_base = "bar"
@@ -125,12 +113,11 @@ class RenewerConfigurationTest(unittest.TestCase):
mock_namespace.config_dir = config_base
mock_namespace.work_dir = work_base
mock_namespace.logs_dir = logs_base
- config = RenewerConfiguration(NamespaceConfig(mock_namespace))
+ config = NamespaceConfig(mock_namespace)
self.assertTrue(os.path.isabs(config.default_archive_dir))
self.assertTrue(os.path.isabs(config.live_dir))
self.assertTrue(os.path.isabs(config.renewal_configs_dir))
- self.assertTrue(os.path.isabs(config.renewer_config_file))
if __name__ == '__main__':
diff --git a/certbot/tests/display/ops_test.py b/certbot/tests/display/ops_test.py
index 1b535bf3a..e2735cbde 100644
--- a/certbot/tests/display/ops_test.py
+++ b/certbot/tests/display/ops_test.py
@@ -84,7 +84,8 @@ class GetEmailTest(unittest.TestCase):
class ChooseAccountTest(unittest.TestCase):
"""Tests for certbot.display.ops.choose_account."""
def setUp(self):
- zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
+ zope.component.provideUtility(display_util.FileDisplay(sys.stdout,
+ False))
self.accounts_dir = tempfile.mkdtemp("accounts")
self.account_keys_dir = os.path.join(self.accounts_dir, "keys")
@@ -127,7 +128,8 @@ class ChooseAccountTest(unittest.TestCase):
class GenSSLLabURLs(unittest.TestCase):
"""Loose test of _gen_ssl_lab_urls. URL can change easily in the future."""
def setUp(self):
- zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
+ zope.component.provideUtility(display_util.FileDisplay(sys.stdout,
+ False))
@classmethod
def _call(cls, domains):
@@ -146,7 +148,8 @@ class GenSSLLabURLs(unittest.TestCase):
class GenHttpsNamesTest(unittest.TestCase):
"""Test _gen_https_names."""
def setUp(self):
- zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
+ zope.component.provideUtility(display_util.FileDisplay(sys.stdout,
+ False))
@classmethod
def _call(cls, domains):
@@ -193,7 +196,8 @@ class GenHttpsNamesTest(unittest.TestCase):
class ChooseNamesTest(unittest.TestCase):
"""Test choose names."""
def setUp(self):
- zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
+ zope.component.provideUtility(display_util.FileDisplay(sys.stdout,
+ False))
self.mock_install = mock.MagicMock()
@classmethod
diff --git a/certbot/tests/display/util_test.py b/certbot/tests/display/util_test.py
index fa1cb89ba..10ec463ba 100644
--- a/certbot/tests/display/util_test.py
+++ b/certbot/tests/display/util_test.py
@@ -1,10 +1,12 @@
"""Test :mod:`certbot.display.util`."""
+import inspect
import os
import unittest
import mock
-import certbot.errors as errors
+from certbot import errors
+from certbot import interfaces
from certbot.display import util as display_util
@@ -20,10 +22,11 @@ class FileOutputDisplayTest(unittest.TestCase):
functions look to a user, uncomment the test_visual function.
"""
+ # pylint:disable=too-many-public-methods
def setUp(self):
super(FileOutputDisplayTest, self).setUp()
self.mock_stdout = mock.MagicMock()
- self.displayer = display_util.FileDisplay(self.mock_stdout)
+ self.displayer = display_util.FileDisplay(self.mock_stdout, False)
def test_notification_no_pause(self):
self.displayer.notification("message", False)
@@ -33,79 +36,128 @@ class FileOutputDisplayTest(unittest.TestCase):
def test_notification_pause(self):
with mock.patch("six.moves.input", return_value="enter"):
- self.displayer.notification("message")
+ self.displayer.notification("message", force_interactive=True)
self.assertTrue("message" in self.mock_stdout.write.call_args[0][0])
+ def test_notification_noninteractive(self):
+ self._force_noninteractive(self.displayer.notification, "message")
+ string = self.mock_stdout.write.call_args[0][0]
+ self.assertTrue("message" in string)
+
+ def test_notification_noninteractive2(self):
+ # The main purpose of this test is to make sure we only call
+ # logger.warning once which _force_noninteractive checks internally
+ self._force_noninteractive(self.displayer.notification, "message")
+ string = self.mock_stdout.write.call_args[0][0]
+ self.assertTrue("message" in string)
+
+ self.assertTrue(self.displayer.skipped_interaction)
+
+ self._force_noninteractive(self.displayer.notification, "message2")
+ string = self.mock_stdout.write.call_args[0][0]
+ self.assertTrue("message2" in string)
+
@mock.patch("certbot.display.util."
"FileDisplay._get_valid_int_ans")
def test_menu(self, mock_ans):
mock_ans.return_value = (display_util.OK, 1)
- ret = self.displayer.menu("message", CHOICES)
+ ret = self.displayer.menu("message", CHOICES, force_interactive=True)
self.assertEqual(ret, (display_util.OK, 0))
+ def test_menu_noninteractive(self):
+ default = 0
+ result = self._force_noninteractive(
+ self.displayer.menu, "msg", CHOICES, default=default)
+ self.assertEqual(result, (display_util.OK, default))
+
def test_input_cancel(self):
with mock.patch("six.moves.input", return_value="c"):
- code, _ = self.displayer.input("message")
+ code, _ = self.displayer.input("message", force_interactive=True)
self.assertTrue(code, display_util.CANCEL)
def test_input_normal(self):
with mock.patch("six.moves.input", return_value="domain.com"):
- code, input_ = self.displayer.input("message")
+ code, input_ = self.displayer.input("message", force_interactive=True)
self.assertEqual(code, display_util.OK)
self.assertEqual(input_, "domain.com")
+ def test_input_noninteractive(self):
+ default = "foo"
+ code, input_ = self._force_noninteractive(
+ self.displayer.input, "message", default=default)
+
+ self.assertEqual(code, display_util.OK)
+ self.assertEqual(input_, default)
+
+ def test_input_assertion_fail(self):
+ self.assertRaises(AssertionError, self._force_noninteractive,
+ self.displayer.input, "message", cli_flag="--flag")
+
def test_yesno(self):
with mock.patch("six.moves.input", return_value="Yes"):
- self.assertTrue(self.displayer.yesno("message"))
+ self.assertTrue(self.displayer.yesno(
+ "message", force_interactive=True))
with mock.patch("six.moves.input", return_value="y"):
- self.assertTrue(self.displayer.yesno("message"))
+ self.assertTrue(self.displayer.yesno(
+ "message", force_interactive=True))
with mock.patch("six.moves.input", side_effect=["maybe", "y"]):
- self.assertTrue(self.displayer.yesno("message"))
+ self.assertTrue(self.displayer.yesno(
+ "message", force_interactive=True))
with mock.patch("six.moves.input", return_value="No"):
- self.assertFalse(self.displayer.yesno("message"))
+ self.assertFalse(self.displayer.yesno(
+ "message", force_interactive=True))
with mock.patch("six.moves.input", side_effect=["cancel", "n"]):
- self.assertFalse(self.displayer.yesno("message"))
+ self.assertFalse(self.displayer.yesno(
+ "message", force_interactive=True))
with mock.patch("six.moves.input", return_value="a"):
- self.assertTrue(self.displayer.yesno("msg", yes_label="Agree"))
+ self.assertTrue(self.displayer.yesno(
+ "msg", yes_label="Agree", force_interactive=True))
- @mock.patch("certbot.display.util.FileDisplay.input")
+ def test_yesno_noninteractive(self):
+ self.assertTrue(self._force_noninteractive(
+ self.displayer.yesno, "message", default=True))
+
+ @mock.patch("certbot.display.util.six.moves.input")
def test_checklist_valid(self, mock_input):
- mock_input.return_value = (display_util.OK, "2 1")
- code, tag_list = self.displayer.checklist("msg", TAGS)
+ mock_input.return_value = "2 1"
+ code, tag_list = self.displayer.checklist(
+ "msg", TAGS, force_interactive=True)
self.assertEqual(
(code, set(tag_list)), (display_util.OK, set(["tag1", "tag2"])))
- @mock.patch("certbot.display.util.FileDisplay.input")
+ @mock.patch("certbot.display.util.six.moves.input")
def test_checklist_empty(self, mock_input):
- mock_input.return_value = (display_util.OK, "")
- code, tag_list = self.displayer.checklist("msg", TAGS)
+ mock_input.return_value = ""
+ code, tag_list = self.displayer.checklist("msg", TAGS, force_interactive=True)
self.assertEqual(
(code, set(tag_list)), (display_util.OK, set(["tag1", "tag2", "tag3"])))
- @mock.patch("certbot.display.util.FileDisplay.input")
+ @mock.patch("certbot.display.util.six.moves.input")
def test_checklist_miss_valid(self, mock_input):
- mock_input.side_effect = [
- (display_util.OK, "10"),
- (display_util.OK, "tag1 please"),
- (display_util.OK, "1")
- ]
+ mock_input.side_effect = ["10", "tag1 please", "1"]
- ret = self.displayer.checklist("msg", TAGS)
+ ret = self.displayer.checklist("msg", TAGS, force_interactive=True)
self.assertEqual(ret, (display_util.OK, ["tag1"]))
- @mock.patch("certbot.display.util.FileDisplay.input")
+ @mock.patch("certbot.display.util.six.moves.input")
def test_checklist_miss_quit(self, mock_input):
- mock_input.side_effect = [
- (display_util.OK, "10"),
- (display_util.CANCEL, "1")
- ]
- ret = self.displayer.checklist("msg", TAGS)
+ mock_input.side_effect = ["10", "c"]
+
+ ret = self.displayer.checklist("msg", TAGS, force_interactive=True)
self.assertEqual(ret, (display_util.CANCEL, []))
+ def test_checklist_noninteractive(self):
+ default = TAGS
+ code, input_ = self._force_noninteractive(
+ self.displayer.checklist, "msg", TAGS, default=default)
+
+ self.assertEqual(code, display_util.OK)
+ self.assertEqual(input_, default)
+
def test_scrub_checklist_input_valid(self):
# pylint: disable=protected-access
indices = [
@@ -123,14 +175,38 @@ class FileOutputDisplayTest(unittest.TestCase):
self.displayer._scrub_checklist_input(list_, TAGS))
self.assertEqual(set_tags, exp[i])
- @mock.patch("certbot.display.util.FileDisplay.input")
+ @mock.patch("certbot.display.util.six.moves.input")
def test_directory_select(self, mock_input):
- message = "msg"
- result = (display_util.OK, "/var/www/html",)
- mock_input.return_value = result
+ # pylint: disable=star-args
+ args = ["msg", "/var/www/html", "--flag", True]
+ user_input = "/var/www/html"
+ mock_input.return_value = user_input
- self.assertEqual(self.displayer.directory_select(message), result)
- mock_input.assert_called_once_with(message)
+ returned = self.displayer.directory_select(*args)
+ self.assertEqual(returned, (display_util.OK, user_input))
+
+ def test_directory_select_noninteractive(self):
+ default = "/var/www/html"
+ code, input_ = self._force_noninteractive(
+ self.displayer.directory_select, "msg", default=default)
+
+ self.assertEqual(code, display_util.OK)
+ self.assertEqual(input_, default)
+
+ def _force_noninteractive(self, func, *args, **kwargs):
+ skipped_interaction = self.displayer.skipped_interaction
+
+ with mock.patch("certbot.display.util.sys.stdin") as mock_stdin:
+ mock_stdin.isatty.return_value = False
+ with mock.patch("certbot.display.util.logger") as mock_logger:
+ result = func(*args, **kwargs)
+
+ if skipped_interaction:
+ self.assertFalse(mock_logger.warning.called)
+ else:
+ self.assertEqual(mock_logger.warning.call_count, 1)
+
+ return result
def test_scrub_checklist_input_invalid(self):
# pylint: disable=protected-access
@@ -185,6 +261,13 @@ class FileOutputDisplayTest(unittest.TestCase):
self.displayer._get_valid_int_ans(3),
(display_util.CANCEL, -1))
+ def test_methods_take_force_interactive(self):
+ # Every IDisplay method implemented by FileDisplay must take
+ # force_interactive to prevent workflow regressions.
+ for name in interfaces.IDisplay.names(): # pylint: disable=no-member
+ arg_spec = inspect.getargspec(getattr(self.displayer, name))
+ self.assertTrue("force_interactive" in arg_spec.args)
+
class NoninteractiveDisplayTest(unittest.TestCase):
"""Test non-interactive display.
@@ -235,6 +318,16 @@ class NoninteractiveDisplayTest(unittest.TestCase):
self.assertRaises(
errors.MissingCommandlineFlag, self.displayer.directory_select, "msg")
+ def test_methods_take_kwargs(self):
+ # Every IDisplay method implemented by NoninteractiveDisplay
+ # should take **kwargs because every method of FileDisplay must
+ # take force_interactive which doesn't apply to
+ # NoninteractiveDisplay.
+ for name in interfaces.IDisplay.names(): # pylint: disable=no-member
+ method = getattr(self.displayer, name)
+ # asserts method accepts arbitrary keyword arguments
+ self.assertFalse(inspect.getargspec(method).keywords is None)
+
class SeparateListInputTest(unittest.TestCase):
"""Test Module functions."""
diff --git a/certbot/tests/main_test.py b/certbot/tests/main_test.py
index a0d6cc418..673952b4e 100644
--- a/certbot/tests/main_test.py
+++ b/certbot/tests/main_test.py
@@ -159,10 +159,12 @@ class ObtainCertTest(unittest.TestCase):
self.assertRaises(errors.ConfigurationError, self._call,
('certonly --webroot -d example.com -d test.com --cert-name example.com').split())
+ @mock.patch('certbot.cert_manager.domains_for_certname')
+ @mock.patch('certbot.display.ops.choose_names')
@mock.patch('certbot.cert_manager.lineage_for_certname')
@mock.patch('certbot.main._report_new_cert')
def test_find_lineage_for_domains_new_certname(self, mock_report_cert,
- mock_lineage):
+ mock_lineage, mock_choose_names, mock_domains_for_certname):
mock_lineage.return_value = None
# no lineage with this name but we specified domains so create a new cert
@@ -172,8 +174,10 @@ class ObtainCertTest(unittest.TestCase):
self.assertTrue(mock_report_cert.call_count == 1)
# no lineage with this name and we didn't give domains
- self.assertRaises(errors.ConfigurationError, self._call,
- ('certonly --webroot --cert-name example.com').split())
+ mock_choose_names.return_value = ["somename"]
+ mock_domains_for_certname.return_value = None
+ self._call(('certonly --webroot --cert-name example.com').split())
+ self.assertTrue(mock_choose_names.called)
class FindDomainsOrCertnameTest(unittest.TestCase):
"""Tests for certbot.main._find_domains_or_certname."""
@@ -193,6 +197,14 @@ class FindDomainsOrCertnameTest(unittest.TestCase):
# pylint: disable=protected-access
self.assertRaises(errors.Error, main._find_domains_or_certname, mock_config, None)
+ @mock.patch('certbot.cert_manager.domains_for_certname')
+ def test_grab_domains(self, mock_domains):
+ mock_config = mock.Mock(domains=None, certname="one.com")
+ mock_domains.return_value = ["one.com", "two.com"]
+ # pylint: disable=protected-access
+ self.assertEqual(main._find_domains_or_certname(mock_config, None),
+ (["one.com", "two.com"], "one.com"))
+
class RevokeTest(unittest.TestCase):
"""Tests for certbot.main.revoke."""
@@ -567,6 +579,11 @@ class MainTest(unittest.TestCase): # pylint: disable=too-many-public-methods
self._call_no_clientmock(['certificates'])
self.assertEqual(1, mock_cert_manager.call_count)
+ @mock.patch('certbot.cert_manager.delete')
+ def test_delete(self, mock_cert_manager):
+ self._call_no_clientmock(['delete'])
+ self.assertEqual(1, mock_cert_manager.call_count)
+
def test_plugins(self):
flags = ['--init', '--prepare', '--authenticators', '--installers']
for args in itertools.chain(
@@ -856,8 +873,7 @@ class MainTest(unittest.TestCase): # pylint: disable=too-many-public-methods
rc_path = test_util.make_lineage(self, 'sample-renewal-ancient.conf')
args = mock.MagicMock(account=None, email=None, webroot_path=None)
config = configuration.NamespaceConfig(args)
- lineage = storage.RenewableCert(rc_path,
- configuration.RenewerConfiguration(config))
+ lineage = storage.RenewableCert(rc_path, config)
renewalparams = lineage.configuration["renewalparams"]
# pylint: disable=protected-access
renewal._restore_webroot_config(config, renewalparams)
diff --git a/certbot/tests/ocsp_test.py b/certbot/tests/ocsp_test.py
new file mode 100644
index 000000000..ff79bb01e
--- /dev/null
+++ b/certbot/tests/ocsp_test.py
@@ -0,0 +1,137 @@
+"""Tests for ocsp.py"""
+# pylint: disable=protected-access
+
+import unittest
+
+import mock
+
+from certbot import errors
+
+out = """Missing = in header key=value
+ocsp: Use -help for summary.
+"""
+
+class OCSPTest(unittest.TestCase):
+
+ _multiprocess_can_split_ = True
+
+ def setUp(self):
+ from certbot import ocsp
+ with mock.patch('certbot.ocsp.Popen') as mock_popen:
+ with mock.patch('certbot.util.exe_exists') as mock_exists:
+ mock_communicate = mock.MagicMock()
+ mock_communicate.communicate.return_value = (None, out)
+ mock_popen.return_value = mock_communicate
+ mock_exists.return_value = True
+ self.checker = ocsp.RevocationChecker()
+
+ def tearDown(self):
+ pass
+
+ @mock.patch('certbot.ocsp.logging.info')
+ @mock.patch('certbot.ocsp.Popen')
+ @mock.patch('certbot.util.exe_exists')
+ def test_init(self, mock_exists, mock_popen, mock_log):
+ mock_communicate = mock.MagicMock()
+ mock_communicate.communicate.return_value = (None, out)
+ mock_popen.return_value = mock_communicate
+ mock_exists.return_value = True
+
+ from certbot import ocsp
+ checker = ocsp.RevocationChecker()
+ self.assertEqual(mock_popen.call_count, 1)
+ self.assertEqual(checker.host_args("x"), ["Host=x"])
+
+ mock_communicate.communicate.return_value = (None, out.partition("\n")[2])
+ checker = ocsp.RevocationChecker()
+ self.assertEqual(checker.host_args("x"), ["Host", "x"])
+ self.assertEqual(checker.broken, False)
+
+ mock_exists.return_value = False
+ mock_popen.call_count = 0
+ checker = ocsp.RevocationChecker()
+ self.assertEqual(mock_popen.call_count, 0)
+ self.assertEqual(mock_log.call_count, 1)
+ self.assertEqual(checker.broken, True)
+
+ @mock.patch('certbot.ocsp.RevocationChecker.determine_ocsp_server')
+ @mock.patch('certbot.util.run_script')
+ def test_ocsp_revoked(self, mock_run, mock_determine):
+ self.checker.broken = True
+ mock_determine.return_value = ("", "")
+ self.assertEqual(self.checker.ocsp_revoked("x", "y"), False)
+
+ self.checker.broken = False
+ mock_run.return_value = tuple(openssl_happy[1:])
+ self.assertEqual(self.checker.ocsp_revoked("x", "y"), False)
+ self.assertEqual(mock_run.call_count, 0)
+
+ mock_determine.return_value = ("http://x.co", "x.co")
+ self.assertEqual(self.checker.ocsp_revoked("blah.pem", "chain.pem"), False)
+ mock_run.side_effect = errors.SubprocessError("Unable to load certificate launcher")
+ self.assertEqual(self.checker.ocsp_revoked("x", "y"), False)
+ self.assertEqual(mock_run.call_count, 2)
+
+
+ @mock.patch('certbot.ocsp.logger.debug')
+ @mock.patch('certbot.ocsp.logger.info')
+ @mock.patch('certbot.util.run_script')
+ def test_determine_ocsp_server(self, mock_run, mock_info, mock_debug):
+ uri = "http://ocsp.stg-int-x1.letsencrypt.org/"
+ host = "ocsp.stg-int-x1.letsencrypt.org"
+ mock_run.return_value = uri, ""
+ self.assertEqual(self.checker.determine_ocsp_server("beep"), (uri, host))
+ mock_run.return_value = "ftp:/" + host + "/", ""
+ self.assertEqual(self.checker.determine_ocsp_server("beep"), (None, None))
+ self.assertEqual(mock_info.call_count, 1)
+
+ c = "confusion"
+ mock_run.side_effect = errors.SubprocessError(c)
+ self.assertEqual(self.checker.determine_ocsp_server("beep"), (None, None))
+ self.assertTrue(c in repr(mock_debug.call_args[0][1]))
+
+ @mock.patch('certbot.ocsp.logger')
+ @mock.patch('certbot.util.run_script')
+ def test_translate_ocsp(self, mock_run, mock_log):
+ # pylint: disable=protected-access,star-args
+ mock_run.return_value = openssl_confused
+ from certbot import ocsp
+ self.assertEqual(ocsp._translate_ocsp_query(*openssl_happy), False)
+ self.assertEqual(ocsp._translate_ocsp_query(*openssl_confused), False)
+ self.assertEqual(mock_log.debug.call_count, 1)
+ self.assertEqual(mock_log.warn.call_count, 0)
+ self.assertEqual(ocsp._translate_ocsp_query(*openssl_broken), False)
+ self.assertEqual(mock_log.warn.call_count, 1)
+ self.assertEqual(ocsp._translate_ocsp_query(*openssl_revoked), True)
+
+
+# pylint: disable=line-too-long
+openssl_confused = ("", """
+/etc/letsencrypt/live/example.org/cert.pem: good
+ This Update: Dec 17 00:00:00 2016 GMT
+ Next Update: Dec 24 00:00:00 2016 GMT
+""",
+"""
+Response Verify Failure
+139903674214048:error:27069065:OCSP routines:OCSP_basic_verify:certificate verify error:ocsp_vfy.c:138:Verify error:unable to get local issuer certificate
+""")
+
+openssl_happy = ("blah.pem", """
+blah.pem: good
+ This Update: Dec 20 18:00:00 2016 GMT
+ Next Update: Dec 27 18:00:00 2016 GMT
+""",
+"Response verify OK")
+
+openssl_revoked = ("blah.pem", """
+blah.pem: revoked
+ This Update: Dec 20 01:00:00 2016 GMT
+ Next Update: Dec 27 01:00:00 2016 GMT
+ Revocation Time: Dec 20 01:46:34 2016 GMT
+""",
+"""Response verify OK""")
+
+openssl_broken = ("", "tentacles", "Response verify OK")
+
+if __name__ == '__main__':
+ unittest.main() # pragma: no cover
diff --git a/certbot/tests/renewal_test.py b/certbot/tests/renewal_test.py
index 207b70041..8155595c2 100644
--- a/certbot/tests/renewal_test.py
+++ b/certbot/tests/renewal_test.py
@@ -21,8 +21,7 @@ class RenewalTest(unittest.TestCase):
rc_path = util.make_lineage(self, 'sample-renewal-ancient.conf')
args = mock.MagicMock(account=None, email=None, webroot_path=None)
config = configuration.NamespaceConfig(args)
- lineage = storage.RenewableCert(
- rc_path, configuration.RenewerConfiguration(config))
+ lineage = storage.RenewableCert(rc_path, config)
renewalparams = lineage.configuration["renewalparams"]
# pylint: disable=protected-access
from certbot import renewal
diff --git a/certbot/tests/storage_test.py b/certbot/tests/storage_test.py
index ebe7d2243..a1fda6535 100644
--- a/certbot/tests/storage_test.py
+++ b/certbot/tests/storage_test.py
@@ -49,7 +49,7 @@ class BaseRenewableCertTest(unittest.TestCase):
from certbot import storage
self.tempdir = tempfile.mkdtemp()
- self.cli_config = configuration.RenewerConfiguration(
+ self.cli_config = configuration.NamespaceConfig(
namespace=mock.MagicMock(
config_dir=self.tempdir,
work_dir=self.tempdir,
@@ -57,16 +57,22 @@ class BaseRenewableCertTest(unittest.TestCase):
)
)
- # TODO: maybe provide RenewerConfiguration.make_dirs?
+ # TODO: maybe provide NamespaceConfig.make_dirs?
# TODO: main() should create those dirs, c.f. #902
os.makedirs(os.path.join(self.tempdir, "live", "example.org"))
- os.makedirs(os.path.join(self.tempdir, "archive", "example.org"))
+ archive_path = os.path.join(self.tempdir, "archive", "example.org")
+ os.makedirs(archive_path)
os.makedirs(os.path.join(self.tempdir, "renewal"))
config = configobj.ConfigObj()
for kind in ALL_FOUR:
- config[kind] = os.path.join(self.tempdir, "live", "example.org",
+ kind_path = os.path.join(self.tempdir, "live", "example.org",
kind + ".pem")
+ config[kind] = kind_path
+ with open(os.path.join(self.tempdir, "live", "example.org",
+ "README"), 'a'):
+ pass
+ config["archive"] = archive_path
config.filename = os.path.join(self.tempdir, "renewal",
"example.org.conf")
config.write()
@@ -770,5 +776,95 @@ class RenewableCertTests(BaseRenewableCertTest):
storage.RenewableCert(self.config.filename, self.cli_config,
update_symlinks=True)
+class DeleteFilesTest(BaseRenewableCertTest):
+ """Tests for certbot.storage.delete_files"""
+ def setUp(self):
+ super(DeleteFilesTest, self).setUp()
+ for kind in ALL_FOUR:
+ kind_path = os.path.join(self.tempdir, "live", "example.org",
+ kind + ".pem")
+ with open(kind_path, 'a'):
+ pass
+ self.config.write()
+ self.assertTrue(os.path.exists(os.path.join(
+ self.cli_config.renewal_configs_dir, "example.org.conf")))
+ self.assertTrue(os.path.exists(os.path.join(
+ self.cli_config.live_dir, "example.org")))
+ self.assertTrue(os.path.exists(os.path.join(
+ self.tempdir, "archive", "example.org")))
+
+ def _call(self):
+ from certbot import storage
+ with mock.patch("certbot.storage.logger"):
+ storage.delete_files(self.cli_config, "example.org")
+
+ def test_delete_all_files(self):
+ self._call()
+
+ self.assertFalse(os.path.exists(os.path.join(
+ self.cli_config.renewal_configs_dir, "example.org.conf")))
+ self.assertFalse(os.path.exists(os.path.join(
+ self.cli_config.live_dir, "example.org")))
+ self.assertFalse(os.path.exists(os.path.join(
+ self.tempdir, "archive", "example.org")))
+
+ def test_bad_renewal_config(self):
+ with open(self.config.filename, 'a') as config_file:
+ config_file.write("asdfasfasdfasdf")
+
+ self.assertRaises(errors.CertStorageError, self._call)
+ self.assertTrue(os.path.exists(os.path.join(
+ self.cli_config.live_dir, "example.org")))
+ self.assertFalse(os.path.exists(os.path.join(
+ self.cli_config.renewal_configs_dir, "example.org.conf")))
+
+ def test_no_renewal_config(self):
+ os.remove(self.config.filename)
+ self.assertRaises(errors.CertStorageError, self._call)
+ self.assertTrue(os.path.exists(os.path.join(
+ self.cli_config.live_dir, "example.org")))
+ self.assertFalse(os.path.exists(self.config.filename))
+
+ def test_no_cert_file(self):
+ os.remove(os.path.join(
+ self.cli_config.live_dir, "example.org", "cert.pem"))
+ self._call()
+ self.assertFalse(os.path.exists(self.config.filename))
+ self.assertFalse(os.path.exists(os.path.join(
+ self.cli_config.live_dir, "example.org")))
+ self.assertFalse(os.path.exists(os.path.join(
+ self.tempdir, "archive", "example.org")))
+
+ def test_no_readme_file(self):
+ os.remove(os.path.join(
+ self.cli_config.live_dir, "example.org", "README"))
+ self._call()
+ self.assertFalse(os.path.exists(self.config.filename))
+ self.assertFalse(os.path.exists(os.path.join(
+ self.cli_config.live_dir, "example.org")))
+ self.assertFalse(os.path.exists(os.path.join(
+ self.tempdir, "archive", "example.org")))
+
+ def test_livedir_not_empty(self):
+ with open(os.path.join(
+ self.cli_config.live_dir, "example.org", "other_file"), 'a'):
+ pass
+ self._call()
+ self.assertFalse(os.path.exists(self.config.filename))
+ self.assertTrue(os.path.exists(os.path.join(
+ self.cli_config.live_dir, "example.org")))
+ self.assertFalse(os.path.exists(os.path.join(
+ self.tempdir, "archive", "example.org")))
+
+ def test_no_archive(self):
+ archive_dir = os.path.join(self.tempdir, "archive", "example.org")
+ os.rmdir(archive_dir)
+ self._call()
+ self.assertFalse(os.path.exists(self.config.filename))
+ self.assertFalse(os.path.exists(os.path.join(
+ self.cli_config.live_dir, "example.org")))
+ self.assertFalse(os.path.exists(archive_dir))
+
+
if __name__ == "__main__":
unittest.main() # pragma: no cover
diff --git a/certbot/util.py b/certbot/util.py
index c09d307d2..733b3e501 100644
--- a/certbot/util.py
+++ b/certbot/util.py
@@ -38,20 +38,22 @@ ANSI_SGR_RED = "\033[31m"
ANSI_SGR_RESET = "\033[0m"
-def run_script(params):
+def run_script(params, log=logger.error):
"""Run the script with the given params.
:param list params: List of parameters to pass to Popen
+ :param logging.Logger log: Logger to use for errors
"""
try:
proc = subprocess.Popen(params,
stdout=subprocess.PIPE,
- stderr=subprocess.PIPE)
+ stderr=subprocess.PIPE,
+ universal_newlines=True)
except (OSError, ValueError):
msg = "Unable to run the command: %s" % " ".join(params)
- logger.error(msg)
+ log(msg)
raise errors.SubprocessError(msg)
stdout, stderr = proc.communicate()
@@ -60,7 +62,7 @@ def run_script(params):
msg = "Error while running %s.\n%s\n%s" % (
" ".join(params), stdout, stderr)
# Enter recovery routine...
- logger.error(msg)
+ log(msg)
raise errors.SubprocessError(msg)
return stdout, stderr
diff --git a/docs/cli-help.txt b/docs/cli-help.txt
index cf93daa0e..279b65219 100644
--- a/docs/cli-help.txt
+++ b/docs/cli-help.txt
@@ -1,4 +1,4 @@
-usage:
+usage:
certbot [SUBCOMMAND] [options] [-d domain] [-d domain] ...
Certbot can obtain and install HTTPS/TLS/SSL certificates. By default,
@@ -178,7 +178,7 @@ renew:
The 'renew' subcommand will attempt to renew all certificates (or more
precisely, certificate lineages) you have previously obtained if they are
close to expiry, and print a summary of the results. By default, 'renew'
- will reuse the options used to create obtain or most recently successfully
+ will reuse the options used to create, obtain or most recently successfully
renew each certificate lineage. You can try it with `--dry-run` first. For
more fine-grained control, you can renew individual lineages with the
`certonly` subcommand. Hooks are available to run commands before and
diff --git a/docs/contributing.rst b/docs/contributing.rst
index a5b9b5688..de520dc0e 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -246,9 +246,8 @@ configuration checkpoints and rollback.
Display
~~~~~~~
-We currently offer a pythondialog and "text" mode for displays. Display
-plugins implement the `~certbot.interfaces.IDisplay`
-interface.
+We currently only offer a "text" mode for displays. Display plugins
+implement the `~certbot.interfaces.IDisplay` interface.
.. _dev-plugin:
diff --git a/docs/using.rst b/docs/using.rst
index 7c1fac003..349867da4 100644
--- a/docs/using.rst
+++ b/docs/using.rst
@@ -61,7 +61,7 @@ manual_ Y N | Helps you obtain a cert by giving you instructions to pe
Under the hood, plugins use one of several ACME protocol "Challenges_" to
prove you control a domain. The options are http-01_ (which uses port 80),
tls-sni-01_ (port 443) and dns-01_ (requring configuration of a DNS server on
-port 53, thought that's often not the same machine as your webserver). A few
+port 53, though that's often not the same machine as your webserver). A few
plugins support more than one challenge type, in which case you can choose one
with ``--preferred-challenges``.
@@ -193,7 +193,7 @@ postfix_ N Y STARTTLS Everywhere is becoming a Certbot Postfix/Exim plu
=========== ==== ==== ===============================================================
.. _plesk: https://github.com/plesk/letsencrypt-plesk
-.. _haproxy: https://code.greenhost.net/open/letsencrypt-haproxy
+.. _haproxy: https://github.com/greenhost/certbot-haproxy
.. _s3front: https://github.com/dlapiduz/letsencrypt-s3front
.. _gandi: https://github.com/Gandi/letsencrypt-gandi
.. _icecast: https://github.com/e00E/lets-encrypt-icecast
@@ -233,7 +233,7 @@ certificate that contains all of the old domains and one or more additional
new domains.
``--allow-subset-of-names`` tells Certbot to continue with cert generation if
-only some of the specified domain authorazations can be obtained. This may
+only some of the specified domain authorizations can be obtained. This may
be useful if some domains specified in a certificate no longer point at this
system.
diff --git a/letsencrypt-auto-source/letsencrypt-auto b/letsencrypt-auto-source/letsencrypt-auto
index 3d2db3065..d62a642ea 100755
--- a/letsencrypt-auto-source/letsencrypt-auto
+++ b/letsencrypt-auto-source/letsencrypt-auto
@@ -15,9 +15,13 @@ set -e # Work even if somebody does "sh thisscript.sh".
# Note: you can set XDG_DATA_HOME or VENV_PATH before running this script,
# if you want to change where the virtual environment will be installed
-XDG_DATA_HOME=${XDG_DATA_HOME:-~/.local/share}
+if [ -z "$XDG_DATA_HOME" ]; then
+ XDG_DATA_HOME="~/.local/share"
+fi
VENV_NAME="letsencrypt"
-VENV_PATH=${VENV_PATH:-"$XDG_DATA_HOME/$VENV_NAME"}
+if [ -z "$VENV_PATH" ]; then
+ VENV_PATH="$XDG_DATA_HOME/$VENV_NAME"
+fi
VENV_BIN="$VENV_PATH/bin"
LE_AUTO_VERSION="0.10.0.dev0"
BASENAME=$(basename $0)
@@ -80,6 +84,17 @@ if [ $BASENAME = "letsencrypt-auto" ]; then
HELP=0
fi
+# Support for busybox and others where there is no "command",
+# but "which" instead
+if command -v command > /dev/null 2>&1 ; then
+ export EXISTS="command -v"
+elif which which > /dev/null 2>&1 ; then
+ export EXISTS="which"
+else
+ echo "Cannot find command nor which... please install one!"
+ exit 1
+fi
+
# certbot-auto needs root access to bootstrap OS dependencies, and
# certbot itself needs root access for almost all modes of operation
# The "normal" case is that sudo is used for the steps that need root, but
@@ -127,7 +142,7 @@ if [ -n "${LE_AUTO_SUDO+x}" ]; then
echo "Using preset root authorization mechanism '$LE_AUTO_SUDO'."
else
if test "`id -u`" -ne "0" ; then
- if command -v sudo 1>/dev/null 2>&1; then
+ if $EXISTS sudo 1>/dev/null 2>&1; then
SUDO=sudo
SUDO_ENV="CERTBOT_AUTO=$0"
else
@@ -157,7 +172,7 @@ ExperimentalBootstrap() {
DeterminePythonVersion() {
for LE_PYTHON in "$LE_PYTHON" python2.7 python27 python2 python; do
# Break (while keeping the LE_PYTHON value) if found.
- command -v "$LE_PYTHON" > /dev/null && break
+ $EXISTS "$LE_PYTHON" > /dev/null && break
done
if [ "$?" != "0" ]; then
echo "Cannot find any Pythons; please install one!"
@@ -198,19 +213,22 @@ BootstrapDebCommon() {
# distro version (#346)
virtualenv=
- if apt-cache show virtualenv > /dev/null 2>&1 && ! apt-cache --quiet=0 show virtualenv 2>&1 | grep -q 'No packages found'; then
- virtualenv="virtualenv"
+ # virtual env is known to apt and is installable
+ if apt-cache show virtualenv > /dev/null 2>&1 ; then
+ if ! LC_ALL=C apt-cache --quiet=0 show virtualenv 2>&1 | grep -q 'No packages found'; then
+ virtualenv="virtualenv"
+ fi
fi
if apt-cache show python-virtualenv > /dev/null 2>&1; then
- virtualenv="$virtualenv python-virtualenv"
+ virtualenv="$virtualenv python-virtualenv"
fi
augeas_pkg="libaugeas0 augeas-lenses"
- AUGVERSION=`apt-cache show --no-all-versions libaugeas0 | grep ^Version: | cut -d" " -f2`
+ AUGVERSION=`LC_ALL=C apt-cache show --no-all-versions libaugeas0 | grep ^Version: | cut -d" " -f2`
if [ "$ASSUME_YES" = 1 ]; then
- YES_FLAG="-y"
+ YES_FLAG="-y"
fi
AddBackportRepo() {
@@ -276,7 +294,7 @@ BootstrapDebCommon() {
- if ! command -v virtualenv > /dev/null ; then
+ if ! $EXISTS virtualenv > /dev/null ; then
echo Failed to install a working \"virtualenv\" command, exiting
exit 1
fi
@@ -960,7 +978,28 @@ UNLIKELY_EOF
# Report error. (Otherwise, be quiet.)
echo "Had a problem while installing Python packages."
if [ "$VERBOSE" != 1 ]; then
+ echo
+ echo "pip prints the following errors: "
+ echo "====================================================="
echo "$PIP_OUT"
+ echo "====================================================="
+ echo
+ echo "Certbot has problem setting up the virtual environment."
+
+ if `echo $PIP_OUT | grep -q Killed` || `echo $PIP_OUT | grep -q "allocate memory"` ; then
+ echo
+ echo "Based on your pip output, the problem can likely be fixed by "
+ echo "increasing the available memory."
+ else
+ echo
+ echo "We were not be able to guess the right solution from your pip "
+ echo "output."
+ fi
+
+ echo
+ echo "Consult https://certbot.eff.org/docs/install.html#problems-with-python-virtual-environment"
+ echo "for possible solutions."
+ echo "You may also find some support resources at https://certbot.eff.org/support/ ."
fi
rm -rf "$VENV_PATH"
exit 1
diff --git a/letsencrypt-auto-source/pieces/bootstrappers/deb_common.sh b/letsencrypt-auto-source/pieces/bootstrappers/deb_common.sh
index 747ab8c8d..27919b67b 100644
--- a/letsencrypt-auto-source/pieces/bootstrappers/deb_common.sh
+++ b/letsencrypt-auto-source/pieces/bootstrappers/deb_common.sh
@@ -23,19 +23,22 @@ BootstrapDebCommon() {
# distro version (#346)
virtualenv=
- if apt-cache show virtualenv > /dev/null 2>&1 && ! apt-cache --quiet=0 show virtualenv 2>&1 | grep -q 'No packages found'; then
- virtualenv="virtualenv"
+ # virtual env is known to apt and is installable
+ if apt-cache show virtualenv > /dev/null 2>&1 ; then
+ if ! LC_ALL=C apt-cache --quiet=0 show virtualenv 2>&1 | grep -q 'No packages found'; then
+ virtualenv="virtualenv"
+ fi
fi
if apt-cache show python-virtualenv > /dev/null 2>&1; then
- virtualenv="$virtualenv python-virtualenv"
+ virtualenv="$virtualenv python-virtualenv"
fi
augeas_pkg="libaugeas0 augeas-lenses"
- AUGVERSION=`apt-cache show --no-all-versions libaugeas0 | grep ^Version: | cut -d" " -f2`
+ AUGVERSION=`LC_ALL=C apt-cache show --no-all-versions libaugeas0 | grep ^Version: | cut -d" " -f2`
if [ "$ASSUME_YES" = 1 ]; then
- YES_FLAG="-y"
+ YES_FLAG="-y"
fi
AddBackportRepo() {
diff --git a/setup.py b/setup.py
index 46dbdac81..4227d5d92 100644
--- a/setup.py
+++ b/setup.py
@@ -131,7 +131,6 @@ setup(
'null = certbot.plugins.null:Installer',
'standalone = certbot.plugins.standalone:Authenticator',
'webroot = certbot.plugins.webroot:Authenticator',
- 'script = certbot.plugins.script:Authenticator',
],
},
)
diff --git a/tests/boulder-integration.sh b/tests/boulder-integration.sh
index f0e6d993e..38448bbf6 100755
--- a/tests/boulder-integration.sh
+++ b/tests/boulder-integration.sh
@@ -33,6 +33,23 @@ common() {
"$@"
}
+CheckHooks() {
+ EXPECTED="/tmp/expected$$"
+ echo "wtf.pre" > "$EXPECTED"
+ echo "wtf2.pre" >> "$EXPECTED"
+ echo "renew" >> "$EXPECTED"
+ echo "renew" >> "$EXPECTED"
+ echo "wtf.post" > "$EXPECTED"
+ echo "wtf2.post" >> "$EXPECTED"
+ if cmp --quiet "$EXPECTED" "$HOOK_TEST" ; then
+ echo Hooks did not run as expected\; got
+ cat "$HOOK_TEST"
+ echo Expected
+ cat "$EXPECTED"
+ fi
+ [ -f "$HOOK_TEST" ] && rm -f "$HOOK_TEST"
+}
+
# We start a server listening on the port for the
# unrequested challenge to prevent regressions in #3601.
python -m SimpleHTTPServer $http_01_port &
@@ -52,26 +69,14 @@ common --domains le2.wtf --preferred-challenges http-01 run \
--renew-hook 'echo renew >> "$HOOK_TEST"'
kill $python_server_pid
-common -a manual -d le.wtf auth --rsa-key-size 4096 \
- --pre-hook 'echo wtf2.pre >> "$HOOK_TEST"' \
- --post-hook 'echo wtf2.post >> "$HOOK_TEST"'
+common certonly -a manual -d le.wtf --rsa-key-size 4096 \
+ --manual-auth-hook ./tests/manual-http-auth.sh \
+ --manual-cleanup-hook ./tests/manual-http-cleanup.sh
+ --pre-hook 'echo wtf2.pre >> "$HOOK_TEST"' \
+ --post-hook 'echo wtf2.post >> "$HOOK_TEST"'
-CheckHooks() {
- EXPECTED="/tmp/expected$$"
- echo "wtf.pre" > "$EXPECTED"
- echo "wtf2.pre" >> "$EXPECTED"
- echo "renew" >> "$EXPECTED"
- echo "renew" >> "$EXPECTED"
- echo "wtf.post" > "$EXPECTED"
- echo "wtf2.post" >> "$EXPECTED"
- if cmp --quiet "$EXPECTED" "$HOOK_TEST" ; then
- echo Hooks did not run as expected\; got
- cat "$HOOK_TEST"
- echo Expected
- cat "$EXPECTED"
- fi
- [ -f "$HOOK_TEST" ] && rm -f "$HOOK_TEST"
-}
+common certonly -a manual -d dns.le.wtf --preferred-challenges dns-01 \
+ --manual-auth-hook ./tests/manual-dns-auth.sh
export CSR_PATH="${root}/csr.der" KEY_PATH="${root}/key.pem" \
OPENSSL_CNF=examples/openssl.cnf
diff --git a/tests/display.py b/tests/display.py
index 7400788a3..1f548e33d 100644
--- a/tests/display.py
+++ b/tests/display.py
@@ -18,5 +18,5 @@ def test_visual(displayer, choices):
if __name__ == "__main__":
- displayer = util.FileDisplay(sys.stdout):
+ displayer = util.FileDisplay(sys.stdout, False)
test_visual(displayer, util_test.CHOICES)
diff --git a/tests/integration/_common.sh b/tests/integration/_common.sh
index 8d01ad763..12924fe21 100755
--- a/tests/integration/_common.sh
+++ b/tests/integration/_common.sh
@@ -25,7 +25,7 @@ certbot_test_no_force_renew () {
--no-verify-ssl \
--tls-sni-01-port $tls_sni_01_port \
--http-01-port $http_01_port \
- --manual-test-mode \
+ --manual-public-ip-logging-ok \
$store_flags \
--non-interactive \
--no-redirect \
diff --git a/tests/manual-dns-auth.sh b/tests/manual-dns-auth.sh
new file mode 100755
index 000000000..9b9a1a5eb
--- /dev/null
+++ b/tests/manual-dns-auth.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+curl -X POST 'http://localhost:8055/set-txt' -d \
+ "{\"host\": \"_acme-challenge.$CERTBOT_DOMAIN.\", \
+ \"value\": \"$CERTBOT_VALIDATION\"}"
diff --git a/tests/manual-http-auth.sh b/tests/manual-http-auth.sh
new file mode 100755
index 000000000..c4730392b
--- /dev/null
+++ b/tests/manual-http-auth.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+uri_path=".well-known/acme-challenge/$CERTBOT_TOKEN"
+
+cd $(mktemp -d)
+mkdir -p $(dirname $uri_path)
+echo $CERTBOT_VALIDATION > $uri_path
+python -m SimpleHTTPServer $http_01_port >/dev/null 2>&1 &
+server_pid=$!
+while ! curl "http://localhost:$http_01_port/$uri_path" >/dev/null 2>&1; do
+ sleep 1s
+done
+echo $server_pid
diff --git a/tests/manual-http-cleanup.sh b/tests/manual-http-cleanup.sh
new file mode 100755
index 000000000..5e437bf08
--- /dev/null
+++ b/tests/manual-http-cleanup.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+kill $CERTBOT_AUTH_OUTPUT
diff --git a/tox.cover.sh b/tox.cover.sh
index d138a98e5..7243c4708 100755
--- a/tox.cover.sh
+++ b/tox.cover.sh
@@ -36,9 +36,8 @@ cover () {
# specific package, positional argument scopes tests only to
# specific package directory; --cover-tests makes sure every tests
# is run (c.f. #403)
- nosetests -c /dev/null --with-cover --cover-tests --cover-package \
- "$1" --cover-min-percentage="$min" "$1" --processes=-1 \
- --process-timeout=100
+ nosetests -c /dev/null --with-cover --cover-tests --cover-package \
+ "$1" --cover-min-percentage="$min" "$1"
}
rm -f .coverage # --cover-erase is off, make sure stats are correct
diff --git a/tox.ini b/tox.ini
index 1d82092f6..959f44a8d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -63,6 +63,11 @@ commands =
pip install -e .[dev]
nosetests -v certbot --processes=-1 --process-timeout=100
+[testenv:py27_install]
+basepython = python2.7
+commands =
+ pip install -e acme[dns,dev] -e .[dev] -e certbot-apache -e certbot-nginx -e letshelp-certbot
+
[testenv:cover]
basepython = python2.7
commands =