Merge branch 'master' into ecdsa

This commit is contained in:
Osiris Inferi 2016-12-23 13:09:32 +01:00
commit 2e5a385181
No known key found for this signature in database
GPG key ID: 590297AD5FAE2134
203 changed files with 8241 additions and 5566 deletions

5
.gitignore vendored
View file

@ -17,11 +17,14 @@ letsencrypt-auto-source/letsencrypt-auto.sig.lzma.base64
/.vagrant
tags
# editor temporary files
*~
*.swp
*.sw?
\#*#
.idea
.ropeproject
# auth --cert-path --chain-path
/*.pem

View file

@ -4,9 +4,6 @@ cache:
directories:
- $HOME/.cache/pip
# This makes sure we get a host with docker-compose present.
dist: trusty
before_install:
- 'dpkg -s libaugeas0'
@ -19,48 +16,80 @@ env:
matrix:
include:
- python: "2.6"
env: TOXENV=py26 BOULDER_INTEGRATION=1
sudo: true
after_failure:
- sudo cat /var/log/mysql/error.log
- ps aux | grep mysql
- python: "2.6"
env: TOXENV=py26-oldest BOULDER_INTEGRATION=1
sudo: true
after_failure:
- sudo cat /var/log/mysql/error.log
- ps aux | grep mysql
- python: "2.7"
env: TOXENV=apacheconftest
sudo: required
- python: "2.7"
env: TOXENV=py27 BOULDER_INTEGRATION=1
sudo: true
after_failure:
- sudo cat /var/log/mysql/error.log
- ps aux | grep mysql
- python: "2.7"
env: TOXENV=py27-oldest BOULDER_INTEGRATION=1
sudo: true
after_failure:
- sudo cat /var/log/mysql/error.log
- ps aux | grep mysql
env: TOXENV=cover
- python: "2.7"
env: TOXENV=lint
- python: "2.7"
env: TOXENV=py27-oldest BOULDER_INTEGRATION=1
sudo: required
after_failure:
- sudo cat /var/log/mysql/error.log
- ps aux | grep mysql
services: docker
- python: "2.6"
env: TOXENV=py26 BOULDER_INTEGRATION=1
sudo: required
after_failure:
- sudo cat /var/log/mysql/error.log
- ps aux | grep mysql
services: docker
- python: "2.6"
env: TOXENV=py26-oldest BOULDER_INTEGRATION=1
sudo: required
after_failure:
- 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=le_auto
env: TOXENV=apache_compat
services: docker
before_install:
addons:
- sudo: required
env: TOXENV=nginx_compat
services: docker
before_install:
addons:
- sudo: required
env: TOXENV=le_auto_precise
services: docker
before_install:
addons:
- sudo: required
env: TOXENV=le_auto_trusty
services: docker
before_install:
addons:
- sudo: required
env: TOXENV=le_auto_wheezy
services: docker
before_install:
addons:
- sudo: required
env: TOXENV=le_auto_centos6
services: docker
before_install:
addons:
- python: "2.7"
env: TOXENV=cover
env: TOXENV=apacheconftest
sudo: required
- python: "3.3"
env: TOXENV=py33
- python: "3.4"
env: TOXENV=py34
- python: "3.5"
env: TOXENV=py35
- python: "2.7"
env: TOXENV=nginxroundtrip
# Only build pushes to the master branch, PRs, and branches beginning with
# `test-`. This reduces the number of simultaneous Travis runs, which speeds
@ -92,7 +121,6 @@ addons:
- python-dev
- python-virtualenv
- gcc
- dialog
- libaugeas0
- libssl-dev
- libffi-dev
@ -104,7 +132,6 @@ addons:
- apache2
- libapache2-mod-wsgi
- libapache2-mod-macro
- sudo
install: "travis_retry pip install tox coveralls"
script: 'travis_retry tox && ([ "xxx$BOULDER_INTEGRATION" = "xxx" ] || ./tests/travis-integration.sh)'

View file

@ -1,11 +1,8 @@
ChangeLog
=========
Please note:
the change log will only get updated after first release - for now please use the
`commit log <https://github.com/certbot/certbot/commits/master>`_.
To see the changes in a given release, view the issues closed in a given
release's GitHub milestone:
To see the changes in a given release, inspect the github milestone for the
release. For instance:
https://github.com/certbot/certbot/issues?utf8=%E2%9C%93&q=milestone%3A0.3.0
- `Past releases <https://github.com/certbot/certbot/milestones?state=closed>`_
- `Upcoming releases <https://github.com/certbot/certbot/milestones>`_

View file

@ -15,4 +15,21 @@ to the Sphinx generated docs is provided below.
-->
https://certbot.eff.org/docs/contributing.html
# Certbot Contributing Guide
Hi! Welcome to the Certbot project. We look forward to collaborating with you.
If you're reporting a bug in Certbot, please make sure to include:
- The version of Certbot you're running.
- The operating system you're running it on.
- The commands you ran.
- What you expected to happen, and
- What actually happened.
If you're a developer, we have some helpful information in our
[Developer's Guide](https://certbot.eff.org/docs/contributing.html) to get you
started. In particular, we recommend you read these sections
- [Finding issues to work on](https://certbot.eff.org/docs/contributing.html#find-issues-to-work-on)
- [Coding style](https://certbot.eff.org/docs/contributing.html#coding-style)
- [Submitting a pull request](https://certbot.eff.org/docs/contributing.html#submitting-a-pull-request)

View file

@ -1,4 +1,4 @@
.. This file contains of a series of comments that are used to include sections of this README in other files. Do not modify these comments unless you know what you are doing. tag:intro-begin
.. This file contains a series of comments that are used to include sections of this README in other files. Do not modify these comments unless you know what you are doing. tag:intro-begin
Certbot is part of EFFs effort to encrypt the entire Internet. Secure communication over the Web relies on HTTPS, which requires the use of a digital certificate that lets browsers verify the identify of web servers (e.g., is that really google.com?). Web servers obtain their certificates from trusted third parties called certificate authorities (CAs). Certbot is an easy-to-use client that fetches a certificate from Lets Encrypt—an open certificate authority launched by the EFF, Mozilla, and others—and deploys it to a web server.
@ -8,6 +8,141 @@ How you use Certbot depends on the configuration of your web server. The best wa
If youre using a hosted service and dont have direct access to your web server, you might not be able to use Certbot. Check with your hosting provider for documentation about uploading certificates or using certificates issues by Lets Encrypt.
Certbot is a fully-featured, extensible client for the Let's
Encrypt CA (or any other CA that speaks the `ACME
<https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md>`_
protocol) that can automate the tasks of obtaining certificates and
configuring webservers to use them. This client runs on Unix-based operating
systems.
Until May 2016, Certbot was named simply ``letsencrypt`` or ``letsencrypt-auto``,
depending on install method. Instructions on the Internet, and some pieces of the
software, may still refer to this older name.
Contributing
------------
If you'd like to contribute to this project please read `Developer Guide
<https://certbot.eff.org/docs/contributing.html>`_.
.. _installation:
Installation
------------
The easiest way to install Certbot is by visiting `certbot.eff.org`_, where you can
find the correct installation instructions for many web server and OS combinations.
For more information, see `Get Certbot <https://certbot.eff.org/docs/install.html>`_.
.. _certbot.eff.org: https://certbot.eff.org/
How to run the client
---------------------
In many cases, you can just run ``certbot-auto`` or ``certbot``, and the
client will guide you through the process of obtaining and installing certs
interactively.
For full command line help, you can type::
./certbot-auto --help all
You can also tell it exactly what you want it to do from the command line.
For instance, if you want to obtain a cert for ``example.com``,
``www.example.com``, and ``other.example.net``, using the Apache plugin to both
obtain and install the certs, you could do this::
./certbot-auto --apache -d example.com -d www.example.com -d other.example.net
(The first time you run the command, it will make an account, and ask for an
email and agreement to the Let's Encrypt Subscriber Agreement; you can
automate those with ``--email`` and ``--agree-tos``)
If you want to use a webserver that doesn't have full plugin support yet, you
can still use "standalone" or "webroot" plugins to obtain a certificate::
./certbot-auto certonly --standalone --email admin@example.com -d example.com -d www.example.com -d other.example.net
Understanding the client in more depth
--------------------------------------
To understand what the client is doing in detail, it's important to
understand the way it uses plugins. Please see the `explanation of
plugins <https://certbot.eff.org/docs/using.html#plugins>`_ in
the User Guide.
Links
=====
.. Do not modify this comment unless you know what you're doing. tag:links-begin
Documentation: https://certbot.eff.org/docs
Software project: https://github.com/certbot/certbot
Notes for developers: https://certbot.eff.org/docs/contributing.html
Main Website: https://certbot.eff.org
Let's Encrypt Website: https://letsencrypt.org
IRC Channel: #letsencrypt on `Freenode`_ or #certbot on `OFTC`_
Community: https://community.letsencrypt.org
ACME spec: http://ietf-wg-acme.github.io/acme/
ACME working area in github: https://github.com/ietf-wg-acme/acme
Mailing list: `client-dev`_ (to subscribe without a Google account, send an
email to client-dev+subscribe@letsencrypt.org)
|build-status| |coverage| |docs| |container|
.. _Freenode: https://webchat.freenode.net?channels=%23letsencrypt
.. _OFTC: https://webchat.oftc.net?channels=%23certbot
.. _client-dev: https://groups.google.com/a/letsencrypt.org/forum/#!forum/client-dev
.. |build-status| image:: https://travis-ci.org/certbot/certbot.svg?branch=master
:target: https://travis-ci.org/certbot/certbot
:alt: Travis CI status
.. |coverage| image:: https://coveralls.io/repos/certbot/certbot/badge.svg?branch=master
:target: https://coveralls.io/r/certbot/certbot
:alt: Coverage status
.. |docs| image:: https://readthedocs.org/projects/letsencrypt/badge/
:target: https://readthedocs.org/projects/letsencrypt/
:alt: Documentation status
.. |container| image:: https://quay.io/repository/letsencrypt/letsencrypt/status
:target: https://quay.io/repository/letsencrypt/letsencrypt
:alt: Docker Repository on Quay.io
.. Do not modify this comment unless you know what you're doing. tag:links-end
System Requirements
===================
The Let's Encrypt Client presently only runs on Unix-ish OSes that include
Python 2.6 or 2.7; Python 3.x support will hopefully be added in the future. The
client requires root access in order to write to ``/etc/letsencrypt``,
``/var/log/letsencrypt``, ``/var/lib/letsencrypt``; to bind to ports 80 and 443
(if you use the ``standalone`` plugin) and to read and modify webserver
configurations (if you use the ``apache`` or ``nginx`` plugins). If none of
these apply to you, it is theoretically possible to run without root privileges,
but for most users who want to avoid running an ACME client as root, either
`letsencrypt-nosudo <https://github.com/diafygi/letsencrypt-nosudo>`_ or
`simp_le <https://github.com/kuba/simp_le>`_ are more appropriate choices.
The Apache plugin currently requires a Debian-based OS with augeas version
1.0; this includes Ubuntu 12.04+ and Debian 7+.
.. Do not modify this comment unless you know what you're doing. tag:intro-end
.. Do not modify this comment unless you know what you're doing. tag:features-begin
@ -17,11 +152,12 @@ Current Features
* Supports multiple web servers:
- apache/2.x (working on Debian 8+ and Ubuntu 12.04+)
- standalone (runs its own simple webserver to prove you control a domain)
- apache/2.x (beta support for auto-configuration)
- nginx/0.8.48+ (alpha support for auto-configuration)
- webroot (adds files to webroot directories in order to prove control of
domains and obtain certs)
- nginx/0.8.48+ (highly experimental, not included in certbot-auto)
- standalone (runs its own simple webserver to prove you control a domain)
- other server software via `third party plugins <https://certbot.eff.org/docs/using.html#third-party-plugins>`_
* The private key is generated locally on your system.
* Can talk to the Let's Encrypt CA or optionally to other ACME

1
Vagrantfile vendored
View file

@ -29,6 +29,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
# VM needs more memory to run test suite, got "OSError: [Errno 12]
# Cannot allocate memory" when running
# letsencrypt.client.tests.display.util_test.NcursesDisplayTest
# We may no longer need this.
v.memory = 1024
# Handle cases when the host is behind a private network by making the

View file

@ -9,6 +9,7 @@ from cryptography.hazmat.primitives import hashes
import OpenSSL
import requests
from acme import dns_resolver
from acme import errors
from acme import crypto_util
from acme import fields
@ -232,11 +233,11 @@ class DNS01Response(KeyAuthorizationChallengeResponse):
logger.debug("Verifying %s at %s...", chall.typ, validation_domain_name)
try:
from acme import dns_resolver
except ImportError: # pragma: no cover
raise errors.Error("Local validation for 'dns-01' challenges "
"requires 'dnspython'")
txt_records = dns_resolver.txt_records_for_name(validation_domain_name)
txt_records = dns_resolver.txt_records_for_name(
validation_domain_name)
except errors.DependencyError:
raise errors.DependencyError("Local validation for 'dns-01' "
"challenges requires 'dnspython'")
exists = validation in txt_records
if not exists:
logger.debug("Key authorization from response (%r) doesn't match "

View file

@ -10,7 +10,7 @@ from six.moves.urllib import parse as urllib_parse # pylint: disable=import-err
from acme import errors
from acme import jose
from acme import test_util
from acme.dns_resolver import DNS_REQUIREMENT
CERT = test_util.load_comparable_cert('cert.pem')
KEY = jose.JWKRSA(key=test_util.load_rsa_private_key('rsa512_key.pem'))
@ -92,6 +92,7 @@ class DNS01ResponseTest(unittest.TestCase):
from acme.challenges import DNS01
self.chall = DNS01(token=(b'x' * 16))
self.response = self.chall.response(KEY)
self.records_for_name_path = "acme.dns_resolver.txt_records_for_name"
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
@ -108,28 +109,41 @@ class DNS01ResponseTest(unittest.TestCase):
key2 = jose.JWKRSA.load(test_util.load_vector('rsa256_key.pem'))
self.response.simple_verify(self.chall, "local", key2.public_key())
@mock.patch("acme.dns_resolver.txt_records_for_name")
def test_simple_verify_good_validation(self, mock_resolver):
mock_resolver.return_value = [self.chall.validation(KEY.public_key())]
self.assertTrue(self.response.simple_verify(
self.chall, "local", KEY.public_key()))
mock_resolver.assert_called_once_with(
self.chall.validation_domain_name("local"))
@mock.patch('acme.dns_resolver.DNS_AVAILABLE', False)
def test_simple_verify_without_dns(self):
self.assertRaises(
errors.DependencyError, self.response.simple_verify,
self.chall, 'local', KEY.public_key())
@mock.patch("acme.dns_resolver.txt_records_for_name")
def test_simple_verify_good_validation_multiple_txts(self, mock_resolver):
mock_resolver.return_value = [
"!", self.chall.validation(KEY.public_key())]
self.assertTrue(self.response.simple_verify(
self.chall, "local", KEY.public_key()))
mock_resolver.assert_called_once_with(
self.chall.validation_domain_name("local"))
@test_util.skip_unless(test_util.requirement_available(DNS_REQUIREMENT),
"optional dependency dnspython is not available")
def test_simple_verify_good_validation(self): # pragma: no cover
with mock.patch(self.records_for_name_path) as mock_resolver:
mock_resolver.return_value = [
self.chall.validation(KEY.public_key())]
self.assertTrue(self.response.simple_verify(
self.chall, "local", KEY.public_key()))
mock_resolver.assert_called_once_with(
self.chall.validation_domain_name("local"))
@mock.patch("acme.dns_resolver.txt_records_for_name")
def test_simple_verify_bad_validation(self, mock_dns):
mock_dns.return_value = ["!"]
self.assertFalse(self.response.simple_verify(
self.chall, "local", KEY.public_key()))
@test_util.skip_unless(test_util.requirement_available(DNS_REQUIREMENT),
"optional dependency dnspython is not available")
def test_simple_verify_good_validation_multitxts(self): # pragma: no cover
with mock.patch(self.records_for_name_path) as mock_resolver:
mock_resolver.return_value = [
"!", self.chall.validation(KEY.public_key())]
self.assertTrue(self.response.simple_verify(
self.chall, "local", KEY.public_key()))
mock_resolver.assert_called_once_with(
self.chall.validation_domain_name("local"))
@test_util.skip_unless(test_util.requirement_available(DNS_REQUIREMENT),
"optional dependency dnspython is not available")
def test_simple_verify_bad_validation(self): # pragma: no cover
with mock.patch(self.records_for_name_path) as mock_resolver:
mock_resolver.return_value = ["!"]
self.assertFalse(self.response.simple_verify(
self.chall, "local", KEY.public_key()))
class DNS01Test(unittest.TestCase):

View file

@ -1,4 +1,5 @@
"""ACME client API."""
import base64
import collections
import datetime
from email.utils import parsedate_tz
@ -28,6 +29,8 @@ logger = logging.getLogger(__name__)
if sys.version_info < (2, 7, 9): # pragma: no cover
requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3()
DER_CONTENT_TYPE = 'application/pkix-cert'
class Client(object): # pylint: disable=too-many-instance-attributes
"""ACME client.
@ -45,7 +48,6 @@ class Client(object): # pylint: disable=too-many-instance-attributes
`verify_ssl`.
"""
DER_CONTENT_TYPE = 'application/pkix-cert'
def __init__(self, directory, key, alg=jose.RS256, verify_ssl=True,
net=None):
@ -89,8 +91,6 @@ class Client(object): # pylint: disable=too-many-instance-attributes
:returns: Registration Resource.
:rtype: `.RegistrationResource`
:raises .UnexpectedUpdate:
"""
new_reg = messages.NewRegistration() if new_reg is None else new_reg
assert isinstance(new_reg, messages.NewRegistration)
@ -101,12 +101,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes
# "Instance of 'Field' has no key/contact member" bug:
# pylint: disable=no-member
regr = self._regr_from_response(response)
if (regr.body.key != self.key.public_key() or
regr.body.contact != new_reg.contact):
raise errors.UnexpectedUpdate(regr)
return regr
return self._regr_from_response(response)
def _send_recv_regr(self, regr, body):
response = self.net.post(regr.uri, body)
@ -311,7 +306,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes
# TODO: assert len(authzrs) == number of SANs
req = messages.CertificateRequest(csr=csr)
content_type = self.DER_CONTENT_TYPE # TODO: add 'cert_type 'argument
content_type = DER_CONTENT_TYPE # TODO: add 'cert_type 'argument
response = self.net.post(
authzrs[0].new_cert_uri, # TODO: acme-spec #90
req,
@ -413,7 +408,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes
:rtype: tuple
"""
content_type = self.DER_CONTENT_TYPE # TODO: make it a param
content_type = DER_CONTENT_TYPE # TODO: make it a param
response = self.net.get(uri, headers={'Accept': content_type},
content_type=content_type)
return response, jose.ComparableX509(OpenSSL.crypto.load_certificate(
@ -502,6 +497,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes
class ClientNetwork(object): # pylint: disable=too-many-instance-attributes
"""Client network."""
JSON_CONTENT_TYPE = 'application/json'
JOSE_CONTENT_TYPE = 'application/jose+json'
JSON_ERROR_CONTENT_TYPE = 'application/problem+json'
REPLAY_NONCE_HEADER = 'Replay-Nonce'
@ -527,10 +523,11 @@ class ClientNetwork(object): # pylint: disable=too-many-instance-attributes
:rtype: `.JWS`
"""
jobj = obj.json_dumps().encode()
logger.debug('Serialized JSON: %s', jobj)
jobj = obj.json_dumps(indent=2).encode()
logger.debug('JWS payload:\n%s', jobj)
return jws.JWS.sign(
payload=jobj, key=self.key, alg=self.alg, nonce=nonce).json_dumps()
payload=jobj, key=self.key, alg=self.alg,
nonce=nonce).json_dumps(indent=2)
@classmethod
def _check_response(cls, response, content_type=None):
@ -551,9 +548,6 @@ class ClientNetwork(object): # pylint: disable=too-many-instance-attributes
:raises .ClientError: In case of other networking errors.
"""
logger.debug('Received response %s (headers: %s): %r',
response, response.headers, response.content)
response_ct = response.headers.get('Content-Type')
try:
# TODO: response.json() is called twice, once here, and
@ -605,14 +599,26 @@ class ClientNetwork(object): # pylint: disable=too-many-instance-attributes
"""
logging.debug('Sending %s request to %s. args: %r, kwargs: %r',
method, url, args, kwargs)
if method == "POST":
logging.debug('Sending POST request to %s:\n%s',
url, kwargs['data'])
else:
logging.debug('Sending %s request to %s.', method, url)
kwargs['verify'] = self.verify_ssl
kwargs.setdefault('headers', {})
kwargs['headers'].setdefault('User-Agent', self.user_agent)
response = self.session.request(method, url, *args, **kwargs)
logging.debug('Received %s. Headers: %s. Content: %r',
response, response.headers, response.content)
# If content is DER, log the base64 of it instead of raw bytes, to keep
# binary data out of the logs.
if response.headers.get("Content-Type") == DER_CONTENT_TYPE:
debug_content = base64.b64encode(response.content)
else:
debug_content = response.content
logger.debug('Received response:\nHTTP %d\n%s\n\n%s',
response.status_code,
"\n".join(["{0}: {1}".format(k, v)
for k, v in response.headers.items()]),
debug_content)
return response
def head(self, *args, **kwargs):
@ -637,7 +643,7 @@ class ClientNetwork(object): # pylint: disable=too-many-instance-attributes
decoded_nonce = jws.Header._fields['nonce'].decode(nonce)
except jose.DeserializationError as error:
raise errors.BadNonce(nonce, error)
logger.debug('Storing nonce: %r', decoded_nonce)
logger.debug('Storing nonce: %s', nonce)
self._nonces.add(decoded_nonce)
else:
raise errors.MissingNonce(response)
@ -648,9 +654,10 @@ class ClientNetwork(object): # pylint: disable=too-many-instance-attributes
self._add_nonce(self.head(url))
return self._nonces.pop()
def post(self, url, obj, content_type=JSON_CONTENT_TYPE, **kwargs):
def post(self, url, obj, content_type=JOSE_CONTENT_TYPE, **kwargs):
"""POST object wrapped in `.JWS` and check response."""
data = self._wrap_in_jws(obj, self._get_nonce(url))
kwargs.setdefault('headers', {'Content-Type': content_type})
response = self._send_request('POST', url, data=data, **kwargs)
self._add_nonce(response)
return self._check_response(response, content_type=content_type)

View file

@ -102,12 +102,6 @@ class ClientTest(unittest.TestCase):
self.assertEqual(self.regr, self.client.register(self.new_reg))
# TODO: test POST call arguments
# TODO: split here and separate test
reg_wrong_key = self.regr.body.update(key=KEY2.public_key())
self.response.json.return_value = reg_wrong_key.to_json()
self.assertRaises(
errors.UnexpectedUpdate, self.client.register, self.new_reg)
def test_register_missing_next(self):
self.response.status_code = http_client.CREATED
self.assertRaises(
@ -540,6 +534,29 @@ class ClientNetworkTest(unittest.TestCase):
'HEAD', 'http://example.com/', 'foo',
headers=mock.ANY, verify=mock.ANY, bar='baz')
@mock.patch('acme.client.logger')
def test_send_request_get_der(self, mock_logger):
self.net.session = mock.MagicMock()
self.net.session.request.return_value = mock.MagicMock(
ok=True, status_code=http_client.OK,
headers={"Content-Type": "application/pkix-cert"},
content=b"hi")
# pylint: disable=protected-access
self.net._send_request('HEAD', 'http://example.com/', 'foo', bar='baz')
mock_logger.debug.assert_called_once_with(
'Received response:\nHTTP %d\n%s\n\n%s', 200,
'Content-Type: application/pkix-cert', b'aGk=')
def test_send_request_post(self):
self.net.session = mock.MagicMock()
self.net.session.request.return_value = self.response
# pylint: disable=protected-access
self.assertEqual(self.response, self.net._send_request(
'POST', 'http://example.com/', 'foo', data='qux', bar='baz'))
self.net.session.request.assert_called_once_with(
'POST', 'http://example.com/', 'foo',
headers=mock.ANY, verify=mock.ANY, data='qux', bar='baz')
def test_send_request_verify_ssl(self):
# pylint: disable=protected-access
for verify in True, False:
@ -636,6 +653,10 @@ class ClientNetworkWithMockedResponseTest(unittest.TestCase):
self.send_request.assert_called_once_with(
'GET', 'http://example.com/', bar='baz')
def test_post_no_content_type(self):
self.content_type = self.net.JOSE_CONTENT_TYPE
self.assertEqual(self.checked_response, self.net.post('uri', self.obj))
def test_post(self):
# pylint: disable=protected-access
self.assertEqual(self.checked_response, self.net.post(

View file

@ -63,6 +63,8 @@ class SSLSocket(object): # pylint: disable=too-few-public-methods
server_name)
return
new_context = OpenSSL.SSL.Context(self.method)
new_context.set_options(OpenSSL.SSL.OP_NO_SSLv2)
new_context.set_options(OpenSSL.SSL.OP_NO_SSLv3)
new_context.use_privatekey(key)
new_context.use_certificate(cert)
connection.set_context(new_context)
@ -86,6 +88,8 @@ class SSLSocket(object): # pylint: disable=too-few-public-methods
sock, addr = self.sock.accept()
context = OpenSSL.SSL.Context(self.method)
context.set_options(OpenSSL.SSL.OP_NO_SSLv2)
context.set_options(OpenSSL.SSL.OP_NO_SSLv3)
context.set_tlsext_servername_callback(self._pick_certificate_cb)
ssl_sock = self.FakeConnection(OpenSSL.SSL.Connection(context, sock))

View file

@ -18,9 +18,11 @@ from acme import test_util
class SSLSocketAndProbeSNITest(unittest.TestCase):
"""Tests for acme.crypto_util.SSLSocket/probe_sni."""
_multiprocess_can_split_ = True
def setUp(self):
self.cert = test_util.load_comparable_cert('cert.pem')
key = test_util.load_pyopenssl_private_key('rsa512_key.pem')
self.cert = test_util.load_comparable_cert('rsa2048_cert.pem')
key = test_util.load_pyopenssl_private_key('rsa2048_key.pem')
# pylint: disable=protected-access
certs = {b'foo': (key, self.cert.wrapped)}
@ -67,6 +69,8 @@ class SSLSocketAndProbeSNITest(unittest.TestCase):
class PyOpenSSLCertOrReqSANTest(unittest.TestCase):
"""Test for acme.crypto_util._pyopenssl_cert_or_req_san."""
_multiprocess_can_split_ = True
@classmethod
def _call(cls, loader, name):
# pylint: disable=protected-access
@ -131,6 +135,8 @@ class PyOpenSSLCertOrReqSANTest(unittest.TestCase):
class RandomSnTest(unittest.TestCase):
"""Test for random certificate serial numbers."""
_multiprocess_can_split_ = True
def setUp(self):
self.cert_count = 5
self.serial_num = []

View file

@ -3,8 +3,20 @@ Required only for local validation of 'dns-01' challenges.
"""
import logging
import dns.resolver
import dns.exception
from acme import errors
from acme import util
DNS_REQUIREMENT = 'dnspython>=1.12'
try:
util.activate(DNS_REQUIREMENT)
# pragma: no cover
import dns.exception
import dns.resolver
DNS_AVAILABLE = True
except errors.DependencyError: # pragma: no cover
DNS_AVAILABLE = False
logger = logging.getLogger(__name__)
@ -18,6 +30,9 @@ def txt_records_for_name(name):
:rtype: list of unicode
"""
if not DNS_AVAILABLE:
raise errors.DependencyError(
'{0} is required to use this function'.format(DNS_REQUIREMENT))
try:
dns_response = dns.resolver.query(name, 'TXT')
except dns.resolver.NXDOMAIN as error:

View file

@ -1,13 +1,16 @@
"""Tests for acme.dns_resolver."""
import unittest
import mock
from six.moves import reload_module # pylint: disable=import-error
from acme import dns_resolver
from acme import errors
from acme import test_util
from acme.dns_resolver import DNS_REQUIREMENT
try:
if test_util.requirement_available(DNS_REQUIREMENT):
import dns
except ImportError: # pragma: no cover
dns = None
def create_txt_response(name, txt_records):
@ -22,32 +25,53 @@ def create_txt_response(name, txt_records):
class TxtRecordsForNameTest(unittest.TestCase):
"""Tests for acme.dns_resolver.txt_records_for_name."""
@classmethod
def _call(cls, *args, **kwargs):
from acme.dns_resolver import txt_records_for_name
return txt_records_for_name(*args, **kwargs)
@test_util.skip_unless(test_util.requirement_available(DNS_REQUIREMENT),
"optional dependency dnspython is not available")
class TxtRecordsForNameWithDnsTest(TxtRecordsForNameTest):
"""Tests for acme.dns_resolver.txt_records_for_name with dns."""
@mock.patch("acme.dns_resolver.dns.resolver.query")
def test_txt_records_for_name_with_single_response(self, mock_dns):
mock_dns.return_value = create_txt_response('name', ['response'])
self.assertEqual(['response'],
dns_resolver.txt_records_for_name('name'))
self.assertEqual(['response'], self._call('name'))
@mock.patch("acme.dns_resolver.dns.resolver.query")
def test_txt_records_for_name_with_multiple_responses(self, mock_dns):
mock_dns.return_value = create_txt_response(
'name', ['response1', 'response2'])
self.assertEqual(['response1', 'response2'],
dns_resolver.txt_records_for_name('name'))
self.assertEqual(['response1', 'response2'], self._call('name'))
@mock.patch("acme.dns_resolver.dns.resolver.query")
def test_txt_records_for_name_domain_not_found(self, mock_dns):
mock_dns.side_effect = dns.resolver.NXDOMAIN
self.assertEquals([], dns_resolver.txt_records_for_name('name'))
self.assertEquals([], self._call('name'))
@mock.patch("acme.dns_resolver.dns.resolver.query")
def test_txt_records_for_name_domain_other_error(self, mock_dns):
mock_dns.side_effect = dns.exception.DNSException
self.assertEquals([], dns_resolver.txt_records_for_name('name'))
self.assertEquals([], self._call('name'))
def run(self, result=None):
if dns is None: # pragma: no cover
print(self, "... SKIPPING, no dnspython available")
return
super(TxtRecordsForNameTest, self).run(result)
class TxtRecordsForNameWithoutDnsTest(TxtRecordsForNameTest):
"""Tests for acme.dns_resolver.txt_records_for_name without dns."""
def setUp(self):
from acme import dns_resolver
dns_resolver.DNS_AVAILABLE = False
def tearDown(self):
from acme import dns_resolver
reload_module(dns_resolver)
def test_exception_raised(self):
self.assertRaises(
errors.DependencyError, self._call, "example.org")
if __name__ == '__main__':
unittest.main() # pragma: no cover

View file

@ -6,6 +6,10 @@ class Error(Exception):
"""Generic ACME error."""
class DependencyError(Error):
"""Dependency error"""
class SchemaValidationError(jose_errors.DeserializationError):
"""JSON schema ACME object validation error."""

View file

@ -253,10 +253,6 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable):
raise errors.SerializationError(
'Could not encode {0} ({1}): {2}'.format(
slot, value, error))
if omitted:
# pylint: disable=star-args
logger.debug('Omitted empty fields: %s', ', '.join(
'{0!s}={1!r}'.format(*field) for field in omitted))
return jobj
def to_partial_json(self):

View file

@ -7,6 +7,37 @@ from acme import fields
from acme import jose
from acme import util
OLD_ERROR_PREFIX = "urn:acme:error:"
ERROR_PREFIX = "urn:ietf:params:acme:error:"
ERROR_CODES = {
'badCSR': 'The CSR is unacceptable (e.g., due to a short key)',
'badNonce': 'The client sent an unacceptable anti-replay nonce',
'connection': ('The server could not connect to the client to verify the'
' domain'),
'dnssec': 'The server could not validate a DNSSEC signed domain',
# deprecate invalidEmail
'invalidEmail': 'The provided email for a registration was invalid',
'invalidContact': 'The provided contact URI was invalid',
'malformed': 'The request message was malformed',
'rateLimited': 'There were too many requests of a given type',
'serverInternal': 'The server experienced an internal error',
'tls': 'The server experienced a TLS error during domain verification',
'unauthorized': 'The client lacks sufficient authorization',
'unknownHost': 'The server could not resolve a domain name',
}
ERROR_TYPE_DESCRIPTIONS = dict(
(ERROR_PREFIX + name, desc) for name, desc in ERROR_CODES.items())
ERROR_TYPE_DESCRIPTIONS.update(dict( # add errors with old prefix, deprecate me
(OLD_ERROR_PREFIX + name, desc) for name, desc in ERROR_CODES.items()))
def is_acme_error(err):
"""Check if argument is an ACME error."""
return (ERROR_PREFIX in str(err)) or (OLD_ERROR_PREFIX in str(err))
class Error(jose.JSONObjectWithFields, errors.Error):
"""ACME error.
@ -18,29 +49,24 @@ class Error(jose.JSONObjectWithFields, errors.Error):
:ivar unicode detail:
"""
ERROR_TYPE_DESCRIPTIONS = dict(
('urn:acme:error:' + name, description) for name, description in (
('badCSR', 'The CSR is unacceptable (e.g., due to a short key)'),
('badNonce', 'The client sent an unacceptable anti-replay nonce'),
('connection', 'The server could not connect to the client to '
'verify the domain'),
('dnssec', 'The server could not validate a DNSSEC signed domain'),
('invalidEmail',
'The provided email for a registration was invalid'),
('malformed', 'The request message was malformed'),
('rateLimited', 'There were too many requests of a given type'),
('serverInternal', 'The server experienced an internal error'),
('tls', 'The server experienced a TLS error during domain '
'verification'),
('unauthorized', 'The client lacks sufficient authorization'),
('unknownHost', 'The server could not resolve a domain name'),
)
)
typ = jose.Field('type', omitempty=True, default='about:blank')
title = jose.Field('title', omitempty=True)
detail = jose.Field('detail', omitempty=True)
@classmethod
def with_code(cls, code, **kwargs):
"""Create an Error instance with an ACME Error code.
:unicode code: An ACME error code, like 'dnssec'.
:kwargs: kwargs to pass to Error.
"""
if code not in ERROR_CODES:
raise ValueError("The supplied code: %s is not a known ACME error"
" code" % code)
typ = ERROR_PREFIX + code
return cls(typ=typ, **kwargs)
@property
def description(self):
"""Hardcoded error description based on its type.
@ -49,7 +75,21 @@ class Error(jose.JSONObjectWithFields, errors.Error):
:rtype: unicode
"""
return self.ERROR_TYPE_DESCRIPTIONS.get(self.typ)
return ERROR_TYPE_DESCRIPTIONS.get(self.typ)
@property
def code(self):
"""ACME error code.
Basically self.typ without the ERROR_PREFIX.
:returns: error code if standard ACME code or ``None``.
:rtype: unicode
"""
code = str(self.typ).split(':')[-1]
if code in ERROR_CODES:
return code
def __str__(self):
return ' :: '.join(

View file

@ -17,13 +17,13 @@ class ErrorTest(unittest.TestCase):
"""Tests for acme.messages.Error."""
def setUp(self):
from acme.messages import Error
from acme.messages import Error, ERROR_PREFIX
self.error = Error(
detail='foo', typ='urn:acme:error:malformed', title='title')
detail='foo', typ=ERROR_PREFIX + 'malformed', title='title')
self.jobj = {
'detail': 'foo',
'title': 'some title',
'type': 'urn:acme:error:malformed',
'type': ERROR_PREFIX + 'malformed',
}
self.error_custom = Error(typ='custom', detail='bar')
self.jobj_cusom = {'type': 'custom', 'detail': 'bar'}
@ -47,10 +47,27 @@ class ErrorTest(unittest.TestCase):
def test_str(self):
self.assertEqual(
'urn:acme:error:malformed :: The request message was '
'urn:ietf:params:acme:error:malformed :: The request message was '
'malformed :: foo :: title', str(self.error))
self.assertEqual('custom :: bar', str(self.error_custom))
def test_code(self):
from acme.messages import Error
self.assertEqual('malformed', self.error.code)
self.assertEqual(None, self.error_custom.code)
self.assertEqual(None, Error().code)
def test_is_acme_error(self):
from acme.messages import is_acme_error
self.assertTrue(is_acme_error(self.error))
self.assertTrue(is_acme_error(str(self.error)))
self.assertFalse(is_acme_error(self.error_custom))
def test_with_code(self):
from acme.messages import Error, is_acme_error
self.assertTrue(is_acme_error(Error.with_code('badCSR')))
self.assertRaises(ValueError, Error.with_code, 'not an ACME error code')
class ConstantTest(unittest.TestCase):
"""Tests for acme.messages._Constant."""
@ -240,7 +257,7 @@ class ChallengeBodyTest(unittest.TestCase):
from acme.messages import Error
from acme.messages import STATUS_INVALID
self.status = STATUS_INVALID
error = Error(typ='urn:acme:error:serverInternal',
error = Error(typ='urn:ietf:params:acme:error:serverInternal',
detail='Unable to communicate with DNS server')
self.challb = ChallengeBody(
uri='http://challb', chall=self.chall, status=self.status,
@ -256,7 +273,7 @@ class ChallengeBodyTest(unittest.TestCase):
self.jobj_from = self.jobj_to.copy()
self.jobj_from['status'] = 'invalid'
self.jobj_from['error'] = {
'type': 'urn:acme:error:serverInternal',
'type': 'urn:ietf:params:acme:error:serverInternal',
'detail': 'Unable to communicate with DNS server',
}

View file

@ -21,6 +21,8 @@ from acme import test_util
class TLSServerTest(unittest.TestCase):
"""Tests for acme.standalone.TLSServer."""
_multiprocess_can_split_ = True
def test_bind(self): # pylint: disable=no-self-use
from acme.standalone import TLSServer
server = TLSServer(
@ -31,10 +33,12 @@ class TLSServerTest(unittest.TestCase):
class TLSSNI01ServerTest(unittest.TestCase):
"""Test for acme.standalone.TLSSNI01Server."""
_multiprocess_can_split_ = True
def setUp(self):
self.certs = {b'localhost': (
test_util.load_pyopenssl_private_key('rsa512_key.pem'),
test_util.load_cert('cert.pem'),
test_util.load_pyopenssl_private_key('rsa2048_key.pem'),
test_util.load_cert('rsa2048_cert.pem'),
)}
from acme.standalone import TLSSNI01Server
self.server = TLSSNI01Server(("", 0), certs=self.certs)
@ -57,6 +61,8 @@ class TLSSNI01ServerTest(unittest.TestCase):
class HTTP01ServerTest(unittest.TestCase):
"""Tests for acme.standalone.HTTP01Server."""
_multiprocess_can_split_ = True
def setUp(self):
self.account_key = jose.JWK.load(
test_util.load_vector('rsa1024_key.pem'))
@ -109,13 +115,16 @@ class HTTP01ServerTest(unittest.TestCase):
class TestSimpleTLSSNI01Server(unittest.TestCase):
"""Tests for acme.standalone.simple_tls_sni_01_server."""
_multiprocess_can_split_ = True
def setUp(self):
# mirror ../examples/standalone
self.test_cwd = tempfile.mkdtemp()
localhost_dir = os.path.join(self.test_cwd, 'localhost')
os.makedirs(localhost_dir)
shutil.copy(test_util.vector_path('cert.pem'), localhost_dir)
shutil.copy(test_util.vector_path('rsa512_key.pem'),
shutil.copy(test_util.vector_path('rsa2048_cert.pem'),
os.path.join(localhost_dir, 'cert.pem'))
shutil.copy(test_util.vector_path('rsa2048_key.pem'),
os.path.join(localhost_dir, 'key.pem'))
from acme.standalone import simple_tls_sni_01_server
@ -147,7 +156,8 @@ class TestSimpleTLSSNI01Server(unittest.TestCase):
time.sleep(1) # wait until thread starts
else:
self.assertEqual(jose.ComparableX509(cert),
test_util.load_comparable_cert('cert.pem'))
test_util.load_comparable_cert(
'rsa2048_cert.pem'))
break

View file

@ -5,12 +5,15 @@
"""
import os
import pkg_resources
import unittest
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import OpenSSL
from acme import errors
from acme import jose
from acme import util
def vector_path(*names):
@ -73,3 +76,38 @@ def load_pyopenssl_private_key(*names):
loader = _guess_loader(
names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1)
return OpenSSL.crypto.load_privatekey(loader, load_vector(*names))
def requirement_available(requirement):
"""Checks if requirement can be imported.
:rtype: bool
:returns: ``True`` iff requirement can be imported
"""
try:
util.activate(requirement)
except errors.DependencyError: # pragma: no cover
return False
return True # pragma: no cover
def skip_unless(condition, reason): # pragma: no cover
"""Skip tests unless a condition holds.
This implements the basic functionality of unittest.skipUnless
which is only available on Python 2.7+.
:param bool condition: If ``False``, the test will be skipped
:param str reason: the reason for skipping the test
:rtype: callable
:returns: decorator that hides tests unless condition is ``True``
"""
if hasattr(unittest, "skipUnless"):
return unittest.skipUnless(condition, reason)
elif condition:
return lambda cls: cls
else:
return lambda cls: None

22
acme/acme/testdata/rsa2048_cert.pem vendored Normal file
View file

@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDjjCCAnagAwIBAgIJALVG/VbBb5U7MA0GCSqGSIb3DQEBCwUAMFsxCzAJBgNV
BAYTAkFVMQswCQYDVQQIDAJXQTEeMBwGA1UEBwwVVGhlIG1pZGRsZSBvZiBub3do
ZXJlMR8wHQYDVQQKDBZDZXJ0Ym90IFRlc3QgQ2VydHMgSW5jMCAXDTE2MTEyODIx
MzUzN1oYDzIyOTAwOTEzMjEzNTM3WjBbMQswCQYDVQQGEwJBVTELMAkGA1UECAwC
V0ExHjAcBgNVBAcMFVRoZSBtaWRkbGUgb2Ygbm93aGVyZTEfMB0GA1UECgwWQ2Vy
dGJvdCBUZXN0IENlcnRzIEluYzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
ggEBANoVT1pdvRUUBOqvm7M2ebLEHV7higUH7qAGUZEkfP6W4YriYVY+IHrH1svN
PSa+oPTK7weDNmT11ehWnGyECIM9z2r2Hi9yVV0ycxh4hWQ4Nt8BAKZwCwaXpyWm
7Gj6m2EzpSN5Dd67g5YAQBrUUh1+RRbFi9c0Ls/6ZOExMvfg8kqt4c2sXCgH1IFn
xvvOjBYop7xh0x3L1Akyax0tw8qgQp/z5mkupmVDNJYPFmbzFPMNyDR61ed6QUTD
g7P4UAuFkejLLzFvz5YaO7vC+huaTuPhInAhpzqpr4yU97KIjos2/83Itu/Cv8U1
RAeEeRTkh0WjUfltoem/5f8bIdsCAwEAAaNTMFEwHQYDVR0OBBYEFHy+bEYqwvFU
uQLTkIfQ36AM2DQiMB8GA1UdIwQYMBaAFHy+bEYqwvFUuQLTkIfQ36AM2DQiMA8G
A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAH3ANVzB59FcunZV/F8T
RiCD6/gV7Jc3CswU8N8tVjzMCg2jOdTFF9iYZzNNKQvG13o/n5LkQr/lkKRQkWTx
nkE5WZbR7vNqlzXgPa9NBiK5rPjgSt8azPW+Skct3Bj4B3PhTMSpoQ7PsUJ8UeV8
kTNR5xrRLt6/mLfRJTXWXBM43GEZi8lL5q0nqz0tPGISADshHMo6ZlUu5Hvfp5v+
aonpO4sVS9hGOVxjGNMXYApEUy4jid9jjAfEk6jeELJMbXGLy/botFgIJK/QPe6P
AfbdFgtg/qzG7Uy0A1iXXfWdgwmVrhCoGYYWCn4yWCAm894QKtdim87CHSDP0WUf
Esg=
-----END CERTIFICATE-----

View file

@ -1,27 +1,28 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA8HwZMHeImB/iM8/n8CTCR4KeYQB2gLGO3v8xLms+PWH3Zbxc
dVtEn25Y34scIh+iOuEXBcSBalBddLHKBGVN3nCfmpupoLm52xgRG44q9OWODpg4
FSi4afqVw2agMx0RHi0v3GVcdpqB83UW42kK1ESZHUuq7mxLg8u3IMYZFm6Amsf+
YQjBbDNn8NczJOFhsExP2EdM5ykgM1Om8aqTqqPMgPub68/r4Sym+BjLnvRq5Qtz
h/jCfOBIIpAwg3lj7l8OyE3kkD3ALtuiuminNUqLHEkUaLq/Xiv8V8mvnrhG7h3Q
+L1Xc707P0dz5YM5XxTMhmUE1cae/lQ0KbNrpwIDAQABAoIBAAiDXCDrGlrIRimv
YnaN1pLRfOnSKl/D6VrbjdIm2b0yip9/W4aMBJHgRiUjt4s9s3CCJ1585lftIGHR
KWWecHM/aWb/u7GE4Z9v6qsfDUY+GhlKKjIVjvGxfTu9lk446TI4R0l2DR/luFP2
ASlrvoZlJ0ZyN0rZapLv0zvFx32Tukd+3rcMmXfHl7aRGMZG1YTKNmBJ4d9iJ6cP
HG3fgSzLQMPLNO/20MzbXdREG5FNQtwaMuFnIcVbtMCvc/71lQQEfANMLCUweEed
YWGOjgDeh+731nJsopel+2TSTgnf5VhcFrgChZZdqeKvP+HbXjTE2VkWo7BrzoM7
xICYBwECgYEA/ZF/JOjZfwIMUdikv8vldJzRMdFryl4NJWnh4NeThNOEpUcpxnyK
wyMnnQaGJa51u9EEnzl0sZ2h2ODjD6KFpz6fkWaVRq5SWalVPAoKZGaoPZV3IUOI
8Tm0xkXho+A/FUUEcxCLME+3V9EdPfHaVRJOrbfDyxvNhsj4w9F0aAkCgYEA8sp7
XTrolOknJGv4Qt1w6gcm5+cMtLaRfi8ZHPHujl2x9eWE8/s2818az7jc0Xr/G4HQ
NeU+3Es4BblEckSHmhUZhx26cZgkLSIIDofEtaEc6u8CyWfxsWvn3l4T3kMdeSLC
9UoLk59AH2tkMIh8vzV8LSisLJa341lMdgryQi8CgYAlJKr7PSCe+i3Tz2hSsAts
iYwbQBIKErzaPihYRzvUuSc1DreP26535y5mUg5UdrnISVXj/Qaa/fw3SLn6EFSD
qyi0o9I6CE8H00YpBU+AZYk/fCV3Oe1VaJ6SbKog1zhmZTXBpSq+aO7ybi9aY5MX
4xajW8fSeMAifk3yYTwsAQKBgErcEcOCOVpItU/uloKPYpRWFjHktK83p46fmP+q
vOJak1d9KExOBfhuN4caucNBSE1D7l3fzE0CSEjDgg41gRYKMW/Ow8DopybfWlqY
lBdokNEDVvmgug35dmnC2h9q1DiYdkJJTV57+Lp3U1H/k28lX59Q7h1lb1eDHic7
YszzAoGBAOx05dhOiYbzAJSTQu3oBHFn4mTYIqCcDO6cQrEJwPKAq7mAhT0yOk9N
CrqRV/1aes665829cyTwcAZl6nqbzHv5XjX5+g6vmooCb4oCkq49rumHjoQdrX8D
RR5b+Spkc1jo4rctCcExzSkgo+K5N3oBVYznecje7O7Z0/qiJE/8
-----END RSA PRIVATE KEY-----
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDaFU9aXb0VFATq
r5uzNnmyxB1e4YoFB+6gBlGRJHz+luGK4mFWPiB6x9bLzT0mvqD0yu8HgzZk9dXo
VpxshAiDPc9q9h4vclVdMnMYeIVkODbfAQCmcAsGl6clpuxo+pthM6UjeQ3eu4OW
AEAa1FIdfkUWxYvXNC7P+mThMTL34PJKreHNrFwoB9SBZ8b7zowWKKe8YdMdy9QJ
MmsdLcPKoEKf8+ZpLqZlQzSWDxZm8xTzDcg0etXnekFEw4Oz+FALhZHoyy8xb8+W
Gju7wvobmk7j4SJwIac6qa+MlPeyiI6LNv/NyLbvwr/FNUQHhHkU5IdFo1H5baHp
v+X/GyHbAgMBAAECggEAURFe4C68XRuGAF+rN2Fmt+djK6QXlGswb1gp9hRkSpd3
3BLvMAoENOAYnsX6l26Bkr3lQRurmrgv/iBEIaqrJ25QrmgzLFwKE4zvcAdNPsYO
z7MltLktwBOb1MlKVHPkUqvKFXeoikWWUqphKhgHNmN7900UALmrNTDVU0jgs3fB
o35o8d5SjoC52K4wCTjhPyjt4cdbfbziRs2qFhfGdawidRO1xLlDM4tTTW+5yWGK
lt0SwyvDVC6XWeNoT3nXyKjXWP7hcYqm0iS7ffL9YzEC2RXNGQUqeR50i9Y0rDdH
Vqcr+Rqio2ww68zbDWBpC/jU133BSoHuSE1wstxIkQKBgQDxlEr42WJfgdajbZ1a
hUIeLEgvhezLmD1hcYwZuQCLgizmY2ovvmeAH74koCDEsUUQunPYHsRla7wT3q1/
IkR1KgJPwESpkQaKuAqxeEAkv7Gn8Lzcn22jCoRCfGA68wKJz2ECFZDc0RDvRrT/
9GhiiGUoO47jv9ezrSDO1eu5/QKBgQDnGfYVMNLiA0fy4AxSyY2vdo7vruOFGpRP
n94gwxZ+0dQDWHzn3J4rHivxtcyd/MOZv4I8PtYK7tmmjYv1ngQ6sGl4p8bpUtwj
9++/B1CyB1W5/VPqMkd+Sj0dbejycME55+F6/r4basPXxBFFCfknjAlVvyvbBhUy
ftNpHxZGtwKBgChJM4t2LPqCW3nbgL8ks9b2SX9rVQbKt4m1dsifWmDpb3VoJMAb
f4UVRg8ziONkMIFOppzm3JeRNMcXflVSMJpdTA9in9CrN60QbfAUfpXiRc0cz1H3
YEAtM8smlKGf/s9efu3rDMJWNv3AC9UXPAUae8wOypBeYKk8+NilQe89AoGAXEA3
xFO+CqyGnwQixzVf0qf//NuSRQLMK1DEyc02gJ9gA4niKmgd11Zu8kjBClvo9MnG
wifPJ4Qa6+pa8UwHoinjoF9Q/rit2cnSMS5JXxegd+MRCU7SzS3zYXkLYSPzbhsL
Hh7sYmNnFA1XW3jUtZ2n6EusxPyTn5mS6MaZDNcCgYBelFKFjNIQ50NbOnm8DewK
jUd5OFKowKodlQVcHiF9CVbjvpN8ZPRcBSmqDU4kpT/rmcybVoL6Zfa/zWkw8+Oh
QxKb3BYf5vRUMd/RA+/t5KG4ZOIIYB3qoltAYlhVaINukL6cGVG1qvV/ntcsfsn6
kmf1UgGFcKrJuXgwEtTVxw==
-----END PRIVATE KEY-----

View file

@ -1,7 +1,25 @@
"""ACME utilities."""
import pkg_resources
import six
from acme import errors
def map_keys(dikt, func):
"""Map dictionary keys."""
return dict((func(key), value) for key, value in six.iteritems(dikt))
def activate(requirement):
"""Make requirement importable.
:param str requirement: the distribution and version to activate
:raises acme.errors.DependencyError: if cannot activate requirement
"""
try:
for distro in pkg_resources.require(requirement): # pylint: disable=not-callable
distro.activate()
except (pkg_resources.DistributionNotFound, pkg_resources.VersionConflict):
raise errors.DependencyError('{0} is unavailable'.format(requirement))

View file

@ -1,6 +1,8 @@
"""Tests for acme.util."""
import unittest
from acme import errors
class MapKeysTest(unittest.TestCase):
"""Tests for acme.util.map_keys."""
@ -12,5 +14,21 @@ class MapKeysTest(unittest.TestCase):
self.assertEqual({2: 2, 4: 4}, map_keys({1: 2, 3: 4}, lambda x: x + 1))
class ActivateTest(unittest.TestCase):
"""Tests for acme.util.activate."""
@classmethod
def _call(cls, *args, **kwargs):
from acme.util import activate
return activate(*args, **kwargs)
def test_failure(self):
self.assertRaises(errors.DependencyError, self._call, 'acme>99.0.0')
def test_success(self):
self._call('acme')
import acme as unused_acme
if __name__ == '__main__':
unittest.main() # pragma: no cover

View file

@ -39,7 +39,6 @@ extensions = [
'sphinx.ext.todo',
'sphinx.ext.coverage',
'sphinx.ext.viewcode',
'sphinxcontrib.programoutput',
]
autodoc_member_order = 'bysource'

8
acme/docs/jws-help.txt Normal file
View file

@ -0,0 +1,8 @@
usage: jws [-h] [--compact] {sign,verify} ...
positional arguments:
{sign,verify}
optional arguments:
-h, --help show this help message and exit
--compact

View file

@ -1 +1 @@
.. program-output:: jws --help all
.. literalinclude:: ../jws-help.txt

View file

@ -1 +1 @@
../../../acme/testdata/cert.pem
../../../acme/testdata/rsa2048_cert.pem

View file

@ -1 +1 @@
../../../acme/testdata/rsa512_key.pem
../../../acme/testdata/rsa2048_key.pem

View file

@ -4,20 +4,18 @@ from setuptools import setup
from setuptools import find_packages
version = '0.9.0.dev0'
version = '0.10.0.dev0'
# Please update tox.ini when modifying dependency version requirements
install_requires = [
# load_pem_private/public_key (>=0.6)
# rsa_recover_prime_factors (>=0.8)
'cryptography>=0.8',
'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304)
'pyasn1', # urllib3 InsecurePlatformWarning (#304)
# Connection.set_tlsext_host_name (>=0.13)
'PyOpenSSL>=0.13',
'pyrfc3339',
'pytz',
'requests',
'requests[security]>=2.4.1', # security extras added in 2.4.1
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
@ -49,7 +47,6 @@ dev_extras = [
docs_extras = [
'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags
'sphinx_rtd_theme',
'sphinxcontrib-programoutput',
]

View file

@ -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
@ -84,7 +85,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
"""
description = "Apache Web Server - Alpha"
description = "Apache Web Server plugin - Beta"
@classmethod
def add_parser_arguments(cls, add):
@ -98,6 +99,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
help="Apache server root directory.")
add("vhost-root", default=constants.os_constant("vhost_root"),
help="Apache server VirtualHost configuration root")
add("logs-root", default=constants.os_constant("logs_root"),
help="Apache server logs directory")
add("challenge-location",
default=constants.os_constant("challenge_location"),
help="Directory path for challenge configuration.")
@ -360,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
@ -461,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
@ -1010,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)
@ -1017,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.
@ -1425,13 +1452,14 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
"RewriteEngine On\n"
"RewriteRule %s\n"
"\n"
"ErrorLog /var/log/apache2/redirect.error.log\n"
"ErrorLog %s/redirect.error.log\n"
"LogLevel warn\n"
"</VirtualHost>\n"
% (" ".join(str(addr) for
addr in self._get_proposed_addrs(ssl_vhost)),
servername, serveralias,
" ".join(rewrite_rule_args)))
" ".join(rewrite_rule_args),
self.conf("logs-root")))
def _write_out_redirect(self, ssl_vhost, text):
# This is the default name
@ -1491,38 +1519,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
return redirects
def get_all_certs_keys(self):
"""Find all existing keys, certs from configuration.
Retrieve all certs and keys set in VirtualHosts on the Apache server
:returns: list of tuples with form [(cert, key, path)]
cert - str path to certificate file
key - str path to associated key file
path - File path to configuration file.
:rtype: list
"""
c_k = set()
for vhost in self.vhosts:
if vhost.ssl:
cert_path = self.parser.find_dir(
"SSLCertificateFile", None,
start=vhost.path, exclude=False)
key_path = self.parser.find_dir(
"SSLCertificateKeyFile", None,
start=vhost.path, exclude=False)
if cert_path and key_path:
cert = os.path.abspath(self.parser.get_arg(cert_path[-1]))
key = os.path.abspath(self.parser.get_arg(key_path[-1]))
c_k.add((cert, key, get_file_path(cert_path[-1])))
else:
logger.warning(
"Invalid VirtualHost configuration - %s", vhost.filep)
return c_k
def is_site_enabled(self, avail_fp):
"""Checks to see if the given site is enabled.

View file

@ -6,6 +6,7 @@ CLI_DEFAULTS_DEFAULT = dict(
server_root="/etc/apache2",
vhost_root="/etc/apache2/sites-available",
vhost_files="*",
logs_root="/var/log/apache2",
version_cmd=['apache2ctl', '-v'],
define_cmd=['apache2ctl', '-t', '-D', 'DUMP_RUN_CFG'],
restart_cmd=['apache2ctl', 'graceful'],
@ -23,6 +24,7 @@ CLI_DEFAULTS_DEBIAN = dict(
server_root="/etc/apache2",
vhost_root="/etc/apache2/sites-available",
vhost_files="*",
logs_root="/var/log/apache2",
version_cmd=['apache2ctl', '-v'],
define_cmd=['apache2ctl', '-t', '-D', 'DUMP_RUN_CFG'],
restart_cmd=['apache2ctl', 'graceful'],
@ -40,6 +42,7 @@ CLI_DEFAULTS_CENTOS = dict(
server_root="/etc/httpd",
vhost_root="/etc/httpd/conf.d",
vhost_files="*.conf",
logs_root="/var/log/httpd",
version_cmd=['apachectl', '-v'],
define_cmd=['apachectl', '-t', '-D', 'DUMP_RUN_CFG'],
restart_cmd=['apachectl', 'graceful'],
@ -57,6 +60,7 @@ CLI_DEFAULTS_GENTOO = dict(
server_root="/etc/apache2",
vhost_root="/etc/apache2/vhosts.d",
vhost_files="*.conf",
logs_root="/var/log/apache2",
version_cmd=['/usr/sbin/apache2', '-v'],
define_cmd=['apache2ctl', 'virtualhosts'],
restart_cmd=['apache2ctl', 'graceful'],
@ -74,6 +78,7 @@ CLI_DEFAULTS_DARWIN = dict(
server_root="/etc/apache2",
vhost_root="/etc/apache2/other",
vhost_files="*.conf",
logs_root="/var/log/apache2",
version_cmd=['/usr/sbin/httpd', '-v'],
define_cmd=['/usr/sbin/httpd', '-t', '-D', 'DUMP_RUN_CFG'],
restart_cmd=['apachectl', 'graceful'],
@ -91,6 +96,7 @@ CLI_DEFAULTS_SUSE = dict(
server_root="/etc/apache2",
vhost_root="/etc/apache2/vhosts.d",
vhost_files="*.conf",
logs_root="/var/log/apache2",
version_cmd=['apache2ctl', '-v'],
define_cmd=['apache2ctl', '-t', '-D', 'DUMP_RUN_CFG'],
restart_cmd=['apache2ctl', 'graceful'],

View file

@ -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 "
@ -101,4 +102,4 @@ 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)),
height=display_util.HEIGHT)
force_interactive=True)

View file

@ -13,6 +13,8 @@ from certbot_apache.tests import util
class AugeasConfiguratorTest(util.ApacheTest):
"""Test for Augeas Configurator base class."""
_multiprocess_can_split_ = True
def setUp(self): # pylint: disable=arguments-differ
super(AugeasConfiguratorTest, self).setUp()

View file

@ -24,6 +24,8 @@ from certbot_apache.tests import util
class MultipleVhostsTest(util.ApacheTest):
"""Test two standard well-configured HTTP vhosts."""
_multiprocess_can_split_ = True
def setUp(self): # pylint: disable=arguments-differ
super(MultipleVhostsTest, self).setUp()
@ -218,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")
@ -775,21 +773,6 @@ class MultipleVhostsTest(util.ApacheTest):
self.assertRaises(errors.MisconfigurationError,
self.config.config_test)
def test_get_all_certs_keys(self):
c_k = self.config.get_all_certs_keys()
self.assertEqual(len(c_k), 3)
cert, key, path = next(iter(c_k))
self.assertTrue("cert" in cert)
self.assertTrue("key" in key)
self.assertTrue("default-ssl" in path or "ocsp-ssl" in path)
def test_get_all_certs_keys_malformed_conf(self):
self.config.parser.find_dir = mock.Mock(
side_effect=[["path"], [], ["path"], [], ["path"], []])
c_k = self.config.get_all_certs_keys()
self.assertFalse(c_k)
def test_more_info(self):
self.assertTrue(self.config.more_info())
@ -1256,6 +1239,7 @@ class MultipleVhostsTest(util.ApacheTest):
class AugeasVhostsTest(util.ApacheTest):
"""Test vhosts with illegal names dependant on augeas version."""
# pylint: disable=protected-access
_multiprocess_can_split_ = True
def setUp(self): # pylint: disable=arguments-differ
td = "debian_apache_2_4/augeas_vhosts"
@ -1267,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)
@ -1293,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

View file

@ -20,25 +20,25 @@ class ConstantsTest(unittest.TestCase):
self.assertEqual(constants.os_constant("vhost_root"),
"/etc/httpd/conf.d")
@mock.patch("certbot.util.get_systemd_os_like")
@mock.patch("certbot.util.get_os_info")
def test_get_default_value(self, os_info):
def test_get_default_values(self, os_info, os_like):
os_info.return_value = ('Nonexistent Linux', '', '')
os_like.return_value = {}
self.assertFalse(constants.os_constant("handle_mods"))
self.assertEqual(constants.os_constant("server_root"), "/etc/apache2")
self.assertEqual(constants.os_constant("vhost_root"),
"/etc/apache2/sites-available")
@mock.patch("certbot.util.get_systemd_os_like")
@mock.patch("certbot.util.get_os_info")
def test_get_default_constants(self, os_info):
def test_get_darwin_like_values(self, os_info, os_like):
os_info.return_value = ('Nonexistent Linux', '', '')
with mock.patch("certbot.util.get_systemd_os_like") as os_like:
# Get defaults
os_like.return_value = False
c_hm = constants.os_constant("handle_mods")
c_sr = constants.os_constant("server_root")
self.assertFalse(c_hm)
self.assertEqual(c_sr, "/etc/apache2")
# Use darwin as like test target
os_like.return_value = ["something", "nonexistent", "darwin"]
d_vr = constants.os_constant("vhost_root")
d_em = constants.os_constant("enmod")
self.assertFalse(d_em)
self.assertEqual(d_vr, "/etc/apache2/other")
os_like.return_value = ["something", "nonexistent", "darwin"]
self.assertFalse(constants.os_constant("enmod"))
self.assertEqual(constants.os_constant("vhost_root"),
"/etc/apache2/other")
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

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

View file

@ -0,0 +1,11 @@
<VirtualHost *:80>
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
</VirtualHost>
# vim: syntax=apache ts=4 sw=4 sts=4 sr noet

View file

@ -0,0 +1,11 @@
<VirtualHost *:80>
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
</VirtualHost>
# vim: syntax=apache ts=4 sw=4 sts=4 sr noet

View file

@ -13,7 +13,7 @@ from certbot.display import util as display_util
from certbot.plugins import common
from certbot.tests import test_util
from certbot.tests import util as test_util
from certbot_apache import configurator
from certbot_apache import constants
@ -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(

View file

@ -4,7 +4,7 @@ from setuptools import setup
from setuptools import find_packages
version = '0.9.0.dev0'
version = '0.10.0.dev0'
# Please update tox.ini when modifying dependency version requirements
install_requires = [

View file

@ -19,7 +19,7 @@ XDG_DATA_HOME=${XDG_DATA_HOME:-~/.local/share}
VENV_NAME="letsencrypt"
VENV_PATH=${VENV_PATH:-"$XDG_DATA_HOME/$VENV_NAME"}
VENV_BIN="$VENV_PATH/bin"
LE_AUTO_VERSION="0.8.1"
LE_AUTO_VERSION="0.9.3"
BASENAME=$(basename $0)
USAGE="Usage: $BASENAME [OPTIONS]
A self-updating wrapper script for the Certbot ACME client. When run, updates
@ -34,6 +34,7 @@ Help for certbot itself cannot be provided until it is installed.
-n, --non-interactive, --noninteractive run without asking for user input
--no-self-upgrade do not download updates
--os-packages-only install OS dependencies and exit
-q, --quiet provide only update/error output
-v, --verbose provide more output
All arguments are accepted and forwarded to the Certbot client when run."
@ -52,15 +53,19 @@ for arg in "$@" ; do
HELP=1;;
--noninteractive|--non-interactive)
ASSUME_YES=1;;
--quiet)
QUIET=1;;
--verbose)
VERBOSE=1;;
-[!-]*)
while getopts ":hnv" short_arg $arg; do
while getopts ":hnvq" short_arg $arg; do
case "$short_arg" in
h)
HELP=1;;
n)
ASSUME_YES=1;;
q)
QUIET=1;;
v)
VERBOSE=1;;
esac
@ -121,7 +126,7 @@ ExperimentalBootstrap() {
$2
fi
else
echo "WARNING: $1 support is very experimental at present..."
echo "FATAL: $1 support is very experimental at present..."
echo "if you would like to work on improving it, please ensure you have backups"
echo "and then run this script again with the --debug flag!"
exit 1
@ -276,6 +281,30 @@ BootstrapRpmCommon() {
exit 1
fi
if [ "$ASSUME_YES" = 1 ]; then
yes_flag="-y"
fi
if ! $SUDO $tool list *virtualenv >/dev/null 2>&1; then
echo "To use Certbot, packages from the EPEL repository need to be installed."
if ! $SUDO $tool list epel-release >/dev/null 2>&1; then
echo "Please enable this repository and try running Certbot again."
exit 1
fi
if [ "$ASSUME_YES" = 1 ]; then
/bin/echo -n "Enabling the EPEL repository in 3 seconds..."
sleep 1s
/bin/echo -ne "\e[0K\rEnabling the EPEL repository in 2 seconds..."
sleep 1s
/bin/echo -e "\e[0K\rEnabling the EPEL repository in 1 seconds..."
sleep 1s
fi
if ! $SUDO $tool install $yes_flag epel-release; then
echo "Could not enable EPEL. Aborting bootstrap!"
exit 1
fi
fi
pkgs="
gcc
dialog
@ -313,10 +342,6 @@ BootstrapRpmCommon() {
"
fi
if [ "$ASSUME_YES" = 1 ]; then
yes_flag="-y"
fi
if ! $SUDO $tool install $yes_flag $pkgs; then
echo "Could not install OS dependencies. Aborting bootstrap!"
exit 1
@ -565,8 +590,9 @@ if [ "$1" = "--le-auto-phase2" ]; then
# -------------------------------------------------------------------------
cat << "UNLIKELY_EOF" > "$TEMP_DIR/letsencrypt-auto-requirements.txt"
# This is the flattened list of packages certbot-auto installs. To generate
# this, do `pip install --no-cache-dir -e acme -e . -e certbot-apache`, and
# then use `hashin` or a more secure method to gather the hashes.
# this, do
# `pip install --no-cache-dir -e acme -e . -e certbot-apache -e certbot-nginx`,
# and then use `hashin` or a more secure method to gather the hashes.
argparse==1.4.0 \
--hash=sha256:c31647edb69fd3d465a847ea3157d37bed1f95f19760b11a47aa91c04b666314 \
@ -598,28 +624,29 @@ ConfigArgParse==0.10.0 \
--hash=sha256:3b50a83dd58149dfcee98cb6565265d10b53e9c0a2bca7eeef7fb5f5524890a7
configobj==5.0.6 \
--hash=sha256:a2f5650770e1c87fb335af19a9b7eb73fc05ccf22144eb68db7d00cd2bcb0902
cryptography==1.2.3 \
--hash=sha256:031938f73a5c5eb3e809e18ff7caeb6865351871417be6050cb8c86a9a202b9a \
--hash=sha256:a179a38d50f8d68b491d7a313db78f8cabe290842cecddddc7b34d408e59db0a \
--hash=sha256:906c88b2aadcf99cfabb24098263d1bf65ab0c8688acde10dae1f09d865920f1 \
--hash=sha256:6e706c5c6088770b1d1b634e959e21963e315b0255f5f4777125ad3d54082977 \
--hash=sha256:f5ebf8e31c48f8707921dca0e994de77813a9c9b9bf03c119c5ddf97bdcffe73 \
--hash=sha256:c7b89e42288cc7fbee3812e99ef5c744f22452e11d6822f6807afc6d6b3be83e \
--hash=sha256:8408d29865947109d8b68f1837a7cde1aa4dc86e0f79ca3ba58c0c44e443d6a5 \
--hash=sha256:c7e76cf3c3d925dd31fa238cfb806cffba718c0f08707d77a538768477969956 \
--hash=sha256:7d8de35380f31702758b7753bb5c40723832c73006dedb2f9099bf61a37f7287 \
--hash=sha256:5edbee71fae5469ee83fe0a37866b9398c8ce3a46325c24fcedfbf097bb48a19 \
--hash=sha256:594edafe4801c13bdc1cc305e7704a90c19617e95936f6ab457ee4ffe000ba50 \
--hash=sha256:b7fdb16a0a7f481be42da744bfe1ea2163025de21f90f2c688a316f3c354da9c \
--hash=sha256:207b8bf0fe0907336df38b733b487521cf9e138189aba9234ad54fe545dd0db8 \
--hash=sha256:509a2f05386270cf783993c90d49ffefb3dd62aee45bf1ea8ce3d2cde7271c21 \
--hash=sha256:ac69b65dd1af0179ede40c9f15788c88f73e628ea6c0519de3838e279bb388c6 \
--hash=sha256:8df6fad6c6ae12fd7004ea29357f0a2b4d3774eaeca7656530d08d2d90cd41aa \
--hash=sha256:0b8b96dd81cc1533a04f30382c0fe21c1972e189f794d0c4261a18cec08fd9b5 \
--hash=sha256:cae8fca1883f23c50ea78d89de6fe4fefdb4cea83177760f47177559414ded93 \
--hash=sha256:1a471ca576a9cdce1b1cd9f3a22b1d09ee44d46862037557de17919c0db44425 \
--hash=sha256:8ec4e8e3d453b3a1b63b5f57737a434dcf1ee4a2f26f6ff7c5a37c3f679104d2 \
--hash=sha256:8eb11c77dd8e73f48df6b2f7a7e16173fe0fe8fdfe266232832e88477e08454e
cryptography==1.3.4 \
--hash=sha256:bede00edd11a2a62c8c98c271cc103fa3a3d72acf64f6e5e4eaf251128897b17 \
--hash=sha256:53b39e687b744bb548a98f40736cc529d9f60959b4e6cc551322cf9505d35eb3 \
--hash=sha256:474b73ad1139b4e423e46bbd818efd0d5c0df1c65d9f7c957d64c9215d77afde \
--hash=sha256:aaddf9592d5b99e32dd518bb4a25b147c124f9d6b4ad64b94f01b15d1666b8c8 \
--hash=sha256:6dcad2f407db8c3cd6ecd78361439c449a4f94786b46c54507e7e68f51e1709d \
--hash=sha256:475c153fc622e656f1f10a9c9941d0ac7ab18df7c38d35d563a437c1c0e34f24 \
--hash=sha256:86dd61df581cba04e89e45081efbc531faff1c9d99c77b1ce97f87216c356353 \
--hash=sha256:75cc697e4ef5fdd0102ca749114c6370dbd11db0c9132a18834858c2566247e3 \
--hash=sha256:ea03ad5b9df6d79fc9fc1ab23729e01e1c920d2974c5e3c634ccf45a5c378452 \
--hash=sha256:c8872b8fe4f3416d6338ab99612f49ab314f7856cb43bffab2a32d28a6267be8 \
--hash=sha256:468fc6e16eaec6ceaa6bc341273e6e9912d01b42b740f8cf896ace7fcd6a321d \
--hash=sha256:d6fea3c6502735011c5d61a62aef1c1d770fc6a2def45d9e6c0d94c9651e3317 \
--hash=sha256:3cf95f179f4bead3d5649b91860ef4cf60ad4244209190fc405908272576d961 \
--hash=sha256:141f77e60a5b9158309b2b60288c7f81d37faa15c22a69b94c190ceefaaa6236 \
--hash=sha256:87b7a1fe703c6424451f3372d1879dae91c7fe5e13375441a72833db76fee30e \
--hash=sha256:f5ee3cb0cf1a6550bf483ccffa6608db267a377b45f7e3a8201a86d1d8feb19f \
--hash=sha256:4e097286651ea318300af3251375d48b71b8228481c56cd617ddd4459a1ff261 \
--hash=sha256:1e3d3ae3f22f22d50d340f47f25227511326f3f1396c6d2446a5b45b516c4313 \
--hash=sha256:6a057941cb64d79834ea3cf99093fcc4787c2a5d44f686c4f297361ddc419bcd \
--hash=sha256:68b3d5390b92559ddd3353c73ab2dfcff758f9c4ec4f5d5226ccede0e5d779f4 \
--hash=sha256:545dc003b4b6081f9c3e452da15d819b04b696f49484aff64c0a2aedf766bef8 \
--hash=sha256:423ff890c01be7c70dbfeaa967eeef5146f1a43a5f810ffdc07b178e48a105a9
enum34==1.1.2 \
--hash=sha256:2475d7fcddf5951e92ff546972758802de5260bf409319a9f1934e6bbc8b1dc7 \
--hash=sha256:35907defb0f992b75ab7788f65fedc1cf20ffa22688e0e6f6f12afc06b3ea501
@ -645,28 +672,6 @@ parsedatetime==2.1 \
pbr==1.8.1 \
--hash=sha256:46c8db75ae75a056bd1cc07fa21734fe2e603d11a07833ecc1eeb74c35c72e0c \
--hash=sha256:e2127626a91e6c885db89668976db31020f0af2da728924b56480fc7ccf09649
psutil==3.3.0 \
--hash=sha256:584f0b29fcc5d523b433cb8918b2fc74d67e30ee0b44a95baf031528f424619f \
--hash=sha256:28ca0b6e9d99aa8dc286e8747a4471362b69812a25291de29b6a8d70a1545a0d \
--hash=sha256:167ad5fff52a672c4ddc1c1a0b25146d6813ebb08a9aab0a3ac45f8a5b669c3b \
--hash=sha256:e6dea6173a988727bb223d3497349ad5cdef5c0b282eff2d83e5f9065c53f85f \
--hash=sha256:2af5e0a4aad66049955d0734aa4e3dc8caa17a9eaf8b4c1a27a5f1ee6e40f6fc \
--hash=sha256:d9884dc0dc2e55e2448e495778dc9899c1c8bf37aeb2f434c1bea74af93c2683 \
--hash=sha256:e27c2fe6dfcc8738be3d2c5a022f785eb72971057e1a9e1e34fba73bce8a71a6 \
--hash=sha256:65afd6fecc8f3aed09ee4be63583bc8eb472f06ceaa4fe24c4d1d5a1a3c0e13f \
--hash=sha256:ba1c558fbfcdf94515c2394b1155c1dc56e2bc2a9c17d30349827c9ed8a67e46 \
--hash=sha256:ba95ea0022dcb64d36f0c1335c0605fae35bdf3e0fea8d92f5d0f6456a35e55b \
--hash=sha256:421b6591d16b509aaa8d8c15821d66bb94cb4a8dc4385cad5c51b85d4a096d85 \
--hash=sha256:326b305cbdb6f94dafbfe2c26b11da88b0ab07b8a07f8188ab9d75ff0c6e841a \
--hash=sha256:9aede5b2b6fe46b3748ea8e5214443890d1634027bef3d33b7dad16556830278 \
--hash=sha256:73bed1db894d1aa9c3c7e611d302cdeab7ae8a0dc0eeaf76727878db1ac5cd87 \
--hash=sha256:935b5dd6d558af512f42501a7c08f41d7aff139af1bb3959daa3abb859234d6c \
--hash=sha256:4ca0111cf157dcc0f2f69a323c5b5478718d68d45fc9435d84be0ec0f186215b \
--hash=sha256:b6f13c95398a3fcf0226c4dcfa448560ba5865259cd96ec2810658651e932189 \
--hash=sha256:ee6be30d1635bbdea4c4325d507dc8a0dbbde7e1c198bd62ddb9f43198b9e214 \
--hash=sha256:dfa786858c268d7fbbe1b6175e001ec02738d7cfae0a7ce77bf9b651af676729 \
--hash=sha256:aa77f9de72af9c16cc288cd4a24cf58824388f57d7a81e400c4616457629870e \
--hash=sha256:f500093357d04da8140d87932cac2e54ef592a54ca8a743abb2850f60c2c22eb
pyasn1==0.1.9 \
--hash=sha256:61f9d99e3cef65feb1bfe3a2eef7a93eb93819d345bf54bcd42f4e63d5204dae \
--hash=sha256:1802a6dd32045e472a419db1441aecab469d33e0d2749e192abdec52101724af \
@ -679,9 +684,18 @@ pyasn1==0.1.9 \
--hash=sha256:5191ff6b9126d2c039dd87f8ff025bed274baf07fa78afa46f556b1ad7265d6e \
--hash=sha256:8323e03637b2d072cc7041300bac6ec448c3c28950ab40376036788e9a1af629 \
--hash=sha256:853cacd96d1f701ddd67aa03ecc05f51890135b7262e922710112f12a2ed2a7f
pyOpenSSL==0.15.1 \
--hash=sha256:88e45e6bb25dfed272a1ef2e728461d44b634c2cd689e989b6e56a349c5a3ae5 \
--hash=sha256:f0a26070d6db0881de8bcc7846934b7c3c930d8f9c79d45883ee48984bc0d672
pyopenssl==16.0.0 \
--hash=sha256:5add70cf00273bf957ca31fdb0df9b0ae4639e081897d5f86a0ae1f104901230 \
--hash=sha256:363d10ee43d062285facf4e465f4f5163f9f702f9134f0a5896f134cbb92d17d
pyparsing==2.1.8 \
--hash=sha256:2f0f5ceb14eccd5aef809d6382e87df22ca1da583c79f6db01675ce7d7f49c18 \
--hash=sha256:03a4869b9f3493807ee1f1cb405e6d576a1a2ca4d81a982677c0c1ad6177c56b \
--hash=sha256:ab09aee814c0241ff0c503cff30018219fe1fc14501d89f406f4664a0ec9fbcd \
--hash=sha256:6e9a7f052f8e26bcf749e4033e3115b6dc7e3c85aafcb794b9a88c9d9ef13c97 \
--hash=sha256:9f463a6bcc4eeb6c08f1ed84439b17818e2085937c0dee0d7674ac127c67c12b \
--hash=sha256:3626b4d81cfb300dad57f52f2f791caaf7b06c09b368c0aa7b868e53a5775424 \
--hash=sha256:367b90cc877b46af56d4580cd0ae278062903f02b8204ab631f5a2c0f50adfd0 \
--hash=sha256:9f1ea360086cd68681e7f4ca8f1f38df47bf81942a0d76a9673c2d23eff35b13
pyRFC3339==1.0 \
--hash=sha256:eea31835c56e2096af4363a5745a784878a61d043e247d3a6d6a0a32a9741f56 \
--hash=sha256:8dfbc6c458b8daba1c0f3620a8c78008b323a268b27b7359e92a4ae41325f535
@ -747,15 +761,18 @@ letsencrypt==0.7.0 \
# THE LINES BELOW ARE EDITED BY THE RELEASE SCRIPT; ADD ALL DEPENDENCIES ABOVE.
acme==0.8.1 \
--hash=sha256:ccd7883772efbf933f91713b8241455993834f3620c8fbd459d9ed5e50bbaaca \
--hash=sha256:d3ea4acf280bf6253ad7d641cb0970f230a19805acfed809e7a8ddcf62157d9f
certbot==0.8.1 \
--hash=sha256:89805d9f70249ae859ec4d7a99c00b4bb7083ca90cd12d4d202b76dfc284f7c5 \
--hash=sha256:6ca8df3d310ced6687d38aac17c0fb8c1b2ec7a3bea156a254e4cc2a1c132771
certbot-apache==0.8.1 \
--hash=sha256:c9e3fdc15e65589c2e39eb0e6b1f61f0c0a1db3c17b00bb337f0ff636cc61cb3 \
--hash=sha256:0faf2879884d3b7a58b071902fba37d4b8b58a50e2c3b8ac262c0a74134045ed
acme==0.9.3 \
--hash=sha256:d18ce17a75ad24d27981dfaef0524aa905eab757b267e027162b56a8967ab8fb \
--hash=sha256:a6eff1f955eb2e4316abd9aa2fedb6d9345e6b5b8a2d64ea0ad35e05d6124099
certbot==0.9.3 \
--hash=sha256:a87ef4c53c018df4e52ee2f2e906ad16bbb37789f29e6f284c495a2eb4d9b243 \
--hash=sha256:68149cb8392b29f5d5246e7226d25f913f2b10482bf3bc7368e8c8821d25f3b0
certbot-apache==0.9.3 \
--hash=sha256:f379b1053e10709692654d7a6fcea9eaed19b66c49a753b61e31bd06a04b0aac \
--hash=sha256:a5d98cf972072de08f984db4e6a7f20269f3f023c43f6d4e781fe43be7c10086
certbot-nginx==0.9.3 \
--hash=sha256:3c26f18f0b57550f069263bd9b2984ef33eab6693e7796611c1b2cc16574069c \
--hash=sha256:7337a2e90e0b28a1ab09e31d9fb81c6d78e6453500c824c0f18bab5d31b63058
UNLIKELY_EOF
# -------------------------------------------------------------------------
@ -912,13 +929,19 @@ UNLIKELY_EOF
# Set PATH so pipstrap upgrades the right (v)env:
PATH="$VENV_BIN:$PATH" "$VENV_BIN/python" "$TEMP_DIR/pipstrap.py"
set +e
PIP_OUT=`"$VENV_BIN/pip" install --no-cache-dir --require-hashes -r "$TEMP_DIR/letsencrypt-auto-requirements.txt" 2>&1`
if [ "$VERBOSE" = 1 ]; then
"$VENV_BIN/pip" install --no-cache-dir --require-hashes -r "$TEMP_DIR/letsencrypt-auto-requirements.txt"
else
PIP_OUT=`"$VENV_BIN/pip" install --no-cache-dir --require-hashes -r "$TEMP_DIR/letsencrypt-auto-requirements.txt" 2>&1`
fi
PIP_STATUS=$?
set -e
if [ "$PIP_STATUS" != 0 ]; then
# Report error. (Otherwise, be quiet.)
echo "Had a problem while installing Python packages:"
echo "$PIP_OUT"
echo "Had a problem while installing Python packages."
if [ "$VERBOSE" != 1 ]; then
echo "$PIP_OUT"
fi
rm -rf "$VENV_PATH"
exit 1
fi
@ -926,8 +949,10 @@ UNLIKELY_EOF
fi
if [ -n "$SUDO" ]; then
# SUDO is su wrapper or sudo
echo "Requesting root privileges to run certbot..."
echo " $VENV_BIN/letsencrypt" "$@"
if [ "$QUIET" != 1 ]; then
echo "Requesting root privileges to run certbot..."
echo " $VENV_BIN/letsencrypt" "$@"
fi
fi
if [ -z "$SUDO_ENV" ] ; then
# SUDO is su wrapper / noop
@ -967,7 +992,7 @@ else
# Print latest released version of LE to stdout:
python fetch.py --latest-version
# Download letsencrypt-auto script from git tag v1.2.3 into the folder I'm
# in, and make sure its signature verifies:
python fetch.py --le-auto-script v1.2.3

View file

@ -0,0 +1,50 @@
FROM debian:jessie
MAINTAINER Brad Warren <bmw@eff.org>
# no need to mkdir anything:
# https://docs.docker.com/reference/builder/#copy
# If <dest> doesn't exist, it is created along with all missing
# directories in its path.
# TODO: Install non-default Python versions for tox.
# TODO: Install Apache/Nginx for plugin development.
COPY certbot-auto /opt/certbot/src/certbot-auto
RUN /opt/certbot/src/certbot-auto -n --os-packages-only
# the above is not likely to change, so by putting it further up the
# Dockerfile we make sure we cache as much as possible
COPY setup.py README.rst CHANGES.rst MANIFEST.in linter_plugin.py tox.cover.sh tox.ini pep8.travis.sh .pep8 .pylintrc /opt/certbot/src/
# all above files are necessary for setup.py, however, package source
# code directory has to be copied separately to a subdirectory...
# https://docs.docker.com/reference/builder/#copy: "If <src> is a
# directory, the entire contents of the directory are copied,
# including filesystem metadata. Note: The directory itself is not
# copied, just its contents." Order again matters, three files are far
# more likely to be cached than the whole project directory
COPY certbot /opt/certbot/src/certbot/
COPY acme /opt/certbot/src/acme/
COPY certbot-apache /opt/certbot/src/certbot-apache/
COPY certbot-nginx /opt/certbot/src/certbot-nginx/
COPY certbot-compatibility-test /opt/certbot/src/certbot-compatibility-test/
RUN virtualenv --no-site-packages -p python2 /opt/certbot/venv && \
/opt/certbot/venv/bin/pip install -U setuptools && \
/opt/certbot/venv/bin/pip install -U pip && \
/opt/certbot/venv/bin/pip install \
-e /opt/certbot/src/acme \
-e /opt/certbot/src \
-e /opt/certbot/src/certbot-apache \
-e /opt/certbot/src/certbot-nginx \
-e /opt/certbot/src/certbot-compatibility-test
# install in editable mode (-e) to save space: it's not possible to
# "rm -rf /opt/certbot/src" (it's stays in the underlaying image);
# this might also help in debugging: you can "docker run --entrypoint
# bash" and investigate, apply patches, etc.
WORKDIR /opt/certbot/src/certbot-compatibility-test/certbot_compatibility_test/testdata
ENV PATH /opt/certbot/venv/bin:$PATH

View file

@ -0,0 +1,6 @@
FROM certbot-compatibility-test
MAINTAINER Brad Warren <bmw@eff.org>
RUN apt-get install apache2 -y
ENTRYPOINT [ "certbot-compatibility-test", "-p", "apache" ]

View file

@ -0,0 +1,6 @@
FROM certbot-compatibility-test
MAINTAINER Brad Warren <bmw@eff.org>
RUN apt-get install nginx -y
ENTRYPOINT [ "certbot-compatibility-test", "-p", "nginx" ]

View file

@ -1,20 +0,0 @@
FROM httpd
MAINTAINER Brad Warren <bradmw@umich.edu>
RUN mkdir /var/run/apache2
ENV APACHE_RUN_USER=daemon \
APACHE_RUN_GROUP=daemon \
APACHE_PID_FILE=/usr/local/apache2/logs/httpd.pid \
APACHE_RUN_DIR=/var/run/apache2 \
APACHE_LOCK_DIR=/var/lock \
APACHE_LOG_DIR=/usr/local/apache2/logs
COPY certbot-compatibility-test/certbot_compatibility_test/configurators/apache/a2enmod.sh /usr/local/bin/
COPY certbot-compatibility-test/certbot_compatibility_test/configurators/apache/a2dismod.sh /usr/local/bin/
COPY certbot-compatibility-test/certbot_compatibility_test/testdata/rsa1024_key2.pem /usr/local/apache2/conf/
COPY certbot-compatibility-test/certbot_compatibility_test/testdata/empty_cert.pem /usr/local/apache2/conf/
# Note: this only exposes the port to other docker containers. You
# still have to bind to 443@host at runtime.
EXPOSE 443

View file

@ -1,5 +1,4 @@
"""Provides a common base for Apache proxies"""
import re
import os
import shutil
import subprocess
@ -17,10 +16,6 @@ from certbot_compatibility_test import util
from certbot_compatibility_test.configurators import common as configurators_common
APACHE_VERSION_REGEX = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE)
APACHE_COMMANDS = ["apachectl", "a2enmod", "a2dismod"]
@zope.interface.implementer(interfaces.IConfiguratorProxy)
class Proxy(configurators_common.Proxy):
# pylint: disable=too-many-instance-attributes
@ -32,37 +27,25 @@ class Proxy(configurators_common.Proxy):
self.le_config.apache_le_vhost_ext = "-le-ssl.conf"
self.modules = self.server_root = self.test_conf = self.version = None
self._apache_configurator = self._all_names = self._test_names = None
patch = mock.patch(
"certbot_apache.configurator.display_ops.select_vhost")
mock_display = patch.start()
mock_display.side_effect = le_errors.PluginError(
"Unable to determine vhost")
def __getattr__(self, name):
"""Wraps the Apache Configurator methods"""
method = getattr(self._apache_configurator, name, None)
if callable(method):
return method
else:
raise AttributeError()
def load_config(self):
"""Loads the next configuration for the plugin to test"""
config = super(Proxy, self).load_config()
self._all_names, self._test_names = _get_names(config)
server_root = _get_server_root(config)
# with open(os.path.join(config, "config_file")) as f:
# config_file = os.path.join(server_root, f.readline().rstrip())
shutil.rmtree("/etc/apache2")
shutil.copytree(server_root, "/etc/apache2", symlinks=True)
self._prepare_configurator()
try:
subprocess.check_call("apachectl -k start".split())
subprocess.check_call("apachectl -k restart".split())
except errors.Error:
raise errors.Error(
"Apache failed to load {0} before tests started".format(
@ -78,38 +61,16 @@ class Proxy(configurators_common.Proxy):
# An alias
self.le_config.apache_handle_modules = self.le_config.apache_handle_mods
self._apache_configurator = configurator.ApacheConfigurator(
self._configurator = configurator.ApacheConfigurator(
config=configuration.NamespaceConfig(self.le_config),
name="apache")
self._apache_configurator.prepare()
self._configurator.prepare()
def cleanup_from_tests(self):
"""Performs any necessary cleanup from running plugin tests"""
super(Proxy, self).cleanup_from_tests()
mock.patch.stopall()
def get_all_names_answer(self):
"""Returns the set of domain names that the plugin should find"""
if self._all_names:
return self._all_names
else:
raise errors.Error("No configuration file loaded")
def get_testable_domain_names(self):
"""Returns the set of domain names that can be tested against"""
if self._test_names:
return self._test_names
else:
return {"example.com"}
def deploy_cert(self, domain, cert_path, key_path, chain_path=None,
fullchain_path=None):
"""Installs cert"""
cert_path, key_path, chain_path = self.copy_certs_and_keys(
cert_path, key_path, chain_path)
self._apache_configurator.deploy_cert(
domain, cert_path, key_path, chain_path, fullchain_path)
def _get_server_root(config):
"""Returns the server root directory in config"""

View file

@ -5,6 +5,7 @@ import shutil
import tempfile
from certbot import constants
from certbot_compatibility_test import errors
from certbot_compatibility_test import util
@ -31,6 +32,18 @@ class Proxy(object):
self.args = args
self.http_port = 80
self.https_port = 443
self._configurator = self._all_names = self._test_names = None
def __getattr__(self, name):
"""Wraps the configurator methods"""
if self._configurator is None:
raise AttributeError()
method = getattr(self._configurator, name, None)
if callable(method):
return method
else:
raise AttributeError()
def has_more_configs(self):
"""Returns true if there are more configs to test"""
@ -63,3 +76,25 @@ class Proxy(object):
chain = None
return cert, key, chain
def get_all_names_answer(self):
"""Returns the set of domain names that the plugin should find"""
if self._all_names:
return self._all_names
else:
raise errors.Error("No configuration file loaded")
def get_testable_domain_names(self):
"""Returns the set of domain names that can be tested against"""
if self._test_names:
return self._test_names
else:
return {"example.com"}
def deploy_cert(self, domain, cert_path, key_path, chain_path=None,
fullchain_path=None):
"""Installs cert"""
cert_path, key_path, chain_path = self.copy_certs_and_keys(
cert_path, key_path, chain_path)
self._configurator.deploy_cert(
domain, cert_path, key_path, chain_path, fullchain_path)

View file

@ -0,0 +1 @@
"""Certbot compatibility test Nginx configurators"""

View file

@ -0,0 +1,84 @@
"""Provides a common base for Nginx proxies"""
import os
import shutil
import subprocess
import zope.interface
from certbot import configuration
from certbot_nginx import configurator
from certbot_nginx import constants
from certbot_compatibility_test import errors
from certbot_compatibility_test import interfaces
from certbot_compatibility_test import util
from certbot_compatibility_test.configurators import common as configurators_common
@zope.interface.implementer(interfaces.IConfiguratorProxy)
class Proxy(configurators_common.Proxy):
# pylint: disable=too-many-instance-attributes
"""A common base for Nginx test configurators"""
def __init__(self, args):
"""Initializes the plugin with the given command line args"""
super(Proxy, self).__init__(args)
def load_config(self):
"""Loads the next configuration for the plugin to test"""
config = super(Proxy, self).load_config()
self._all_names, self._test_names = _get_names(config)
server_root = _get_server_root(config)
# XXX: Deleting all of this is kind of scary unless the test
# instances really each have a complete configuration!
shutil.rmtree("/etc/nginx")
shutil.copytree(server_root, "/etc/nginx", symlinks=True)
self._prepare_configurator()
try:
subprocess.check_call("service nginx reload".split())
except errors.Error:
raise errors.Error(
"Nginx failed to load {0} before tests started".format(
config))
return config
def _prepare_configurator(self):
"""Prepares the Nginx plugin for testing"""
for k in constants.CLI_DEFAULTS.keys():
setattr(self.le_config, "nginx_" + k, constants.os_constant(k))
conf = configuration.NamespaceConfig(self.le_config)
zope.component.provideUtility(conf)
self._configurator = configurator.NginxConfigurator(
config=conf, name="nginx")
self._configurator.prepare()
def _get_server_root(config):
"""Returns the server root directory in config"""
subdirs = [
name for name in os.listdir(config)
if os.path.isdir(os.path.join(config, name))]
if len(subdirs) != 1:
raise errors.Error("Malformed configuration directory {0}".format(config))
return os.path.join(config, subdirs[0].rstrip())
def _get_names(config):
"""Returns all and testable domain names in config"""
all_names = set()
for root, _dirs, files in os.walk(config):
for this_file in files:
for line in open(os.path.join(root, this_file)):
if line.strip().startswith("server_name"):
names = line.partition("server_name")[2].rpartition(";")[0]
for n in names.split():
all_names.add(n)
non_ip_names = set(n for n in all_names if not util.IP_REGEX.match(n))
return all_names, non_ip_names

View file

@ -22,7 +22,8 @@ from certbot_compatibility_test import errors
from certbot_compatibility_test import util
from certbot_compatibility_test import validator
from certbot_compatibility_test.configurators.apache import common
from certbot_compatibility_test.configurators.apache import common as a_common
from certbot_compatibility_test.configurators.nginx import common as n_common
DESCRIPTION = """
@ -32,7 +33,7 @@ tests that the plugin supports are performed.
"""
PLUGINS = {"apache": common.Proxy}
PLUGINS = {"apache": a_common.Proxy, "nginx": n_common.Proxy}
logger = logging.getLogger(__name__)
@ -143,7 +144,7 @@ def test_deploy_cert(plugin, temp_dir, domains):
for domain in domains:
try:
plugin.deploy_cert(domain, cert_path, util.KEY_PATH, cert_path)
plugin.deploy_cert(domain, cert_path, util.KEY_PATH, cert_path, cert_path)
plugin.save() # Needed by the Apache plugin
except le_errors.Error as error:
logger.error("Plugin failed to deploy ceritificate for %s:", domain)

View file

@ -40,8 +40,15 @@ class Validator(object):
return False
redirect_location = response.headers.get("location", "")
# We're checking that the redirect we added behaves correctly.
# It's okay for some server configuration to redirect to an
# http URL, as long as it's on some other domain.
if not redirect_location.startswith("https://"):
return False
if not redirect_location.startswith("http://"):
return False
else:
if redirect_location[len("http://"):] == name:
return False
if response.status_code != 301:
logger.error("Server did not redirect with permanent code")

View file

@ -4,7 +4,7 @@ from setuptools import setup
from setuptools import find_packages
version = '0.9.0.dev0'
version = '0.10.0.dev0'
install_requires = [
'certbot',

View file

@ -24,12 +24,41 @@ from certbot.plugins import common
from certbot_nginx import constants
from certbot_nginx import tls_sni_01
from certbot_nginx import obj
from certbot_nginx import parser
logger = logging.getLogger(__name__)
REDIRECT_BLOCK = [[
['\n ', 'if', ' ', '($scheme != "https") '],
[['\n ', 'return', ' ', '301 https://$host$request_uri'],
'\n ']
], ['\n']]
TEST_REDIRECT_BLOCK = [
[
['if', '($scheme != "https")'],
[
['return', '301 https://$host$request_uri']
]
],
['#', ' managed by Certbot']
]
REDIRECT_COMMENT_BLOCK = [
['\n ', '#', ' Redirect non-https traffic to https'],
['\n ', '#', ' if ($scheme != "https") {'],
['\n ', '#', " return 301 https://$host$request_uri;"],
['\n ', '#', " } # managed by Certbot"],
['\n']
]
TEST_REDIRECT_COMMENT_BLOCK = [
['#', ' Redirect non-https traffic to https'],
['#', ' if ($scheme != "https") {'],
['#', " return 301 https://$host$request_uri;"],
['#', " } # managed by Certbot"],
]
@zope.interface.implementer(interfaces.IAuthenticator, interfaces.IInstaller)
@zope.interface.provider(interfaces.IPluginFactory)
@ -55,7 +84,11 @@ class NginxConfigurator(common.Plugin):
"""
description = "Nginx Web Server - currently doesn't work"
description = "Nginx Web Server plugin - Alpha"
hidden = True
DEFAULT_LISTEN_PORT = '80'
@classmethod
def add_parser_arguments(cls, add):
@ -92,7 +125,8 @@ class NginxConfigurator(common.Plugin):
# These will be set in the prepare function
self.parser = None
self.version = version
self._enhance_func = {"redirect": self._enable_redirect}
self._enhance_func = {"redirect": self._enable_redirect,
"staple-ocsp": self._enable_ocsp_stapling}
# Set up reverter
self.reverter = reverter.Reverter(self.config)
@ -117,6 +151,8 @@ class NginxConfigurator(common.Plugin):
# Make sure configuration is valid
self.config_test()
# temp_install must be run before creating the NginxParser
temp_install(self.mod_ssl_conf)
self.parser = parser.NginxParser(
self.conf('server-root'), self.mod_ssl_conf)
@ -124,8 +160,6 @@ class NginxConfigurator(common.Plugin):
if self.version is None:
self.version = self.get_version()
temp_install(self.mod_ssl_conf)
# Entry point in main.py for installing cert
def deploy_cert(self, domain, cert_path, key_path,
chain_path=None, fullchain_path=None):
@ -135,11 +169,6 @@ class NginxConfigurator(common.Plugin):
.. note:: Aborts if the vhost is missing ssl_certificate or
ssl_certificate_key.
.. note:: Nginx doesn't have a cert chain directive.
It expects the cert file to have the concatenated chain.
However, we use the chain file as input to the
ssl_trusted_certificate directive, used for verify OCSP responses.
.. note:: This doesn't save the config files!
:raises errors.PluginError: When unable to deploy certificate due to
@ -155,26 +184,9 @@ class NginxConfigurator(common.Plugin):
cert_directives = [['\n', 'ssl_certificate', ' ', fullchain_path],
['\n', 'ssl_certificate_key', ' ', key_path]]
# OCSP stapling was introduced in Nginx 1.3.7. If we have that version
# or greater, add config settings for it.
stapling_directives = []
if self.version >= (1, 3, 7):
stapling_directives = [
['\n ', 'ssl_trusted_certificate', ' ', chain_path],
['\n ', 'ssl_stapling', ' ', 'on'],
['\n ', 'ssl_stapling_verify', ' ', 'on'], ['\n']]
if len(stapling_directives) != 0 and not chain_path:
raise errors.PluginError(
"--chain-path is required to enable "
"Online Certificate Status Protocol (OCSP) stapling "
"on nginx >= 1.3.7.")
try:
self.parser.add_server_directives(vhost.filep, vhost.names,
self.parser.add_server_directives(vhost,
cert_directives, replace=True)
self.parser.add_server_directives(vhost.filep, vhost.names,
stapling_directives, replace=False)
logger.info("Deployed Certificate to VirtualHost %s for %s",
vhost.filep, vhost.names)
except errors.MisconfigurationError as error:
@ -190,12 +202,6 @@ class NginxConfigurator(common.Plugin):
", ".join(str(addr) for addr in vhost.addrs)))
self.save_notes += "\tssl_certificate %s\n" % fullchain_path
self.save_notes += "\tssl_certificate_key %s\n" % key_path
if len(stapling_directives) > 0:
self.save_notes += "\tssl_trusted_certificate %s\n" % chain_path
self.save_notes += "\tssl_stapling on\n"
self.save_notes += "\tssl_stapling_verify on\n"
#######################
# Vhost parsing methods
@ -222,22 +228,13 @@ class NginxConfigurator(common.Plugin):
vhost = None
matches = self._get_ranked_matches(target_name)
if not matches:
# No matches. Create a new vhost with this name in nginx.conf.
filep = self.parser.loc["root"]
new_block = [['server'], [['\n', 'server_name', ' ', target_name]]]
self.parser.add_http_directives(filep, new_block)
vhost = obj.VirtualHost(filep, set([]), False, True,
set([target_name]), list(new_block[1]))
elif matches[0]['rank'] in xrange(2, 6):
# Wildcard match - need to find the longest one
rank = matches[0]['rank']
wildcards = [x for x in matches if x['rank'] == rank]
vhost = max(wildcards, key=lambda x: len(x['name']))['vhost']
vhost = self._select_best_name_match(matches)
if not vhost:
# No matches. Raise a misconfiguration error.
raise errors.MisconfigurationError(
"Cannot find a VirtualHost matching domain %s." % (target_name))
else:
vhost = matches[0]['vhost']
if vhost is not None:
# Note: if we are enhancing with ocsp, vhost should already be ssl.
if not vhost.ssl:
self._make_server_ssl(vhost)
@ -252,6 +249,41 @@ class NginxConfigurator(common.Plugin):
the numerical rank
:rtype: list
"""
vhost_list = self.parser.get_vhosts()
return self._rank_matches_by_name_and_ssl(vhost_list, target_name)
def _select_best_name_match(self, matches):
"""Returns the best name match of a ranked list of vhosts.
:param list matches: list of dicts containing the vhost, the matching name,
and the numerical rank
:returns: the most matching vhost
:rtype: :class:`~certbot_nginx.obj.VirtualHost`
"""
if not matches:
return None
elif matches[0]['rank'] in xrange(2, 6):
# Wildcard match - need to find the longest one
rank = matches[0]['rank']
wildcards = [x for x in matches if x['rank'] == rank]
return max(wildcards, key=lambda x: len(x['name']))['vhost']
else:
# Exact or regex match
return matches[0]['vhost']
def _rank_matches_by_name_and_ssl(self, vhost_list, target_name):
"""Returns a ranked list of vhosts from vhost_list that match target_name.
The ranking gives preference to SSL vhosts.
:param list vhost_list: list of vhosts to filter and rank
:param str target_name: The name to match
:returns: list of dicts containing the vhost, the matching name, and
the numerical rank
:rtype: list
"""
# Nginx chooses a matching server name for a request with precedence:
# 1. exact name match
@ -259,7 +291,7 @@ class NginxConfigurator(common.Plugin):
# 3. longest wildcard name ending with *
# 4. first matching regex in order of appearance in the file
matches = []
for vhost in self.parser.get_vhosts():
for vhost in vhost_list:
name_type, name = parser.get_best_match(target_name, vhost.names)
if name_type == 'exact':
matches.append({'vhost': vhost,
@ -279,6 +311,73 @@ class NginxConfigurator(common.Plugin):
'rank': 6 if vhost.ssl else 7})
return sorted(matches, key=lambda x: x['rank'])
def choose_redirect_vhost(self, target_name, port):
"""Chooses a single virtual host for redirect enhancement.
Chooses the vhost most closely matching target_name that is
listening to port without using ssl.
.. todo:: This should maybe return list if no obvious answer
is presented.
.. todo:: The special name "$hostname" corresponds to the machine's
hostname. Currently we just ignore this.
:param str target_name: domain name
:param str port: port number
:returns: vhost associated with name
:rtype: :class:`~certbot_nginx.obj.VirtualHost`
"""
matches = self._get_redirect_ranked_matches(target_name, port)
return self._select_best_name_match(matches)
def _get_redirect_ranked_matches(self, target_name, port):
"""Gets a ranked list of plaintextish port-listening vhosts matching target_name
Filter all hosts for those listening on port without using ssl.
Rank by how well these match target_name.
:param str target_name: The name to match
:param str port: port number
:returns: list of dicts containing the vhost, the matching name, and
the numerical rank
:rtype: list
"""
all_vhosts = self.parser.get_vhosts()
def _port_matches(test_port, matching_port):
# test_port is a number, matching is a number or "" or None
if matching_port == "" or matching_port is None:
# if no port is specified, Nginx defaults to listening on port 80.
return test_port == self.DEFAULT_LISTEN_PORT
else:
return test_port == matching_port
def _vhost_matches(vhost, port):
found_matching_port = False
if len(vhost.addrs) == 0:
# if there are no listen directives at all, Nginx defaults to
# listening on port 80.
found_matching_port = (port == self.DEFAULT_LISTEN_PORT)
else:
for addr in vhost.addrs:
if _port_matches(port, addr.get_port()) and addr.ssl == False:
found_matching_port = True
if found_matching_port:
# make sure we don't have an 'ssl on' directive
return not self.parser.has_ssl_on_directive(vhost)
else:
return False
matching_vhosts = [vhost for vhost in all_vhosts if _vhost_matches(vhost, port)]
# We can use this ranking function because sslishness doesn't matter to us, and
# there shouldn't be conflicting plaintextish servers listening on 80.
return self._rank_matches_by_name_and_ssl(matching_vhosts, target_name)
def get_all_names(self):
"""Returns all names found in the Nginx Configuration.
@ -306,7 +405,25 @@ class NginxConfigurator(common.Plugin):
except (socket.error, socket.herror, socket.timeout):
continue
return all_names
return self._get_filtered_names(all_names)
def _get_filtered_names(self, all_names):
"""Removes names that aren't considered valid by Let's Encrypt.
:param set all_names: all names found in the Nginx configuration
:returns: all found names that are considered valid by LE
:rtype: set
"""
filtered_names = set()
for name in all_names:
try:
filtered_names.add(util.enforce_le_validity(name))
except errors.ConfigurationError as error:
logger.debug('Not suggesting name "%s"', name)
logger.debug(error)
return filtered_names
def _get_snakeoil_paths(self):
# TODO: generate only once
@ -319,7 +436,8 @@ class NginxConfigurator(common.Plugin):
cert = acme_crypto_util.gen_ss_cert(key, domains=[socket.gethostname()])
cert_pem = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, cert)
cert_file, cert_path = util.unique_file(os.path.join(tmp_dir, "cert.pem"))
cert_file, cert_path = util.unique_file(
os.path.join(tmp_dir, "cert.pem"), mode="wb")
with cert_file:
cert_file.write(cert_pem)
return cert_path, le_key.file
@ -337,36 +455,32 @@ class NginxConfigurator(common.Plugin):
:type vhost: :class:`~certbot_nginx.obj.VirtualHost`
"""
# If the vhost was implicitly listening on the default Nginx port,
# have it continue to do so.
if len(vhost.addrs) == 0:
listen_block = [['\n ', 'listen', ' ', self.DEFAULT_LISTEN_PORT]]
self.parser.add_server_directives(vhost, listen_block, replace=False)
snakeoil_cert, snakeoil_key = self._get_snakeoil_paths()
ssl_block = [['\n ', 'listen', ' ', '{0} ssl'.format(self.config.tls_sni_01_port)],
['\n ', 'ssl_certificate', ' ', snakeoil_cert],
['\n ', 'ssl_certificate_key', ' ', snakeoil_key],
['\n ', 'include', ' ', self.parser.loc["ssl_options"]]]
# the options file doesn't have a newline at the beginning, but there
# needs to be one when it's dropped into the file
ssl_block = (
[['\n ', 'listen', ' ', '{0} ssl'.format(self.config.tls_sni_01_port)],
['\n ', 'ssl_certificate', ' ', snakeoil_cert],
['\n ', 'ssl_certificate_key', ' ', snakeoil_key],
['\n']] +
self.parser.loc["ssl_options"])
self.parser.add_server_directives(
vhost.filep, vhost.names, ssl_block, replace=False)
vhost.ssl = True
vhost.raw.extend(ssl_block)
vhost.addrs.add(obj.Addr(
'', str(self.config.tls_sni_01_port), True, False))
def get_all_certs_keys(self):
"""Find all existing keys, certs from configuration.
:returns: list of tuples with form [(cert, key, path)]
cert - str path to certificate file
key - str path to associated key file
path - File path to configuration file.
:rtype: set
"""
return self.parser.get_all_certs_keys()
vhost, ssl_block, replace=False)
##################################
# enhancement methods (IInstaller)
##################################
def supported_enhancements(self): # pylint: disable=no-self-use
"""Returns currently supported enhancements."""
return ['redirect']
return ['redirect', 'staple-ocsp']
def enhance(self, domain, enhancement, options=None):
"""Enhance configuration.
@ -380,35 +494,105 @@ class NginxConfigurator(common.Plugin):
"""
try:
return self._enhance_func[enhancement](
self.choose_vhost(domain), options)
return self._enhance_func[enhancement](domain, options)
except (KeyError, ValueError):
raise errors.PluginError(
"Unsupported enhancement: {0}".format(enhancement))
except errors.PluginError:
logger.warning("Failed %s for %s", enhancement, domain)
raise
def _enable_redirect(self, vhost, unused_options):
def _has_certbot_redirect(self, vhost):
return vhost.contains_list(TEST_REDIRECT_BLOCK)
def _has_certbot_redirect_comment(self, vhost):
return vhost.contains_list(TEST_REDIRECT_COMMENT_BLOCK)
def _add_redirect_block(self, vhost, active=True):
"""Add redirect directive to vhost
"""
if active:
redirect_block = REDIRECT_BLOCK
else:
redirect_block = REDIRECT_COMMENT_BLOCK
self.parser.add_server_directives(
vhost, redirect_block, replace=False)
def _enable_redirect(self, domain, unused_options):
"""Redirect all equivalent HTTP traffic to ssl_vhost.
Add rewrite directive to non https traffic
.. note:: This function saves the configuration
:param vhost: Destination of traffic, an ssl enabled vhost
:type vhost: :class:`~certbot_nginx.obj.VirtualHost`
:param str domain: domain to enable redirect for
:param unused_options: Not currently used
:type unused_options: Not Available
"""
redirect_block = [[
['\n ', 'if', ' ', '($scheme != "https") '],
[['\n ', 'return', ' ', '301 https://$host$request_uri'],
'\n ']
], ['\n']]
self.parser.add_server_directives(
vhost.filep, vhost.names, redirect_block, replace=False)
logger.info("Redirecting all traffic to ssl in %s", vhost.filep)
port = self.DEFAULT_LISTEN_PORT
vhost = None
# If there are blocks listening plaintextishly on self.DEFAULT_LISTEN_PORT,
# choose the most name-matching one.
vhost = self.choose_redirect_vhost(domain, port)
if vhost is None:
logger.info("No matching insecure server blocks listening on port %s found.",
self.DEFAULT_LISTEN_PORT)
else:
if self._has_certbot_redirect(vhost):
logger.info("Traffic on port %s already redirecting to ssl in %s",
self.DEFAULT_LISTEN_PORT, vhost.filep)
elif vhost.has_redirect():
if not self._has_certbot_redirect_comment(vhost):
self._add_redirect_block(vhost, active=False)
logger.info("The appropriate server block is already redirecting "
"traffic. To enable redirect anyway, uncomment the "
"redirect lines in %s.", vhost.filep)
else:
# Redirect plaintextish host to https
self._add_redirect_block(vhost, active=True)
logger.info("Redirecting all traffic on port %s to ssl in %s",
self.DEFAULT_LISTEN_PORT, vhost.filep)
def _enable_ocsp_stapling(self, domain, chain_path):
"""Include OCSP response in TLS handshake
:param str domain: domain to enable OCSP response for
:param chain_path: chain file path
:type chain_path: `str` or `None`
"""
vhost = self.choose_vhost(domain)
if self.version < (1, 3, 7):
raise errors.PluginError("Version 1.3.7 or greater of nginx "
"is needed to enable OCSP stapling")
if chain_path is None:
raise errors.PluginError(
"--chain-path is required to enable "
"Online Certificate Status Protocol (OCSP) stapling "
"on nginx >= 1.3.7.")
stapling_directives = [
['\n ', 'ssl_trusted_certificate', ' ', chain_path],
['\n ', 'ssl_stapling', ' ', 'on'],
['\n ', 'ssl_stapling_verify', ' ', 'on'], ['\n']]
try:
self.parser.add_server_directives(vhost,
stapling_directives, replace=False)
except errors.MisconfigurationError as error:
logger.debug(error)
raise errors.PluginError("An error occurred while enabling OCSP "
"stapling for {0}.".format(vhost.names))
self.save_notes += ("OCSP Stapling was enabled "
"on SSL Vhost: {0}.\n".format(vhost.filep))
self.save_notes += "\tssl_trusted_certificate {0}\n".format(chain_path)
self.save_notes += "\tssl_stapling on\n"
self.save_notes += "\tssl_stapling_verify on\n"
######################################
# Nginx server management (IInstaller)
@ -527,14 +711,16 @@ class NginxConfigurator(common.Plugin):
"""
save_files = set(self.parser.parsed.keys())
try:
try: # TODO: make a common base for Apache and Nginx plugins
# Create Checkpoint
if temporary:
self.reverter.add_to_temp_checkpoint(
save_files, self.save_notes)
# how many comments does it take
else:
self.reverter.add_to_checkpoint(save_files,
self.save_notes)
# to confuse a linter?
except errors.ReverterError as err:
raise errors.PluginError(str(err))

View file

@ -16,3 +16,17 @@ MOD_SSL_CONF_SRC = pkg_resources.resource_filename(
"certbot_nginx", "options-ssl-nginx.conf")
"""Path to the nginx mod_ssl config file found in the Certbot
distribution."""
def os_constant(key):
# XXX TODO: In the future, this could return different constants
# based on what OS we are running under. To see an
# approach to how to handle different OSes, see the
# apache version of this file. Currently, we do not
# actually have any OS-specific constants on Nginx.
"""
Get a constant value for operating system
:param key: name of cli constant
:return: value of constant for active os
"""
return CLI_DEFAULTS[key]

View file

@ -227,7 +227,8 @@ class UnspacedList(list):
def insert(self, i, x):
item, spaced_item = self._coerce(x)
self.spaced.insert(self._spaced_position(i), spaced_item)
slicepos = self._spaced_position(i) if i < len(self) else len(self.spaced)
self.spaced.insert(slicepos, spaced_item)
list.insert(self, i, item)
self.dirty = True

View file

@ -3,6 +3,7 @@ import re
from certbot.plugins import common
REDIRECT_DIRECTIVES = ['return', 'rewrite']
class Addr(common.Addr):
r"""Represents an Nginx address, i.e. what comes after the 'listen'
@ -28,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):
@ -69,7 +74,8 @@ class Addr(common.Addr):
return cls(host, port, ssl, default)
def __str__(self):
def to_string(self, include_default=True):
"""Return string representation of Addr"""
parts = ''
if self.tup[0] and self.tup[1]:
parts = "%s:%s" % self.tup
@ -78,19 +84,36 @@ class Addr(common.Addr):
else:
parts = self.tup[1]
if self.default:
if self.default and include_default:
parts += ' default_server'
if self.ssl:
parts += ' ssl'
return parts
def __str__(self):
return self.to_string()
def __repr__(self):
return "Addr(" + self.__str__() + ")"
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)
def __eq__(self, other):
if isinstance(other, self.__class__):
return (self.tup == other.tup and
return (self.super_eq(other) and
self.ssl == other.ssl and
self.default == other.default)
return False
@ -107,10 +130,12 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods
:ivar bool ssl: SSLEngine on in vhost
:ivar bool enabled: Virtual host is enabled
:ivar list path: The indices into the parsed file used to access
the server block defining the vhost
"""
def __init__(self, filep, addrs, ssl, enabled, names, raw):
def __init__(self, filep, addrs, ssl, enabled, names, raw, path):
# pylint: disable=too-many-arguments
"""Initialize a VH."""
self.filep = filep
@ -119,6 +144,7 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods
self.ssl = ssl
self.enabled = enabled
self.raw = raw
self.path = path
def __str__(self):
addr_str = ", ".join(str(addr) for addr in self.addrs)
@ -137,6 +163,37 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods
return (self.filep == other.filep and
list(self.addrs) == list(other.addrs) and
self.names == other.names and
self.ssl == other.ssl and self.enabled == other.enabled)
self.ssl == other.ssl and
self.enabled == other.enabled and
self.path == other.path)
return False
def has_redirect(self):
"""Determine if this vhost has a redirecting statement
"""
for directive_name in REDIRECT_DIRECTIVES:
found = _find_directive(self.raw, directive_name)
if found is not None:
return True
return False
def contains_list(self, test):
"""Determine if raw server block contains test list at top level
"""
for i in xrange(0, len(self.raw) - len(test)):
if self.raw[i:i + len(test)] == test:
return True
return False
def _find_directive(directives, directive_name):
"""Find a directive of type directive_name in directives
"""
if not directives or isinstance(directives, str) or len(directives) == 0:
return None
if directives[0] == directive_name:
return directives
matches = (_find_directive(line, directive_name) for line in directives)
return next((m for m in matches if m is not None), None)

View file

@ -1,8 +1,7 @@
ssl_session_cache shared:SSL:1m;
ssl_session_cache shared:le_nginx_SSL:1m;
ssl_session_timeout 1440m;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
# Using list of ciphers from "Bulletproof SSL and TLS"
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256 ECDHE-ECDSA-AES256-GCM-SHA384 ECDHE-ECDSA-AES128-SHA ECDHE-ECDSA-AES256-SHA ECDHE-ECDSA-AES128-SHA256 ECDHE-ECDSA-AES256-SHA384 ECDHE-RSA-AES128-GCM-SHA256 ECDHE-RSA-AES256-GCM-SHA384 ECDHE-RSA-AES128-SHA ECDHE-RSA-AES128-SHA256 ECDHE-RSA-AES256-SHA384 DHE-RSA-AES128-GCM-SHA256 DHE-RSA-AES256-GCM-SHA384 DHE-RSA-AES128-SHA DHE-RSA-AES256-SHA DHE-RSA-AES128-SHA256 DHE-RSA-AES256-SHA256 EDH-RSA-DES-CBC3-SHA";

View file

@ -82,6 +82,43 @@ class NginxParser(object):
else:
return path
def _build_addr_to_ssl(self):
"""Builds a map from address to whether it listens on ssl in any server block
"""
servers = self._get_raw_servers()
addr_to_ssl = {}
for filename in servers:
for server, _ in servers[filename]:
# Parse the server block to save addr info
parsed_server = _parse_server_raw(server)
for addr in parsed_server['addrs']:
addr_tuple = addr.normalized_tuple()
if addr_tuple not in addr_to_ssl:
addr_to_ssl[addr_tuple] = addr.ssl
addr_to_ssl[addr_tuple] = addr.ssl or addr_to_ssl[addr_tuple]
return addr_to_ssl
def _get_raw_servers(self):
# pylint: disable=cell-var-from-loop
"""Get a map of unparsed all server blocks
"""
servers = {}
for filename in self.parsed:
tree = self.parsed[filename]
servers[filename] = []
srv = servers[filename] # workaround undefined loop var in lambdas
# Find all the server blocks
_do_for_subarray(tree, lambda x: x[0] == ['server'],
lambda x, y: srv.append((x[1], y)))
# Find 'include' statements in server blocks and append their trees
for i, (server, path) in enumerate(servers[filename]):
new_server = self._get_included_directives(server)
servers[filename][i] = (new_server, path)
return servers
def get_vhosts(self):
# pylint: disable=cell-var-from-loop
"""Gets list of all 'virtual hosts' found in Nginx configuration.
@ -94,38 +131,37 @@ class NginxParser(object):
"""
enabled = True # We only look at enabled vhosts for now
servers = self._get_raw_servers()
vhosts = []
servers = {}
for filename in self.parsed:
tree = self.parsed[filename]
servers[filename] = []
srv = servers[filename] # workaround undefined loop var in lambdas
# Find all the server blocks
_do_for_subarray(tree, lambda x: x[0] == ['server'],
lambda x: srv.append(x[1]))
# Find 'include' statements in server blocks and append their trees
for i, server in enumerate(servers[filename]):
new_server = self._get_included_directives(server)
servers[filename][i] = new_server
for filename in servers:
for server in servers[filename]:
for server, path in servers[filename]:
# Parse the server block into a VirtualHost object
parsed_server = parse_server(server)
parsed_server = _parse_server_raw(server)
vhost = obj.VirtualHost(filename,
parsed_server['addrs'],
parsed_server['ssl'],
enabled,
parsed_server['names'],
server)
server,
path)
vhosts.append(vhost)
self._update_vhosts_addrs_ssl(vhosts)
return vhosts
def _update_vhosts_addrs_ssl(self, vhosts):
"""Update a list of raw parsed vhosts to include global address sslishness
"""
addr_to_ssl = self._build_addr_to_ssl()
for vhost in vhosts:
for addr in vhost.addrs:
addr.ssl = addr_to_ssl[addr.normalized_tuple()]
if addr.ssl:
vhost.ssl = True
def _get_included_directives(self, block):
"""Returns array with the "include" directives expanded out by
concatenating the contents of the included file to the block.
@ -173,6 +209,17 @@ class NginxParser(object):
logger.debug("Could not parse file: %s", item)
return trees
def _parse_ssl_options(self, ssl_options):
if ssl_options is not None:
try:
with open(ssl_options) as _file:
return nginxparser.load(_file).spaced
except IOError:
logger.warn("Missing NGINX TLS options file: %s", ssl_options)
except pyparsing.ParseBaseException:
logger.debug("Could not parse file: %s", ssl_options)
return []
def _set_locations(self, ssl_options):
"""Set default location for directives.
@ -192,7 +239,7 @@ class NginxParser(object):
name = default
return {"root": root, "default": default, "listen": listen,
"name": name, "ssl_options": ssl_options}
"name": name, "ssl_options": self._parse_ssl_options(ssl_options)}
def _find_config_root(self):
"""Find the Nginx Configuration Root file."""
@ -229,42 +276,39 @@ class NginxParser(object):
except IOError:
logger.error("Could not open file for writing: %s", filename)
def _has_server_names(self, entry, names):
"""Checks if a server block has the given set of server_names. This
is the primary way of identifying server blocks in the configurator.
Returns false if 'entry' doesn't look like a server block at all.
def parse_server(self, server):
"""Parses a list of server directives, accounting for global address sslishness.
..todo :: Doesn't match server blocks whose server_name directives are
split across multiple conf files.
:param list server: list of directives in a server block
:rtype: dict
"""
addr_to_ssl = self._build_addr_to_ssl()
parsed_server = _parse_server_raw(server)
_apply_global_addr_ssl(addr_to_ssl, parsed_server)
return parsed_server
:param list entry: The block to search
:param set names: The names to match
def has_ssl_on_directive(self, vhost):
"""Does vhost have ssl on for all ports?
:param :class:`~certbot_nginx.obj.VirtualHost` vhost: The vhost in question
:returns: True if 'ssl on' directive is included
:rtype: bool
"""
if len(names) == 0:
# Nothing to identify blocks with
return False
server = vhost.raw
for directive in server:
if not directive or len(directive) < 2:
continue
elif directive[0] == 'ssl' and directive[1] == 'on':
return True
if not isinstance(entry, list):
# Can't be a server block
return False
return False
new_entry = self._get_included_directives(entry)
server_names = set()
for item in new_entry:
if not isinstance(item, list):
# Can't be a server block
return False
def add_server_directives(self, vhost, directives, replace):
"""Add or replace directives in the server block identified by vhost.
if len(item) > 0 and item[0] == 'server_name':
server_names.update(_get_servernames(item[1]))
return server_names == names
def add_server_directives(self, filename, names, directives,
replace):
"""Add or replace directives in the first server block with names.
This method modifies vhost to be fully consistent with the new directives.
..note :: If replace is True, this raises a misconfiguration error
if the directive does not already exist.
@ -274,60 +318,34 @@ class NginxParser(object):
..todo :: Doesn't match server blocks whose server_name directives are
split across multiple conf files.
:param str filename: The absolute filename of the config file
:param set names: The server_name to match
:param :class:`~certbot_nginx.obj.VirtualHost` vhost: The vhost
whose information we use to match on
:param list directives: The directives to add
:param bool replace: Whether to only replace existing directives
"""
filename = vhost.filep
try:
_do_for_subarray(self.parsed[filename],
lambda x: self._has_server_names(x, names),
lambda x: _add_directives(x, directives, replace))
result = self.parsed[filename]
for index in vhost.path:
result = result[index]
if not isinstance(result, list) or len(result) != 2:
raise errors.MisconfigurationError("Not a server block.")
result = result[1]
_add_directives(result, directives, replace)
# update vhost based on new directives
new_server = self._get_included_directives(result)
parsed_server = self.parse_server(new_server)
vhost.addrs = parsed_server['addrs']
vhost.ssl = parsed_server['ssl']
vhost.names = parsed_server['names']
vhost.raw = new_server
except errors.MisconfigurationError as err:
raise errors.MisconfigurationError("Problem in %s: %s" % (filename, err.message))
def add_http_directives(self, filename, directives):
"""Adds directives to the first encountered HTTP block in filename.
We insert new directives at the top of the block to work around
https://trac.nginx.org/nginx/ticket/810: If the first server block
doesn't enable OCSP stapling, stapling is broken for all blocks.
:param str filename: The absolute filename of the config file
:param list directives: The directives to add
"""
_do_for_subarray(self.parsed[filename],
lambda x: x[0] == ['http'],
lambda x: x[1].insert(0, directives))
def get_all_certs_keys(self):
"""Gets all certs and keys in the nginx config.
:returns: list of tuples with form [(cert, key, path)]
cert - str path to certificate file
key - str path to associated key file
path - File path to configuration file.
:rtype: set
"""
c_k = set()
vhosts = self.get_vhosts()
for vhost in vhosts:
tup = [None, None, vhost.filep]
if vhost.ssl:
for directive in vhost.raw:
if directive[0] == 'ssl_certificate':
tup[0] = directive[1]
elif directive[0] == 'ssl_certificate_key':
tup[1] = directive[1]
if tup[0] is not None and tup[1] is not None:
c_k.add(tuple(tup))
return c_k
def _do_for_subarray(entry, condition, func):
def _do_for_subarray(entry, condition, func, path=None):
"""Executes a function for a subarray of a nested array if it matches
the given condition.
@ -336,12 +354,14 @@ def _do_for_subarray(entry, condition, func):
:param function func: The function to call for each matching item
"""
if path is None:
path = []
if isinstance(entry, list):
if condition(entry):
func(entry)
func(entry, path)
else:
for item in entry:
_do_for_subarray(item, condition, func)
for index, item in enumerate(entry):
_do_for_subarray(item, condition, func, path + [index])
def get_best_match(target_name, names):
@ -460,35 +480,6 @@ def _get_servernames(names):
names = re.sub(whitespace_re, ' ', names)
return names.split(' ')
def parse_server(server):
"""Parses a list of server directives.
:param list server: list of directives in a server block
:rtype: dict
"""
parsed_server = {'addrs': set(),
'ssl': False,
'names': set()}
for directive in server:
if not directive:
continue
if directive[0] == 'listen':
addr = obj.Addr.fromstring(directive[1])
parsed_server['addrs'].add(addr)
if not parsed_server['ssl'] and addr.ssl:
parsed_server['ssl'] = True
elif directive[0] == 'server_name':
parsed_server['names'].update(
_get_servernames(directive[1]))
elif directive[0] == 'ssl' and directive[1] == 'on':
parsed_server['ssl'] = True
return parsed_server
def _add_directives(block, directives, replace):
"""Adds or replaces directives in a config block.
@ -506,8 +497,30 @@ def _add_directives(block, directives, replace):
"""
for directive in directives:
_add_directive(block, directive, replace)
if block and '\n' not in block[-1]: # could be " \n " or ["\n"] !
block.append(nginxparser.UnspacedList('\n'))
REPEATABLE_DIRECTIVES = set(['server_name', 'listen', 'include'])
COMMENT = ' managed by Certbot'
COMMENT_BLOCK = [' ', '#', COMMENT]
def _comment_directive(block, location):
"""Add a comment to the end of the line at location."""
next_entry = block[location + 1] if location + 1 < len(block) else None
if isinstance(next_entry, list) and next_entry:
if len(next_entry) >= 2 and next_entry[-2] == "#" and COMMENT in next_entry[-1]:
return
elif isinstance(next_entry, nginxparser.UnspacedList):
next_entry = next_entry.spaced[0]
else:
next_entry = next_entry[0]
block.insert(location + 1, COMMENT_BLOCK[:])
if next_entry is not None and "\n" not in next_entry:
block.insert(location + 2, '\n')
repeatable_directives = set(['server_name', 'listen', 'include'])
def _add_directive(block, directive, replace):
"""Adds or replaces a single directive in a config block.
@ -516,37 +529,75 @@ def _add_directive(block, directive, replace):
"""
directive = nginxparser.UnspacedList(directive)
if len(directive) == 0:
# whitespace
if len(directive) == 0 or directive[0] == '#':
# whitespace or comment
block.append(directive)
return
location = -1
# Find the index of a config line where the name of the directive matches
# the name of the directive we want to add.
for index, line in enumerate(block):
if len(line) > 0 and line[0] == directive[0]:
location = index
break
# the name of the directive we want to add. If no line exists, use None.
location = next((index for index, line in enumerate(block)
if line and line[0] == directive[0]), None)
if replace:
if location == -1:
if location is None:
raise errors.MisconfigurationError(
'expected directive for %s in the Nginx '
'config but did not find it.' % directive[0])
'expected directive for {0} in the Nginx '
'config but did not find it.'.format(directive[0]))
block[location] = directive
_comment_directive(block, location)
else:
# Append directive. Fail if the name is not a repeatable directive name,
# and there is already a copy of that directive with a different value
# in the config file.
directive_name = directive[0]
directive_value = directive[1]
if location != -1 and directive_name.__str__() not in repeatable_directives:
if block[location][1] == directive_value:
# There's a conflict, but the existing value matches the one we
# want to insert, so it's fine.
pass
else:
raise errors.MisconfigurationError(
'tried to insert directive "%s" but found conflicting "%s".' % (
directive, block[location]))
else:
if location is None or (isinstance(directive_name, str) and
directive_name in REPEATABLE_DIRECTIVES):
block.append(directive)
_comment_directive(block, len(block) - 1)
elif block[location][1] != directive_value:
raise errors.MisconfigurationError(
'tried to insert directive "{0}" but found '
'conflicting "{1}".'.format(directive, block[location]))
def _apply_global_addr_ssl(addr_to_ssl, parsed_server):
"""Apply global sslishness information to the parsed server block
"""
for addr in parsed_server['addrs']:
addr.ssl = addr_to_ssl[addr.normalized_tuple()]
if addr.ssl:
parsed_server['ssl'] = True
def _parse_server_raw(server):
"""Parses a list of server directives.
:param list server: list of directives in a server block
:rtype: dict
"""
parsed_server = {'addrs': set(),
'ssl': False,
'names': set()}
apply_ssl_to_all_addrs = False
for directive in server:
if not directive:
continue
if directive[0] == 'listen':
addr = obj.Addr.fromstring(directive[1])
parsed_server['addrs'].add(addr)
if addr.ssl:
parsed_server['ssl'] = True
elif directive[0] == 'server_name':
parsed_server['names'].update(
_get_servernames(directive[1]))
elif directive[0] == 'ssl' and directive[1] == 'on':
parsed_server['ssl'] = True
apply_ssl_to_all_addrs = True
if apply_ssl_to_all_addrs:
for addr in parsed_server['addrs']:
addr.ssl = True
return parsed_server

View file

@ -13,12 +13,16 @@ from acme import messages
from certbot import achallenges
from certbot import errors
from certbot_nginx import obj
from certbot_nginx import parser
from certbot_nginx.tests import util
class NginxConfiguratorTest(util.NginxTest):
"""Test a semi complex vhost configuration."""
_multiprocess_can_split_ = True
def setUp(self):
super(NginxConfiguratorTest, self).setUp()
@ -38,7 +42,9 @@ class NginxConfiguratorTest(util.NginxTest):
def test_prepare(self):
self.assertEqual((1, 6, 2), self.config.version)
self.assertEqual(5, len(self.config.parser.parsed))
self.assertEqual(8, len(self.config.parser.parsed))
# ensure we successfully parsed a file for ssl_options
self.assertTrue(self.config.parser.loc["ssl_options"])
@mock.patch("certbot_nginx.configurator.util.exe_exists")
@mock.patch("certbot_nginx.configurator.subprocess.Popen")
@ -63,13 +69,13 @@ class NginxConfiguratorTest(util.NginxTest):
mock_gethostbyaddr.return_value = ('155.225.50.69.nephoscale.net', [], [])
names = self.config.get_all_names()
self.assertEqual(names, set(
["*.www.foo.com", "somename", "another.alias",
"alias", "localhost", ".example.com", r"~^(www\.)?(example|bar)\.",
"155.225.50.69.nephoscale.net", "*.www.example.com",
"example.*", "www.example.org", "myhost"]))
["155.225.50.69.nephoscale.net", "www.example.org", "another.alias",
"migration.com", "summer.com", "geese.com", "sslon.com",
"globalssl.com", "globalsslsetssl.com"]))
def test_supported_enhancements(self):
self.assertEqual(['redirect'], self.config.supported_enhancements())
self.assertEqual(['redirect', 'staple-ocsp'],
self.config.supported_enhancements())
def test_enhance(self):
self.assertRaises(
@ -81,21 +87,25 @@ class NginxConfiguratorTest(util.NginxTest):
def test_save(self):
filep = self.config.parser.abs_path('sites-enabled/example.com')
mock_vhost = obj.VirtualHost(filep,
None, None, None,
set(['.example.com', 'example.*']),
None, [0])
self.config.parser.add_server_directives(
filep, set(['.example.com', 'example.*']),
mock_vhost,
[['listen', ' ', '5001 ssl']],
replace=False)
self.config.save()
# pylint: disable=protected-access
parsed = self.config.parser._parse_files(filep, override=True)
self.assertEqual([[['server'], [
['listen', '69.50.225.155:9000'],
['listen', '127.0.0.1'],
['server_name', '.example.com'],
['server_name', 'example.*'],
['listen', '5001 ssl']
]]],
self.assertEqual([[['server'],
[['listen', '69.50.225.155:9000'],
['listen', '127.0.0.1'],
['server_name', '.example.com'],
['server_name', 'example.*'],
['listen', '5001 ssl'],
['#', parser.COMMENT]]]],
parsed[0])
def test_choose_vhost(self):
@ -133,42 +143,12 @@ class NginxConfiguratorTest(util.NginxTest):
self.assertEqual(conf_path[name], path)
for name in bad_results:
self.assertEqual(set([name]), self.config.choose_vhost(name).names)
self.assertRaises(errors.MisconfigurationError,
self.config.choose_vhost, name)
def test_more_info(self):
self.assertTrue('nginx.conf' in self.config.more_info())
def test_deploy_cert_stapling(self):
# Choose a version of Nginx greater than 1.3.7 so stapling code gets
# invoked.
self.config.version = (1, 9, 6)
example_conf = self.config.parser.abs_path('sites-enabled/example.com')
self.config.deploy_cert(
"www.example.com",
"example/cert.pem",
"example/key.pem",
"example/chain.pem",
"example/fullchain.pem")
self.config.save()
self.config.parser.load()
generated_conf = self.config.parser.parsed[example_conf]
self.assertTrue(util.contains_at_depth(generated_conf,
['ssl_stapling', 'on'], 2))
self.assertTrue(util.contains_at_depth(generated_conf,
['ssl_stapling_verify', 'on'], 2))
self.assertTrue(util.contains_at_depth(generated_conf,
['ssl_trusted_certificate', 'example/chain.pem'], 2))
def test_deploy_cert_stapling_requires_chain_path(self):
self.config.version = (1, 3, 7)
self.assertRaises(errors.PluginError, self.config.deploy_cert,
"www.example.com",
"example/cert.pem",
"example/key.pem",
None,
"example/fullchain.pem")
def test_deploy_cert_requires_fullchain_path(self):
self.config.version = (1, 3, 1)
self.assertRaises(errors.PluginError, self.config.deploy_cert,
@ -182,8 +162,6 @@ class NginxConfiguratorTest(util.NginxTest):
server_conf = self.config.parser.abs_path('server.conf')
nginx_conf = self.config.parser.abs_path('nginx.conf')
example_conf = self.config.parser.abs_path('sites-enabled/example.com')
# Choose a version of Nginx less than 1.3.7 so stapling code doesn't get
# invoked.
self.config.version = (1, 3, 1)
# Get the default SSL vhost
@ -216,9 +194,9 @@ class NginxConfiguratorTest(util.NginxTest):
['listen', '5001 ssl'],
['ssl_certificate', 'example/fullchain.pem'],
['ssl_certificate_key', 'example/key.pem'],
['include', self.config.parser.loc["ssl_options"]]
]]],
['ssl_certificate_key', 'example/key.pem']] +
util.filter_comments(self.config.parser.loc["ssl_options"])
]],
parsed_example_conf)
self.assertEqual([['server_name', 'somename alias another.alias']],
parsed_server_conf)
@ -234,34 +212,34 @@ class NginxConfiguratorTest(util.NginxTest):
['index', 'index.html index.htm']]],
['listen', '5001 ssl'],
['ssl_certificate', '/etc/nginx/fullchain.pem'],
['ssl_certificate_key', '/etc/nginx/key.pem'],
['include', self.config.parser.loc["ssl_options"]]]],
['ssl_certificate_key', '/etc/nginx/key.pem']] +
util.filter_comments(self.config.parser.loc["ssl_options"])
],
2))
def test_get_all_certs_keys(self):
nginx_conf = self.config.parser.abs_path('nginx.conf')
example_conf = self.config.parser.abs_path('sites-enabled/example.com')
# Get the default SSL vhost
def test_deploy_cert_add_explicit_listen(self):
migration_conf = self.config.parser.abs_path('sites-enabled/migration.com')
self.config.deploy_cert(
"www.example.com",
"example/cert.pem",
"example/key.pem",
"example/chain.pem",
"example/fullchain.pem")
self.config.deploy_cert(
"another.alias",
"/etc/nginx/cert.pem",
"/etc/nginx/key.pem",
"/etc/nginx/chain.pem",
"/etc/nginx/fullchain.pem")
"summer.com",
"summer/cert.pem",
"summer/key.pem",
"summer/chain.pem",
"summer/fullchain.pem")
self.config.save()
self.config.parser.load()
self.assertEqual(set([
('example/fullchain.pem', 'example/key.pem', example_conf),
('/etc/nginx/fullchain.pem', '/etc/nginx/key.pem', nginx_conf),
]), self.config.get_all_certs_keys())
parsed_migration_conf = util.filter_comments(self.config.parser.parsed[migration_conf])
self.assertEqual([['server'],
[
['server_name', 'migration.com'],
['server_name', 'summer.com'],
['listen', '80'],
['listen', '5001 ssl'],
['ssl_certificate', 'summer/fullchain.pem'],
['ssl_certificate_key', 'summer/key.pem']] +
util.filter_comments(self.config.parser.loc["ssl_options"])
],
parsed_migration_conf[0])
@mock.patch("certbot_nginx.configurator.tls_sni_01.NginxTlsSni01.perform")
@mock.patch("certbot_nginx.configurator.NginxConfigurator.restart")
@ -367,15 +345,15 @@ class NginxConfiguratorTest(util.NginxTest):
mock_popen.side_effect = OSError("Can't find program")
self.assertRaises(errors.MisconfigurationError, self.config.restart)
@mock.patch("certbot.util.run_script")
def test_config_test(self, _):
self.config.config_test()
@mock.patch("certbot.util.run_script")
def test_config_test_bad_process(self, mock_run_script):
mock_run_script.side_effect = errors.SubprocessError
self.assertRaises(errors.MisconfigurationError, self.config.config_test)
@mock.patch("certbot.util.run_script")
def test_config_test(self, _):
self.config.config_test()
@mock.patch("certbot.reverter.Reverter.recovery_routine")
def test_recovery_routine_throws_error_from_reverter(self, mock_recovery_routine):
mock_recovery_routine.side_effect = errors.ReverterError("foo")
@ -414,6 +392,8 @@ class NginxConfiguratorTest(util.NginxTest):
OpenSSL.crypto.FILETYPE_PEM, key_file.read())
def test_redirect_enhance(self):
# Test that we successfully add a redirect when there is
# a listen directive
expected = [
['if', '($scheme != "https") '],
[['return', '301 https://$host$request_uri']]
@ -425,5 +405,109 @@ class NginxConfiguratorTest(util.NginxTest):
generated_conf = self.config.parser.parsed[example_conf]
self.assertTrue(util.contains_at_depth(generated_conf, expected, 2))
# Test that we successfully add a redirect when there is
# no listen directive
migration_conf = self.config.parser.abs_path('sites-enabled/migration.com')
self.config.enhance("migration.com", "redirect")
generated_conf = self.config.parser.parsed[migration_conf]
self.assertTrue(util.contains_at_depth(generated_conf, expected, 2))
@mock.patch('certbot_nginx.obj.VirtualHost.contains_list')
@mock.patch('certbot_nginx.obj.VirtualHost.has_redirect')
def test_certbot_redirect_exists(self, mock_has_redirect, mock_contains_list):
# Test that we add no redirect statement if there is already a
# redirect in the block that is managed by certbot
# Has a certbot redirect
mock_has_redirect.return_value = True
mock_contains_list.return_value = True
with mock.patch("certbot_nginx.configurator.logger") as mock_logger:
self.config.enhance("www.example.com", "redirect")
self.assertEqual(mock_logger.info.call_args[0][0],
"Traffic on port %s already redirecting to ssl in %s")
@mock.patch('certbot_nginx.obj.VirtualHost.contains_list')
@mock.patch('certbot_nginx.obj.VirtualHost.has_redirect')
def test_non_certbot_redirect_exists(self, mock_has_redirect, mock_contains_list):
# Test that we add a redirect as a comment if there is already a
# redirect-class statement in the block that isn't managed by certbot
example_conf = self.config.parser.abs_path('sites-enabled/example.com')
# Has a non-Certbot redirect, and has no existing comment
mock_contains_list.return_value = False
mock_has_redirect.return_value = True
with mock.patch("certbot_nginx.configurator.logger") as mock_logger:
self.config.enhance("www.example.com", "redirect")
self.assertEqual(mock_logger.info.call_args[0][0],
"The appropriate server block is already redirecting "
"traffic. To enable redirect anyway, uncomment the "
"redirect lines in %s.")
generated_conf = self.config.parser.parsed[example_conf]
expected = [
['#', ' Redirect non-https traffic to https'],
['#', ' if ($scheme != "https") {'],
['#', ' return 301 https://$host$request_uri;'],
['#', ' } # managed by Certbot']
]
for line in expected:
self.assertTrue(util.contains_at_depth(generated_conf, line, 2))
@mock.patch('certbot_nginx.obj.VirtualHost.contains_list')
@mock.patch('certbot_nginx.obj.VirtualHost.has_redirect')
@mock.patch('certbot_nginx.configurator.NginxConfigurator._has_certbot_redirect_comment')
@mock.patch('certbot_nginx.configurator.NginxConfigurator._add_redirect_block')
def test_redirect_comment_exists(self, mock_add_redirect_block,
mock_has_cb_redirect_comment, mock_has_redirect, mock_contains_list):
# Test that we add nothing if there is a non-Certbot redirect and a
# preexisting comment
# Has a non-Certbot redirect and a comment
mock_has_redirect.return_value = True
mock_contains_list.return_value = False # self._has_certbot_redirect(vhost):
mock_has_cb_redirect_comment.return_value = True
# assert _add_redirect_block not called
with mock.patch("certbot_nginx.configurator.logger") as mock_logger:
self.config.enhance("www.example.com", "redirect")
self.assertFalse(mock_add_redirect_block.called)
self.assertTrue(mock_logger.info.called)
def test_redirect_dont_enhance(self):
# Test that we don't accidentally add redirect to ssl-only block
with mock.patch("certbot_nginx.configurator.logger") as mock_logger:
self.config.enhance("geese.com", "redirect")
self.assertEqual(mock_logger.info.call_args[0][0],
'No matching insecure server blocks listening on port %s found.')
def test_staple_ocsp_bad_version(self):
self.config.version = (1, 3, 1)
self.assertRaises(errors.PluginError, self.config.enhance,
"www.example.com", "staple-ocsp", "chain_path")
def test_staple_ocsp_no_chain_path(self):
self.assertRaises(errors.PluginError, self.config.enhance,
"www.example.com", "staple-ocsp", None)
def test_staple_ocsp_internal_error(self):
self.config.enhance("www.example.com", "staple-ocsp", "chain_path")
# error is raised because the server block has conflicting directives
self.assertRaises(errors.PluginError, self.config.enhance,
"www.example.com", "staple-ocsp", "different_path")
def test_staple_ocsp(self):
chain_path = "example/chain.pem"
self.config.enhance("www.example.com", "staple-ocsp", chain_path)
example_conf = self.config.parser.abs_path('sites-enabled/example.com')
generated_conf = self.config.parser.parsed[example_conf]
self.assertTrue(util.contains_at_depth(
generated_conf,
['ssl_trusted_certificate', 'example/chain.pem'], 2))
self.assertTrue(util.contains_at_depth(
generated_conf, ['ssl_stapling', 'on'], 2))
self.assertTrue(util.contains_at_depth(
generated_conf, ['ssl_stapling_verify', 'on'], 2))
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

@ -1,7 +1,7 @@
"""Test for certbot_nginx.nginxparser."""
import copy
import operator
import os
import tempfile
import unittest
from pyparsing import ParseException
@ -128,44 +128,34 @@ class TestRawNginxParser(unittest.TestCase):
[['root', ' ', 'html'],
['index', ' ', 'index.html index.htm']]]]]))
with open(util.get_data_filename('nginx.new.conf'), 'w') as handle:
dump(parsed, handle)
with open(util.get_data_filename('nginx.new.conf')) as handle:
parsed_new = load(handle)
try:
self.maxDiff = None
self.assertEqual(parsed[0], parsed_new[0])
self.assertEqual(parsed[1:], parsed_new[1:])
finally:
os.unlink(util.get_data_filename('nginx.new.conf'))
with tempfile.TemporaryFile() as f:
dump(parsed, f)
f.seek(0)
parsed_new = load(f)
self.assertEqual(parsed, parsed_new)
def test_comments(self):
with open(util.get_data_filename('minimalistic_comments.conf')) as handle:
parsed = load(handle)
with open(util.get_data_filename('minimalistic_comments.new.conf'), 'w') as handle:
dump(parsed, handle)
with tempfile.TemporaryFile() as f:
dump(parsed, f)
f.seek(0)
parsed_new = load(f)
with open(util.get_data_filename('minimalistic_comments.new.conf')) as handle:
parsed_new = load(handle)
try:
self.assertEqual(parsed, parsed_new)
self.assertEqual(parsed_new, [
['#', " Use bar.conf when it's a full moon!"],
['include', 'foo.conf'],
['#', ' Kilroy was here'],
['check_status'],
[['server'],
[['#', ''],
['#', " Don't forget to open up your firewall!"],
['#', ''],
['listen', '1234'],
['#', ' listen 80;']]],
])
finally:
os.unlink(util.get_data_filename('minimalistic_comments.new.conf'))
self.assertEqual(parsed, parsed_new)
self.assertEqual(parsed_new, [
['#', " Use bar.conf when it's a full moon!"],
['include', 'foo.conf'],
['#', ' Kilroy was here'],
['check_status'],
[['server'],
[['#', ''],
['#', " Don't forget to open up your firewall!"],
['#', ''],
['listen', '1234'],
['#', ' listen 80;']]],
])
def test_issue_518(self):
parsed = loads('if ($http_accept ~* "webp") { set $webp "true"; }')
@ -223,6 +213,26 @@ class TestUnspacedList(unittest.TestCase):
self.assertRaises(IndexError, self.ul2.__getitem__, 2)
self.assertRaises(IndexError, self.ul2.__getitem__, -3)
def test_insert(self):
x = UnspacedList(
[['\n ', 'listen', ' ', '69.50.225.155:9000'],
['\n ', 'listen', ' ', '127.0.0.1'],
['\n ', 'server_name', ' ', '.example.com'],
['\n ', 'server_name', ' ', 'example.*'], '\n',
['listen', ' ', '5001 ssl']])
x.insert(5, "FROGZ")
self.assertEqual(x,
[['listen', '69.50.225.155:9000'], ['listen', '127.0.0.1'],
['server_name', '.example.com'], ['server_name', 'example.*'],
['listen', '5001 ssl'], 'FROGZ'])
self.assertEqual(x.spaced,
[['\n ', 'listen', ' ', '69.50.225.155:9000'],
['\n ', 'listen', ' ', '127.0.0.1'],
['\n ', 'server_name', ' ', '.example.com'],
['\n ', 'server_name', ' ', 'example.*'], '\n',
['listen', ' ', '5001 ssl'],
'FROGZ'])
def test_rawlists(self):
ul3 = copy.deepcopy(self.ul)
ul3.insert(0, "some")

View file

@ -1,5 +1,6 @@
"""Test the helper objects in certbot_nginx.obj."""
import unittest
import itertools
class AddrTest(unittest.TestCase):
@ -55,6 +56,16 @@ class AddrTest(unittest.TestCase):
self.assertEqual(str(self.addr5), "myhost")
self.assertEqual(str(self.addr6), "80 default_server")
def test_to_string(self):
self.assertEqual(self.addr1.to_string(), "192.168.1.1")
self.assertEqual(self.addr2.to_string(), "192.168.1.1:* ssl")
self.assertEqual(self.addr3.to_string(), "192.168.1.1:80")
self.assertEqual(self.addr4.to_string(), "*:80 default_server ssl")
self.assertEqual(self.addr4.to_string(include_default=False), "*:80 ssl")
self.assertEqual(self.addr5.to_string(), "myhost")
self.assertEqual(self.addr6.to_string(), "80 default_server")
self.assertEqual(self.addr6.to_string(include_default=False), "80")
def test_eq(self):
from certbot_nginx.obj import Addr
new_addr1 = Addr.fromstring("192.168.1.1 spdy")
@ -62,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])
@ -77,10 +106,43 @@ class VirtualHostTest(unittest.TestCase):
def setUp(self):
from certbot_nginx.obj import VirtualHost
from certbot_nginx.obj import Addr
raw1 = [
['listen', '69.50.225.155:9000'],
[['if', '($scheme != "https") '],
[['return', '301 https://$host$request_uri']]
],
['#', ' managed by Certbot']
]
self.vhost1 = VirtualHost(
"filep",
set([Addr.fromstring("localhost")]), False, False,
set(['localhost']), [])
set(['localhost']), raw1, [])
raw2 = [
['listen', '69.50.225.155:9000'],
[['if', '($scheme != "https") '],
[['return', '301 https://$host$request_uri']]
]
]
self.vhost2 = VirtualHost(
"filep",
set([Addr.fromstring("localhost")]), False, False,
set(['localhost']), raw2, [])
raw3 = [
['listen', '69.50.225.155:9000'],
['rewrite', '^(.*)$ $scheme://www.domain.com$1 permanent;']
]
self.vhost3 = VirtualHost(
"filep",
set([Addr.fromstring("localhost")]), False, False,
set(['localhost']), raw3, [])
raw4 = [
['listen', '69.50.225.155:9000'],
['server_name', 'return.com']
]
self.vhost4 = VirtualHost(
"filp",
set([Addr.fromstring("localhost")]), False, False,
set(['localhost']), raw4, [])
def test_eq(self):
from certbot_nginx.obj import Addr
@ -88,7 +150,7 @@ class VirtualHostTest(unittest.TestCase):
vhost1b = VirtualHost(
"filep",
set([Addr.fromstring("localhost blah")]), False, False,
set(['localhost']), [])
set(['localhost']), [], [])
self.assertEqual(vhost1b, self.vhost1)
self.assertEqual(str(vhost1b), str(self.vhost1))
@ -100,6 +162,47 @@ class VirtualHostTest(unittest.TestCase):
'enabled: False'])
self.assertEqual(stringified, str(self.vhost1))
def test_has_redirect(self):
self.assertTrue(self.vhost1.has_redirect())
self.assertTrue(self.vhost2.has_redirect())
self.assertTrue(self.vhost3.has_redirect())
self.assertFalse(self.vhost4.has_redirect())
def test_contains_list(self):
from certbot_nginx.obj import VirtualHost
from certbot_nginx.obj import Addr
from certbot_nginx.configurator import TEST_REDIRECT_BLOCK
test_needle = TEST_REDIRECT_BLOCK
test_haystack = [['listen', '80'], ['root', '/var/www/html'],
['index', 'index.html index.htm index.nginx-debian.html'],
['server_name', 'two.functorkitten.xyz'], ['listen', '443 ssl'],
['#', ' managed by Certbot'],
['ssl_certificate', '/etc/letsencrypt/live/two.functorkitten.xyz/fullchain.pem'],
['#', ' managed by Certbot'],
['ssl_certificate_key', '/etc/letsencrypt/live/two.functorkitten.xyz/privkey.pem'],
['#', ' managed by Certbot'],
[['if', '($scheme != "https")'], [['return', '301 https://$host$request_uri']]],
['#', ' managed by Certbot'], []]
vhost_haystack = VirtualHost(
"filp",
set([Addr.fromstring("localhost")]), False, False,
set(['localhost']), test_haystack, [])
test_bad_haystack = [['listen', '80'], ['root', '/var/www/html'],
['index', 'index.html index.htm index.nginx-debian.html'],
['server_name', 'two.functorkitten.xyz'], ['listen', '443 ssl'],
['#', ' managed by Certbot'],
['ssl_certificate', '/etc/letsencrypt/live/two.functorkitten.xyz/fullchain.pem'],
['#', ' managed by Certbot'],
['ssl_certificate_key', '/etc/letsencrypt/live/two.functorkitten.xyz/privkey.pem'],
['#', ' managed by Certbot'],
[['if', '($scheme != "https")'], [['return', '302 https://$host$request_uri']]],
['#', ' managed by Certbot'], []]
vhost_bad_haystack = VirtualHost(
"filp",
set([Addr.fromstring("localhost")]), False, False,
set(['localhost']), test_bad_haystack, [])
self.assertTrue(vhost_haystack.contains_list(test_needle))
self.assertFalse(vhost_bad_haystack.contains_list(test_needle))
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

@ -47,7 +47,10 @@ class NginxParserTest(util.NginxTest):
self.assertEqual(set([nparser.abs_path(x) for x in
['foo.conf', 'nginx.conf', 'server.conf',
'sites-enabled/default',
'sites-enabled/example.com']]),
'sites-enabled/example.com',
'sites-enabled/migration.com',
'sites-enabled/sslon.com',
'sites-enabled/globalssl.com']]),
set(nparser.parsed.keys()))
self.assertEqual([['server_name', 'somename alias another.alias']],
nparser.parsed[nparser.abs_path('server.conf')])
@ -71,7 +74,7 @@ class NginxParserTest(util.NginxTest):
parsed = nparser._parse_files(nparser.abs_path(
'sites-enabled/example.com.test'))
self.assertEqual(3, len(glob.glob(nparser.abs_path('*.test'))))
self.assertEqual(2, len(
self.assertEqual(5, len(
glob.glob(nparser.abs_path('sites-enabled/*.test'))))
self.assertEqual([[['server'], [['listen', '69.50.225.155:9000'],
['listen', '127.0.0.1'],
@ -79,6 +82,40 @@ class NginxParserTest(util.NginxTest):
['server_name', 'example.*']]]],
parsed[0])
def test__do_for_subarray(self):
# pylint: disable=protected-access
mylists = [([[2], [3], [2]], [[0], [2]]),
([[2], [3], [4]], [[0]]),
([[4], [3], [2]], [[2]]),
([], []),
(2, []),
([[[2], [3], [2]], [[2], [3], [2]]],
[[0, 0], [0, 2], [1, 0], [1, 2]]),
([[[0], [3], [2]], [[2], [3], [2]]], [[0, 2], [1, 0], [1, 2]]),
([[[0], [3], [4]], [[2], [3], [2]]], [[1, 0], [1, 2]]),
([[[0], [3], [4]], [[5], [3], [2]]], [[1, 2]]),
([[[0], [3], [4]], [[5], [3], [0]]], [])]
for mylist, result in mylists:
paths = []
parser._do_for_subarray(mylist,
lambda x: isinstance(x, list) and
len(x) >= 1 and
x[0] == 2,
lambda x, y, pts=paths: pts.append(y))
self.assertEqual(paths, result)
def test_get_vhosts_global_ssl(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
vhosts = nparser.get_vhosts()
vhost = obj.VirtualHost(nparser.abs_path('sites-enabled/globalssl.com'),
[obj.Addr('4.8.2.6', '57', True, False)],
True, True, set(['globalssl.com']), [], [0])
globalssl_com = [x for x in vhosts if 'globalssl.com' in x.filep][0]
self.assertEqual(vhost, globalssl_com)
def test_get_vhosts(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
vhosts = nparser.get_vhosts()
@ -88,28 +125,30 @@ class NginxParserTest(util.NginxTest):
False, True,
set(['localhost',
r'~^(www\.)?(example|bar)\.']),
[])
[], [9, 1, 9])
vhost2 = obj.VirtualHost(nparser.abs_path('nginx.conf'),
[obj.Addr('somename', '8080', False, False),
obj.Addr('', '8000', False, False)],
False, True,
set(['somename', 'another.alias', 'alias']),
[])
[], [9, 1, 12])
vhost3 = obj.VirtualHost(nparser.abs_path('sites-enabled/example.com'),
[obj.Addr('69.50.225.155', '9000',
False, False),
obj.Addr('127.0.0.1', '', False, False)],
False, True,
set(['.example.com', 'example.*']), [])
set(['.example.com', 'example.*']), [], [0])
vhost4 = obj.VirtualHost(nparser.abs_path('sites-enabled/default'),
[obj.Addr('myhost', '', False, True)],
False, True, set(['www.example.org']), [])
False, True, set(['www.example.org']),
[], [0])
vhost5 = obj.VirtualHost(nparser.abs_path('foo.conf'),
[obj.Addr('*', '80', True, True)],
True, True, set(['*.www.foo.com',
'*.www.example.com']), [])
'*.www.example.com']),
[], [2, 1, 0])
self.assertEqual(5, len(vhosts))
self.assertEqual(10, len(vhosts))
example_com = [x for x in vhosts if 'example.com' in x.filep][0]
self.assertEqual(vhost3, example_com)
default = [x for x in vhosts if 'default' in x.filep][0]
@ -121,11 +160,34 @@ class NginxParserTest(util.NginxTest):
somename = [x for x in vhosts if 'somename' in x.names][0]
self.assertEqual(vhost2, somename)
def test_has_ssl_on_directive(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
mock_vhost = obj.VirtualHost(None, None, None, None, None,
[['listen', 'myhost default_server'],
['server_name', 'www.example.org'],
[['location', '/'], [['root', 'html'], ['index', 'index.html index.htm']]]
], None)
self.assertFalse(nparser.has_ssl_on_directive(mock_vhost))
mock_vhost.raw = [['listen', '*:80 default_server ssl'],
['server_name', '*.www.foo.com *.www.example.com'],
['root', '/home/ubuntu/sites/foo/']]
self.assertFalse(nparser.has_ssl_on_directive(mock_vhost))
mock_vhost.raw = [['listen', '80 ssl'],
['server_name', '*.www.foo.com *.www.example.com']]
self.assertFalse(nparser.has_ssl_on_directive(mock_vhost))
mock_vhost.raw = [['listen', '80'],
['ssl', 'on'],
['server_name', '*.www.foo.com *.www.example.com']]
self.assertTrue(nparser.has_ssl_on_directive(mock_vhost))
def test_add_server_directives(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
nparser.add_server_directives(nparser.abs_path('nginx.conf'),
set(['localhost',
mock_vhost = obj.VirtualHost(nparser.abs_path('nginx.conf'),
None, None, None,
set(['localhost',
r'~^(www\.)?(example|bar)\.']),
None, [9, 1, 9])
nparser.add_server_directives(mock_vhost,
[['foo', 'bar'], ['\n ', 'ssl_certificate', ' ',
'/etc/ssl/cert.pem']],
replace=False)
@ -133,53 +195,60 @@ class NginxParserTest(util.NginxTest):
dump = nginxparser.dumps(nparser.parsed[nparser.abs_path('nginx.conf')])
self.assertEqual(1, len(re.findall(ssl_re, dump)))
server_conf = nparser.abs_path('server.conf')
names = set(['alias', 'another.alias', 'somename'])
nparser.add_server_directives(server_conf, names,
example_com = nparser.abs_path('sites-enabled/example.com')
names = set(['.example.com', 'example.*'])
mock_vhost.filep = example_com
mock_vhost.names = names
mock_vhost.path = [0]
nparser.add_server_directives(mock_vhost,
[['foo', 'bar'], ['ssl_certificate',
'/etc/ssl/cert2.pem']],
replace=False)
nparser.add_server_directives(server_conf, names, [['foo', 'bar']],
nparser.add_server_directives(mock_vhost, [['foo', 'bar']],
replace=False)
self.assertEqual(nparser.parsed[server_conf],
[['server_name', 'somename alias another.alias'],
['foo', 'bar'],
['ssl_certificate', '/etc/ssl/cert2.pem']
])
from certbot_nginx.parser import COMMENT
self.assertEqual(nparser.parsed[example_com],
[[['server'], [['listen', '69.50.225.155:9000'],
['listen', '127.0.0.1'],
['server_name', '.example.com'],
['server_name', 'example.*'],
['foo', 'bar'],
['#', COMMENT],
['ssl_certificate', '/etc/ssl/cert2.pem'],
['#', COMMENT], [], []
]]])
def test_add_http_directives(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
filep = nparser.abs_path('nginx.conf')
block = [['server'],
[['listen', '80'],
['server_name', 'localhost']]]
nparser.add_http_directives(filep, block)
root = nparser.parsed[filep]
self.assertTrue(util.contains_at_depth(root, ['http'], 1))
self.assertTrue(util.contains_at_depth(root, block, 2))
# Check that our server block got inserted first among all server
# blocks.
http_block = [x for x in root if x[0] == ['http']][0][1]
server_blocks = [x for x in http_block if x[0] == ['server']]
self.assertEqual(server_blocks[0], block)
server_conf = nparser.abs_path('server.conf')
names = set(['alias', 'another.alias', 'somename'])
mock_vhost.filep = server_conf
mock_vhost.names = names
mock_vhost.path = []
self.assertRaises(errors.MisconfigurationError,
nparser.add_server_directives,
mock_vhost,
[['foo', 'bar'],
['ssl_certificate', '/etc/ssl/cert2.pem']],
replace=False)
def test_replace_server_directives(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
target = set(['.example.com', 'example.*'])
filep = nparser.abs_path('sites-enabled/example.com')
mock_vhost = obj.VirtualHost(filep, None, None, None, target, None, [0])
nparser.add_server_directives(
filep, target, [['server_name', 'foobar.com']], replace=True)
mock_vhost, [['server_name', 'foobar.com']], replace=True)
from certbot_nginx.parser import COMMENT
self.assertEqual(
nparser.parsed[filep],
[[['server'], [['listen', '69.50.225.155:9000'],
['listen', '127.0.0.1'],
['server_name', 'foobar.com'],
['server_name', 'example.*'],
['server_name', 'foobar.com'], ['#', COMMENT],
['server_name', 'example.*'], []
]]])
mock_vhost.names = set(['foobar.com', 'example.*'])
self.assertRaises(errors.MisconfigurationError,
nparser.add_server_directives,
filep, set(['foobar.com', 'example.*']),
mock_vhost,
[['ssl_certificate', 'cert.pem']],
replace=True)
@ -216,38 +285,67 @@ class NginxParserTest(util.NginxTest):
self.assertEqual(winner,
parser.get_best_match(target_name, names[i]))
def test_get_all_certs_keys(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
filep = nparser.abs_path('sites-enabled/example.com')
nparser.add_server_directives(filep,
set(['.example.com', 'example.*']),
[['ssl_certificate', 'foo.pem'],
['ssl_certificate_key', 'bar.key'],
['listen', '443 ssl']],
replace=False)
c_k = nparser.get_all_certs_keys()
self.assertEqual(set([('foo.pem', 'bar.key', filep)]), c_k)
def test_comment_directive(self):
# pylint: disable=protected-access
block = nginxparser.UnspacedList([
["\n", "a", " ", "b", "\n"],
["c", " ", "d"],
["\n", "e", " ", "f"]])
from certbot_nginx.parser import _comment_directive, COMMENT_BLOCK
_comment_directive(block, 1)
_comment_directive(block, 0)
self.assertEqual(block.spaced, [
["\n", "a", " ", "b", "\n"],
COMMENT_BLOCK,
"\n",
["c", " ", "d"],
COMMENT_BLOCK,
["\n", "e", " ", "f"]])
def test_parse_server_ssl(self):
server = parser.parse_server([
def test_parse_server_raw_ssl(self):
server = parser._parse_server_raw([ #pylint: disable=protected-access
['listen', '443']
])
self.assertFalse(server['ssl'])
server = parser.parse_server([
server = parser._parse_server_raw([ #pylint: disable=protected-access
['listen', '443 ssl']
])
self.assertTrue(server['ssl'])
server = parser.parse_server([
server = parser._parse_server_raw([ #pylint: disable=protected-access
['listen', '443'], ['ssl', 'off']
])
self.assertFalse(server['ssl'])
server = parser.parse_server([
server = parser._parse_server_raw([ #pylint: disable=protected-access
['listen', '443'], ['ssl', 'on']
])
self.assertTrue(server['ssl'])
def test_parse_server_global_ssl_applied(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
server = nparser.parse_server([
['listen', '443']
])
self.assertTrue(server['ssl'])
def test_ssl_options_should_be_parsed_ssl_directives(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
self.assertEqual(nginxparser.UnspacedList(nparser.loc["ssl_options"]),
[['ssl_session_cache', 'shared:le_nginx_SSL:1m'],
['ssl_session_timeout', '1440m'],
['ssl_protocols', 'TLSv1 TLSv1.1 TLSv1.2'],
['ssl_prefer_server_ciphers', 'on'],
['ssl_ciphers', '"ECDHE-ECDSA-AES128-GCM-SHA256 ECDHE-ECDSA-'+
'AES256-GCM-SHA384 ECDHE-ECDSA-AES128-SHA ECDHE-ECDSA-AES256'+
'-SHA ECDHE-ECDSA-AES128-SHA256 ECDHE-ECDSA-AES256-SHA384'+
' ECDHE-RSA-AES128-GCM-SHA256 ECDHE-RSA-AES256-GCM-SHA384'+
' ECDHE-RSA-AES128-SHA ECDHE-RSA-AES128-SHA256 ECDHE-RSA-'+
'AES256-SHA384 DHE-RSA-AES128-GCM-SHA256 DHE-RSA-AES256-GCM'+
'-SHA384 DHE-RSA-AES128-SHA DHE-RSA-AES256-SHA DHE-RSA-'+
'AES128-SHA256 DHE-RSA-AES256-SHA256 EDH-RSA-DES-CBC3-SHA"']
])
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

@ -0,0 +1,9 @@
server {
server_name globalssl.com;
listen 4.8.2.6:57;
}
server {
server_name globalsslsetssl.com;
listen 4.8.2.6:57 ssl;
}

View file

@ -0,0 +1,19 @@
server {
server_name migration.com;
server_name summer.com;
}
server {
listen 443 ssl;
server_name migration.com;
server_name geese.com;
ssl_certificate cert.pem;
ssl_certificate_key cert.key;
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
}

View file

@ -0,0 +1,6 @@
server {
server_name sslon.com;
ssl on;
ssl_certificate snakeoil.cert;
ssl_certificate_key snakeoil.key;
}

View file

@ -31,7 +31,7 @@ class TlsSniPerformTest(util.NginxTest):
token="\xba\xa9\xda?<m\xaewmx\xea\xad\xadv\xf4\x02\xc9y"
"\x80\xe2_X\t\xe7\xc7\xa4\t\xca\xf7&\x945"
), "pending"),
domain="blah", account_key=account_key),
domain="another.alias", account_key=account_key),
achallenges.KeyAuthorizationAnnotatedChallenge(
challb=acme_util.chall_to_challb(
challenges.TLSSNI01(
@ -39,6 +39,10 @@ class TlsSniPerformTest(util.NginxTest):
"\xeb9\xf1\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4"
), "pending"),
domain="www.example.org", account_key=account_key),
achallenges.KeyAuthorizationAnnotatedChallenge(
challb=acme_util.chall_to_challb(
challenges.TLSSNI01(token="kNdwjxOeX0I_A8DXt9Msmg"), "pending"),
domain="sslon.com", account_key=account_key),
]
def setUp(self):
@ -100,7 +104,7 @@ class TlsSniPerformTest(util.NginxTest):
sni_responses = self.sni.perform()
self.assertEqual(mock_setup_cert.call_count, 3)
self.assertEqual(mock_setup_cert.call_count, 4)
for index, achall in enumerate(self.achalls):
self.assertEqual(
@ -109,11 +113,11 @@ class TlsSniPerformTest(util.NginxTest):
http = self.sni.configurator.parser.parsed[
self.sni.configurator.parser.loc["root"]][-1]
self.assertTrue(['include', self.sni.challenge_conf] in http[1])
self.assertTrue(
util.contains_at_depth(http, ['server_name', 'blah'], 3))
self.assertFalse(
util.contains_at_depth(http, ['server_name', 'another.alias'], 3))
self.assertEqual(len(sni_responses), 3)
for i in xrange(3):
self.assertEqual(len(sni_responses), 4)
for i in xrange(4):
self.assertEqual(sni_responses[i], acme_responses[i])
def test_mod_config(self):
@ -123,6 +127,7 @@ class TlsSniPerformTest(util.NginxTest):
v_addr1 = [obj.Addr("69.50.225.155", "9000", True, False),
obj.Addr("127.0.0.1", "", False, False)]
v_addr2 = [obj.Addr("myhost", "", False, True)]
v_addr2_print = [obj.Addr("myhost", "", False, False)]
ll_addr = [v_addr1, v_addr2]
self.sni._mod_config(ll_addr) # pylint: disable=protected-access
@ -142,7 +147,7 @@ class TlsSniPerformTest(util.NginxTest):
response = self.achalls[0].response(self.account_key)
else:
response = self.achalls[2].response(self.account_key)
self.assertEqual(vhost.addrs, set(v_addr2))
self.assertEqual(vhost.addrs, set(v_addr2_print))
self.assertEqual(vhost.names, set([response.z_domain]))
self.assertEqual(len(vhs), 2)

View file

@ -11,12 +11,13 @@ from acme import jose
from certbot import configuration
from certbot.tests import test_util
from certbot.tests import util as test_util
from certbot.plugins import common
from certbot_nginx import constants
from certbot_nginx import configurator
from certbot_nginx import nginxparser
class NginxTest(unittest.TestCase): # pylint: disable=too-few-public-methods
@ -84,14 +85,20 @@ def filter_comments(tree):
def traverse(tree):
"""Generator dropping comment nodes"""
for entry in tree:
key, values = entry
# key, values = entry
spaceless = [e for e in entry if not nginxparser.spacey(e)]
if spaceless:
key = spaceless[0]
values = spaceless[1] if len(spaceless) > 1 else None
else:
key = values = ""
if isinstance(key, list):
new = copy.deepcopy(entry)
new[1] = filter_comments(values)
yield new
else:
if key != '#':
yield entry
if key != '#' and spaceless:
yield spaceless
return list(traverse(tree))

View file

@ -47,7 +47,7 @@ class NginxTlsSni01(common.TLSSNI01):
return []
addresses = []
default_addr = "{0} default_server ssl".format(
default_addr = "{0} ssl".format(
self.configurator.config.tls_sni_01_port)
for achall in self.achalls:
@ -59,12 +59,10 @@ class NginxTlsSni01(common.TLSSNI01):
achall.domain)
return None
for addr in vhost.addrs:
if addr.default:
addresses.append([obj.Addr.fromstring(default_addr)])
break
else:
if vhost.addrs:
addresses.append(list(vhost.addrs))
else:
addresses.append([obj.Addr.fromstring(default_addr)])
# Create challenge certs
responses = [self._setup_challenge_cert(x) for x in self.achalls]
@ -91,17 +89,17 @@ class NginxTlsSni01(common.TLSSNI01):
# Add the 'include' statement for the challenges if it doesn't exist
# already in the main config
included = False
include_directive = ['include', ' ', self.challenge_conf]
include_directive = ['\n', 'include', ' ', self.challenge_conf]
root = self.configurator.parser.loc["root"]
bucket_directive = ['server_names_hash_bucket_size', ' ', '128']
bucket_directive = ['\n', 'server_names_hash_bucket_size', ' ', '128']
main = self.configurator.parser.parsed[root]
for key, body in main:
if key == ['http']:
found_bucket = False
for k, _ in body:
if k == bucket_directive[0]:
if k == bucket_directive[1]:
found_bucket = True
if not found_bucket:
body.insert(0, bucket_directive)
@ -141,11 +139,10 @@ class NginxTlsSni01(common.TLSSNI01):
document_root = os.path.join(
self.configurator.config.work_dir, "tls_sni_01_page")
block = [['listen', ' ', str(addr)] for addr in addrs]
block = [['listen', ' ', addr.to_string(include_default=False)] for addr in addrs]
block.extend([['server_name', ' ',
achall.response(achall.account_key).z_domain],
['include', ' ', self.configurator.parser.loc["ssl_options"]],
# access and error logs necessary for
# integration testing (non-root)
['access_log', ' ', os.path.join(
@ -154,6 +151,6 @@ class NginxTlsSni01(common.TLSSNI01):
self.configurator.config.work_dir, 'error.log')],
['ssl_certificate', ' ', self.get_cert_path(achall)],
['ssl_certificate_key', ' ', self.get_key_path(achall)],
[['location', ' ', '/'], [['root', ' ', document_root]]]])
[['location', ' ', '/'], [['root', ' ', document_root]]]] +
self.configurator.parser.loc["ssl_options"])
return [['server'], block]

View file

@ -4,7 +4,7 @@ from setuptools import setup
from setuptools import find_packages
version = '0.9.0.dev0'
version = '0.10.0.dev0'
# Please update tox.ini when modifying dependency version requirements
install_requires = [

View file

@ -52,6 +52,7 @@ http {
listen 8081;
# IPv6.
listen [::]:8081 default ipv6only=on;
server_name nginx.wtf;
root $root/webroot;

View file

@ -1,4 +1,4 @@
"""Certbot client."""
# version number like 1.2.3a0, must have at least 2 parts, like 1.2
__version__ = '0.9.0.dev0'
__version__ = '0.10.0.dev0'

View file

@ -8,6 +8,7 @@ import socket
from cryptography.hazmat.primitives import serialization
import pyrfc3339
import pytz
import six
import zope.component
from acme import fields as acme_fields
@ -108,7 +109,7 @@ class AccountMemoryStorage(interfaces.AccountStorage):
self.accounts = initial_accounts if initial_accounts is not None else {}
def find_all(self):
return self.accounts.values()
return list(six.itervalues(self.accounts))
def save(self, account):
if account.id in self.accounts:

View file

@ -33,14 +33,18 @@ class AuthHandler(object):
and values are :class:`acme.messages.AuthorizationResource`
:ivar list achalls: DV challenges in the form of
:class:`certbot.achallenges.AnnotatedChallenge`
:ivar list pref_challs: sorted user specified preferred challenges
in the form of subclasses of :class:`acme.challenges.Challenge`
with the most preferred challenge listed first
"""
def __init__(self, auth, acme, account):
def __init__(self, auth, acme, account, pref_challs):
self.auth = auth
self.acme = acme
self.account = account
self.authzr = dict()
self.pref_challs = pref_challs
# List must be used to keep responses straight.
self.achalls = []
@ -244,9 +248,18 @@ class AuthHandler(object):
:param str domain: domain for which you are requesting preferences
"""
# Make sure to make a copy...
chall_prefs = []
chall_prefs.extend(self.auth.get_chall_pref(domain))
# Make sure to make a copy...
plugin_pref = self.auth.get_chall_pref(domain)
if self.pref_challs:
chall_prefs.extend(pref for pref in self.pref_challs
if pref in plugin_pref)
if chall_prefs:
return chall_prefs
raise errors.AuthorizationError(
"None of the preferred challenges "
"are supported by the selected plugin")
chall_prefs.extend(plugin_pref)
return chall_prefs
def _cleanup_challenges(self, achall_list=None):
@ -424,9 +437,6 @@ def _report_no_chall_path():
raise errors.AuthorizationError(msg)
_ACME_PREFIX = "urn:acme:error:"
_ERROR_HELP_COMMON = (
"To fix these errors, please make sure that your domain name was entered "
"correctly and the DNS A record(s) for that domain contain(s) the "
@ -488,9 +498,10 @@ def _generate_failed_chall_msg(failed_achalls):
:rtype: str
"""
typ = failed_achalls[0].error.typ
if typ.startswith(_ACME_PREFIX):
typ = typ[len(_ACME_PREFIX):]
error = failed_achalls[0].error
typ = error.typ
if messages.is_acme_error(error):
typ = error.code
msg = ["The following errors were reported by the server:"]
for achall in failed_achalls:

252
certbot/cert_manager.py Normal file
View file

@ -0,0 +1,252 @@
"""Tools for managing certificates."""
import datetime
import logging
import os
import pytz
import traceback
import zope.component
from certbot import errors
from certbot import interfaces
from certbot import ocsp
from certbot import storage
from certbot import util
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.
Use the information in the config file to make symlinks point to
the correct archive directory.
.. note:: This assumes that the installation is using a Reverter object.
:param config: Configuration.
:type config: :class:`certbot.configuration.NamespaceConfig`
"""
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.configuration.NamespaceConfig`
"""
disp = zope.component.getUtility(interfaces.IDisplay)
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", force_interactive=True)
if code != display_util.OK or not new_certname:
raise errors.Error("User ended interaction.")
lineage = lineage_for_certname(config, certname)
if not lineage:
raise errors.ConfigurationError("No existing certificate with name "
"{0} found.".format(certname))
storage.rename_renewal_config(certname, new_certname, config)
disp.notification("Successfully renamed {0} to {1}."
.format(certname, new_certname), pause=False)
def certificates(config):
"""Display information about certs configured with Certbot
:param config: Configuration.
:type config: :class:`certbot.configuration.NamespaceConfig`
"""
parsed_certs = []
parse_failures = []
for renewal_file in storage.renewal_conf_files(config):
try:
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 "
"unexpected error: %s. Skipping.", renewal_file, e)
logger.debug("Traceback was:\n%s", traceback.format_exc())
parse_failures.append(renewal_file)
# Describe all the certs
_describe_certs(config, parsed_certs, parse_failures)
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)
###################
# Public Helpers
###################
def lineage_for_certname(config, certname):
"""Find a lineage object with name certname."""
def update_cert_for_name_match(candidate_lineage, rv):
"""Return cert if it has name certname, else return rv
"""
matching_lineage_name_cert = rv
if candidate_lineage.lineagename == certname:
matching_lineage_name_cert = candidate_lineage
return matching_lineage_name_cert
return _search_lineages(config, update_cert_for_name_match, None)
def domains_for_certname(config, certname):
"""Find the domains in the cert with name certname."""
def update_domains_for_name_match(candidate_lineage, rv):
"""Return domains if certname matches, else return rv
"""
matching_domains = rv
if candidate_lineage.lineagename == certname:
matching_domains = candidate_lineage.names()
return matching_domains
return _search_lineages(config, update_domains_for_name_match, None)
def find_duplicative_certs(config, domains):
"""Find existing certs that duplicate the request."""
def update_certs_for_domain_matches(candidate_lineage, rv):
"""Return cert as identical_names_cert if it matches,
or subset_names_cert if it matches as subset
"""
# TODO: Handle these differently depending on whether they are
# expired or still valid?
identical_names_cert, subset_names_cert = rv
candidate_names = set(candidate_lineage.names())
if candidate_names == set(domains):
identical_names_cert = candidate_lineage
elif candidate_names.issubset(set(domains)):
# This logic finds and returns the largest subset-names cert
# in the case where there are several available.
if subset_names_cert is None:
subset_names_cert = candidate_lineage
elif len(candidate_names) > len(subset_names_cert.names()):
subset_names_cert = candidate_lineage
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

View file

@ -11,6 +11,8 @@ import sys
import configargparse
import six
from acme import challenges
import certbot
from certbot import constants
@ -49,47 +51,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
plugins Display information about installed plugins
# 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
--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, install, register, nginx, apache, standalone, webroot,
etc.)
all, automation, commands, paths, security, testing, or any of the
subcommands or plugins (certonly, renew, install, register, nginx,
apache, standalone, webroot, etc.)
"""
@ -135,19 +146,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:
@ -303,6 +301,103 @@ 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"
}),
("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.
@ -313,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,
@ -320,25 +416,18 @@ class HelpfulArgumentParser(object):
"install": main.install, "plugins": main.plugins_cmd,
"register": main.register, "renew": main.renew,
"revoke": main.revoke, "rollback": main.rollback,
"everything": main.run}
"everything": main.run, "update_symlinks": main.update_symlinks,
"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(
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)
@ -347,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.
@ -374,9 +522,18 @@ class HelpfulArgumentParser(object):
# Do any post-parsing homework here
if self.verb == "renew" and not parsed_args.dialog_mode:
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)
@ -386,18 +543,6 @@ class HelpfulArgumentParser(object):
if parsed_args.must_staple:
parsed_args.staple = True
# Avoid conflicting args
conficting_args = ["quiet", "noninteractive_mode", "text_mode"]
if parsed_args.dialog_mode:
for arg in conficting_args:
if getattr(parsed_args, arg):
raise errors.Error(
("Conflicting values for displayer."
" {0} conflicts with dialog_mode").format(arg)
)
elif parsed_args.verbose_count > flag_default("verbose_count"):
parsed_args.text_mode = True
if parsed_args.validate_hooks:
hooks.validate_hooks(parsed_args)
@ -502,16 +647,25 @@ class HelpfulArgumentParser(object):
pass
return True
def add(self, topic, *args, **kwargs):
def add(self, topics, *args, **kwargs):
"""Add a new command line argument.
:param str: help topic this should be listed under, can be None for
"always documented"
:param topics: str or [str] help topic(s) this should be listed under,
or None for "always documented". The first entry
determines where the flag lives in the "--help all"
output (None -> "optional arguments").
:param list *args: the names of this argument flag
:param dict **kwargs: various argparse settings for this argument
"""
if isinstance(topics, list):
# if this flag can be listed in multiple sections, try to pick the one
# that the user has asked for help about
topic = self.help_arg if self.help_arg in topics else topics[0]
else:
topic = topics # there's only one
if self.detect_defaults:
kwargs = self.modify_kwargs_for_default_detection(**kwargs)
@ -561,7 +715,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
@ -569,6 +723,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`
@ -576,6 +732,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)
@ -587,7 +746,8 @@ class HelpfulArgumentParser(object):
"""
for name, plugin_ep in six.iteritems(plugins):
parser_or_group = self.add_group(name, description=plugin_ep.description)
parser_or_group = self.add_group(name,
description=plugin_ep.long_description)
plugin_ep.plugin_cls.inject_parser_options(parser_or_group, name)
def determine_help_topics(self, chosen_topic):
@ -612,6 +772,21 @@ class HelpfulArgumentParser(object):
else:
return dict([(t, t == chosen_topic) for t in self.help_topics])
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 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
"""Returns parsed command line arguments.
@ -627,6 +802,7 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
# pylint: disable=too-many-statements
helpful = HelpfulArgumentParser(args, plugins, detect_defaults)
_add_all_groups(helpful)
# --help is automatically provided by argparse
helpful.add(
@ -636,18 +812,41 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
"e.g. -vvv.")
helpful.add(
None, "-t", "--text", dest="text_mode", action="store_true",
help="Use the text output instead of the curses UI.")
help=argparse.SUPPRESS)
helpful.add(
None, "-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, "--dialog", dest="dialog_mode", action="store_true",
help="Run using dialog")
[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, "--dry-run", action="store_true", dest="dry_run",
[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. (default: Ask)")
helpful.add(
[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'. "
"When creating a new certificate, specifies the new certificate's name.")
helpful.add(
["rename", "manage"],
"--updated-cert-name", dest="new_certname",
metavar="NEW_CERTNAME", default=None,
help="New name for the certificate. Must be a valid filename.")
helpful.add(
[None, "testing", "renew", "certonly"],
"--dry-run", action="store_true", dest="dry_run",
help="Perform a test run of the client, obtaining test (invalid) certs"
" but not saving them to disk. This can currently only be used"
" with the 'certonly' and 'renew' subcommands. \nNote: Although --dry-run"
@ -659,7 +858,7 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
" if they are defined because they may be necessary to accurately simulate"
" renewal. --renew-hook commands are not called.")
helpful.add(
None, "--register-unsafely-without-email", action="store_true",
["register", "automation"], "--register-unsafely-without-email", action="store_true",
help="Specifying this flag enables registering an account with no "
"email address. This is strongly discouraged, because in the "
"event of key loss or account compromise you will irrevocably "
@ -673,42 +872,39 @@ 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"))
# positional arg shadows --domains, instead of appending, and
# --domains is useful, because it can be stored in config
#for subparser in parser_run, parser_auth, parser_install:
# subparser.add_argument("domains", nargs="*", metavar="domain")
helpful.add(None, "-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.")
helpful.add_group(
"automation",
description="Arguments for automating execution & other tweaks")
helpful.add(["register", "automation"], "-m", "--email", help=config_help("email"))
helpful.add(
"automation", "--keep-until-expiring", "--keep", "--reinstall",
["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__),
help="show program's version number and exit")
helpful.add(
"automation", "--force-renewal", "--renew-by-default",
["automation", "renew"],
"--force-renewal", "--renew-by-default",
action="store_true", dest="renew_by_default", help="If a certificate "
"already exists for the requested domains, renew it now, "
"regardless of whether it is near expiry. (Often "
"--keep-until-expiring is more appropriate). Also implies "
"--expand.")
helpful.add(
"automation", "--allow-subset-of-names", action="store_true",
"automation", "--renew-with-new-domains",
action="store_true", dest="renew_with_new_domains", help="If a "
"certificate already exists for the requested certificate name "
"but does not match the requested domains, renew it now, "
"regardless of whether it is near expiry.")
helpful.add(
["automation", "renew", "certonly"],
"--allow-subset-of-names", action="store_true",
help="When performing domain validation, do not consider it a failure "
"if authorizations can not be obtained for a strict subset of "
"the requested domains. This may be useful for allowing renewals for "
@ -716,7 +912,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")
@ -730,16 +926,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", "-q", "--quiet", dest="quiet", action="store_true",
["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.")
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!")
# 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'
' to --server ' + constants.STAGING_URI)
helpful.add(
"testing", "--debug", action="store_true",
help="Show tracebacks in case of errors, and allow certbot-auto "
@ -749,18 +946,17 @@ 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(
"testing", "--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(
"testing", "--http-01-port", type=int, dest="http01_port",
["testing", "standalone", "manual"], "--http-01-port", type=int,
dest="http01_port",
default=flag_default("http01_port"), help=config_help("http01_port"))
helpful.add(
"testing", "--break-my-certs", action="store_true",
help="Be willing to replace or renew valid certs with invalid "
"(testing/staging) certs")
helpful.add_group(
"security", description="Security parameters & server settings")
helpful.add(
"security", "--rsa-key-size", type=int, metavar="N",
default=flag_default("rsa_key_size"), help=config_help("rsa_key_size"))
@ -776,11 +972,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."
@ -788,8 +984,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\""
@ -797,9 +992,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"
@ -807,55 +1000,56 @@ 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_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(
["manual", "standalone", "certonly", "renew"],
"--preferred-challenges", dest="pref_challs",
action=_PrefChallAction, default=[],
help='A sorted, comma delimited list of the preferred challenge to '
'use during authorization with the most preferred challenge '
'listed first (Eg, "dns" or "tls-sni-01,http,dns"). '
'Not all plugins support all challenges. See '
'https://certbot.eff.org/docs/using.html#plugins for details. '
'ACME Challenges are versioned, but if you pick "http" rather '
'than "http-01", Certbot will select the latest version '
'automatically.')
helpful.add(
"renew", "--pre-hook",
help="Command to be run in a shell before obtaining any certificates. Intended"
" primarily for renewal, where it can be used to temporarily shut down a"
" webserver that might conflict with the standalone plugin. This will "
" only be called if a certificate is actually to be obtained/renewed. ")
help="Command to be run in a shell before obtaining any certificates."
" Intended primarily for renewal, where it can be used to temporarily"
" shut down a webserver that might conflict with the standalone"
" plugin. This will only be called if a certificate is actually to be"
" obtained/renewed.")
helpful.add(
"renew", "--post-hook",
help="Command to be run in a shell after attempting to obtain/renew "
" certificates. Can be used to deploy renewed certificates, or to restart"
" any servers that were stopped by --pre-hook. This is only run if"
" an attempt was made to obtain/renew a certificate.")
help="Command to be run in a shell after attempting to obtain/renew"
" certificates. Can be used to deploy renewed certificates, or to"
" restart any servers that were stopped by --pre-hook. This is only"
" run if an attempt was made to obtain/renew a certificate.")
helpful.add(
"renew", "--renew-hook",
help="Command to be run in a shell once for each successfully renewed certificate."
"For this command, the shell variable $RENEWED_LINEAGE will point to the"
"config live subdirectory containing the new certs and keys; the shell variable "
"$RENEWED_DOMAINS will contain a space-delimited list of renewed cert domains")
help="Command to be run in a shell once for each successfully renewed"
" certificate. For this command, the shell variable $RENEWED_LINEAGE"
" will point to the config live subdirectory containing the new certs"
" and keys; the shell variable $RENEWED_DOMAINS will contain a"
" space-delimited list of renewed cert domains")
helpful.add(
"renew", "--disable-hook-validation",
action='store_false', dest='validate_hooks', default=True,
help="Ordinarily the commands specified for --pre-hook/--post-hook/--renew-hook"
" will be checked for validity, to 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.")
help="Ordinarily the commands specified for"
" --pre-hook/--post-hook/--renew-hook will be checked for validity, to"
" 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."
" (default: False)")
helpful.add_deprecated_argument("--agree-dev-preview", 0)
helpful.add_deprecated_argument("--dialog", 0)
_create_subparsers(helpful)
_paths_parser(helpful)
@ -870,27 +1064,21 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
def _create_subparsers(helpful):
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="Plugin options")
helpful.add_group("config_changes",
description="Options for showing a history of config changes")
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"
" format; note that the .csr file *must* contain a Subject"
" Alternative Name field for each domain you want certified."
" Currently --csr only works with the 'certonly' subcommand'")
help="Path to a Certificate Signing Request (CSR) in DER or PEM format."
" Currently --csr only works with the 'certonly' subcommand.")
helpful.add("rollback",
"--checkpoints", type=int, metavar="N",
default=flag_default("rollback_checkpoints"),
@ -912,13 +1100,9 @@ def _paths_parser(helpful):
verb = helpful.verb
if verb == "help":
verb = helpful.help_arg
helpful.add_group(
"paths", description="Arguments changing execution paths & servers")
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)
@ -940,7 +1124,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.")
@ -952,36 +1136,34 @@ def _paths_parser(helpful):
help="Logs directory.")
add("paths", "--server", default=flag_default("server"),
help=config_help("server"))
# overwrites server, handled in HelpfulArgumentParser.parse_args()
add("testing", "--test-cert", "--staging", action='store_true', dest='staging',
help='Use the staging server to obtain test (invalid) certs; equivalent'
' to --server ' + constants.STAGING_URI)
def _plugins_parsing(helpful, plugins):
# It's nuts, but there are two "plugins" topics. Somehow this works
helpful.add_group(
"plugins", description="Certbot client supports an "
"plugins", description="Plugin Selection: Certbot client supports an "
"extensible plugins architecture. See '%(prog)s plugins' for a "
"list of all installed plugins and their names. You can force "
"a particular plugin by setting options provided below. Running "
"--help <plugin_name> 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", "--apache", action="store_true",
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", "--nginx", action="store_true",
help="Obtain and install certs using Nginx")
helpful.add("plugins", "--standalone", action="store_true",
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", "--manual", action="store_true",
helpful.add(["plugins", "certonly"], "--manual", action="store_true",
help='Provide laborious manual instructions for obtaining a cert')
helpful.add("plugins", "--webroot", action="store_true",
helpful.add(["plugins", "certonly"], "--webroot", action="store_true",
help='Obtain certs by placing files in a webroot directory.')
# things should not be reorder past/pre this comment:
@ -998,7 +1180,6 @@ class _DomainsAction(argparse.Action):
"""Just wrap add_domains in argparseese."""
add_domains(namespace, domain)
def add_domains(args_or_config, domains):
"""Registers new domains to be used during the current client run.
@ -1022,3 +1203,18 @@ def add_domains(args_or_config, domains):
args_or_config.domains.append(domain)
return validated_domains
class _PrefChallAction(argparse.Action):
"""Action class for parsing preferred challenges."""
def __call__(self, parser, namespace, pref_challs, option_string=None):
aliases = {"dns": "dns-01", "http": "http-01", "tls-sni": "tls-sni-01"}
challs = [c.strip() for c in pref_challs.split(",")]
challs = [aliases[c] if c in aliases else c for c in challs]
unrecognized = ", ".join(name for name in challs
if name not in challenges.Challenge.TYPES)
if unrecognized:
raise argparse.ArgumentTypeError(
"Unrecognized challenges: {0}".format(unrecognized))
namespace.pref_challs.extend(challenges.Challenge.TYPES[name]
for name in challs)

View file

@ -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.
@ -149,9 +158,15 @@ def perform_registration(acme, config):
try:
return acme.register(messages.NewRegistration.from_data(email=config.email))
except messages.Error as e:
if e.typ == "urn:acme:error:invalidEmail":
config.namespace.email = display_ops.get_email(invalid=True)
return perform_registration(acme, config)
if e.code == "invalidEmail" or e.code == "invalidContact":
if config.noninteractive_mode:
msg = ("The ACME server believes %s is an invalid email address. "
"Please ensure it is a valid email and attempt "
"registration again." % config.email)
raise errors.Error(msg)
else:
config.namespace.email = display_ops.get_email(invalid=True)
return perform_registration(acme, config)
else:
raise
@ -186,7 +201,7 @@ class Client(object):
if auth is not None:
self.auth_handler = auth_handler.AuthHandler(
auth, self.acme, self.account)
auth, self.acme, self.account, self.config.pref_challs)
else:
self.auth_handler = None
@ -248,8 +263,7 @@ class Client(object):
domains,
self.config.allow_subset_of_names)
auth_domains = set(a.body.identifier.value.encode('ascii')
for a in authzr)
auth_domains = set(a.body.identifier.value for a in authzr)
domains = [d for d in domains if d in auth_domains]
# Create CSR from names
@ -292,7 +306,7 @@ class Client(object):
return (self.obtain_certificate_from_csr(domains, csr, authzr=authzr)
+ (key, csr))
def obtain_and_enroll_certificate(self, domains):
def obtain_and_enroll_certificate(self, domains, certname):
"""Obtain and enroll certificate.
Get a new certificate for the specified domains using the specified
@ -301,6 +315,7 @@ class Client(object):
:param list domains: Domains to request.
:param plugins: A PluginsFactory object.
:param str certname: Name of new cert
:returns: A new :class:`certbot.storage.RenewableCert` instance
referred to the enrolled cert lineage, False if the cert could not
@ -315,16 +330,17 @@ class Client(object):
"Non-standard path(s), might not work with crontab installed "
"by your operating system package manager")
new_name = certname if certname else domains[0]
if self.config.dry_run:
logger.debug("Dry run: Skipping creating new lineage for %s",
domains[0])
new_name)
return None
else:
return storage.RenewableCert.new_lineage(
domains[0], OpenSSL.crypto.dump_certificate(
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):
@ -411,53 +427,46 @@ class Client(object):
with error_handler.ErrorHandler(self._rollback_and_restart, msg):
# sites may have been enabled / final cleanup
self.installer.restart()
def enhance_config(self, domains, config):
def enhance_config(self, domains, chain_path):
"""Enhance the configuration.
:param list domains: list of domains to configure
:ivar config: Namespace typically produced by
:meth:`argparse.ArgumentParser.parse_args`.
it must have the redirect, hsts and uir attributes.
:type namespace: :class:`argparse.Namespace`
:param chain_path: chain file path
:type chain_path: `str` or `None`
:raises .errors.Error: if no installer is specified in the
client.
"""
if self.installer is None:
logger.warning("No installer is specified, there isn't any "
"configuration to enhance.")
raise errors.Error("No installer available")
if config is None:
logger.warning("No config is specified.")
raise errors.Error("No config available")
enhanced = False
enhancement_info = (
("hsts", "ensure-http-header", "Strict-Transport-Security"),
("redirect", "redirect", None),
("staple", "staple-ocsp", chain_path),
("uir", "ensure-http-header", "Upgrade-Insecure-Requests"),)
supported = self.installer.supported_enhancements()
redirect = config.redirect if "redirect" in supported else False
hsts = config.hsts if "ensure-http-header" in supported else False
uir = config.uir if "ensure-http-header" in supported else False
staple = config.staple if "staple-ocsp" in supported else False
if redirect is None:
redirect = enhancements.ask("redirect")
if redirect:
self.apply_enhancement(domains, "redirect")
if hsts:
self.apply_enhancement(domains, "ensure-http-header",
"Strict-Transport-Security")
if uir:
self.apply_enhancement(domains, "ensure-http-header",
"Upgrade-Insecure-Requests")
if staple:
self.apply_enhancement(domains, "staple-ocsp")
for config_name, enhancement_name, option in enhancement_info:
config_value = getattr(self.config, config_name)
if enhancement_name in supported:
if config_name == "redirect" and config_value is None:
config_value = enhancements.ask(enhancement_name)
if config_value:
self.apply_enhancement(domains, enhancement_name, option)
enhanced = True
elif config_value:
logger.warning(
"Option %s is not supported by the selected installer. "
"Skipping enhancement.", config_name)
msg = ("We were unable to restart web server")
if redirect or hsts or uir or staple:
if enhanced:
with error_handler.ErrorHandler(self._rollback_and_restart, msg):
self.installer.restart()
@ -620,10 +629,10 @@ def _open_pem_file(cli_arg_path, pem_path):
"""
if cli.set_by_cli(cli_arg_path):
return util.safe_open(pem_path, chmod=0o644),\
return util.safe_open(pem_path, chmod=0o644, mode="wb"),\
os.path.abspath(pem_path)
else:
uniq = util.unique_file(pem_path, 0o644)
uniq = util.unique_file(pem_path, 0o644, "wb")
return uniq[0], os.path.abspath(uniq[1])
def _save_chain(chain_pem, chain_file):

View file

@ -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,18 +92,8 @@ 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 archive_dir(self): # pylint: disable=missing-docstring
def default_archive_dir(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.config_dir, constants.ARCHIVE_DIR)
@property
@ -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

View file

@ -39,6 +39,9 @@ STAGING_URI = "https://acme-staging.api.letsencrypt.org/directory"
"""Defaults for CLI flags and `.IConfig` attributes."""
QUIET_LOGGING_LEVEL = logging.WARNING
"""Logging level to use in quiet mode."""
RENEWER_DEFAULTS = dict(
renewer_enabled="yes",
renew_before_expiry="30 days",
@ -56,7 +59,7 @@ enhancements.
List of expected options parameters:
- redirect: None
- http-header: TODO
- ocsp-stapling: TODO
- ocsp-stapling: certificate chain file path
- spdy: TODO
"""
@ -92,5 +95,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."""

View file

@ -10,6 +10,7 @@ import traceback
import OpenSSL
import pyrfc3339
import six
import zope.component
from acme import crypto_util as acme_crypto_util
@ -48,7 +49,8 @@ def save_key(key_pem, key_dir, keyname="key-certbot.pem"):
# Save file
util.make_or_verify_dir(key_dir, 0o700, os.geteuid(),
config.strict_permissions)
key_f, key_path = util.unique_file(os.path.join(key_dir, keyname), 0o600)
key_f, key_path = util.unique_file(
os.path.join(key_dir, keyname), 0o600, "wb")
with key_f:
logger.info("Saving key to: %s", key_path)
key_f.write(key_pem)
@ -79,7 +81,7 @@ def init_save_csr(privkey, names, path, csrname="csr-certbot.pem"):
util.make_or_verify_dir(path, 0o755, os.geteuid(),
config.strict_permissions)
csr_f, csr_filename = util.unique_file(
os.path.join(path, csrname), 0o644)
os.path.join(path, csrname), 0o644, "wb")
csr_f.write(csr_pem)
csr_f.close()
@ -110,16 +112,16 @@ def make_csr(key_str, domains, must_staple=False):
# TODO: put SAN if len(domains) > 1
extensions = [
OpenSSL.crypto.X509Extension(
"subjectAltName",
b"subjectAltName",
critical=False,
value=", ".join("DNS:%s" % d for d in domains)
value=", ".join("DNS:%s" % d for d in domains).encode('ascii')
)
]
if must_staple:
extensions.append(OpenSSL.crypto.X509Extension(
"1.3.6.1.5.5.7.1.24",
b"1.3.6.1.5.5.7.1.24",
critical=False,
value="DER:30:03:02:01:05"))
value=b"DER:30:03:02:01:05"))
req.add_extensions(extensions)
req.set_version(2)
req.set_pubkey(pkey)
@ -373,7 +375,7 @@ def dump_pyopenssl_chain(chain, filetype=OpenSSL.crypto.FILETYPE_PEM):
# assumes that OpenSSL.crypto.dump_certificate includes ending
# newline character
return "".join(_dump_cert(cert) for cert in chain)
return b"".join(_dump_cert(cert) for cert in chain)
def notBefore(cert_path):
@ -414,8 +416,14 @@ def _notAfterBefore(cert_path, method):
with open(cert_path) as f:
x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM,
f.read())
# pyopenssl always returns bytes
timestamp = method(x509)
reformatted_timestamp = [timestamp[0:4], "-", timestamp[4:6], "-",
timestamp[6:8], "T", timestamp[8:10], ":",
timestamp[10:12], ":", timestamp[12:]]
return pyrfc3339.parse("".join(reformatted_timestamp))
reformatted_timestamp = [timestamp[0:4], b"-", timestamp[4:6], b"-",
timestamp[6:8], b"T", timestamp[8:10], b":",
timestamp[10:12], b":", timestamp[12:]]
timestamp_str = b"".join(reformatted_timestamp)
# pyrfc3339 uses "native" strings. That is, bytes on Python 2 and unicode
# on Python 3
if six.PY3:
timestamp_str = timestamp_str.decode('ascii')
return pyrfc3339.parse(timestamp_str)

View file

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

View file

@ -29,7 +29,7 @@ def get_email(invalid=False, optional=True):
"""
invalid_prefix = "There seem to be problems with that address. "
msg = "Enter email address (used for urgent notices and lost key recovery)"
msg = "Enter email address (used for urgent renewal and security notices)"
unsafe_suggestion = ("\n\nIf you really want to skip this, you can run "
"the client with --register-unsafely-without-email "
"but make sure you then backup your account key from "
@ -37,6 +37,7 @@ def get_email(invalid=False, optional=True):
if optional:
if invalid:
msg += unsafe_suggestion
suggest_unsafe = False
else:
suggest_unsafe = True
else:
@ -45,10 +46,11 @@ 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 <email_address> flags")
"or provide --agree-tos and --email <email_address> flags.")
raise errors.MissingCommandlineFlag(msg)
if code != display_util.OK:
@ -78,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:
@ -103,18 +105,8 @@ def choose_names(installer):
names = get_valid_domains(domains)
if not names:
manual = z_util(interfaces.IDisplay).yesno(
"No names were found in your configuration files.{0}You should "
"specify ServerNames in your config files in order to allow for "
"accurate installation of your certificate.{0}"
"If you do use the default vhost, you may specify the name "
"manually. Would you like to continue?{0}".format(os.linesep),
default=True)
if manual:
return _choose_names_manually()
else:
return []
return _choose_names_manually(
"No names were found in your configuration files. ")
code, names = _filter_names(names)
if code == display_util.OK and names:
@ -139,6 +131,16 @@ def get_valid_domains(domains):
continue
return valid_domains
def _sort_names(FQDNs):
"""Sort FQDNs by SLD (and if many, by their subdomains)
:param list FQDNs: list of domain names
:returns: Sorted list of domain names
:rtype: list
"""
return sorted(FQDNs, key=lambda fqdn: fqdn.split('.')[::-1][1:])
def _filter_names(names):
"""Determine which names the user would like to select from a list.
@ -151,18 +153,28 @@ def _filter_names(names):
:rtype: tuple
"""
#Sort by domain first, and then by subdomain
sorted_names = _sort_names(names)
code, names = z_util(interfaces.IDisplay).checklist(
"Which names would you like to activate HTTPS for?",
tags=names, cli_flag="--domains")
tags=sorted_names, cli_flag="--domains", force_interactive=True)
return code, [str(s) for s in names]
def _choose_names_manually():
"""Manually input names for those without an installer."""
def _choose_names_manually(prompt_prefix=""):
"""Manually input names for those without an installer.
:param str prompt_prefix: string to prepend to prompt for domains
:returns: list of provided names
:rtype: `list` of `str`
"""
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()
@ -180,7 +192,12 @@ def _choose_names_manually():
try:
domain_list[i] = util.enforce_domain_sanity(domain)
except errors.ConfigurationError as e:
invalid_domains[domain] = e.message
try: # Python 2
# pylint: disable=no-member
err_msg = e.message.encode('utf-8')
except AttributeError:
err_msg = str(e)
invalid_domains[domain] = err_msg
if len(invalid_domains):
retry_message = (
@ -195,7 +212,8 @@ def _choose_names_manually():
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:
@ -206,8 +224,6 @@ def _choose_names_manually():
def success_installation(domains):
"""Display a box confirming the installation of HTTPS.
.. todo:: This should be centered on the screen
:param list domains: domain names which were enabled
"""
@ -217,29 +233,36 @@ def success_installation(domains):
_gen_https_names(domains),
os.linesep,
os.linesep.join(_gen_ssl_lab_urls(domains))),
height=(10 + len(domains)),
pause=False)
def success_renewal(domains, action):
def success_renewal(domains):
"""Display a box confirming the renewal of an existing certificate.
.. todo:: This should be centered on the screen
:param list domains: domain names which were renewed
:param str action: can be "reinstall" or "renew"
"""
z_util(interfaces.IDisplay).notification(
"Your existing certificate has been successfully {3}ed, and the "
"Your existing certificate has been successfully renewed, and the "
"new certificate has been installed.{1}{1}"
"The new certificate covers the following domains: {0}{1}{1}"
"You should test your configuration at:{1}{2}".format(
_gen_https_names(domains),
os.linesep,
os.linesep.join(_gen_ssl_lab_urls(domains)),
action),
height=(14 + len(domains)),
os.linesep.join(_gen_ssl_lab_urls(domains))),
pause=False)
def success_revocation(cert_path):
"""Display a box confirming a certificate has been revoked.
:param list cert_path: path to certificate which was revoked.
"""
z_util(interfaces.IDisplay).notification(
"Congratulations! You have successfully revoked the certificate "
"that was located at {0}{1}{1}".format(
cert_path,
os.linesep),
pause=False)

View file

@ -2,26 +2,19 @@
import logging
import os
import textwrap
import sys
import dialog
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
HEIGHT = 20
DSELECT_HELP = (
"Use the arrow keys or Tab to move between window elements. Space can be "
"used to complete the input path with the selected element in the "
"directory window. Pressing enter will select the currently highlighted "
"button.")
"""Help text on how to use dialog's dselect."""
# Display exit codes
OK = "ok"
@ -58,205 +51,45 @@ def _wrap_lines(msg):
return os.linesep.join(fixed_l)
def _clean(dialog_result):
"""Treat sundy python-dialog return codes as CANCEL
:param tuple dialog_result: (code, result)
:returns: the argument but with unknown codes set to -1 (Error)
:rtype: tuple
"""
code, result = dialog_result
if code in (OK, HELP):
return dialog_result
elif code in (CANCEL, ESC):
return (CANCEL, result)
else:
logger.debug("Surprising dialog return code %s", code)
return (CANCEL, result)
@zope.interface.implementer(interfaces.IDisplay)
class NcursesDisplay(object):
"""Ncurses-based display."""
def __init__(self, width=WIDTH, height=HEIGHT):
super(NcursesDisplay, self).__init__()
self.dialog = dialog.Dialog()
assert OK == self.dialog.DIALOG_OK, "What kind of absurdity is this?"
self.width = width
self.height = height
def notification(self, message, height=10, pause=False):
# pylint: disable=unused-argument
"""Display a notification to the user and wait for user acceptance.
.. todo:: It probably makes sense to use one of the transient message
types for pause. It isn't straightforward how best to approach
the matter though given the context of our messages.
http://pythondialog.sourceforge.net/doc/widgets.html#displaying-transient-messages
:param str message: Message to display
:param int height: Height of the dialog box
:param bool pause: Not applicable to NcursesDisplay
"""
self.dialog.msgbox(message, height, width=self.width)
def menu(self, message, choices, ok_label="OK", cancel_label="Cancel",
help_label="", **unused_kwargs):
"""Display a menu.
:param str message: title of menu
:param choices: menu lines, len must be > 0
:type choices: list of tuples (`tag`, `item`) tags must be unique or
list of items (tags will be enumerated)
:param str ok_label: label of the OK button
:param str help_label: label of the help button
:param dict unused_kwargs: absorbs default / cli_args
:returns: tuple of the form (`code`, `index`) where
`code` - display exit code
`int` - index of the selected item
:rtype: tuple
"""
menu_options = {
"choices": choices,
"ok_label": ok_label,
"cancel_label": cancel_label,
"help_button": bool(help_label),
"help_label": help_label,
"width": self.width,
"height": self.height,
"menu_height": self.height - 6,
}
# Can accept either tuples or just the actual choices
if choices and isinstance(choices[0], tuple):
# pylint: disable=star-args
code, selection = _clean(self.dialog.menu(message, **menu_options))
# Return the selection index
for i, choice in enumerate(choices):
if choice[0] == selection:
return code, i
return code, -1
else:
# "choices" is not formatted the way the dialog.menu expects...
menu_options["choices"] = [
(str(i), choice) for i, choice in enumerate(choices, 1)
]
# pylint: disable=star-args
code, index = _clean(self.dialog.menu(message, **menu_options))
if code == CANCEL or index == "":
return code, -1
return code, int(index) - 1
def input(self, message, **unused_kwargs):
"""Display an input box to the user.
:param str message: Message to display that asks for input.
:param dict _kwargs: absorbs default / cli_args
:returns: tuple of the form (`code`, `string`) where
`code` - display exit code
`string` - input entered by the user
"""
sections = message.split("\n")
# each section takes at least one line, plus extras if it's longer than self.width
wordlines = [1 + (len(section) / self.width) for section in sections]
height = 6 + sum(wordlines) + len(sections)
return _clean(self.dialog.inputbox(message, width=self.width, height=height))
def yesno(self, message, yes_label="Yes", no_label="No", **unused_kwargs):
"""Display a Yes/No dialog box.
Yes and No label must begin with different letters.
:param str message: message to display to user
:param str yes_label: label on the "yes" button
:param str no_label: label on the "no" button
:param dict _kwargs: absorbs default / cli_args
:returns: if yes_label was selected
:rtype: bool
"""
return self.dialog.DIALOG_OK == self.dialog.yesno(
message, self.height, self.width,
yes_label=yes_label, no_label=no_label)
def checklist(self, message, tags, default_status=True, **unused_kwargs):
"""Displays a checklist.
:param message: Message to display before choices
: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 dict _kwargs: absorbs default / cli_args
:returns: tuple of the form (`code`, `list_tags`) where
`code` - display exit code
`list_tags` - list of str tags selected by the user
"""
choices = [(tag, "", default_status) for tag in tags]
return _clean(self.dialog.checklist(
message, width=self.width, height=self.height, choices=choices))
def directory_select(self, message, **unused_kwargs):
"""Display a directory selection screen.
:param str message: prompt to give the user
:returns: tuple of the form (`code`, `string`) where
`code` - display exit code
`string` - input entered by the user
"""
root_directory = os.path.abspath(os.sep)
return _clean(self.dialog.dselect(
filepath=root_directory, width=self.width,
height=self.height, help_button=True, title=message))
@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, height=10, pause=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 int height: No effect for FileDisplay
: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
message = _wrap_lines(message)
if wrap:
message = _wrap_lines(message)
self.outfile.write(
"{line}{frame}{line}{msg}{line}{frame}{line}".format(
line=os.linesep, frame=side_frame, msg=message))
if pause:
raw_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.
@ -267,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
@ -276,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
@ -295,7 +138,10 @@ class FileDisplay(object):
:rtype: tuple
"""
ans = raw_input(
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,
80,
@ -307,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
@ -316,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)
@ -330,7 +183,7 @@ class FileDisplay(object):
os.linesep, frame=side_frame, msg=message))
while True:
ans = raw_input("{yes}/{no}: ".format(
ans = six.moves.input("{yes}/{no}: ".format(
yes=_parens_around_char(yes_label),
no=_parens_around_char(no_label)))
@ -343,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
@ -358,13 +215,20 @@ 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")
"by commas and/or spaces, or leave input "
"blank to select all options shown",
force_interactive=True)
if code == OK:
if len(ans.strip()) == 0:
ans = " ".join(str(x) for x in range(1, len(tags)+1))
indices = separate_list_input(ans)
selected_tags = self._scrub_checklist_input(indices, tags)
if selected_tags:
@ -375,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
@ -386,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
@ -468,7 +387,7 @@ class FileDisplay(object):
input_msg = ("Press 1 [enter] to confirm the selection "
"(press 'c' to cancel): ")
while selection < 1:
ans = raw_input(input_msg)
ans = six.moves.input(input_msg)
if ans.startswith("c") or ans.startswith("C"):
return CANCEL, -1
try:
@ -488,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
@ -502,23 +421,24 @@ 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, height=10, pause=False):
def notification(self, message, pause=False, wrap=True, **unused_kwargs):
# pylint: disable=unused-argument
"""Displays a notification without waiting for user acceptance.
:param str message: Message to display to stdout
:param int height: No effect for NoninteractiveDisplay
:param bool pause: The NoninteractiveDisplay waits for no keyboard
:param bool wrap: Whether or not the application should wrap text
"""
side_frame = "-" * 79
message = _wrap_lines(message)
if wrap:
message = _wrap_lines(message)
self.outfile.write(
"{line}{frame}{line}{msg}{line}{frame}{line}".format(
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.
@ -541,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
@ -558,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
@ -575,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
@ -594,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,

View file

@ -15,9 +15,14 @@ logger = logging.getLogger(__name__)
# potentially occur from inside Python. Signals such as SIGILL were not
# included as they could be a sign of something devious and we should terminate
# immediately.
_SIGNALS = ([signal.SIGTERM] if os.name == "nt" else
[signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT,
signal.SIGXCPU, signal.SIGXFSZ])
_SIGNALS = [signal.SIGTERM]
if os.name != "nt":
for signal_code in [signal.SIGHUP, signal.SIGQUIT,
signal.SIGXCPU, signal.SIGXFSZ]:
# Adding only those signals that their default action is not Ignore.
# This is platform-dependent, so we check it dynamically.
if signal.getsignal(signal_code) != signal.SIG_IGN:
_SIGNALS.append(signal_code)
class ErrorHandler(object):

View file

@ -12,16 +12,16 @@ logger = logging.getLogger(__name__)
def validate_hooks(config):
"""Check hook commands are executable."""
_validate_hook(config.pre_hook, "pre")
_validate_hook(config.post_hook, "post")
_validate_hook(config.renew_hook, "renew")
validate_hook(config.pre_hook, "pre")
validate_hook(config.post_hook, "post")
validate_hook(config.renew_hook, "renew")
def _prog(shell_cmd):
"""Extract the program run by a shell command"""
cmd = _which(shell_cmd)
return os.path.basename(cmd) if cmd else None
def _validate_hook(shell_cmd, hook_name):
def validate_hook(shell_cmd, hook_name):
"""Check that a command provided as a hook is plausibly executable.
:raises .errors.HookCommandNotFound: if the command is not found
@ -69,17 +69,33 @@ def renew_hook(config, domains, lineage_path):
else:
logger.warning("Dry run: skipping renewal hook command: %s", config.renew_hook)
def _run_hook(shell_cmd):
"""Run a hook command.
:returns: stderr if there was any"""
cmd = Popen(shell_cmd, shell=True, stdout=PIPE, stderr=PIPE, stdin=PIPE)
_out, err = cmd.communicate()
err, _ = execute(shell_cmd)
return err
def execute(shell_cmd):
"""Run a command.
:returns: `tuple` (`str` stderr, `str` stdout)"""
# 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', shell_cmd, cmd.returncode)
logger.error('Hook command "%s" returned error code %d',
shell_cmd, cmd.returncode)
if err:
logger.error('Error output from %s:\n%s', _prog(shell_cmd), err)
return (err, out)
def _is_exe(fpath):
return os.path.isfile(fpath) and os.access(fpath, os.X_OK)

View file

@ -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.")
ecdsa_curve = zope.interface.Attribute(
"Set the certificate ECDSA curve. Current possible curves: "
@ -231,17 +231,17 @@ 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 SSL certificate verification.")
"Disable verification of the ACME server's certificate.")
tls_sni_01_port = zope.interface.Attribute(
"Port number to perform tls-sni-01 challenge. "
"Boulder in testing mode defaults to 5001.")
"Port used during tls-sni-01 challenge. "
"This only affects the port Certbot listens on. "
"A conforming ACME server will still attempt to connect on port 443.")
http01_port = zope.interface.Attribute(
"Port used in the SimpleHttp challenge.")
"Port used in the http-01 challenge. "
"This only affects the port Certbot listens on. "
"A conforming ACME server will still attempt to connect on port 80.")
class IInstaller(IPlugin):
@ -262,7 +262,7 @@ class IInstaller(IPlugin):
def get_all_names():
"""Returns all names that may be authenticated.
:rtype: `list` of `str`
:rtype: `collections.Iterable` of `str`
"""
@ -297,24 +297,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`
"""
def get_all_certs_keys():
"""Retrieve all certs and keys set in configuration.
:returns: tuples with form `[(cert, key, path)]`, where:
- `cert` - str path to certificate file
- `key` - str path to associated key file
- `path` - file path to configuration file
:rtype: list
:rtype: :class:`collections.Iterable` of :class:`str`
"""
@ -382,21 +369,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, height, pause):
def notification(message, pause, wrap=True, force_interactive=False):
"""Displays a string message
:param str message: Message to display
:param int height: Height of dialog box if applicable
: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
@ -407,6 +402,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
@ -417,10 +414,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
@ -433,14 +436,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
@ -450,14 +458,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
@ -469,15 +483,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

View file

@ -1,64 +0,0 @@
"""Logging utilities."""
import logging
import dialog
from certbot.display import util as display_util
class DialogHandler(logging.Handler): # pylint: disable=too-few-public-methods
"""Logging handler using dialog info box.
:ivar int height: Height of the info box (without padding).
:ivar int width: Width of the info box (without padding).
:ivar list lines: Lines to be displayed in the info box.
:ivar d: Instance of :class:`dialog.Dialog`.
"""
PADDING_HEIGHT = 2
PADDING_WIDTH = 4
def __init__(self, level=logging.NOTSET, height=display_util.HEIGHT,
width=display_util.WIDTH - 4, d=None):
# Handler not new-style -> no super
logging.Handler.__init__(self, level)
self.height = height
self.width = width
# "dialog" collides with module name...
self.d = dialog.Dialog() if d is None else d
self.lines = []
def emit(self, record):
"""Emit message to a dialog info box.
Only show the last (self.height) lines; note that lines can wrap
at self.width, so a single line could actually be multiple
lines.
"""
for line in self.format(record).splitlines():
# check for lines that would wrap
cur_out = line
while len(cur_out) > self.width:
# find first space before self.width chars into cur_out
last_space_pos = cur_out.rfind(' ', 0, self.width)
if last_space_pos == -1:
# no spaces, just cut them off at whatever
self.lines.append(cur_out[0:self.width])
cur_out = cur_out[self.width:]
else:
# cut off at last space
self.lines.append(cur_out[0:last_space_pos])
cur_out = cur_out[last_space_pos + 1:]
if cur_out != '':
self.lines.append(cur_out)
# show last 16 lines
content = '\n'.join(self.lines[-self.height:])
# add the padding around the box
self.d.infobox(
content, self.height + self.PADDING_HEIGHT,
self.width + self.PADDING_WIDTH)

View file

@ -1,7 +1,6 @@
"""Certbot main entry point."""
from __future__ import print_function
import atexit
import dialog
import functools
import logging.handlers
import os
@ -12,10 +11,13 @@ import traceback
import zope.component
from acme import jose
from acme import messages
from acme import errors as acme_errors
import certbot
from certbot import account
from certbot import cert_manager
from certbot import client
from certbot import cli
from certbot import crypto_util
@ -26,10 +28,8 @@ from certbot import errors
from certbot import hooks
from certbot import interfaces
from certbot import util
from certbot import log
from certbot import reporter
from certbot import renewal
from certbot import storage
from certbot.display import util as display_util, ops as display_ops
from certbot.plugins import disco as plugins_disco
@ -41,6 +41,9 @@ _PERM_ERR_FMT = os.linesep.join((
"If running as non-root, set --config-dir, "
"--logs-dir, and --work-dir to writeable paths."))
USER_CANCELLED = ("User chose to cancel the operation and may "
"reinvoke the client.")
logger = logging.getLogger(__name__)
@ -67,21 +70,21 @@ def _report_successful_dry_run(config):
reporter_util.HIGH_PRIORITY, on_crash=False)
def _auth_from_available(le_client, config, domains=None, certname=None, lineage=None):
"""Authenticate and enroll certificate.
def _auth_from_domains(le_client, config, domains, lineage=None):
"""Authenticate and enroll certificate."""
# Note: This can raise errors... caught above us though. This is now
# a three-way case: reinstall (which results in a no-op here because
# although there is a relevant lineage, we don't do anything to it
# inside this function -- we don't obtain a new certificate), renew
# (which results in treating the request as a renewal), or newcert
# (which results in treating the request as a new certificate request).
This method finds the relevant lineage, figures out what to do with it,
then performs that action. Includes calls to hooks, various reports,
checks, and requests for user input.
:returns: Tuple of (str action, cert_or_None) as per _find_lineage_for_domains_and_certname
action can be: "newcert" | "renew" | "reinstall"
"""
# If lineage is specified, use that one instead of looking around for
# a matching one.
if lineage is None:
# This will find a relevant matching lineage that exists
action, lineage = _treat_as_renewal(config, domains)
action, lineage = _find_lineage_for_domains_and_certname(config, domains, certname)
else:
# Renewal, where we already know the specific lineage we're
# interested in
@ -91,17 +94,17 @@ def _auth_from_domains(le_client, config, domains, lineage=None):
# The lineage already exists; allow the caller to try installing
# it without getting a new certificate at all.
logger.info("Keeping the existing certificate")
return lineage, "reinstall"
return "reinstall", lineage
hooks.pre_hook(config)
try:
if action == "renew":
logger.info("Renewing an existing certificate")
renewal.renew_cert(config, domains, le_client, lineage)
renewal.renew_cert(config, le_client, lineage)
elif action == "newcert":
# TREAT AS NEW REQUEST
logger.info("Obtaining a new certificate")
lineage = le_client.obtain_and_enroll_certificate(domains)
lineage = le_client.obtain_and_enroll_certificate(domains, certname)
if lineage is False:
raise errors.Error("Certificate could not be obtained")
finally:
@ -110,7 +113,7 @@ def _auth_from_domains(le_client, config, domains, lineage=None):
if not config.dry_run and not config.verb == "renew":
_report_new_cert(config, lineage.cert, lineage.fullchain)
return lineage, action
return action, lineage
def _handle_subset_cert_request(config, domains, cert):
@ -118,7 +121,7 @@ def _handle_subset_cert_request(config, domains, cert):
:param storage.RenewableCert cert:
:returns: Tuple of (str action, cert_or_None) as per _treat_as_renewal
:returns: Tuple of (str action, cert_or_None) as per _find_lineage_for_domains_and_certname
action can be: "newcert" | "renew" | "reinstall"
:rtype: tuple
@ -136,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)
@ -150,32 +154,32 @@ def _handle_subset_cert_request(config, domains, cert):
br=os.linesep
),
reporter_util.HIGH_PRIORITY)
raise errors.Error(
"User chose to cancel the operation and may "
"reinvoke the client.")
raise errors.Error(USER_CANCELLED)
def _handle_identical_cert_request(config, cert):
"""Figure out what to do if a cert has the same names as a previously obtained one
def _handle_identical_cert_request(config, lineage):
"""Figure out what to do if a lineage has the same names as a previously obtained one
:param storage.RenewableCert cert:
:param storage.RenewableCert lineage:
:returns: Tuple of (str action, cert_or_None) as per _treat_as_renewal
:returns: Tuple of (str action, cert_or_None) as per _find_lineage_for_domains_and_certname
action can be: "newcert" | "renew" | "reinstall"
:rtype: tuple
"""
if renewal.should_renew(config, cert):
return "renew", cert
if not lineage.ensure_deployed():
return "reinstall", lineage
if renewal.should_renew(config, lineage):
return "renew", lineage
if config.reinstall:
# Set with --reinstall, force an identical certificate to be
# reinstalled without further prompting.
return "reinstall", cert
return "reinstall", lineage
question = (
"You have an existing certificate that contains exactly the same "
"domains you requested and isn't close to expiry."
"You have an existing certificate that has exactly the same "
"domains or certificate name you requested and isn't close to expiry."
"{br}(ref: {0}){br}{br}What would you like to do?"
).format(cert.configfile.filename, br=os.linesep)
).format(lineage.configfile.filename, br=os.linesep)
if config.verb == "run":
keep_opt = "Attempt to reinstall this existing certificate"
@ -185,7 +189,8 @@ def _handle_identical_cert_request(config, cert):
"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.
@ -193,14 +198,13 @@ def _handle_identical_cert_request(config, cert):
"User chose to cancel the operation and may "
"reinvoke the client.")
elif response[1] == 0:
return "reinstall", cert
return "reinstall", lineage
elif response[1] == 1:
return "renew", cert
return "renew", lineage
else:
assert False, "This is impossible"
def _treat_as_renewal(config, domains):
def _find_lineage_for_domains(config, domains):
"""Determine whether there are duplicated names and how to handle
them (renew, reinstall, newcert, or raising an error to stop
the client run if the user chooses to cancel the operation when
@ -220,7 +224,7 @@ def _treat_as_renewal(config, domains):
if config.duplicate:
return "newcert", None
# TODO: Also address superset case
ident_names_cert, subset_names_cert = _find_duplicative_certs(config, domains)
ident_names_cert, subset_names_cert = cert_manager.find_duplicative_certs(config, domains)
# XXX ^ schoen is not sure whether that correctly reads the systemwide
# configuration file.
if ident_names_cert is None and subset_names_cert is None:
@ -231,51 +235,75 @@ def _treat_as_renewal(config, domains):
elif subset_names_cert is not None:
return _handle_subset_cert_request(config, domains, subset_names_cert)
def _find_lineage_for_domains_and_certname(config, domains, certname):
"""Find appropriate lineage based on given domains and/or certname.
def _find_duplicative_certs(config, domains):
"""Find existing certs that duplicate the request."""
:returns: Two-element tuple containing desired new-certificate behavior as
a string token ("reinstall", "renew", or "newcert"), plus either
a RenewableCert instance or None if renewal shouldn't occur.
identical_names_cert, subset_names_cert = None, None
:raises .Error: If the user would like to rerun the client again.
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())
"""
if not certname:
return _find_lineage_for_domains(config, domains)
else:
lineage = cert_manager.lineage_for_certname(config, certname)
if lineage:
if domains:
if set(cert_manager.domains_for_certname(config, certname)) != set(domains):
_ask_user_to_confirm_new_names(config, domains, certname,
lineage.names()) # raises if no
return "renew", lineage
# unnecessarily specified domains or no domains specified
return _handle_identical_cert_request(config, lineage)
else:
if domains:
return "newcert", None
else:
raise errors.ConfigurationError("No certificate with name {0} found. "
"Use -d to specify domains, or run certbot --certificates to see "
"possible certificate names.".format(certname))
for renewal_file in renewal.renewal_conf_files(cli_config):
try:
candidate_lineage = storage.RenewableCert(renewal_file, cli_config)
except (errors.CertStorageError, IOError):
logger.warning("Renewal conf file %s is broken. Skipping.", renewal_file)
logger.debug("Traceback was:\n%s", traceback.format_exc())
continue
# TODO: Handle these differently depending on whether they are
# expired or still valid?
candidate_names = set(candidate_lineage.names())
if candidate_names == set(domains):
identical_names_cert = candidate_lineage
elif candidate_names.issubset(set(domains)):
# This logic finds and returns the largest subset-names cert
# in the case where there are several available.
if subset_names_cert is None:
subset_names_cert = candidate_lineage
elif len(candidate_names) > len(subset_names_cert.names()):
subset_names_cert = candidate_lineage
def _ask_user_to_confirm_new_names(config, new_domains, certname, old_domains):
"""Ask user to confirm update cert certname to contain new_domains.
"""
if config.renew_with_new_domains:
return
msg = ("Confirm that you intend to update certificate {0} "
"to include domains {1}. Note that it previously "
"contained domains {2}.".format(
certname,
new_domains,
old_domains))
obj = zope.component.getUtility(interfaces.IDisplay)
if not obj.yesno(msg, "Update cert", "Cancel", default=True):
raise errors.ConfigurationError("Specified mismatched cert name and domains.")
return identical_names_cert, subset_names_cert
def _find_domains(config, installer):
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
else:
# 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:
if not domains and not certname:
raise errors.Error("Please specify --domains, or --installer that "
"will help in domain names autodiscovery")
"will help in domain names autodiscovery, or "
"--cert-name for an existing certificate name.")
return domains
return domains, certname
def _report_new_cert(config, cert_path, fullchain_path):
@ -348,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(
@ -430,13 +459,13 @@ def install(config, plugins):
except errors.PluginSelectionError as e:
return e.message
domains = _find_domains(config, installer)
domains, _ = _find_domains_or_certname(config, installer)
le_client = _init_le_client(config, authenticator=None, installer=installer)
assert config.cert_path is not None # required=True in the subparser
le_client.deploy_certificate(
domains, config.key_path, config.cert_path, config.chain_path,
config.fullchain_path)
le_client.enhance_config(domains, config)
le_client.enhance_config(domains, config.chain_path)
def plugins_cmd(config, plugins): # TODO: Use IDisplay rather than print
@ -478,6 +507,34 @@ def config_changes(config, unused_plugins):
"""
client.view_config_changes(config, num=config.num)
def update_symlinks(config, unused_plugins):
"""Update the certificate file family symlinks
Use the information in the config file to make symlinks point to
the correct archive directory.
"""
cert_manager.update_live_symlinks(config)
def rename(config, unused_plugins):
"""Rename a certificate
Use the information in the config file to rename an existing
lineage.
"""
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
"""
cert_manager.certificates(config)
def revoke(config, unused_plugins): # TODO: coop with renewal config
"""Revoke a previously obtained certificate."""
@ -493,7 +550,12 @@ def revoke(config, unused_plugins): # TODO: coop with renewal config
key = acc.key
acme = client.acme_from_config_key(config, key)
cert = crypto_util.pyopenssl_load_certificate(config.cert_path[1])[0]
acme.revoke(jose.ComparableX509(cert))
try:
acme.revoke(jose.ComparableX509(cert))
except acme_errors.ClientError as e:
return e.message
display_ops.success_revocation(config.cert_path[0])
def run(config, plugins): # pylint: disable=too-many-branches,too-many-locals
@ -505,23 +567,23 @@ def run(config, plugins): # pylint: disable=too-many-branches,too-many-locals
except errors.PluginSelectionError as e:
return e.message
domains = _find_domains(config, installer)
domains, certname = _find_domains_or_certname(config, installer)
# TODO: Handle errors from _init_le_client?
le_client = _init_le_client(config, authenticator, installer)
lineage, action = _auth_from_domains(le_client, config, domains)
action, lineage = _auth_from_available(le_client, config, domains, certname)
le_client.deploy_certificate(
domains, lineage.privkey, lineage.cert,
lineage.chain, lineage.fullchain)
le_client.enhance_config(domains, config)
le_client.enhance_config(domains, lineage.chain)
if len(lineage.available_versions("cert")) == 1:
if action in ("newcert", "reinstall",):
display_ops.success_installation(domains)
else:
display_ops.success_renewal(domains, action)
display_ops.success_renewal(domains)
_suggest_donation_if_appropriate(config, action)
@ -543,7 +605,6 @@ def _csr_obtain_cert(config, le_client):
certr, chain, config.cert_path, config.chain_path, config.fullchain_path)
_report_new_cert(config, cert_path, cert_fullchain)
def obtain_cert(config, plugins, lineage=None):
"""Authenticate & obtain cert, but do not install it.
@ -561,8 +622,8 @@ def obtain_cert(config, plugins, lineage=None):
# SHOWTIME: Possibly obtain/renew a cert, and set action to renew | newcert | reinstall
if config.csr is None: # the common case
domains = _find_domains(config, installer)
_, action = _auth_from_domains(le_client, config, domains, lineage)
domains, certname = _find_domains_or_certname(config, installer)
action, _ = _auth_from_available(le_client, config, domains, certname, lineage)
else:
assert lineage is None, "Did not expect a CSR with a RenewableCert"
_csr_obtain_cert(config, le_client)
@ -591,7 +652,7 @@ def obtain_cert(config, plugins, lineage=None):
def renew(config, unused_plugins):
"""Renew previously-obtained certificates."""
try:
renewal.renew_all_lineages(config)
renewal.handle_renewal_request(config)
finally:
hooks.post_hook(config, final=True)
@ -601,7 +662,7 @@ def setup_log_file_handler(config, logfile, fmt):
log_file_path = os.path.join(config.logs_dir, logfile)
try:
handler = logging.handlers.RotatingFileHandler(
log_file_path, maxBytes=2 ** 20, backupCount=10)
log_file_path, maxBytes=2 ** 20, backupCount=1000)
except IOError as error:
raise errors.Error(_PERM_ERR_FMT.format(error))
# rotate on each invocation, rollover only possible when maxBytes
@ -615,26 +676,29 @@ def setup_log_file_handler(config, logfile, fmt):
return handler, log_file_path
def _cli_log_handler(config, level, fmt):
if config.text_mode or config.noninteractive_mode or config.verb == "renew":
handler = colored_logging.StreamHandler()
handler.setFormatter(logging.Formatter(fmt))
else:
handler = log.DialogHandler()
# dialog box is small, display as less as possible
handler.setFormatter(logging.Formatter("%(message)s"))
def _cli_log_handler(level, fmt):
handler = colored_logging.StreamHandler()
handler.setFormatter(logging.Formatter(fmt))
handler.setLevel(level)
return handler
def setup_logging(config, cli_handler_factory, logfile):
"""Setup logging."""
file_fmt = "%(asctime)s:%(levelname)s:%(name)s:%(message)s"
def setup_logging(config):
"""Sets up logging to logfiles and the terminal.
:param certbot.interface.IConfig config: Configuration object
"""
cli_fmt = "%(message)s"
level = -config.verbose_count * 10
file_fmt = "%(asctime)s:%(levelname)s:%(name)s:%(message)s"
logfile = "letsencrypt.log"
if config.quiet:
level = constants.QUIET_LOGGING_LEVEL
else:
level = -config.verbose_count * 10
file_handler, log_file_path = setup_log_file_handler(
config, logfile=logfile, fmt=file_fmt)
cli_handler = cli_handler_factory(config, level, cli_fmt)
cli_handler = _cli_log_handler(level, cli_fmt)
# TODO: use fileConfig?
@ -658,10 +722,8 @@ def _handle_exception(exc_type, exc_value, trace, config):
to the user. sys.exit is always called with a nonzero status.
"""
logger.debug(
"Exiting abnormally:%s%s",
os.linesep,
"".join(traceback.format_exception(exc_type, exc_value, trace)))
tb_str = "".join(traceback.format_exception(exc_type, exc_value, trace))
logger.debug("Exiting abnormally:%s%s", os.linesep, tb_str)
if issubclass(exc_type, Exception) and (config is None or not config.debug):
if config is None:
@ -670,9 +732,11 @@ 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("".join(
traceback.format_exception(exc_type, exc_value, trace)))
sys.exit(tb_str)
if "--debug" in sys.argv:
sys.exit(tb_str)
if issubclass(exc_type, errors.Error):
sys.exit(exc_value)
@ -680,16 +744,14 @@ def _handle_exception(exc_type, exc_value, trace, config):
# Here we're passing a client or ACME error out to the client at the shell
# Tell the user a bit about what happened, without overwhelming
# them with a full traceback
if issubclass(exc_type, dialog.error):
err = exc_value.complete_message()
else:
err = traceback.format_exception_only(exc_type, exc_value)[0]
err = traceback.format_exception_only(exc_type, exc_value)[0]
# Typical error from the ACME module:
# acme.messages.Error: urn:acme:error:malformed :: The request message was
# malformed :: Error creating new registration :: Validation of contact
# mailto:none@longrandomstring.biz failed: Server failure at resolver
if (("urn:acme" in err and ":: " in err and
config.verbose_count <= cli.flag_default("verbose_count"))):
# acme.messages.Error: urn:ietf:params:acme:error:malformed :: The
# request message was malformed :: Error creating new registration
# :: Validation of contact mailto:none@longrandomstring.biz failed:
# Server failure at resolver
if (messages.is_acme_error(err) and ":: " in err and
config.verbose_count <= cli.flag_default("verbose_count")):
# prune ACME error code, we have a human description
_code, _sep, err = err.partition(":: ")
msg = "An unexpected error occurred:\n" + err + "Please see the "
@ -699,8 +761,7 @@ def _handle_exception(exc_type, exc_value, trace, config):
msg += "logfiles in {0} for more details.".format(config.logs_dir)
sys.exit(msg)
else:
sys.exit("".join(
traceback.format_exception(exc_type, exc_value, trace)))
sys.exit(tb_str)
def make_or_verify_core_dir(directory, mode, uid, strict):
@ -719,6 +780,30 @@ def make_or_verify_core_dir(directory, mode, uid, strict):
except OSError as error:
raise errors.Error(_PERM_ERR_FMT.format(error))
def make_or_verify_needed_dirs(config):
"""Create or verify existance of config, work, or logs directories"""
make_or_verify_core_dir(config.config_dir, constants.CONFIG_DIRS_MODE,
os.geteuid(), config.strict_permissions)
make_or_verify_core_dir(config.work_dir, constants.CONFIG_DIRS_MODE,
os.geteuid(), config.strict_permissions)
# TODO: logs might contain sensitive data such as contents of the
# private key! #525
make_or_verify_core_dir(config.logs_dir, 0o700,
os.geteuid(), config.strict_permissions)
def set_displayer(config):
"""Set the displayer"""
if config.quiet:
config.noninteractive_mode = True
displayer = display_util.NoninteractiveDisplay(open(os.devnull, "w"))
elif config.noninteractive_mode:
displayer = display_util.NoninteractiveDisplay(sys.stdout)
else:
displayer = display_util.FileDisplay(sys.stdout,
config.force_interactive)
zope.component.provideUtility(displayer)
def main(cli_args=sys.argv[1:]):
"""Command line argument parsing and main script execution."""
@ -730,17 +815,12 @@ def main(cli_args=sys.argv[1:]):
config = configuration.NamespaceConfig(args)
zope.component.provideUtility(config)
make_or_verify_core_dir(config.config_dir, constants.CONFIG_DIRS_MODE,
os.geteuid(), config.strict_permissions)
make_or_verify_core_dir(config.work_dir, constants.CONFIG_DIRS_MODE,
os.geteuid(), config.strict_permissions)
# TODO: logs might contain sensitive data such as contents of the
# private key! #525
make_or_verify_core_dir(config.logs_dir, 0o700,
os.geteuid(), config.strict_permissions)
make_or_verify_needed_dirs(config)
# Setup logging ASAP, otherwise "No handlers could be found for
# logger ..." TODO: this should be done before plugins discovery
setup_logging(config, _cli_log_handler, logfile='letsencrypt.log')
setup_logging(config)
cli.possible_deprecation_warning(config)
logger.debug("certbot version: %s", certbot.__version__)
@ -750,17 +830,7 @@ def main(cli_args=sys.argv[1:]):
sys.excepthook = functools.partial(_handle_exception, config=config)
# Displayer
if config.quiet:
config.noninteractive_mode = True
displayer = display_util.NoninteractiveDisplay(open(os.devnull, "w"))
elif config.noninteractive_mode:
displayer = display_util.NoninteractiveDisplay(sys.stdout)
elif config.text_mode:
displayer = display_util.FileDisplay(sys.stdout)
else:
displayer = display_util.NcursesDisplay()
zope.component.provideUtility(displayer)
set_displayer(config)
# Reporter
report = reporter.Reporter(config)

109
certbot/ocsp.py Normal file
View file

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

View file

@ -127,17 +127,18 @@ class Addr(object):
return "%s:%s" % self.tup
return self.tup[0]
def normalized_tuple(self):
"""Normalized representation of addr/port tuple
"""
if self.ipv6:
return (self._normalize_ipv6(self.tup[0]), self.tup[1])
return self.tup
def __eq__(self, other):
if isinstance(other, self.__class__):
if self.ipv6:
# compare normalized to take different
# styles of representation into account
return (other.ipv6 and
self._normalize_ipv6(self.tup[0]) ==
self._normalize_ipv6(other.tup[0]) and
self.tup[1] == other.tup[1])
else:
return self.tup == other.tup
# compare normalized to take different
# styles of representation into account
return self.normalized_tuple() == other.normalized_tuple()
return False

View file

@ -10,7 +10,7 @@ from acme import jose
from certbot import achallenges
from certbot.tests import acme_util
from certbot.tests import test_util
from certbot.tests import util as test_util
class NamespaceFunctionsTest(unittest.TestCase):

View file

@ -53,6 +53,14 @@ class PluginEntryPoint(object):
"""Description with name. Handy for UI."""
return "{0} ({1})".format(self.description, self.name)
@property
def long_description(self):
"""Long description of the plugin."""
try:
return self.plugin_cls.long_description
except AttributeError:
return self.description
@property
def hidden(self):
"""Should this plugin be hidden from UI?"""
@ -255,4 +263,4 @@ class PluginsRegistry(collections.Mapping):
def __str__(self):
if not self._plugins:
return "No plugins"
return "\n\n".join(str(p_ep) for p_ep in self._plugins.itervalues())
return "\n\n".join(str(p_ep) for p_ep in six.itervalues(self._plugins))

View file

@ -3,6 +3,7 @@ import unittest
import mock
import pkg_resources
import six
import zope.interface
from certbot import errors
@ -50,20 +51,30 @@ class PluginEntryPointTest(unittest.TestCase):
EP_SA: "sa",
}
for entry_point, name in names.iteritems():
for entry_point, name in six.iteritems(names):
self.assertEqual(
name, PluginEntryPoint.entry_point_to_plugin_name(entry_point))
def test_description(self):
self.assertEqual(
"Automatically use a temporary webserver",
self.plugin_ep.description)
self.assertTrue("temporary webserver" in self.plugin_ep.description)
def test_description_with_name(self):
self.plugin_ep.plugin_cls = mock.MagicMock(description="Desc")
self.assertEqual(
"Desc (sa)", self.plugin_ep.description_with_name)
def test_long_description(self):
self.plugin_ep.plugin_cls = mock.MagicMock(
long_description="Long desc")
self.assertEqual(
"Long desc", self.plugin_ep.long_description)
def test_long_description_nonexistent(self):
self.plugin_ep.plugin_cls = mock.MagicMock(
description="Long desc not found", spec=["description"])
self.assertEqual(
"Long desc not found", self.plugin_ep.long_description)
def test_ifaces(self):
self.assertTrue(self.plugin_ep.ifaces((interfaces.IAuthenticator,)))
self.assertFalse(self.plugin_ep.ifaces((interfaces.IInstaller,)))

View file

@ -1,47 +1,49 @@
"""Manual plugin."""
"""Manual authenticator plugin"""
import os
import logging
import pipes
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
import six
import zope.component
import zope.interface
from acme import challenges
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
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:
description = "Manually configure an HTTP server"
{validation}
MESSAGE_TEMPLATE = """\
Once this is deployed,"""
_HTTP_INSTRUCTIONS = """\
Make sure your web server displays the following content at
{uri} before continuing:
@ -50,155 +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 an 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.")
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]
return [challenges.HTTP01, challenges.DNS01]
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(self._perform_single(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 _perform_single(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:
if not 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")
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
self._notify_and_wait(self.MESSAGE_TEMPLATE.format(
validation=validation, response=response,
uri=achall.chall.uri(achall.domain),
command=command))
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:
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)
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 _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, height=25, pause=True)
sys.stdout.write(message)
six.moves.input("Press ENTER to continue")
def cleanup(self, achalls):
# pylint: disable=missing-docstring,no-self-use,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")
os.killpg(self._httpd.pid, signal.SIGTERM)
else:
logger.debug("Manual command process already terminated "
"with %s code", self._httpd.returncode)
shutil.rmtree(self._root)
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'))

View file

@ -1,113 +1,112 @@
"""Tests for certbot.plugins.manual."""
import signal
"""Tests for certbot.plugins.manual"""
import os
import unittest
import six
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 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")
self.achalls = [achallenges.KeyAuthorizationAnnotatedChallenge(
challb=acme_util.HTTP01_P, domain="foo.com", account_key=KEY)]
http01_port=0, manual_auth_hook=None, manual_cleanup_hook=None,
manual_public_ip_logging_ok=False, noninteractive_mode=False,
validate_hooks=False)
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")
from certbot.plugins.manual import Authenticator
self.auth = Authenticator(self.config, 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])
def test_perform_empty(self):
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("__builtin__.raw_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 = self.achalls[0].response(KEY)
self.assertEqual([resp], self.auth.perform(self.achalls))
self.assertEqual(1, mock_raw_input.call_count)
mock_verify.assert_called_with(
self.achalls[0].challb.chall, "foo.com", KEY.public_key(), 8080)
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))
message = mock_stdout.write.mock_calls[0][1][0]
self.assertTrue(self.achalls[0].chall.encode("token") in message)
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)
mock_verify.return_value = False
with mock.patch("certbot.plugins.manual.logger") as mock_logger:
self.auth.perform(self.achalls)
mock_logger.warning.assert_called_once_with(mock.ANY)
@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.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")
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)
self.assertRaises(errors.PluginError, self.auth.perform, self.achalls)
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))
@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.achalls))
@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)
@mock.patch("certbot.plugins.manual.os.killpg", autospec=True)
def test_cleanup_test_mode_kills_still_running(self, mock_killpg):
# 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)
mock_killpg.assert_called_once_with(1234, signal.SIGTERM)
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

View file

@ -40,9 +40,6 @@ class Installer(common.Plugin):
def supported_enhancements(self):
return []
def get_all_certs_keys(self):
return []
def save(self, title=None, temporary=False):
pass # pragma: no cover

View file

@ -15,7 +15,6 @@ class InstallerTest(unittest.TestCase):
self.assertTrue(isinstance(self.installer.more_info(), str))
self.assertEqual([], self.installer.get_all_names())
self.assertEqual([], self.installer.supported_enhancements())
self.assertEqual([], self.installer.get_all_certs_keys())
if __name__ == "__main__":

View file

@ -4,6 +4,7 @@ from __future__ import print_function
import os
import logging
import six
import zope.component
from certbot import errors
@ -78,7 +79,7 @@ def pick_plugin(config, default, plugins, question, ifaces):
if len(prepared) > 1:
logger.debug("Multiple candidate plugins: %s", prepared)
plugin_ep = choose_plugin(prepared.values(), question)
plugin_ep = choose_plugin(list(six.itervalues(prepared)), question)
if plugin_ep is None:
return None
else:
@ -110,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]
@ -118,8 +120,7 @@ def choose_plugin(prepared, question):
z_util(interfaces.IDisplay).notification(
"The selected plugin encountered an error while parsing "
"your server configuration and cannot be used. The error "
"was:\n\n{0}".format(plugin_ep.prepare()),
height=display_util.HEIGHT, pause=False)
"was:\n\n{0}".format(plugin_ep.prepare()), pause=False)
else:
return plugin_ep
elif code == display_util.HELP:
@ -127,8 +128,8 @@ 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, height=display_util.HEIGHT)
z_util(interfaces.IDisplay).notification(msg,
force_interactive=True)
else:
return None
@ -260,9 +261,12 @@ def diagnose_configurator_problem(cfg_type, requested, plugins):
"your existing configuration.\nThe error was: {1!r}"
.format(requested, plugins[requested].problem))
elif cfg_type == "installer":
msg = ('No installer plugins seem to be present and working on your system; '
'fix that or try running certbot with the "certonly" command to obtain'
' a certificate you can install manually')
from certbot.cli import cli_command
msg = ('Certbot doesn\'t know how to automatically configure the web '
'server on this system. However, it can still get a certificate for '
'you. Please run "{0} certonly" to do so. You\'ll need to '
'manually configure your web server to use the resulting '
'certificate.').format(cli_command)
else:
msg = "{0} could not be determined or is not installed".format(cfg_type)
raise errors.PluginSelectionError(msg)

Some files were not shown because too many files have changed in this diff Show more