Skip to content

Commit 7705b18

Browse files
committed
Refactor integration test.
Add a new tiny client called chisel, in place of test.js. This reduces the number of language runtimes Boulder depends on for its tests. Also, since chisel uses the acme Python library, we get more testing of that library, which underlies Certbot. This also gives us more flexibility to hook different parts of the issuance flows in our tests. Reorganize integration-test.py itself. There was not clear separation of specific test cases. Some test cases were added as part of run_node_test; some were wrapped around it. There is now much closer to one function per test case. Eventually we may be able to adopt Python's test infrastructure for these test cases. Remove some unused imports; consolidate on urllib2 instead of urllib. For getting serial number and expiration date, replace shelling out to OpenSSL with using pyOpenSSL, since we already have an in-memory parsed certificate. Replace ISSUANCE_FAILED, REVOCATION_FAILED, MAILER_FAILED with simple die, since we don't use these. Later, I'd like to remove the other specific exit codes. We don't make very good use of them, and it would be more effective to just use stack traces or, even better, reporting of which test cases failed. Make single_ocsp_sign responsible for its own subprocess lifecycle. Skip running startservers if WFE is already running, to make it easier to iterate against a running Boulder (saves a few seconds of Boulder startup).
1 parent 16ab736 commit 7705b18

File tree

4 files changed

+281
-162
lines changed

4 files changed

+281
-162
lines changed

test.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ if [[ "$RUN" =~ "integration" ]] ; then
191191
source ${CERTBOT_PATH}/${VENV_NAME:-venv}/bin/activate
192192
fi
193193

194-
run python test/integration-test.py --all
194+
run python test/integration-test.py --chisel
195195
end_context #integration
196196
fi
197197

test/chisel.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""
2+
A simple client that uses the Python ACME library to run a test issuance against
3+
a local Boulder server. Usage:
4+
5+
$ virtualenv venv
6+
$ . venv/bin/activate
7+
$ pip install -r requirements.txt
8+
$ python chisel.py foo.com bar.com
9+
"""
10+
import json
11+
import logging
12+
import os
13+
import sys
14+
import threading
15+
import time
16+
import urllib2
17+
18+
from cryptography.hazmat.backends import default_backend
19+
from cryptography.hazmat.primitives.asymmetric import rsa
20+
21+
import OpenSSL
22+
23+
from acme import challenges
24+
from acme import client as acme_client
25+
from acme import errors as acme_errors
26+
from acme import jose
27+
from acme import messages
28+
from acme import standalone
29+
30+
logger = logging.getLogger()
31+
logger.setLevel(int(os.getenv('LOGLEVEL', 20)))
32+
33+
def make_client(email=None):
34+
"""Build an acme.Client and register a new account with a random key."""
35+
key = jose.JWKRSA(key=rsa.generate_private_key(65537, 2048, default_backend()))
36+
37+
net = acme_client.ClientNetwork(key, verify_ssl=False,
38+
user_agent="Boulder integration tester")
39+
40+
client = acme_client.Client("http://localhost:4000/directory", key=key, net=net)
41+
account = client.register(messages.NewRegistration.from_data(email=email))
42+
client.agree_to_tos(account)
43+
return client
44+
45+
def get_chall(client, domain):
46+
"""Ask the server for an authz, return the authz and an HTTP-01 challenge."""
47+
authz = client.request_domain_challenges(domain)
48+
for chall_body in authz.body.challenges:
49+
if isinstance(chall_body.chall, challenges.HTTP01):
50+
return authz, chall_body
51+
raise "No HTTP-01 challenge found"
52+
53+
def make_authzs(client, domains):
54+
"""Make authzs for each of the given domains. Return a list of authzs
55+
and challenges."""
56+
authzs, challenges = [], []
57+
for d in domains:
58+
authz, chall_body = get_chall(client, d)
59+
60+
authzs.append(authz)
61+
challenges.append(chall_body)
62+
return authzs, challenges
63+
64+
class ValidationError(Exception):
65+
"""An error that occurs during challenge validation."""
66+
def __init__(self, domain, problem_type, detail, *args, **kwargs):
67+
self.domain = domain
68+
self.problem_type = problem_type
69+
self.detail = detail
70+
71+
def __str__(self):
72+
return "%s: %s: %s" % (self.domain, self.problem_type, self.detail)
73+
74+
def issue(client, authzs, cert_output=None):
75+
"""Given a list of authzs that are being processed by the server,
76+
wait for them to be ready, then request issuance of a cert with a random
77+
key for the given domains."""
78+
domains = [authz.body.identifier.value for authz in authzs]
79+
pkey = OpenSSL.crypto.PKey()
80+
pkey.generate_key(OpenSSL.crypto.TYPE_RSA, 2048)
81+
csr = OpenSSL.crypto.X509Req()
82+
csr.add_extensions([
83+
OpenSSL.crypto.X509Extension(
84+
'subjectAltName',
85+
critical=False,
86+
value=', '.join('DNS:' + d for d in domains).encode()
87+
),
88+
])
89+
csr.set_pubkey(pkey)
90+
csr.set_version(2)
91+
csr.sign(pkey, 'sha256')
92+
93+
cert_resource = None
94+
try:
95+
cert_resource, _ = client.poll_and_request_issuance(jose.ComparableX509(csr), authzs)
96+
except acme_errors.PollError as error:
97+
# If we get a PollError, pick the first failed authz and turn it into a more
98+
# useful ValidationError that contains details we can look for in tests.
99+
for authz in error.updated:
100+
updated_authz = json.loads(urllib2.urlopen(authz.uri).read())
101+
domain = authz.body.identifier.value,
102+
for c in updated_authz['challenges']:
103+
if 'error' in c:
104+
err = c['error']
105+
raise ValidationError(domain, err['type'], err['detail'])
106+
# If none of the authz's had an error, just re-raise.
107+
raise
108+
if cert_output != None:
109+
pem = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM,
110+
cert_resource.body)
111+
with open(cert_output, 'w') as f:
112+
f.write(pem)
113+
return cert_resource
114+
115+
def http_01_answer(client, chall_body):
116+
"""Return an HTTP01Resource to server in response to the given challenge."""
117+
response, validation = chall_body.response_and_validation(client.key)
118+
return standalone.HTTP01RequestHandler.HTTP01Resource(
119+
chall=chall_body.chall, response=response,
120+
validation=validation)
121+
122+
def auth_and_issue(domains, email=None, cert_output=None, client=None):
123+
"""Make authzs for each of the given domains, set up a server to answer the
124+
challenges in those authzs, tell the ACME server to validate the challenges,
125+
then poll for the authzs to be ready and issue a cert."""
126+
if client == None:
127+
client = make_client(email)
128+
authzs, challenges = make_authzs(client, domains)
129+
port = 5002
130+
answers = set([http_01_answer(client, c) for c in challenges])
131+
server = standalone.HTTP01Server(("", port), answers)
132+
thread = threading.Thread(target=server.serve_forever)
133+
thread.start()
134+
135+
# Loop until the HTTP01Server is ready.
136+
while True:
137+
try:
138+
urllib2.urlopen("http://localhost:%d" % port)
139+
break
140+
except urllib2.URLError:
141+
time.sleep(0.1)
142+
143+
try:
144+
for chall_body in challenges:
145+
client.answer_challenge(chall_body, chall_body.response(client.key))
146+
cert_resource = issue(client, authzs, cert_output)
147+
return cert_resource
148+
finally:
149+
server.shutdown()
150+
server.server_close()
151+
thread.join()
152+
153+
def expect_problem(problem_type, func):
154+
"""Run a function. If it raises a ValidationError or messages.Error that
155+
contains the given problem_type, return. If it raises no error or the wrong
156+
error, raise an exception."""
157+
ok = False
158+
try:
159+
func()
160+
except ValidationError as e:
161+
if e.problem_type == problem_type:
162+
ok = True
163+
else:
164+
raise
165+
except messages.Error as e:
166+
if problem_type in e.__str__():
167+
ok = True
168+
else:
169+
raise
170+
if not ok:
171+
raise Exception("Expected %s, got no error" % problem_type)
172+
173+
if __name__ == "__main__":
174+
try:
175+
auth_and_issue(sys.argv[1:])
176+
except messages.Error, e:
177+
print e
178+
sys.exit(1)

0 commit comments

Comments
 (0)