Skip to content

Commit c2fb699

Browse files
committed
Merge /httpd/httpd/trunk:r1920747,1920751
*) mod_md: update to version 2.4.28 - When the server starts, it looks for new, staged certificates to activate. If the staged set of files in 'md/staging/<domain>' is messed up, this could prevent further renewals to happen. Now, when the staging set is present, but could not be activated due to an error, purge the whole directory. [icing] - Fix certificate retrieval on ACME renewal to not require a 'Location:' header returned by the ACME CA. This was the way it was done in ACME before it became an IETF standard. Let's Encrypt still supports this, but other CAs do not. [icing] - Restore compatibility with OpenSSL < 1.1. [ylavic] git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/branches/2.4.x@1920753 13f79535-47bb-0310-9956-ffa450edef68
1 parent b7988b2 commit c2fb699

File tree

15 files changed

+264
-150
lines changed

15 files changed

+264
-150
lines changed

changes-entries/md_v2.4.28.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
*) mod_md: update to version 2.4.28
2+
- When the server starts, it looks for new, staged certificates to
3+
activate. If the staged set of files in 'md/staging/<domain>' is messed
4+
up, this could prevent further renewals to happen. Now, when the staging
5+
set is present, but could not be activated due to an error, purge the
6+
whole directory. [icing]
7+
- Fix certificate retrieval on ACME renewal to not require a 'Location:'
8+
header returned by the ACME CA. This was the way it was done in ACME
9+
before it became an IETF standard. Let's Encrypt still supports this,
10+
but other CAs do not. [icing]
11+
- Restore compatibility with OpenSSL < 1.1. [ylavic]

modules/md/md_acme_drive.c

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -305,11 +305,11 @@ static apr_status_t csr_req(md_acme_t *acme, const md_http_response_t *res, void
305305

306306
(void)acme;
307307
location = apr_table_get(res->headers, "location");
308-
if (!location) {
309-
md_log_perror(MD_LOG_MARK, MD_LOG_ERR, APR_EINVAL, d->p,
310-
"cert created without giving its location header");
311-
return APR_EINVAL;
312-
}
308+
if (!location)
309+
return rv;
310+
311+
md_log_perror(MD_LOG_MARK, MD_LOG_DEBUG, 0, d->p,
312+
"cert created with location header (old ACMEv1 style)");
313313
ad->order->certificate = apr_pstrdup(d->p, location);
314314
if (APR_SUCCESS != (rv = md_acme_order_save(d->store, d->p, MD_SG_STAGING,
315315
d->md->name, ad->order, 0))) {

modules/md/md_reg.c

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,7 +1194,7 @@ static apr_status_t run_load_staging(void *baton, apr_pool_t *p, apr_pool_t *pte
11941194
result = va_arg(ap, md_result_t*);
11951195

11961196
if (APR_STATUS_IS_ENOENT(rv = md_load(reg->store, MD_SG_STAGING, md->name, NULL, ptemp))) {
1197-
md_log_perror(MD_LOG_MARK, MD_LOG_TRACE2, 0, ptemp, "%s: nothing staged", md->name);
1197+
md_log_perror(MD_LOG_MARK, MD_LOG_DEBUG, 0, ptemp, "%s: nothing staged", md->name);
11981198
goto out;
11991199
}
12001200

@@ -1259,7 +1259,9 @@ apr_status_t md_reg_load_stagings(md_reg_t *reg, apr_array_header_t *mds,
12591259
}
12601260
else if (!APR_STATUS_IS_ENOENT(rv)) {
12611261
md_log_perror(MD_LOG_MARK, MD_LOG_ERR, rv, p, APLOGNO(10069)
1262-
"%s: error loading staged set", md->name);
1262+
"%s: error loading staged set, purging it", md->name);
1263+
md_store_purge(reg->store, p, MD_SG_STAGING, md->name);
1264+
md_store_purge(reg->store, p, MD_SG_CHALLENGES, md->name);
12631265
}
12641266
}
12651267

modules/md/md_version.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,15 @@
2727
* @macro
2828
* Version number of the md module as c string
2929
*/
30-
#define MOD_MD_VERSION "2.4.26"
30+
#define MOD_MD_VERSION "2.4.28"
3131

3232
/**
3333
* @macro
3434
* Numerical representation of the version number of the md module
3535
* release. This is a 24 bit number with 8 bits for major number, 8 bits
3636
* for minor and 8 bits for patch. Version 1.2.3 becomes 0x010203.
3737
*/
38-
#define MOD_MD_VERSION_NUM 0x02041a
38+
#define MOD_MD_VERSION_NUM 0x02041c
3939

4040
#define MD_ACME_DEF_URL "https://acme-v02.api.letsencrypt.org/directory"
4141
#define MD_TAILSCALE_DEF_URL "file://localhost/var/run/tailscale/tailscaled.sock"

test/modules/md/conftest.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,7 @@ def env(pytestconfig) -> MDTestEnv:
3939
@pytest.fixture(autouse=True, scope="package")
4040
def _md_package_scope(env):
4141
env.httpd_error_log.add_ignored_lognos([
42-
"AH10085", # There are no SSL certificates configured and no other module contributed any
43-
"AH10045", # No VirtualHost matches Managed Domain
44-
"AH10105", # MDomain does not match any VirtualHost with 'SSLEngine on'
42+
"AH10085" # There are no SSL certificates configured and no other module contributed any
4543
])
4644

4745

@@ -59,7 +57,3 @@ def acme(env):
5957
if acme_server is not None:
6058
acme_server.stop()
6159

62-
@pytest.fixture(autouse=True, scope="package")
63-
def _stop_package_scope(env):
64-
yield
65-
assert env.apache_stop() == 0

test/modules/md/md_cert_util.py

Lines changed: 20 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import logging
22
import re
3-
import os
43
import socket
54
import OpenSSL
65
import time
@@ -12,6 +11,7 @@
1211
from http.client import HTTPConnection
1312
from urllib.parse import urlparse
1413

14+
from cryptography import x509
1515

1616
SEC_PER_DAY = 24 * 60 * 60
1717

@@ -23,45 +23,6 @@ class MDCertUtil(object):
2323
# Utility class for inspecting certificates in test cases
2424
# Uses PyOpenSSL: https://pyopenssl.org/en/stable/index.html
2525

26-
@classmethod
27-
def create_self_signed_cert(cls, path, name_list, valid_days, serial=1000):
28-
domain = name_list[0]
29-
if not os.path.exists(path):
30-
os.makedirs(path)
31-
32-
cert_file = os.path.join(path, 'pubcert.pem')
33-
pkey_file = os.path.join(path, 'privkey.pem')
34-
# create a key pair
35-
if os.path.exists(pkey_file):
36-
key_buffer = open(pkey_file, 'rt').read()
37-
k = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, key_buffer)
38-
else:
39-
k = OpenSSL.crypto.PKey()
40-
k.generate_key(OpenSSL.crypto.TYPE_RSA, 2048)
41-
42-
# create a self-signed cert
43-
cert = OpenSSL.crypto.X509()
44-
cert.get_subject().C = "DE"
45-
cert.get_subject().ST = "NRW"
46-
cert.get_subject().L = "Muenster"
47-
cert.get_subject().O = "greenbytes GmbH"
48-
cert.get_subject().CN = domain
49-
cert.set_serial_number(serial)
50-
cert.gmtime_adj_notBefore(valid_days["notBefore"] * SEC_PER_DAY)
51-
cert.gmtime_adj_notAfter(valid_days["notAfter"] * SEC_PER_DAY)
52-
cert.set_issuer(cert.get_subject())
53-
54-
cert.add_extensions([OpenSSL.crypto.X509Extension(
55-
b"subjectAltName", False, b", ".join(map(lambda n: b"DNS:" + n.encode(), name_list))
56-
)])
57-
cert.set_pubkey(k)
58-
cert.sign(k, 'sha1')
59-
60-
open(cert_file, "wt").write(
61-
OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert).decode('utf-8'))
62-
open(pkey_file, "wt").write(
63-
OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, k).decode('utf-8'))
64-
6526
@classmethod
6627
def load_server_cert(cls, host_ip, host_port, host_name, tls=None, ciphers=None):
6728
ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)
@@ -138,17 +99,26 @@ def get_serial(self):
13899
# add leading 0s to align with word boundaries.
139100
return ("%lx" % (self.cert.get_serial_number())).upper()
140101

141-
def same_serial_as(self, other):
142-
if isinstance(other, MDCertUtil):
143-
return self.cert.get_serial_number() == other.cert.get_serial_number()
144-
elif isinstance(other, OpenSSL.crypto.X509):
145-
return self.cert.get_serial_number() == other.get_serial_number()
146-
elif isinstance(other, str):
102+
@staticmethod
103+
def _get_serial(cert) -> int:
104+
if isinstance(cert, x509.Certificate):
105+
return cert.serial_number
106+
if isinstance(cert, MDCertUtil):
107+
return cert.get_serial_number()
108+
elif isinstance(cert, OpenSSL.crypto.X509):
109+
return cert.get_serial_number()
110+
elif isinstance(cert, str):
147111
# assume a hex number
148-
return self.cert.get_serial_number() == int(other, 16)
149-
elif isinstance(other, int):
150-
return self.cert.get_serial_number() == other
151-
return False
112+
return int(cert, 16)
113+
elif isinstance(cert, int):
114+
return cert
115+
return 0
116+
117+
def get_serial_number(self):
118+
return self._get_serial(self.cert)
119+
120+
def same_serial_as(self, other):
121+
return self._get_serial(self.cert) == self._get_serial(other)
152122

153123
def get_not_before(self):
154124
tsp = self.cert.get_notBefore()

test/modules/md/md_env.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
import time
1313

1414
from datetime import datetime, timedelta
15-
from typing import Dict, Optional
15+
from typing import Dict, Optional, Any
1616

17-
from pyhttpd.certs import CertificateSpec
17+
from pyhttpd.certs import CertificateSpec, Credentials, HttpdTestCA
1818
from .md_cert_util import MDCertUtil
1919
from pyhttpd.env import HttpdTestSetup, HttpdTestEnv
2020
from pyhttpd.result import ExecResult
@@ -73,10 +73,10 @@ def has_acme_server(cls):
7373

7474
@classmethod
7575
def has_acme_eab(cls):
76-
# Pebble v2.5.0 and v2.5.1 do not support HS256 for EAB, which
77-
# is the only thing mod_md supports.
78-
# Should work for pebble until v2.4.0 and v2.5.2+.
79-
# Reference: https://github.com/letsencrypt/pebble/issues/455
76+
# Pebble, in v2.5.0 no longer supported HS256 for EAB, which
77+
# is the only thing mod_md supports. Issue opened at pebble:
78+
# https://github.com/letsencrypt/pebble/issues/455
79+
# is fixed in v2.6.0
8080
return cls.get_acme_server() == 'pebble'
8181

8282
@classmethod
@@ -611,8 +611,13 @@ def await_ocsp_status(self, domain, timeout=10, ca_file=None):
611611
time.sleep(0.1)
612612
raise TimeoutError(f"ocsp respopnse not available: {domain}")
613613

614-
def create_self_signed_cert(self, name_list, valid_days, serial=1000, path=None):
615-
dirpath = path
616-
if not path:
617-
dirpath = os.path.join(self.store_domains(), name_list[0])
618-
return MDCertUtil.create_self_signed_cert(dirpath, name_list, valid_days, serial)
614+
def create_self_signed_cert(self, spec: CertificateSpec,
615+
valid_from: timedelta = timedelta(days=-1),
616+
valid_to: timedelta = timedelta(days=89),
617+
serial: Optional[int] = None) -> Credentials:
618+
key_type = spec.key_type if spec.key_type else 'rsa4096'
619+
return HttpdTestCA.create_credentials(spec=spec, issuer=None,
620+
key_type=key_type,
621+
valid_from=valid_from,
622+
valid_to=valid_to,
623+
serial=serial)

test/modules/md/test_502_acmev2_drive.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
import json
55
import os.path
66
import re
7-
import time
7+
from datetime import timedelta
88

99
import pytest
10+
from pyhttpd.certs import CertificateSpec
1011

11-
from .md_conf import MDConf, MDConf
12+
from .md_conf import MDConf
1213
from .md_cert_util import MDCertUtil
1314
from .md_env import MDTestEnv
1415

@@ -430,9 +431,12 @@ def test_md_502_201(self, env, renew_window, test_data_list):
430431
print("TRACE: start testing renew window: %s" % renew_window)
431432
for tc in test_data_list:
432433
print("TRACE: create self-signed cert: %s" % tc["valid"])
433-
env.create_self_signed_cert([name], tc["valid"])
434-
cert2 = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
435-
assert not cert2.same_serial_as(cert1)
434+
creds = env.create_self_signed_cert(CertificateSpec(domains=[name]),
435+
valid_from=timedelta(days=tc["valid"]["notBefore"]),
436+
valid_to=timedelta(days=tc["valid"]["notAfter"]))
437+
assert creds.certificate.serial_number != cert1.get_serial_number()
438+
# copy it over, assess status again
439+
creds.save_cert_pem(env.store_domain_file(name, 'pubcert.pem'))
436440
md = env.a2md(["list", name]).json['output'][0]
437441
assert md["renew"] == tc["renew"], \
438442
"Expected renew == {} indicator in {}, test case {}".format(tc["renew"], md, tc)

test/modules/md/test_702_auto.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import os
2-
import time
2+
from datetime import timedelta
33

44
import pytest
5+
from pyhttpd.certs import CertificateSpec
56

6-
from pyhttpd.conf import HttpdConf
77
from pyhttpd.env import HttpdTestEnv
88
from .md_cert_util import MDCertUtil
99
from .md_env import MDTestEnv
@@ -320,18 +320,22 @@ def test_md_702_009(self, env):
320320
assert cert1.same_serial_as(stat['rsa']['serial'])
321321
#
322322
# create self-signed cert, with critical remaining valid duration -> drive again
323-
env.create_self_signed_cert([domain], {"notBefore": -120, "notAfter": 2}, serial=7029)
324-
cert3 = MDCertUtil(env.store_domain_file(domain, 'pubcert.pem'))
325-
assert cert3.same_serial_as('1B75')
323+
creds = env.create_self_signed_cert(CertificateSpec(domains=[domain]),
324+
valid_from=timedelta(days=-120),
325+
valid_to=timedelta(days=2),
326+
serial=7029)
327+
creds.save_cert_pem(env.store_domain_file(domain, 'pubcert.pem'))
328+
creds.save_pkey_pem(env.store_domain_file(domain, 'privkey.pem'))
329+
assert creds.certificate.serial_number == 7029
326330
assert env.apache_restart() == 0
327331
stat = env.get_certificate_status(domain)
328-
assert cert3.same_serial_as(stat['rsa']['serial'])
332+
assert creds.certificate.serial_number == int(stat['rsa']['serial'], 16)
329333
#
330334
# cert should renew and be different afterwards
331335
assert env.await_completion([domain], must_renew=True)
332336
stat = env.get_certificate_status(domain)
333-
assert not cert3.same_serial_as(stat['rsa']['serial'])
334-
337+
creds.certificate.serial_number != int(stat['rsa']['serial'], 16)
338+
335339
# test case: drive with an unsupported challenge due to port availability
336340
def test_md_702_010(self, env):
337341
domain = self.test_domain
@@ -543,6 +547,40 @@ def test_md_702_032(self, env):
543547
assert name2 in cert1b.get_san_list()
544548
assert not cert1.same_serial_as(cert1b)
545549

550+
# test case: one MD on a vhost with ServerAlias. Renew.
551+
# Exchange ServerName and ServerAlias. Is the rename detected?
552+
# See: https://github.com/icing/mod_md/issues/338
553+
def test_md_702_033(self, env):
554+
domain = self.test_domain
555+
name_x = "test-x." + domain
556+
name_a = "test-a." + domain
557+
domains1 = [name_x, name_a]
558+
#
559+
# generate 1 MD and 2 vhosts
560+
conf = MDConf(env, admin="admin@" + domain)
561+
conf.add_md(domains=[name_x])
562+
conf.add_vhost(domains=domains1)
563+
conf.install()
564+
#
565+
# restart (-> drive), check that MD was synched and completes
566+
assert env.apache_restart() == 0
567+
env.check_md(domains1)
568+
assert env.await_completion([name_x])
569+
env.check_md_complete(name_x)
570+
cert_x = env.get_cert(name_x)
571+
#
572+
# reverse ServerName and ServerAlias
573+
domains2 = [name_a, name_x]
574+
conf = MDConf(env, admin="admin@" + domain)
575+
conf.add_md(domains=[name_a])
576+
conf.add_vhost(domains=domains2)
577+
conf.install()
578+
# restart, check that host still works and kept the cert
579+
assert env.apache_restart() == 0
580+
status = env.get_certificate_status(name_a)
581+
assert cert_x.same_serial_as(status['rsa']['serial'])
582+
583+
546584
# test case: test "tls-alpn-01" challenge handling
547585
def test_md_702_040(self, env):
548586
domain = self.test_domain

0 commit comments

Comments
 (0)