Skip to content

Commit

Permalink
Command-line UX overhaul (certbot#8852)
Browse files Browse the repository at this point in the history
Streamline and reorganize Certbot's CLI output.

This change is a substantial command-line UX overhaul,
based on previous user research. The main goal was to streamline
and clarify output. To see more verbose output, use the -v or -vv flags.

---

* nginx,apache: CLI logging changes

- Add "Successfully deployed ..." message using display_util
- Remove IReporter usage and replace with display_util
- Standardize "... could not find a VirtualHost ..." error

This changes also bumps the version of certbot required by certbot-nginx
and certbot-apache to take use of the new display_util function.

* fix certbot_compatibility_test

since the http plugins now require IDisplay, we need to inject it

* fix dependency version on certbot

* use better asserts

* try fix oldest deps

because certbot 1.10.0 depends on acme>=1.8.0, we need to use
acme==1.8.0 in the -oldest tests

* cli: redesign output of new certificate reporting

Changes the output of run, certonly and certonly --csr. No longer uses
IReporter.

* cli: redesign output of failed authz reporting

* fix problem sorting to be stable between py2 & 3

* add some catch-all error text

* cli: dont use IReporter for EFF donation prompt

* add per-authenticator hints

* pass achalls to auth_hint, write some tests

* exclude static auth hints from coverage

* dont call auth_hint unless derived from .Plugin

* dns fallback hint: dont assume --dns-blah works

--dns-blah won't work for third-party plugins, they need to be specified
using --authenticator dns-blah.

* add code comments about the auth_hint interface

* renew: don't restart the installer for dry-runs

Prevents Certbot from superfluously invoking the installer restart
during dry-run renewals. (This does not affect authenticator restarts).

Additionally removes some CLI output that was reporting the fullchain
path of the renewed certificate.

* update CHANGELOG.md

* cli: redesign output when cert installation failed

- Display a message when certificate installation begins.
- Don't use IReporter, just log errors immediately if restart/rollback
  fails.
- Prompt the user with a command to retry the installation process once
  they have fixed any underlying problems.

* vary by preconfigured_renewal

and move expiry date to be above the renewal advice

* update code comment

Co-authored-by: ohemorange <[email protected]>

* update code comment

Co-authored-by: ohemorange <[email protected]>

* fix lint

* derve cert name from cert_path, if possible

* fix type annotation

* text change in nginx hint

Co-authored-by: ohemorange <[email protected]>

* print message when restarting server after renewal

* log: print "advice" when exiting with an error

When running in non-quiet mode.

* try fix -oldest lock_test.py

* fix docstring

* s/Restarting/Reloading/ when notifying the user

* fix test name

Co-authored-by: ohemorange <[email protected]>

* type annotations

* s/using the {} plugin/installer: {}/

* copy: avoid "plugin" where possible

* link to user guide#automated-renewals

when not running with --preconfigured-renewal

* cli: reduce default logging verbosity

* fix lock_test: -vv is needed to see logger.debug

* Change comment in log.py to match the change to default verbosity

* Audit and adjust logging levels in apache module

* Audit and adjust logging levels in nginx module

* Audit, adjust logging levels, and improve logging calls in certbot module

* Fix tests to mock correct methods and classes

* typo in non-preconfigured-renewal message

Co-authored-by: ohemorange <[email protected]>

* fix test

* revert acme version bump

* catch up to python3 changes

* Revert "revert acme version bump"

This reverts commit fa83d6a.

* Change ocsp check error to warning since it's non-fatal

* Update storage_test in parallel with last change

* get rid of leading newline on "Deploying [...]"

* shrink renewal and installation success messages

* print logfile rather than logdir in exit handler

* Decrease logging level to info for idempotent operation where enhancement is already set

* Display cert not yet due for renewal message when renewing and no other action will be taken, and change cert to certificate

* also write to logger so it goes in the log file

* Don't double write to log file; fix main test

* cli: remove trailing newline on new cert reporting

* ignore type error

* revert accidental changes to dependencies

* Pass tests in any timezone by using utcfromtimestamp

* Add changelog entry

* fix nits

* Improve wording of try again message

* minor wording change to changelog

* hooks: send hook stdout to CLI stdout

includes both --manual and --{pre,post,renew} hooks

* update docstrings and remove TODO

* add a pending deprecation on execute_command

* add test coverage for both

* update deprecation text

Co-authored-by: ohemorange <[email protected]>

Co-authored-by: Alex Zorin <[email protected]>
Co-authored-by: alexzorin <[email protected]>
  • Loading branch information
3 people authored May 25, 2021
1 parent 099c6c8 commit 6f27c32
Show file tree
Hide file tree
Showing 55 changed files with 1,011 additions and 387 deletions.
58 changes: 34 additions & 24 deletions certbot-apache/certbot_apache/_internal/configurator.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from certbot.achallenges import KeyAuthorizationAnnotatedChallenge # pylint: disable=unused-import
from certbot.compat import filesystem
from certbot.compat import os
from certbot.display import util as display_util
from certbot.plugins import common
from certbot.plugins.enhancements import AutoHSTSEnhancement
from certbot.plugins.util import path_surgery
Expand Down Expand Up @@ -515,6 +516,8 @@ def deploy_cert(self, domain, cert_path, key_path,
vhosts = self.choose_vhosts(domain)
for vhost in vhosts:
self._deploy_cert(vhost, cert_path, key_path, chain_path, fullchain_path)
display_util.notify("Successfully deployed certificate for {} to {}"
.format(domain, vhost.filep))

def choose_vhosts(self, domain, create_if_no_ssl=True):
"""
Expand Down Expand Up @@ -553,6 +556,19 @@ def _vhosts_for_wildcard(self, domain):

return list(matched)

def _raise_no_suitable_vhost_error(self, target_name: str):
"""
Notifies the user that Certbot could not find a vhost to secure
and raises an error.
:param str target_name: The server name that could not be mapped
:raises errors.PluginError: Raised unconditionally
"""
raise errors.PluginError(
"Certbot could not find a VirtualHost for {0} in the Apache "
"configuration. Please create a VirtualHost with a ServerName "
"matching {0} and try again.".format(target_name)
)

def _in_wildcard_scope(self, name, domain):
"""
Helper method for _vhosts_for_wildcard() that makes sure that the domain
Expand Down Expand Up @@ -590,12 +606,7 @@ def _choose_vhosts_wildcard(self, domain, create_ssl=True):
dialog_output = display_ops.select_vhost_multiple(list(dialog_input))

if not dialog_output:
logger.error(
"No vhost exists with servername or alias for domain %s. "
"No vhost was selected. Please specify ServerName or ServerAlias "
"in the Apache config.",
domain)
raise errors.PluginError("No vhost selected")
self._raise_no_suitable_vhost_error(domain)

# Make sure we create SSL vhosts for the ones that are HTTP only
# if requested.
Expand Down Expand Up @@ -719,12 +730,7 @@ def _choose_vhost_from_list(self, target_name, temp=False):
# Select a vhost from a list
vhost = display_ops.select_vhost(target_name, self.vhosts)
if vhost is None:
logger.error(
"No vhost exists with servername or alias of %s. "
"No vhost was selected. Please specify ServerName or ServerAlias "
"in the Apache config.",
target_name)
raise errors.PluginError("No vhost selected")
self._raise_no_suitable_vhost_error(target_name)
if temp:
return vhost
if not vhost.ssl:
Expand Down Expand Up @@ -1532,12 +1538,11 @@ def _copy_create_ssl_vhost_skeleton(self, vhost, ssl_fp):
raise errors.PluginError("Unable to write/read in make_vhost_ssl")

if sift:
reporter = zope.component.getUtility(interfaces.IReporter)
reporter.add_message(
"Some rewrite rules copied from {0} were disabled in the "
"vhost for your HTTPS site located at {1} because they have "
"the potential to create redirection loops.".format(
vhost.filep, ssl_fp), reporter.MEDIUM_PRIORITY)
display_util.notify(
f"Some rewrite rules copied from {vhost.filep} were disabled in the "
f"vhost for your HTTPS site located at {ssl_fp} because they have "
"the potential to create redirection loops."
)
self.parser.aug.set("/augeas/files%s/mtime" % (self._escape(ssl_fp)), "0")
self.parser.aug.set("/augeas/files%s/mtime" % (self._escape(vhost.filep)), "0")

Expand Down Expand Up @@ -1866,13 +1871,13 @@ def enhance(self, domain, enhancement, options=None):
if options:
msg_enhancement += ": " + options
msg = msg_tmpl.format(domain, msg_enhancement)
logger.warning(msg)
logger.error(msg)
raise errors.PluginError(msg)
try:
for vhost in vhosts:
func(vhost, options)
except errors.PluginError:
logger.warning("Failed %s for %s", enhancement, domain)
logger.error("Failed %s for %s", enhancement, domain)
raise

def _autohsts_increase(self, vhost, id_str, nextstep):
Expand Down Expand Up @@ -2436,7 +2441,7 @@ def _reload(self):
try:
util.run_script(self.options.restart_cmd)
except errors.SubprocessError as err:
logger.info("Unable to restart apache using %s",
logger.warning("Unable to restart apache using %s",
self.options.restart_cmd)
alt_restart = self.options.restart_cmd_alt
if alt_restart:
Expand Down Expand Up @@ -2500,6 +2505,11 @@ def more_info(self):
version=".".join(str(i) for i in self.version))
)

def auth_hint(self, failed_achalls): # pragma: no cover
return ("The Certificate Authority failed to verify the temporary Apache configuration "
"changes made by Certbot. Ensure that the listed domains point to this Apache "
"server and that it is accessible from the internet.")

###########################################################################
# Challenges Section
###########################################################################
Expand Down Expand Up @@ -2593,7 +2603,7 @@ def enable_autohsts(self, _unused_lineage, domains):
msg_tmpl = ("Certbot was not able to find SSL VirtualHost for a "
"domain {0} for enabling AutoHSTS enhancement.")
msg = msg_tmpl.format(d)
logger.warning(msg)
logger.error(msg)
raise errors.PluginError(msg)
for vh in vhosts:
try:
Expand Down Expand Up @@ -2679,7 +2689,7 @@ def update_autohsts(self, _unused_domain):
except errors.PluginError:
msg = ("Could not find VirtualHost with ID {0}, disabling "
"AutoHSTS for this VirtualHost").format(id_str)
logger.warning(msg)
logger.error(msg)
# Remove the orphaned AutoHSTS entry from pluginstorage
self._autohsts.pop(id_str)
continue
Expand Down Expand Up @@ -2719,7 +2729,7 @@ def deploy_autohsts(self, lineage):
except errors.PluginError:
msg = ("VirtualHost with id {} was not found, unable to "
"make HSTS max-age permanent.").format(id_str)
logger.warning(msg)
logger.error(msg)
self._autohsts.pop(id_str)
continue
if self._autohsts_vhost_in_lineage(vhost, lineage):
Expand Down
2 changes: 1 addition & 1 deletion certbot-apache/certbot_apache/_internal/display_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def _vhost_menu(domain, vhosts):
"guidance in non-interactive mode. Certbot may need "
"vhosts to be explicitly labelled with ServerName or "
"ServerAlias directives.".format(domain))
logger.warning(msg)
logger.error(msg)
raise errors.MissingCommandlineFlag(msg)

return code, tag
2 changes: 1 addition & 1 deletion certbot-apache/certbot_apache/_internal/override_debian.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def enable_site(self, vhost):
# Already in shape
vhost.enabled = True
return None
logger.warning(
logger.error(
"Could not symlink %s to %s, got error: %s", enabled_path,
vhost.filep, err.strerror)
errstring = ("Encountered error while trying to enable a " +
Expand Down
4 changes: 2 additions & 2 deletions certbot-apache/local-oldest-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Remember to update setup.py to match the package versions below.
acme[dev]==0.29.0
certbot[dev]==1.6.0
acme[dev]==1.8.0
certbot[dev]==1.10.0
4 changes: 2 additions & 2 deletions certbot-apache/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
# Remember to update local-oldest-requirements.txt when changing the minimum
# acme/certbot version.
install_requires = [
'acme>=0.29.0',
'certbot>=1.6.0',
'acme>=1.8.0',
'certbot>=1.10.0',
'python-augeas',
'setuptools>=39.0.1',
'zope.component',
Expand Down
4 changes: 2 additions & 2 deletions certbot-apache/tests/autohsts_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def test_autohsts_make_permanent_noop(self):
@mock.patch("certbot_apache._internal.display_ops.select_vhost")
def test_autohsts_no_ssl_vhost(self, mock_select):
mock_select.return_value = self.vh_truth[0]
with mock.patch("certbot_apache._internal.configurator.logger.warning") as mock_log:
with mock.patch("certbot_apache._internal.configurator.logger.error") as mock_log:
self.assertRaises(errors.PluginError,
self.config.enable_autohsts,
mock.MagicMock(), "invalid.example.com")
Expand Down Expand Up @@ -179,7 +179,7 @@ def test_autohsts_make_permanent_vhost_not_found(self):
self.config._autohsts_fetch_state()
self.config._autohsts["orphan_id"] = {"laststep": 999, "timestamp": 0}
self.config._autohsts_save_state()
with mock.patch("certbot_apache._internal.configurator.logger.warning") as mock_log:
with mock.patch("certbot_apache._internal.configurator.logger.error") as mock_log:
self.config.deploy_autohsts(mock.MagicMock())
self.assertTrue(mock_log.called)
self.assertTrue(
Expand Down
16 changes: 11 additions & 5 deletions certbot-apache/tests/centos6_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Test for certbot_apache._internal.configurator for CentOS 6 overrides"""
import unittest
from unittest import mock

from certbot.compat import os
from certbot.errors import MisconfigurationError
Expand Down Expand Up @@ -65,7 +66,8 @@ def test_get_virtual_hosts(self):
raise Exception("Missed: %s" % vhost) # pragma: no cover
self.assertEqual(found, 2)

def test_loadmod_default(self):
@mock.patch("certbot_apache._internal.configurator.display_util.notify")
def test_loadmod_default(self, unused_mock_notify):
ssl_loadmods = self.config.parser.find_dir(
"LoadModule", "ssl_module", exclude=False)
self.assertEqual(len(ssl_loadmods), 1)
Expand Down Expand Up @@ -95,7 +97,8 @@ def test_loadmod_default(self):
ifmod_args = self.config.parser.get_all_args(lm[:-17])
self.assertTrue("!mod_ssl.c" in ifmod_args)

def test_loadmod_multiple(self):
@mock.patch("certbot_apache._internal.configurator.display_util.notify")
def test_loadmod_multiple(self, unused_mock_notify):
sslmod_args = ["ssl_module", "modules/mod_ssl.so"]
# Adds another LoadModule to main httpd.conf in addtition to ssl.conf
self.config.parser.add_dir(self.config.parser.loc["default"], "LoadModule",
Expand All @@ -115,7 +118,8 @@ def test_loadmod_multiple(self):
for mod in post_loadmods:
self.assertTrue(self.config.parser.not_modssl_ifmodule(mod)) #pylint: disable=no-member

def test_loadmod_rootconf_exists(self):
@mock.patch("certbot_apache._internal.configurator.display_util.notify")
def test_loadmod_rootconf_exists(self, unused_mock_notify):
sslmod_args = ["ssl_module", "modules/mod_ssl.so"]
rootconf_ifmod = self.config.parser.get_ifmod(
parser.get_aug_path(self.config.parser.loc["default"]),
Expand All @@ -142,7 +146,8 @@ def test_loadmod_rootconf_exists(self):
self.config.parser.get_all_args(mods[0][:-7]),
sslmod_args)

def test_neg_loadmod_already_on_path(self):
@mock.patch("certbot_apache._internal.configurator.display_util.notify")
def test_neg_loadmod_already_on_path(self, unused_mock_notify):
loadmod_args = ["ssl_module", "modules/mod_ssl.so"]
ifmod = self.config.parser.get_ifmod(
self.vh_truth[1].path, "!mod_ssl.c", beginning=True)
Expand Down Expand Up @@ -185,7 +190,8 @@ def test_loadmod_non_duplicate(self):
# Make sure that none was changed
self.assertEqual(pre_matches, post_matches)

def test_loadmod_not_found(self):
@mock.patch("certbot_apache._internal.configurator.display_util.notify")
def test_loadmod_not_found(self, unused_mock_notify):
# Remove all existing LoadModule ssl_module... directives
orig_loadmods = self.config.parser.find_dir("LoadModule",
"ssl_module",
Expand Down
30 changes: 17 additions & 13 deletions certbot-apache/tests/configurator_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,8 @@ def test_non_default_vhosts(self):
vhosts = self.config._non_default_vhosts(self.config.vhosts)
self.assertEqual(len(vhosts), 10)

def test_deploy_cert_enable_new_vhost(self):
@mock.patch('certbot_apache._internal.configurator.display_util.notify')
def test_deploy_cert_enable_new_vhost(self, unused_mock_notify):
# Create
ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[0])
self.config.parser.modules["ssl_module"] = None
Expand Down Expand Up @@ -375,7 +376,8 @@ def mock_find_dir(directive, argument, _):
self.fail("Include shouldn't be added, as patched find_dir 'finds' existing one") \
# pragma: no cover

def test_deploy_cert(self):
@mock.patch('certbot_apache._internal.configurator.display_util.notify')
def test_deploy_cert(self, unused_mock_notify):
self.config.parser.modules["ssl_module"] = None
self.config.parser.modules["mod_ssl.c"] = None
self.config.parser.modules["socache_shmcb_module"] = None
Expand Down Expand Up @@ -891,7 +893,7 @@ def test_enhance_unknown_enhancement(self):
self.config.enhance, "certbot.demo", "unknown_enhancement")

def test_enhance_no_ssl_vhost(self):
with mock.patch("certbot_apache._internal.configurator.logger.warning") as mock_log:
with mock.patch("certbot_apache._internal.configurator.logger.error") as mock_log:
self.assertRaises(errors.PluginError, self.config.enhance,
"certbot.demo", "redirect")
# Check that correct logger.warning was printed
Expand Down Expand Up @@ -1290,7 +1292,8 @@ def test_enable_site_nondebian(self):
os.path.basename(inc_path) in self.config.parser.existing_paths[
os.path.dirname(inc_path)])

def test_deploy_cert_not_parsed_path(self):
@mock.patch('certbot_apache._internal.configurator.display_util.notify')
def test_deploy_cert_not_parsed_path(self, unused_mock_notify):
# Make sure that we add include to root config for vhosts when
# handle-sites is false
self.config.parser.modules["ssl_module"] = None
Expand Down Expand Up @@ -1386,7 +1389,8 @@ def test_choose_vhosts_wildcard_already_ssl(self, mock_makessl, mock_vh_for_w):
self.assertEqual(vhs[0], self.vh_truth[7])


def test_deploy_cert_wildcard(self):
@mock.patch('certbot_apache._internal.configurator.display_util.notify')
def test_deploy_cert_wildcard(self, unused_mock_notify):
# pylint: disable=protected-access
mock_choose_vhosts = mock.MagicMock()
mock_choose_vhosts.return_value = [self.vh_truth[7]]
Expand Down Expand Up @@ -1606,8 +1610,8 @@ def test_get_new_path(self):
self.assertEqual(self.config._get_new_vh_path(without_index, both),
with_index_2[0])

@certbot_util.patch_get_utility()
def test_make_vhost_ssl_with_existing_rewrite_rule(self, mock_get_utility):
@mock.patch("certbot_apache._internal.configurator.display_util.notify")
def test_make_vhost_ssl_with_existing_rewrite_rule(self, mock_notify):
self.config.parser.modules["rewrite_module"] = None

ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[4])
Expand All @@ -1623,11 +1627,11 @@ def test_make_vhost_ssl_with_existing_rewrite_rule(self, mock_get_utility):
"\"http://new.example.com/docs/$1\" [R,L]")
self.assertTrue(commented_rewrite_rule in conf_text)
self.assertTrue(uncommented_rewrite_rule in conf_text)
mock_get_utility().add_message.assert_called_once_with(mock.ANY,
mock.ANY)
self.assertEqual(mock_notify.call_count, 1)
self.assertIn("Some rewrite rules", mock_notify.call_args[0][0])

@certbot_util.patch_get_utility()
def test_make_vhost_ssl_with_existing_rewrite_conds(self, mock_get_utility):
@mock.patch("certbot_apache._internal.configurator.display_util.notify")
def test_make_vhost_ssl_with_existing_rewrite_conds(self, mock_notify):
self.config.parser.modules["rewrite_module"] = None

ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[3])
Expand All @@ -1652,8 +1656,8 @@ def test_make_vhost_ssl_with_existing_rewrite_conds(self, mock_get_utility):
self.assertTrue(commented_cond1 in conf_line_set)
self.assertTrue(commented_cond2 in conf_line_set)
self.assertTrue(commented_rewrite_rule in conf_line_set)
mock_get_utility().add_message.assert_called_once_with(mock.ANY,
mock.ANY)
self.assertEqual(mock_notify.call_count, 1)
self.assertIn("Some rewrite rules", mock_notify.call_args[0][0])


class InstallSslOptionsConfTest(util.ApacheTest):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import time
from typing import List
from typing import Tuple
import zope.component

import OpenSSL
from urllib3.util import connection
Expand All @@ -19,6 +20,7 @@
from acme import messages
from certbot import achallenges
from certbot import errors as le_errors
from certbot.display import util as display_util
from certbot.tests import acme_util
from certbot_compatibility_test import errors
from certbot_compatibility_test import util
Expand Down Expand Up @@ -327,10 +329,17 @@ def setup_logging(args):
root_logger.addHandler(handler)


def setup_display():
""""Prepares IDisplay for the Certbot plugins """
displayer = display_util.NoninteractiveDisplay(sys.stdout)
zope.component.provideUtility(displayer)


def main():
"""Main test script execution."""
args = get_args()
setup_logging(args)
setup_display()

if args.plugin not in PLUGINS:
raise errors.Error("Unknown plugin {0}".format(args.plugin))
Expand Down
6 changes: 4 additions & 2 deletions certbot-dns-cloudflare/tests/dns_cloudflare_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ def setUp(self):
# _get_cloudflare_client | pylint: disable=protected-access
self.auth._get_cloudflare_client = mock.MagicMock(return_value=self.mock_client)

def test_perform(self):
@test_util.patch_get_utility()
def test_perform(self, unused_mock_get_utility):
self.auth.perform([self.achall])

expected = [mock.call.add_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)]
Expand All @@ -55,7 +56,8 @@ def test_cleanup(self):
expected = [mock.call.del_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY)]
self.assertEqual(expected, self.mock_client.mock_calls)

def test_api_token(self):
@test_util.patch_get_utility()
def test_api_token(self, unused_mock_get_utility):
dns_test_common.write({"cloudflare_api_token": API_TOKEN},
self.config.cloudflare_credentials)
self.auth.perform([self.achall])
Expand Down
Loading

0 comments on commit 6f27c32

Please sign in to comment.