mirror of
https://github.com/certbot/certbot.git
synced 2026-06-03 22:08:07 -04:00
Merge remote-tracking branch 'github/letsencrypt/master' into mock-2.6
This commit is contained in:
commit
3f08932479
117 changed files with 2079 additions and 1750 deletions
4
.pep8
Normal file
4
.pep8
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[pep8]
|
||||
# E265 block comment should start with '# '
|
||||
# E501 line too long (X > 79 characters)
|
||||
ignore = E265,E501
|
||||
|
|
@ -38,7 +38,7 @@ load-plugins=linter_plugin
|
|||
# --enable=similarities". If you want to run only the classes checker, but have
|
||||
# no Warning level messages displayed, use"--disable=all --enable=classes
|
||||
# --disable=W"
|
||||
disable=fixme,locally-disabled,abstract-class-not-used
|
||||
disable=fixme,locally-disabled,abstract-class-not-used,bad-continuation,too-few-public-methods,no-self-use,invalid-name
|
||||
# abstract-class-not-used cannot be disabled locally (at least in pylint 1.4.1)
|
||||
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ function-rgx=[a-z_][a-z0-9_]{2,40}$
|
|||
function-name-hint=[a-z_][a-z0-9_]{2,40}$
|
||||
|
||||
# Regular expression matching correct variable names
|
||||
variable-rgx=[a-z_][a-z0-9_]{2,30}$
|
||||
variable-rgx=[a-z_][a-z0-9_]{1,30}$
|
||||
|
||||
# Naming hint for variable names
|
||||
variable-name-hint=[a-z_][a-z0-9_]{2,30}$
|
||||
|
|
@ -218,7 +218,7 @@ ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
|||
single-line-if-stmt=no
|
||||
|
||||
# List of optional constructs for which whitespace checking is disabled
|
||||
no-space-check=trailing-comma,dict-separator
|
||||
no-space-check=trailing-comma
|
||||
|
||||
# Maximum number of lines in a module
|
||||
max-module-lines=1250
|
||||
|
|
@ -228,7 +228,8 @@ max-module-lines=1250
|
|||
indent-string=' '
|
||||
|
||||
# Number of spaces of indent required inside a hanging or continued line.
|
||||
indent-after-paren=4
|
||||
# This does something silly/broken...
|
||||
#indent-after-paren=4
|
||||
|
||||
|
||||
[TYPECHECK]
|
||||
|
|
|
|||
26
.travis.yml
26
.travis.yml
|
|
@ -1,8 +1,5 @@
|
|||
language: python
|
||||
|
||||
go:
|
||||
- 1.5
|
||||
|
||||
services:
|
||||
- rabbitmq
|
||||
- mysql
|
||||
|
|
@ -10,8 +7,6 @@ services:
|
|||
# http://docs.travis-ci.com/user/ci-environment/#CI-environment-OS
|
||||
# gimme has to be kept in sync with Boulder's Go version setting in .travis.yml
|
||||
before_install:
|
||||
- travis_retry sudo ./bootstrap/ubuntu.sh
|
||||
- travis_retry sudo apt-get install --no-install-recommends nginx-light openssl
|
||||
- '[ "xxx$BOULDER_INTEGRATION" = "xxx" ] || eval "$(gimme 1.5)"'
|
||||
|
||||
# using separate envs with different TOXENVs creates 4x1 Travis build
|
||||
|
|
@ -22,16 +17,33 @@ env:
|
|||
- GOPATH=/tmp/go
|
||||
- PATH=$GOPATH/bin:$PATH
|
||||
matrix:
|
||||
- TOXENV=py26 BOULDER_INTEGRATION=1
|
||||
- TOXENV=py27 BOULDER_INTEGRATION=1
|
||||
- TOXENV=lint
|
||||
- TOXENV=cover
|
||||
|
||||
# make sure simplehttp simple verification works (custom /etc/hosts)
|
||||
sudo: false # containers
|
||||
addons:
|
||||
# make sure simplehttp simple verification works (custom /etc/hosts)
|
||||
hosts:
|
||||
- le.wtf
|
||||
mariadb: "10.0"
|
||||
apt:
|
||||
packages: # keep in sync with bootstrap/ubuntu.sh and Boulder
|
||||
- lsb-release
|
||||
- python
|
||||
- python-dev
|
||||
- python-virtualenv
|
||||
- gcc
|
||||
- dialog
|
||||
- libaugeas0
|
||||
- libssl-dev
|
||||
- libffi-dev
|
||||
- ca-certificates
|
||||
# For letsencrypt-nginx integration testing
|
||||
- nginx-light
|
||||
- openssl
|
||||
# For Boulder integration testing
|
||||
- rsyslog
|
||||
|
||||
install: "travis_retry pip install tox coveralls"
|
||||
before_script: '[ "xxx$BOULDER_INTEGRATION" = "xxx" ] || ./tests/boulder-start.sh'
|
||||
|
|
|
|||
|
|
@ -62,5 +62,5 @@ RUN virtualenv --no-site-packages -p python2 /opt/letsencrypt/venv && \
|
|||
# bash" and investigate, apply patches, etc.
|
||||
|
||||
ENV PATH /opt/letsencrypt/venv/bin:$PATH
|
||||
# TODO: is --text really necessary?
|
||||
ENTRYPOINT [ "letsencrypt", "--text" ]
|
||||
|
||||
ENTRYPOINT [ "letsencrypt" ]
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ RUN /opt/letsencrypt/src/ubuntu.sh && \
|
|||
# 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 requirements.txt EULA linter_plugin.py tox.cover.sh tox.ini /opt/letsencrypt/src/
|
||||
COPY setup.py README.rst CHANGES.rst MANIFEST.in requirements.txt EULA linter_plugin.py tox.cover.sh tox.ini pep8.travis.sh .pep8 .pylintrc /opt/letsencrypt/src/
|
||||
|
||||
# all above files are necessary for setup.py, however, package source
|
||||
# code directory has to be copied separately to a subdirectory...
|
||||
|
|
@ -46,6 +46,8 @@ COPY letsencrypt /opt/letsencrypt/src/letsencrypt/
|
|||
COPY acme /opt/letsencrypt/src/acme/
|
||||
COPY letsencrypt-apache /opt/letsencrypt/src/letsencrypt-apache/
|
||||
COPY letsencrypt-nginx /opt/letsencrypt/src/letsencrypt-nginx/
|
||||
COPY letshelp-letsencrypt /opt/letsencrypt/src/letshelp-letsencrypt/
|
||||
COPY letsencrypt-compatibility-test /opt/letsencrypt/src/letsencrypt-compatibility-test/
|
||||
COPY tests /opt/letsencrypt/src/tests/
|
||||
|
||||
RUN virtualenv --no-site-packages -p python2 /opt/letsencrypt/venv && \
|
||||
|
|
@ -55,6 +57,8 @@ RUN virtualenv --no-site-packages -p python2 /opt/letsencrypt/venv && \
|
|||
-e /opt/letsencrypt/src \
|
||||
-e /opt/letsencrypt/src/letsencrypt-apache \
|
||||
-e /opt/letsencrypt/src/letsencrypt-nginx \
|
||||
-e /opt/letsencrypt/src/letshelp-letsencrypt \
|
||||
-e /opt/letsencrypt/src/letsencrypt-compatibility-test \
|
||||
-e /opt/letsencrypt/src[dev,docs,testing]
|
||||
|
||||
# install in editable mode (-e) to save space: it's not possible to
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
Let's Encrypt:
|
||||
Copyright (c) Internet Security Research Group
|
||||
Let's Encrypt Python Client
|
||||
Copyright (c) Electronic Frontier Foundation and others
|
||||
Licensed Apache Version 2.0
|
||||
|
||||
Incorporating code from nginxparser
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ Current Features
|
|||
* web servers supported:
|
||||
|
||||
- apache/2.x (tested and working on Ubuntu Linux)
|
||||
- nginx/0.8.48+ (tested and mostly working on Ubuntu Linux)
|
||||
- nginx/0.8.48+ (under development)
|
||||
- standalone (runs its own webserver to prove you control the domain)
|
||||
|
||||
* the private key is generated locally on your system
|
||||
|
|
@ -116,6 +116,8 @@ Main Website: https://letsencrypt.org/
|
|||
|
||||
IRC Channel: #letsencrypt on `Freenode`_
|
||||
|
||||
Community: https://community.letsencrypt.org
|
||||
|
||||
Mailing list: `client-dev`_ (to subscribe without a Google account, send an
|
||||
email to client-dev+subscribe@letsencrypt.org)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ class Challenge(jose.TypedJSONObjectWithFields):
|
|||
"""ACME challenge."""
|
||||
TYPES = {}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
try:
|
||||
return super(Challenge, cls).from_json(jobj)
|
||||
except jose.UnrecognizedTypeError as error:
|
||||
logger.debug(error)
|
||||
return UnrecognizedChallenge.from_json(jobj)
|
||||
|
||||
|
||||
class ContinuityChallenge(Challenge): # pylint: disable=abstract-method
|
||||
"""Client validation challenges."""
|
||||
|
|
@ -42,6 +50,32 @@ class ChallengeResponse(jose.TypedJSONObjectWithFields):
|
|||
resource = fields.Resource(resource_type)
|
||||
|
||||
|
||||
class UnrecognizedChallenge(Challenge):
|
||||
"""Unrecognized challenge.
|
||||
|
||||
ACME specification defines a generic framework for challenges and
|
||||
defines some standard challenges that are implemented in this
|
||||
module. However, other implementations (including peers) might
|
||||
define additional challenge types, which should be ignored if
|
||||
unrecognized.
|
||||
|
||||
:ivar jobj: Original JSON decoded object.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, jobj):
|
||||
super(UnrecognizedChallenge, self).__init__()
|
||||
object.__setattr__(self, "jobj", jobj)
|
||||
|
||||
def to_partial_json(self):
|
||||
# pylint: disable=no-member
|
||||
return self.jobj
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
return cls(jobj)
|
||||
|
||||
|
||||
@Challenge.register
|
||||
class SimpleHTTP(DVChallenge):
|
||||
"""ACME "simpleHttp" challenge.
|
||||
|
|
@ -514,10 +548,100 @@ class DNS(DVChallenge):
|
|||
|
||||
"""
|
||||
typ = "dns"
|
||||
token = jose.Field("token")
|
||||
|
||||
LABEL = "_acme-challenge"
|
||||
"""Label clients prepend to the domain name being validated."""
|
||||
|
||||
TOKEN_SIZE = 128 / 8 # Based on the entropy value from the spec
|
||||
"""Minimum size of the :attr:`token` in bytes."""
|
||||
|
||||
token = jose.Field(
|
||||
"token", encoder=jose.encode_b64jose, decoder=functools.partial(
|
||||
jose.decode_b64jose, size=TOKEN_SIZE, minimum=True))
|
||||
|
||||
def gen_validation(self, account_key, alg=jose.RS256, **kwargs):
|
||||
"""Generate validation.
|
||||
|
||||
:param .JWK account_key: Private account key.
|
||||
:param .JWA alg:
|
||||
|
||||
:returns: This challenge wrapped in `.JWS`
|
||||
:rtype: .JWS
|
||||
|
||||
"""
|
||||
return jose.JWS.sign(
|
||||
payload=self.json_dumps(sort_keys=True).encode('utf-8'),
|
||||
key=account_key, alg=alg, **kwargs)
|
||||
|
||||
def check_validation(self, validation, account_public_key):
|
||||
"""Check validation.
|
||||
|
||||
:param JWS validation:
|
||||
:type account_public_key:
|
||||
`~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey`
|
||||
or
|
||||
`~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey`
|
||||
or
|
||||
`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey`
|
||||
wrapped in `.ComparableKey`
|
||||
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
if not validation.verify(key=account_public_key):
|
||||
return False
|
||||
try:
|
||||
return self == self.json_loads(
|
||||
validation.payload.decode('utf-8'))
|
||||
except jose.DeserializationError as error:
|
||||
logger.debug("Checking validation for DNS failed: %s", error)
|
||||
return False
|
||||
|
||||
def gen_response(self, account_key, **kwargs):
|
||||
"""Generate response.
|
||||
|
||||
:param .JWK account_key: Private account key.
|
||||
:param .JWA alg:
|
||||
|
||||
:rtype: DNSResponse
|
||||
|
||||
"""
|
||||
return DNSResponse(validation=self.gen_validation(
|
||||
self, account_key, **kwargs))
|
||||
|
||||
def validation_domain_name(self, name):
|
||||
"""Domain name for TXT validation record.
|
||||
|
||||
:param unicode name: Domain name being validated.
|
||||
|
||||
"""
|
||||
return "{0}.{1}".format(self.LABEL, name)
|
||||
|
||||
|
||||
@ChallengeResponse.register
|
||||
class DNSResponse(ChallengeResponse):
|
||||
"""ACME "dns" challenge response."""
|
||||
"""ACME "dns" challenge response.
|
||||
|
||||
:param JWS validation:
|
||||
|
||||
"""
|
||||
typ = "dns"
|
||||
|
||||
validation = jose.Field("validation", decoder=jose.JWS.from_json)
|
||||
|
||||
def check_validation(self, chall, account_public_key):
|
||||
"""Check validation.
|
||||
|
||||
:param challenges.DNS chall:
|
||||
:type account_public_key:
|
||||
`~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey`
|
||||
or
|
||||
`~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey`
|
||||
or
|
||||
`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey`
|
||||
wrapped in `.ComparableKey`
|
||||
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
return chall.check_validation(self.validation, account_public_key)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,32 @@ CERT = test_util.load_cert('cert.pem')
|
|||
KEY = test_util.load_rsa_private_key('rsa512_key.pem')
|
||||
|
||||
|
||||
class ChallengeTest(unittest.TestCase):
|
||||
|
||||
def test_from_json_unrecognized(self):
|
||||
from acme.challenges import Challenge
|
||||
from acme.challenges import UnrecognizedChallenge
|
||||
chall = UnrecognizedChallenge({"type": "foo"})
|
||||
# pylint: disable=no-member
|
||||
self.assertEqual(chall, Challenge.from_json(chall.jobj))
|
||||
|
||||
|
||||
class UnrecognizedChallengeTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from acme.challenges import UnrecognizedChallenge
|
||||
self.jobj = {"type": "foo"}
|
||||
self.chall = UnrecognizedChallenge(self.jobj)
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jobj, self.chall.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import UnrecognizedChallenge
|
||||
self.assertEqual(
|
||||
self.chall, UnrecognizedChallenge.from_json(self.jobj))
|
||||
|
||||
|
||||
class SimpleHTTPTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
|
@ -136,7 +162,7 @@ class SimpleHTTPResponseTest(unittest.TestCase):
|
|||
jose.JWS.sign(payload=bad_resource.json_dumps().encode('utf-8'),
|
||||
alg=jose.RS256, key=account_key)
|
||||
for bad_resource in (resource.update(tls=True),
|
||||
resource.update(token=b'x'*20))
|
||||
resource.update(token=(b'x' * 20)))
|
||||
)
|
||||
for validation in validations:
|
||||
self.assertFalse(self.resp_http.check_validation(
|
||||
|
|
@ -144,7 +170,7 @@ class SimpleHTTPResponseTest(unittest.TestCase):
|
|||
account_public_key=account_key.public_key()))
|
||||
|
||||
@mock.patch("acme.challenges.requests.get")
|
||||
def test_simple_verify_good_token(self, mock_get):
|
||||
def test_simple_verify_good_validation(self, mock_get):
|
||||
account_key = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||
for resp in self.resp_http, self.resp_https:
|
||||
mock_get.reset_mock()
|
||||
|
|
@ -156,9 +182,9 @@ class SimpleHTTPResponseTest(unittest.TestCase):
|
|||
"local", self.chall), verify=False)
|
||||
|
||||
@mock.patch("acme.challenges.requests.get")
|
||||
def test_simple_verify_bad_token(self, mock_get):
|
||||
def test_simple_verify_bad_validation(self, mock_get):
|
||||
mock_get.return_value = mock.MagicMock(
|
||||
text=self.chall.token + "!", headers=self.good_headers)
|
||||
text="!", headers=self.good_headers)
|
||||
self.assertFalse(self.resp_http.simple_verify(
|
||||
self.chall, "local", None))
|
||||
|
||||
|
|
@ -320,7 +346,7 @@ class DVSNIResponseTest(unittest.TestCase):
|
|||
|
||||
def test_simple_verify_wrong_token(self):
|
||||
msg = self.msg.update(validation=jose.JWS.sign(
|
||||
payload=self.chall.update(token=b'b'*20).json_dumps().encode(),
|
||||
payload=self.chall.update(token=(b'b' * 20)).json_dumps().encode(),
|
||||
key=self.key, alg=jose.RS256))
|
||||
self.assertFalse(msg.simple_verify(
|
||||
self.chall, self.domain, self.key.public_key()))
|
||||
|
|
@ -350,9 +376,9 @@ class RecoveryContactTest(unittest.TestCase):
|
|||
contact='c********n@example.com')
|
||||
self.jmsg = {
|
||||
'type': 'recoveryContact',
|
||||
'activationURL' : 'https://example.ca/sendrecovery/a5bd99383fb0',
|
||||
'successURL' : 'https://example.ca/confirmrecovery/bb1b9928932',
|
||||
'contact' : 'c********n@example.com',
|
||||
'activationURL': 'https://example.ca/sendrecovery/a5bd99383fb0',
|
||||
'successURL': 'https://example.ca/confirmrecovery/bb1b9928932',
|
||||
'contact': 'c********n@example.com',
|
||||
}
|
||||
|
||||
def test_to_partial_json(self):
|
||||
|
|
@ -570,9 +596,15 @@ class ProofOfPossessionResponseTest(unittest.TestCase):
|
|||
class DNSTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.account_key = jose.JWKRSA.load(
|
||||
test_util.load_vector('rsa512_key.pem'))
|
||||
from acme.challenges import DNS
|
||||
self.msg = DNS(token='17817c66b60ce2e4012dfad92657527a')
|
||||
self.jmsg = {'type': 'dns', 'token': '17817c66b60ce2e4012dfad92657527a'}
|
||||
self.msg = DNS(token=jose.b64decode(
|
||||
b'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA'))
|
||||
self.jmsg = {
|
||||
'type': 'dns',
|
||||
'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA',
|
||||
}
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg, self.msg.to_partial_json())
|
||||
|
|
@ -585,27 +617,84 @@ class DNSTest(unittest.TestCase):
|
|||
from acme.challenges import DNS
|
||||
hash(DNS.from_json(self.jmsg))
|
||||
|
||||
def test_gen_check_validation(self):
|
||||
self.assertTrue(self.msg.check_validation(
|
||||
self.msg.gen_validation(self.account_key),
|
||||
self.account_key.public_key()))
|
||||
|
||||
def test_gen_check_validation_wrong_key(self):
|
||||
key2 = jose.JWKRSA.load(test_util.load_vector('rsa1024_key.pem'))
|
||||
self.assertFalse(self.msg.check_validation(
|
||||
self.msg.gen_validation(self.account_key), key2.public_key()))
|
||||
|
||||
def test_check_validation_wrong_payload(self):
|
||||
validations = tuple(
|
||||
jose.JWS.sign(payload=payload, alg=jose.RS256, key=self.account_key)
|
||||
for payload in (b'', b'{}')
|
||||
)
|
||||
for validation in validations:
|
||||
self.assertFalse(self.msg.check_validation(
|
||||
validation, self.account_key.public_key()))
|
||||
|
||||
def test_check_validation_wrong_fields(self):
|
||||
bad_validation = jose.JWS.sign(
|
||||
payload=self.msg.update(token=b'x' * 20).json_dumps().encode('utf-8'),
|
||||
alg=jose.RS256, key=self.account_key)
|
||||
self.assertFalse(self.msg.check_validation(
|
||||
bad_validation, self.account_key.public_key()))
|
||||
|
||||
def test_gen_response(self):
|
||||
with mock.patch('acme.challenges.DNS.gen_validation') as mock_gen:
|
||||
mock_gen.return_value = mock.sentinel.validation
|
||||
response = self.msg.gen_response(self.account_key)
|
||||
from acme.challenges import DNSResponse
|
||||
self.assertTrue(isinstance(response, DNSResponse))
|
||||
self.assertEqual(response.validation, mock.sentinel.validation)
|
||||
|
||||
def test_validation_domain_name(self):
|
||||
self.assertEqual(
|
||||
'_acme-challenge.le.wtf', self.msg.validation_domain_name('le.wtf'))
|
||||
|
||||
|
||||
class DNSResponseTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.key = jose.JWKRSA(key=KEY)
|
||||
|
||||
from acme.challenges import DNS
|
||||
self.chall = DNS(token=jose.b64decode(
|
||||
b"evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA"))
|
||||
self.validation = jose.JWS.sign(
|
||||
payload=self.chall.json_dumps(sort_keys=True).encode(),
|
||||
key=self.key, alg=jose.RS256)
|
||||
|
||||
from acme.challenges import DNSResponse
|
||||
self.msg = DNSResponse()
|
||||
self.jmsg = {
|
||||
self.msg = DNSResponse(validation=self.validation)
|
||||
self.jmsg_to = {
|
||||
'resource': 'challenge',
|
||||
'type': 'dns',
|
||||
'validation': self.validation,
|
||||
}
|
||||
self.jmsg_from = {
|
||||
'resource': 'challenge',
|
||||
'type': 'dns',
|
||||
'validation': self.validation.to_json(),
|
||||
}
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jmsg, self.msg.to_partial_json())
|
||||
self.assertEqual(self.jmsg_to, self.msg.to_partial_json())
|
||||
|
||||
def test_from_json(self):
|
||||
from acme.challenges import DNSResponse
|
||||
self.assertEqual(self.msg, DNSResponse.from_json(self.jmsg))
|
||||
self.assertEqual(self.msg, DNSResponse.from_json(self.jmsg_from))
|
||||
|
||||
def test_from_json_hashable(self):
|
||||
from acme.challenges import DNSResponse
|
||||
hash(DNSResponse.from_json(self.jmsg))
|
||||
hash(DNSResponse.from_json(self.jmsg_from))
|
||||
|
||||
def test_check_validation(self):
|
||||
self.assertTrue(
|
||||
self.msg.check_validation(self.chall, self.key.public_key()))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ import heapq
|
|||
import logging
|
||||
import time
|
||||
|
||||
import six
|
||||
from six.moves import http_client # pylint: disable=import-error
|
||||
|
||||
import OpenSSL
|
||||
import requests
|
||||
import six
|
||||
import sys
|
||||
import werkzeug
|
||||
|
||||
from acme import errors
|
||||
|
|
@ -19,8 +20,9 @@ from acme import messages
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Python does not validate certificates by default before version 2.7.9
|
||||
# https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning
|
||||
if six.PY2:
|
||||
if sys.version_info < (2, 7, 9): # pragma: no cover
|
||||
requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3()
|
||||
|
||||
|
||||
|
|
@ -31,7 +33,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes
|
|||
Clean up raised error types hierarchy, document, and handle (wrap)
|
||||
instances of `.DeserializationError` raised in `from_json()`.
|
||||
|
||||
:ivar str new_reg_uri: Location of new-reg
|
||||
:ivar messages.Directory directory:
|
||||
:ivar key: `.JWK` (private)
|
||||
:ivar alg: `.JWASignature`
|
||||
:ivar bool verify_ssl: Verify SSL certificates?
|
||||
|
|
@ -42,12 +44,23 @@ class Client(object): # pylint: disable=too-many-instance-attributes
|
|||
"""
|
||||
DER_CONTENT_TYPE = 'application/pkix-cert'
|
||||
|
||||
def __init__(self, new_reg_uri, key, alg=jose.RS256,
|
||||
verify_ssl=True, net=None):
|
||||
self.new_reg_uri = new_reg_uri
|
||||
def __init__(self, directory, key, alg=jose.RS256, verify_ssl=True,
|
||||
net=None):
|
||||
"""Initialize.
|
||||
|
||||
:param directory: Directory Resource (`.messages.Directory`) or
|
||||
URI from which the resource will be downloaded.
|
||||
|
||||
"""
|
||||
self.key = key
|
||||
self.net = ClientNetwork(key, alg, verify_ssl) if net is None else net
|
||||
|
||||
if isinstance(directory, six.string_types):
|
||||
self.directory = messages.Directory.from_json(
|
||||
self.net.get(directory).json())
|
||||
else:
|
||||
self.directory = directory
|
||||
|
||||
@classmethod
|
||||
def _regr_from_response(cls, response, uri=None, new_authzr_uri=None,
|
||||
terms_of_service=None):
|
||||
|
|
@ -81,7 +94,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes
|
|||
new_reg = messages.NewRegistration() if new_reg is None else new_reg
|
||||
assert isinstance(new_reg, messages.NewRegistration)
|
||||
|
||||
response = self.net.post(self.new_reg_uri, new_reg)
|
||||
response = self.net.post(self.directory[new_reg], new_reg)
|
||||
# TODO: handle errors
|
||||
assert response.status_code == http_client.CREATED
|
||||
|
||||
|
|
@ -416,20 +429,34 @@ class Client(object): # pylint: disable=too-many-instance-attributes
|
|||
# respond with status code 403 (Forbidden)
|
||||
return self.check_cert(certr)
|
||||
|
||||
def fetch_chain(self, certr):
|
||||
def fetch_chain(self, certr, max_length=10):
|
||||
"""Fetch chain for certificate.
|
||||
|
||||
:param certr: Certificate Resource
|
||||
:type certr: `.CertificateResource`
|
||||
:param .CertificateResource certr: Certificate Resource
|
||||
:param int max_length: Maximum allowed length of the chain.
|
||||
Note that each element in the certificate requires new
|
||||
``HTTP GET`` request, and the length of the chain is
|
||||
controlled by the ACME CA.
|
||||
|
||||
:returns: Certificate chain, or `None` if no "up" Link was provided.
|
||||
:rtype: `OpenSSL.crypto.X509` wrapped in `.ComparableX509`
|
||||
:raises errors.Error: if recursion exceeds `max_length`
|
||||
|
||||
:returns: Certificate chain for the Certificate Resource. It is
|
||||
a list ordered so that the first element is a signer of the
|
||||
certificate from Certificate Resource. Will be empty if
|
||||
``cert_chain_uri`` is ``None``.
|
||||
:rtype: `list` of `OpenSSL.crypto.X509` wrapped in `.ComparableX509`
|
||||
|
||||
"""
|
||||
if certr.cert_chain_uri is not None:
|
||||
return self._get_cert(certr.cert_chain_uri)[1]
|
||||
else:
|
||||
return None
|
||||
chain = []
|
||||
uri = certr.cert_chain_uri
|
||||
while uri is not None and len(chain) < max_length:
|
||||
response, cert = self._get_cert(uri)
|
||||
uri = response.links.get('up', {}).get('url')
|
||||
chain.append(cert)
|
||||
if uri is not None:
|
||||
raise errors.Error(
|
||||
"Recursion limit reached. Didn't get {0}".format(uri))
|
||||
return chain
|
||||
|
||||
def revoke(self, cert):
|
||||
"""Revoke certificate.
|
||||
|
|
@ -440,8 +467,9 @@ class Client(object): # pylint: disable=too-many-instance-attributes
|
|||
:raises .ClientError: If revocation is unsuccessful.
|
||||
|
||||
"""
|
||||
response = self.net.post(messages.Revocation.url(self.new_reg_uri),
|
||||
messages.Revocation(certificate=cert))
|
||||
response = self.net.post(self.directory[messages.Revocation],
|
||||
messages.Revocation(certificate=cert),
|
||||
content_type=None)
|
||||
if response.status_code != http_client.OK:
|
||||
raise errors.ClientError(
|
||||
'Successful revocation must return HTTP OK status')
|
||||
|
|
@ -559,7 +587,7 @@ class ClientNetwork(object):
|
|||
"""Send HEAD request without checking the response.
|
||||
|
||||
Note, that `_check_response` is not called, as it is expected
|
||||
that status code other than successfuly 2xx will be returned, or
|
||||
that status code other than successfully 2xx will be returned, or
|
||||
messages2.Error will be raised by the server.
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -33,10 +33,14 @@ class ClientTest(unittest.TestCase):
|
|||
self.net.post.return_value = self.response
|
||||
self.net.get.return_value = self.response
|
||||
|
||||
self.directory = messages.Directory({
|
||||
messages.NewRegistration: 'https://www.letsencrypt-demo.org/acme/new-reg',
|
||||
messages.Revocation: 'https://www.letsencrypt-demo.org/acme/revoke-cert',
|
||||
})
|
||||
|
||||
from acme.client import Client
|
||||
self.client = Client(
|
||||
new_reg_uri='https://www.letsencrypt-demo.org/acme/new-reg',
|
||||
key=KEY, alg=jose.RS256, net=self.net)
|
||||
directory=self.directory, key=KEY, alg=jose.RS256, net=self.net)
|
||||
|
||||
self.identifier = messages.Identifier(
|
||||
typ=messages.IDENTIFIER_FQDN, value='example.com')
|
||||
|
|
@ -55,7 +59,8 @@ class ClientTest(unittest.TestCase):
|
|||
authzr_uri = 'https://www.letsencrypt-demo.org/acme/authz/1'
|
||||
challb = messages.ChallengeBody(
|
||||
uri=(authzr_uri + '/1'), status=messages.STATUS_VALID,
|
||||
chall=challenges.DNS(token='foo'))
|
||||
chall=challenges.DNS(token=jose.b64decode(
|
||||
'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA')))
|
||||
self.challr = messages.ChallengeResource(
|
||||
body=challb, authzr_uri=authzr_uri)
|
||||
self.authz = messages.Authorization(
|
||||
|
|
@ -72,6 +77,13 @@ class ClientTest(unittest.TestCase):
|
|||
uri='https://www.letsencrypt-demo.org/acme/cert/1',
|
||||
cert_chain_uri='https://www.letsencrypt-demo.org/ca')
|
||||
|
||||
def test_init_downloads_directory(self):
|
||||
uri = 'http://www.letsencrypt-demo.org/directory'
|
||||
from acme.client import Client
|
||||
self.client = Client(
|
||||
directory=uri, key=KEY, alg=jose.RS256, net=self.net)
|
||||
self.net.get.assert_called_once_with(uri)
|
||||
|
||||
def test_register(self):
|
||||
# "Instance of 'Field' has no to_json/update member" bug:
|
||||
# pylint: disable=no-member
|
||||
|
|
@ -155,7 +167,7 @@ class ClientTest(unittest.TestCase):
|
|||
self.response.links['up'] = {'url': self.challr.authzr_uri}
|
||||
self.response.json.return_value = self.challr.body.to_json()
|
||||
|
||||
chall_response = challenges.DNSResponse()
|
||||
chall_response = challenges.DNSResponse(validation=None)
|
||||
|
||||
self.client.answer_challenge(self.challr.body, chall_response)
|
||||
|
||||
|
|
@ -164,8 +176,9 @@ class ClientTest(unittest.TestCase):
|
|||
self.challr.body.update(uri='foo'), chall_response)
|
||||
|
||||
def test_answer_challenge_missing_next(self):
|
||||
self.assertRaises(errors.ClientError, self.client.answer_challenge,
|
||||
self.challr.body, challenges.DNSResponse())
|
||||
self.assertRaises(
|
||||
errors.ClientError, self.client.answer_challenge,
|
||||
self.challr.body, challenges.DNSResponse(validation=None))
|
||||
|
||||
def test_retry_after_date(self):
|
||||
self.response.headers['Retry-After'] = 'Fri, 31 Dec 1999 23:59:59 GMT'
|
||||
|
|
@ -335,21 +348,39 @@ class ClientTest(unittest.TestCase):
|
|||
self.assertEqual(
|
||||
self.client.check_cert(self.certr), self.client.refresh(self.certr))
|
||||
|
||||
def test_fetch_chain(self):
|
||||
def test_fetch_chain_no_up_link(self):
|
||||
self.assertEqual([], self.client.fetch_chain(self.certr.update(
|
||||
cert_chain_uri=None)))
|
||||
|
||||
def test_fetch_chain_single(self):
|
||||
# pylint: disable=protected-access
|
||||
self.client._get_cert = mock.MagicMock()
|
||||
self.client._get_cert.return_value = ("response", "certificate")
|
||||
self.assertEqual(self.client._get_cert(self.certr.cert_chain_uri)[1],
|
||||
self.client._get_cert.return_value = (
|
||||
mock.MagicMock(links={}), "certificate")
|
||||
self.assertEqual([self.client._get_cert(self.certr.cert_chain_uri)[1]],
|
||||
self.client.fetch_chain(self.certr))
|
||||
|
||||
def test_fetch_chain_no_up_link(self):
|
||||
self.assertTrue(self.client.fetch_chain(self.certr.update(
|
||||
cert_chain_uri=None)) is None)
|
||||
def test_fetch_chain_max(self):
|
||||
# pylint: disable=protected-access
|
||||
up_response = mock.MagicMock(links={'up': {'url': 'http://cert'}})
|
||||
noup_response = mock.MagicMock(links={})
|
||||
self.client._get_cert = mock.MagicMock()
|
||||
self.client._get_cert.side_effect = [
|
||||
(up_response, "cert")] * 9 + [(noup_response, "last_cert")]
|
||||
chain = self.client.fetch_chain(self.certr, max_length=10)
|
||||
self.assertEqual(chain, ["cert"] * 9 + ["last_cert"])
|
||||
|
||||
def test_fetch_chain_too_many(self): # recursive
|
||||
# pylint: disable=protected-access
|
||||
response = mock.MagicMock(links={'up': {'url': 'http://cert'}})
|
||||
self.client._get_cert = mock.MagicMock()
|
||||
self.client._get_cert.return_value = (response, "certificate")
|
||||
self.assertRaises(errors.Error, self.client.fetch_chain, self.certr)
|
||||
|
||||
def test_revoke(self):
|
||||
self.client.revoke(self.certr.body)
|
||||
self.net.post.assert_called_once_with(messages.Revocation.url(
|
||||
self.client.new_reg_uri), mock.ANY)
|
||||
self.net.post.assert_called_once_with(
|
||||
self.directory[messages.Revocation], mock.ANY, content_type=None)
|
||||
|
||||
def test_revoke_bad_status_raises_error(self):
|
||||
self.response.status_code = http_client.METHOD_NOT_ALLOWED
|
||||
|
|
@ -379,11 +410,14 @@ class ClientNetworkTest(unittest.TestCase):
|
|||
# pylint: disable=missing-docstring
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def to_partial_json(self):
|
||||
return {'foo': self.value}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, value):
|
||||
pass # pragma: no cover
|
||||
|
||||
# pylint: disable=protected-access
|
||||
jws_dump = self.net._wrap_in_jws(
|
||||
MockJSONDeSerializable('foo'), nonce=b'Tg')
|
||||
|
|
@ -487,6 +521,7 @@ class ClientNetworkWithMockedResponseTest(unittest.TestCase):
|
|||
|
||||
self.all_nonces = [jose.b64encode(b'Nonce'), jose.b64encode(b'Nonce2')]
|
||||
self.available_nonces = self.all_nonces[:]
|
||||
|
||||
def send_request(*args, **kwargs):
|
||||
# pylint: disable=unused-argument,missing-docstring
|
||||
if self.available_nonces:
|
||||
|
|
|
|||
|
|
@ -55,10 +55,11 @@ class ServeProbeSNITest(unittest.TestCase):
|
|||
def test_probe_not_recognized_name(self):
|
||||
self.assertRaises(errors.Error, self._probe, b'bar')
|
||||
|
||||
def test_probe_connection_error(self):
|
||||
self._probe(b'foo')
|
||||
time.sleep(1) # TODO: avoid race conditions in other way
|
||||
self.assertRaises(errors.Error, self._probe, b'bar')
|
||||
# TODO: py33/py34 tox hangs forever on do_hendshake in second probe
|
||||
#def probe_connection_error(self):
|
||||
# self._probe(b'foo')
|
||||
# #time.sleep(1) # TODO: avoid race conditions in other way
|
||||
# self.assertRaises(errors.Error, self._probe, b'bar')
|
||||
|
||||
|
||||
class PyOpenSSLCertOrReqSANTest(unittest.TestCase):
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class JSONDeSerializable(object):
|
|||
be encoded into a JSON document. **Full serialization** produces
|
||||
a Python object composed of only basic types as required by the
|
||||
:ref:`conversion table <conversion-table>`. **Partial
|
||||
serialization** (acomplished by :meth:`to_partial_json`)
|
||||
serialization** (accomplished by :meth:`to_partial_json`)
|
||||
produces a Python object that might also be built from other
|
||||
:class:`JSONDeSerializable` objects.
|
||||
|
||||
|
|
|
|||
|
|
@ -307,6 +307,7 @@ def encode_b64jose(data):
|
|||
# b64encode produces ASCII characters only
|
||||
return b64.b64encode(data).decode('ascii')
|
||||
|
||||
|
||||
def decode_b64jose(data, size=None, minimum=False):
|
||||
"""Decode JOSE Base-64 field.
|
||||
|
||||
|
|
@ -324,13 +325,14 @@ def decode_b64jose(data, size=None, minimum=False):
|
|||
except error_cls as error:
|
||||
raise errors.DeserializationError(error)
|
||||
|
||||
if size is not None and ((not minimum and len(decoded) != size)
|
||||
or (minimum and len(decoded) < size)):
|
||||
if size is not None and ((not minimum and len(decoded) != size) or
|
||||
(minimum and len(decoded) < size)):
|
||||
raise errors.DeserializationError(
|
||||
"Expected at least or exactly {0} bytes".format(size))
|
||||
|
||||
return decoded
|
||||
|
||||
|
||||
def encode_hex16(value):
|
||||
"""Hexlify.
|
||||
|
||||
|
|
@ -340,6 +342,7 @@ def encode_hex16(value):
|
|||
"""
|
||||
return binascii.hexlify(value).decode()
|
||||
|
||||
|
||||
def decode_hex16(value, size=None, minimum=False):
|
||||
"""Decode hexlified field.
|
||||
|
||||
|
|
@ -352,8 +355,8 @@ def decode_hex16(value, size=None, minimum=False):
|
|||
|
||||
"""
|
||||
value = value.encode()
|
||||
if size is not None and ((not minimum and len(value) != size * 2)
|
||||
or (minimum and len(value) < size * 2)):
|
||||
if size is not None and ((not minimum and len(value) != size * 2) or
|
||||
(minimum and len(value) < size * 2)):
|
||||
raise errors.DeserializationError()
|
||||
error_cls = TypeError if six.PY2 else binascii.Error
|
||||
try:
|
||||
|
|
@ -361,6 +364,7 @@ def decode_hex16(value, size=None, minimum=False):
|
|||
except error_cls as error:
|
||||
raise errors.DeserializationError(error)
|
||||
|
||||
|
||||
def encode_cert(cert):
|
||||
"""Encode certificate as JOSE Base-64 DER.
|
||||
|
||||
|
|
@ -371,6 +375,7 @@ def encode_cert(cert):
|
|||
return encode_b64jose(OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, cert))
|
||||
|
||||
|
||||
def decode_cert(b64der):
|
||||
"""Decode JOSE Base-64 DER-encoded certificate.
|
||||
|
||||
|
|
@ -384,6 +389,7 @@ def decode_cert(b64der):
|
|||
except OpenSSL.crypto.Error as error:
|
||||
raise errors.DeserializationError(error)
|
||||
|
||||
|
||||
def encode_csr(csr):
|
||||
"""Encode CSR as JOSE Base-64 DER.
|
||||
|
||||
|
|
@ -394,6 +400,7 @@ def encode_csr(csr):
|
|||
return encode_b64jose(OpenSSL.crypto.dump_certificate_request(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, csr))
|
||||
|
||||
|
||||
def decode_csr(b64der):
|
||||
"""Decode JOSE Base-64 DER-encoded CSR.
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ class FieldTest(unittest.TestCase):
|
|||
# pylint: disable=missing-docstring
|
||||
def to_partial_json(self):
|
||||
return 'foo' # pragma: no cover
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
pass # pragma: no cover
|
||||
|
|
@ -93,14 +94,18 @@ class JSONObjectWithFieldsMetaTest(unittest.TestCase):
|
|||
self.field2 = Field('Baz2')
|
||||
# pylint: disable=invalid-name,missing-docstring,too-few-public-methods
|
||||
# pylint: disable=blacklisted-name
|
||||
|
||||
@six.add_metaclass(JSONObjectWithFieldsMeta)
|
||||
class A(object):
|
||||
__slots__ = ('bar',)
|
||||
baz = self.field
|
||||
|
||||
class B(A):
|
||||
pass
|
||||
|
||||
class C(A):
|
||||
baz = self.field2
|
||||
|
||||
self.a_cls = A
|
||||
self.b_cls = B
|
||||
self.c_cls = C
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from acme.jose import jwk
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JWA(interfaces.JSONDeSerializable): # pylint: disable=abstract-method
|
||||
class JWA(interfaces.JSONDeSerializable): # pylint: disable=abstract-method
|
||||
# pylint: disable=too-few-public-methods
|
||||
# for some reason disable=abstract-method has to be on the line
|
||||
# above...
|
||||
|
|
@ -159,7 +159,7 @@ class _JWAES(JWASignature): # pylint: disable=abstract-class-not-used
|
|||
def sign(self, key, msg): # pragma: no cover
|
||||
raise NotImplementedError()
|
||||
|
||||
def verify(self, key, msg, sig): # pragma: no cover
|
||||
def verify(self, key, msg, sig): # pragma: no cover
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ class JWKRSA(JWK):
|
|||
'n': numbers.n,
|
||||
'e': numbers.e,
|
||||
}
|
||||
else: # rsa.RSAPrivateKey
|
||||
else: # rsa.RSAPrivateKey
|
||||
private = self.key.private_numbers()
|
||||
public = self.key.public_key().public_numbers()
|
||||
params = {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ class Header(json_util.JSONObjectWithFields):
|
|||
.. warning:: This class does not support any extensions through
|
||||
the "crit" (Critical) Header Parameter (4.1.11) and as a
|
||||
conforming implementation, :meth:`from_json` treats its
|
||||
occurence as an error. Please subclass if you seek for
|
||||
occurrence as an error. Please subclass if you seek for
|
||||
a different behaviour.
|
||||
|
||||
:ivar x5tS256: "x5t#S256"
|
||||
|
|
@ -294,10 +294,10 @@ class JWS(json_util.JSONObjectWithFields):
|
|||
# ... it must be in protected
|
||||
|
||||
return (
|
||||
b64.b64encode(self.signature.protected.encode('utf-8'))
|
||||
+ b'.' +
|
||||
b64.b64encode(self.payload)
|
||||
+ b'.' +
|
||||
b64.b64encode(self.signature.protected.encode('utf-8')) +
|
||||
b'.' +
|
||||
b64.b64encode(self.payload) +
|
||||
b'.' +
|
||||
b64.b64encode(self.signature.signature))
|
||||
|
||||
@classmethod
|
||||
|
|
@ -345,6 +345,7 @@ class JWS(json_util.JSONObjectWithFields):
|
|||
signatures=tuple(cls.signature_cls.from_json(sig)
|
||||
for sig in jobj['signatures']))
|
||||
|
||||
|
||||
class CLI(object):
|
||||
"""JWS CLI."""
|
||||
|
||||
|
|
|
|||
|
|
@ -107,8 +107,8 @@ class ComparableRSAKey(ComparableKey): # pylint: disable=too-few-public-methods
|
|||
"""Wrapper for `cryptography` RSA keys.
|
||||
|
||||
Wraps around:
|
||||
- `cryptography.hazmat.primitives.assymetric.RSAPrivateKey`
|
||||
- `cryptography.hazmat.primitives.assymetric.RSAPublicKey`
|
||||
- `cryptography.hazmat.primitives.asymmetric.RSAPrivateKey`
|
||||
- `cryptography.hazmat.primitives.asymmetric.RSAPublicKey`
|
||||
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
"""ACME protocol messages."""
|
||||
import collections
|
||||
|
||||
from six.moves.urllib import parse as urllib_parse # pylint: disable=import-error
|
||||
|
||||
from acme import challenges
|
||||
from acme import fields
|
||||
from acme import jose
|
||||
from acme import util
|
||||
|
||||
|
||||
class Error(jose.JSONObjectWithFields, Exception):
|
||||
|
|
@ -128,6 +127,56 @@ class Identifier(jose.JSONObjectWithFields):
|
|||
value = jose.Field('value')
|
||||
|
||||
|
||||
class Directory(jose.JSONDeSerializable):
|
||||
"""Directory."""
|
||||
|
||||
_REGISTERED_TYPES = {}
|
||||
|
||||
@classmethod
|
||||
def _canon_key(cls, key):
|
||||
return getattr(key, 'resource_type', key)
|
||||
|
||||
@classmethod
|
||||
def register(cls, resource_body_cls):
|
||||
"""Register resource."""
|
||||
assert resource_body_cls.resource_type not in cls._REGISTERED_TYPES
|
||||
cls._REGISTERED_TYPES[resource_body_cls.resource_type] = resource_body_cls
|
||||
return resource_body_cls
|
||||
|
||||
def __init__(self, jobj):
|
||||
canon_jobj = util.map_keys(jobj, self._canon_key)
|
||||
if not set(canon_jobj).issubset(self._REGISTERED_TYPES):
|
||||
# TODO: acme-spec is not clear about this: 'It is a JSON
|
||||
# dictionary, whose keys are the "resource" values listed
|
||||
# in {{https-requests}}'z
|
||||
raise ValueError('Wrong directory fields')
|
||||
# TODO: check that everything is an absolute URL; acme-spec is
|
||||
# not clear on that
|
||||
self._jobj = canon_jobj
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
return self[name.replace('_', '-')]
|
||||
except KeyError as error:
|
||||
raise AttributeError(str(error))
|
||||
|
||||
def __getitem__(self, name):
|
||||
try:
|
||||
return self._jobj[self._canon_key(name)]
|
||||
except KeyError:
|
||||
raise KeyError('Directory field not found')
|
||||
|
||||
def to_partial_json(self):
|
||||
return self._jobj
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, jobj):
|
||||
try:
|
||||
return cls(jobj)
|
||||
except ValueError as error:
|
||||
raise jose.DeserializationError(str(error))
|
||||
|
||||
|
||||
class Resource(jose.JSONObjectWithFields):
|
||||
"""ACME Resource.
|
||||
|
||||
|
|
@ -216,16 +265,20 @@ class Registration(ResourceBody):
|
|||
"""All emails found in the ``contact`` field."""
|
||||
return self._filter_contact(self.email_prefix)
|
||||
|
||||
|
||||
@Directory.register
|
||||
class NewRegistration(Registration):
|
||||
"""New registration."""
|
||||
resource_type = 'new-reg'
|
||||
resource = fields.Resource(resource_type)
|
||||
|
||||
|
||||
class UpdateRegistration(Registration):
|
||||
"""Update registration."""
|
||||
resource_type = 'reg'
|
||||
resource = fields.Resource(resource_type)
|
||||
|
||||
|
||||
class RegistrationResource(ResourceWithURI):
|
||||
"""Registration Resource.
|
||||
|
||||
|
|
@ -328,11 +381,14 @@ class Authorization(ResourceBody):
|
|||
return tuple(tuple(self.challenges[idx] for idx in combo)
|
||||
for combo in self.combinations)
|
||||
|
||||
|
||||
@Directory.register
|
||||
class NewAuthorization(Authorization):
|
||||
"""New authorization."""
|
||||
resource_type = 'new-authz'
|
||||
resource = fields.Resource(resource_type)
|
||||
|
||||
|
||||
class AuthorizationResource(ResourceWithURI):
|
||||
"""Authorization Resource.
|
||||
|
||||
|
|
@ -344,6 +400,7 @@ class AuthorizationResource(ResourceWithURI):
|
|||
new_cert_uri = jose.Field('new_cert_uri')
|
||||
|
||||
|
||||
@Directory.register
|
||||
class CertificateRequest(jose.JSONObjectWithFields):
|
||||
"""ACME new-cert request.
|
||||
|
||||
|
|
@ -369,6 +426,7 @@ class CertificateResource(ResourceWithURI):
|
|||
authzrs = jose.Field('authzrs')
|
||||
|
||||
|
||||
@Directory.register
|
||||
class Revocation(jose.JSONObjectWithFields):
|
||||
"""Revocation message.
|
||||
|
||||
|
|
@ -380,16 +438,3 @@ class Revocation(jose.JSONObjectWithFields):
|
|||
resource = fields.Resource(resource_type)
|
||||
certificate = jose.Field(
|
||||
'certificate', decoder=jose.decode_cert, encoder=jose.encode_cert)
|
||||
|
||||
# TODO: acme-spec#138, this allows only one ACME server instance per domain
|
||||
PATH = '/acme/revoke-cert'
|
||||
"""Path to revocation URL, see `url`"""
|
||||
|
||||
@classmethod
|
||||
def url(cls, base):
|
||||
"""Get revocation URL.
|
||||
|
||||
:param str base: New Registration Resource or server (root) URL.
|
||||
|
||||
"""
|
||||
return urllib_parse.urljoin(base, cls.PATH)
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ class ConstantTest(unittest.TestCase):
|
|||
|
||||
def setUp(self):
|
||||
from acme.messages import _Constant
|
||||
|
||||
class MockConstant(_Constant): # pylint: disable=missing-docstring
|
||||
POSSIBLE_NAMES = {}
|
||||
|
||||
|
|
@ -92,6 +93,45 @@ class ConstantTest(unittest.TestCase):
|
|||
self.assertFalse(self.const_a != const_a_prime)
|
||||
|
||||
|
||||
class DirectoryTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.Directory."""
|
||||
|
||||
def setUp(self):
|
||||
from acme.messages import Directory
|
||||
self.dir = Directory({
|
||||
'new-reg': 'reg',
|
||||
mock.MagicMock(resource_type='new-cert'): 'cert',
|
||||
})
|
||||
|
||||
def test_init_wrong_key_value_error(self):
|
||||
from acme.messages import Directory
|
||||
self.assertRaises(ValueError, Directory, {'foo': 'bar'})
|
||||
|
||||
def test_getitem(self):
|
||||
self.assertEqual('reg', self.dir['new-reg'])
|
||||
from acme.messages import NewRegistration
|
||||
self.assertEqual('reg', self.dir[NewRegistration])
|
||||
self.assertEqual('reg', self.dir[NewRegistration()])
|
||||
|
||||
def test_getitem_fails_with_key_error(self):
|
||||
self.assertRaises(KeyError, self.dir.__getitem__, 'foo')
|
||||
|
||||
def test_getattr(self):
|
||||
self.assertEqual('reg', self.dir.new_reg)
|
||||
|
||||
def test_getattr_fails_with_attribute_error(self):
|
||||
self.assertRaises(AttributeError, self.dir.__getattr__, 'foo')
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(
|
||||
self.dir.to_partial_json(), {'new-reg': 'reg', 'new-cert': 'cert'})
|
||||
|
||||
def test_from_json_deserialization_error_on_wrong_key(self):
|
||||
from acme.messages import Directory
|
||||
self.assertRaises(
|
||||
jose.DeserializationError, Directory.from_json, {'foo': 'bar'})
|
||||
|
||||
|
||||
class RegistrationTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.Registration."""
|
||||
|
||||
|
|
@ -185,7 +225,8 @@ class ChallengeBodyTest(unittest.TestCase):
|
|||
"""Tests for acme.messages.ChallengeBody."""
|
||||
|
||||
def setUp(self):
|
||||
self.chall = challenges.DNS(token='foo')
|
||||
self.chall = challenges.DNS(token=jose.b64decode(
|
||||
'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA'))
|
||||
|
||||
from acme.messages import ChallengeBody
|
||||
from acme.messages import Error
|
||||
|
|
@ -201,7 +242,7 @@ class ChallengeBodyTest(unittest.TestCase):
|
|||
'uri': 'http://challb',
|
||||
'status': self.status,
|
||||
'type': 'dns',
|
||||
'token': 'foo',
|
||||
'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA',
|
||||
'error': error,
|
||||
}
|
||||
self.jobj_from = self.jobj_to.copy()
|
||||
|
|
@ -211,7 +252,6 @@ class ChallengeBodyTest(unittest.TestCase):
|
|||
'detail': 'Unable to communicate with DNS server',
|
||||
}
|
||||
|
||||
|
||||
def test_to_partial_json(self):
|
||||
self.assertEqual(self.jobj_to, self.challb.to_partial_json())
|
||||
|
||||
|
|
@ -224,7 +264,8 @@ class ChallengeBodyTest(unittest.TestCase):
|
|||
hash(ChallengeBody.from_json(self.jobj_from))
|
||||
|
||||
def test_proxy(self):
|
||||
self.assertEqual('foo', self.challb.token)
|
||||
self.assertEqual(jose.b64decode(
|
||||
'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA'), self.challb.token)
|
||||
|
||||
|
||||
class AuthorizationTest(unittest.TestCase):
|
||||
|
|
@ -233,6 +274,7 @@ class AuthorizationTest(unittest.TestCase):
|
|||
def setUp(self):
|
||||
from acme.messages import ChallengeBody
|
||||
from acme.messages import STATUS_VALID
|
||||
|
||||
self.challbs = (
|
||||
ChallengeBody(
|
||||
uri='http://challb1', status=STATUS_VALID,
|
||||
|
|
@ -320,13 +362,6 @@ class CertificateResourceTest(unittest.TestCase):
|
|||
class RevocationTest(unittest.TestCase):
|
||||
"""Tests for acme.messages.RevocationTest."""
|
||||
|
||||
def test_url(self):
|
||||
from acme.messages import Revocation
|
||||
url = 'https://letsencrypt-demo.org/acme/revoke-cert'
|
||||
self.assertEqual(url, Revocation.url('https://letsencrypt-demo.org'))
|
||||
self.assertEqual(
|
||||
url, Revocation.url('https://letsencrypt-demo.org/acme/new-reg'))
|
||||
|
||||
def setUp(self):
|
||||
from acme.messages import Revocation
|
||||
self.rev = Revocation(certificate=CERT)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class Signature(jose.JSONObjectWithFields):
|
|||
:param bytes msg: Message to be signed.
|
||||
|
||||
:param key: Key used for signing.
|
||||
:type key: `cryptography.hazmat.primitives.assymetric.rsa.RSAPrivateKey`
|
||||
:type key: `cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey`
|
||||
(optionally wrapped in `.ComparableRSAKey`).
|
||||
|
||||
:param bytes nonce: Nonce to be used. If None, nonce of
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Symlinked in letsencrypt/tests/test_util.py, casues duplicate-code
|
||||
# Symlinked in letsencrypt/tests/test_util.py, causes duplicate-code
|
||||
# warning that cannot be disabled locally.
|
||||
"""Test utilities.
|
||||
|
||||
|
|
@ -20,12 +20,14 @@ def vector_path(*names):
|
|||
return pkg_resources.resource_filename(
|
||||
__name__, os.path.join('testdata', *names))
|
||||
|
||||
|
||||
def load_vector(*names):
|
||||
"""Load contents of a test vector."""
|
||||
# luckily, resource_string opens file in binary mode
|
||||
return pkg_resources.resource_string(
|
||||
__name__, os.path.join('testdata', *names))
|
||||
|
||||
|
||||
def _guess_loader(filename, loader_pem, loader_der):
|
||||
_, ext = os.path.splitext(filename)
|
||||
if ext.lower() == '.pem':
|
||||
|
|
@ -35,6 +37,7 @@ def _guess_loader(filename, loader_pem, loader_der):
|
|||
else: # pragma: no cover
|
||||
raise ValueError("Loader could not be recognized based on extension")
|
||||
|
||||
|
||||
def load_cert(*names):
|
||||
"""Load certificate."""
|
||||
loader = _guess_loader(
|
||||
|
|
@ -42,6 +45,7 @@ def load_cert(*names):
|
|||
return jose.ComparableX509(OpenSSL.crypto.load_certificate(
|
||||
loader, load_vector(*names)))
|
||||
|
||||
|
||||
def load_csr(*names):
|
||||
"""Load certificate request."""
|
||||
loader = _guess_loader(
|
||||
|
|
@ -49,6 +53,7 @@ def load_csr(*names):
|
|||
return jose.ComparableX509(OpenSSL.crypto.load_certificate_request(
|
||||
loader, load_vector(*names)))
|
||||
|
||||
|
||||
def load_rsa_private_key(*names):
|
||||
"""Load RSA private key."""
|
||||
loader = _guess_loader(names[-1], serialization.load_pem_private_key,
|
||||
|
|
@ -56,6 +61,7 @@ def load_rsa_private_key(*names):
|
|||
return jose.ComparableRSAKey(loader(
|
||||
load_vector(*names), password=None, backend=default_backend()))
|
||||
|
||||
|
||||
def load_pyopenssl_private_key(*names):
|
||||
"""Load pyOpenSSL private key."""
|
||||
loader = _guess_loader(
|
||||
|
|
|
|||
7
acme/acme/util.py
Normal file
7
acme/acme/util.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""ACME utilities."""
|
||||
import six
|
||||
|
||||
|
||||
def map_keys(dikt, func):
|
||||
"""Map dictionary keys."""
|
||||
return dict((func(key), value) for key, value in six.iteritems(dikt))
|
||||
16
acme/acme/util_test.py
Normal file
16
acme/acme/util_test.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""Tests for acme.util."""
|
||||
import unittest
|
||||
|
||||
|
||||
class MapKeysTest(unittest.TestCase):
|
||||
"""Tests for acme.util.map_keys."""
|
||||
|
||||
def test_it(self):
|
||||
from acme.util import map_keys
|
||||
self.assertEqual({'a': 'b', 'c': 'd'},
|
||||
map_keys({'a': 'b', 'c': 'd'}, lambda key: key))
|
||||
self.assertEqual({2: 2, 4: 4}, map_keys({1: 2, 3: 4}, lambda x: x + 1))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main() # pragma: no cover
|
||||
|
|
@ -5,17 +5,17 @@ from setuptools import find_packages
|
|||
|
||||
|
||||
install_requires = [
|
||||
'argparse',
|
||||
# load_pem_private/public_key (>=0.6)
|
||||
# rsa_recover_prime_factors (>=0.8)
|
||||
'cryptography>=0.8',
|
||||
'pyrfc3339',
|
||||
'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304)
|
||||
'pyasn1', # urllib3 InsecurePlatformWarning (#304)
|
||||
# Connection.set_tlsext_host_name (>=0.13), X509Req.get_extensions (>=0.15)
|
||||
'PyOpenSSL>=0.15',
|
||||
'pyrfc3339',
|
||||
'pytz',
|
||||
'requests',
|
||||
'setuptools', # pkg_resources
|
||||
'six',
|
||||
'werkzeug',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,8 +4,19 @@
|
|||
# - Fedora 22 (x64)
|
||||
# - Centos 7 (x64: on AWS EC2 t2.micro, DigitalOcean droplet)
|
||||
|
||||
if type yum 2>/dev/null
|
||||
then
|
||||
tool=yum
|
||||
elif type dnf 2>/dev/null
|
||||
then
|
||||
tool=dnf
|
||||
else
|
||||
echo "Neither yum nor dnf found. Aborting bootstrap!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# "git-core" seems to be an alias for "git" in CentOS 7 (yum search fails)
|
||||
yum install -y \
|
||||
$tool install -y \
|
||||
git-core \
|
||||
python \
|
||||
python-devel \
|
||||
|
|
|
|||
8
bootstrap/freebsd.sh
Executable file
8
bootstrap/freebsd.sh
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
#!/bin/sh -xe
|
||||
|
||||
pkg install -Ay \
|
||||
git \
|
||||
python \
|
||||
py27-virtualenv \
|
||||
augeas \
|
||||
libffi \
|
||||
|
|
@ -1,2 +1,8 @@
|
|||
#!/bin/sh
|
||||
if ! hash brew 2>/dev/null; then
|
||||
echo "Homebrew Not Installed\nDownloading..."
|
||||
ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
|
||||
fi
|
||||
|
||||
brew install augeas
|
||||
brew install dialog
|
||||
|
|
|
|||
|
|
@ -21,9 +21,3 @@
|
|||
|
||||
.. automodule:: letsencrypt.display.enhancements
|
||||
:members:
|
||||
|
||||
:mod:`letsencrypt.display.revocation`
|
||||
=====================================
|
||||
|
||||
.. automodule:: letsencrypt.display.revocation
|
||||
:members:
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
:mod:`letsencrypt.recovery_token`
|
||||
--------------------------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.recovery_token
|
||||
:members:
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
:mod:`letsencrypt.revoker`
|
||||
--------------------------
|
||||
|
||||
.. automodule:: letsencrypt.revoker
|
||||
:members:
|
||||
|
|
@ -52,7 +52,8 @@ The following tools are there to help you:
|
|||
before submitting a new pull request.
|
||||
|
||||
- ``tox -e cover`` checks the test coverage only. Calling the
|
||||
``./tox.cover.sh`` script directly might be a bit quicker, though.
|
||||
``./tox.cover.sh`` script directly (or even ``./tox.cover.sh $pkg1
|
||||
$pkg2 ...`` for any subpackages) might be a bit quicker, though.
|
||||
|
||||
- ``tox -e lint`` checks the style of the whole project, while
|
||||
``pylint --rcfile=.pylintrc path`` will check a single file or
|
||||
|
|
@ -60,27 +61,29 @@ The following tools are there to help you:
|
|||
|
||||
- For debugging, we recommend ``pip install ipdb`` and putting
|
||||
``import ipdb; ipdb.set_trace()`` statement inside the source
|
||||
code. Alternatively, you can use Python'd standard library `pdb`,
|
||||
code. Alternatively, you can use Python's standard library `pdb`,
|
||||
but you won't get TAB completion...
|
||||
|
||||
|
||||
Integration
|
||||
~~~~~~~~~~~
|
||||
|
||||
First, install `Go`_ 1.5 and start Boulder_, an ACME CA server::
|
||||
First, install `Go`_ 1.5, libtool-ltdl, mariadb-server and
|
||||
rabbitmq-server and then start Boulder_, an ACME CA server::
|
||||
|
||||
./tests/boulder-start.sh
|
||||
|
||||
The script will download, compile and run the executable; please be
|
||||
patient - it will take some time... Once its ready, you will see
|
||||
``Server running, listening on 127.0.0.1:4000...``. You may now run
|
||||
(in a separate terminal)::
|
||||
``Server running, listening on 127.0.0.1:4000...``. Add an
|
||||
``/etc/hosts`` entry pointing ``le.wtf`` to 127.0.0.1. You may now
|
||||
run (in a separate terminal)::
|
||||
|
||||
./tests/boulder-integration.sh && echo OK || echo FAIL
|
||||
|
||||
If you would like to test `letsencrypt_nginx` plugin (highly
|
||||
encouraged) make sure to install prerequisites as listed in
|
||||
``tests/integration/nginx.sh``:
|
||||
``letsencrypt-nginx/tests/boulder-integration.sh``:
|
||||
|
||||
.. include:: ../letsencrypt-nginx/tests/boulder-integration.sh
|
||||
:start-line: 1
|
||||
|
|
@ -126,9 +129,8 @@ Docker
|
|||
|
||||
OSX users will probably find it easiest to set up a Docker container for
|
||||
development. Let's Encrypt comes with a Dockerfile (``Dockerfile-dev``)
|
||||
for doing so. To use Docker on OSX, install boot2docker using the
|
||||
instructions at https://docs.docker.com/installation/mac/ and start it
|
||||
from the command line (``boot2docker init``).
|
||||
for doing so. To use Docker on OSX, install and setup docker-machine using the
|
||||
instructions at https://docs.docker.com/installation/mac/.
|
||||
|
||||
To build the development Docker image::
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,21 @@ Centos 7
|
|||
sudo ./bootstrap/centos.sh
|
||||
|
||||
|
||||
FreeBSD
|
||||
-------
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
sudo ./bootstrap/freebsd.sh
|
||||
|
||||
Bootstrap script for FreeBSD uses ``pkg`` for package installation,
|
||||
i.e. it does not use ports.
|
||||
|
||||
FreeBSD by default uses ``tcsh``. In order to activate virtulenv (see
|
||||
below), you will need a compatbile shell, e.g. ``pkg install bash &&
|
||||
bash``.
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
|
|
@ -129,7 +144,7 @@ To get a new certificate run:
|
|||
|
||||
.. code-block:: shell
|
||||
|
||||
./venv/bin/letsencrypt auth
|
||||
sudo ./venv/bin/letsencrypt auth
|
||||
|
||||
The ``letsencrypt`` commandline tool has a builtin help:
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ domains = example.com
|
|||
|
||||
text = True
|
||||
agree-eula = True
|
||||
agree-tos = True
|
||||
debug = True
|
||||
# Unfortunately, it's not possible to specify "verbose" multiple times
|
||||
# (correspondingly to -vvvvvv)
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
|
|||
|
||||
description = "Apache Web Server - Alpha"
|
||||
|
||||
|
||||
@classmethod
|
||||
def add_parser_arguments(cls, add):
|
||||
add("ctl", default=constants.CLI_DEFAULTS["ctl"],
|
||||
|
|
@ -138,6 +137,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
|
|||
:raises .errors.PluginError: If there is any other error
|
||||
|
||||
"""
|
||||
# Verify Apache is installed
|
||||
for exe in (self.conf("ctl"), self.conf("enmod"),
|
||||
self.conf("dismod"), self.conf("init-script")):
|
||||
if not le_util.exe_exists(exe):
|
||||
raise errors.NoInstallationError
|
||||
|
||||
# Make sure configuration is valid
|
||||
self.config_test()
|
||||
|
||||
|
|
@ -283,7 +288,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
|
|||
self.assoc[target_name] = vhost
|
||||
return vhost
|
||||
|
||||
|
||||
def _find_best_vhost(self, target_name):
|
||||
"""Finds the best vhost for a target_name.
|
||||
|
||||
|
|
@ -492,7 +496,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
|
|||
|
||||
"""
|
||||
if "ssl_module" not in self.parser.modules:
|
||||
logger.info("Loading mod_ssl into Apache Server")
|
||||
self.enable_mod("ssl", temp=temp)
|
||||
|
||||
# Check for Listen <port>
|
||||
|
|
@ -583,7 +586,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
|
|||
ssl_vhost = self._create_vhost(vh_p)
|
||||
self.vhosts.append(ssl_vhost)
|
||||
|
||||
|
||||
# NOTE: Searches through Augeas seem to ruin changes to directives
|
||||
# The configuration must also be saved before being searched
|
||||
# for the new directives; For these reasons... this is tacked
|
||||
|
|
@ -794,7 +796,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
|
|||
raise errors.PluginError(
|
||||
"Let's Encrypt has already enabled redirection")
|
||||
|
||||
|
||||
def _create_redirect_vhost(self, ssl_vhost):
|
||||
"""Creates an http_vhost specifically to redirect for the ssl_vhost.
|
||||
|
||||
|
|
@ -997,22 +998,41 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
|
|||
|
||||
"""
|
||||
# Support Debian specific setup
|
||||
if (not os.path.isdir(os.path.join(self.parser.root, "mods-available"))
|
||||
or not os.path.isdir(
|
||||
os.path.join(self.parser.root, "mods-enabled"))):
|
||||
avail_path = os.path.join(self.parser.root, "mods-available")
|
||||
enabled_path = os.path.join(self.parser.root, "mods-enabled")
|
||||
if not os.path.isdir(avail_path) or not os.path.isdir(enabled_path):
|
||||
raise errors.NotSupportedError(
|
||||
"Unsupported directory layout. You may try to enable mod %s "
|
||||
"and try again." % mod_name)
|
||||
|
||||
deps = _get_mod_deps(mod_name)
|
||||
|
||||
# Enable all dependencies
|
||||
for dep in deps:
|
||||
if (dep + "_module") not in self.parser.modules:
|
||||
self._enable_mod_debian(dep, temp)
|
||||
self._add_parser_mod(dep)
|
||||
|
||||
note = "Enabled dependency of %s module - %s" % (mod_name, dep)
|
||||
if not temp:
|
||||
self.save_notes += note + os.linesep
|
||||
logger.debug(note)
|
||||
|
||||
# Enable actual module
|
||||
self._enable_mod_debian(mod_name, temp)
|
||||
self.save_notes += "Enabled %s module in Apache" % mod_name
|
||||
logger.debug("Enabled Apache %s module", mod_name)
|
||||
self._add_parser_mod(mod_name)
|
||||
|
||||
if not temp:
|
||||
self.save_notes += "Enabled %s module in Apache\n" % mod_name
|
||||
logger.info("Enabled Apache %s module", mod_name)
|
||||
|
||||
# Modules can enable additional config files. Variables may be defined
|
||||
# within these new configuration sections.
|
||||
# Restart is not necessary as DUMP_RUN_CFG uses latest config.
|
||||
self.parser.update_runtime_variables(self.conf("ctl"))
|
||||
|
||||
def _add_parser_mod(self, mod_name):
|
||||
"""Shortcut for updating parser modules."""
|
||||
self.parser.modules.add(mod_name + "_module")
|
||||
self.parser.modules.add("mod_" + mod_name + ".c")
|
||||
|
||||
|
|
@ -1140,6 +1160,25 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
|
|||
self.parser.init_modules()
|
||||
|
||||
|
||||
def _get_mod_deps(mod_name):
|
||||
"""Get known module dependencies.
|
||||
|
||||
.. note:: This does not need to be accurate in order for the client to
|
||||
run. This simply keeps things clean if the user decides to revert
|
||||
changes.
|
||||
.. warning:: If all deps are not included, it may cause incorrect parsing
|
||||
behavior, due to enable_mod's shortcut for updating the parser's
|
||||
currently defined modules (`.ApacheConfigurator._add_parser_mod`)
|
||||
This would only present a major problem in extremely atypical
|
||||
configs that use ifmod for the missing deps.
|
||||
|
||||
"""
|
||||
deps = {
|
||||
"ssl": ["setenvif", "mime", "socache_shmcb"]
|
||||
}
|
||||
return deps.get(mod_name, [])
|
||||
|
||||
|
||||
def apache_restart(apache_init_script):
|
||||
"""Restarts the Apache Server.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ class Addr(common.Addr):
|
|||
"""
|
||||
if isinstance(other, self.__class__):
|
||||
return ((self.tup == other.tup) or
|
||||
(self.tup[0] == other.tup[0]
|
||||
and self.is_wildcard() and other.is_wildcard()))
|
||||
(self.tup[0] == other.tup[0] and
|
||||
self.is_wildcard() and other.is_wildcard()))
|
||||
return False
|
||||
|
||||
def __ne__(self, other):
|
||||
|
|
|
|||
|
|
@ -195,8 +195,7 @@ class ApacheParser(object):
|
|||
self.aug.set(nvh_path + "/arg", args[0])
|
||||
else:
|
||||
for i, arg in enumerate(args):
|
||||
self.aug.set("%s/arg[%d]" % (nvh_path, i+1), arg)
|
||||
|
||||
self.aug.set("%s/arg[%d]" % (nvh_path, i + 1), arg)
|
||||
|
||||
def _get_ifmod(self, aug_conf_path, mod):
|
||||
"""Returns the path to <IfMod mod> and creates one if it doesn't exist.
|
||||
|
|
@ -242,6 +241,10 @@ class ApacheParser(object):
|
|||
Directives should be in the form of a case insensitive regex currently
|
||||
|
||||
.. todo:: arg should probably be a list
|
||||
.. todo:: arg search currently only supports direct matching. It does
|
||||
not handle the case of variables or quoted arguments. This should
|
||||
be adapted to use a generic search for the directive and then do a
|
||||
case-insensitive self.get_arg filter
|
||||
|
||||
Note: Augeas is inherently case sensitive while Apache is case
|
||||
insensitive. Augeas 1.0 allows case insensitive regexes like
|
||||
|
|
@ -316,6 +319,14 @@ class ApacheParser(object):
|
|||
|
||||
"""
|
||||
value = self.aug.get(match)
|
||||
|
||||
# No need to strip quotes for variables, as apache2ctl already does this
|
||||
# but we do need to strip quotes for all normal arguments.
|
||||
|
||||
# Note: normal argument may be a quoted variable
|
||||
# e.g. strip now, not later
|
||||
value = value.strip("'\"")
|
||||
|
||||
variables = ApacheParser.arg_var_interpreter.findall(value)
|
||||
|
||||
for var in variables:
|
||||
|
|
@ -391,10 +402,15 @@ class ApacheParser(object):
|
|||
# logger.error("Error: Invalid regexp characters in %s", arg)
|
||||
# return []
|
||||
|
||||
# Remove beginning and ending quotes
|
||||
arg = arg.strip("'\"")
|
||||
|
||||
# Standardize the include argument based on server root
|
||||
if not arg.startswith("/"):
|
||||
# Normpath will condense ../
|
||||
arg = os.path.normpath(os.path.join(self.root, arg))
|
||||
else:
|
||||
arg = os.path.normpath(arg)
|
||||
|
||||
# Attempts to add a transform to the file if one does not already exist
|
||||
if os.path.isdir(arg):
|
||||
|
|
@ -568,7 +584,7 @@ def case_i(string):
|
|||
:param str string: string to make case i regex
|
||||
|
||||
"""
|
||||
return "".join(["["+c.upper()+c.lower()+"]"
|
||||
return "".join(["[" + c.upper() + c.lower() + "]"
|
||||
if c.isalpha() else c for c in re.escape(string)])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class ComplexParserTest(util.ParserTest):
|
|||
"COMPLEX": "",
|
||||
"tls_port": "1234",
|
||||
"fnmatch_filename": "test_fnmatch.conf",
|
||||
"tls_port_str": "1234"
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -49,6 +50,12 @@ class ComplexParserTest(util.ParserTest):
|
|||
self.assertEqual(len(matches), 1)
|
||||
self.assertEqual(self.parser.get_arg(matches[0]), "1234")
|
||||
|
||||
def test_basic_variable_parsing_quotes(self):
|
||||
matches = self.parser.find_dir("TestVariablePortStr")
|
||||
|
||||
self.assertEqual(len(matches), 1)
|
||||
self.assertEqual(self.parser.get_arg(matches[0]), "1234")
|
||||
|
||||
def test_invalid_variable_parsing(self):
|
||||
del self.parser.variables["tls_port"]
|
||||
|
||||
|
|
@ -56,7 +63,6 @@ class ComplexParserTest(util.ParserTest):
|
|||
self.assertRaises(
|
||||
errors.PluginError, self.parser.get_arg, matches[0])
|
||||
|
||||
|
||||
def test_basic_ifdefine(self):
|
||||
self.assertEqual(len(self.parser.find_dir("VAR_DIRECTIVE")), 2)
|
||||
self.assertEqual(len(self.parser.find_dir("INVALID_VAR_DIRECTIVE")), 0)
|
||||
|
|
@ -71,7 +77,6 @@ class ComplexParserTest(util.ParserTest):
|
|||
self.assertEqual(
|
||||
len(self.parser.find_dir("INVALID_NESTED_DIRECTIVE")), 0)
|
||||
|
||||
|
||||
def test_load_modules(self):
|
||||
"""If only first is found, there is bad variable parsing."""
|
||||
self.assertTrue("status_module" in self.parser.modules)
|
||||
|
|
@ -91,6 +96,7 @@ class ComplexParserTest(util.ParserTest):
|
|||
else:
|
||||
self.assertFalse(self.parser.find_dir("FNMATCH_DIRECTIVE"))
|
||||
|
||||
# NOTE: Only run one test per function otherwise you will have inf recursion
|
||||
def test_include(self):
|
||||
self.verify_fnmatch("test_fnmatch.?onf")
|
||||
|
||||
|
|
@ -100,6 +106,15 @@ class ComplexParserTest(util.ParserTest):
|
|||
def test_include_fullpath(self):
|
||||
self.verify_fnmatch(os.path.join(self.config_path, "test_fnmatch.conf"))
|
||||
|
||||
def test_include_fullpath_trailing_slash(self):
|
||||
self.verify_fnmatch(self.config_path + "//")
|
||||
|
||||
def test_include_single_quotes(self):
|
||||
self.verify_fnmatch("'" + self.config_path + "'")
|
||||
|
||||
def test_include_double_quotes(self):
|
||||
self.verify_fnmatch('"' + self.config_path + '"')
|
||||
|
||||
def test_include_variable(self):
|
||||
self.verify_fnmatch("../complex_parsing/${fnmatch_filename}")
|
||||
|
||||
|
|
|
|||
|
|
@ -37,8 +37,16 @@ class TwoVhost80Test(util.ApacheTest):
|
|||
shutil.rmtree(self.config_dir)
|
||||
shutil.rmtree(self.work_dir)
|
||||
|
||||
@mock.patch("letsencrypt_apache.configurator.le_util.exe_exists")
|
||||
def test_prepare_no_install(self, mock_exe_exists):
|
||||
mock_exe_exists.return_value = False
|
||||
self.assertRaises(
|
||||
errors.NoInstallationError, self.config.prepare)
|
||||
|
||||
@mock.patch("letsencrypt_apache.parser.ApacheParser")
|
||||
def test_prepare_version(self, _):
|
||||
@mock.patch("letsencrypt_apache.configurator.le_util.exe_exists")
|
||||
def test_prepare_version(self, mock_exe_exists, _):
|
||||
mock_exe_exists.return_value = True
|
||||
self.config.version = None
|
||||
self.config.config_test = mock.Mock()
|
||||
self.config.get_version = mock.Mock(return_value=(1, 1))
|
||||
|
|
@ -551,6 +559,7 @@ class TwoVhost80Test(util.ApacheTest):
|
|||
self.assertRaises(
|
||||
errors.PluginError,
|
||||
self.config.enhance, "letsencrypt.demo", "redirect")
|
||||
|
||||
def test_unknown_rewrite2(self):
|
||||
# Skip the enable mod
|
||||
self.config.parser.modules.add("rewrite_module")
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ class BasicParserTest(util.ParserTest):
|
|||
'Group: name="www-data" id=33 not_used\n'
|
||||
)
|
||||
expected_vars = {"TEST": "", "U_MICH": "", "TLS": "443",
|
||||
"example_path":"Documents/path"}
|
||||
"example_path": "Documents/path"}
|
||||
|
||||
self.parser.update_runtime_variables("ctl")
|
||||
self.assertEqual(self.parser.variables, expected_vars)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ IncludeOptional sites-enabled/*.conf
|
|||
Define COMPLEX
|
||||
|
||||
Define tls_port 1234
|
||||
Define tls_port_str "1234"
|
||||
|
||||
Define fnmatch_filename test_fnmatch.conf
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
TestVariablePort ${tls_port}
|
||||
TestVariablePortStr "${tls_port_str}"
|
||||
|
||||
LoadModule status_module modules/mod_status.so
|
||||
|
||||
|
|
|
|||
|
|
@ -66,31 +66,34 @@ def get_apache_configurator(
|
|||
|
||||
"""
|
||||
backups = os.path.join(work_dir, "backups")
|
||||
mock_le_config = mock.MagicMock(
|
||||
apache_server_root=config_path,
|
||||
apache_le_vhost_ext=constants.CLI_DEFAULTS["le_vhost_ext"],
|
||||
backup_dir=backups,
|
||||
config_dir=config_dir,
|
||||
temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"),
|
||||
in_progress_dir=os.path.join(backups, "IN_PROGRESS"),
|
||||
work_dir=work_dir)
|
||||
|
||||
with mock.patch("letsencrypt_apache.configurator."
|
||||
"subprocess.Popen") as mock_popen:
|
||||
with mock.patch("letsencrypt_apache.parser.ApacheParser."
|
||||
"update_runtime_variables"):
|
||||
# This indicates config_test passes
|
||||
mock_popen().communicate.return_value = ("Fine output", "No problems")
|
||||
mock_popen().returncode = 0
|
||||
# This indicates config_test passes
|
||||
mock_popen().communicate.return_value = ("Fine output", "No problems")
|
||||
mock_popen().returncode = 0
|
||||
with mock.patch("letsencrypt_apache.configurator.le_util."
|
||||
"exe_exists") as mock_exe_exists:
|
||||
mock_exe_exists.return_value = True
|
||||
with mock.patch("letsencrypt_apache.parser.ApacheParser."
|
||||
"update_runtime_variables"):
|
||||
config = configurator.ApacheConfigurator(
|
||||
config=mock_le_config,
|
||||
name="apache",
|
||||
version=version)
|
||||
# This allows testing scripts to set it a bit more quickly
|
||||
if conf is not None:
|
||||
config.conf = conf # pragma: no cover
|
||||
|
||||
config = configurator.ApacheConfigurator(
|
||||
config=mock.MagicMock(
|
||||
apache_server_root=config_path,
|
||||
apache_le_vhost_ext=constants.CLI_DEFAULTS["le_vhost_ext"],
|
||||
backup_dir=backups,
|
||||
config_dir=config_dir,
|
||||
temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"),
|
||||
in_progress_dir=os.path.join(backups, "IN_PROGRESS"),
|
||||
work_dir=work_dir),
|
||||
name="apache",
|
||||
version=version)
|
||||
# This allows testing scripts to set it a bit more quickly
|
||||
if conf is not None:
|
||||
config.conf = conf # pragma: no cover
|
||||
|
||||
config.prepare()
|
||||
config.prepare()
|
||||
|
||||
return config
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ install_requires = [
|
|||
'acme',
|
||||
'letsencrypt',
|
||||
'python-augeas',
|
||||
'setuptools', # pkg_resources
|
||||
'zope.component',
|
||||
'zope.interface',
|
||||
]
|
||||
|
|
@ -24,7 +25,7 @@ setup(
|
|||
entry_points={
|
||||
'letsencrypt.plugins': [
|
||||
'apache = letsencrypt_apache.configurator:ApacheConfigurator',
|
||||
],
|
||||
],
|
||||
},
|
||||
include_package_data=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from letsencrypt_compatibility_test.configurators.apache import common as apache
|
|||
# config uses mod_heartbeat or mod_heartmonitor (which aren't installed and
|
||||
# therefore the config won't be loaded), I believe this isn't a problem
|
||||
# http://httpd.apache.org/docs/2.4/mod/mod_watchdog.html
|
||||
STATIC_MODULES = {"core", "so", "http", "mpm_event", "watchdog",}
|
||||
STATIC_MODULES = set(["core", "so", "http", "mpm_event", "watchdog"])
|
||||
|
||||
|
||||
SHARED_MODULES = {
|
||||
|
|
@ -31,7 +31,7 @@ SHARED_MODULES = {
|
|||
"session_cookie", "session_crypto", "session_dbd", "setenvif",
|
||||
"slotmem_shm", "socache_dbm", "socache_memcache", "socache_shmcb",
|
||||
"speling", "ssl", "status", "substitute", "unique_id", "userdir",
|
||||
"vhost_alias",}
|
||||
"vhost_alias"}
|
||||
|
||||
|
||||
class Proxy(apache_common.Proxy):
|
||||
|
|
|
|||
|
|
@ -72,11 +72,10 @@ class Proxy(object):
|
|||
logger.debug(line)
|
||||
|
||||
host_config = docker.utils.create_host_config(
|
||||
binds={
|
||||
self._temp_dir : {"bind" : self._temp_dir, "mode" : "rw"}},
|
||||
binds={self._temp_dir: {"bind": self._temp_dir, "mode": "rw"}},
|
||||
port_bindings={
|
||||
80 : ("127.0.0.1", self.http_port),
|
||||
443 : ("127.0.0.1", self.https_port)},)
|
||||
80: ("127.0.0.1", self.http_port),
|
||||
443: ("127.0.0.1", self.https_port)},)
|
||||
container = self._docker_client.create_container(
|
||||
image_name, command, ports=[80, 443], volumes=self._temp_dir,
|
||||
host_config=host_config)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class IPluginProxy(zope.interface.Interface):
|
|||
def cleanup_from_tests():
|
||||
"""Performs any necessary cleanup from running plugin tests.
|
||||
|
||||
This is guarenteed to be called before the program exits.
|
||||
This is guaranteed to be called before the program exits.
|
||||
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ tests that the plugin supports are performed.
|
|||
|
||||
"""
|
||||
|
||||
PLUGINS = {"apache" : apache24.Proxy}
|
||||
PLUGINS = {"apache": apache24.Proxy}
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -191,7 +191,7 @@ def test_enhancements(plugin, domains):
|
|||
success = True
|
||||
for domain in domains:
|
||||
verify = functools.partial(validator.Validator().redirect, "localhost",
|
||||
plugin.http_port, headers={"Host" : domain})
|
||||
plugin.http_port, headers={"Host": domain})
|
||||
if not _try_until_true(verify):
|
||||
logger.error("Improper redirect for domain %s", domain)
|
||||
success = False
|
||||
|
|
|
|||
|
|
@ -34,12 +34,12 @@ def create_le_config(parent_dir):
|
|||
os.mkdir(config["work_dir"])
|
||||
os.mkdir(config["logs_dir"])
|
||||
|
||||
return argparse.Namespace(**config) # pylint: disable=star-args
|
||||
return argparse.Namespace(**config) # pylint: disable=star-args
|
||||
|
||||
|
||||
def extract_configs(configs, parent_dir):
|
||||
"""Extracts configs to a new dir under parent_dir and returns it"""
|
||||
config_dir = os.path.join(parent_dir, "configs")
|
||||
config_dir = os.path.join(parent_dir, "renewal")
|
||||
|
||||
if os.path.isdir(configs):
|
||||
shutil.copytree(configs, config_dir, symlinks=True)
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ class NginxConfigurator(common.Plugin):
|
|||
zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller)
|
||||
zope.interface.classProvides(interfaces.IPluginFactory)
|
||||
|
||||
description = "Nginx Web Server"
|
||||
description = "Nginx Web Server - currently doesn't work"
|
||||
|
||||
@classmethod
|
||||
def add_parser_arguments(cls, add):
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from pyparsing import (
|
|||
from pyparsing import stringEnd
|
||||
from pyparsing import restOfLine
|
||||
|
||||
|
||||
class RawNginxParser(object):
|
||||
# pylint: disable=expression-not-assigned
|
||||
"""A class that parses nginx configuration with pyparsing."""
|
||||
|
|
@ -32,10 +33,10 @@ class RawNginxParser(object):
|
|||
block = Forward()
|
||||
|
||||
block << Group(
|
||||
(Group(key + location_statement) ^ Group(if_statement))
|
||||
+ left_bracket
|
||||
+ Group(ZeroOrMore(Group(comment | assignment) | block))
|
||||
+ right_bracket)
|
||||
(Group(key + location_statement) ^ Group(if_statement)) +
|
||||
left_bracket +
|
||||
Group(ZeroOrMore(Group(comment | assignment) | block)) +
|
||||
right_bracket)
|
||||
|
||||
script = OneOrMore(Group(comment | assignment) ^ block) + stringEnd
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ class DvsniPerformTest(util.NginxTest):
|
|||
domain="www.example.org", account_key=account_key),
|
||||
]
|
||||
|
||||
|
||||
def setUp(self):
|
||||
super(DvsniPerformTest, self).setUp()
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ import pkg_resources
|
|||
import unittest
|
||||
|
||||
import mock
|
||||
import zope.component
|
||||
|
||||
from acme import jose
|
||||
|
||||
from letsencrypt import configuration
|
||||
|
||||
from letsencrypt.tests import test_util
|
||||
|
||||
from letsencrypt.plugins import common
|
||||
|
|
@ -55,11 +58,17 @@ def get_nginx_configurator(
|
|||
backup_dir=backups,
|
||||
temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"),
|
||||
in_progress_dir=os.path.join(backups, "IN_PROGRESS"),
|
||||
server="https://acme-server.org:443/new",
|
||||
dvsni_port=5001,
|
||||
),
|
||||
name="nginx",
|
||||
version=version)
|
||||
config.prepare()
|
||||
|
||||
# Provide general config utility.
|
||||
nsconfig = configuration.NamespaceConfig(config.config)
|
||||
zope.component.provideUtility(nsconfig)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ from setuptools import find_packages
|
|||
install_requires = [
|
||||
'acme',
|
||||
'letsencrypt',
|
||||
'PyOpenSSL',
|
||||
'pyparsing>=1.5.5', # Python3 support; perhaps unnecessary?
|
||||
'setuptools', # pkg_resources
|
||||
'zope.interface',
|
||||
]
|
||||
|
||||
|
|
@ -23,7 +25,7 @@ setup(
|
|||
entry_points={
|
||||
'letsencrypt.plugins': [
|
||||
'nginx = letsencrypt_nginx.configurator:NginxConfigurator',
|
||||
],
|
||||
],
|
||||
},
|
||||
include_package_data=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class Account(object): # pylint: disable=too-few-public-methods
|
|||
tz=pytz.UTC).replace(microsecond=0),
|
||||
creation_host=socket.getfqdn()) if meta is None else meta
|
||||
|
||||
self.id = hashlib.md5( # pylint: disable=invalid-name
|
||||
self.id = hashlib.md5(
|
||||
self.key.key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
|
|
@ -62,7 +62,7 @@ class Account(object): # pylint: disable=too-few-public-methods
|
|||
# Implementation note: Email? Multiple accounts can have the
|
||||
# same email address. Registration URI? Assigned by the
|
||||
# server, not guaranteed to be stable over time, nor
|
||||
# cannonical URI can be generated. ACME protocol doesn't allow
|
||||
# canonical URI can be generated. ACME protocol doesn't allow
|
||||
# account key (and thus its fingerprint) to be updated...
|
||||
|
||||
@property
|
||||
|
|
@ -92,13 +92,13 @@ def report_new_account(acc, config):
|
|||
"contain certificates and private keys obtained by Let's Encrypt "
|
||||
"so making regular backups of this folder is ideal.".format(
|
||||
config.config_dir),
|
||||
reporter.MEDIUM_PRIORITY, True)
|
||||
reporter.MEDIUM_PRIORITY)
|
||||
|
||||
if acc.regr.body.emails:
|
||||
recovery_msg = ("If you lose your account credentials, you can "
|
||||
"recover through e-mails sent to {0}.".format(
|
||||
", ".join(acc.regr.body.emails)))
|
||||
reporter.add_message(recovery_msg, reporter.HIGH_PRIORITY, True)
|
||||
reporter.add_message(recovery_msg, reporter.HIGH_PRIORITY)
|
||||
|
||||
|
||||
class AccountMemoryStorage(interfaces.AccountStorage):
|
||||
|
|
@ -129,8 +129,9 @@ class AccountFileStorage(interfaces.AccountStorage):
|
|||
|
||||
"""
|
||||
def __init__(self, config):
|
||||
le_util.make_or_verify_dir(config.accounts_dir, 0o700, os.geteuid())
|
||||
self.config = config
|
||||
le_util.make_or_verify_dir(config.accounts_dir, 0o700, os.geteuid(),
|
||||
self.config.strict_permissions)
|
||||
|
||||
def _account_dir_path(self, account_id):
|
||||
return os.path.join(self.config.accounts_dir, account_id)
|
||||
|
|
@ -186,7 +187,8 @@ class AccountFileStorage(interfaces.AccountStorage):
|
|||
|
||||
def save(self, account):
|
||||
account_dir_path = self._account_dir_path(account.id)
|
||||
le_util.make_or_verify_dir(account_dir_path, 0o700, os.geteuid())
|
||||
le_util.make_or_verify_dir(account_dir_path, 0o700, os.geteuid(),
|
||||
self.config.strict_permissions)
|
||||
try:
|
||||
with open(self._regr_path(account_dir_path), "w") as regr_file:
|
||||
regr_file.write(account.regr.json_dumps())
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from acme import messages
|
|||
from letsencrypt import achallenges
|
||||
from letsencrypt import constants
|
||||
from letsencrypt import errors
|
||||
from letsencrypt import error_handler
|
||||
from letsencrypt import interfaces
|
||||
|
||||
|
||||
|
|
@ -106,17 +107,16 @@ class AuthHandler(object):
|
|||
"""Get Responses for challenges from authenticators."""
|
||||
cont_resp = []
|
||||
dv_resp = []
|
||||
try:
|
||||
if self.cont_c:
|
||||
cont_resp = self.cont_auth.perform(self.cont_c)
|
||||
if self.dv_c:
|
||||
dv_resp = self.dv_auth.perform(self.dv_c)
|
||||
# This will catch both specific types of errors.
|
||||
except errors.AuthorizationError:
|
||||
logger.critical("Failure in setting up challenges.")
|
||||
logger.info("Attempting to clean up outstanding challenges...")
|
||||
self._cleanup_challenges()
|
||||
raise
|
||||
with error_handler.ErrorHandler(self._cleanup_challenges):
|
||||
try:
|
||||
if self.cont_c:
|
||||
cont_resp = self.cont_auth.perform(self.cont_c)
|
||||
if self.dv_c:
|
||||
dv_resp = self.dv_auth.perform(self.dv_c)
|
||||
except errors.AuthorizationError:
|
||||
logger.critical("Failure in setting up challenges.")
|
||||
logger.info("Attempting to clean up outstanding challenges...")
|
||||
raise
|
||||
|
||||
assert len(cont_resp) == len(self.cont_c)
|
||||
assert len(dv_resp) == len(self.dv_c)
|
||||
|
|
@ -244,7 +244,7 @@ class AuthHandler(object):
|
|||
|
||||
"""
|
||||
for authzr_challb in authzr.body.challenges:
|
||||
if type(authzr_challb.chall) is type(achall.challb.chall):
|
||||
if type(authzr_challb.chall) is type(achall.challb.chall): # noqa
|
||||
return authzr_challb
|
||||
raise errors.AuthorizationError(
|
||||
"Target challenge not found in authorization resource")
|
||||
|
|
@ -493,26 +493,27 @@ _ERROR_HELP_COMMON = (
|
|||
|
||||
|
||||
_ERROR_HELP = {
|
||||
"connection" :
|
||||
"connection":
|
||||
_ERROR_HELP_COMMON + " Additionally, please check that your computer "
|
||||
"has publicly routable IP address and no firewalls are preventing the "
|
||||
"server from communicating with the client.",
|
||||
"dnssec" :
|
||||
"dnssec":
|
||||
_ERROR_HELP_COMMON + " Additionally, if you have DNSSEC enabled for "
|
||||
"your domain, please ensure the signature is valid.",
|
||||
"malformed" :
|
||||
"malformed":
|
||||
"To fix these errors, please make sure that you did not provide any "
|
||||
"invalid information to the client and try running Let's Encrypt "
|
||||
"again.",
|
||||
"serverInternal" :
|
||||
"serverInternal":
|
||||
"Unfortunately, an error on the ACME server prevented you from completing "
|
||||
"authorization. Please try again later.",
|
||||
"tls" :
|
||||
"tls":
|
||||
_ERROR_HELP_COMMON + " Additionally, please check that you have an up "
|
||||
"to date TLS configuration that allows the server to communicate with "
|
||||
"the Let's Encrypt client.",
|
||||
"unauthorized" : _ERROR_HELP_COMMON,
|
||||
"unknownHost" : _ERROR_HELP_COMMON,}
|
||||
"unauthorized": _ERROR_HELP_COMMON,
|
||||
"unknownHost": _ERROR_HELP_COMMON,
|
||||
}
|
||||
|
||||
|
||||
def _report_failed_challs(failed_achalls):
|
||||
|
|
@ -530,7 +531,7 @@ def _report_failed_challs(failed_achalls):
|
|||
reporter = zope.component.getUtility(interfaces.IReporter)
|
||||
for achalls in problems.itervalues():
|
||||
reporter.add_message(
|
||||
_generate_failed_chall_msg(achalls), reporter.MEDIUM_PRIORITY, True)
|
||||
_generate_failed_chall_msg(achalls), reporter.MEDIUM_PRIORITY)
|
||||
|
||||
|
||||
def _generate_failed_chall_msg(failed_achalls):
|
||||
|
|
|
|||
|
|
@ -12,21 +12,29 @@ import time
|
|||
import traceback
|
||||
|
||||
import configargparse
|
||||
import configobj
|
||||
import OpenSSL
|
||||
import zope.component
|
||||
import zope.interface.exceptions
|
||||
import zope.interface.verify
|
||||
|
||||
from acme import client as acme_client
|
||||
from acme import jose
|
||||
|
||||
import letsencrypt
|
||||
|
||||
from letsencrypt import account
|
||||
from letsencrypt import colored_logging
|
||||
from letsencrypt import configuration
|
||||
from letsencrypt import constants
|
||||
from letsencrypt import client
|
||||
from letsencrypt import crypto_util
|
||||
from letsencrypt import errors
|
||||
from letsencrypt import interfaces
|
||||
from letsencrypt import le_util
|
||||
from letsencrypt import log
|
||||
from letsencrypt import reporter
|
||||
from letsencrypt import storage
|
||||
|
||||
from letsencrypt.display import util as display_util
|
||||
from letsencrypt.display import ops as display_ops
|
||||
|
|
@ -69,11 +77,11 @@ Choice of server for authentication/installation:
|
|||
|
||||
More detailed help:
|
||||
|
||||
-h, --help [topic] print this message, or detailed help on a topic;
|
||||
-h, --help [topic] print this message, or detailed help on a topic;
|
||||
the available topics are:
|
||||
|
||||
all, apache, automation, nginx, paths, security, testing, or any of the
|
||||
subcommands
|
||||
all, apache, automation, manual, nginx, paths, security, testing, or any of
|
||||
the subcommands
|
||||
"""
|
||||
|
||||
|
||||
|
|
@ -159,8 +167,149 @@ def _init_le_client(args, config, authenticator, installer):
|
|||
return client.Client(config, acc, authenticator, installer, acme=acme)
|
||||
|
||||
|
||||
def run(args, config, plugins):
|
||||
def _find_duplicative_certs(domains, config, renew_config):
|
||||
"""Find existing certs that duplicate the request."""
|
||||
|
||||
identical_names_cert, subset_names_cert = None, None
|
||||
|
||||
configs_dir = renew_config.renewal_configs_dir
|
||||
# Verify the directory is there
|
||||
le_util.make_or_verify_dir(configs_dir, mode=0o755, uid=os.geteuid())
|
||||
|
||||
cli_config = configuration.RenewerConfiguration(config)
|
||||
for renewal_file in os.listdir(configs_dir):
|
||||
try:
|
||||
full_path = os.path.join(configs_dir, renewal_file)
|
||||
rc_config = configobj.ConfigObj(renew_config.renewer_config_file)
|
||||
rc_config.merge(configobj.ConfigObj(full_path))
|
||||
rc_config.filename = full_path
|
||||
candidate_lineage = storage.RenewableCert(
|
||||
rc_config, config_opts=None, cli_config=cli_config)
|
||||
except (configobj.ConfigObjError, errors.CertStorageError, IOError):
|
||||
logger.warning("Renewal configuration file %s is broken. "
|
||||
"Skipping.", full_path)
|
||||
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)):
|
||||
subset_names_cert = candidate_lineage
|
||||
|
||||
return identical_names_cert, subset_names_cert
|
||||
|
||||
|
||||
def _treat_as_renewal(config, domains):
|
||||
"""Determine whether or not the call should be treated as a renewal.
|
||||
|
||||
:returns: RenewableCert or None if renewal shouldn't occur.
|
||||
:rtype: :class:`.storage.RenewableCert`
|
||||
|
||||
:raises .Error: If the user would like to rerun the client again.
|
||||
|
||||
"""
|
||||
renewal = False
|
||||
|
||||
# Considering the possibility that the requested certificate is
|
||||
# related to an existing certificate. (config.duplicate, which
|
||||
# is set with --duplicate, skips all of this logic and forces any
|
||||
# kind of certificate to be obtained with renewal = False.)
|
||||
if not config.duplicate:
|
||||
ident_names_cert, subset_names_cert = _find_duplicative_certs(
|
||||
domains, config, configuration.RenewerConfiguration(config))
|
||||
# I am not sure whether that correctly reads the systemwide
|
||||
# configuration file.
|
||||
question = None
|
||||
if ident_names_cert is not None:
|
||||
question = (
|
||||
"You have an existing certificate that contains exactly the "
|
||||
"same domains you requested (ref: {0}){br}{br}Do you want to "
|
||||
"renew and replace this certificate with a newly-issued one?"
|
||||
).format(ident_names_cert.configfile.filename, br=os.linesep)
|
||||
elif subset_names_cert is not None:
|
||||
question = (
|
||||
"You have an existing certificate that contains a portion of "
|
||||
"the domains you requested (ref: {0}){br}{br}It contains these "
|
||||
"names: {1}{br}{br}You requested these names for the new "
|
||||
"certificate: {2}.{br}{br}Do you want to replace this existing "
|
||||
"certificate with the new certificate?"
|
||||
).format(subset_names_cert.configfile.filename,
|
||||
", ".join(subset_names_cert.names()),
|
||||
", ".join(domains),
|
||||
br=os.linesep)
|
||||
if question is None:
|
||||
# We aren't in a duplicative-names situation at all, so we don't
|
||||
# have to tell or ask the user anything about this.
|
||||
pass
|
||||
elif config.renew_by_default or zope.component.getUtility(
|
||||
interfaces.IDisplay).yesno(question, "Replace", "Cancel"):
|
||||
renewal = True
|
||||
else:
|
||||
reporter_util = zope.component.getUtility(interfaces.IReporter)
|
||||
reporter_util.add_message(
|
||||
"To obtain a new certificate that {0} an existing certificate "
|
||||
"in its domain-name coverage, you must use the --duplicate "
|
||||
"option.{br}{br}For example:{br}{br}{1} --duplicate {2}".format(
|
||||
"duplicates" if ident_names_cert is not None else
|
||||
"overlaps with",
|
||||
sys.argv[0], " ".join(sys.argv[1:]),
|
||||
br=os.linesep
|
||||
),
|
||||
reporter_util.HIGH_PRIORITY)
|
||||
raise errors.Error(
|
||||
"User did not use proper CLI and would like "
|
||||
"to reinvoke the client.")
|
||||
|
||||
if renewal:
|
||||
return ident_names_cert if ident_names_cert is not None else subset_names_cert
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _report_new_cert(cert_path):
|
||||
"""Reports the creation of a new certificate to the user."""
|
||||
reporter_util = zope.component.getUtility(interfaces.IReporter)
|
||||
reporter_util.add_message("Congratulations! Your certificate has been "
|
||||
"saved at {0}.".format(cert_path),
|
||||
reporter_util.MEDIUM_PRIORITY)
|
||||
|
||||
|
||||
def _auth_from_domains(le_client, config, domains, plugins):
|
||||
"""Authenticate and enroll certificate."""
|
||||
# Note: This can raise errors... caught above us though.
|
||||
lineage = _treat_as_renewal(config, domains)
|
||||
|
||||
if lineage is not None:
|
||||
# TODO: schoen wishes to reuse key - discussion
|
||||
# https://github.com/letsencrypt/letsencrypt/pull/777/files#r40498574
|
||||
new_certr, new_chain, new_key, _ = le_client.obtain_certificate(domains)
|
||||
# TODO: Check whether it worked! <- or make sure errors are thrown (jdk)
|
||||
lineage.save_successor(
|
||||
lineage.latest_common_version(), OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_PEM, new_certr.body),
|
||||
new_key.pem, crypto_util.dump_pyopenssl_chain(new_chain))
|
||||
|
||||
lineage.update_all_links_to(lineage.latest_common_version())
|
||||
# TODO: Check return value of save_successor
|
||||
# TODO: Also update lineage renewal config with any relevant
|
||||
# configuration values from this attempt? <- Absolutely (jdkasten)
|
||||
else:
|
||||
# TREAT AS NEW REQUEST
|
||||
lineage = le_client.obtain_and_enroll_certificate(domains, plugins)
|
||||
if not lineage:
|
||||
raise errors.Error("Certificate could not be obtained")
|
||||
|
||||
_report_new_cert(lineage.cert)
|
||||
|
||||
return lineage
|
||||
|
||||
|
||||
# TODO: Make run as close to auth + install as possible
|
||||
# Possible difficulties: args.csr was hacked into auth
|
||||
def run(args, config, plugins): # pylint: disable=too-many-branches,too-many-locals
|
||||
"""Obtain a certificate and install."""
|
||||
# Begin authenticator and installer setup
|
||||
if args.configurator is not None and (args.installer is not None or
|
||||
args.authenticator is not None):
|
||||
return ("Either --configurator or --authenticator/--installer"
|
||||
|
|
@ -179,22 +328,28 @@ def run(args, config, plugins):
|
|||
|
||||
if installer is None or authenticator is None:
|
||||
return "Configurator could not be determined"
|
||||
# End authenticator and installer setup
|
||||
|
||||
domains = _find_domains(args, installer)
|
||||
|
||||
# TODO: Handle errors from _init_le_client?
|
||||
le_client = _init_le_client(args, config, authenticator, installer)
|
||||
lineage = le_client.obtain_and_enroll_certificate(
|
||||
domains, authenticator, installer, plugins)
|
||||
if not lineage:
|
||||
return "Certificate could not be obtained"
|
||||
|
||||
lineage = _auth_from_domains(le_client, config, domains, plugins)
|
||||
|
||||
# TODO: We also need to pass the fullchain (for Nginx)
|
||||
le_client.deploy_certificate(
|
||||
domains, lineage.privkey, lineage.cert, lineage.chain)
|
||||
le_client.enhance_config(domains, args.redirect)
|
||||
|
||||
if len(lineage.available_versions("cert")) == 1:
|
||||
display_ops.success_installation(domains)
|
||||
else:
|
||||
display_ops.success_renewal(domains)
|
||||
|
||||
|
||||
def auth(args, config, plugins):
|
||||
"""Authenticate & obtain cert, but do not install it."""
|
||||
# XXX: Update for renewer / RenewableCert
|
||||
|
||||
if args.domains is not None and args.csr is not None:
|
||||
# TODO: --csr could have a priority, when --domains is
|
||||
|
|
@ -214,16 +369,16 @@ def auth(args, config, plugins):
|
|||
# TODO: Handle errors from _init_le_client?
|
||||
le_client = _init_le_client(args, config, authenticator, installer)
|
||||
|
||||
# This is a special case; cert and chain are simply saved
|
||||
if args.csr is not None:
|
||||
certr, chain = le_client.obtain_certificate_from_csr(le_util.CSR(
|
||||
file=args.csr[0], data=args.csr[1], form="der"))
|
||||
le_client.save_certificate(
|
||||
certr, chain, args.cert_path, args.chain_path)
|
||||
_report_new_cert(args.cert_path)
|
||||
else:
|
||||
domains = _find_domains(args, installer)
|
||||
if not le_client.obtain_and_enroll_certificate(
|
||||
domains, authenticator, installer, plugins):
|
||||
return "Certificate could not be obtained"
|
||||
_auth_from_domains(le_client, config, domains, plugins)
|
||||
|
||||
|
||||
def install(args, config, plugins):
|
||||
|
|
@ -241,16 +396,20 @@ def install(args, config, plugins):
|
|||
le_client.enhance_config(domains, args.redirect)
|
||||
|
||||
|
||||
def revoke(args, unused_config, unused_plugins):
|
||||
def revoke(args, config, unused_plugins): # TODO: coop with renewal config
|
||||
"""Revoke a previously obtained certificate."""
|
||||
if args.cert_path is None and args.key_path is None:
|
||||
return "At least one of --cert-path or --key-path is required"
|
||||
|
||||
# This depends on the renewal config and cannot be completed yet.
|
||||
zope.component.getUtility(interfaces.IDisplay).notification(
|
||||
"Revocation is not available with the new Boulder server yet.")
|
||||
#client.revoke(args.installer, config, plugins, args.no_confirm,
|
||||
# args.cert_path, args.key_path)
|
||||
if args.key_path is not None: # revocation by cert key
|
||||
logger.debug("Revoking %s using cert key %s",
|
||||
args.cert_path[0], args.key_path[0])
|
||||
acme = acme_client.Client(
|
||||
config.server, key=jose.JWK.load(args.key_path[1]))
|
||||
else: # revocation by account key
|
||||
logger.debug("Revoking %s using Account Key", args.cert_path[0])
|
||||
acc, _ = _determine_account(args, config)
|
||||
# pylint: disable=protected-access
|
||||
acme = client._acme_from_config_key(config, acc.key)
|
||||
acme.revoke(jose.ComparableX509(crypto_util.pyopenssl_load_certificate(
|
||||
args.cert_path[1])[0]))
|
||||
|
||||
|
||||
def rollback(args, config, plugins):
|
||||
|
|
@ -272,7 +431,7 @@ def plugins_cmd(args, config, plugins): # TODO: Use IDisplay rather than print
|
|||
logger.debug("Expected interfaces: %s", args.ifaces)
|
||||
|
||||
ifaces = [] if args.ifaces is None else args.ifaces
|
||||
filtered = plugins.ifaces(ifaces)
|
||||
filtered = plugins.visible().ifaces(ifaces)
|
||||
logger.debug("Filtered plugins: %r", filtered)
|
||||
|
||||
if not args.init and not args.prepare:
|
||||
|
|
@ -334,15 +493,13 @@ class SilentParser(object): # pylint: disable=too-few-public-methods
|
|||
"""
|
||||
def __init__(self, parser):
|
||||
self.parser = parser
|
||||
|
||||
def add_argument(self, *args, **kwargs):
|
||||
"""Wrap, but silence help"""
|
||||
kwargs["help"] = argparse.SUPPRESS
|
||||
self.parser.add_argument(*args, **kwargs)
|
||||
|
||||
|
||||
HELP_TOPICS = ["all", "security", "paths", "automation", "testing", "plugins"]
|
||||
|
||||
|
||||
class HelpfulArgumentParser(object):
|
||||
"""Argparse Wrapper.
|
||||
|
||||
|
|
@ -352,7 +509,6 @@ class HelpfulArgumentParser(object):
|
|||
|
||||
"""
|
||||
def __init__(self, args, plugins):
|
||||
self.args = args
|
||||
plugin_names = [name for name, _p in plugins.iteritems()]
|
||||
self.help_topics = HELP_TOPICS + plugin_names + [None]
|
||||
self.parser = configargparse.ArgParser(
|
||||
|
|
@ -362,14 +518,16 @@ class HelpfulArgumentParser(object):
|
|||
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.parser._add_config_file_help = False # pylint: disable=protected-access
|
||||
self.silent_parser = SilentParser(self.parser)
|
||||
|
||||
self.verb = None
|
||||
self.args = self.preprocess_args(args)
|
||||
help1 = self.prescan_for_flag("-h", self.help_topics)
|
||||
help2 = self.prescan_for_flag("--help", self.help_topics)
|
||||
assert max(True, "a") == "a", "Gravity changed direction"
|
||||
help_arg = max(help1, help2)
|
||||
if help_arg == True:
|
||||
if help_arg is True:
|
||||
# just --help with no topic; avoid argparse altogether
|
||||
print USAGE
|
||||
sys.exit(0)
|
||||
|
|
@ -377,6 +535,26 @@ class HelpfulArgumentParser(object):
|
|||
#print self.visible_topics
|
||||
self.groups = {} # elements are added by .add_group()
|
||||
|
||||
def preprocess_args(self, args):
|
||||
"""Work around some limitations in argparse.
|
||||
|
||||
Currently: add the default verb "run" as a default, and ensure that the
|
||||
subcommand / verb comes last.
|
||||
"""
|
||||
if "-h" in args or "--help" in args:
|
||||
# all verbs double as help arguments; don't get them confused
|
||||
self.verb = "help"
|
||||
return args
|
||||
|
||||
for i, token in enumerate(args):
|
||||
if token in VERBS:
|
||||
reordered = args[:i] + args[i+1:] + [args[i]]
|
||||
self.verb = token
|
||||
return reordered
|
||||
|
||||
self.verb = "run"
|
||||
return args + ["run"]
|
||||
|
||||
def prescan_for_flag(self, flag, possible_arguments):
|
||||
"""Checks cli input for flags.
|
||||
|
||||
|
|
@ -483,6 +661,9 @@ def create_parser(plugins, args):
|
|||
#for subparser in parser_run, parser_auth, parser_install:
|
||||
# subparser.add_argument("domains", nargs="*", metavar="domain")
|
||||
helpful.add(None, "-d", "--domains", metavar="DOMAIN", action="append")
|
||||
helpful.add(
|
||||
None, "--duplicate", dest="duplicate", action="store_true",
|
||||
help="Allow getting a certificate that duplicates an existing one")
|
||||
|
||||
helpful.add_group(
|
||||
"automation",
|
||||
|
|
@ -492,8 +673,9 @@ def create_parser(plugins, args):
|
|||
version="%(prog)s {0}".format(letsencrypt.__version__),
|
||||
help="show program's version number and exit")
|
||||
helpful.add(
|
||||
"automation", "--no-confirm", dest="no_confirm", action="store_true",
|
||||
help="Turn off confirmation screens, currently used for --revoke")
|
||||
"automation", "--renew-by-default", action="store_true",
|
||||
help="Select renewal by default when domains are a superset of a "
|
||||
"a previously attained cert")
|
||||
helpful.add(
|
||||
"automation", "--agree-eula", dest="eula", action="store_true",
|
||||
help="Agree to the Let's Encrypt Developer Preview EULA")
|
||||
|
|
@ -533,6 +715,10 @@ def create_parser(plugins, args):
|
|||
"security", "-r", "--redirect", action="store_true",
|
||||
help="Automatically redirect all HTTP traffic to HTTPS for the newly "
|
||||
"authenticated vhost.")
|
||||
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/")
|
||||
|
||||
_paths_parser(helpful)
|
||||
# _plugins_parsing should be the last thing to act upon the main
|
||||
|
|
@ -541,73 +727,83 @@ def create_parser(plugins, args):
|
|||
|
||||
_create_subparsers(helpful)
|
||||
|
||||
return helpful.parser
|
||||
return helpful.parser, helpful.args
|
||||
|
||||
# For now unfortunately this constant just needs to match the code below;
|
||||
# there isn't an elegant way to autogenerate it in time.
|
||||
VERBS = ["run", "auth", "install", "revoke", "rollback", "config_changes", "plugins"]
|
||||
HELP_TOPICS = ["all", "security", "paths", "automation", "testing"] + VERBS
|
||||
|
||||
def _create_subparsers(helpful):
|
||||
subparsers = helpful.parser.add_subparsers(metavar="SUBCOMMAND")
|
||||
def add_subparser(name, func): # pylint: disable=missing-docstring
|
||||
subparser = subparsers.add_parser(
|
||||
name, help=func.__doc__.splitlines()[0], description=func.__doc__)
|
||||
|
||||
def add_subparser(name): # pylint: disable=missing-docstring
|
||||
if name == "plugins":
|
||||
func = plugins_cmd
|
||||
else:
|
||||
func = eval(name) # pylint: disable=eval-used
|
||||
h = func.__doc__.splitlines()[0]
|
||||
subparser = subparsers.add_parser(name, help=h, description=func.__doc__)
|
||||
subparser.set_defaults(func=func)
|
||||
return subparser
|
||||
|
||||
# the order of add_subparser() calls is important: it defines the
|
||||
# order in which subparser names will be displayed in --help
|
||||
add_subparser("run", run)
|
||||
parser_auth = add_subparser("auth", auth)
|
||||
parser_install = add_subparser("install", install)
|
||||
parser_revoke = add_subparser("revoke", revoke)
|
||||
parser_rollback = add_subparser("rollback", rollback)
|
||||
add_subparser("config_changes", config_changes)
|
||||
parser_plugins = add_subparser("plugins", plugins_cmd)
|
||||
# these add_subparser objects return objects to which arguments could be
|
||||
# attached, but they have annoying arg ordering constrains so we use
|
||||
# groups instead: https://github.com/letsencrypt/letsencrypt/issues/820
|
||||
for v in VERBS:
|
||||
add_subparser(v)
|
||||
|
||||
parser_auth.add_argument(
|
||||
"--csr", type=read_file, help="Path to a Certificate Signing "
|
||||
"Request (CSR) in DER format.")
|
||||
parser_auth.add_argument(
|
||||
"--cert-path", default=flag_default("auth_cert_path"),
|
||||
help="When using --csr this is where certificate is saved.")
|
||||
parser_auth.add_argument(
|
||||
"--chain-path", default=flag_default("auth_chain_path"),
|
||||
help="When using --csr this is where certificate chain is saved.")
|
||||
helpful.add_group("auth", 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")
|
||||
|
||||
parser_install.add_argument(
|
||||
"--cert-path", required=True, help="Path to a certificate that "
|
||||
"is going to be installed.")
|
||||
parser_install.add_argument(
|
||||
"--key-path", required=True, help="Accompynying private key")
|
||||
parser_install.add_argument(
|
||||
"--chain-path", help="Accompanying path to a certificate chain.")
|
||||
parser_revoke.add_argument(
|
||||
"--cert-path", type=read_file, help="Revoke a specific certificate.")
|
||||
parser_revoke.add_argument(
|
||||
"--key-path", type=read_file,
|
||||
help="Revoke all certs generated by the provided authorized key.")
|
||||
|
||||
parser_rollback.add_argument(
|
||||
helpful.add("auth",
|
||||
"--csr", type=read_file, help="Path to a Certificate Signing Request (CSR) in DER format.")
|
||||
helpful.add("rollback",
|
||||
"--checkpoints", type=int, metavar="N",
|
||||
default=flag_default("rollback_checkpoints"),
|
||||
help="Revert configuration N number of checkpoints.")
|
||||
|
||||
parser_plugins.add_argument(
|
||||
helpful.add("plugins",
|
||||
"--init", action="store_true", help="Initialize plugins.")
|
||||
parser_plugins.add_argument(
|
||||
"--prepare", action="store_true",
|
||||
help="Initialize and prepare plugins.")
|
||||
parser_plugins.add_argument(
|
||||
helpful.add("plugins",
|
||||
"--prepare", action="store_true", help="Initialize and prepare plugins.")
|
||||
helpful.add("plugins",
|
||||
"--authenticators", action="append_const", dest="ifaces",
|
||||
const=interfaces.IAuthenticator,
|
||||
help="Limit to authenticator plugins only.")
|
||||
parser_plugins.add_argument(
|
||||
const=interfaces.IAuthenticator, help="Limit to authenticator plugins only.")
|
||||
helpful.add("plugins",
|
||||
"--installers", action="append_const", dest="ifaces",
|
||||
const=interfaces.IInstaller, help="Limit to installer plugins only.")
|
||||
|
||||
|
||||
def _paths_parser(helpful):
|
||||
add = helpful.add
|
||||
verb = helpful.verb
|
||||
helpful.add_group(
|
||||
"paths", description="Arguments changing execution paths & servers")
|
||||
|
||||
cph = "Path to where cert is saved (with auth), installed (with install --csr) or revoked."
|
||||
if verb == "auth":
|
||||
add("paths", "--cert-path", default=flag_default("auth_cert_path"), help=cph)
|
||||
elif verb == "revoke":
|
||||
add("paths", "--cert-path", type=read_file, required=True, help=cph)
|
||||
else:
|
||||
add("paths", "--cert-path", help=cph, required=(verb == "install"))
|
||||
|
||||
# revoke --key-path reads a file, install --key-path takes a string
|
||||
add("paths", "--key-path", type=((verb == "revoke" and read_file) or str),
|
||||
required=(verb == "install"),
|
||||
help="Path to private key for cert creation or revocation (if account key is missing)")
|
||||
|
||||
default_cp = None
|
||||
if verb == "auth":
|
||||
default_cp = flag_default("auth_chain_path")
|
||||
add("paths", "--chain-path", default=default_cp,
|
||||
help="Accompanying path to a certificate chain.")
|
||||
add("paths", "--config-dir", default=flag_default("config_dir"),
|
||||
help=config_help("config_dir"))
|
||||
add("paths", "--work-dir", default=flag_default("work_dir"),
|
||||
|
|
@ -623,7 +819,7 @@ def _plugins_parsing(helpful, plugins):
|
|||
"plugins", description="Let's Encrypt client supports an "
|
||||
"extensible plugins architecture. See '%(prog)s plugins' for a "
|
||||
"list of all available plugins and their names. You can force "
|
||||
"a particular plugin by setting options provided below. Futher "
|
||||
"a particular plugin by setting options provided below. Further "
|
||||
"down this help message you will find plugin-specific options "
|
||||
"(prefixed by --{plugin_name}).")
|
||||
helpful.add(
|
||||
|
|
@ -646,7 +842,7 @@ def _setup_logging(args):
|
|||
level = -args.verbose_count * 10
|
||||
fmt = "%(asctime)s:%(levelname)s:%(name)s:%(message)s"
|
||||
if args.text_mode:
|
||||
handler = logging.StreamHandler()
|
||||
handler = colored_logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter(fmt))
|
||||
else:
|
||||
handler = log.DialogHandler()
|
||||
|
|
@ -660,7 +856,7 @@ def _setup_logging(args):
|
|||
# TODO: change before release?
|
||||
log_file_name = os.path.join(args.logs_dir, 'letsencrypt.log')
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
log_file_name, maxBytes=2**20, backupCount=10)
|
||||
log_file_name, maxBytes=2 ** 20, backupCount=10)
|
||||
# rotate on each invocation, rollover only possible when maxBytes
|
||||
# is nonzero and backupCount is nonzero, so we set maxBytes as big
|
||||
# as possible not to overrun in single CLI invocation (1MB).
|
||||
|
|
@ -691,7 +887,8 @@ def _handle_exception(exc_type, exc_value, trace, args):
|
|||
|
||||
"""
|
||||
logger.debug(
|
||||
"Exiting abnormally:\n%s",
|
||||
"Exiting abnormally:%s%s",
|
||||
os.linesep,
|
||||
"".join(traceback.format_exception(exc_type, exc_value, trace)))
|
||||
|
||||
if issubclass(exc_type, Exception) and (args is None or not args.debug):
|
||||
|
|
@ -701,20 +898,23 @@ def _handle_exception(exc_type, exc_value, trace, args):
|
|||
with open(logfile, "w") as logfd:
|
||||
traceback.print_exception(
|
||||
exc_type, exc_value, trace, file=logfd)
|
||||
except: # pylint: disable=bare-except
|
||||
except: # pylint: disable=bare-except
|
||||
sys.exit("".join(
|
||||
traceback.format_exception(exc_type, exc_value, trace)))
|
||||
|
||||
if issubclass(exc_type, errors.Error):
|
||||
sys.exit(exc_value)
|
||||
elif args is None:
|
||||
sys.exit(
|
||||
"An unexpected error occurred. Please see the logfile '{0}' "
|
||||
"for more details.".format(logfile))
|
||||
else:
|
||||
sys.exit(
|
||||
"An unexpected error occurred. Please see the logfiles in {0} "
|
||||
"for more details.".format(args.logs_dir))
|
||||
# Tell the user a bit about what happened, without overwhelming
|
||||
# them with a full traceback
|
||||
msg = ("An unexpected error occurred.\n" +
|
||||
traceback.format_exception_only(exc_type, exc_value)[0] +
|
||||
"Please see the ")
|
||||
if args is None:
|
||||
msg += "logfile '{0}' for more details.".format(logfile)
|
||||
else:
|
||||
msg += "logfiles in {0} for more details.".format(args.logs_dir)
|
||||
sys.exit(msg)
|
||||
else:
|
||||
sys.exit("".join(
|
||||
traceback.format_exception(exc_type, exc_value, trace)))
|
||||
|
|
@ -726,17 +926,21 @@ def main(cli_args=sys.argv[1:]):
|
|||
|
||||
# note: arg parser internally handles --help (and exits afterwards)
|
||||
plugins = plugins_disco.PluginsRegistry.find_all()
|
||||
args = create_parser(plugins, cli_args).parse_args(cli_args)
|
||||
parser, tweaked_cli_args = create_parser(plugins, cli_args)
|
||||
args = parser.parse_args(tweaked_cli_args)
|
||||
config = configuration.NamespaceConfig(args)
|
||||
zope.component.provideUtility(config)
|
||||
|
||||
# Setup logging ASAP, otherwise "No handlers could be found for
|
||||
# logger ..." TODO: this should be done before plugins discovery
|
||||
for directory in config.config_dir, config.work_dir:
|
||||
le_util.make_or_verify_dir(
|
||||
directory, constants.CONFIG_DIRS_MODE, os.geteuid())
|
||||
directory, constants.CONFIG_DIRS_MODE, os.geteuid(),
|
||||
"--strict-permissions" in cli_args)
|
||||
# TODO: logs might contain sensitive data such as contents of the
|
||||
# private key! #525
|
||||
le_util.make_or_verify_dir(args.logs_dir, 0o700, os.geteuid())
|
||||
le_util.make_or_verify_dir(
|
||||
args.logs_dir, 0o700, os.geteuid(), "--strict-permissions" in cli_args)
|
||||
_setup_logging(args)
|
||||
|
||||
# do not log `args`, as it contains sensitive data (e.g. revoke --key)!
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ from letsencrypt import constants
|
|||
from letsencrypt import continuity_auth
|
||||
from letsencrypt import crypto_util
|
||||
from letsencrypt import errors
|
||||
from letsencrypt import error_handler
|
||||
from letsencrypt import interfaces
|
||||
from letsencrypt import le_util
|
||||
from letsencrypt import reverter
|
||||
from letsencrypt import revoker
|
||||
from letsencrypt import storage
|
||||
|
||||
from letsencrypt.display import ops as display_ops
|
||||
|
|
@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
def _acme_from_config_key(config, key):
|
||||
# TODO: Allow for other alg types besides RS256
|
||||
return acme_client.Client(new_reg_uri=config.server, key=key,
|
||||
return acme_client.Client(directory=config.server, key=key,
|
||||
verify_ssl=(not config.no_verify_ssl))
|
||||
|
||||
|
||||
|
|
@ -111,6 +111,8 @@ class Client(object):
|
|||
:ivar .AuthHandler auth_handler: Authorizations handler that will
|
||||
dispatch DV and Continuity challenges to appropriate
|
||||
authenticators (providing `.IAuthenticator` interface).
|
||||
:ivar .IAuthenticator dv_auth: Prepared (`.IAuthenticator.prepare`)
|
||||
authenticator that can solve the `.constants.DV_CHALLENGES`.
|
||||
:ivar .IInstaller installer: Installer.
|
||||
:ivar acme.client.Client acme: Optional ACME client API handle.
|
||||
You might already have one from `register`.
|
||||
|
|
@ -118,14 +120,10 @@ class Client(object):
|
|||
"""
|
||||
|
||||
def __init__(self, config, account_, dv_auth, installer, acme=None):
|
||||
"""Initialize a client.
|
||||
|
||||
:param .IAuthenticator dv_auth: Prepared (`.IAuthenticator.prepare`)
|
||||
authenticator that can solve the `.constants.DV_CHALLENGES`.
|
||||
|
||||
"""
|
||||
"""Initialize a client."""
|
||||
self.config = config
|
||||
self.account = account_
|
||||
self.dv_auth = dv_auth
|
||||
self.installer = installer
|
||||
|
||||
# Initialize ACME if account is provided
|
||||
|
|
@ -211,12 +209,11 @@ class Client(object):
|
|||
# Create CSR from names
|
||||
key = crypto_util.init_save_key(
|
||||
self.config.rsa_key_size, self.config.key_dir)
|
||||
csr = crypto_util.init_save_csr(key, domains, self.config.cert_dir)
|
||||
csr = crypto_util.init_save_csr(key, domains, self.config.csr_dir)
|
||||
|
||||
return self._obtain_certificate(domains, csr) + (key, csr)
|
||||
|
||||
def obtain_and_enroll_certificate(
|
||||
self, domains, authenticator, installer, plugins):
|
||||
def obtain_and_enroll_certificate(self, domains, plugins):
|
||||
"""Obtain and enroll certificate.
|
||||
|
||||
Get a new certificate for the specified domains using the specified
|
||||
|
|
@ -224,12 +221,6 @@ class Client(object):
|
|||
containing it.
|
||||
|
||||
:param list domains: Domains to request.
|
||||
:param authenticator: The authenticator to use.
|
||||
:type authenticator: :class:`letsencrypt.interfaces.IAuthenticator`
|
||||
|
||||
:param installer: The installer to use.
|
||||
:type installer: :class:`letsencrypt.interfaces.IInstaller`
|
||||
|
||||
:param plugins: A PluginsFactory object.
|
||||
|
||||
:returns: A new :class:`letsencrypt.storage.RenewableCert` instance
|
||||
|
|
@ -241,9 +232,10 @@ class Client(object):
|
|||
|
||||
# TODO: remove this dirty hack
|
||||
self.config.namespace.authenticator = plugins.find_init(
|
||||
authenticator).name
|
||||
if installer is not None:
|
||||
self.config.namespace.installer = plugins.find_init(installer).name
|
||||
self.dv_auth).name
|
||||
if self.installer is not None:
|
||||
self.config.namespace.installer = plugins.find_init(
|
||||
self.installer).name
|
||||
|
||||
# XXX: We clearly need a more general and correct way of getting
|
||||
# options into the configobj for the RenewableCert instance.
|
||||
|
|
@ -261,14 +253,11 @@ class Client(object):
|
|||
"Non-standard path(s), might not work with crontab installed "
|
||||
"by your operating system package manager")
|
||||
|
||||
# XXX: just to stop RenewableCert from complaining; this is
|
||||
# probably not a good solution
|
||||
chain_pem = "" if chain is None else OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_PEM, chain)
|
||||
lineage = storage.RenewableCert.new_lineage(
|
||||
domains[0], OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_PEM, certr.body),
|
||||
key.pem, chain_pem, params, config, cli_config)
|
||||
key.pem, crypto_util.dump_pyopenssl_chain(chain),
|
||||
params, config, cli_config)
|
||||
self._report_renewal_status(lineage)
|
||||
return lineage
|
||||
|
||||
|
|
@ -279,8 +268,8 @@ class Client(object):
|
|||
:param .RenewableCert cert: Newly issued certificate
|
||||
|
||||
"""
|
||||
if ("autorenew" not in cert.configuration
|
||||
or cert.configuration.as_bool("autorenew")):
|
||||
if ("autorenew" not in cert.configuration or
|
||||
cert.configuration.as_bool("autorenew")):
|
||||
if ("autodeploy" not in cert.configuration or
|
||||
cert.configuration.as_bool("autodeploy")):
|
||||
msg = "Automatic renewal and deployment has "
|
||||
|
|
@ -297,7 +286,7 @@ class Client(object):
|
|||
"configured in the directories under {0}.").format(
|
||||
cert.cli_config.renewal_configs_dir)
|
||||
reporter = zope.component.getUtility(interfaces.IReporter)
|
||||
reporter.add_message(msg, reporter.LOW_PRIORITY, True)
|
||||
reporter.add_message(msg, reporter.LOW_PRIORITY)
|
||||
|
||||
def save_certificate(self, certr, chain_cert, cert_path, chain_path):
|
||||
# pylint: disable=no-self-use
|
||||
|
|
@ -306,7 +295,7 @@ class Client(object):
|
|||
:param certr: ACME "certificate" resource.
|
||||
:type certr: :class:`acme.messages.Certificate`
|
||||
|
||||
:param chain_cert:
|
||||
:param list chain_cert:
|
||||
:param str cert_path: Candidate path to a certificate.
|
||||
:param str chain_path: Candidate path to a certificate chain.
|
||||
|
||||
|
|
@ -318,7 +307,8 @@ class Client(object):
|
|||
"""
|
||||
for path in cert_path, chain_path:
|
||||
le_util.make_or_verify_dir(
|
||||
os.path.dirname(path), 0o755, os.geteuid())
|
||||
os.path.dirname(path), 0o755, os.geteuid(),
|
||||
self.config.strict_permissions)
|
||||
|
||||
# try finally close
|
||||
cert_chain_abspath = None
|
||||
|
|
@ -333,12 +323,11 @@ class Client(object):
|
|||
logger.info("Server issued certificate; certificate written to %s",
|
||||
act_cert_path)
|
||||
|
||||
if chain_cert is not None:
|
||||
if chain_cert:
|
||||
chain_file, act_chain_path = le_util.unique_file(
|
||||
chain_path, 0o644)
|
||||
# TODO: Except
|
||||
chain_pem = OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_PEM, chain_cert)
|
||||
chain_pem = crypto_util.dump_pyopenssl_chain(chain_cert)
|
||||
try:
|
||||
chain_file.write(chain_pem)
|
||||
finally:
|
||||
|
|
@ -367,18 +356,17 @@ class Client(object):
|
|||
|
||||
chain_path = None if chain_path is None else os.path.abspath(chain_path)
|
||||
|
||||
for dom in domains:
|
||||
# TODO: Provide a fullchain reference for installers like
|
||||
# nginx that want it
|
||||
self.installer.deploy_cert(
|
||||
dom, os.path.abspath(cert_path),
|
||||
os.path.abspath(privkey_path), chain_path)
|
||||
with error_handler.ErrorHandler(self.installer.recovery_routine):
|
||||
for dom in domains:
|
||||
# TODO: Provide a fullchain reference for installers like
|
||||
# nginx that want it
|
||||
self.installer.deploy_cert(
|
||||
dom, os.path.abspath(cert_path),
|
||||
os.path.abspath(privkey_path), chain_path)
|
||||
|
||||
self.installer.save("Deployed Let's Encrypt Certificate")
|
||||
# sites may have been enabled / final cleanup
|
||||
self.installer.restart()
|
||||
|
||||
display_ops.success_installation(domains)
|
||||
self.installer.save("Deployed Let's Encrypt Certificate")
|
||||
# sites may have been enabled / final cleanup
|
||||
self.installer.restart()
|
||||
|
||||
def enhance_config(self, domains, redirect=None):
|
||||
"""Enhance the configuration.
|
||||
|
|
@ -404,6 +392,8 @@ class Client(object):
|
|||
if redirect is None:
|
||||
redirect = enhancements.ask("redirect")
|
||||
|
||||
# When support for more enhancements are added, the call to the
|
||||
# plugin's `enhance` function should be wrapped by an ErrorHandler
|
||||
if redirect:
|
||||
self.redirect_to_ssl(domains)
|
||||
|
||||
|
|
@ -414,14 +404,16 @@ class Client(object):
|
|||
:type vhost: :class:`letsencrypt.interfaces.IInstaller`
|
||||
|
||||
"""
|
||||
for dom in domains:
|
||||
try:
|
||||
self.installer.enhance(dom, "redirect")
|
||||
except errors.PluginError:
|
||||
logger.warn("Unable to perform redirect for %s", dom)
|
||||
with error_handler.ErrorHandler(self.installer.recovery_routine):
|
||||
for dom in domains:
|
||||
try:
|
||||
self.installer.enhance(dom, "redirect")
|
||||
except errors.PluginError:
|
||||
logger.warn("Unable to perform redirect for %s", dom)
|
||||
raise
|
||||
|
||||
self.installer.save("Add Redirects")
|
||||
self.installer.restart()
|
||||
self.installer.save("Add Redirects")
|
||||
self.installer.restart()
|
||||
|
||||
|
||||
def validate_key_csr(privkey, csr=None):
|
||||
|
|
@ -490,27 +482,6 @@ def rollback(default_installer, checkpoints, config, plugins):
|
|||
installer.restart()
|
||||
|
||||
|
||||
def revoke(default_installer, config, plugins, no_confirm, cert, authkey):
|
||||
"""Revoke certificates.
|
||||
|
||||
:param config: Configuration.
|
||||
:type config: :class:`letsencrypt.interfaces.IConfig`
|
||||
|
||||
"""
|
||||
installer = display_ops.pick_installer(
|
||||
config, default_installer, plugins, question="Which installer "
|
||||
"should be used for certificate revocation?")
|
||||
|
||||
revoc = revoker.Revoker(installer, config, no_confirm)
|
||||
# Cert is most selective, so it is chosen first.
|
||||
if cert is not None:
|
||||
revoc.revoke_from_cert(cert[0])
|
||||
elif authkey is not None:
|
||||
revoc.revoke_from_key(le_util.Key(authkey[0], authkey[1]))
|
||||
else:
|
||||
revoc.revoke_from_menu()
|
||||
|
||||
|
||||
def view_config_changes(config):
|
||||
"""View checkpoints and associated configuration changes.
|
||||
|
||||
|
|
|
|||
40
letsencrypt/colored_logging.py
Normal file
40
letsencrypt/colored_logging.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""A formatter and StreamHandler for colorizing logging output."""
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from letsencrypt import le_util
|
||||
|
||||
|
||||
class StreamHandler(logging.StreamHandler):
|
||||
"""Sends colored logging output to a stream.
|
||||
|
||||
If the specified stream is not a tty, the class works like the
|
||||
standard logging.StreamHandler. Default red_level is logging.WARNING.
|
||||
|
||||
:ivar bool colored: True if output should be colored
|
||||
:ivar bool red_level: The level at which to output
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, stream=None):
|
||||
super(StreamHandler, self).__init__(stream)
|
||||
self.colored = (sys.stderr.isatty() if stream is None else
|
||||
stream.isatty())
|
||||
self.red_level = logging.WARNING
|
||||
|
||||
def format(self, record):
|
||||
"""Formats the string representation of record.
|
||||
|
||||
:param logging.LogRecord record: Record to be formatted
|
||||
|
||||
:returns: Formatted, string representation of record
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
output = super(StreamHandler, self).format(record)
|
||||
if self.colored and record.levelno >= self.red_level:
|
||||
return ''.join((le_util.ANSI_SGR_RED,
|
||||
output,
|
||||
le_util.ANSI_SGR_RESET))
|
||||
else:
|
||||
return output
|
||||
|
|
@ -18,8 +18,7 @@ class NamespaceConfig(object):
|
|||
paths defined in :py:mod:`letsencrypt.constants`:
|
||||
|
||||
- `accounts_dir`
|
||||
- `cert_dir`
|
||||
- `cert_key_backup`
|
||||
- `csr_dir`
|
||||
- `in_progress_dir`
|
||||
- `key_dir`
|
||||
- `renewer_config_file`
|
||||
|
|
@ -45,7 +44,7 @@ class NamespaceConfig(object):
|
|||
return (parsed.netloc + parsed.path).replace('/', os.path.sep)
|
||||
|
||||
@property
|
||||
def accounts_dir(self): #pylint: disable=missing-docstring
|
||||
def accounts_dir(self): # pylint: disable=missing-docstring
|
||||
return os.path.join(
|
||||
self.namespace.config_dir, constants.ACCOUNTS_DIR, self.server_path)
|
||||
|
||||
|
|
@ -54,13 +53,8 @@ class NamespaceConfig(object):
|
|||
return os.path.join(self.namespace.work_dir, constants.BACKUP_DIR)
|
||||
|
||||
@property
|
||||
def cert_dir(self): # pylint: disable=missing-docstring
|
||||
return os.path.join(self.namespace.config_dir, constants.CERT_DIR)
|
||||
|
||||
@property
|
||||
def cert_key_backup(self): # pylint: disable=missing-docstring
|
||||
return os.path.join(self.namespace.work_dir,
|
||||
constants.CERT_KEY_BACKUP_DIR, self.server_path)
|
||||
def csr_dir(self): # pylint: disable=missing-docstring
|
||||
return os.path.join(self.namespace.config_dir, constants.CSR_DIR)
|
||||
|
||||
@property
|
||||
def in_progress_dir(self): # pylint: disable=missing-docstring
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ CLI_DEFAULTS = dict(
|
|||
"letsencrypt", "cli.ini"),
|
||||
],
|
||||
verbose_count=-(logging.WARNING / 10),
|
||||
server="https://acme-staging.api.letsencrypt.org/acme/new-reg",
|
||||
server="https://acme-staging.api.letsencrypt.org/directory",
|
||||
rsa_key_size=2048,
|
||||
rollback_checkpoints=1,
|
||||
config_dir="/etc/letsencrypt",
|
||||
|
|
@ -68,12 +68,8 @@ ACCOUNTS_DIR = "accounts"
|
|||
BACKUP_DIR = "backups"
|
||||
"""Directory (relative to `IConfig.work_dir`) where backups are kept."""
|
||||
|
||||
CERT_DIR = "certs"
|
||||
"""See `.IConfig.cert_dir`."""
|
||||
|
||||
CERT_KEY_BACKUP_DIR = "keys-certs"
|
||||
"""Directory where all certificates and keys are stored (relative to
|
||||
`IConfig.work_dir`). Used for easy revocation."""
|
||||
CSR_DIR = "csr"
|
||||
"""See `.IConfig.csr_dir`."""
|
||||
|
||||
IN_PROGRESS_DIR = "IN_PROGRESS"
|
||||
"""Directory used before a permanent checkpoint is finalized (relative to
|
||||
|
|
@ -88,7 +84,7 @@ LIVE_DIR = "live"
|
|||
TEMP_CHECKPOINT_DIR = "temp_checkpoint"
|
||||
"""Temporary checkpoint directory (relative to `IConfig.work_dir`)."""
|
||||
|
||||
RENEWAL_CONFIGS_DIR = "configs"
|
||||
RENEWAL_CONFIGS_DIR = "renewal"
|
||||
"""Renewal configs directory, relative to `IConfig.config_dir`."""
|
||||
|
||||
RENEWER_CONFIG_FILENAME = "renewer.conf"
|
||||
|
|
|
|||
|
|
@ -9,10 +9,13 @@ import logging
|
|||
import os
|
||||
|
||||
import OpenSSL
|
||||
import zope.component
|
||||
|
||||
from acme import crypto_util as acme_crypto_util
|
||||
from acme import jose
|
||||
|
||||
from letsencrypt import errors
|
||||
from letsencrypt import interfaces
|
||||
from letsencrypt import le_util
|
||||
|
||||
|
||||
|
|
@ -44,8 +47,10 @@ def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"):
|
|||
logger.exception(err)
|
||||
raise err
|
||||
|
||||
config = zope.component.getUtility(interfaces.IConfig)
|
||||
# Save file
|
||||
le_util.make_or_verify_dir(key_dir, 0o700, os.geteuid())
|
||||
le_util.make_or_verify_dir(key_dir, 0o700, os.geteuid(),
|
||||
config.strict_permissions)
|
||||
key_f, key_path = le_util.unique_file(
|
||||
os.path.join(key_dir, keyname), 0o600)
|
||||
key_f.write(key_pem)
|
||||
|
|
@ -72,8 +77,10 @@ def init_save_csr(privkey, names, path, csrname="csr-letsencrypt.pem"):
|
|||
"""
|
||||
csr_pem, csr_der = make_csr(privkey.pem, names)
|
||||
|
||||
config = zope.component.getUtility(interfaces.IConfig)
|
||||
# Save CSR
|
||||
le_util.make_or_verify_dir(path, 0o755, os.geteuid())
|
||||
le_util.make_or_verify_dir(path, 0o755, os.geteuid(),
|
||||
config.strict_permissions)
|
||||
csr_f, csr_filename = le_util.unique_file(
|
||||
os.path.join(path, csrname), 0o644)
|
||||
csr_f.write(csr_pem)
|
||||
|
|
@ -205,6 +212,7 @@ def _pyopenssl_load(data, method, types=(
|
|||
raise errors.Error("Unable to load: {0}".format(",".join(
|
||||
str(error) for error in openssl_errors)))
|
||||
|
||||
|
||||
def pyopenssl_load_certificate(data):
|
||||
"""Load PEM/DER certificate.
|
||||
|
||||
|
|
@ -269,3 +277,24 @@ def asn1_generalizedtime_to_dt(timestamp):
|
|||
def pyopenssl_x509_name_as_text(x509name):
|
||||
"""Convert `OpenSSL.crypto.X509Name` to text."""
|
||||
return "/".join("{0}={1}" for key, value in x509name.get_components())
|
||||
|
||||
|
||||
def dump_pyopenssl_chain(chain, filetype=OpenSSL.crypto.FILETYPE_PEM):
|
||||
"""Dump certificate chain into a bundle.
|
||||
|
||||
:param list chain: List of `OpenSSL.crypto.X509` (or wrapped in
|
||||
`acme.jose.ComparableX509`).
|
||||
|
||||
"""
|
||||
# XXX: returns empty string when no chain is available, which
|
||||
# shuts up RenewableCert, but might not be the best solution...
|
||||
|
||||
def _dump_cert(cert):
|
||||
if isinstance(cert, jose.ComparableX509):
|
||||
# pylint: disable=protected-access
|
||||
cert = cert._wrapped
|
||||
return OpenSSL.crypto.dump_certificate(filetype, cert)
|
||||
|
||||
# assumes that OpenSSL.crypto.dump_certificate includes ending
|
||||
# newline character
|
||||
return "".join(_dump_cert(cert) for cert in chain)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from letsencrypt.display import util as display_util
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Define a helper function to avoid verbose code
|
||||
util = zope.component.getUtility # pylint: disable=invalid-name
|
||||
util = zope.component.getUtility
|
||||
|
||||
|
||||
def ask(enhancement):
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ from letsencrypt.display import util as display_util
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Define a helper function to avoid verbose code
|
||||
util = zope.component.getUtility # pylint: disable=invalid-name
|
||||
util = zope.component.getUtility
|
||||
|
||||
|
||||
def choose_plugin(prepared, question):
|
||||
"""Allow the user to choose ther plugin.
|
||||
"""Allow the user to choose their plugin.
|
||||
|
||||
:param list prepared: List of `~.PluginEntryPoint`.
|
||||
:param str question: Question to be presented to the user.
|
||||
|
|
@ -25,8 +25,8 @@ def choose_plugin(prepared, question):
|
|||
:rtype: `~.PluginEntryPoint`
|
||||
|
||||
"""
|
||||
opts = [plugin_ep.description_with_name
|
||||
+ (" [Misconfigured]" if plugin_ep.misconfigured else "")
|
||||
opts = [plugin_ep.description_with_name +
|
||||
(" [Misconfigured]" if plugin_ep.misconfigured else "")
|
||||
for plugin_ep in prepared]
|
||||
|
||||
while True:
|
||||
|
|
@ -65,7 +65,7 @@ def pick_plugin(config, default, plugins, question, ifaces):
|
|||
# throw more UX-friendly error if default not in plugins
|
||||
filtered = plugins.filter(lambda p_ep: p_ep.name == default)
|
||||
else:
|
||||
filtered = plugins.ifaces(ifaces)
|
||||
filtered = plugins.visible().ifaces(ifaces)
|
||||
|
||||
filtered.init(config)
|
||||
verified = filtered.verify(ifaces)
|
||||
|
|
@ -233,6 +233,26 @@ def success_installation(domains):
|
|||
pause=False)
|
||||
|
||||
|
||||
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
|
||||
|
||||
"""
|
||||
util(interfaces.IDisplay).notification(
|
||||
"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))),
|
||||
height=(14 + len(domains)),
|
||||
pause=False)
|
||||
|
||||
|
||||
def _gen_ssl_lab_urls(domains):
|
||||
"""Returns a list of urls.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
"""Revocation UI class."""
|
||||
import os
|
||||
|
||||
import zope.component
|
||||
|
||||
from letsencrypt import interfaces
|
||||
from letsencrypt.display import util as display_util
|
||||
|
||||
# Define a helper function to avoid verbose code
|
||||
util = zope.component.getUtility # pylint: disable=invalid-name
|
||||
|
||||
|
||||
def display_certs(certs):
|
||||
"""Display the certificates in a menu for revocation.
|
||||
|
||||
:param list certs: each is a :class:`letsencrypt.revoker.Cert`
|
||||
|
||||
:returns: tuple of the form (code, selection) where
|
||||
code is a display exit code
|
||||
selection is the user's int selection
|
||||
:rtype: tuple
|
||||
|
||||
"""
|
||||
list_choices = [
|
||||
"%s | %s | %s" % (
|
||||
str(cert.get_cn().ljust(display_util.WIDTH - 39)),
|
||||
cert.get_not_before().strftime("%m-%d-%y"),
|
||||
"Installed" if cert.installed and cert.installed != ["Unknown"]
|
||||
else "") for cert in certs
|
||||
]
|
||||
|
||||
code, tag = util(interfaces.IDisplay).menu(
|
||||
"Which certificates would you like to revoke?",
|
||||
list_choices, help_label="More Info", ok_label="Revoke",
|
||||
cancel_label="Exit")
|
||||
|
||||
return code, tag
|
||||
|
||||
|
||||
def confirm_revocation(cert):
|
||||
"""Confirm revocation screen.
|
||||
|
||||
:param cert: certificate object
|
||||
:type cert: :class:
|
||||
|
||||
:returns: True if user would like to revoke, False otherwise
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
return util(interfaces.IDisplay).yesno(
|
||||
"Are you sure you would like to revoke the following "
|
||||
"certificate:{0}{cert}This action cannot be reversed!".format(
|
||||
os.linesep, cert=cert.pretty_print()))
|
||||
|
||||
|
||||
def more_info_cert(cert):
|
||||
"""Displays more info about the cert.
|
||||
|
||||
:param dict cert: cert dict used throughout revoker.py
|
||||
|
||||
"""
|
||||
util(interfaces.IDisplay).notification(
|
||||
"Certificate Information:{0}{1}".format(
|
||||
os.linesep, cert.pretty_print()),
|
||||
height=display_util.HEIGHT)
|
||||
|
||||
|
||||
def success_revocation(cert):
|
||||
"""Display a success message.
|
||||
|
||||
:param cert: cert that was revoked
|
||||
:type cert: :class:`letsencrypt.revoker.Cert`
|
||||
|
||||
"""
|
||||
util(interfaces.IDisplay).notification(
|
||||
"You have successfully revoked the certificate for "
|
||||
"%s" % cert.get_cn())
|
||||
|
|
@ -76,7 +76,7 @@ class NcursesDisplay(object):
|
|||
"help_label": help_label,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"menu_height": self.height-6,
|
||||
"menu_height": self.height - 6,
|
||||
}
|
||||
|
||||
# Can accept either tuples or just the actual choices
|
||||
|
|
@ -315,7 +315,7 @@ class FileDisplay(object):
|
|||
if index < 1 or index > len(tags):
|
||||
return []
|
||||
# Transform indices to appropriate tags
|
||||
return [tags[index-1] for index in indices]
|
||||
return [tags[index - 1] for index in indices]
|
||||
|
||||
def _print_menu(self, message, choices):
|
||||
"""Print a menu on the screen.
|
||||
|
|
|
|||
99
letsencrypt/error_handler.py
Normal file
99
letsencrypt/error_handler.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Registers functions to be called if an exception or signal occurs."""
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import traceback
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# _SIGNALS stores the signals that will be handled by the ErrorHandler. These
|
||||
# signals were chosen as their default handler terminates the process and could
|
||||
# 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])
|
||||
|
||||
|
||||
class ErrorHandler(object):
|
||||
"""Registers functions to be called if an exception or signal occurs.
|
||||
|
||||
This class allows you to register functions that will be called when
|
||||
an exception (excluding SystemExit) or signal is encountered. The
|
||||
class works best as a context manager. For example:
|
||||
|
||||
with ErrorHandler(cleanup_func):
|
||||
do_something()
|
||||
|
||||
If an exception is raised out of do_something, cleanup_func will be
|
||||
called. The exception is not caught by the ErrorHandler. Similarly,
|
||||
if a signal is encountered, cleanup_func is called followed by the
|
||||
previously registered signal handler.
|
||||
|
||||
Every registered function is attempted to be run to completion
|
||||
exactly once. If a registered function raises an exception, it is
|
||||
logged and the next function is called. If a (different) handled
|
||||
signal occurs while calling a registered function, it is attempted
|
||||
to be called again by the next signal handler.
|
||||
|
||||
"""
|
||||
def __init__(self, func=None):
|
||||
self.funcs = []
|
||||
self.prev_handlers = {}
|
||||
if func is not None:
|
||||
self.register(func)
|
||||
|
||||
def __enter__(self):
|
||||
self.set_signal_handlers()
|
||||
|
||||
def __exit__(self, exec_type, exec_value, trace):
|
||||
# SystemExit is ignored to properly handle forks that don't exec
|
||||
if exec_type not in (None, SystemExit):
|
||||
logger.debug("Encountered exception:\n%s", "".join(
|
||||
traceback.format_exception(exec_type, exec_value, trace)))
|
||||
self.call_registered()
|
||||
self.reset_signal_handlers()
|
||||
|
||||
def register(self, func):
|
||||
"""Registers func to be called if an error occurs."""
|
||||
self.funcs.append(func)
|
||||
|
||||
def call_registered(self):
|
||||
"""Calls all registered functions"""
|
||||
logger.debug("Calling registered functions")
|
||||
while self.funcs:
|
||||
try:
|
||||
self.funcs[-1]()
|
||||
except Exception as error: # pylint: disable=broad-except
|
||||
logger.error("Encountered exception during recovery")
|
||||
logger.exception(error)
|
||||
self.funcs.pop()
|
||||
|
||||
def set_signal_handlers(self):
|
||||
"""Sets signal handlers for signals in _SIGNALS."""
|
||||
for signum in _SIGNALS:
|
||||
prev_handler = signal.getsignal(signum)
|
||||
# If prev_handler is None, the handler was set outside of Python
|
||||
if prev_handler is not None:
|
||||
self.prev_handlers[signum] = prev_handler
|
||||
signal.signal(signum, self._signal_handler)
|
||||
|
||||
def reset_signal_handlers(self):
|
||||
"""Resets signal handlers for signals in _SIGNALS."""
|
||||
for signum in self.prev_handlers:
|
||||
signal.signal(signum, self.prev_handlers[signum])
|
||||
self.prev_handlers.clear()
|
||||
|
||||
def _signal_handler(self, signum, unused_frame):
|
||||
"""Calls registered functions and the previous signal handler.
|
||||
|
||||
:param int signum: number of current signal
|
||||
|
||||
"""
|
||||
logger.debug("Singal %s encountered", signum)
|
||||
self.call_registered()
|
||||
signal.signal(signum, self.prev_handlers[signum])
|
||||
os.kill(os.getpid(), signum)
|
||||
|
|
@ -73,6 +73,7 @@ class NoInstallationError(PluginError):
|
|||
class MisconfigurationError(PluginError):
|
||||
"""Let's Encrypt Misconfiguration error."""
|
||||
|
||||
|
||||
class NotSupportedError(PluginError):
|
||||
"""Let's Encrypt Plugin function not supported error."""
|
||||
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ class IAuthenticator(IPlugin):
|
|||
|
||||
:param str domain: Domain for which challenge preferences are sought.
|
||||
|
||||
:returns: List of challege types (subclasses of
|
||||
:returns: List 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.
|
||||
|
|
@ -194,8 +194,7 @@ class IConfig(zope.interface.Interface):
|
|||
filtered, stripped or sanitized.
|
||||
|
||||
"""
|
||||
server = zope.interface.Attribute(
|
||||
"ACME new registration URI (including /acme/new-reg).")
|
||||
server = zope.interface.Attribute("ACME Directory Resource URI.")
|
||||
email = zope.interface.Attribute(
|
||||
"Email used for registration and recovery contact.")
|
||||
rsa_key_size = zope.interface.Attribute("Size of the RSA key.")
|
||||
|
|
@ -206,12 +205,9 @@ class IConfig(zope.interface.Interface):
|
|||
accounts_dir = zope.interface.Attribute(
|
||||
"Directory where all account information is stored.")
|
||||
backup_dir = zope.interface.Attribute("Configuration backups directory.")
|
||||
cert_dir = zope.interface.Attribute(
|
||||
csr_dir = zope.interface.Attribute(
|
||||
"Directory where newly generated Certificate Signing Requests "
|
||||
"(CSRs) and certificates not enrolled in the renewer are saved.")
|
||||
cert_key_backup = zope.interface.Attribute(
|
||||
"Directory where all certificates and keys are stored. "
|
||||
"Used for easy revocation.")
|
||||
"(CSRs) are saved.")
|
||||
in_progress_dir = zope.interface.Attribute(
|
||||
"Directory used before a permanent checkpoint is finalized.")
|
||||
key_dir = zope.interface.Attribute("Keys storage.")
|
||||
|
|
@ -322,6 +318,17 @@ class IInstaller(IPlugin):
|
|||
|
||||
"""
|
||||
|
||||
def recovery_routine():
|
||||
"""Revert configuration to most recent finalized checkpoint.
|
||||
|
||||
Remove all changes (temporary and permanent) that have not been
|
||||
finalized. This is useful to protect against crashes and other
|
||||
execution interruptions.
|
||||
|
||||
:raises .errors.PluginError: If unable to recover the configuration
|
||||
|
||||
"""
|
||||
|
||||
def view_config_changes():
|
||||
"""Display all of the LE config changes.
|
||||
|
||||
|
|
@ -440,7 +447,6 @@ class IValidator(zope.interface.Interface):
|
|||
|
||||
"""
|
||||
|
||||
|
||||
def hsts(name):
|
||||
"""Verify HSTS header is enabled
|
||||
|
||||
|
|
@ -472,7 +478,7 @@ class IReporter(zope.interface.Interface):
|
|||
LOW_PRIORITY = zope.interface.Attribute(
|
||||
"Used to denote low priority messages")
|
||||
|
||||
def add_message(self, msg, priority, on_crash=False):
|
||||
def add_message(self, msg, priority, on_crash=True):
|
||||
"""Adds msg to the list of messages to be printed.
|
||||
|
||||
:param str msg: Message to be displayed to the user.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ Key = collections.namedtuple("Key", "file pem")
|
|||
CSR = collections.namedtuple("CSR", "file data form")
|
||||
|
||||
|
||||
# ANSI SGR escape codes
|
||||
# Formats text as bold or with increased intensity
|
||||
ANSI_SGR_BOLD = '\033[1m'
|
||||
# Colors text red
|
||||
ANSI_SGR_RED = "\033[31m"
|
||||
# Resets output format
|
||||
ANSI_SGR_RESET = "\033[0m"
|
||||
|
||||
|
||||
def run_script(params):
|
||||
"""Run the script with the given params.
|
||||
|
||||
|
|
@ -70,7 +79,7 @@ def exe_exists(exe):
|
|||
return False
|
||||
|
||||
|
||||
def make_or_verify_dir(directory, mode=0o755, uid=0):
|
||||
def make_or_verify_dir(directory, mode=0o755, uid=0, strict=False):
|
||||
"""Make sure directory exists with proper permissions.
|
||||
|
||||
:param str directory: Path to a directory.
|
||||
|
|
@ -89,9 +98,10 @@ def make_or_verify_dir(directory, mode=0o755, uid=0):
|
|||
os.makedirs(directory, mode)
|
||||
except OSError as exception:
|
||||
if exception.errno == errno.EEXIST:
|
||||
if not check_permissions(directory, mode, uid):
|
||||
if strict and not check_permissions(directory, mode, uid):
|
||||
raise errors.Error(
|
||||
"%s exists, this client can't access it" % directory)
|
||||
"%s exists, but it should be owned by user %d with"
|
||||
"permissions %s" % (directory, uid, oct(mode)))
|
||||
else:
|
||||
raise
|
||||
|
||||
|
|
@ -196,6 +206,8 @@ def safely_remove(path):
|
|||
# start with a period or have two consecutive periods <- this needs to
|
||||
# be done in addition to the regex
|
||||
EMAIL_REGEX = re.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$")
|
||||
|
||||
|
||||
def safe_email(email):
|
||||
"""Scrub email address before using it."""
|
||||
if EMAIL_REGEX.match(email) is not None:
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class DialogHandler(logging.Handler): # pylint: disable=too-few-public-methods
|
|||
logging.Handler.__init__(self, level)
|
||||
self.height = height
|
||||
self.width = width
|
||||
# "dialog" collides with module name... pylint: disable=invalid-name
|
||||
# "dialog" collides with module name...
|
||||
self.d = dialog.Dialog() if d is None else d
|
||||
self.lines = []
|
||||
|
||||
|
|
|
|||
|
|
@ -18,14 +18,15 @@ def option_namespace(name):
|
|||
"""ArgumentParser options namespace (prefix of all options)."""
|
||||
return name + "-"
|
||||
|
||||
|
||||
def dest_namespace(name):
|
||||
"""ArgumentParser dest namespace (prefix of all destinations)."""
|
||||
return name.replace("-", "_") + "_"
|
||||
|
||||
private_ips_regex = re.compile( # pylint: disable=invalid-name
|
||||
private_ips_regex = re.compile(
|
||||
r"(^127\.0\.0\.1)|(^10\.)|(^172\.1[6-9]\.)|"
|
||||
r"(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)")
|
||||
hostname_regex = re.compile( # pylint: disable=invalid-name
|
||||
hostname_regex = re.compile(
|
||||
r"^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*[a-z]+$", re.IGNORECASE)
|
||||
|
||||
|
||||
|
|
@ -86,6 +87,7 @@ class Plugin(object):
|
|||
|
||||
# other
|
||||
|
||||
|
||||
class Addr(object):
|
||||
r"""Represents an virtual host address.
|
||||
|
||||
|
|
@ -171,7 +173,7 @@ class Dvsni(object):
|
|||
achall.chall.encode("token") + '.pem')
|
||||
|
||||
def _setup_challenge_cert(self, achall, s=None):
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
"""Generate and write out challenge certificate."""
|
||||
cert_path = self.get_cert_path(achall)
|
||||
key_path = self.get_key_path(achall)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@ class PluginEntryPoint(object):
|
|||
"""Description with name. Handy for UI."""
|
||||
return "{0} ({1})".format(self.description, self.name)
|
||||
|
||||
@property
|
||||
def hidden(self):
|
||||
"""Should this plugin be hidden from UI?"""
|
||||
return getattr(self.plugin_cls, "hidden", False)
|
||||
|
||||
def ifaces(self, *ifaces_groups):
|
||||
"""Does plugin implements specified interface groups?"""
|
||||
return not ifaces_groups or any(
|
||||
|
|
@ -183,6 +188,10 @@ class PluginsRegistry(collections.Mapping):
|
|||
return type(self)(dict((name, plugin_ep) for name, plugin_ep
|
||||
in self._plugins.iteritems() if pred(plugin_ep)))
|
||||
|
||||
def visible(self):
|
||||
"""Filter plugins based on visibility."""
|
||||
return self.filter(lambda plugin_ep: not plugin_ep.hidden)
|
||||
|
||||
def ifaces(self, *ifaces_groups):
|
||||
"""Filter plugins based on interfaces."""
|
||||
# pylint: disable=star-args
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ class PluginEntryPointTest(unittest.TestCase):
|
|||
with mock.patch("letsencrypt.plugins."
|
||||
"disco.zope.interface") as mock_zope:
|
||||
mock_zope.exceptions = exceptions
|
||||
|
||||
def verify_object(iface, obj): # pylint: disable=missing-docstring
|
||||
assert obj is plugin
|
||||
assert iface is iface1 or iface is iface2 or iface is iface3
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import logging
|
|||
import pipes
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
|
@ -22,7 +23,7 @@ from letsencrypt.plugins import common
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ManualAuthenticator(common.Plugin):
|
||||
class Authenticator(common.Plugin):
|
||||
"""Manual Authenticator.
|
||||
|
||||
.. todo:: Support for `~.challenges.DVSNI`.
|
||||
|
|
@ -37,7 +38,7 @@ class ManualAuthenticator(common.Plugin):
|
|||
Make sure your web server displays the following content at
|
||||
{uri} before continuing:
|
||||
|
||||
{achall.token}
|
||||
{validation}
|
||||
|
||||
Content-Type header MUST be set to {ct}.
|
||||
|
||||
|
|
@ -86,7 +87,7 @@ s.serve_forever()" """
|
|||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ManualAuthenticator, self).__init__(*args, **kwargs)
|
||||
super(Authenticator, self).__init__(*args, **kwargs)
|
||||
self.template = (self.HTTP_TEMPLATE if self.config.no_simple_http_tls
|
||||
else self.HTTPS_TEMPLATE)
|
||||
self._root = (tempfile.mkdtemp() if self.conf("test-mode")
|
||||
|
|
@ -122,6 +123,20 @@ binary for temporary key/certificate generation.""".replace("\n", "")
|
|||
responses.append(self._perform_single(achall))
|
||||
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
|
||||
else:
|
||||
break
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
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
|
||||
|
|
@ -129,13 +144,13 @@ binary for temporary key/certificate generation.""".replace("\n", "")
|
|||
response, validation = achall.gen_response_and_validation(
|
||||
tls=(not self.config.no_simple_http_tls))
|
||||
|
||||
port = (response.port if self.config.simple_http_port is None
|
||||
else int(self.config.simple_http_port))
|
||||
command = self.template.format(
|
||||
root=self._root, achall=achall, response=response,
|
||||
validation=pipes.quote(validation.json_dumps()),
|
||||
encoded_token=achall.chall.encode("token"),
|
||||
ct=response.CONTENT_TYPE, port=(
|
||||
response.port if self.config.simple_http_port is None
|
||||
else self.config.simple_http_port))
|
||||
ct=response.CONTENT_TYPE, port=port)
|
||||
if self.conf("test-mode"):
|
||||
logger.debug("Test mode. Executing the manual command: %s", command)
|
||||
try:
|
||||
|
|
@ -153,12 +168,12 @@ binary for temporary key/certificate generation.""".replace("\n", "")
|
|||
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)
|
||||
time.sleep(4) # XXX
|
||||
self._test_mode_busy_wait(port)
|
||||
if self._httpd.poll() is not None:
|
||||
raise errors.Error("Couldn't execute manual command")
|
||||
else:
|
||||
self._notify_and_wait(self.MESSAGE_TEMPLATE.format(
|
||||
achall=achall, response=response,
|
||||
validation=validation.json_dumps(), response=response,
|
||||
uri=response.uri(achall.domain, achall.challb.chall),
|
||||
ct=response.CONTENT_TYPE, command=command))
|
||||
|
||||
|
|
@ -167,6 +182,8 @@ binary for temporary key/certificate generation.""".replace("\n", "")
|
|||
achall.account_key.public_key(), self.config.simple_http_port):
|
||||
return response
|
||||
else:
|
||||
logger.error(
|
||||
"Self-verify of challenge failed, authorization abandoned.")
|
||||
if self.conf("test-mode") and self._httpd.poll() is not None:
|
||||
# simply verify cause command failure...
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -17,22 +17,22 @@ from letsencrypt.tests import test_util
|
|||
KEY = jose.JWKRSA.load(test_util.load_vector("rsa512_key.pem"))
|
||||
|
||||
|
||||
class ManualAuthenticatorTest(unittest.TestCase):
|
||||
"""Tests for letsencrypt.plugins.manual.ManualAuthenticator."""
|
||||
class AuthenticatorTest(unittest.TestCase):
|
||||
"""Tests for letsencrypt.plugins.manual.Authenticator."""
|
||||
|
||||
def setUp(self):
|
||||
from letsencrypt.plugins.manual import ManualAuthenticator
|
||||
from letsencrypt.plugins.manual import Authenticator
|
||||
self.config = mock.MagicMock(
|
||||
no_simple_http_tls=True, simple_http_port=4430,
|
||||
manual_test_mode=False)
|
||||
self.auth = ManualAuthenticator(config=self.config, name="manual")
|
||||
self.auth = Authenticator(config=self.config, name="manual")
|
||||
self.achalls = [achallenges.SimpleHTTP(
|
||||
challb=acme_util.SIMPLE_HTTP_P, domain="foo.com", account_key=KEY)]
|
||||
|
||||
config_test_mode = mock.MagicMock(
|
||||
no_simple_http_tls=True, simple_http_port=4430,
|
||||
manual_test_mode=True)
|
||||
self.auth_test_mode = ManualAuthenticator(
|
||||
self.auth_test_mode = Authenticator(
|
||||
config=config_test_mode, name="manual")
|
||||
|
||||
def test_more_info(self):
|
||||
|
|
@ -46,12 +46,9 @@ class ManualAuthenticatorTest(unittest.TestCase):
|
|||
self.assertEqual([], self.auth.perform([]))
|
||||
|
||||
@mock.patch("letsencrypt.plugins.manual.sys.stdout")
|
||||
@mock.patch("letsencrypt.plugins.manual.os.urandom")
|
||||
@mock.patch("acme.challenges.SimpleHTTPResponse.simple_verify")
|
||||
@mock.patch("__builtin__.raw_input")
|
||||
def test_perform(self, mock_raw_input, mock_verify, mock_urandom,
|
||||
mock_stdout):
|
||||
mock_urandom.side_effect = nonrandom_urandom
|
||||
def test_perform(self, mock_raw_input, mock_verify, mock_stdout):
|
||||
mock_verify.return_value = True
|
||||
|
||||
resp = challenges.SimpleHTTPResponse(tls=False)
|
||||
|
|
@ -61,7 +58,7 @@ class ManualAuthenticatorTest(unittest.TestCase):
|
|||
self.achalls[0].challb.chall, "foo.com", KEY.public_key(), 4430)
|
||||
|
||||
message = mock_stdout.write.mock_calls[0][1][0]
|
||||
self.assertTrue(self.achalls[0].token in message)
|
||||
self.assertTrue(self.achalls[0].chall.encode("token") in message)
|
||||
|
||||
mock_verify.return_value = False
|
||||
self.assertEqual([None], self.auth.perform(self.achalls))
|
||||
|
|
@ -71,25 +68,29 @@ class ManualAuthenticatorTest(unittest.TestCase):
|
|||
mock_popen.side_effect = OSError
|
||||
self.assertEqual([False], self.auth_test_mode.perform(self.achalls))
|
||||
|
||||
@mock.patch("letsencrypt.plugins.manual.socket.socket", autospec=True)
|
||||
@mock.patch("letsencrypt.plugins.manual.time.sleep", autospec=True)
|
||||
@mock.patch("letsencrypt.plugins.manual.subprocess.Popen", autospec=True)
|
||||
def test_perform_test_command_run_failure(
|
||||
self, mock_popen, unused_mock_sleep):
|
||||
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)
|
||||
|
||||
@mock.patch("letsencrypt.plugins.manual.socket.socket", autospec=True)
|
||||
@mock.patch("letsencrypt.plugins.manual.time.sleep", autospec=True)
|
||||
@mock.patch("acme.challenges.SimpleHTTPResponse.simple_verify",
|
||||
autospec=True)
|
||||
@mock.patch("letsencrypt.plugins.manual.subprocess.Popen", autospec=True)
|
||||
def test_perform_test_mode(self, mock_popen, mock_verify, mock_sleep):
|
||||
def test_perform_test_mode(self, mock_popen, mock_verify, mock_sleep,
|
||||
mock_socket):
|
||||
mock_popen.return_value.poll.side_effect = [None, 10]
|
||||
mock_popen.return_value.pid = 1234
|
||||
mock_verify.return_value = False
|
||||
self.assertEqual([False], self.auth_test_mode.perform(self.achalls))
|
||||
self.assertEqual(1, mock_sleep.call_count)
|
||||
self.assertEqual(1, mock_socket.call_count)
|
||||
|
||||
def test_cleanup_test_mode_already_terminated(self):
|
||||
# pylint: disable=protected-access
|
||||
|
|
@ -106,10 +107,5 @@ class ManualAuthenticatorTest(unittest.TestCase):
|
|||
mock_killpg.assert_called_once_with(1234, signal.SIGTERM)
|
||||
|
||||
|
||||
def nonrandom_urandom(num_bytes):
|
||||
"""Returns a string of length num_bytes"""
|
||||
return "x" * num_bytes
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main() # pragma: no cover
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class Installer(common.Plugin):
|
|||
zope.interface.classProvides(interfaces.IPluginFactory)
|
||||
|
||||
description = "Null Installer"
|
||||
hidden = True
|
||||
|
||||
# pylint: disable=missing-docstring,no-self-use
|
||||
|
||||
|
|
@ -47,6 +48,9 @@ class Installer(common.Plugin):
|
|||
def rollback_checkpoints(self, rollback=1):
|
||||
pass # pragma: no cover
|
||||
|
||||
def recovery_routine(self):
|
||||
pass # pragma: no cover
|
||||
|
||||
def view_config_changes(self):
|
||||
pass # pragma: no cover
|
||||
|
||||
|
|
|
|||
|
|
@ -321,10 +321,8 @@ class PerformTest(unittest.TestCase):
|
|||
self.authenticator.already_listening = mock.Mock(return_value=False)
|
||||
result = self.authenticator.perform(self.achalls)
|
||||
self.assertEqual(len(self.authenticator.tasks), 2)
|
||||
self.assertTrue(
|
||||
self.authenticator.tasks.has_key(self.achall1.token))
|
||||
self.assertTrue(
|
||||
self.authenticator.tasks.has_key(self.achall2.token))
|
||||
self.assertTrue(self.achall1.token in self.authenticator.tasks)
|
||||
self.assertTrue(self.achall2.token in self.authenticator.tasks)
|
||||
self.assertTrue(isinstance(result, list))
|
||||
self.assertEqual(len(result), 3)
|
||||
self.assertTrue(isinstance(result[0], challenges.ChallengeResponse))
|
||||
|
|
@ -340,10 +338,8 @@ class PerformTest(unittest.TestCase):
|
|||
self.authenticator.already_listening = mock.Mock(return_value=False)
|
||||
result = self.authenticator.perform(self.achalls)
|
||||
self.assertEqual(len(self.authenticator.tasks), 2)
|
||||
self.assertTrue(
|
||||
self.authenticator.tasks.has_key(self.achall1.token))
|
||||
self.assertTrue(
|
||||
self.authenticator.tasks.has_key(self.achall2.token))
|
||||
self.assertTrue(self.achall1.token in self.authenticator.tasks)
|
||||
self.assertTrue(self.achall2.token in self.authenticator.tasks)
|
||||
self.assertTrue(isinstance(result, list))
|
||||
self.assertEqual(len(result), 3)
|
||||
self.assertEqual(result, [None, None, False])
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from letsencrypt.display import util as display_util
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProofOfPossession(object): # pylint: disable=too-few-public-methods
|
||||
class ProofOfPossession(object): # pylint: disable=too-few-public-methods
|
||||
"""Proof of Possession Identifier Validation Challenge.
|
||||
|
||||
Based on draft-barnes-acme, section 6.5.
|
||||
|
|
@ -71,7 +71,7 @@ class ProofOfPossession(object): # pylint: disable=too-few-public-methods
|
|||
# If we get here, the key wasn't found
|
||||
return False
|
||||
|
||||
def _gen_response(self, achall, key_path): # pylint: disable=no-self-use
|
||||
def _gen_response(self, achall, key_path): # pylint: disable=no-self-use
|
||||
"""Create the response to the Proof of Possession Challenge.
|
||||
|
||||
:param achall: Proof of Possession Challenge
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ def renew(cert, old_version):
|
|||
# was an int, not a str)
|
||||
config.rsa_key_size = int(config.rsa_key_size)
|
||||
config.dvsni_port = int(config.dvsni_port)
|
||||
zope.component.provideUtility(config)
|
||||
try:
|
||||
authenticator = plugins[renewalparams["authenticator"]]
|
||||
except KeyError:
|
||||
|
|
@ -85,7 +86,7 @@ def renew(cert, old_version):
|
|||
with open(cert.version("cert", old_version)) as f:
|
||||
sans = crypto_util.get_sans_from_cert(f.read())
|
||||
new_certr, new_chain, new_key, _ = le_client.obtain_certificate(sans)
|
||||
if new_chain is not None:
|
||||
if new_chain:
|
||||
# XXX: Assumes that there was no key change. We need logic
|
||||
# for figuring out whether there was or not. Probably
|
||||
# best is to have obtain_certificate return None for
|
||||
|
|
@ -94,8 +95,7 @@ def renew(cert, old_version):
|
|||
return cert.save_successor(
|
||||
old_version, OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_PEM, new_certr.body),
|
||||
new_key.pem, OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_PEM, new_chain))
|
||||
new_key.pem, crypto_util.dump_pyopenssl_chain(new_chain))
|
||||
# TODO: Notify results
|
||||
else:
|
||||
# TODO: Notify negative results
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import textwrap
|
|||
import zope.interface
|
||||
|
||||
from letsencrypt import interfaces
|
||||
from letsencrypt import le_util
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -30,14 +31,12 @@ class Reporter(object):
|
|||
LOW_PRIORITY = 2
|
||||
"""Low priority constant. See `add_message`."""
|
||||
|
||||
_RESET = '\033[0m'
|
||||
_BOLD = '\033[1m'
|
||||
_msg_type = collections.namedtuple('ReporterMsg', 'priority text on_crash')
|
||||
|
||||
def __init__(self):
|
||||
self.messages = Queue.PriorityQueue()
|
||||
|
||||
def add_message(self, msg, priority, on_crash=False):
|
||||
def add_message(self, msg, priority, on_crash=True):
|
||||
"""Adds msg to the list of messages to be printed.
|
||||
|
||||
:param str msg: Message to be displayed to the user.
|
||||
|
|
@ -76,7 +75,7 @@ class Reporter(object):
|
|||
no_exception = sys.exc_info()[0] is None
|
||||
bold_on = sys.stdout.isatty()
|
||||
if bold_on:
|
||||
print self._BOLD
|
||||
print le_util.ANSI_SGR_BOLD
|
||||
print 'IMPORTANT NOTES:'
|
||||
first_wrapper = textwrap.TextWrapper(
|
||||
initial_indent=' - ', subsequent_indent=(' ' * 3))
|
||||
|
|
@ -87,7 +86,7 @@ class Reporter(object):
|
|||
msg = self.messages.get()
|
||||
if no_exception or msg.on_crash:
|
||||
if bold_on and msg.priority > self.HIGH_PRIORITY:
|
||||
sys.stdout.write(self._RESET)
|
||||
sys.stdout.write(le_util.ANSI_SGR_RESET)
|
||||
bold_on = False
|
||||
lines = msg.text.splitlines()
|
||||
print first_wrapper.fill(lines[0])
|
||||
|
|
@ -95,4 +94,4 @@ class Reporter(object):
|
|||
print "\n".join(
|
||||
next_wrapper.fill(line) for line in lines[1:])
|
||||
if bold_on:
|
||||
sys.stdout.write(self._RESET)
|
||||
sys.stdout.write(le_util.ANSI_SGR_RESET)
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ class Reverter(object):
|
|||
self.config = config
|
||||
|
||||
le_util.make_or_verify_dir(
|
||||
config.backup_dir, constants.CONFIG_DIRS_MODE, os.geteuid())
|
||||
config.backup_dir, constants.CONFIG_DIRS_MODE, os.geteuid(),
|
||||
self.config.strict_permissions)
|
||||
|
||||
def revert_temporary_config(self):
|
||||
"""Reload users original configuration files after a temporary save.
|
||||
|
|
@ -180,7 +181,8 @@ class Reverter(object):
|
|||
|
||||
"""
|
||||
le_util.make_or_verify_dir(
|
||||
cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid())
|
||||
cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid(),
|
||||
self.config.strict_permissions)
|
||||
|
||||
op_fd, existing_filepaths = self._read_and_append(
|
||||
os.path.join(cp_dir, "FILEPATHS"))
|
||||
|
|
@ -393,7 +395,8 @@ class Reverter(object):
|
|||
cp_dir = self.config.in_progress_dir
|
||||
|
||||
le_util.make_or_verify_dir(
|
||||
cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid())
|
||||
cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid(),
|
||||
self.config.strict_permissions)
|
||||
|
||||
return cp_dir
|
||||
|
||||
|
|
|
|||
|
|
@ -1,558 +0,0 @@
|
|||
"""Revoker module to enable LE revocations.
|
||||
|
||||
The backend of this module would fit a database quite nicely, but in order to
|
||||
minimize dependencies and maintain transparency, the class currently implements
|
||||
its own storage system. The number of certs that will likely be stored on any
|
||||
given client might not warrant requiring a database.
|
||||
|
||||
"""
|
||||
import collections
|
||||
import csv
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import OpenSSL
|
||||
|
||||
from acme import client as acme_client
|
||||
from acme import crypto_util as acme_crypto_util
|
||||
from acme.jose import util as jose_util
|
||||
|
||||
from letsencrypt import crypto_util
|
||||
from letsencrypt import errors
|
||||
from letsencrypt import le_util
|
||||
|
||||
from letsencrypt.display import util as display_util
|
||||
from letsencrypt.display import revocation
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Revoker(object):
|
||||
"""A revocation class for LE.
|
||||
|
||||
.. todo:: Add a method to specify your own certificate for revocation - CLI
|
||||
|
||||
:ivar .acme.client.Client acme: ACME client
|
||||
|
||||
:ivar installer: Installer object
|
||||
:type installer: :class:`~letsencrypt.interfaces.IInstaller`
|
||||
|
||||
:ivar config: Configuration.
|
||||
:type config: :class:`~letsencrypt.interfaces.IConfig`
|
||||
|
||||
:ivar bool no_confirm: Whether or not to ask for confirmation for revocation
|
||||
|
||||
"""
|
||||
def __init__(self, installer, config, no_confirm=False):
|
||||
# XXX
|
||||
self.acme = acme_client.Client(new_reg_uri=None, key=None, alg=None)
|
||||
|
||||
self.installer = installer
|
||||
self.config = config
|
||||
self.no_confirm = no_confirm
|
||||
|
||||
le_util.make_or_verify_dir(config.cert_key_backup, 0o700, os.geteuid())
|
||||
|
||||
# TODO: Find a better solution for this...
|
||||
self.list_path = os.path.join(config.cert_key_backup, "LIST")
|
||||
# Make sure that the file is available for use for rest of class
|
||||
open(self.list_path, "a").close()
|
||||
|
||||
def revoke_from_key(self, authkey):
|
||||
"""Revoke all certificates under an authorized key.
|
||||
|
||||
:param authkey: Authorized key used in previous transactions
|
||||
:type authkey: :class:`letsencrypt.le_util.Key`
|
||||
|
||||
"""
|
||||
certs = []
|
||||
try:
|
||||
clean_pem = OpenSSL.crypto.dump_privatekey(
|
||||
OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.load_privatekey(
|
||||
OpenSSL.crypto.FILETYPE_PEM, authkey.pem))
|
||||
except OpenSSL.crypto.Error as error:
|
||||
logger.debug(error, exc_info=True)
|
||||
raise errors.RevokerError(
|
||||
"Invalid key file specified to revoke_from_key")
|
||||
|
||||
with open(self.list_path, "rb") as csvfile:
|
||||
csvreader = csv.reader(csvfile)
|
||||
for row in csvreader:
|
||||
# idx, cert, key
|
||||
# Add all keys that match to marked list
|
||||
# Note: The key can be different than the pub key found in the
|
||||
# certificate.
|
||||
_, b_k = self._row_to_backup(row)
|
||||
try:
|
||||
test_pem = OpenSSL.crypto.dump_privatekey(
|
||||
OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.load_privatekey(
|
||||
OpenSSL.crypto.FILETYPE_PEM, open(b_k).read()))
|
||||
except OpenSSL.crypto.Error as error:
|
||||
logger.debug(error, exc_info=True)
|
||||
# This should never happen given the assumptions of the
|
||||
# module. If it does, it is probably best to delete the
|
||||
# the offending key/cert. For now... just raise an exception
|
||||
raise errors.RevokerError("%s - backup file is corrupted.")
|
||||
|
||||
if clean_pem == test_pem:
|
||||
certs.append(
|
||||
Cert.fromrow(row, self.config.cert_key_backup))
|
||||
if certs:
|
||||
self._safe_revoke(certs)
|
||||
else:
|
||||
logger.info("No certificates using the authorized key were found.")
|
||||
|
||||
def revoke_from_cert(self, cert_path):
|
||||
"""Revoke a certificate by specifying a file path.
|
||||
|
||||
.. todo:: Add the ability to revoke the certificate even if the cert
|
||||
is not stored locally. A path to the auth key will need to be
|
||||
attained from the user.
|
||||
|
||||
:param str cert_path: path to ACME certificate in pem form
|
||||
|
||||
"""
|
||||
# Locate the correct certificate (do not rely on filename)
|
||||
cert_to_revoke = Cert(cert_path)
|
||||
|
||||
with open(self.list_path, "rb") as csvfile:
|
||||
csvreader = csv.reader(csvfile)
|
||||
for row in csvreader:
|
||||
cert = Cert.fromrow(row, self.config.cert_key_backup)
|
||||
|
||||
if cert.get_der() == cert_to_revoke.get_der():
|
||||
self._safe_revoke([cert])
|
||||
return
|
||||
|
||||
logger.info("Associated ACME certificate was not found.")
|
||||
|
||||
def revoke_from_menu(self):
|
||||
"""List trusted Let's Encrypt certificates."""
|
||||
|
||||
csha1_vhlist = self._get_installed_locations()
|
||||
certs = self._populate_saved_certs(csha1_vhlist)
|
||||
|
||||
while True:
|
||||
if certs:
|
||||
code, selection = revocation.display_certs(certs)
|
||||
|
||||
if code == display_util.OK:
|
||||
revoked_certs = self._safe_revoke([certs[selection]])
|
||||
# Since we are currently only revoking one cert at a time...
|
||||
if revoked_certs:
|
||||
del certs[selection]
|
||||
elif code == display_util.HELP:
|
||||
revocation.more_info_cert(certs[selection])
|
||||
else:
|
||||
return
|
||||
else:
|
||||
logger.info(
|
||||
"There are not any trusted Let's Encrypt "
|
||||
"certificates for this server.")
|
||||
return
|
||||
|
||||
def _populate_saved_certs(self, csha1_vhlist):
|
||||
# pylint: disable=no-self-use
|
||||
"""Populate a list of all the saved certs.
|
||||
|
||||
It is important to read from the file rather than the directory.
|
||||
We assume that the LIST file is the master record and depending on
|
||||
program crashes, this may differ from what is actually in the directory.
|
||||
Namely, additional certs/keys may exist. There should never be any
|
||||
certs/keys in the LIST that don't exist in the directory however.
|
||||
|
||||
:param dict csha1_vhlist: map from cert sha1 fingerprints to a list
|
||||
of it's installed location paths.
|
||||
|
||||
"""
|
||||
certs = []
|
||||
with open(self.list_path, "rb") as csvfile:
|
||||
csvreader = csv.reader(csvfile)
|
||||
# idx, orig_cert, orig_key
|
||||
for row in csvreader:
|
||||
cert = Cert.fromrow(row, self.config.cert_key_backup)
|
||||
|
||||
# If we were able to find the cert installed... update status
|
||||
cert.installed = csha1_vhlist.get(cert.get_fingerprint(), [])
|
||||
|
||||
certs.append(cert)
|
||||
|
||||
return certs
|
||||
|
||||
def _get_installed_locations(self):
|
||||
"""Get installed locations of certificates.
|
||||
|
||||
:returns: map from cert sha1 fingerprint to :class:`list` of vhosts
|
||||
where the certificate is installed.
|
||||
|
||||
"""
|
||||
csha1_vhlist = {}
|
||||
|
||||
if self.installer is None:
|
||||
return csha1_vhlist
|
||||
|
||||
for (cert_path, _, path) in self.installer.get_all_certs_keys():
|
||||
try:
|
||||
with open(cert_path) as cert_file:
|
||||
cert_data = cert_file.read()
|
||||
except IOError:
|
||||
continue
|
||||
try:
|
||||
cert_obj, _ = crypto_util.pyopenssl_load_certificate(cert_data)
|
||||
except errors.Error:
|
||||
continue
|
||||
cert_sha1 = cert_obj.digest("sha1")
|
||||
if cert_sha1 in csha1_vhlist:
|
||||
csha1_vhlist[cert_sha1].append(path)
|
||||
else:
|
||||
csha1_vhlist[cert_sha1] = [path]
|
||||
|
||||
return csha1_vhlist
|
||||
|
||||
def _safe_revoke(self, certs):
|
||||
"""Confirm and revoke certificates.
|
||||
|
||||
:param certs: certs intended to be revoked
|
||||
:type certs: :class:`list` of :class:`letsencrypt.revoker.Cert`
|
||||
|
||||
:returns: certs successfully revoked
|
||||
:rtype: :class:`list` of :class:`letsencrypt.revoker.Cert`
|
||||
|
||||
"""
|
||||
success_list = []
|
||||
try:
|
||||
for cert in certs:
|
||||
if self.no_confirm or revocation.confirm_revocation(cert):
|
||||
try:
|
||||
self._acme_revoke(cert)
|
||||
except errors.Error:
|
||||
# TODO: Improve error handling when networking is set...
|
||||
logger.error(
|
||||
"Unable to revoke cert:%s%s", os.linesep, str(cert))
|
||||
success_list.append(cert)
|
||||
revocation.success_revocation(cert)
|
||||
finally:
|
||||
if success_list:
|
||||
self._remove_certs_keys(success_list)
|
||||
|
||||
return success_list
|
||||
|
||||
def _acme_revoke(self, cert):
|
||||
"""Revoke the certificate with the ACME server.
|
||||
|
||||
:param cert: certificate to revoke
|
||||
:type cert: :class:`letsencrypt.revoker.Cert`
|
||||
|
||||
:returns: TODO
|
||||
|
||||
"""
|
||||
# XXX | pylint: disable=unused-variable
|
||||
|
||||
# pylint: disable=protected-access
|
||||
certificate = jose_util.ComparableX509(cert._cert)
|
||||
try:
|
||||
with open(cert.backup_key_path, "rU") as backup_key_file:
|
||||
key = OpenSSL.crypto.load_privatekey(
|
||||
OpenSSL.crypto.FILETYPE_PEM, backup_key_file.read())
|
||||
# If the key file doesn't exist... or is corrupted
|
||||
except OpenSSL.crypto.Error as error:
|
||||
logger.debug(error, exc_info=True)
|
||||
raise errors.RevokerError(
|
||||
"Corrupted backup key file: %s" % cert.backup_key_path)
|
||||
|
||||
return self.acme.revoke(cert=None) # XXX
|
||||
|
||||
def _remove_certs_keys(self, cert_list): # pylint: disable=no-self-use
|
||||
"""Remove certificate and key.
|
||||
|
||||
:param list cert_list: Must contain certs, each is of type
|
||||
:class:`letsencrypt.revoker.Cert`
|
||||
|
||||
"""
|
||||
# This must occur first, LIST is the official key
|
||||
self._remove_certs_from_list(cert_list)
|
||||
|
||||
# Remove files
|
||||
for cert in cert_list:
|
||||
os.remove(cert.backup_path)
|
||||
os.remove(cert.backup_key_path)
|
||||
|
||||
def _remove_certs_from_list(self, cert_list): # pylint: disable=no-self-use
|
||||
"""Remove a certificate from the LIST file.
|
||||
|
||||
:param list cert_list: Must contain valid certs, each is of type
|
||||
:class:`letsencrypt.revoker.Cert`
|
||||
|
||||
"""
|
||||
list_path2 = tempfile.mktemp(".tmp", "LIST")
|
||||
idx = 0
|
||||
|
||||
with open(self.list_path, "rb") as orgfile:
|
||||
csvreader = csv.reader(orgfile)
|
||||
with open(list_path2, "wb") as newfile:
|
||||
csvwriter = csv.writer(newfile)
|
||||
|
||||
for row in csvreader:
|
||||
if idx >= len(cert_list) or row != cert_list[idx].get_row():
|
||||
csvwriter.writerow(row)
|
||||
else:
|
||||
idx += 1
|
||||
|
||||
# This should never happen...
|
||||
if idx != len(cert_list):
|
||||
raise errors.RevokerError(
|
||||
"Did not find all cert_list items to remove from LIST")
|
||||
|
||||
shutil.copy2(list_path2, self.list_path)
|
||||
os.remove(list_path2)
|
||||
|
||||
def _row_to_backup(self, row):
|
||||
"""Convenience function
|
||||
|
||||
:param list row: csv file row 'idx', 'cert_path', 'key_path'
|
||||
|
||||
:returns: tuple of the form ('backup_cert_path', 'backup_key_path')
|
||||
:rtype: tuple
|
||||
|
||||
"""
|
||||
return (self._get_backup(self.config.cert_key_backup, row[0], row[1]),
|
||||
self._get_backup(self.config.cert_key_backup, row[0], row[2]))
|
||||
|
||||
@classmethod
|
||||
def store_cert_key(cls, cert_path, key_path, config):
|
||||
"""Store certificate key. (Used to allow quick revocation)
|
||||
|
||||
:param str cert_path: Path to a certificate file.
|
||||
:param str key_path: Path to authorized key for certificate
|
||||
|
||||
:ivar config: Configuration.
|
||||
:type config: :class:`~letsencrypt.interfaces.IConfig`
|
||||
|
||||
"""
|
||||
list_path = os.path.join(config.cert_key_backup, "LIST")
|
||||
le_util.make_or_verify_dir(config.cert_key_backup, 0o700, os.geteuid())
|
||||
|
||||
cls._catalog_files(
|
||||
config.cert_key_backup, cert_path, key_path, list_path)
|
||||
|
||||
@classmethod
|
||||
def _catalog_files(cls, backup_dir, cert_path, key_path, list_path):
|
||||
idx = 0
|
||||
if os.path.isfile(list_path):
|
||||
with open(list_path, "r+b") as csvfile:
|
||||
csvreader = csv.reader(csvfile)
|
||||
|
||||
# Find the highest index in the file
|
||||
for row in csvreader:
|
||||
idx = int(row[0]) + 1
|
||||
csvwriter = csv.writer(csvfile)
|
||||
# You must move the files before appending the row
|
||||
cls._copy_files(backup_dir, idx, cert_path, key_path)
|
||||
csvwriter.writerow([str(idx), cert_path, key_path])
|
||||
|
||||
else:
|
||||
with open(list_path, "wb") as csvfile:
|
||||
csvwriter = csv.writer(csvfile)
|
||||
# You must move the files before appending the row
|
||||
cls._copy_files(backup_dir, idx, cert_path, key_path)
|
||||
csvwriter.writerow([str(idx), cert_path, key_path])
|
||||
|
||||
@classmethod
|
||||
def _copy_files(cls, backup_dir, idx, cert_path, key_path):
|
||||
"""Copies the files into the backup dir appropriately."""
|
||||
shutil.copy2(cert_path, cls._get_backup(backup_dir, idx, cert_path))
|
||||
shutil.copy2(key_path, cls._get_backup(backup_dir, idx, key_path))
|
||||
|
||||
@classmethod
|
||||
def _get_backup(cls, backup_dir, idx, orig_path):
|
||||
"""Returns the path to the backup."""
|
||||
return os.path.join(
|
||||
backup_dir, "{name}_{idx}".format(
|
||||
name=os.path.basename(orig_path), idx=str(idx)))
|
||||
|
||||
|
||||
class Cert(object):
|
||||
"""Cert object used for Revocation convenience.
|
||||
|
||||
:ivar _cert: Certificate
|
||||
:type _cert: :class:`OpenSSL.crypto.X509`
|
||||
|
||||
:ivar int idx: convenience index used for listing
|
||||
:ivar orig: (`str` path - original certificate, `str` status)
|
||||
:type orig: :class:`PathStatus`
|
||||
:ivar orig_key: (`str` path - original auth key, `str` status)
|
||||
:type orig_key: :class:`PathStatus`
|
||||
:ivar str backup_path: backup filepath of the certificate
|
||||
:ivar str backup_key_path: backup filepath of the authorized key
|
||||
|
||||
:ivar list installed: `list` of `str` describing all locations the cert
|
||||
is installed
|
||||
|
||||
"""
|
||||
PathStatus = collections.namedtuple("PathStatus", "path status")
|
||||
"""Convenience container to hold path and status info"""
|
||||
|
||||
DELETED_MSG = "This file has been moved or deleted"
|
||||
CHANGED_MSG = "This file has changed"
|
||||
|
||||
def __init__(self, cert_path):
|
||||
"""Cert initialization
|
||||
|
||||
:param str cert_filepath: Name of file containing certificate in
|
||||
PEM format.
|
||||
|
||||
"""
|
||||
try:
|
||||
with open(cert_path) as cert_file:
|
||||
cert_data = cert_file.read()
|
||||
except IOError:
|
||||
raise errors.RevokerError(
|
||||
"Error loading certificate: %s" % cert_path)
|
||||
|
||||
try:
|
||||
self._cert = OpenSSL.crypto.load_certificate(
|
||||
OpenSSL.crypto.FILETYPE_PEM, cert_data)
|
||||
except OpenSSL.crypto.Error:
|
||||
raise errors.RevokerError(
|
||||
"Error loading certificate: %s" % cert_path)
|
||||
|
||||
self.idx = -1
|
||||
|
||||
self.orig = None
|
||||
self.orig_key = None
|
||||
self.backup_path = ""
|
||||
self.backup_key_path = ""
|
||||
|
||||
self.installed = ["Unknown"]
|
||||
|
||||
@classmethod
|
||||
def fromrow(cls, row, backup_dir):
|
||||
# pylint: disable=protected-access
|
||||
"""Initialize Cert from a csv row."""
|
||||
idx = int(row[0])
|
||||
backup = Revoker._get_backup(backup_dir, idx, row[1])
|
||||
backup_key = Revoker._get_backup(backup_dir, idx, row[2])
|
||||
|
||||
obj = cls(backup)
|
||||
obj.add_meta(idx, row[1], row[2], backup, backup_key)
|
||||
return obj
|
||||
|
||||
def get_row(self):
|
||||
"""Returns a list in CSV format. If meta data is available."""
|
||||
if self.orig is not None and self.orig_key is not None:
|
||||
return [str(self.idx), self.orig.path, self.orig_key.path]
|
||||
return None
|
||||
|
||||
def add_meta(self, idx, orig, orig_key, backup, backup_key):
|
||||
"""Add meta data to cert
|
||||
|
||||
:param int idx: convenience index for revoker
|
||||
:param tuple orig: (`str` original certificate filepath, `str` status)
|
||||
:param tuple orig_key: (`str` original auth key path, `str` status)
|
||||
:param str backup: backup certificate filepath
|
||||
:param str backup_key: backup key filepath
|
||||
|
||||
"""
|
||||
status = ""
|
||||
key_status = ""
|
||||
|
||||
# Verify original cert path
|
||||
if not os.path.isfile(orig):
|
||||
status = Cert.DELETED_MSG
|
||||
else:
|
||||
with open(orig) as orig_file:
|
||||
orig_data = orig_file.read()
|
||||
o_cert = OpenSSL.crypto.load_certificate(
|
||||
OpenSSL.crypto.FILETYPE_PEM, orig_data)
|
||||
if self.get_fingerprint() != o_cert.digest("sha1"):
|
||||
status = Cert.CHANGED_MSG
|
||||
|
||||
# Verify original key path
|
||||
if not os.path.isfile(orig_key):
|
||||
key_status = Cert.DELETED_MSG
|
||||
else:
|
||||
with open(orig_key, "r") as fd:
|
||||
key_pem = fd.read()
|
||||
with open(backup_key, "r") as fd:
|
||||
backup_key_pem = fd.read()
|
||||
if key_pem != backup_key_pem:
|
||||
key_status = Cert.CHANGED_MSG
|
||||
|
||||
self.idx = idx
|
||||
self.orig = Cert.PathStatus(orig, status)
|
||||
self.orig_key = Cert.PathStatus(orig_key, key_status)
|
||||
self.backup_path = backup
|
||||
self.backup_key_path = backup_key
|
||||
|
||||
def get_cn(self):
|
||||
"""Get common name."""
|
||||
return self._cert.get_subject().CN
|
||||
|
||||
def get_fingerprint(self):
|
||||
"""Get SHA1 fingerprint."""
|
||||
return self._cert.digest("sha1")
|
||||
|
||||
def get_not_before(self):
|
||||
"""Get not_valid_before field."""
|
||||
return crypto_util.asn1_generalizedtime_to_dt(
|
||||
self._cert.get_notBefore())
|
||||
|
||||
def get_not_after(self):
|
||||
"""Get not_valid_after field."""
|
||||
return crypto_util.asn1_generalizedtime_to_dt(
|
||||
self._cert.get_notAfter())
|
||||
|
||||
def get_der(self):
|
||||
"""Get certificate in der format."""
|
||||
return OpenSSL.crypto.dump_certificate(
|
||||
OpenSSL.crypto.FILETYPE_ASN1, self._cert)
|
||||
|
||||
def get_pub_key(self):
|
||||
"""Get public key size.
|
||||
|
||||
.. todo:: Support for ECC
|
||||
|
||||
"""
|
||||
return "RSA {0}".format(self._cert.get_pubkey().bits)
|
||||
|
||||
def get_san(self):
|
||||
"""Get subject alternative name if available."""
|
||||
# pylint: disable=protected-access
|
||||
return ", ".join(acme_crypto_util._pyopenssl_cert_or_req_san(self._cert))
|
||||
|
||||
def __str__(self):
|
||||
text = [
|
||||
"Subject: %s" % crypto_util.pyopenssl_x509_name_as_text(
|
||||
self._cert.get_subject()),
|
||||
"SAN: %s" % self.get_san(),
|
||||
"Issuer: %s" % crypto_util.pyopenssl_x509_name_as_text(
|
||||
self._cert.get_issuer()),
|
||||
"Public Key: %s" % self.get_pub_key(),
|
||||
"Not Before: %s" % str(self.get_not_before()),
|
||||
"Not After: %s" % str(self.get_not_after()),
|
||||
"Serial Number: %s" % self._cert.get_serial_number(),
|
||||
"SHA1: %s%s" % (self.get_fingerprint(), os.linesep),
|
||||
"Installed: %s" % ", ".join(self.installed),
|
||||
]
|
||||
|
||||
if self.orig is not None:
|
||||
if self.orig.status == "":
|
||||
text.append("Path: %s" % self.orig.path)
|
||||
else:
|
||||
text.append("Orig Path: %s (%s)" % self.orig)
|
||||
if self.orig_key is not None:
|
||||
if self.orig_key.status == "":
|
||||
text.append("Auth Key Path: %s" % self.orig_key.path)
|
||||
else:
|
||||
text.append("Orig Auth Key Path: %s (%s)" % self.orig_key)
|
||||
|
||||
text.append("")
|
||||
return os.linesep.join(text)
|
||||
|
||||
def pretty_print(self):
|
||||
"""Nicely frames a cert str"""
|
||||
frame = "-" * (display_util.WIDTH - 4) + os.linesep
|
||||
return "{frame}{cert}{frame}".format(frame=frame, cert=str(self))
|
||||
|
|
@ -11,6 +11,7 @@ import pytz
|
|||
import pyrfc3339
|
||||
|
||||
from letsencrypt import constants
|
||||
from letsencrypt import crypto_util
|
||||
from letsencrypt import errors
|
||||
from letsencrypt import le_util
|
||||
|
||||
|
|
@ -223,7 +224,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
|
|||
target = os.readlink(link)
|
||||
if not os.path.isabs(target):
|
||||
target = os.path.join(os.path.dirname(link), target)
|
||||
return target
|
||||
return os.path.abspath(target)
|
||||
|
||||
def current_version(self, kind):
|
||||
"""Returns numerical version of the specified item.
|
||||
|
|
@ -421,6 +422,23 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
|
|||
"""
|
||||
return self._notafterbefore(lambda x509: x509.get_notAfter(), version)
|
||||
|
||||
def names(self, version=None):
|
||||
"""What are the subject names of this certificate?
|
||||
|
||||
(If no version is specified, use the current version.)
|
||||
|
||||
:param int version: the desired version number
|
||||
:returns: the subject names
|
||||
:rtype: `list` of `str`
|
||||
|
||||
"""
|
||||
if version is None:
|
||||
target = self.current_target("cert")
|
||||
else:
|
||||
target = self.version("cert", version)
|
||||
with open(target) as f:
|
||||
return crypto_util.get_sans_from_cert(f.read())
|
||||
|
||||
def should_autodeploy(self):
|
||||
"""Should this lineage now automatically deploy a newer version?
|
||||
|
||||
|
|
@ -486,8 +504,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
|
|||
:rtype: bool
|
||||
|
||||
"""
|
||||
if ("autorenew" not in self.configuration
|
||||
or self.configuration.as_bool("autorenew")):
|
||||
if ("autorenew" not in self.configuration or
|
||||
self.configuration.as_bool("autorenew")):
|
||||
# Consider whether to attempt to autorenew this cert now
|
||||
|
||||
# Renewals on the basis of revocation
|
||||
|
|
@ -502,7 +520,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
|
|||
remaining = expiry - now
|
||||
if remaining < autorenew_interval:
|
||||
return True
|
||||
return False
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def new_lineage(cls, lineagename, cert, privkey, chain,
|
||||
|
|
@ -586,6 +604,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
|
|||
with open(target["chain"], "w") as f:
|
||||
f.write(chain)
|
||||
with open(target["fullchain"], "w") as f:
|
||||
# assumes that OpenSSL.crypto.dump_certificate includes
|
||||
# ending newline character
|
||||
f.write(cert + chain)
|
||||
|
||||
# Document what we've done in a new renewal config file
|
||||
|
|
@ -603,7 +623,6 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
|
|||
new_config.write()
|
||||
return cls(new_config, config, cli_config)
|
||||
|
||||
|
||||
def save_successor(self, prior_version, new_cert, new_privkey, new_chain):
|
||||
"""Save new cert and chain as a successor of a prior version.
|
||||
|
||||
|
|
@ -626,7 +645,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
|
|||
|
||||
"""
|
||||
# XXX: assumes official archive location rather than examining links
|
||||
# XXX: consider using os.open for availablity of os.O_EXCL
|
||||
# XXX: consider using os.open for availability of os.O_EXCL
|
||||
# XXX: ensure file permissions are correct; also create directories
|
||||
# if needed (ensuring their permissions are correct)
|
||||
# Figure out what the new version is and hence where to save things
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ POP = challenges.ProofOfPossession(
|
|||
"16d95b7b63f1972b980b14c20291f3c0d1855d95",
|
||||
"48b46570d9fc6358108af43ad1649484def0debf"
|
||||
),
|
||||
certs=(), # TODO
|
||||
certs=(), # TODO
|
||||
subject_key_identifiers=("d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"),
|
||||
serial_numbers=(34234239832, 23993939911, 17),
|
||||
issuers=(
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class ChallengeFactoryTest(unittest.TestCase):
|
|||
self.dom = "test"
|
||||
self.handler.authzr[self.dom] = acme_util.gen_authzr(
|
||||
messages.STATUS_PENDING, self.dom, acme_util.CHALLENGES,
|
||||
[messages.STATUS_PENDING]*6, False)
|
||||
[messages.STATUS_PENDING] * 6, False)
|
||||
|
||||
def test_all(self):
|
||||
cont_c, dv_c = self.handler._challenge_factory(
|
||||
|
|
@ -163,7 +163,7 @@ class GetAuthorizationsTest(unittest.TestCase):
|
|||
messages.STATUS_VALID,
|
||||
dom,
|
||||
[challb.chall for challb in azr.body.challenges],
|
||||
[messages.STATUS_VALID]*len(azr.body.challenges),
|
||||
[messages.STATUS_VALID] * len(azr.body.challenges),
|
||||
azr.body.combinations)
|
||||
|
||||
|
||||
|
|
@ -183,15 +183,15 @@ class PollChallengesTest(unittest.TestCase):
|
|||
self.doms = ["0", "1", "2"]
|
||||
self.handler.authzr[self.doms[0]] = acme_util.gen_authzr(
|
||||
messages.STATUS_PENDING, self.doms[0],
|
||||
acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False)
|
||||
acme_util.DV_CHALLENGES, [messages.STATUS_PENDING] * 3, False)
|
||||
|
||||
self.handler.authzr[self.doms[1]] = acme_util.gen_authzr(
|
||||
messages.STATUS_PENDING, self.doms[1],
|
||||
acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False)
|
||||
acme_util.DV_CHALLENGES, [messages.STATUS_PENDING] * 3, False)
|
||||
|
||||
self.handler.authzr[self.doms[2]] = acme_util.gen_authzr(
|
||||
messages.STATUS_PENDING, self.doms[2],
|
||||
acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False)
|
||||
acme_util.DV_CHALLENGES, [messages.STATUS_PENDING] * 3, False)
|
||||
|
||||
self.chall_update = {}
|
||||
for dom in self.doms:
|
||||
|
|
@ -282,6 +282,7 @@ class PollChallengesTest(unittest.TestCase):
|
|||
)
|
||||
return (new_authzr, "response")
|
||||
|
||||
|
||||
class GenChallengePathTest(unittest.TestCase):
|
||||
"""Tests for letsencrypt.auth_handler.gen_challenge_path.
|
||||
|
||||
|
|
@ -321,7 +322,7 @@ class GenChallengePathTest(unittest.TestCase):
|
|||
combos = acme_util.gen_combos(challbs)
|
||||
self.assertEqual(self._call(challbs, prefs, combos), (0, 2))
|
||||
|
||||
# dumb_path() trivial test
|
||||
# dumb_path() trivial test
|
||||
self.assertTrue(self._call(challbs, prefs, None))
|
||||
|
||||
def test_full_cont_server(self):
|
||||
|
|
@ -354,7 +355,7 @@ class GenChallengePathTest(unittest.TestCase):
|
|||
class MutuallyExclusiveTest(unittest.TestCase):
|
||||
"""Tests for letsencrypt.auth_handler.mutually_exclusive."""
|
||||
|
||||
# pylint: disable=invalid-name,missing-docstring,too-few-public-methods
|
||||
# pylint: disable=missing-docstring,too-few-public-methods
|
||||
class A(object):
|
||||
pass
|
||||
|
||||
|
|
@ -427,26 +428,29 @@ class ReportFailedChallsTest(unittest.TestCase):
|
|||
from letsencrypt import achallenges
|
||||
|
||||
kwargs = {
|
||||
"chall" : acme_util.SIMPLE_HTTP,
|
||||
"chall": acme_util.SIMPLE_HTTP,
|
||||
"uri": "uri",
|
||||
"status": messages.STATUS_INVALID,
|
||||
"error": messages.Error(typ="tls", detail="detail"),
|
||||
}
|
||||
|
||||
self.simple_http = achallenges.SimpleHTTP(
|
||||
challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args
|
||||
# pylint: disable=star-args
|
||||
challb=messages.ChallengeBody(**kwargs),
|
||||
domain="example.com",
|
||||
account_key="key")
|
||||
|
||||
kwargs["chall"] = acme_util.DVSNI
|
||||
self.dvsni_same = achallenges.DVSNI(
|
||||
challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args
|
||||
# pylint: disable=star-args
|
||||
challb=messages.ChallengeBody(**kwargs),
|
||||
domain="example.com",
|
||||
account_key="key")
|
||||
|
||||
kwargs["error"] = messages.Error(typ="dnssec", detail="detail")
|
||||
self.dvsni_diff = achallenges.DVSNI(
|
||||
challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args
|
||||
# pylint: disable=star-args
|
||||
challb=messages.ChallengeBody(**kwargs),
|
||||
domain="foo.bar",
|
||||
account_key="key")
|
||||
|
||||
|
|
@ -477,7 +481,7 @@ def gen_dom_authzr(domain, unused_new_authzr_uri, challs):
|
|||
"""Generates new authzr for domains."""
|
||||
return acme_util.gen_authzr(
|
||||
messages.STATUS_PENDING, domain, challs,
|
||||
[messages.STATUS_PENDING]*len(challs))
|
||||
[messages.STATUS_PENDING] * len(challs))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import itertools
|
||||
import os
|
||||
import shutil
|
||||
import StringIO
|
||||
import traceback
|
||||
import tempfile
|
||||
import unittest
|
||||
|
|
@ -12,6 +13,12 @@ from letsencrypt import account
|
|||
from letsencrypt import configuration
|
||||
from letsencrypt import errors
|
||||
|
||||
from letsencrypt.tests import renewer_test
|
||||
from letsencrypt.tests import test_util
|
||||
|
||||
|
||||
CSR = test_util.vector_path('csr.der')
|
||||
|
||||
|
||||
class CLITest(unittest.TestCase):
|
||||
"""Tests for different commands."""
|
||||
|
|
@ -36,12 +43,54 @@ class CLITest(unittest.TestCase):
|
|||
ret = cli.main(args)
|
||||
return ret, stdout, stderr, client
|
||||
|
||||
def _call_stdout(self, args):
|
||||
"""
|
||||
Variant of _call that preserves stdout so that it can be mocked by the
|
||||
caller.
|
||||
"""
|
||||
from letsencrypt import cli
|
||||
args = ['--text', '--config-dir', self.config_dir,
|
||||
'--work-dir', self.work_dir, '--logs-dir', self.logs_dir,
|
||||
'--agree-eula'] + args
|
||||
with mock.patch('letsencrypt.cli.sys.stderr') as stderr:
|
||||
with mock.patch('letsencrypt.cli.client') as client:
|
||||
ret = cli.main(args)
|
||||
return ret, None, stderr, client
|
||||
|
||||
|
||||
def test_no_flags(self):
|
||||
self.assertRaises(SystemExit, self._call, [])
|
||||
with mock.patch('letsencrypt.cli.run') as mock_run:
|
||||
self._call([])
|
||||
self.assertEqual(1, mock_run.call_count)
|
||||
|
||||
def test_help(self):
|
||||
self.assertRaises(SystemExit, self._call, ['--help'])
|
||||
self.assertRaises(SystemExit, self._call, ['--help all'])
|
||||
self.assertRaises(SystemExit, self._call, ['--help', 'all'])
|
||||
output = StringIO.StringIO()
|
||||
with mock.patch('letsencrypt.cli.sys.stdout', new=output):
|
||||
self.assertRaises(SystemExit, self._call_stdout, ['--help', 'all'])
|
||||
out = output.getvalue()
|
||||
self.assertTrue("--configurator" in out)
|
||||
self.assertTrue("how a cert is deployed" in out)
|
||||
self.assertTrue("--manual-test-mode" in out)
|
||||
output.truncate(0)
|
||||
self.assertRaises(SystemExit, self._call_stdout, ['-h', 'nginx'])
|
||||
out = output.getvalue()
|
||||
self.assertTrue("--nginx-ctl" in out)
|
||||
self.assertTrue("--manual-test-mode" not in out)
|
||||
self.assertTrue("--checkpoints" not in out)
|
||||
output.truncate(0)
|
||||
self.assertRaises(SystemExit, self._call_stdout, ['--help', 'plugins'])
|
||||
out = output.getvalue()
|
||||
self.assertTrue("--manual-test-mode" not in out)
|
||||
self.assertTrue("--prepare" in out)
|
||||
self.assertTrue("Plugin options" in out)
|
||||
output.truncate(0)
|
||||
self.assertRaises(SystemExit, self._call_stdout, ['-h'])
|
||||
out = output.getvalue()
|
||||
from letsencrypt import cli
|
||||
self.assertTrue(cli.USAGE in out)
|
||||
|
||||
|
||||
def test_rollback(self):
|
||||
_, _, _, client = self._call(['rollback'])
|
||||
|
|
@ -60,42 +109,116 @@ class CLITest(unittest.TestCase):
|
|||
for args in itertools.chain(
|
||||
*(itertools.combinations(flags, r)
|
||||
for r in xrange(len(flags)))):
|
||||
self._call(['plugins',] + list(args))
|
||||
self._call(['plugins'] + list(args))
|
||||
|
||||
@mock.patch("letsencrypt.cli.sys")
|
||||
def test_auth_bad_args(self):
|
||||
ret, _, _, _ = self._call(['-d', 'foo.bar', 'auth', '--csr', CSR])
|
||||
self.assertEqual(ret, '--domains and --csr are mutually exclusive')
|
||||
|
||||
ret, _, _, _ = self._call(['-a', 'bad_auth', 'auth'])
|
||||
self.assertEqual(ret, 'Authenticator could not be determined')
|
||||
|
||||
@mock.patch('letsencrypt.cli.zope.component.getUtility')
|
||||
def test_auth_new_request_success(self, mock_get_utility):
|
||||
cert_path = '/etc/letsencrypt/live/foo.bar'
|
||||
mock_lineage = mock.MagicMock(cert=cert_path)
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client.obtain_and_enroll_certificate.return_value = mock_lineage
|
||||
self._auth_new_request_common(mock_client)
|
||||
self.assertEqual(
|
||||
mock_client.obtain_and_enroll_certificate.call_count, 1)
|
||||
self.assertTrue(
|
||||
cert_path in mock_get_utility().add_message.call_args[0][0])
|
||||
|
||||
def test_auth_new_request_failure(self):
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client.obtain_and_enroll_certificate.return_value = False
|
||||
self.assertRaises(errors.Error,
|
||||
self._auth_new_request_common, mock_client)
|
||||
|
||||
def _auth_new_request_common(self, mock_client):
|
||||
with mock.patch('letsencrypt.cli._treat_as_renewal') as mock_renewal:
|
||||
mock_renewal.return_value = None
|
||||
with mock.patch('letsencrypt.cli._init_le_client') as mock_init:
|
||||
mock_init.return_value = mock_client
|
||||
self._call(['-d', 'foo.bar', '-a', 'standalone', 'auth'])
|
||||
|
||||
@mock.patch('letsencrypt.cli.zope.component.getUtility')
|
||||
@mock.patch('letsencrypt.cli._treat_as_renewal')
|
||||
@mock.patch('letsencrypt.cli._init_le_client')
|
||||
def test_auth_renewal(self, mock_init, mock_renewal, mock_get_utility):
|
||||
cert_path = '/etc/letsencrypt/live/foo.bar'
|
||||
mock_lineage = mock.MagicMock(cert=cert_path)
|
||||
mock_cert = mock.MagicMock(body='body')
|
||||
mock_key = mock.MagicMock(pem='pem_key')
|
||||
mock_renewal.return_value = mock_lineage
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client.obtain_certificate.return_value = (mock_cert, 'chain',
|
||||
mock_key, 'csr')
|
||||
mock_init.return_value = mock_client
|
||||
with mock.patch('letsencrypt.cli.OpenSSL'):
|
||||
with mock.patch('letsencrypt.cli.crypto_util'):
|
||||
self._call(['-d', 'foo.bar', '-a', 'standalone', 'auth'])
|
||||
mock_client.obtain_certificate.assert_called_once_with(['foo.bar'])
|
||||
self.assertEqual(mock_lineage.save_successor.call_count, 1)
|
||||
mock_lineage.update_all_links_to.assert_called_once_with(
|
||||
mock_lineage.latest_common_version())
|
||||
self.assertTrue(
|
||||
cert_path in mock_get_utility().add_message.call_args[0][0])
|
||||
|
||||
@mock.patch('letsencrypt.cli.display_ops.pick_installer')
|
||||
@mock.patch('letsencrypt.cli.zope.component.getUtility')
|
||||
@mock.patch('letsencrypt.cli._init_le_client')
|
||||
def test_auth_csr(self, mock_init, mock_get_utility, mock_pick_installer):
|
||||
cert_path = '/etc/letsencrypt/live/foo.bar'
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client.obtain_certificate_from_csr.return_value = ('certr',
|
||||
'chain')
|
||||
mock_init.return_value = mock_client
|
||||
installer = 'installer'
|
||||
self._call(
|
||||
['-a', 'standalone', '-i', installer, 'auth', '--csr', CSR,
|
||||
'--cert-path', cert_path, '--chain-path', '/'])
|
||||
self.assertEqual(mock_pick_installer.call_args[0][1], installer)
|
||||
mock_client.save_certificate.assert_called_once_with(
|
||||
'certr', 'chain', cert_path, '/')
|
||||
self.assertTrue(
|
||||
cert_path in mock_get_utility().add_message.call_args[0][0])
|
||||
|
||||
@mock.patch('letsencrypt.cli.sys')
|
||||
def test_handle_exception(self, mock_sys):
|
||||
# pylint: disable=protected-access
|
||||
from letsencrypt import cli
|
||||
|
||||
mock_open = mock.mock_open()
|
||||
with mock.patch("letsencrypt.cli.open", mock_open, create=True):
|
||||
exception = Exception("detail")
|
||||
with mock.patch('letsencrypt.cli.open', mock_open, create=True):
|
||||
exception = Exception('detail')
|
||||
cli._handle_exception(
|
||||
Exception, exc_value=exception, trace=None, args=None)
|
||||
mock_open().write.assert_called_once_with("".join(
|
||||
mock_open().write.assert_called_once_with(''.join(
|
||||
traceback.format_exception_only(Exception, exception)))
|
||||
error_msg = mock_sys.exit.call_args_list[0][0][0]
|
||||
self.assertTrue("unexpected error" in error_msg)
|
||||
self.assertTrue('unexpected error' in error_msg)
|
||||
|
||||
with mock.patch("letsencrypt.cli.open", mock_open, create=True):
|
||||
with mock.patch('letsencrypt.cli.open', mock_open, create=True):
|
||||
mock_open.side_effect = [KeyboardInterrupt]
|
||||
error = errors.Error("detail")
|
||||
error = errors.Error('detail')
|
||||
cli._handle_exception(
|
||||
errors.Error, exc_value=error, trace=None, args=None)
|
||||
# assert_any_call used because sys.exit doesn't exit in cli.py
|
||||
mock_sys.exit.assert_any_call("".join(
|
||||
mock_sys.exit.assert_any_call(''.join(
|
||||
traceback.format_exception_only(errors.Error, error)))
|
||||
|
||||
args = mock.MagicMock(debug=False)
|
||||
cli._handle_exception(
|
||||
Exception, exc_value=Exception("detail"), trace=None, args=args)
|
||||
Exception, exc_value=Exception('detail'), trace=None, args=args)
|
||||
error_msg = mock_sys.exit.call_args_list[-1][0][0]
|
||||
self.assertTrue("unexpected error" in error_msg)
|
||||
self.assertTrue('unexpected error' in error_msg)
|
||||
|
||||
interrupt = KeyboardInterrupt("detail")
|
||||
interrupt = KeyboardInterrupt('detail')
|
||||
cli._handle_exception(
|
||||
KeyboardInterrupt, exc_value=interrupt, trace=None, args=None)
|
||||
mock_sys.exit.assert_called_with("".join(
|
||||
mock_sys.exit.assert_called_with(''.join(
|
||||
traceback.format_exception_only(KeyboardInterrupt, interrupt)))
|
||||
|
||||
|
||||
|
|
@ -105,13 +228,13 @@ class DetermineAccountTest(unittest.TestCase):
|
|||
def setUp(self):
|
||||
self.args = mock.MagicMock(account=None, email=None)
|
||||
self.config = configuration.NamespaceConfig(self.args)
|
||||
self.accs = [mock.MagicMock(id="x"), mock.MagicMock(id="y")]
|
||||
self.accs = [mock.MagicMock(id='x'), mock.MagicMock(id='y')]
|
||||
self.account_storage = account.AccountMemoryStorage()
|
||||
|
||||
def _call(self):
|
||||
# pylint: disable=protected-access
|
||||
from letsencrypt.cli import _determine_account
|
||||
with mock.patch("letsencrypt.cli.account.AccountFileStorage") as mock_storage:
|
||||
with mock.patch('letsencrypt.cli.account.AccountFileStorage') as mock_storage:
|
||||
mock_storage.return_value = self.account_storage
|
||||
return _determine_account(self.args, self.config)
|
||||
|
||||
|
|
@ -128,7 +251,7 @@ class DetermineAccountTest(unittest.TestCase):
|
|||
self.assertEqual(self.accs[0].id, self.args.account)
|
||||
self.assertTrue(self.args.email is None)
|
||||
|
||||
@mock.patch("letsencrypt.client.display_ops.choose_account")
|
||||
@mock.patch('letsencrypt.client.display_ops.choose_account')
|
||||
def test_multiple_accounts(self, mock_choose_accounts):
|
||||
for acc in self.accs:
|
||||
self.account_storage.save(acc)
|
||||
|
|
@ -139,11 +262,11 @@ class DetermineAccountTest(unittest.TestCase):
|
|||
self.assertEqual(self.accs[1].id, self.args.account)
|
||||
self.assertTrue(self.args.email is None)
|
||||
|
||||
@mock.patch("letsencrypt.client.display_ops.get_email")
|
||||
@mock.patch('letsencrypt.client.display_ops.get_email')
|
||||
def test_no_accounts_no_email(self, mock_get_email):
|
||||
mock_get_email.return_value = "foo@bar.baz"
|
||||
mock_get_email.return_value = 'foo@bar.baz'
|
||||
|
||||
with mock.patch("letsencrypt.cli.client") as client:
|
||||
with mock.patch('letsencrypt.cli.client') as client:
|
||||
client.register.return_value = (
|
||||
self.accs[0], mock.sentinel.acme)
|
||||
self.assertEqual((self.accs[0], mock.sentinel.acme), self._call())
|
||||
|
|
@ -151,15 +274,57 @@ class DetermineAccountTest(unittest.TestCase):
|
|||
self.config, self.account_storage, tos_cb=mock.ANY)
|
||||
|
||||
self.assertEqual(self.accs[0].id, self.args.account)
|
||||
self.assertEqual("foo@bar.baz", self.args.email)
|
||||
self.assertEqual('foo@bar.baz', self.args.email)
|
||||
|
||||
def test_no_accounts_email(self):
|
||||
self.args.email = "other email"
|
||||
with mock.patch("letsencrypt.cli.client") as client:
|
||||
self.args.email = 'other email'
|
||||
with mock.patch('letsencrypt.cli.client') as client:
|
||||
client.register.return_value = (self.accs[1], mock.sentinel.acme)
|
||||
self._call()
|
||||
self.assertEqual(self.accs[1].id, self.args.account)
|
||||
self.assertEqual("other email", self.args.email)
|
||||
self.assertEqual('other email', self.args.email)
|
||||
|
||||
|
||||
class DuplicativeCertsTest(renewer_test.BaseRenewableCertTest):
|
||||
"""Test to avoid duplicate lineages."""
|
||||
|
||||
def setUp(self):
|
||||
super(DuplicativeCertsTest, self).setUp()
|
||||
self.config.write()
|
||||
self._write_out_ex_kinds()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tempdir)
|
||||
|
||||
@mock.patch('letsencrypt.le_util.make_or_verify_dir')
|
||||
def test_find_duplicative_names(self, unused_makedir):
|
||||
from letsencrypt.cli import _find_duplicative_certs
|
||||
test_cert = test_util.load_vector('cert-san.pem')
|
||||
with open(self.test_rc.cert, 'w') as f:
|
||||
f.write(test_cert)
|
||||
|
||||
# No overlap at all
|
||||
result = _find_duplicative_certs(['wow.net', 'hooray.org'],
|
||||
self.config, self.cli_config)
|
||||
self.assertEqual(result, (None, None))
|
||||
|
||||
# Totally identical
|
||||
result = _find_duplicative_certs(['example.com', 'www.example.com'],
|
||||
self.config, self.cli_config)
|
||||
self.assertTrue(result[0].configfile.filename.endswith('example.org.conf'))
|
||||
self.assertEqual(result[1], None)
|
||||
|
||||
# Superset
|
||||
result = _find_duplicative_certs(['example.com', 'www.example.com',
|
||||
'something.new'], self.config,
|
||||
self.cli_config)
|
||||
self.assertEqual(result[0], None)
|
||||
self.assertTrue(result[1].configfile.filename.endswith('example.org.conf'))
|
||||
|
||||
# Partial overlap doesn't count
|
||||
result = _find_duplicative_certs(['example.com', 'something.new'],
|
||||
self.config, self.cli_config)
|
||||
self.assertEqual(result, (None, None))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
"""Tests for letsencrypt.client."""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import configobj
|
||||
|
|
@ -70,7 +73,7 @@ class ClientTest(unittest.TestCase):
|
|||
|
||||
def test_init_acme_verify_ssl(self):
|
||||
self.acme_client.assert_called_once_with(
|
||||
new_reg_uri=mock.ANY, key=mock.ANY, verify_ssl=True)
|
||||
directory=mock.ANY, key=mock.ANY, verify_ssl=True)
|
||||
|
||||
def _mock_obtain_certificate(self):
|
||||
self.client.auth_handler = mock.MagicMock()
|
||||
|
|
@ -110,7 +113,7 @@ class ClientTest(unittest.TestCase):
|
|||
mock_crypto_util.init_save_key.assert_called_once_with(
|
||||
self.config.rsa_key_size, self.config.key_dir)
|
||||
mock_crypto_util.init_save_csr.assert_called_once_with(
|
||||
mock.sentinel.key, domains, self.config.cert_dir)
|
||||
mock.sentinel.key, domains, self.config.csr_dir)
|
||||
self._check_obtain_certificate()
|
||||
|
||||
@mock.patch("letsencrypt.client.zope.component.getUtility")
|
||||
|
|
@ -145,6 +148,69 @@ class ClientTest(unittest.TestCase):
|
|||
self.assertTrue("renewal but not automatic deployment" in msg)
|
||||
self.assertTrue(cert.cli_config.renewal_configs_dir in msg)
|
||||
|
||||
def test_save_certificate(self):
|
||||
certs = ["matching_cert.pem", "cert.pem", "cert-san.pem"]
|
||||
tmp_path = tempfile.mkdtemp()
|
||||
os.chmod(tmp_path, 0o755) # TODO: really??
|
||||
|
||||
certr = mock.MagicMock(body=test_util.load_cert(certs[0]))
|
||||
cert1 = test_util.load_cert(certs[1])
|
||||
cert2 = test_util.load_cert(certs[2])
|
||||
candidate_cert_path = os.path.join(tmp_path, "certs", "cert.pem")
|
||||
candidate_chain_path = os.path.join(tmp_path, "chains", "chain.pem")
|
||||
|
||||
cert_path, chain_path = self.client.save_certificate(
|
||||
certr, [cert1, cert2], candidate_cert_path, candidate_chain_path)
|
||||
|
||||
self.assertEqual(os.path.dirname(cert_path),
|
||||
os.path.dirname(candidate_cert_path))
|
||||
self.assertEqual(os.path.dirname(chain_path),
|
||||
os.path.dirname(candidate_chain_path))
|
||||
|
||||
with open(cert_path, "r") as cert_file:
|
||||
cert_contents = cert_file.read()
|
||||
self.assertEqual(cert_contents, test_util.load_vector(certs[0]))
|
||||
|
||||
with open(chain_path, "r") as chain_file:
|
||||
chain_contents = chain_file.read()
|
||||
self.assertEqual(chain_contents, test_util.load_vector(certs[1]) +
|
||||
test_util.load_vector(certs[2]))
|
||||
|
||||
shutil.rmtree(tmp_path)
|
||||
|
||||
def test_deploy_certificate(self):
|
||||
self.assertRaises(errors.Error, self.client.deploy_certificate,
|
||||
["foo.bar"], "key", "cert", "chain")
|
||||
|
||||
installer = mock.MagicMock()
|
||||
self.client.installer = installer
|
||||
|
||||
self.client.deploy_certificate(["foo.bar"], "key", "cert", "chain")
|
||||
installer.deploy_cert.assert_called_once_with(
|
||||
"foo.bar", os.path.abspath("cert"),
|
||||
os.path.abspath("key"), os.path.abspath("chain"))
|
||||
self.assertEqual(installer.save.call_count, 1)
|
||||
installer.restart.assert_called_once_with()
|
||||
|
||||
@mock.patch("letsencrypt.client.enhancements")
|
||||
def test_enhance_config(self, mock_enhancements):
|
||||
self.assertRaises(errors.Error,
|
||||
self.client.enhance_config, ["foo.bar"])
|
||||
|
||||
mock_enhancements.ask.return_value = True
|
||||
installer = mock.MagicMock()
|
||||
self.client.installer = installer
|
||||
|
||||
self.client.enhance_config(["foo.bar"])
|
||||
installer.enhance.assert_called_once_with("foo.bar", "redirect")
|
||||
self.assertEqual(installer.save.call_count, 1)
|
||||
installer.restart.assert_called_once_with()
|
||||
|
||||
installer.enhance.side_effect = errors.PluginError
|
||||
self.assertRaises(errors.PluginError,
|
||||
self.client.enhance_config, ["foo.bar"], True)
|
||||
installer.recovery_routine.assert_called_once_with()
|
||||
|
||||
|
||||
class RollbackTest(unittest.TestCase):
|
||||
"""Tests for letsencrypt.client.rollback."""
|
||||
|
|
@ -166,7 +232,7 @@ class RollbackTest(unittest.TestCase):
|
|||
self.assertEqual(self.m_install().restart.call_count, 1)
|
||||
|
||||
def test_no_installer(self):
|
||||
self._call(1, None) # Just make sure no exceptions are raised
|
||||
self._call(1, None) # Just make sure no exceptions are raised
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
40
letsencrypt/tests/colored_logging_test.py
Normal file
40
letsencrypt/tests/colored_logging_test.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Tests for letsencrypt.colored_logging."""
|
||||
import logging
|
||||
import StringIO
|
||||
import unittest
|
||||
|
||||
from letsencrypt import le_util
|
||||
|
||||
|
||||
class StreamHandlerTest(unittest.TestCase):
|
||||
"""Tests for letsencrypt.colored_logging."""
|
||||
|
||||
def setUp(self):
|
||||
from letsencrypt import colored_logging
|
||||
|
||||
self.stream = StringIO.StringIO()
|
||||
self.stream.isatty = lambda: True
|
||||
self.handler = colored_logging.StreamHandler(self.stream)
|
||||
|
||||
self.logger = logging.getLogger()
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
self.logger.addHandler(self.handler)
|
||||
|
||||
def test_format(self):
|
||||
msg = 'I did a thing'
|
||||
self.logger.debug(msg)
|
||||
self.assertEqual(self.stream.getvalue(), '{0}\n'.format(msg))
|
||||
|
||||
def test_format_and_red_level(self):
|
||||
msg = 'I did another thing'
|
||||
self.handler.red_level = logging.DEBUG
|
||||
self.logger.debug(msg)
|
||||
|
||||
self.assertEqual(self.stream.getvalue(),
|
||||
'{0}{1}{2}\n'.format(le_util.ANSI_SGR_RED,
|
||||
msg,
|
||||
le_util.ANSI_SGR_RESET))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main() # pragma: no cover
|
||||
|
|
@ -32,8 +32,8 @@ class NamespaceConfigTest(unittest.TestCase):
|
|||
def test_dynamic_dirs(self, constants):
|
||||
constants.ACCOUNTS_DIR = 'acc'
|
||||
constants.BACKUP_DIR = 'backups'
|
||||
constants.CERT_KEY_BACKUP_DIR = 'c/'
|
||||
constants.CERT_DIR = 'certs'
|
||||
constants.CSR_DIR = 'csr'
|
||||
|
||||
constants.IN_PROGRESS_DIR = '../p'
|
||||
constants.KEY_DIR = 'keys'
|
||||
constants.TEMP_CHECKPOINT_DIR = 't'
|
||||
|
|
@ -41,9 +41,7 @@ class NamespaceConfigTest(unittest.TestCase):
|
|||
self.assertEqual(
|
||||
self.config.accounts_dir, '/tmp/config/acc/acme-server.org:443/new')
|
||||
self.assertEqual(self.config.backup_dir, '/tmp/foo/backups')
|
||||
self.assertEqual(self.config.cert_dir, '/tmp/config/certs')
|
||||
self.assertEqual(
|
||||
self.config.cert_key_backup, '/tmp/foo/c/acme-server.org:443/new')
|
||||
self.assertEqual(self.config.csr_dir, '/tmp/config/csr')
|
||||
self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p')
|
||||
self.assertEqual(self.config.key_dir, '/tmp/config/keys')
|
||||
self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t')
|
||||
|
|
@ -59,9 +57,9 @@ class RenewerConfigurationTest(unittest.TestCase):
|
|||
|
||||
@mock.patch('letsencrypt.configuration.constants')
|
||||
def test_dynamic_dirs(self, constants):
|
||||
constants.ARCHIVE_DIR = "a"
|
||||
constants.ARCHIVE_DIR = 'a'
|
||||
constants.LIVE_DIR = 'l'
|
||||
constants.RENEWAL_CONFIGS_DIR = "renewal_configs"
|
||||
constants.RENEWAL_CONFIGS_DIR = 'renewal_configs'
|
||||
constants.RENEWER_CONFIG_FILENAME = 'r.conf'
|
||||
|
||||
self.assertEqual(self.config.archive_dir, '/tmp/config/a')
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class PerformTest(unittest.TestCase):
|
|||
self.assertRaises(
|
||||
errors.ContAuthError, self.auth.perform, [
|
||||
achallenges.DVSNI(
|
||||
challb=None, domain="0", account_key="invalid_key"),])
|
||||
challb=None, domain="0", account_key="invalid_key")])
|
||||
|
||||
def test_chall_pref(self):
|
||||
self.assertEqual(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import unittest
|
|||
|
||||
import OpenSSL
|
||||
import mock
|
||||
import zope.component
|
||||
|
||||
from letsencrypt import interfaces
|
||||
from letsencrypt.tests import test_util
|
||||
|
||||
|
||||
|
|
@ -20,6 +22,8 @@ class InitSaveKeyTest(unittest.TestCase):
|
|||
"""Tests for letsencrypt.crypto_util.init_save_key."""
|
||||
def setUp(self):
|
||||
logging.disable(logging.CRITICAL)
|
||||
zope.component.provideUtility(
|
||||
mock.Mock(strict_permissions=True), interfaces.IConfig)
|
||||
self.key_dir = tempfile.mkdtemp('key_dir')
|
||||
|
||||
def tearDown(self):
|
||||
|
|
@ -48,6 +52,8 @@ class InitSaveCSRTest(unittest.TestCase):
|
|||
"""Tests for letsencrypt.crypto_util.init_save_csr."""
|
||||
|
||||
def setUp(self):
|
||||
zope.component.provideUtility(
|
||||
mock.Mock(strict_permissions=True), interfaces.IConfig)
|
||||
self.csr_dir = tempfile.mkdtemp('csr_dir')
|
||||
|
||||
def tearDown(self):
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class PickPluginTest(unittest.TestCase):
|
|||
|
||||
def test_no_default(self):
|
||||
self._call()
|
||||
self.assertEqual(1, self.reg.ifaces.call_count)
|
||||
self.assertEqual(1, self.reg.visible().ifaces.call_count)
|
||||
|
||||
def test_no_candidate(self):
|
||||
self.assertTrue(self._call() is None)
|
||||
|
|
@ -94,7 +94,8 @@ class PickPluginTest(unittest.TestCase):
|
|||
plugin_ep.init.return_value = "foo"
|
||||
plugin_ep.misconfigured = False
|
||||
|
||||
self.reg.ifaces().verify().available.return_value = {"bar": plugin_ep}
|
||||
self.reg.visible().ifaces().verify().available.return_value = {
|
||||
"bar": plugin_ep}
|
||||
self.assertEqual("foo", self._call())
|
||||
|
||||
def test_single_misconfigured(self):
|
||||
|
|
@ -102,13 +103,14 @@ class PickPluginTest(unittest.TestCase):
|
|||
plugin_ep.init.return_value = "foo"
|
||||
plugin_ep.misconfigured = True
|
||||
|
||||
self.reg.ifaces().verify().available.return_value = {"bar": plugin_ep}
|
||||
self.reg.visible().ifaces().verify().available.return_value = {
|
||||
"bar": plugin_ep}
|
||||
self.assertTrue(self._call() is None)
|
||||
|
||||
def test_multiple(self):
|
||||
plugin_ep = mock.MagicMock()
|
||||
plugin_ep.init.return_value = "foo"
|
||||
self.reg.ifaces().verify().available.return_value = {
|
||||
self.reg.visible().ifaces().verify().available.return_value = {
|
||||
"bar": plugin_ep,
|
||||
"baz": plugin_ep,
|
||||
}
|
||||
|
|
@ -119,7 +121,7 @@ class PickPluginTest(unittest.TestCase):
|
|||
[plugin_ep, plugin_ep], self.question)
|
||||
|
||||
def test_choose_plugin_none(self):
|
||||
self.reg.ifaces().verify().available.return_value = {
|
||||
self.reg.visible().ifaces().verify().available.return_value = {
|
||||
"bar": None,
|
||||
"baz": None,
|
||||
}
|
||||
|
|
@ -250,6 +252,7 @@ class GenSSLLabURLs(unittest.TestCase):
|
|||
self.assertTrue("eff.org" in urls[0])
|
||||
self.assertTrue("umich.edu" in urls[1])
|
||||
|
||||
|
||||
class GenHttpsNamesTest(unittest.TestCase):
|
||||
"""Test _gen_https_names."""
|
||||
def setUp(self):
|
||||
|
|
@ -383,5 +386,27 @@ class SuccessInstallationTest(unittest.TestCase):
|
|||
self.assertTrue(name in arg)
|
||||
|
||||
|
||||
class SuccessRenewalTest(unittest.TestCase):
|
||||
# pylint: disable=too-few-public-methods
|
||||
"""Test the success renewal message."""
|
||||
@classmethod
|
||||
def _call(cls, names):
|
||||
from letsencrypt.display.ops import success_renewal
|
||||
success_renewal(names)
|
||||
|
||||
@mock.patch("letsencrypt.display.ops.util")
|
||||
def test_success_renewal(self, mock_util):
|
||||
mock_util().notification.return_value = None
|
||||
names = ["example.com", "abc.com"]
|
||||
|
||||
self._call(names)
|
||||
|
||||
self.assertEqual(mock_util().notification.call_count, 1)
|
||||
arg = mock_util().notification.call_args_list[0][0][0]
|
||||
|
||||
for name in names:
|
||||
self.assertTrue(name in arg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main() # pragma: no cover
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
"""Test :mod:`letsencrypt.display.revocation`."""
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
import zope.component
|
||||
|
||||
from letsencrypt.display import util as display_util
|
||||
|
||||
from letsencrypt.tests import test_util
|
||||
|
||||
|
||||
class DisplayCertsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from letsencrypt.revoker import Cert
|
||||
self.cert0 = Cert(test_util.vector_path("cert.pem"))
|
||||
self.cert1 = Cert(test_util.vector_path("cert-san.pem"))
|
||||
|
||||
self.certs = [self.cert0, self.cert1]
|
||||
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
|
||||
|
||||
@classmethod
|
||||
def _call(cls, certs):
|
||||
from letsencrypt.display.revocation import display_certs
|
||||
return display_certs(certs)
|
||||
|
||||
@mock.patch("letsencrypt.display.revocation.util")
|
||||
def test_revocation(self, mock_util):
|
||||
mock_util().menu.return_value = (display_util.OK, 0)
|
||||
|
||||
code, choice = self._call(self.certs)
|
||||
|
||||
self.assertEqual(display_util.OK, code)
|
||||
self.assertEqual(self.certs[choice], self.cert0)
|
||||
|
||||
@mock.patch("letsencrypt.display.revocation.util")
|
||||
def test_cancel(self, mock_util):
|
||||
mock_util().menu.return_value = (display_util.CANCEL, -1)
|
||||
|
||||
code, _ = self._call(self.certs)
|
||||
self.assertEqual(display_util.CANCEL, code)
|
||||
|
||||
|
||||
class MoreInfoCertTest(unittest.TestCase):
|
||||
# pylint: disable=too-few-public-methods
|
||||
@classmethod
|
||||
def _call(cls, cert):
|
||||
from letsencrypt.display.revocation import more_info_cert
|
||||
more_info_cert(cert)
|
||||
|
||||
@mock.patch("letsencrypt.display.revocation.util")
|
||||
def test_more_info(self, mock_util):
|
||||
self._call(mock.MagicMock())
|
||||
|
||||
self.assertEqual(mock_util().notification.call_count, 1)
|
||||
|
||||
|
||||
class SuccessRevocationTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from letsencrypt.revoker import Cert
|
||||
self.cert = Cert(test_util.vector_path("cert.pem"))
|
||||
|
||||
@classmethod
|
||||
def _call(cls, cert):
|
||||
from letsencrypt.display.revocation import success_revocation
|
||||
success_revocation(cert)
|
||||
|
||||
# Pretty trivial test... something is displayed...
|
||||
@mock.patch("letsencrypt.display.revocation.util")
|
||||
def test_success_revocation(self, mock_util):
|
||||
self._call(self.cert)
|
||||
|
||||
self.assertEqual(mock_util().notification.call_count, 1)
|
||||
|
||||
|
||||
class ConfirmRevocationTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from letsencrypt.revoker import Cert
|
||||
self.cert = Cert(test_util.vector_path("cert.pem"))
|
||||
|
||||
@classmethod
|
||||
def _call(cls, cert):
|
||||
from letsencrypt.display.revocation import confirm_revocation
|
||||
return confirm_revocation(cert)
|
||||
|
||||
@mock.patch("letsencrypt.display.revocation.util")
|
||||
def test_confirm_revocation(self, mock_util):
|
||||
mock_util().yesno.return_value = True
|
||||
self.assertTrue(self._call(self.cert))
|
||||
|
||||
mock_util().yesno.return_value = False
|
||||
self.assertFalse(self._call(self.cert))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main() # pragma: no cover
|
||||
|
|
@ -35,7 +35,7 @@ class NcursesDisplayTest(unittest.TestCase):
|
|||
"help_label": "",
|
||||
"width": display_util.WIDTH,
|
||||
"height": display_util.HEIGHT,
|
||||
"menu_height": display_util.HEIGHT-6,
|
||||
"menu_height": display_util.HEIGHT - 6,
|
||||
}
|
||||
|
||||
@mock.patch("letsencrypt.display.util.dialog.Dialog.msgbox")
|
||||
|
|
|
|||
64
letsencrypt/tests/error_handler_test.py
Normal file
64
letsencrypt/tests/error_handler_test.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Tests for letsencrypt.error_handler."""
|
||||
import signal
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
|
||||
class ErrorHandlerTest(unittest.TestCase):
|
||||
"""Tests for letsencrypt.error_handler."""
|
||||
|
||||
def setUp(self):
|
||||
from letsencrypt import error_handler
|
||||
|
||||
self.init_func = mock.MagicMock()
|
||||
self.handler = error_handler.ErrorHandler(self.init_func)
|
||||
# pylint: disable=protected-access
|
||||
self.signals = error_handler._SIGNALS
|
||||
|
||||
def test_context_manager(self):
|
||||
try:
|
||||
with self.handler:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
pass
|
||||
self.init_func.assert_called_once_with()
|
||||
|
||||
@mock.patch('letsencrypt.error_handler.os')
|
||||
@mock.patch('letsencrypt.error_handler.signal')
|
||||
def test_signal_handler(self, mock_signal, mock_os):
|
||||
# pylint: disable=protected-access
|
||||
mock_signal.getsignal.return_value = signal.SIG_DFL
|
||||
self.handler.set_signal_handlers()
|
||||
signal_handler = self.handler._signal_handler
|
||||
for signum in self.signals:
|
||||
mock_signal.signal.assert_any_call(signum, signal_handler)
|
||||
|
||||
signum = self.signals[0]
|
||||
signal_handler(signum, None)
|
||||
self.init_func.assert_called_once_with()
|
||||
mock_os.kill.assert_called_once_with(mock_os.getpid(), signum)
|
||||
|
||||
self.handler.reset_signal_handlers()
|
||||
for signum in self.signals:
|
||||
mock_signal.signal.assert_any_call(signum, signal.SIG_DFL)
|
||||
|
||||
def test_bad_recovery(self):
|
||||
bad_func = mock.MagicMock(side_effect=[ValueError])
|
||||
self.handler.register(bad_func)
|
||||
self.handler.call_registered()
|
||||
self.init_func.assert_called_once_with()
|
||||
bad_func.assert_called_once_with()
|
||||
|
||||
def test_sysexit_ignored(self):
|
||||
try:
|
||||
with self.handler:
|
||||
sys.exit(0)
|
||||
except SystemExit:
|
||||
pass
|
||||
self.assertFalse(self.init_func.called)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main() # pragma: no cover
|
||||
|
|
@ -92,7 +92,7 @@ class MakeOrVerifyDirTest(unittest.TestCase):
|
|||
|
||||
def _call(self, directory, mode):
|
||||
from letsencrypt.le_util import make_or_verify_dir
|
||||
return make_or_verify_dir(directory, mode, self.uid)
|
||||
return make_or_verify_dir(directory, mode, self.uid, strict=True)
|
||||
|
||||
def test_creates_dir_when_missing(self):
|
||||
path = os.path.join(self.root_path, "bar")
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import mock
|
|||
class DialogHandlerTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.d = mock.MagicMock() # pylint: disable=invalid-name
|
||||
self.d = mock.MagicMock()
|
||||
|
||||
from letsencrypt.log import DialogHandler
|
||||
self.handler = DialogHandler(height=2, width=6, d=self.d)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
"""Tests for letsencrypt.notify."""
|
||||
|
||||
import mock
|
||||
import socket
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
|
||||
class NotifyTests(unittest.TestCase):
|
||||
"""Tests for the notifier."""
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue