]> git.ipfire.org Git - thirdparty/apache/httpd.git/commitdiff
*) test: adding modules/md test suite
authorStefan Eissing <icing@apache.org>
Tue, 14 Dec 2021 11:33:27 +0000 (11:33 +0000)
committerStefan Eissing <icing@apache.org>
Tue, 14 Dec 2021 11:33:27 +0000 (11:33 +0000)
git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/branches/2.4.x@1895947 13f79535-47bb-0310-9956-ffa450edef68

53 files changed:
test/modules/md/__init__.py [new file with mode: 0644]
test/modules/md/conftest.py [new file with mode: 0755]
test/modules/md/data/store_migrate/1.0/sample1/accounts/ACME-localhost-0000/account.json [new file with mode: 0644]
test/modules/md/data/store_migrate/1.0/sample1/accounts/ACME-localhost-0000/account.pem [new file with mode: 0644]
test/modules/md/data/store_migrate/1.0/sample1/archive/7007-1502285564.org.1/md.json [new file with mode: 0644]
test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/cert.pem [new file with mode: 0644]
test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/chain.pem [new file with mode: 0644]
test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/md.json [new file with mode: 0644]
test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/pkey.pem [new file with mode: 0644]
test/modules/md/data/store_migrate/1.0/sample1/httpd.json [new file with mode: 0644]
test/modules/md/data/store_migrate/1.0/sample1/md_store.json [new file with mode: 0644]
test/modules/md/data/test_920/002.pubcert [new file with mode: 0644]
test/modules/md/data/test_conf_validate/test_014.conf [new file with mode: 0644]
test/modules/md/data/test_drive/test1.example.org.conf [new file with mode: 0644]
test/modules/md/data/test_roundtrip/temp.conf [new file with mode: 0644]
test/modules/md/dns01.py [new file with mode: 0755]
test/modules/md/http_challenge_foobar.py [new file with mode: 0755]
test/modules/md/md_acme.py [new file with mode: 0755]
test/modules/md/md_cert_util.py [new file with mode: 0755]
test/modules/md/md_certs.py [new file with mode: 0755]
test/modules/md/md_conf.py [new file with mode: 0755]
test/modules/md/md_env.py [new file with mode: 0755]
test/modules/md/message.py [new file with mode: 0755]
test/modules/md/msg_fail_on.py [new file with mode: 0755]
test/modules/md/notifail.py [new file with mode: 0755]
test/modules/md/notify.py [new file with mode: 0755]
test/modules/md/pebble/pebble-eab.json.template [new file with mode: 0644]
test/modules/md/pebble/pebble.json.template [new file with mode: 0644]
test/modules/md/test_001_store.py [new file with mode: 0644]
test/modules/md/test_010_store_migrate.py [new file with mode: 0644]
test/modules/md/test_100_reg_add.py [new file with mode: 0644]
test/modules/md/test_110_reg_update.py [new file with mode: 0644]
test/modules/md/test_120_reg_list.py [new file with mode: 0644]
test/modules/md/test_202_acmev2_regs.py [new file with mode: 0644]
test/modules/md/test_300_conf_validate.py [new file with mode: 0644]
test/modules/md/test_310_conf_store.py [new file with mode: 0644]
test/modules/md/test_502_acmev2_drive.py [new file with mode: 0644]
test/modules/md/test_602_roundtrip.py [new file with mode: 0644]
test/modules/md/test_702_auto.py [new file with mode: 0644]
test/modules/md/test_720_wildcard.py [new file with mode: 0644]
test/modules/md/test_730_static.py [new file with mode: 0644]
test/modules/md/test_740_acme_errors.py [new file with mode: 0644]
test/modules/md/test_741_setup_errors.py [new file with mode: 0644]
test/modules/md/test_750_eab.py [new file with mode: 0644]
test/modules/md/test_751_sectigo.py [new file with mode: 0644]
test/modules/md/test_752_zerossl.py [new file with mode: 0644]
test/modules/md/test_800_must_staple.py [new file with mode: 0644]
test/modules/md/test_801_stapling.py [new file with mode: 0644]
test/modules/md/test_810_ec.py [new file with mode: 0644]
test/modules/md/test_900_notify.py [new file with mode: 0644]
test/modules/md/test_901_message.py [new file with mode: 0644]
test/modules/md/test_910_cleanups.py [new file with mode: 0644]
test/modules/md/test_920_status.py [new file with mode: 0644]

diff --git a/test/modules/md/__init__.py b/test/modules/md/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/test/modules/md/conftest.py b/test/modules/md/conftest.py
new file mode 100755 (executable)
index 0000000..fc3206d
--- /dev/null
@@ -0,0 +1,89 @@
+import logging
+import os
+import re
+import sys
+import pytest
+
+sys.path.append(os.path.join(os.path.dirname(__file__), '../..'))
+
+from .md_conf import HttpdConf
+from .md_env import MDTestEnv
+from .md_acme import MDPebbleRunner, MDBoulderRunner
+
+
+def pytest_report_header(config, startdir):
+    env = MDTestEnv()
+    return "mod_md: [apache: {aversion}({prefix}), mod_{ssl}, ACME server: {acme}]".format(
+        prefix=env.prefix,
+        aversion=env.get_httpd_version(),
+        ssl=env.ssl_module,
+        acme=env.acme_server,
+    )
+
+
+@pytest.fixture(scope="package")
+def env(pytestconfig) -> MDTestEnv:
+    level = logging.INFO
+    console = logging.StreamHandler()
+    console.setLevel(level)
+    console.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
+    logging.getLogger('').addHandler(console)
+    logging.getLogger('').setLevel(level=level)
+    env = MDTestEnv(pytestconfig=pytestconfig)
+    env.setup_httpd()
+    env.apache_access_log_clear()
+    env.httpd_error_log.clear_log()
+    return env
+
+
+@pytest.fixture(autouse=True, scope="package")
+def _session_scope(env):
+    # we'd like to check the httpd error logs after the test suite has
+    # run to catch anything unusual. For this, we setup the ignore list
+    # of errors and warnings that we do expect.
+    env.httpd_error_log.set_ignored_lognos([
+        'AH10040',  # mod_md, setup complain
+        'AH10045',  # mod_md complains that there is no vhost for an MDomain
+        'AH10056',  # mod_md, invalid params
+        'AH10105',  # mod_md does not find a vhost with SSL enabled for an MDomain
+        'AH10085',  # mod_ssl complains about fallback certificates
+        'AH01909',  # mod_ssl, cert alt name complains
+        'AH10170',  # mod_md, wrong config, tested
+        'AH10171',  # mod_md, wrong config, tested
+    ])
+
+    env.httpd_error_log.add_ignored_patterns([
+        re.compile(r'.*urn:ietf:params:acme:error:.*'),
+        re.compile(r'.*None of the ACME challenge methods configured for this domain are suitable.*'),
+        re.compile(r'.*problem\[(challenge-mismatch|challenge-setup-failure|apache:eab-hmac-invalid)].*'),
+        re.compile(r'.*CA considers answer to challenge invalid.].*'),
+        re.compile(r'.*problem\[urn:org:apache:httpd:log:AH\d+:].*'),
+        re.compile(r'.*Unsuccessful in contacting ACME server at :*'),
+        re.compile(r'.*test-md-720-002-\S+.org: dns-01 setup command failed .*'),
+    ])
+    if env.lacks_ocsp():
+        env.httpd_error_log.add_ignored_patterns([
+            re.compile(r'.*certificate with serial \S+ has no OCSP responder URL.*'),
+        ])
+    yield
+    assert env.apache_stop() == 0
+    errors, warnings = env.httpd_error_log.get_missed()
+    assert (len(errors), len(warnings)) == (0, 0),\
+            f"apache logged {len(errors)} errors and {len(warnings)} warnings: \n"\
+            "{0}\n{1}\n".format("\n".join(errors), "\n".join(warnings))
+
+
+@pytest.fixture(scope="package")
+def acme(env):
+    acme_server = None
+    if env.acme_server == 'pebble':
+        acme_server = MDPebbleRunner(env, configs={
+            'default': os.path.join(env.gen_dir, 'pebble/pebble.json'),
+            'eab': os.path.join(env.gen_dir, 'pebble/pebble-eab.json'),
+        })
+    elif env.acme_server == 'boulder':
+        acme_server = MDBoulderRunner(env)
+    yield acme_server
+    if acme_server is not None:
+        acme_server.stop()
+
diff --git a/test/modules/md/data/store_migrate/1.0/sample1/accounts/ACME-localhost-0000/account.json b/test/modules/md/data/store_migrate/1.0/sample1/accounts/ACME-localhost-0000/account.json
new file mode 100644 (file)
index 0000000..37c23c3
--- /dev/null
@@ -0,0 +1,6 @@
+{
+  "disabled": false,
+  "url": "http://localhost:4000/acme/reg/494",
+  "ca-url": "http://localhost:4000/directory",
+  "id": "ACME-localhost-0000"
+}
\ No newline at end of file
diff --git a/test/modules/md/data/store_migrate/1.0/sample1/accounts/ACME-localhost-0000/account.pem b/test/modules/md/data/store_migrate/1.0/sample1/accounts/ACME-localhost-0000/account.pem
new file mode 100644 (file)
index 0000000..c4da46b
--- /dev/null
@@ -0,0 +1,54 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIIJnzBJBgkqhkiG9w0BBQ0wPDAbBgkqhkiG9w0BBQwwDgQI0s8pf5rIPTECAggA
+MB0GCWCGSAFlAwQBKgQQ2u9SobgmVMhhZxYkXf9kpwSCCVD04Xywr0m+b5f+2aE5
+qjGr8y6xlf4NC/+QL6mBCw+9tlsgt7Z9bBt7PR1eMUQ0Bz5a9veBT2JwqGFU8XLv
+Anfd4a8ciKRx4kdP7JL08rkKAqPxuwkzMin3TeOJwsoghyvt8zFrXrWEcHyhHd4L
+HAoA3ccCxDHH7ydORd7rhEQUOkcjbaJkZi6pzvv+C7kgSTMKYBaI1mlNzX5Oxm6I
+ziwmDcOtRgKb17z26zOYWjzbKHGopPlFe9/l32JxTr5UuCihR4NoPGiK08280OWQ
+HIwRxQ900AKyJZM1q3RkH4r0xtiik0lX0isx+UIiNEefA4Za/kXLCM7hHVCGwF1z
+eE8oX2yNcsX/sw7aVLhRyVDzrT8C5T7+s+K0eV/hfyYXXAZ0z0H+l3f3TRbMlLuq
+1FQnOmEtQy0CbfPGNlzbiK3glp2fc2ZHubTkprMoRTkEKWNiXD0Suhnsll9eV3d2
+cHZgsCQyD3LRz+Xj2v6P+fDOcu7IuM7om9GEjNQB1e7dzo6HOSTG2mIsQo6VByJw
+syoK1zzC70Jhj/G6aFALTh4dMceoBDyHZzOfiVwC3dGX1QEnNvGD7Za/woMNIx8S
+hiqjntDhlXPXCRX/Z/Zvg///6+Ip9FqkCVk74DRWjH9iUzdP7/E1GCyAH2BSdsdc
+PnK15p79Ff5TMV91IQmnVV37s57VqXIez2RtuLd530iUk4RtkJ1/PphybHd+JW/n
+avMj8gsuWB7RqaBsmbjLmSudSl0DNgy0IJKZs11UifrZmSkaUJH+JJ1W2hLHR980
+X75IujUmZasWYkVqq0nvdy8JConCaLd3TT8r8DcO73vZqjFnN+EEHENaEg7F7ig8
+xkp0wk4F3u1BEnkwd34aLonZ9DtSK3miDRqlWXqQGESMaQLYQvHUn9q4X57Tyz4T
+9ZVPeLJiuHwCGq6z2BJhgkAlGs7Eqra0pMpjVnRdylTQzx0Q2vLQbrZasyBpReeM
+zGdadxRR84PyhAGDGdLKR8VCVFhWX32ZBfqJQOjpyAT30Wu11ZDvEPASuTL4GdcD
+o5seucpUZdgzrivvjUhYLkRd0WOjgJyuvtWdillpSiweeGfDAnZvUZUFLd4EMmwH
+W+IUr7yIsjNuGZU3NW0pW/L9d9GuwgljP61WKhS6B7hRmx22YU3z2Y7islXiey3m
+kZ37mAqdK4EIQca2j9GmBQk7oUz+boYdm4vtk7tJI07LEDI79U95B8x1MpzjuIbj
+zlYmH1yw8UefsFrOfjJ4BpkDjVux+J2DmSqCFb5XBcjwWsYiY17niW6Qfrypd6vq
+bew1HgbBhdBNQoL1P8uS1fNNwoHmhJc6PNHFFxU3NP91yqB8Igj3khqk9+/VBcCt
+8xRc/1jR5mfAgvaCWyQgIZAsCgTLnvEXy91MG/DKR0ZdOLZJNas+1W9fjhcFvP6S
+nNmeMMrIAxaI85RVvnLqPEZhsb9AOlyaf6tKFJiCteyQlie6MOQTKSp4jjSOVW+w
+q/WtSZup9zXo8Ek+TnLhD0IJhpIbfR5is5iZaVY7lbcg4pc3Csh/SiMUJ4TJgiPS
+/End7LPoRIabRnw4PBtJRNCwf3ilsWUmi95HU3wLAmLpI1AtnbfQi+zva4UJdOTV
+HJxNN84ZGuey1gG7qZb3U6WpwzQDKvqTm5jK32nIS/LuNv1qpv0FdAmvulV9wBar
+M19CcD5kOlTvNZcf6B4Fkrr+x+Anji/kUV4slIvUbAaU9P4lMO0ORCTg1es7QvI7
+v0KRYYSULrO+G2CNYL7fN8Vf5tRpBZ3H1o6u3plw/P86MTQPOskppjK1VKsBBmL2
+isdeumWjLpFVr1vWxTm68f88f+iau3BRUkCDQXFEVTN7YuOhpexb6Js0T220HYTS
+9hmeVUnNlXii1BpnxLhBx/0O3heVOLc/C7b7vASg5PljieUQmpuyeJSUAJm1vKrI
+p2G/46MgBl+3/NkzLRGepzAH2IGAhhtXEk/zePdRptbVr29+vGDX6IzEWqZ5UYHG
+P5JYzaojrmLd0BNYwEbCrRBRHyM4jYFkRERs/kwCh5/Kle/eZpb+bjvIsAs0xcOC
+/uRF8RfHW1h8M8Bm9tR+rUX8CTxaIF3IY+N5qSPstNt8xGYLv7uvd+KoK0xVHAm+
+FAreqql7koa5D0ncLjTpQGnHiLBKsYmJWC4+TKC+a5m0eKmRgO/r5o+7mmoB9qCZ
+bI9GB9HoYeVW/QVWfmoH0W6rbQCmK/VcSB1dGwvz9rKU1DXHhXvGU2k1IAfPX11t
+RfwUmmLtrM9tjOWdBh74N4G8UvTk5FGygzJ+Eclm/ABeAChIFU7mLJFejOue/bKq
+CRAQul45+CskNyVyZWZvWTFT0UMN290b4E4sjUKoLbFZiA1Y/aU+ruG9iwPJ3yVS
+s09VqogNwKBLWYW5TclUzgf71AQTlnZpTudkqwr36ogIAXXaQpE1f6/HLQz3k1PA
+WmTaxoM//X00WvTq2UxxSmKf7mNPEg9UZ9m4ZTKe35a//ONxXVjBjtK23yN5MuHY
+YrgWF84xlLRPY3Um2ukCsRGb7yZRhlPmOBeYQvRod7BqEA0UmIR+ctnBWDwzSZw7
+JWuR+AZdjIfM+Ilh15fokpLI5IFnTAqvTYDoF0185kqYPkjtI2STAWpALA9XJp70
+aF/rbdbSrRPFI1+izTIvQjffYftro7EOfCFv62XZm6tj5RLHalfgTcWoUWw81ylL
+DOZZaKsv4bOW7HCM47pitFojwzNf9OaHd5VTaSPWts49siF/qCxcG8bwu51picbc
+96H1h3/npNhxDUA5qKzkBK9Bs7panzXt2kNJxPzHEiCjVVGq7t/ei4TZGoSw806D
+kNPFhztVoM1k2m7F7lu1EYOwJH/yXKJUgJYIycIoQyRMX7h0jb76U0oOHrdkw3A2
+9Helksl8kqz10td2PZyoj3K/EWu+33cFKgLtC9JrDATR3Lhdo2N3BQQAotW2+Tht
+HqHj/UzUoIWcEkzCZeJhRn9WRRbbLeWKwdXBxGl0ZESpJJ2+Ml6QkMkdZSUzDURD
+kxYl04U9JXk6vC2hT6780OBLnLivBqIaSUJ72DSkOFnifFoP/OeglWFVkJHWQjQP
+aGMcPD/xLLYhdRQlJND9K12FXtsazW2K/V+861y4rJOt6zJGSZwPrQBkLf7QBNAC
+DWiLOvp6tLT58pX8TSlplbITcQ==
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/test/modules/md/data/store_migrate/1.0/sample1/archive/7007-1502285564.org.1/md.json b/test/modules/md/data/store_migrate/1.0/sample1/archive/7007-1502285564.org.1/md.json
new file mode 100644 (file)
index 0000000..33c771b
--- /dev/null
@@ -0,0 +1,18 @@
+{
+  "name": "7007-1502285564.org",
+  "domains": [
+    "7007-1502285564.org"
+  ],
+  "contacts": [
+    "mailto:admin@7007-1502285564.org"
+  ],
+  "transitive": 0,
+  "ca": {
+    "proto": "ACME",
+    "url": "http://localhost:4000/directory",
+    "agreement": "http://boulder:4000/terms/v1"
+  },
+  "state": 1,
+  "renew-mode": 2,
+  "renew-window": 1209600
+}
diff --git a/test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/cert.pem b/test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/cert.pem
new file mode 100644 (file)
index 0000000..1a72637
--- /dev/null
@@ -0,0 +1,32 @@
+-----BEGIN CERTIFICATE-----
+MIIFkDCCBHigAwIBAgITAP8PGcftT0j60OOjL+Er/XuHrzANBgkqhkiG9w0BAQsF
+ADAfMR0wGwYDVQQDDBRoMnBweSBoMmNrZXIgZmFrZSBDQTAeFw0xNzA4MDkxMjMz
+MDBaFw0xNzExMDcxMjMzMDBaME0xHDAaBgNVBAMTEzcwMDctMTUwMjI4NTU2NC5v
+cmcxLTArBgNVBAUTJGZmMGYxOWM3ZWQ0ZjQ4ZmFkMGUzYTMyZmUxMmJmZDdiODdh
+ZjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMHuhVxT9Jpc6EpNAhrq
+RqzDJ4tWSG9BtguKZzh3sbY92EE5rqym7wpdb5DG5gwew4iD1R+YizY+99+00qlB
+3kNBUVsJCBnew0apmhPq4jjF8v8t3Qqq0ISn2Sdv5bt5mB9NWeO83h3zT1LW0rTm
+847nwxUuGxlIjLXxsibUvPunMfyGJUshflN5V9/Q3YQBOCnDWy5s4FKN2N34cHFE
+IgJo5ToBKZLp9eUaLm03mlfhTFc3/h0AtWwMZ5P2tRRB9EiijqI9nkrVzqyi1QTN
+Hn/XfgDgKRCyMp6i5kcK3hCXo4GjOIU0KA91ttf3IeKhXHKzC7ybc4hdJH2rWzoN
+srYq6tNZ+cOaa1E/H+v+OMSeIRaRrpM56c3nUssIzbneMIXuLHuOluaaL4baCjYp
+Pdc80bUlps06XcnVHysAbsfbtWAtUdzj2l4flVySruGoaqVDudl1GqYoYa+0oReM
+Zqd09Q+pCQvDNE+jiVq3An+JA4msux9EMMz7jkAwnl8iiWy0GMuQPsL5gp3TEXGY
+Cp1wQlzpmxZSdUZ+J6f4UkFOS/Zn6gS6nSxN8nj3XKbRYRbebPQMwRGYGttCyeZO
+dHiUY/3gQBUdpcMBJhAa5GFoabK0J5XPmK2E1P9cGQo7DbNn+Skojnz2WuUtCuyo
+m9la14Ruca9V8NmjBsu+4mXvAgMBAAGjggGVMIIBkTAOBgNVHQ8BAf8EBAMCBaAw
+HQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYD
+VR0OBBYEFH426IYgY0KXUe9cLMZ3d8tipsDkMB8GA1UdIwQYMBaAFPt4TxL5YBWD
+LJ8XfzQZsy426kGJMGYGCCsGAQUFBwEBBFowWDAiBggrBgEFBQcwAYYWaHR0cDov
+LzEyNy4wLjAuMTo0MDAyLzAyBggrBgEFBQcwAoYmaHR0cDovLzEyNy4wLjAuMTo0
+MDAwL2FjbWUvaXNzdWVyLWNlcnQwHgYDVR0RBBcwFYITNzAwNy0xNTAyMjg1NTY0
+Lm9yZzAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8vZXhhbXBsZS5jb20vY3JsMGEG
+A1UdIARaMFgwCAYGZ4EMAQIBMEwGAyoDBDBFMCIGCCsGAQUFBwIBFhZodHRwOi8v
+ZXhhbXBsZS5jb20vY3BzMB8GCCsGAQUFBwICMBMMEURvIFdoYXQgVGhvdSBXaWx0
+MA0GCSqGSIb3DQEBCwUAA4IBAQBfqLXSJZ5Izs2I44cXWrAto631aTylValp0Fiy
+Zz1dj00FS6XN5DGtfIyq7Ymd3MMiOZCLkTOMMb7BrJAvcgeJteKwdk3ffXEDyKH0
+1ttXK7l46trEyGOB+f9PMMKxVMyhDhGKyb6ro4Y5WTK/w4862soqKcP1SjHvk65u
+lIkFws1fWYYzqPLKLij2ILm+4NjdGIl8qPQWP2PtbOaDTFspJBz6hvLmqRgmjVVv
+cENwBUML4LCkVY3TUqoBHXDhpocTZlVeAVRVsroosboQJlY5nIKz6cOjilILn4cT
+hgEKa5IRwK5lUveCoeQtYUyLoyp5ncbota+UxNxCnkl/0veK
+-----END CERTIFICATE-----
diff --git a/test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/chain.pem b/test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/chain.pem
new file mode 100644 (file)
index 0000000..267866e
--- /dev/null
@@ -0,0 +1,27 @@
+-----BEGIN CERTIFICATE-----
+MIIEijCCA3KgAwIBAgICEk0wDQYJKoZIhvcNAQELBQAwKzEpMCcGA1UEAwwgY2Fj
+a2xpbmcgY3J5cHRvZ3JhcGhlciBmYWtlIFJPT1QwHhcNMTUxMDIxMjAxMTUyWhcN
+MjAxMDE5MjAxMTUyWjAfMR0wGwYDVQQDExRoYXBweSBoYWNrZXIgZmFrZSBDQTCC
+ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMIKR3maBcUSsncXYzQT13D5
+Nr+Z3mLxMMh3TUdt6sACmqbJ0btRlgXfMtNLM2OU1I6a3Ju+tIZSdn2v21JBwvxU
+zpZQ4zy2cimIiMQDZCQHJwzC9GZn8HaW091iz9H0Go3A7WDXwYNmsdLNRi00o14U
+joaVqaPsYrZWvRKaIRqaU0hHmS0AWwQSvN/93iMIXuyiwywmkwKbWnnxCQ/gsctK
+FUtcNrwEx9Wgj6KlhwDTyI1QWSBbxVYNyUgPFzKxrSmwMO0yNff7ho+QT9x5+Y/7
+XE59S4Mc4ZXxcXKew/gSlN9U5mvT+D2BhDtkCupdfsZNCQWp27A+b/DmrFI9NqsC
+AwEAAaOCAcIwggG+MBIGA1UdEwEB/wQIMAYBAf8CAQAwQwYDVR0eBDwwOqE4MAaC
+BC5taWwwCocIAAAAAAAAAAAwIocgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAwDgYDVR0PAQH/BAQDAgGGMH8GCCsGAQUFBwEBBHMwcTAyBggrBgEFBQcw
+AYYmaHR0cDovL2lzcmcudHJ1c3RpZC5vY3NwLmlkZW50cnVzdC5jb20wOwYIKwYB
+BQUHMAKGL2h0dHA6Ly9hcHBzLmlkZW50cnVzdC5jb20vcm9vdHMvZHN0cm9vdGNh
+eDMucDdjMB8GA1UdIwQYMBaAFOmkP+6epeby1dd5YDyTpi4kjpeqMFQGA1UdIARN
+MEswCAYGZ4EMAQIBMD8GCysGAQQBgt8TAQEBMDAwLgYIKwYBBQUHAgEWImh0dHA6
+Ly9jcHMucm9vdC14MS5sZXRzZW5jcnlwdC5vcmcwPAYDVR0fBDUwMzAxoC+gLYYr
+aHR0cDovL2NybC5pZGVudHJ1c3QuY29tL0RTVFJPT1RDQVgzQ1JMLmNybDAdBgNV
+HQ4EFgQU+3hPEvlgFYMsnxd/NBmzLjbqQYkwDQYJKoZIhvcNAQELBQADggEBAA0Y
+AeLXOklx4hhCikUUl+BdnFfn1g0W5AiQLVNIOL6PnqXu0wjnhNyhqdwnfhYMnoy4
+idRh4lB6pz8Gf9pnlLd/DnWSV3gS+/I/mAl1dCkKby6H2V790e6IHmIK2KYm3jm+
+U++FIdGpBdsQTSdmiX/rAyuxMDM0adMkNBwTfQmZQCz6nGHw1QcSPZMvZpsC8Skv
+ekzxsjF1otOrMUPNPQvtTWrVx8GlR2qfx/4xbQa1v2frNvFBCmO59goz+jnWvfTt
+j2NjwDZ7vlMBsPm16dbKYC840uvRoZjxqsdc3ChCZjqimFqlNG/xoPA8+dTicZzC
+XE9ijPIcvW6y1aa3bGw=
+-----END CERTIFICATE-----
diff --git a/test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/md.json b/test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/md.json
new file mode 100644 (file)
index 0000000..12e4e48
--- /dev/null
@@ -0,0 +1,23 @@
+{
+  "name": "7007-1502285564.org",
+  "domains": [
+    "7007-1502285564.org"
+  ],
+  "contacts": [
+    "mailto:admin@7007-1502285564.org"
+  ],
+  "transitive": 0,
+  "ca": {
+    "account": "ACME-localhost-0000",
+    "proto": "ACME",
+    "url": "http://localhost:4000/directory",
+    "agreement": "http://boulder:4000/terms/v1"
+  },
+  "cert": {
+    "url": "http://localhost:4000/acme/cert/ff0f19c7ed4f48fad0e3a32fe12bfd7b87af",
+    "expires": "Tue, 07 Nov 2017 12:33:00 GMT"
+  },
+  "state": 2,
+  "renew-mode": 2,
+  "renew-window": 1209600
+}
diff --git a/test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/pkey.pem b/test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/pkey.pem
new file mode 100644 (file)
index 0000000..0438ddd
--- /dev/null
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDB7oVcU/SaXOhK
+TQIa6kaswyeLVkhvQbYLimc4d7G2PdhBOa6spu8KXW+QxuYMHsOIg9UfmIs2Pvff
+tNKpQd5DQVFbCQgZ3sNGqZoT6uI4xfL/Ld0KqtCEp9knb+W7eZgfTVnjvN4d809S
+1tK05vOO58MVLhsZSIy18bIm1Lz7pzH8hiVLIX5TeVff0N2EATgpw1subOBSjdjd
++HBxRCICaOU6ASmS6fXlGi5tN5pX4UxXN/4dALVsDGeT9rUUQfRIoo6iPZ5K1c6s
+otUEzR5/134A4CkQsjKeouZHCt4Ql6OBoziFNCgPdbbX9yHioVxyswu8m3OIXSR9
+q1s6DbK2KurTWfnDmmtRPx/r/jjEniEWka6TOenN51LLCM253jCF7ix7jpbmmi+G
+2go2KT3XPNG1JabNOl3J1R8rAG7H27VgLVHc49peH5Vckq7hqGqlQ7nZdRqmKGGv
+tKEXjGandPUPqQkLwzRPo4latwJ/iQOJrLsfRDDM+45AMJ5fIolstBjLkD7C+YKd
+0xFxmAqdcEJc6ZsWUnVGfien+FJBTkv2Z+oEup0sTfJ491ym0WEW3mz0DMERmBrb
+QsnmTnR4lGP94EAVHaXDASYQGuRhaGmytCeVz5ithNT/XBkKOw2zZ/kpKI589lrl
+LQrsqJvZWteEbnGvVfDZowbLvuJl7wIDAQABAoICAQCVSZob0v1O/wpKeDGQqpwx
+TiHY31jvXHRZOffvviRtl/ora84NVoxZPEgv+Q0Kc3wuUN31bqZr4dlKupYYeX4x
+48xO+grkb1l/wfu8LWpsLeW7joDEP245UESYWUlOInJ6Vj9GUxPhlnWP3ZNicw83
+CS5h1ZZCxlibjy2HOukoCDMwo8t9pJDsjVKaFt0PSykC7UH54RJmOo+hgCh+6OYN
+WNZs6owobjY+YQMwTEdiMytjUNUrWmpOfNYXTyliKMt2RrzqI+kAzspElyzIf2Zl
+H2v+HJFAKw1QlTITqkf8Gd9iYlWWJOpZzFIuui25mmHiYfY9AKXVaW4313tomzbg
+L9Muc0pCmR8ge/hsC+C2QkVhHRFThakd5zU8rOEeXClzLKg1tjSVwcyNllXwd3Uy
+gQRtDqAWcWhXj2pqPzLc4v/wobjPE+xEpAbvDBvEof1fMy1PBeyKq7T4mIxswuWF
+takm9/Bt15K2TNBc7qNQV2x+MCS0Bi2Hd1yjLbIHllBDQR2ZsHRw1D38ckbL7ATE
+yDwnzI2gxlYYV7K/iQG9XkM54Ra5tNOFYv9GiCw+JPrLcQ5qmGsCCu6lfktMC8pN
+7VQRbHt60ZKaunE1muwWDmyYzP106qUXMw6nIVMyqX0ywTEPAgtRgWcucLWR33DD
+k1OBcq2tOceaZjA5Pbi4sQKCAQEA+MbI4HEbROlsPeQ7VMOoAHjJPWuhDNXqnz4Q
+c4z3X+W61TAWZINRENYDZd3c7D7wOWb9VBA+o62xrzYviul9qhTAjZ8dRfxagJpH
+OxNY348HNj+IxONj3RXr/7tfOXtzcjiFwzn85oPLRM56XfjYZ5lUgQBSEauXOue5
++bpNBvrYZLPm7i5BM8RpBElH2wtCizLAE9BrKYUqTYWyl76miPfpeSVMv2JOpUwp
+josVrAWAOoQHeIrCLmSF43oqmtzJ9Aq1r/VeOQB/3TT4E0RhWhDWOg3zNuA20w+E
+VuKyl4J/XLo6T86Zc/PM4+vb8zPztjZHQVJj58Iq7N4/y5cBfQKCAQEAx5AP10sw
+C4kCwU/yXORhimMPlRldKx2h+8Ha/0whTkehXaJ0synCV0ZLh7jSgfe81Zx5/3RK
+KKRWVx7+wmQiOqfSIBJN4xWdpVDS7yndk/FW8sYqT1v2sgr2t1u41bQAY3qzezsK
+elNsjbRsUCVvVu9HZ5zH7Pvmf0Ma8P2t8EioQWJ2ptgF6imTXIrQORJPBqDEzp6W
+EjiHC9kuZ2E+uPGl+6oQcxRUjtFkxnI9LgpOQCjNNIhW6cEhJxV3z8YIUnUyd7vd
+i0eEfhKF+DXzrqbtve63iGGU7TFMiiNF59hPxKHkPvHnUlXNZjJ8om9M579i/9fm
+OHYWaWFuzb6g2wKCAQAIZ37FxkxriY80kA9JD8sPKQVzY71vF5Lzij84CB0bSkGD
+jjpTbvRAI1q+CD68ZGvtJIOOYXYcRXPpPWVhxf2Oz2Cp6CQvBxVvnsalQkQQWV6f
+AIp4TE5FW8Y7P3M6F+eQhkROkhjvGKi3TFpp7kwxQ8bNDNu46RkUzltECn0rrTG+
+RS2aAkoFm68IjAk3Zyv6U96VTMcyAeOp9shPxAsQOX/TreTn2kRZ5TbKL/ytcQoh
+7+/orJdexdqYErp5vNe9vNbieOGT/2ZSbMWssPSw/DygfXQn+G8htjZ8UPBDmg7/
+bPMnWw1oE2ZqlL87ehfTogXKOSRS4gZdNizljdZpAoIBADxSfZdUcOdruNt6MQaH
+Ojy8iN9G1XTM9kPFa080UfT5jfthuejWPJpo8zfJVEhY/EmNjQr8udXjJv4armNQ
+JVCZndh37/cud4KbFceZXhL0JpYn9G4cnEthKQZvwUVHrb5kPpCHXjlvsiZ7XSo0
+xpz+oxTcvUoTMq9RN3mVFNjG/aUWAEuajN8lRhf5FcvKjvyv6A2UvkQvthKMyYwS
+RwVcdhHGbEZ85Lpu7QlXSsr57oFSVAUHGU57RGwt/xNdBvL13hV3QhZxvcjmDHzk
+wg4PA1ogKHYfGQdBmaM/2kekiSgkz3t/X67xpK65oBbxkcuTfHddaYezmj6sZvPm
+JXUCggEBAO37OxP7B66FQghuBkfui8sPymY2oSFQIb3IRO5A17/wp9yW1f9X4Bu4
+dh7ln+6IEURZyldAZcVRSHbjrL8VWXtS86eDttnKD7L46BbqAytckc/pebA/5bu0
+tjsM8ulayPGuJzEl/g1F1bU1eduXkmq/O7636S0Q1KCVHldn9qNgkowfjpzANHNs
+ksSwxMIY8n4U2kckMmfCj2B6UrnqQ6Bs7IaijQJ5u/mGYke+gKEGQ99esx2Ts1Vl
+w8WDaDUOwHEywuFyqtGJzizX8BazIzwmSCh8hpedDtFVVnfjszLnf3Y+FOrb9XlM
+Wc8hH7giOwSubI2D2mauspM5CZlez7A=
+-----END PRIVATE KEY-----
diff --git a/test/modules/md/data/store_migrate/1.0/sample1/httpd.json b/test/modules/md/data/store_migrate/1.0/sample1/httpd.json
new file mode 100644 (file)
index 0000000..a5bd7fb
--- /dev/null
@@ -0,0 +1,6 @@
+{
+  "proto": {
+    "http": true,
+    "https": true
+  }
+}
\ No newline at end of file
diff --git a/test/modules/md/data/store_migrate/1.0/sample1/md_store.json b/test/modules/md/data/store_migrate/1.0/sample1/md_store.json
new file mode 100644 (file)
index 0000000..157782b
--- /dev/null
@@ -0,0 +1,7 @@
+{
+  "version": "0.6.1-git",
+  "store": {
+    "version": 1.0
+  },
+  "key": "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXphYmNkZWZnaGlqa2xtbm9wcXJzdHV2"
+}
\ No newline at end of file
diff --git a/test/modules/md/data/test_920/002.pubcert b/test/modules/md/data/test_920/002.pubcert
new file mode 100644 (file)
index 0000000..02c9e87
--- /dev/null
@@ -0,0 +1,58 @@
+-----BEGIN CERTIFICATE-----
+MIIFYDCCBEigAwIBAgISAwOcRk1FTt55/NLK6Fn2aPJpMA0GCSqGSIb3DQEBCwUA
+MEoxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSMwIQYDVQQD
+ExpMZXQncyBFbmNyeXB0IEF1dGhvcml0eSBYMzAeFw0xOTA1MzExNjA2MzVaFw0x
+OTA4MjkxNjA2MzVaMBYxFDASBgNVBAMTC2Vpc3Npbmcub3JnMIIBIjANBgkqhkiG
+9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9d5xZdknImIPfmiUaiiRhHLx4bvazWRTgA2+
+etRNKr42MRjkuLbAhvxGjhw4El0GJlbngKTfiSK0Vq0idW/ehUr++czRSDrRVfqq
+qcI/F4NXLIbIZfmR7/vG0IP8Xc8D9VyQCX0uDapCvw+A/U46p0VOZz4bIB/bl0BW
+/mqBvVhBU9owskUcPjwwI/tK6My933CUVKXuFpPZ4V7zoY0/8Xa6JmWC2q1+7XmE
+h51hPnU35dYH1bA7WblX8rVxnEPCyCOgABVLKb6NhWfTCEqy+yzr32KsoSR1xqe4
+T2EeTcoamwF2yhz2zRC4glX0LM4inJ1/ZOQ+nKbFZTOPVWEnLQIDAQABo4ICcjCC
+Am4wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
+AjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTfO7pZGPLsa0NuPZMG4NGlr1TaWjAf
+BgNVHSMEGDAWgBSoSmpjBH3duubRObemRWXv86jsoTBvBggrBgEFBQcBAQRjMGEw
+LgYIKwYBBQUHMAGGImh0dHA6Ly9vY3NwLmludC14My5sZXRzZW5jcnlwdC5vcmcw
+LwYIKwYBBQUHMAKGI2h0dHA6Ly9jZXJ0LmludC14My5sZXRzZW5jcnlwdC5vcmcv
+MCcGA1UdEQQgMB6CC2Vpc3Npbmcub3Jngg93d3cuZWlzc2luZy5vcmcwTAYDVR0g
+BEUwQzAIBgZngQwBAgEwNwYLKwYBBAGC3xMBAQEwKDAmBggrBgEFBQcCARYaaHR0
+cDovL2Nwcy5sZXRzZW5jcnlwdC5vcmcwggEFBgorBgEEAdZ5AgQCBIH2BIHzAPEA
+dwB0ftqDMa0zEJEhnM4lT0Jwwr/9XkIgCMY3NXnmEHvMVgAAAWsO24QlAAAEAwBI
+MEYCIQD8yd2uHl2DNgvnBkSiA8vsK5pOv204NixI9F89LWERwgIhAPMLLiZkFG2h
+DTpEwF50BbZ+laYH8VP03Teq5csk2lX0AHYAKTxRllTIOWW6qlD8WAfUt2+/WHop
+ctykwwz05UVH9HgAAAFrDtuEFgAABAMARzBFAiEA3bYpKSNigSe0HuDyH/kerTW2
+55ugvODp6d+vNbNmgZoCIGTd4cio769BTKfLJTqNbjc9sKK9T7XkHUO4JgQdY6Nq
+MA0GCSqGSIb3DQEBCwUAA4IBAQBeatZxh8leVmeFE/IYTKKqHyZqTccJKdugXIOr
+uIF6sLup/8Fv/2N0wZc+edkj+NCyWhxxkZULyW6xhlL7rtzcwLYbQBSxKvT4Utur
+01a5bwhM62MdMjzkFgCCa5nRKPQ7bc684RrUFNi94d0KSb5ArFv8wovqPW7jbmFp
+X50dYKCE+wohFPHcsQapnV0lXK4+5qJZSZkp/pHANdndLCvFfzRHhV4nqRA12G2T
+VVWjdHN6ShL2uykJVAnSBhu/XD4mh79Yq9TQtS1DHfP3HcKstLqR0nrwBFaB6087
+jXfIpJ46yObq001qHeUMhT+B3WI2YPp/hY7u8A9+hCmDyyq8
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIEkjCCA3qgAwIBAgIQCgFBQgAAAVOFc2oLheynCDANBgkqhkiG9w0BAQsFADA/
+MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
+DkRTVCBSb290IENBIFgzMB4XDTE2MDMxNzE2NDA0NloXDTIxMDMxNzE2NDA0Nlow
+SjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUxldCdzIEVuY3J5cHQxIzAhBgNVBAMT
+GkxldCdzIEVuY3J5cHQgQXV0aG9yaXR5IFgzMIIBIjANBgkqhkiG9w0BAQEFAAOC
+AQ8AMIIBCgKCAQEAnNMM8FrlLke3cl03g7NoYzDq1zUmGSXhvb418XCSL7e4S0EF
+q6meNQhY7LEqxGiHC6PjdeTm86dicbp5gWAf15Gan/PQeGdxyGkOlZHP/uaZ6WA8
+SMx+yk13EiSdRxta67nsHjcAHJyse6cF6s5K671B5TaYucv9bTyWaN8jKkKQDIZ0
+Z8h/pZq4UmEUEz9l6YKHy9v6Dlb2honzhT+Xhq+w3Brvaw2VFn3EK6BlspkENnWA
+a6xK8xuQSXgvopZPKiAlKQTGdMDQMc2PMTiVFrqoM7hD8bEfwzB/onkxEz0tNvjj
+/PIzark5McWvxI0NHWQWM6r6hCm21AvA2H3DkwIDAQABo4IBfTCCAXkwEgYDVR0T
+AQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwfwYIKwYBBQUHAQEEczBxMDIG
+CCsGAQUFBzABhiZodHRwOi8vaXNyZy50cnVzdGlkLm9jc3AuaWRlbnRydXN0LmNv
+bTA7BggrBgEFBQcwAoYvaHR0cDovL2FwcHMuaWRlbnRydXN0LmNvbS9yb290cy9k
+c3Ryb290Y2F4My5wN2MwHwYDVR0jBBgwFoAUxKexpHsscfrb4UuQdf/EFWCFiRAw
+VAYDVR0gBE0wSzAIBgZngQwBAgEwPwYLKwYBBAGC3xMBAQEwMDAuBggrBgEFBQcC
+ARYiaHR0cDovL2Nwcy5yb290LXgxLmxldHNlbmNyeXB0Lm9yZzA8BgNVHR8ENTAz
+MDGgL6AthitodHRwOi8vY3JsLmlkZW50cnVzdC5jb20vRFNUUk9PVENBWDNDUkwu
+Y3JsMB0GA1UdDgQWBBSoSmpjBH3duubRObemRWXv86jsoTANBgkqhkiG9w0BAQsF
+AAOCAQEA3TPXEfNjWDjdGBX7CVW+dla5cEilaUcne8IkCJLxWh9KEik3JHRRHGJo
+uM2VcGfl96S8TihRzZvoroed6ti6WqEBmtzw3Wodatg+VyOeph4EYpr/1wXKtx8/
+wApIvJSwtmVi4MFU5aMqrSDE6ea73Mj2tcMyo5jMd6jmeWUHK8so/joWUoHOUgwu
+X4Po1QYz+3dszkDqMp4fklxBwXRsW10KXzPMTZ+sOPAveyxindmjkW8lGy+QsRlG
+PfZ+G6Z6h7mjem0Y+iWlkYcV4PIWL1iwBi8saCbGS5jN2p8M+X+Q7UNKEkROb3N6
+KOqkqm57TH2H3eDJAkSnh6/DNFu0Qg==
+-----END CERTIFICATE-----
diff --git a/test/modules/md/data/test_conf_validate/test_014.conf b/test/modules/md/data/test_conf_validate/test_014.conf
new file mode 100644 (file)
index 0000000..c1a8e06
--- /dev/null
@@ -0,0 +1,8 @@
+# global server name as managed domain name
+
+MDomain resistance.fritz.box www.example2.org
+
+<VirtualHost *:12346>
+    ServerName www.example2.org
+
+</VirtualHost>
diff --git a/test/modules/md/data/test_drive/test1.example.org.conf b/test/modules/md/data/test_drive/test1.example.org.conf
new file mode 100644 (file)
index 0000000..dd42072
--- /dev/null
@@ -0,0 +1,6 @@
+# A setup that required manual driving, e.g. invoking a2md outside apache
+#
+MDRenewMode manual
+
+MDomain test1.not-forbidden.org www.test1.not-forbidden.org mail.test1.not-forbidden.org
+
diff --git a/test/modules/md/data/test_roundtrip/temp.conf b/test/modules/md/data/test_roundtrip/temp.conf
new file mode 100644 (file)
index 0000000..eb7b75f
--- /dev/null
@@ -0,0 +1,27 @@
+  MDDriveMode manual
+  MDCertificateAuthority http://localhost:4000/directory
+  MDCertificateProtocol ACME
+  MDCertificateAgreement http://boulder:4000/terms/v1
+
+  ServerAdmin mailto:admin@test102-1499953506.org
+
+  ManagedDomain test102-1499953506.org test-a.test102-1499953506.org test-b.test102-1499953506.org
+
+<VirtualHost *:5001>
+    ServerName test-a.test102-1499953506.org
+    DocumentRoot htdocs/a
+
+    SSLEngine on
+    SSLCertificateFile /Users/sei/projects/mod_md/test/gen/apache/md/domains/test102-1499953506.org/cert.pem
+    SSLCertificateKeyFile /Users/sei/projects/mod_md/test/gen/apache/md/domains/test102-1499953506.org/pkey.pem
+</VirtualHost>
+
+<VirtualHost *:5001>
+    ServerName test-b.test102-1499953506.org
+    DocumentRoot htdocs/b
+
+    SSLEngine on
+    SSLCertificateFile /Users/sei/projects/mod_md/test/gen/apache/md/domains/test102-1499953506.org/cert.pem
+    SSLCertificateKeyFile /Users/sei/projects/mod_md/test/gen/apache/md/domains/test102-1499953506.org/pkey.pem
+</VirtualHost>
+
diff --git a/test/modules/md/dns01.py b/test/modules/md/dns01.py
new file mode 100755 (executable)
index 0000000..3afa467
--- /dev/null
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+
+import subprocess
+import sys
+
+curl = "curl"
+challtestsrv = "localhost:8055"
+
+
+def run(args):
+    sys.stderr.write(f"run: {' '.join(args)}\n")
+    p = subprocess.Popen(args, stdin=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    output, errput = p.communicate(None)
+    rv = p.wait()
+    if rv != 0:
+        sys.stderr.write(errput.decode())
+    sys.stdout.write(output.decode())
+    return rv
+
+
+def teardown(domain):
+    rv = run([curl, '-s', '-d', f'{{"host":"_acme-challenge.{domain}"}}',
+              f'{challtestsrv}/clear-txt'])
+    if rv == 0:
+        rv = run([curl, '-s', '-d', f'{{"host":"{domain}"}}',
+                  f'{challtestsrv}/set-txt'])
+    return rv
+
+
+def setup(domain, challenge):
+    teardown(domain)
+    rv = run([curl, '-s', '-d', f'{{"host":"{domain}", "addresses":["127.0.0.1"]}}',
+              f'{challtestsrv}/set-txt'])
+    if rv == 0:
+        rv = run([curl, '-s', '-d', f'{{"host":"_acme-challenge.{domain}.", "value":"{challenge}"}}',
+                  f'{challtestsrv}/set-txt'])
+    return rv
+
+
+def main(argv):
+    if len(argv) > 1:
+        if argv[1] == 'setup':
+            if len(argv) != 4:
+                sys.stderr.write("wrong number of arguments: dns01.py setup <domain> <challenge>\n")
+                sys.exit(2)
+            rv = setup(argv[2], argv[3])
+        elif argv[1] == 'teardown':
+            if len(argv) != 3:
+                sys.stderr.write("wrong number of arguments: dns01.py teardown <domain>\n")
+                sys.exit(1)
+            rv = teardown(argv[2])
+        else:
+            sys.stderr.write(f"unknown option {argv[1]}\n")
+            rv = 2
+    else:
+        sys.stderr.write("dns01.py wrong number of arguments\n")
+        rv = 2
+    sys.exit(rv)
+    
+
+if __name__ == "__main__":
+    main(sys.argv)
diff --git a/test/modules/md/http_challenge_foobar.py b/test/modules/md/http_challenge_foobar.py
new file mode 100755 (executable)
index 0000000..557f907
--- /dev/null
@@ -0,0 +1,27 @@
+#!/usr/bin/env python3
+import os
+import re
+import sys
+
+
+def main(argv):
+    if len(argv) < 4:
+        sys.stderr.write(f"{argv[0]} without too few arguments")
+        sys.exit(7)
+    store_dir = argv[1]
+    event = argv[2]
+    mdomain = argv[3]
+    m = re.match(r'(\S+):(\S+):(\S+)', event)
+    if m and 'challenge-setup' == m.group(1) and 'http-01' == m.group(2):
+        dns_name = m.group(3)
+        challenge_file = f"{store_dir}/challenges/{dns_name}/acme-http-01.txt"
+        if not os.path.isfile(challenge_file):
+            sys.stderr.write(f"{argv[0]} does not exist: {challenge_file}")
+            sys.exit(8)
+        with open(challenge_file, 'w') as fd:
+            fd.write('this_is_an_invalidated_http-01_challenge')
+    sys.exit(0)
+
+
+if __name__ == "__main__":
+    main(sys.argv)
diff --git a/test/modules/md/md_acme.py b/test/modules/md/md_acme.py
new file mode 100755 (executable)
index 0000000..36be347
--- /dev/null
@@ -0,0 +1,125 @@
+import logging
+import os
+import shutil
+import subprocess
+import time
+from abc import ABCMeta, abstractmethod
+from datetime import datetime, timedelta
+from threading import Thread
+from typing import Dict
+
+from .md_env import MDTestEnv
+
+
+log = logging.getLogger(__name__)
+
+
+def monitor_proc(env: MDTestEnv, proc):
+    _env = env
+    proc.wait()
+
+
+class ACMEServer:
+    __metaclass__ = ABCMeta
+
+    @abstractmethod
+    def start(self):
+        raise NotImplementedError
+
+    @abstractmethod
+    def stop(self):
+        raise NotImplementedError
+
+    @abstractmethod
+    def install_ca_bundle(self, dest):
+        raise NotImplementedError
+
+
+class MDPebbleRunner(ACMEServer):
+
+    def __init__(self, env: MDTestEnv, configs: Dict[str, str]):
+        self.env = env
+        self.configs = configs
+        self._current = 'default'
+        self._pebble = None
+        self._challtestsrv = None
+        self._log = None
+
+    def start(self, config: str = None):
+        if config is not None and config != self._current:
+            # change, tear down and start again
+            assert config in self.configs
+            self.stop()
+            self._current = config
+        elif self._pebble is not None:
+            # already running
+            return
+        args = ['pebble', '-config', self.configs[self._current], '-dnsserver', ':8053']
+        env = {}
+        env.update(os.environ)
+        env['PEBBLE_VA_NOSLEEP'] = '1'
+        self._log = open(f'{self.env.gen_dir}/pebble.log', 'w')
+        self._pebble = subprocess.Popen(args=args, env=env,
+                                        stdout=self._log, stderr=self._log)
+        t = Thread(target=monitor_proc, args=(self.env, self._pebble))
+        t.start()
+
+        args = ['pebble-challtestsrv', '-http01', '', '-https01', '', '-tlsalpn01', '']
+        self._challtestsrv = subprocess.Popen(args, stdout=self._log, stderr=self._log)
+        t = Thread(target=monitor_proc, args=(self.env, self._challtestsrv))
+        t.start()
+        self.install_ca_bundle(self.env.acme_ca_pemfile)
+        # disable ipv6 default address, this gives trouble inside docker
+        end = datetime.now() + timedelta(seconds=5)
+        while True:
+            r = self.env.run(['curl', 'localhost:8055/'])
+            if r.exit_code == 0:
+                break
+            if datetime.now() > end:
+                raise TimeoutError(f'unable to contact pebble-challtestsrv on localhost:8055')
+            time.sleep(.1)
+        r = self.env.run(['curl', '-d', f'{{"ip":""}}',
+                          'localhost:8055/set-default-ipv6'])
+        assert r.exit_code == 0, f"{r}"
+
+    def stop(self):
+        if self._pebble:
+            self._pebble.terminate()
+            self._pebble = None
+        if self._challtestsrv:
+            self._challtestsrv.terminate()
+            self._challtestsrv = None
+        if self._log:
+            self._log.close()
+            self._log = None
+
+    def install_ca_bundle(self, dest):
+        shutil.copyfile(self.env.ca.cert_file, dest)
+        end = datetime.now() + timedelta(seconds=20)
+        while datetime.now() < end:
+            r = self.env.curl_get('https://localhost:15000/roots/0', insecure=True)
+            if r.exit_code == 0:
+                with open(dest, 'a') as fd:
+                    fd.write(r.stdout)
+                break
+
+
+class MDBoulderRunner(ACMEServer):
+
+    def __init__(self, env: MDTestEnv):
+        self.env = env
+        self.install_ca_bundle(self.env.acme_ca_pemfile)
+
+    def start(self, config=None):
+        pass
+
+    def stop(self):
+        pass
+
+    def install_ca_bundle(self, dest):
+        r = self.env.run([
+            'docker', 'exec', 'boulder_boulder_1', 'bash', '-c', "cat /tmp/root*.pem"
+        ])
+        assert r.exit_code == 0
+        with open(dest, 'w') as fd:
+            fd.write(r.stdout)
diff --git a/test/modules/md/md_cert_util.py b/test/modules/md/md_cert_util.py
new file mode 100755 (executable)
index 0000000..8cd99aa
--- /dev/null
@@ -0,0 +1,239 @@
+import logging
+import re
+import os
+import socket
+import OpenSSL
+import time
+import sys
+
+from datetime import datetime
+from datetime import tzinfo
+from datetime import timedelta
+from http.client import HTTPConnection
+from urllib.parse import urlparse
+
+
+SEC_PER_DAY = 24 * 60 * 60
+
+
+log = logging.getLogger(__name__)
+
+
+class MDCertUtil(object):
+    # Utility class for inspecting certificates in test cases
+    # Uses PyOpenSSL: https://pyopenssl.org/en/stable/index.html
+
+    @classmethod
+    def create_self_signed_cert(cls, path, name_list, valid_days, serial=1000):
+        domain = name_list[0]
+        if not os.path.exists(path):
+            os.makedirs(path)
+
+        cert_file = os.path.join(path, 'pubcert.pem')
+        pkey_file = os.path.join(path, 'privkey.pem')
+        # create a key pair
+        if os.path.exists(pkey_file):
+            key_buffer = open(pkey_file, 'rt').read()
+            k = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, key_buffer)
+        else:
+            k = OpenSSL.crypto.PKey()
+            k.generate_key(OpenSSL.crypto.TYPE_RSA, 2048)
+
+        # create a self-signed cert
+        cert = OpenSSL.crypto.X509()
+        cert.get_subject().C = "DE"
+        cert.get_subject().ST = "NRW"
+        cert.get_subject().L = "Muenster"
+        cert.get_subject().O = "greenbytes GmbH"
+        cert.get_subject().CN = domain
+        cert.set_serial_number(serial)
+        cert.gmtime_adj_notBefore(valid_days["notBefore"] * SEC_PER_DAY)
+        cert.gmtime_adj_notAfter(valid_days["notAfter"] * SEC_PER_DAY)
+        cert.set_issuer(cert.get_subject())
+
+        cert.add_extensions([OpenSSL.crypto.X509Extension(
+            b"subjectAltName", False, b", ".join(map(lambda n: b"DNS:" + n.encode(), name_list))
+        )])
+        cert.set_pubkey(k)
+        cert.sign(k, 'sha1')
+
+        open(cert_file, "wt").write(
+            OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert).decode('utf-8'))
+        open(pkey_file, "wt").write(
+            OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, k).decode('utf-8'))
+
+    @classmethod
+    def load_server_cert(cls, host_ip, host_port, host_name, tls=None, ciphers=None):
+        ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)
+        if tls is not None and tls != 1.0:
+            ctx.set_options(OpenSSL.SSL.OP_NO_TLSv1)
+        if tls is not None and tls != 1.1:
+            ctx.set_options(OpenSSL.SSL.OP_NO_TLSv1_1)
+        if tls is not None and tls != 1.2:
+            ctx.set_options(OpenSSL.SSL.OP_NO_TLSv1_2)
+        if tls is not None and tls != 1.3 and hasattr(OpenSSL.SSL, "OP_NO_TLSv1_3"):
+            ctx.set_options(OpenSSL.SSL.OP_NO_TLSv1_3)
+        if ciphers is not None:
+            ctx.set_cipher_list(ciphers)
+        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        connection = OpenSSL.SSL.Connection(ctx, s)
+        connection.connect((host_ip, int(host_port)))
+        connection.setblocking(1)
+        connection.set_tlsext_host_name(host_name.encode('utf-8'))
+        connection.do_handshake()
+        peer_cert = connection.get_peer_certificate()
+        return MDCertUtil(None, cert=peer_cert)
+
+    @classmethod
+    def parse_pem_cert(cls, text):
+        cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, text.encode('utf-8'))
+        return MDCertUtil(None, cert=cert)
+
+    @classmethod
+    def get_plain(cls, url, timeout):
+        server = urlparse(url)
+        try_until = time.time() + timeout
+        while time.time() < try_until:
+            # noinspection PyBroadException
+            try:
+                c = HTTPConnection(server.hostname, server.port, timeout=timeout)
+                c.request('GET', server.path)
+                resp = c.getresponse()
+                data = resp.read()
+                c.close()
+                return data
+            except IOError:
+                log.debug("connect error:", sys.exc_info()[0])
+                time.sleep(.1)
+            except:
+                log.error("Unexpected error:", sys.exc_info()[0])
+        log.error("Unable to contact server after %d sec" % timeout)
+        return None
+
+    def __init__(self, cert_path, cert=None):
+        if cert_path is not None:
+            self.cert_path = cert_path
+            # load certificate and private key
+            if cert_path.startswith("http"):
+                cert_data = self.get_plain(cert_path, 1)
+            else:
+                cert_data = MDCertUtil._load_binary_file(cert_path)
+
+            for file_type in (OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1):
+                try:
+                    self.cert = OpenSSL.crypto.load_certificate(file_type, cert_data)
+                except Exception as error:
+                    self.error = error
+        if cert is not None:
+            self.cert = cert
+
+        if self.cert is None:
+            raise self.error
+
+    def get_issuer(self):
+        return self.cert.get_issuer()
+
+    def get_serial(self):
+        # the string representation of a serial number is not unique. Some
+        # add leading 0s to align with word boundaries.
+        return ("%lx" % (self.cert.get_serial_number())).upper()
+
+    def same_serial_as(self, other):
+        if isinstance(other, MDCertUtil):
+            return self.cert.get_serial_number() == other.cert.get_serial_number()
+        elif isinstance(other, OpenSSL.crypto.X509):
+            return self.cert.get_serial_number() == other.get_serial_number()
+        elif isinstance(other, str):
+            # assume a hex number
+            return self.cert.get_serial_number() == int(other, 16)
+        elif isinstance(other, int):
+            return self.cert.get_serial_number() == other
+        return False
+
+    def get_not_before(self):
+        tsp = self.cert.get_notBefore()
+        return self._parse_tsp(tsp)
+
+    def get_not_after(self):
+        tsp = self.cert.get_notAfter()
+        return self._parse_tsp(tsp)
+
+    def get_cn(self):
+        return self.cert.get_subject().CN
+
+    def get_key_length(self):
+        return self.cert.get_pubkey().bits()
+
+    def get_san_list(self):
+        text = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_TEXT, self.cert).decode("utf-8")
+        m = re.search(r"X509v3 Subject Alternative Name:\s*(.*)", text)
+        sans_list = []
+        if m:
+            sans_list = m.group(1).split(",")
+
+        def _strip_prefix(s):
+            return s.split(":")[1] if s.strip().startswith("DNS:") else s.strip()
+        return list(map(_strip_prefix, sans_list))
+
+    def get_must_staple(self):
+        text = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_TEXT, self.cert).decode("utf-8")
+        m = re.search(r"1.3.6.1.5.5.7.1.24:\s*\n\s*0....", text)
+        if not m:
+            # Newer openssl versions print this differently
+            m = re.search(r"TLS Feature:\s*\n\s*status_request\s*\n", text)
+        return m is not None
+
+    @classmethod
+    def validate_privkey(cls, privkey_path, passphrase=None):
+        privkey_data = cls._load_binary_file(privkey_path)
+        if passphrase:
+            privkey = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, privkey_data, passphrase)
+        else:
+            privkey = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, privkey_data)
+        return privkey.check()
+
+    def validate_cert_matches_priv_key(self, privkey_path):
+        # Verifies that the private key and cert match.
+        privkey_data = MDCertUtil._load_binary_file(privkey_path)
+        privkey = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, privkey_data)
+        context = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)
+        context.use_privatekey(privkey)
+        context.use_certificate(self.cert)
+        context.check_privatekey()
+
+    # --------- _utils_ ---------
+
+    def astr(self, s):
+        return s.decode('utf-8')
+        
+    def _parse_tsp(self, tsp):
+        # timestampss returned by PyOpenSSL are bytes
+        # parse date and time part
+        s = ("%s-%s-%s %s:%s:%s" % (self.astr(tsp[0:4]), self.astr(tsp[4:6]), self.astr(tsp[6:8]),
+                                    self.astr(tsp[8:10]), self.astr(tsp[10:12]), self.astr(tsp[12:14])))
+        timestamp = datetime.strptime(s, '%Y-%m-%d %H:%M:%S')
+        # adjust timezone
+        tz_h, tz_m = 0, 0
+        m = re.match(r"([+\-]\d{2})(\d{2})", self.astr(tsp[14:]))
+        if m:
+            tz_h, tz_m = int(m.group(1)),  int(m.group(2)) if tz_h > 0 else -1 * int(m.group(2))
+        return timestamp.replace(tzinfo=self.FixedOffset(60 * tz_h + tz_m))
+
+    @classmethod
+    def _load_binary_file(cls, path):
+        with open(path, mode="rb") as file:
+            return file.read()
+
+    class FixedOffset(tzinfo):
+
+        def __init__(self, offset):
+            self.__offset = timedelta(minutes=offset)
+
+        def utcoffset(self, dt):
+            return self.__offset
+
+        def tzname(self, dt):
+            return None
+
+        def dst(self, dt):
+            return timedelta(0)
diff --git a/test/modules/md/md_certs.py b/test/modules/md/md_certs.py
new file mode 100755 (executable)
index 0000000..2501d25
--- /dev/null
@@ -0,0 +1,444 @@
+import os
+import re
+from datetime import timedelta, datetime
+from typing import List, Any, Optional
+
+from cryptography import x509
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.asymmetric import ec, rsa
+from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
+from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
+from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption, load_pem_private_key
+from cryptography.x509 import ExtendedKeyUsageOID, NameOID
+
+
+EC_SUPPORTED = {}
+EC_SUPPORTED.update([(curve.name.upper(), curve) for curve in [
+    ec.SECP192R1,
+    ec.SECP224R1,
+    ec.SECP256R1,
+    ec.SECP384R1,
+]])
+
+
+def _private_key(key_type):
+    if isinstance(key_type, str):
+        key_type = key_type.upper()
+        m = re.match(r'^(RSA)?(\d+)$', key_type)
+        if m:
+            key_type = int(m.group(2))
+
+    if isinstance(key_type, int):
+        return rsa.generate_private_key(
+            public_exponent=65537,
+            key_size=key_type,
+            backend=default_backend()
+        )
+    if not isinstance(key_type, ec.EllipticCurve) and key_type in EC_SUPPORTED:
+        key_type = EC_SUPPORTED[key_type]
+    return ec.generate_private_key(
+        curve=key_type,
+        backend=default_backend()
+    )
+
+
+class CertificateSpec:
+
+    def __init__(self, name: str = None, domains: List[str] = None,
+                 email: str = None,
+                 key_type: str = None, single_file: bool = False,
+                 valid_from: timedelta = timedelta(days=-1),
+                 valid_to: timedelta = timedelta(days=89),
+                 client: bool = False,
+                 sub_specs: List['CertificateSpec'] = None):
+        self._name = name
+        self.domains = domains
+        self.client = client
+        self.email = email
+        self.key_type = key_type
+        self.single_file = single_file
+        self.valid_from = valid_from
+        self.valid_to = valid_to
+        self.sub_specs = sub_specs
+
+    @property
+    def name(self) -> Optional[str]:
+        if self._name:
+            return self._name
+        elif self.domains:
+            return self.domains[0]
+        return None
+
+
+class Credentials:
+
+    def __init__(self, name: str, cert: Any, pkey: Any):
+        self._name = name
+        self._cert = cert
+        self._pkey = pkey
+        self._cert_file = None
+        self._pkey_file = None
+        self._store = None
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @property
+    def subject(self) -> x509.Name:
+        return self._cert.subject
+
+    @property
+    def key_type(self):
+        if isinstance(self._pkey, RSAPrivateKey):
+            return f"rsa{self._pkey.key_size}"
+        elif isinstance(self._pkey, EllipticCurvePrivateKey):
+            return f"{self._pkey.curve.name}"
+        else:
+            raise Exception(f"unknown key type: {self._pkey}")
+
+    @property
+    def private_key(self) -> Any:
+        return self._pkey
+
+    @property
+    def certificate(self) -> Any:
+        return self._cert
+
+    @property
+    def cert_pem(self) -> bytes:
+        return self._cert.public_bytes(Encoding.PEM)
+
+    @property
+    def pkey_pem(self) -> bytes:
+        return self._pkey.private_bytes(
+            Encoding.PEM,
+            PrivateFormat.TraditionalOpenSSL if self.key_type.startswith('rsa') else PrivateFormat.PKCS8,
+            NoEncryption())
+
+    def set_store(self, store: 'CertStore'):
+        self._store = store
+
+    def set_files(self, cert_file: str, pkey_file: str = None):
+        self._cert_file = cert_file
+        self._pkey_file = pkey_file
+
+    @property
+    def cert_file(self) -> str:
+        return self._cert_file
+
+    @property
+    def pkey_file(self) -> Optional[str]:
+        return self._pkey_file
+
+    def get_first(self, name) -> Optional['Credentials']:
+        creds = self._store.get_credentials_for_name(name) if self._store else []
+        return creds[0] if len(creds) else None
+
+    def get_credentials_for_name(self, name) -> List['Credentials']:
+        return self._store.get_credentials_for_name(name) if self._store else []
+
+    def issue_certs(self, specs: List[CertificateSpec],
+                    chain: List['Credentials'] = None) -> List['Credentials']:
+        return [self.issue_cert(spec=spec, chain=chain) for spec in specs]
+
+    def issue_cert(self, spec: CertificateSpec, chain: List['Credentials'] = None) -> 'Credentials':
+        key_type = spec.key_type if spec.key_type else self.key_type
+        creds = self._store.load_credentials(name=spec.name, key_type=key_type, single_file=spec.single_file) \
+            if self._store else None
+        if creds is None:
+            creds = MDTestCA.create_credentials(spec=spec, issuer=self, key_type=key_type,
+                                                valid_from=spec.valid_from, valid_to=spec.valid_to)
+            if self._store:
+                self._store.save(creds, single_file=spec.single_file)
+
+        if spec.sub_specs:
+            if self._store:
+                sub_store = CertStore(fpath=os.path.join(self._store.path, creds.name))
+                creds.set_store(sub_store)
+            subchain = chain.copy() if chain else []
+            subchain.append(self)
+            creds.issue_certs(spec.sub_specs, chain=subchain)
+        return creds
+
+
+class CertStore:
+
+    def __init__(self, fpath: str):
+        self._store_dir = fpath
+        if not os.path.exists(self._store_dir):
+            os.makedirs(self._store_dir)
+        self._creds_by_name = {}
+
+    @property
+    def path(self) -> str:
+        return self._store_dir
+
+    def save(self, creds: Credentials, name: str = None,
+             chain: List[Credentials] = None,
+             single_file: bool = False) -> None:
+        name = name if name is not None else creds.name
+        cert_file = self.get_cert_file(name=name, key_type=creds.key_type)
+        pkey_file = self.get_pkey_file(name=name, key_type=creds.key_type)
+        if single_file:
+            pkey_file = None
+        with open(cert_file, "wb") as fd:
+            fd.write(creds.cert_pem)
+            if chain:
+                for c in chain:
+                    fd.write(c.cert_pem)
+            if pkey_file is None:
+                fd.write(creds.pkey_pem)
+        if pkey_file is not None:
+            with open(pkey_file, "wb") as fd:
+                fd.write(creds.pkey_pem)
+        creds.set_files(cert_file, pkey_file)
+        self._add_credentials(name, creds)
+
+    def _add_credentials(self, name: str, creds: Credentials):
+        if name not in self._creds_by_name:
+            self._creds_by_name[name] = []
+        self._creds_by_name[name].append(creds)
+
+    def get_credentials_for_name(self, name) -> List[Credentials]:
+        return self._creds_by_name[name] if name in self._creds_by_name else []
+
+    def get_cert_file(self, name: str, key_type=None) -> str:
+        key_infix = ".{0}".format(key_type) if key_type is not None else ""
+        return os.path.join(self._store_dir, f'{name}{key_infix}.cert.pem')
+
+    def get_pkey_file(self, name: str, key_type=None) -> str:
+        key_infix = ".{0}".format(key_type) if key_type is not None else ""
+        return os.path.join(self._store_dir, f'{name}{key_infix}.pkey.pem')
+
+    def load_pem_cert(self, fpath: str) -> x509.Certificate:
+        with open(fpath) as fd:
+            return x509.load_pem_x509_certificate("".join(fd.readlines()).encode())
+
+    def load_pem_pkey(self, fpath: str):
+        with open(fpath) as fd:
+            return load_pem_private_key("".join(fd.readlines()).encode(), password=None)
+
+    def load_credentials(self, name: str, key_type=None, single_file: bool = False):
+        cert_file = self.get_cert_file(name=name, key_type=key_type)
+        pkey_file = cert_file if single_file else self.get_pkey_file(name=name, key_type=key_type)
+        if os.path.isfile(cert_file) and os.path.isfile(pkey_file):
+            cert = self.load_pem_cert(cert_file)
+            pkey = self.load_pem_pkey(pkey_file)
+            creds = Credentials(name=name, cert=cert, pkey=pkey)
+            creds.set_store(self)
+            creds.set_files(cert_file, pkey_file)
+            self._add_credentials(name, creds)
+            return creds
+        return None
+
+
+class MDTestCA:
+
+    @classmethod
+    def create_root(cls, name: str, store_dir: str, key_type: str = "rsa2048") -> Credentials:
+        store = CertStore(fpath=store_dir)
+        creds = store.load_credentials(name="ca", key_type=key_type)
+        if creds is None:
+            creds = MDTestCA._make_ca_credentials(name=name, key_type=key_type)
+            store.save(creds, name="ca")
+            creds.set_store(store)
+        return creds
+
+    @staticmethod
+    def create_credentials(spec: CertificateSpec, issuer: Credentials, key_type: Any,
+                           valid_from: timedelta = timedelta(days=-1),
+                           valid_to: timedelta = timedelta(days=89),
+                           ) -> Credentials:
+        """Create a certificate signed by this CA for the given domains.
+        :returns: the certificate and private key PEM file paths
+        """
+        if spec.domains and len(spec.domains):
+            creds = MDTestCA._make_server_credentials(name=spec.name, domains=spec.domains,
+                                                      issuer=issuer, valid_from=valid_from,
+                                                      valid_to=valid_to, key_type=key_type)
+        elif spec.client:
+            creds = MDTestCA._make_client_credentials(name=spec.name, issuer=issuer,
+                                                      email=spec.email, valid_from=valid_from,
+                                                      valid_to=valid_to, key_type=key_type)
+        elif spec.name:
+            creds = MDTestCA._make_ca_credentials(name=spec.name, issuer=issuer,
+                                                  valid_from=valid_from, valid_to=valid_to,
+                                                  key_type=key_type)
+        else:
+            raise Exception(f"unrecognized certificate specification: {spec}")
+        return creds
+
+    @staticmethod
+    def _make_x509_name(org_name: str = None, common_name: str = None, parent: x509.Name = None) -> x509.Name:
+        name_pieces = []
+        if org_name:
+            oid = NameOID.ORGANIZATIONAL_UNIT_NAME if parent else NameOID.ORGANIZATION_NAME
+            name_pieces.append(x509.NameAttribute(oid, org_name))
+        elif common_name:
+            name_pieces.append(x509.NameAttribute(NameOID.COMMON_NAME, common_name))
+        if parent:
+            name_pieces.extend([rdn for rdn in parent])
+        return x509.Name(name_pieces)
+
+    @staticmethod
+    def _make_csr(
+            subject: x509.Name,
+            pkey: Any,
+            issuer_subject: Optional[Credentials],
+            valid_from_delta: timedelta = None,
+            valid_until_delta: timedelta = None
+    ):
+        pubkey = pkey.public_key()
+        issuer_subject = issuer_subject if issuer_subject is not None else subject
+
+        valid_from = datetime.now()
+        if valid_until_delta is not None:
+            valid_from += valid_from_delta
+        valid_until = datetime.now()
+        if valid_until_delta is not None:
+            valid_until += valid_until_delta
+
+        return (
+            x509.CertificateBuilder()
+            .subject_name(subject)
+            .issuer_name(issuer_subject)
+            .public_key(pubkey)
+            .not_valid_before(valid_from)
+            .not_valid_after(valid_until)
+            .serial_number(x509.random_serial_number())
+            .add_extension(
+                x509.SubjectKeyIdentifier.from_public_key(pubkey),
+                critical=False,
+            )
+        )
+
+    @staticmethod
+    def _add_ca_usages(csr: Any) -> Any:
+        return csr.add_extension(
+            x509.BasicConstraints(ca=True, path_length=9),
+            critical=True,
+        ).add_extension(
+            x509.KeyUsage(
+                digital_signature=True,
+                content_commitment=False,
+                key_encipherment=False,
+                data_encipherment=False,
+                key_agreement=False,
+                key_cert_sign=True,
+                crl_sign=True,
+                encipher_only=False,
+                decipher_only=False),
+            critical=True
+        ).add_extension(
+            x509.ExtendedKeyUsage([
+                ExtendedKeyUsageOID.CLIENT_AUTH,
+                ExtendedKeyUsageOID.SERVER_AUTH,
+                ExtendedKeyUsageOID.CODE_SIGNING,
+            ]),
+            critical=True
+        )
+
+    @staticmethod
+    def _add_leaf_usages(csr: Any, domains: List[str], issuer: Credentials) -> Any:
+        return csr.add_extension(
+            x509.BasicConstraints(ca=False, path_length=None),
+            critical=True,
+        ).add_extension(
+            x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
+                issuer.certificate.extensions.get_extension_for_class(
+                    x509.SubjectKeyIdentifier).value),
+            critical=False
+        ).add_extension(
+            x509.SubjectAlternativeName([x509.DNSName(domain) for domain in domains]),
+            critical=True,
+        ).add_extension(
+            x509.ExtendedKeyUsage([
+                ExtendedKeyUsageOID.SERVER_AUTH,
+            ]),
+            critical=True
+        )
+
+    @staticmethod
+    def _add_client_usages(csr: Any, issuer: Credentials, rfc82name: str = None) -> Any:
+        cert = csr.add_extension(
+            x509.BasicConstraints(ca=False, path_length=None),
+            critical=True,
+        ).add_extension(
+            x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
+                issuer.certificate.extensions.get_extension_for_class(
+                    x509.SubjectKeyIdentifier).value),
+            critical=False
+        )
+        if rfc82name:
+            cert.add_extension(
+                x509.SubjectAlternativeName([x509.RFC822Name(rfc82name)]),
+                critical=True,
+            )
+        cert.add_extension(
+            x509.ExtendedKeyUsage([
+                ExtendedKeyUsageOID.CLIENT_AUTH,
+            ]),
+            critical=True
+        )
+        return cert
+
+    @staticmethod
+    def _make_ca_credentials(name, key_type: Any,
+                             issuer: Credentials = None,
+                             valid_from: timedelta = timedelta(days=-1),
+                             valid_to: timedelta = timedelta(days=89),
+                             ) -> Credentials:
+        pkey = _private_key(key_type=key_type)
+        if issuer is not None:
+            issuer_subject = issuer.certificate.subject
+            issuer_key = issuer.private_key
+        else:
+            issuer_subject = None
+            issuer_key = pkey
+        subject = MDTestCA._make_x509_name(org_name=name, parent=issuer.subject if issuer else None)
+        csr = MDTestCA._make_csr(subject=subject,
+                                 issuer_subject=issuer_subject, pkey=pkey,
+                                 valid_from_delta=valid_from, valid_until_delta=valid_to)
+        csr = MDTestCA._add_ca_usages(csr)
+        cert = csr.sign(private_key=issuer_key,
+                        algorithm=hashes.SHA256(),
+                        backend=default_backend())
+        return Credentials(name=name, cert=cert, pkey=pkey)
+
+    @staticmethod
+    def _make_server_credentials(name: str, domains: List[str], issuer: Credentials,
+                                 key_type: Any,
+                                 valid_from: timedelta = timedelta(days=-1),
+                                 valid_to: timedelta = timedelta(days=89),
+                                 ) -> Credentials:
+        name = name
+        pkey = _private_key(key_type=key_type)
+        subject = MDTestCA._make_x509_name(common_name=name, parent=issuer.subject)
+        csr = MDTestCA._make_csr(subject=subject,
+                                 issuer_subject=issuer.certificate.subject, pkey=pkey,
+                                 valid_from_delta=valid_from, valid_until_delta=valid_to)
+        csr = MDTestCA._add_leaf_usages(csr, domains=domains, issuer=issuer)
+        cert = csr.sign(private_key=issuer.private_key,
+                        algorithm=hashes.SHA256(),
+                        backend=default_backend())
+        return Credentials(name=name, cert=cert, pkey=pkey)
+
+    @staticmethod
+    def _make_client_credentials(name: str,
+                                 issuer: Credentials, email: Optional[str],
+                                 key_type: Any,
+                                 valid_from: timedelta = timedelta(days=-1),
+                                 valid_to: timedelta = timedelta(days=89),
+                                 ) -> Credentials:
+        pkey = _private_key(key_type=key_type)
+        subject = MDTestCA._make_x509_name(common_name=name, parent=issuer.subject)
+        csr = MDTestCA._make_csr(subject=subject,
+                                 issuer_subject=issuer.certificate.subject, pkey=pkey,
+                                 valid_from_delta=valid_from, valid_until_delta=valid_to)
+        csr = MDTestCA._add_client_usages(csr, issuer=issuer, rfc82name=email)
+        cert = csr.sign(private_key=issuer.private_key,
+                        algorithm=hashes.SHA256(),
+                        backend=default_backend())
+        return Credentials(name=name, cert=cert, pkey=pkey)
diff --git a/test/modules/md/md_conf.py b/test/modules/md/md_conf.py
new file mode 100755 (executable)
index 0000000..0b4502a
--- /dev/null
@@ -0,0 +1,81 @@
+from .md_env import MDTestEnv
+from pyhttpd.conf import HttpdConf
+
+
+class MDConf(HttpdConf):
+
+    def __init__(self, env: MDTestEnv, text=None, std_ports=True,
+                 local_ca=True, std_vhosts=True, proxy=False,
+                 admin=None):
+        super().__init__(env=env)
+
+        if admin is None:
+            admin = f"admin@{env.http_tld}"
+        if len(admin.strip()):
+            self.add_admin(admin)
+
+        if local_ca:
+            self.add([
+                f"MDCertificateAuthority {env.acme_url}",
+                f"MDCertificateAgreement accepted",
+                f"MDCACertificateFile {env.server_dir}/acme-ca.pem",
+                "",
+                ])
+        if std_ports:
+            self.add(f"MDPortMap 80:{env.http_port} 443:{env.https_port}")
+            if env.ssl_module == "tls":
+                self.add(f"TLSListen {env.https_port}")
+        self.add([
+            "<Location /server-status>",
+            "    SetHandler server-status",
+            "</Location>",
+            "<Location /md-status>",
+            "    SetHandler md-status",
+            "</Location>",
+        ])
+        if std_vhosts:
+            self.add_vhost_test1()
+        if proxy:
+            self.add([
+                f"Listen {self.env.proxy_port}",
+                f"<VirtualHost *:{self.env.proxy_port}>",
+                "    ProxyRequests On",
+                "    ProxyVia On",
+                "    # be totally open",
+                "    AllowCONNECT 0-56535",
+                "    <Proxy *>",
+                "       # No require or other restrictions, this is just a test server",
+                "    </Proxy>",
+                "</VirtualHost>",
+            ])
+        if text is not None:
+            self.add(text)
+
+    def add_drive_mode(self, mode):
+        self.add("MDRenewMode \"%s\"\n" % mode)
+
+    def add_renew_window(self, window):
+        self.add("MDRenewWindow %s\n" % window)
+
+    def add_private_key(self, key_type, key_params):
+        self.add("MDPrivateKeys %s %s\n" % (key_type, " ".join(map(lambda p: str(p), key_params))))
+
+    def add_admin(self, email):
+        self.add(f"ServerAdmin mailto:{email}")
+
+    def add_md(self, domains):
+        dlist = " ".join(domains)    # without quotes
+        self.add(f"MDomain {dlist}\n")
+
+    def start_md(self, domains):
+        dlist = " ".join([f"\"{d}\"" for d in domains])  # with quotes, #257
+        self.add(f"<MDomain {dlist}>\n")
+        
+    def end_md(self):
+        self.add("</MDomain>\n")
+
+    def start_md2(self, domains):
+        self.add("<MDomainSet %s>\n" % " ".join(domains))
+
+    def end_md2(self):
+        self.add("</MDomainSet>\n")
diff --git a/test/modules/md/md_env.py b/test/modules/md/md_env.py
new file mode 100755 (executable)
index 0000000..718e5d1
--- /dev/null
@@ -0,0 +1,605 @@
+import copy
+import inspect
+import json
+import logging
+from configparser import ConfigParser, ExtendedInterpolation
+
+import pytest
+import re
+import os
+import shutil
+import subprocess
+import time
+
+from datetime import datetime, timedelta
+from typing import Dict, Optional
+
+from pyhttpd.certs import CertificateSpec
+from .md_cert_util import MDCertUtil
+from pyhttpd.env import HttpdTestSetup, HttpdTestEnv
+from pyhttpd.result import ExecResult
+
+log = logging.getLogger(__name__)
+
+
+class MDTestSetup(HttpdTestSetup):
+
+    def __init__(self, env: 'MDTestEnv'):
+        super().__init__(env=env)
+        self.mdenv = env
+        self.add_modules(["watchdog", "proxy_connect", "md"])
+
+    def make(self):
+        super().make()
+        if "pebble" == self.mdenv.acme_server:
+            self._make_pebble_conf()
+        self.mdenv.clear_store()
+
+    def _make_pebble_conf(self):
+        our_dir = os.path.dirname(inspect.getfile(MDTestSetup))
+        conf_src_dir = os.path.join(our_dir, 'pebble')
+        conf_dest_dir = os.path.join(self.env.gen_dir, 'pebble')
+        if not os.path.exists(conf_dest_dir):
+            os.makedirs(conf_dest_dir)
+        for name in os.listdir(conf_src_dir):
+            src_path = os.path.join(conf_src_dir, name)
+            m = re.match(r'(.+).template', name)
+            if m:
+                self._make_template(src_path, os.path.join(conf_dest_dir, m.group(1)))
+            elif os.path.isfile(src_path):
+                shutil.copy(src_path, os.path.join(conf_dest_dir, name))
+
+
+class MDTestEnv(HttpdTestEnv):
+
+    MD_S_UNKNOWN = 0
+    MD_S_INCOMPLETE = 1
+    MD_S_COMPLETE = 2
+    MD_S_EXPIRED = 3
+    MD_S_ERROR = 4
+
+    EMPTY_JOUT = {'status': 0, 'output': []}
+
+    DOMAIN_SUFFIX = "%d.org" % time.time()
+    LOG_FMT_TIGHT = '%(levelname)s: %(message)s'
+
+    @classmethod
+    def get_acme_server(cls):
+        return os.environ['ACME'] if 'ACME' in os.environ else "pebble"
+
+    @classmethod
+    def has_acme_server(cls):
+        return cls.get_acme_server() != 'none'
+
+    @classmethod
+    def has_acme_eab(cls):
+        return cls.get_acme_server() == 'pebble'
+
+    @classmethod
+    def is_pebble(cls) -> bool:
+        return cls.get_acme_server() == 'pebble'
+
+    @classmethod
+    def lacks_ocsp(cls):
+        return cls.is_pebble()
+
+    @classmethod
+    def has_a2md(cls):
+        d = os.path.dirname(inspect.getfile(HttpdTestEnv))
+        config = ConfigParser(interpolation=ExtendedInterpolation())
+        config.read(os.path.join(d, 'config.ini'))
+        bin_dir = config.get('global', 'bindir')
+        a2md_bin = os.path.join(bin_dir, 'a2md')
+        return os.path.isfile(a2md_bin)
+
+    def __init__(self, pytestconfig=None):
+        super().__init__(pytestconfig=pytestconfig)
+        self.add_httpd_log_modules(["md"])
+        self._acme_server = self.get_acme_server()
+        self._acme_tos = "accepted"
+        self._acme_ca_pemfile = os.path.join(self.gen_dir, "apache/acme-ca.pem")
+        if "pebble" == self._acme_server:
+            self._acme_url = "https://localhost:14000/dir"
+            self._acme_eab_url = "https://localhost:14001/dir"
+        elif "boulder" == self._acme_server:
+            self._acme_url = "http://localhost:4001/directory"
+            self._acme_eab_url = None
+        else:
+            raise Exception(f"unknown ACME server type: {self._acme_server}")
+        self._acme_server_down = False
+        self._acme_server_ok = False
+
+        self._a2md_bin = os.path.join(self.bin_dir, 'a2md')
+        self._default_domain = f"test1.{self.http_tld}"
+        self._store_dir = "./md"
+        self.set_store_dir_default()
+
+        self.add_cert_specs([
+            CertificateSpec(domains=[f"expired.{self._http_tld}"],
+                            valid_from=timedelta(days=-100),
+                            valid_to=timedelta(days=-10)),
+            CertificateSpec(domains=["localhost"], key_type='rsa2048'),
+        ])
+
+    def setup_httpd(self, setup: HttpdTestSetup = None):
+        super().setup_httpd(setup=MDTestSetup(env=self))
+
+    def set_store_dir_default(self):
+        dirpath = "md"
+        if self.httpd_is_at_least("2.5.0"):
+            dirpath = os.path.join("state", dirpath)
+        self.set_store_dir(dirpath)
+
+    def set_store_dir(self, dirpath):
+        self._store_dir = os.path.join(self.server_dir, dirpath)
+        if self.acme_url:
+            self.a2md_stdargs([self.a2md_bin, "-a", self.acme_url,
+                               "-d", self._store_dir,  "-C", self.acme_ca_pemfile, "-j"])
+            self.a2md_rawargs([self.a2md_bin, "-a", self.acme_url,
+                               "-d", self._store_dir,  "-C", self.acme_ca_pemfile])
+
+    def get_apxs_var(self, name: str) -> str:
+        p = subprocess.run([self._apxs, "-q", name], capture_output=True, text=True)
+        if p.returncode != 0:
+            return ""
+        return p.stdout.strip()
+
+    @property
+    def acme_server(self):
+        return self._acme_server
+
+    @property
+    def acme_url(self):
+        return self._acme_url
+
+    @property
+    def acme_tos(self):
+        return self._acme_tos
+
+    @property
+    def a2md_bin(self):
+        return self._a2md_bin
+
+    @property
+    def acme_ca_pemfile(self):
+        return self._acme_ca_pemfile
+
+    @property
+    def store_dir(self):
+        return self._store_dir
+
+    def get_request_domain(self, request):
+        name = request.node.originalname if request.node.originalname else request.node.name
+        return "%s-%s" % (re.sub(r'[_]', '-', name), MDTestEnv.DOMAIN_SUFFIX)
+
+    def get_method_domain(self, method):
+        return "%s-%s" % (re.sub(r'[_]', '-', method.__name__.lower()), MDTestEnv.DOMAIN_SUFFIX)
+
+    def get_module_domain(self, module):
+        return "%s-%s" % (re.sub(r'[_]', '-', module.__name__.lower()), MDTestEnv.DOMAIN_SUFFIX)
+
+    def get_class_domain(self, c):
+        return "%s-%s" % (re.sub(r'[_]', '-', c.__name__.lower()), MDTestEnv.DOMAIN_SUFFIX)
+
+    # --------- cmd execution ---------
+
+    _a2md_args = []
+    _a2md_args_raw = []
+
+    def a2md_stdargs(self, args):
+        self._a2md_args = [] + args
+
+    def a2md_rawargs(self, args):
+        self._a2md_args_raw = [] + args
+
+    def a2md(self, args, raw=False) -> ExecResult:
+        preargs = self._a2md_args
+        if raw:
+            preargs = self._a2md_args_raw
+        log.debug("running: {0} {1}".format(preargs, args))
+        return self.run(preargs + args)
+
+    def check_acme(self):
+        if self._acme_server_ok:
+            return True
+        if self._acme_server_down:
+            pytest.skip(msg="ACME server not running")
+            return False
+        if self.is_live(self.acme_url, timeout=timedelta(seconds=0.5)):
+            self._acme_server_ok = True
+            return True
+        else:
+            self._acme_server_down = True
+            pytest.fail(msg="ACME server not running", pytrace=False)
+            return False
+
+    def get_ca_pem_file(self, hostname: str) -> Optional[str]:
+        pem_file = super().get_ca_pem_file(hostname)
+        if pem_file is None:
+            pem_file = self.acme_ca_pemfile
+        return pem_file
+
+    # --------- access local store ---------
+
+    def purge_store(self):
+        log.debug("purge store dir: %s" % self._store_dir)
+        assert len(self._store_dir) > 1
+        if os.path.exists(self._store_dir):
+            shutil.rmtree(self._store_dir, ignore_errors=False)
+        os.makedirs(self._store_dir)
+
+    def clear_store(self):
+        log.debug("clear store dir: %s" % self._store_dir)
+        assert len(self._store_dir) > 1
+        if not os.path.exists(self._store_dir):
+            os.makedirs(self._store_dir)
+        for dirpath in ["challenges", "tmp", "archive", "domains", "accounts", "staging", "ocsp"]:
+            shutil.rmtree(os.path.join(self._store_dir, dirpath), ignore_errors=True)
+
+    def clear_ocsp_store(self):
+        assert len(self._store_dir) > 1
+        dirpath = os.path.join(self._store_dir, "ocsp")
+        log.debug("clear ocsp store dir: %s" % dir)
+        if os.path.exists(dirpath):
+            shutil.rmtree(dirpath, ignore_errors=True)
+
+    def authz_save(self, name, content):
+        dirpath = os.path.join(self._store_dir, 'staging', name)
+        os.makedirs(dirpath)
+        open(os.path.join(dirpath, 'authz.json'), "w").write(content)
+
+    def path_store_json(self):
+        return os.path.join(self._store_dir, 'md_store.json')
+
+    def path_account(self, acct):
+        return os.path.join(self._store_dir, 'accounts', acct, 'account.json')
+
+    def path_account_key(self, acct):
+        return os.path.join(self._store_dir, 'accounts', acct, 'account.pem')
+
+    def store_domains(self):
+        return os.path.join(self._store_dir, 'domains')
+
+    def store_archives(self):
+        return os.path.join(self._store_dir, 'archive')
+
+    def store_stagings(self):
+        return os.path.join(self._store_dir, 'staging')
+
+    def store_challenges(self):
+        return os.path.join(self._store_dir, 'challenges')
+
+    def store_domain_file(self, domain, filename):
+        return os.path.join(self.store_domains(), domain, filename)
+
+    def store_archived_file(self, domain, version, filename):
+        return os.path.join(self.store_archives(), "%s.%d" % (domain, version), filename)
+
+    def store_staged_file(self, domain, filename):
+        return os.path.join(self.store_stagings(), domain, filename)
+
+    def path_fallback_cert(self, domain):
+        return os.path.join(self._store_dir, 'domains', domain, 'fallback-pubcert.pem')
+
+    def path_job(self, domain):
+        return os.path.join(self._store_dir, 'staging', domain, 'job.json')
+
+    def replace_store(self, src):
+        shutil.rmtree(self._store_dir, ignore_errors=False)
+        shutil.copytree(src, self._store_dir)
+
+    def list_accounts(self):
+        return os.listdir(os.path.join(self._store_dir, 'accounts'))
+
+    def check_md(self, domain, md=None, state=-1, ca=None, protocol=None, agreement=None, contacts=None):
+        domains = None
+        if isinstance(domain, list):
+            domains = domain
+            domain = domains[0]
+        if md:
+            domain = md
+        path = self.store_domain_file(domain, 'md.json')
+        with open(path) as f:
+            md = json.load(f)
+        assert md
+        if domains:
+            assert md['domains'] == domains
+        if state >= 0:
+            assert md['state'] == state
+        if ca:
+            assert md['ca']['url'] == ca
+        if protocol:
+            assert md['ca']['proto'] == protocol
+        if agreement:
+            assert md['ca']['agreement'] == agreement
+        if contacts:
+            assert md['contacts'] == contacts
+
+    def pkey_fname(self, pkeyspec=None):
+        if pkeyspec and not re.match(r'^rsa( ?\d+)?$', pkeyspec.lower()):
+            return "privkey.{0}.pem".format(pkeyspec.lower())
+        return 'privkey.pem'
+
+    def cert_fname(self, pkeyspec=None):
+        if pkeyspec and not re.match(r'^rsa( ?\d+)?$', pkeyspec.lower()):
+            return "pubcert.{0}.pem".format(pkeyspec.lower())
+        return 'pubcert.pem'
+
+    def check_md_complete(self, domain, pkey=None):
+        md = self.get_md_status(domain)
+        assert md
+        assert 'state' in md, "md is unexpected: {0}".format(md)
+        assert md['state'] is MDTestEnv.MD_S_COMPLETE, f"unexpected state: {md['state']}"
+        pkey_file = self.store_domain_file(domain, self.pkey_fname(pkey))
+        cert_file = self.store_domain_file(domain, self.cert_fname(pkey))
+        r = self.run(['ls', os.path.dirname(pkey_file)])
+        if not os.path.isfile(pkey_file):
+            assert False, f"pkey missing: {pkey_file}: {r.stdout}"
+        if not os.path.isfile(cert_file):
+            assert False, f"cert missing: {cert_file}: {r.stdout}"
+
+    def check_md_credentials(self, domain):
+        if isinstance(domain, list):
+            domains = domain
+            domain = domains[0]
+        else:
+            domains = [domain]
+        # check private key, validate certificate, etc
+        MDCertUtil.validate_privkey(self.store_domain_file(domain, 'privkey.pem'))
+        cert = MDCertUtil(self.store_domain_file(domain, 'pubcert.pem'))
+        cert.validate_cert_matches_priv_key(self.store_domain_file(domain, 'privkey.pem'))
+        # check SANs and CN
+        assert cert.get_cn() == domain
+        # compare lists twice in opposite directions: SAN may not respect ordering
+        san_list = list(cert.get_san_list())
+        assert len(san_list) == len(domains)
+        assert set(san_list).issubset(domains)
+        assert set(domains).issubset(san_list)
+        # check valid dates interval
+        not_before = cert.get_not_before()
+        not_after = cert.get_not_after()
+        assert not_before < datetime.now(not_before.tzinfo)
+        assert not_after > datetime.now(not_after.tzinfo)
+
+    # --------- check utilities ---------
+
+    def check_json_contains(self, actual, expected):
+        # write all expected key:value bindings to a copy of the actual data ... 
+        # ... assert it stays unchanged 
+        test_json = copy.deepcopy(actual)
+        test_json.update(expected)
+        assert actual == test_json
+
+    def check_file_access(self, path, exp_mask):
+        actual_mask = os.lstat(path).st_mode & 0o777
+        assert oct(actual_mask) == oct(exp_mask)
+
+    def check_dir_empty(self, path):
+        assert os.listdir(path) == []
+
+    def get_http_status(self, domain, path, use_https=True):
+        r = self.get_meta(domain, path, use_https, insecure=True)
+        return r.response['status']
+
+    def get_cert(self, domain, tls=None, ciphers=None):
+        return MDCertUtil.load_server_cert(self._httpd_addr, self.https_port,
+                                           domain, tls=tls, ciphers=ciphers)
+
+    def get_server_cert(self, domain, proto=None, ciphers=None):
+        args = [
+            "openssl", "s_client", "-status",
+            "-connect", "%s:%s" % (self._httpd_addr, self.https_port),
+            "-CAfile", self.acme_ca_pemfile,
+            "-servername", domain,
+            "-showcerts"
+        ]
+        if proto is not None:
+            args.extend(["-{0}".format(proto)])
+        if ciphers is not None:
+            args.extend(["-cipher", ciphers])
+        r = self.run(args)
+        # noinspection PyBroadException
+        try:
+            return MDCertUtil.parse_pem_cert(r.stdout)
+        except:
+            return None
+
+    def verify_cert_key_lenghts(self, domain, pkeys):
+        for p in pkeys:
+            cert = self.get_server_cert(domain, proto="tls1_2", ciphers=p['ciphers'])
+            if 0 == p['keylen']:
+                assert cert is None
+            else:
+                assert cert, "no cert returned for cipher: {0}".format(p['ciphers'])
+                assert cert.get_key_length() == p['keylen'], "key length, expected {0}, got {1}".format(
+                    p['keylen'], cert.get_key_length()
+                )
+
+    def get_meta(self, domain, path, use_https=True, insecure=False):
+        schema = "https" if use_https else "http"
+        port = self.https_port if use_https else self.http_port
+        r = self.curl_get(f"{schema}://{domain}:{port}{path}", insecure=insecure)
+        assert r.exit_code == 0
+        assert r.response
+        assert r.response['header']
+        return r
+
+    def get_content(self, domain, path, use_https=True):
+        schema = "https" if use_https else "http"
+        port = self.https_port if use_https else self.http_port
+        r = self.curl_get(f"{schema}://{domain}:{port}{path}")
+        assert r.exit_code == 0
+        return r.stdout
+
+    def get_json_content(self, domain, path, use_https=True, insecure=False):
+        schema = "https" if use_https else "http"
+        port = self.https_port if use_https else self.http_port
+        url = f"{schema}://{domain}:{port}{path}"
+        r = self.curl_get(url, insecure=insecure)
+        if r.exit_code != 0:
+            log.error(f"curl get on {url} returned {r.exit_code}"
+                      f"\nstdout: {r.stdout}"
+                      f"\nstderr: {r.stderr}")
+        assert r.exit_code == 0, r.stderr
+        return r.json
+
+    def get_certificate_status(self, domain) -> Dict:
+        return self.get_json_content(domain, "/.httpd/certificate-status", insecure=True)
+
+    def get_md_status(self, domain, via_domain=None, use_https=True) -> Dict:
+        if via_domain is None:
+            via_domain = self._default_domain
+        return self.get_json_content(via_domain, f"/md-status/{domain}",
+                                     use_https=use_https)
+
+    def get_server_status(self, query="/", via_domain=None, use_https=True):
+        if via_domain is None:
+            via_domain = self._default_domain
+        return self.get_content(via_domain, "/server-status%s" % query, use_https=use_https)
+
+    def await_completion(self, names, must_renew=False, restart=True, timeout=60,
+                         via_domain=None, use_https=True):
+        try_until = time.time() + timeout
+        renewals = {}
+        names = names.copy()
+        while len(names) > 0:
+            if time.time() >= try_until:
+                return False
+            for name in names:
+                mds = self.get_md_status(name, via_domain=via_domain, use_https=use_https)
+                if mds is None:
+                    log.debug("not managed by md: %s" % name)
+                    return False
+
+                if 'renewal' in mds:
+                    renewal = mds['renewal']
+                    renewals[name] = True
+                    if 'finished' in renewal and renewal['finished'] is True:
+                        if (not must_renew) or (name in renewals):
+                            log.debug(f"domain cert was renewed: {name}")
+                            names.remove(name)
+
+            if len(names) != 0:
+                time.sleep(0.1)
+        if restart:
+            time.sleep(0.1)
+            return self.apache_restart() == 0
+        return True
+
+    def is_renewing(self, name):
+        stat = self.get_certificate_status(name)
+        return 'renewal' in stat
+
+    def await_renewal(self, names, timeout=60):
+        try_until = time.time() + timeout
+        while len(names) > 0:
+            if time.time() >= try_until:
+                return False
+            for name in names:
+                md = self.get_md_status(name)
+                if md is None:
+                    log.debug("not managed by md: %s" % name)
+                    return False
+
+                if 'renewal' in md:
+                    names.remove(name)
+
+            if len(names) != 0:
+                time.sleep(0.1)
+        return True
+
+    def await_error(self, domain, timeout=60, via_domain=None, use_https=True, errors=1):
+        try_until = time.time() + timeout
+        while True:
+            if time.time() >= try_until:
+                return False
+            md = self.get_md_status(domain, via_domain=via_domain, use_https=use_https)
+            if md:
+                if 'state' in md and md['state'] == MDTestEnv.MD_S_ERROR:
+                    return md
+                if 'renewal' in md and 'errors' in md['renewal'] \
+                        and md['renewal']['errors'] >= errors:
+                    return md
+            time.sleep(0.1)
+
+    def await_file(self, fpath, timeout=60):
+        try_until = time.time() + timeout
+        while True:
+            if time.time() >= try_until:
+                return False
+            if os.path.isfile(fpath):
+                return True
+            time.sleep(0.1)
+
+    def check_file_permissions(self, domain):
+        dpath = os.path.join(self.store_dir, 'domains', domain)
+        assert os.path.isdir(dpath)
+        md = json.load(open(os.path.join(dpath, 'md.json')))
+        assert md
+        acct = md['ca']['account']
+        assert acct
+        self.check_file_access(self.path_store_json(), 0o600)
+        # domains
+        self.check_file_access(self.store_domains(), 0o700)
+        self.check_file_access(os.path.join(self.store_domains(), domain), 0o700)
+        self.check_file_access(self.store_domain_file(domain, 'privkey.pem'), 0o600)
+        self.check_file_access(self.store_domain_file(domain, 'pubcert.pem'), 0o600)
+        self.check_file_access(self.store_domain_file(domain, 'md.json'), 0o600)
+        # archive
+        self.check_file_access(self.store_archived_file(domain, 1, 'md.json'), 0o600)
+        # accounts
+        self.check_file_access(os.path.join(self._store_dir, 'accounts'), 0o755)
+        self.check_file_access(os.path.join(self._store_dir, 'accounts', acct), 0o755)
+        self.check_file_access(self.path_account(acct), 0o644)
+        self.check_file_access(self.path_account_key(acct), 0o644)
+        # staging
+        self.check_file_access(self.store_stagings(), 0o755)
+
+    def get_ocsp_status(self, domain, proto=None, cipher=None, ca_file=None):
+        stat = {}
+        args = [
+            "openssl", "s_client", "-status",
+            "-connect", "%s:%s" % (self._httpd_addr, self.https_port),
+            "-CAfile", ca_file if ca_file else self.acme_ca_pemfile,
+            "-servername", domain,
+            "-showcerts"
+        ]
+        if proto is not None:
+            args.extend(["-{0}".format(proto)])
+        if cipher is not None:
+            args.extend(["-cipher", cipher])
+        r = self.run(args, debug_log=False)
+        ocsp_regex = re.compile(r'OCSP response: +([^=\n]+)\n')
+        matches = ocsp_regex.finditer(r.stdout)
+        for m in matches:
+            if m.group(1) != "":
+                stat['ocsp'] = m.group(1)
+        if 'ocsp' not in stat:
+            ocsp_regex = re.compile(r'OCSP Response Status:\s*(.+)')
+            matches = ocsp_regex.finditer(r.stdout)
+            for m in matches:
+                if m.group(1) != "":
+                    stat['ocsp'] = m.group(1)
+        verify_regex = re.compile(r'Verify return code:\s*(.+)')
+        matches = verify_regex.finditer(r.stdout)
+        for m in matches:
+            if m.group(1) != "":
+                stat['verify'] = m.group(1)
+        return stat
+
+    def await_ocsp_status(self, domain, timeout=10, ca_file=None):
+        try_until = time.time() + timeout
+        while True:
+            if time.time() >= try_until:
+                break
+            stat = self.get_ocsp_status(domain, ca_file=ca_file)
+            if 'ocsp' in stat and stat['ocsp'] != "no response sent":
+                return stat
+            time.sleep(0.1)
+        raise TimeoutError(f"ocsp respopnse not available: {domain}")
+
+    def create_self_signed_cert(self, name_list, valid_days, serial=1000, path=None):
+        dirpath = path
+        if not path:
+            dirpath = os.path.join(self.store_domains(), name_list[0])
+        return MDCertUtil.create_self_signed_cert(dirpath, name_list, valid_days, serial)
diff --git a/test/modules/md/message.py b/test/modules/md/message.py
new file mode 100755 (executable)
index 0000000..578289c
--- /dev/null
@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+
+def main(argv):
+    if len(argv) > 2:
+        cmd = argv[2]
+        if 'renewing' != cmd:
+            f1 = open(argv[1], 'a+')
+            f1.write(f'{argv}\n')
+            if 'MD_VERSION' in os.environ:
+                f1.write(f'MD_VERSION={os.environ["MD_VERSION"]}\n')
+            if 'MD_STORE' in os.environ:
+                f1.write(f'MD_STORE={os.environ["MD_STORE"]}\n')
+            f1.close()
+        sys.stderr.write("done, all fine.\n")
+        sys.exit(0)
+    else:
+        sys.stderr.write(f"{argv[0]} without arguments")
+        sys.exit(7)
+    
+
+if __name__ == "__main__":
+    main(sys.argv)
diff --git a/test/modules/md/msg_fail_on.py b/test/modules/md/msg_fail_on.py
new file mode 100755 (executable)
index 0000000..fec95d4
--- /dev/null
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+
+def main(argv):
+    if len(argv) > 3:
+        log = argv[1]
+        fail_on = argv[2]
+        cmd = argv[3]
+        domain = argv[4]
+        if 'renewing' != cmd:
+            f1 = open(log, 'a+')
+            f1.write(f"{[argv[0], log, cmd, domain]}\n")
+            f1.close()
+        if cmd.startswith(fail_on):
+            sys.stderr.write(f"failing on: {cmd}\n")
+            sys.exit(1)
+        sys.stderr.write("done, all fine.\n")
+        sys.exit(0)
+    else:
+        sys.stderr.write("%s without arguments" % (argv[0]))
+        sys.exit(7)
+
+
+if __name__ == "__main__":
+    main(sys.argv)
diff --git a/test/modules/md/notifail.py b/test/modules/md/notifail.py
new file mode 100755 (executable)
index 0000000..a02cd39
--- /dev/null
@@ -0,0 +1,16 @@
+#!/usr/bin/env python3
+
+import sys
+
+
+def main(argv):
+    if len(argv) > 1:
+        msg = argv[2] if len(argv) > 2 else None
+        # fail on later messaging stages, not the initial 'renewing' one.
+        # we have test_901_030 that check that later stages are not invoked
+        # when misconfigurations are detected early.
+        sys.exit(1 if msg != "renewing" else 0)
+    
+
+if __name__ == "__main__":
+    main(sys.argv)
diff --git a/test/modules/md/notify.py b/test/modules/md/notify.py
new file mode 100755 (executable)
index 0000000..c5971c8
--- /dev/null
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+
+import sys
+
+
+def main(argv):
+    if len(argv) > 2:
+        with open(argv[1], 'a+') as f1:
+            f1.write(f"{argv}\n")
+        sys.stderr.write("done, all fine.\n")
+        sys.exit(0)
+    else:
+        sys.stderr.write(f"{argv[0]} without arguments")
+        sys.exit(7)
+    
+
+if __name__ == "__main__":
+    main(sys.argv)
diff --git a/test/modules/md/pebble/pebble-eab.json.template b/test/modules/md/pebble/pebble-eab.json.template
new file mode 100644 (file)
index 0000000..dd5bee5
--- /dev/null
@@ -0,0 +1,16 @@
+{
+  "pebble": {
+    "listenAddress": "0.0.0.0:14000",
+    "managementListenAddress": "0.0.0.0:15000",
+    "certificate": "${server_dir}/ca/localhost.rsa2048.cert.pem",
+    "privateKey": "${server_dir}/ca/localhost.rsa2048.pkey.pem",
+    "httpPort": ${http_port},
+    "tlsPort": ${https_port},
+    "ocspResponderURL": "",
+    "externalAccountBindingRequired": true,
+    "externalAccountMACKeys": {
+      "kid-1": "zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W",
+      "kid-2": "b10lLJs8l1GPIzsLP0s6pMt8O0XVGnfTaCeROxQM0BIt2XrJMDHJZBM5NuQmQJQH"
+    }
+  }
+}
diff --git a/test/modules/md/pebble/pebble.json.template b/test/modules/md/pebble/pebble.json.template
new file mode 100644 (file)
index 0000000..9c41271
--- /dev/null
@@ -0,0 +1,12 @@
+{
+  "pebble": {
+    "listenAddress": "0.0.0.0:14000",
+    "managementListenAddress": "0.0.0.0:15000",
+    "certificate": "${server_dir}/ca/localhost.rsa2048.cert.pem",
+    "privateKey": "${server_dir}/ca/localhost.rsa2048.pkey.pem",
+    "httpPort": ${http_port},
+    "tlsPort": ${https_port},
+    "ocspResponderURL": "",
+    "externalAccountBindingRequired": false
+  }
+}
diff --git a/test/modules/md/test_001_store.py b/test/modules/md/test_001_store.py
new file mode 100644 (file)
index 0000000..c888db9
--- /dev/null
@@ -0,0 +1,213 @@
+# test mod_md acme terms-of-service handling
+
+import re
+
+import pytest
+
+from .md_env import MDTestEnv
+
+
+def md_name(md):
+    return md['name']
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
+class TestStore:
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env):
+        env.purge_store()
+    # verify expected binary version
+    def test_md_001_001(self, env: MDTestEnv):
+        r = env.run([env.a2md_bin, "-V"])
+        m = re.match(r'version: (\d+\.\d+\.\d+)(-git)?$', r.stdout)
+        assert m, f"expected version info in '{r.stdout}'"
+
+    # verify that store is clean
+    def test_md_001_002(self, env: MDTestEnv):
+        r = env.run(["find", env.store_dir])
+        assert re.match(env.store_dir, r.stdout)
+
+    # test case: add a single dns managed domain
+    def test_md_001_100(self, env: MDTestEnv):
+        dns = "greenbytes.de"
+        env.check_json_contains(
+            env.a2md(["store", "add", dns]).json['output'][0],
+            {
+                "name": dns,
+                "domains": [dns],
+                "contacts": [],
+                "ca": {
+                    "url": env.acme_url,
+                    "proto": "ACME"
+                },
+                "state": 0
+            })
+
+    # test case: add > 1 dns managed domain
+    def test_md_001_101(self, env: MDTestEnv):
+        dns = ["greenbytes2.de", "www.greenbytes2.de", "mail.greenbytes2.de"]
+        env.check_json_contains(
+            env.a2md(["store", "add"] + dns).json['output'][0],
+            {
+                "name": dns[0],
+                "domains": dns,
+                "contacts": [],
+                "ca": {
+                    "url": env.acme_url,
+                    "proto": "ACME"
+                },
+                "state": 0
+            })
+
+    # test case: add second managed domain
+    def test_md_001_102(self, env: MDTestEnv):
+        dns1 = ["test000-102.com", "test000-102a.com", "test000-102b.com"]
+        assert env.a2md(["store", "add"] + dns1).exit_code == 0
+        #
+        # add second managed domain
+        dns2 = ["greenbytes2.de", "www.greenbytes2.de", "mail.greenbytes2.de"]
+        jout = env.a2md(["store", "add"] + dns2).json
+        # assert: output covers only changed md
+        assert len(jout['output']) == 1
+        env.check_json_contains(jout['output'][0], {
+            "name": dns2[0],
+            "domains": dns2,
+            "contacts": [],
+            "ca": {
+                "url": env.acme_url,
+                "proto": "ACME"
+            },
+            "state": 0
+        })
+
+    # test case: add existing domain 
+    def test_md_001_103(self, env: MDTestEnv):
+        dns = "greenbytes.de"
+        assert env.a2md(["store", "add", dns]).exit_code == 0
+        # add same domain again
+        assert env.a2md(["store", "add", dns]).exit_code == 1
+
+    # test case: add without CA URL
+    def test_md_001_104(self, env: MDTestEnv):
+        dns = "greenbytes.de"
+        args = [env.a2md_bin, "-d", env.store_dir, "-j", "store", "add", dns]
+        jout = env.run(args).json
+        assert len(jout['output']) == 1
+        env.check_json_contains(jout['output'][0], {
+            "name": dns,
+            "domains": [dns],
+            "contacts": [],
+            "ca": {
+                "proto": "ACME"
+            },
+            "state": 0
+        })
+
+    # test case: list empty store
+    def test_md_001_200(self, env: MDTestEnv):
+        assert env.a2md(["store", "list"]).json == env.EMPTY_JOUT
+
+    # test case: list two managed domains
+    def test_md_001_201(self, env: MDTestEnv):
+        domains = [ 
+            ["test000-201.com", "test000-201a.com", "test000-201b.com"],
+            ["greenbytes2.de", "www.greenbytes2.de", "mail.greenbytes2.de"]
+        ]
+        for dns in domains:
+            assert env.a2md(["store", "add"] + dns).exit_code == 0
+        #
+        # list all store content
+        jout = env.a2md(["store", "list"]).json
+        assert len(jout['output']) == len(domains)
+        domains.reverse()
+        jout['output'] = sorted(jout['output'], key=md_name)
+        for i in range(0, len(jout['output'])):
+            env.check_json_contains(jout['output'][i], {
+                "name": domains[i][0],
+                "domains": domains[i],
+                "contacts": [],
+                "ca": {
+                    "url": env.acme_url,
+                    "proto": "ACME"
+                },
+                "state": 0
+            })
+
+    # test case: remove managed domain
+    def test_md_001_300(self, env: MDTestEnv):
+        dns = "test000-300.com"
+        assert env.a2md(["store", "add", dns]).exit_code == 0
+        assert env.a2md(["store", "remove", dns]).json == env.EMPTY_JOUT
+        assert env.a2md(["store", "list"]).json == env.EMPTY_JOUT
+
+    # test case: remove from list of managed domains 
+    def test_md_001_301(self, env: MDTestEnv):
+        dns1 = ["test000-301.com", "test000-301a.com", "test000-301b.com"]
+        assert env.a2md(["store", "add"] + dns1).exit_code == 0
+        #
+        dns2 = ["greenbytes2.de", "www.greenbytes2.de", "mail.greenbytes2.de"]
+        jout1 = env.a2md(["store", "add"] + dns2).json
+        # remove managed domain
+        assert env.a2md(["store", "remove", "test000-301.com"]).json == env.EMPTY_JOUT
+        # list store content
+        assert env.a2md(["store", "list"]).json == jout1
+
+    # test case: remove nonexisting managed domain
+    def test_md_001_302(self, env: MDTestEnv):
+        dns1 = "test000-302.com"
+        r = env.a2md(["store", "remove", dns1])
+        assert r.exit_code == 1
+        assert r.json == {
+            'status': 2, 'description': 'No such file or directory', 'output': []
+        }
+
+    # test case: force remove nonexisting managed domain
+    def test_md_001_303(self, env: MDTestEnv):
+        dns1 = "test000-303.com"
+        assert env.a2md(["store", "remove", "-f", dns1]).json == env.EMPTY_JOUT
+
+    # test case: null change
+    def test_md_001_400(self, env: MDTestEnv):
+        dns = "test000-400.com"
+        r1 = env.a2md(["store", "add", dns])
+        assert env.a2md(["store", "update", dns]).json == r1.json
+
+    # test case: add dns to managed domain
+    def test_md_001_401(self, env: MDTestEnv):
+        dns1 = "test000-401.com"
+        env.a2md(["store", "add", dns1])
+        dns2 = "test-101.com"
+        args = ["store", "update", dns1, "domains", dns1, dns2]
+        assert env.a2md(args).json['output'][0]['domains'] == [dns1, dns2]
+
+    # test case: change CA URL
+    def test_md_001_402(self, env: MDTestEnv):
+        dns = "test000-402.com"
+        args = ["store", "add", dns]
+        assert env.a2md(args).json['output'][0]['ca']['url'] == env.acme_url
+        nurl = "https://foo.com/"
+        args = [env.a2md_bin, "-a", nurl, "-d", env.store_dir, "-j", "store", "update", dns]
+        assert env.run(args).json['output'][0]['ca']['url'] == nurl
+
+    # test case: update nonexisting managed domain
+    def test_md_001_403(self, env: MDTestEnv):
+        dns = "test000-403.com"
+        assert env.a2md(["store", "update", dns]).exit_code == 1
+
+    # test case: update domains, throw away md name
+    def test_md_001_404(self, env: MDTestEnv):
+        dns1 = "test000-404.com"
+        dns2 = "greenbytes.com"
+        args = ["store", "add", dns1]
+        assert env.a2md(args).json['output'][0]['domains'] == [dns1]
+        # override domains list
+        args = ["store", "update", dns1, "domains", dns2]
+        assert env.a2md(args).json['output'][0]['domains'] == [dns2]
+
+    # test case: update domains with empty dns list
+    def test_md_001_405(self, env: MDTestEnv):
+        dns1 = "test000-405.com"
+        assert env.a2md(["store", "add", dns1]).exit_code == 0
+        assert env.a2md(["store", "update", dns1, "domains"]).exit_code == 1
diff --git a/test/modules/md/test_010_store_migrate.py b/test/modules/md/test_010_store_migrate.py
new file mode 100644 (file)
index 0000000..d734b29
--- /dev/null
@@ -0,0 +1,43 @@
+# test mod_md acme terms-of-service handling
+
+import os
+import pytest
+
+from .md_conf import MDConf
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
+class TestStoreMigrate:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env):
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    # install old store, start a2md list, check files afterwards
+    def test_md_010_000(self, env):
+        domain = "7007-1502285564.org"
+        env.replace_store(os.path.join(env.test_dir, "../modules/md/data/store_migrate/1.0/sample1"))
+        #
+        # use 1.0 file name for private key
+        fpkey_1_0 = os.path.join(env.store_dir, 'domains', domain, 'pkey.pem')
+        fpkey_1_1 = os.path.join(env.store_dir, 'domains', domain, 'privkey.pem')
+        cert_1_0 = os.path.join(env.store_dir, 'domains', domain, 'cert.pem')
+        cert_1_1 = os.path.join(env.store_dir, 'domains', domain, 'pubcert.pem')
+        chain_1_0 = os.path.join(env.store_dir, 'domains', domain, 'chain.pem')
+        #
+        assert os.path.exists(fpkey_1_0)
+        assert os.path.exists(cert_1_0)
+        assert os.path.exists(chain_1_0)
+        assert not os.path.exists(fpkey_1_1)
+        assert not os.path.exists(cert_1_1)
+        #
+        md = env.a2md(["-vvv", "list", domain]).json['output'][0]
+        assert domain == md["name"]
+        #
+        assert not os.path.exists(fpkey_1_0)
+        assert os.path.exists(cert_1_0)
+        assert os.path.exists(chain_1_0)
+        assert os.path.exists(fpkey_1_1)
+        assert os.path.exists(cert_1_1)
diff --git a/test/modules/md/test_100_reg_add.py b/test/modules/md/test_100_reg_add.py
new file mode 100644 (file)
index 0000000..2b5bd23
--- /dev/null
@@ -0,0 +1,152 @@
+# test mod_md acme terms-of-service handling
+
+import pytest
+
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestRegAdd:
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env):
+        env.purge_store()
+
+    # test case: add a single dns managed domain
+    def test_md_100_000(self, env):
+        dns = "greenbytes.de"
+        jout1 = env.a2md(["add", dns]).json
+        env.check_json_contains(jout1['output'][0], {
+            "name": dns,
+            "domains": [dns],
+            "contacts": [],
+            "ca": {
+                "url": env.acme_url,
+                "proto": "ACME"
+            },
+            "state": env.MD_S_INCOMPLETE
+        })
+        assert env.a2md(["list"]).json == jout1
+
+    # test case: add > 1 dns managed domain
+    def test_md_100_001(self, env):
+        dns = ["greenbytes2.de", "www.greenbytes2.de", "mail.greenbytes2.de"]
+        jout1 = env.a2md(["add"] + dns).json
+        env.check_json_contains(jout1['output'][0], {
+            "name": dns[0],
+            "domains": dns,
+            "contacts": [],
+            "ca": {
+                "url": env.acme_url,
+                "proto": "ACME"
+            },
+            "state": env.MD_S_INCOMPLETE
+        })
+        assert env.a2md(["list"]).json == jout1
+
+    # test case: add second managed domain
+    def test_md_100_002(self, env):
+        dns1 = ["test100-002.com", "test100-002a.com", "test100-002b.com"]
+        env.a2md(["add"] + dns1)
+        # add second managed domain
+        dns2 = ["greenbytes2.de", "www.greenbytes2.de", "mail.greenbytes2.de"]
+        jout = env.a2md(["add"] + dns2).json
+        # assert: output covers only changed md
+        assert len(jout['output']) == 1
+        env.check_json_contains(jout['output'][0], {
+            "name": dns2[0],
+            "domains": dns2,
+            "contacts": [],
+            "ca": {
+                "url": env.acme_url,
+                "proto": "ACME"
+            },
+            "state": env.MD_S_INCOMPLETE
+        })
+        assert len(env.a2md(["list"]).json['output']) == 2
+
+    # test case: add existing domain 
+    def test_md_100_003(self, env):
+        dns = "greenbytes.de"
+        assert env.a2md(["add", dns]).exit_code == 0
+        assert env.a2md(["add", dns]).exit_code == 1
+
+    # test case: add without CA URL
+    def test_md_100_004(self, env):
+        dns = "greenbytes.de"
+        jout1 = env.run([env.a2md_bin, "-d", env.store_dir, "-j", "add", dns]).json
+        assert len(jout1['output']) == 1
+        env.check_json_contains(jout1['output'][0], {
+            "name": dns,
+            "domains": [dns],
+            "contacts": [],
+            "ca": {
+                "proto": "ACME"
+            },
+            "state": env.MD_S_INCOMPLETE
+        })
+        assert env.a2md(["list"]).json == jout1
+
+    # test case: add with invalid DNS
+    @pytest.mark.parametrize("invalid_dns", [
+        "tld", "white sp.ace", "invalid.*.wildcard.com", "k\xc3ller.idn.com"
+    ])
+    def test_md_100_005(self, env, invalid_dns):
+        assert env.a2md(["add", invalid_dns]).exit_code == 1
+        assert env.a2md(["add", "test-100.de", invalid_dns]).exit_code == 1
+
+    # test case: add with invalid ACME URL
+    @pytest.mark.parametrize("invalid_url", [
+        "no.schema/path", "http://white space/path", "http://bad.port:-1/path"])
+    def test_md_100_006(self, env, invalid_url):
+        args = [env.a2md_bin, "-a", invalid_url, "-d", env.store_dir, "-j"]
+        dns = "greenbytes.de"
+        args.extend(["add", dns])
+        assert env.run(args).exit_code == 1
+
+    # test case: add overlapping dns names
+    def test_md_100_007(self, env):
+        assert env.a2md(["add", "test-100.com", "test-101.com"]).exit_code == 0
+        # 1: alternate DNS exists as primary name
+        assert env.a2md(["add", "greenbytes2.de", "test-100.com"]).exit_code == 1
+        # 2: alternate DNS exists as alternate DNS
+        assert env.a2md(["add", "greenbytes2.de", "test-101.com"]).exit_code == 1
+        # 3: primary name exists as alternate DNS
+        assert env.a2md(["add", "test-101.com"]).exit_code == 1
+
+    # test case: add subdomains as separate managed domain
+    def test_md_100_008(self, env):
+        assert env.a2md(["add", "test-100.com"]).exit_code == 0
+        assert env.a2md(["add", "sub.test-100.com"]).exit_code == 0
+
+    # test case: add duplicate domain
+    def test_md_100_009(self, env):
+        dns1 = "test-100.com"
+        dns2 = "test-101.com"
+        jout = env.a2md(["add", dns1, dns2, dns1, dns2]).json
+        # DNS is only listed once
+        assert len(jout['output']) == 1
+        md = jout['output'][0]
+        assert md['domains'] == [dns1, dns2]
+
+    # test case: add pnuycode name
+    def test_md_100_010(self, env):
+        assert env.a2md(["add", "xn--kller-jua.punycode.de"]).exit_code == 0
+
+    # test case: don't sort alternate names
+    def test_md_100_011(self, env):
+        dns = ["test-100.com", "test-xxx.com", "test-aaa.com"]
+        jout = env.a2md(["add"] + dns).json
+        # DNS is only listed as specified
+        assert len(jout['output']) == 1
+        md = jout['output'][0]
+        assert md['domains'] == dns
+
+    # test case: add DNS wildcard
+    @pytest.mark.parametrize("wild_dns", [
+        "*.wildcard.com"
+    ])
+    def test_md_100_012(self, env, wild_dns):
+        assert env.a2md(["add", wild_dns]).exit_code == 0
diff --git a/test/modules/md/test_110_reg_update.py b/test/modules/md/test_110_reg_update.py
new file mode 100644 (file)
index 0000000..3120ced
--- /dev/null
@@ -0,0 +1,273 @@
+# test mod_md acme terms-of-service handling
+
+import pytest
+
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestRegUpdate:
+
+    NAME1 = "greenbytes2.de"
+    NAME2 = "test-100.com"
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env):
+        env.clear_store()
+        # add managed domains
+        domains = [ 
+            [self.NAME1, "www.greenbytes2.de", "mail.greenbytes2.de"],
+            [self.NAME2, "test-101.com", "test-102.com"]
+        ]
+        for dns in domains:
+            env.a2md(["-a", env.acme_url, "add"] + dns)
+
+    def teardown_method(self, method):
+        print("teardown_method: %s" % method.__name__)
+
+    # test case: update domains
+    def test_md_110_000(self, env):
+        dns = ["foo.de", "bar.de"]
+        output1 = env.a2md(["-vvvv", "update", self.NAME1, "domains"] + dns).json['output']
+        assert len(output1) == 1
+        env.check_json_contains(output1[0], {
+            "name": self.NAME1,
+            "domains": dns,
+            "contacts": [],
+            "ca": {
+                "url": env.acme_url,
+                "proto": "ACME"
+            },
+            "state": env.MD_S_INCOMPLETE
+        })
+        assert env.a2md(["list"]).json['output'][0] == output1[0]
+
+    # test case: remove all domains
+    def test_md_110_001(self, env):
+        assert env.a2md(["update", self.NAME1, "domains"]).exit_code == 1
+
+    # test case: update domains with invalid DNS
+    @pytest.mark.parametrize("invalid_dns", [
+        "tld", "white sp.ace", "invalid.*.wildcard.com", "k\xc3ller.idn.com"
+    ])
+    def test_md_110_002(self, env, invalid_dns):
+        assert env.a2md(["update", self.NAME1, "domains", invalid_dns]).exit_code == 1
+
+    # test case: update domains with overlapping DNS list
+    def test_md_110_003(self, env):
+        dns = [self.NAME1, self.NAME2]
+        assert env.a2md(["update", self.NAME1, "domains"] + dns).exit_code == 1
+
+    # test case: update with subdomains
+    def test_md_110_004(self, env):
+        dns = ["test-foo.com", "sub.test-foo.com"]
+        md = env.a2md(["update", self.NAME1, "domains"] + dns).json['output'][0]
+        assert md['name'] == self.NAME1
+        assert md['domains'] == dns
+
+    # test case: update domains with duplicates
+    def test_md_110_005(self, env):
+        dns = [self.NAME1, self.NAME1, self.NAME1]
+        md = env.a2md(["update", self.NAME1, "domains"] + dns).json['output'][0]
+        assert md['name'] == self.NAME1
+        assert md['domains'] == [self.NAME1]
+
+    # test case: remove domains with punycode
+    def test_md_110_006(self, env):
+        dns = [self.NAME1, "xn--kller-jua.punycode.de"]
+        md = env.a2md(["update", self.NAME1, "domains"] + dns).json['output'][0]
+        assert md['name'] == self.NAME1
+        assert md['domains'] == dns
+
+    # test case: update non-existing managed domain
+    def test_md_110_007(self, env):
+        assert env.a2md(["update", "test-foo.com", "domains", "test-foo.com"]).exit_code == 1
+
+    # test case: update domains with DNS wildcard
+    @pytest.mark.parametrize("wild_dns", [
+        "*.wildcard.com"
+    ])
+    def test_md_110_008(self, env, wild_dns):
+        assert env.a2md(["update", self.NAME1, "domains", wild_dns]).exit_code == 0
+    
+    # --------- update ca ---------
+
+    # test case: update CA URL
+    def test_md_110_100(self, env):
+        url = "http://localhost.com:9999"
+        output = env.a2md(["update", self.NAME1, "ca", url]).json['output']
+        assert len(output) == 1
+        env.check_json_contains(output[0], {
+            "name": self.NAME1,
+            "domains": [self.NAME1, "www.greenbytes2.de", "mail.greenbytes2.de"],
+            "contacts": [],
+            "ca": {
+                "url": url,
+                "proto": "ACME"
+            },
+            "state": env.MD_S_INCOMPLETE
+        })
+
+    # test case: update CA with invalid URL
+    @pytest.mark.parametrize("invalid_url", [
+        "no.schema/path", "http://white space/path", "http://bad.port:-1/path"
+    ])
+    def test_md_110_101(self, env, invalid_url):
+        assert env.a2md(["update", self.NAME1, "ca", invalid_url]).exit_code == 1
+
+    # test case: update ca protocol
+    def test_md_110_102(self, env):
+        md = env.a2md(["update", self.NAME1, "ca", env.acme_url, "FOO"]).json['output'][0]
+        env.check_json_contains(md['ca'], {
+            "url": env.acme_url,
+            "proto": "FOO"
+        })
+        assert md['state'] == 1
+
+    # test case: update account ID
+    def test_md_110_200(self, env):
+        acc_id = "test.account.id"
+        output = env.a2md(["update", self.NAME1, "account", acc_id]).json['output']
+        assert len(output) == 1
+        env.check_json_contains(output[0], {
+            "name": self.NAME1,
+            "domains": [self.NAME1, "www.greenbytes2.de", "mail.greenbytes2.de"],
+            "contacts": [],
+            "ca": {
+                "account": acc_id,
+                "url": env.acme_url,
+                "proto": "ACME"
+            },
+            "state": env.MD_S_INCOMPLETE
+        })
+
+    # test case: remove account ID
+    def test_md_110_201(self, env):
+        assert env.a2md(["update", self.NAME1, "account", "test.account.id"]).exit_code == 0
+        md = env.a2md(["update", self.NAME1, "account"]).json['output'][0]
+        env.check_json_contains(md['ca'], {
+            "url": env.acme_url,
+            "proto": "ACME"
+        })
+        assert md['state'] == 1
+
+    # test case: change existing account ID
+    def test_md_110_202(self, env):
+        assert env.a2md(["update", self.NAME1, "account", "test.account.id"]).exit_code == 0
+        md = env.a2md(["update", self.NAME1, "account", "foo.test.com"]).json['output'][0]
+        env.check_json_contains(md['ca'], {
+            "account": "foo.test.com",
+            "url": env.acme_url,
+            "proto": "ACME"
+        })
+        assert md['state'] == 1
+
+    # test case: ignore additional argument
+    def test_md_110_203(self, env):
+        md = env.a2md(["update", self.NAME1, "account", "test.account.id",
+                       "test2.account.id"]).json['output'][0]
+        env.check_json_contains(md['ca'], {
+            "account": "test.account.id",
+            "url": env.acme_url,
+            "proto": "ACME"
+        })
+        assert md['state'] == 1
+
+    # test case: add contact info
+    def test_md_110_300(self, env):
+        mail = "test@greenbytes.de"
+        output = env.a2md(["update", self.NAME1, "contacts", mail]).json['output']
+        assert len(output) == 1
+        env.check_json_contains(output[0], {
+            "name": self.NAME1,
+            "domains": [self.NAME1, "www.greenbytes2.de", "mail.greenbytes2.de"],
+            "contacts": ["mailto:" + mail],
+            "ca": {
+                "url": env.acme_url,
+                "proto": "ACME"
+            },
+            "state": env.MD_S_INCOMPLETE
+        })
+
+    # test case: add multiple contact info, preserve order
+    def test_md_110_301(self, env):
+        mail = ["xxx@greenbytes.de", "aaa@greenbytes.de"]
+        md = env.a2md(["update", self.NAME1, "contacts"] + mail).json['output'][0]
+        assert md['contacts'] == ["mailto:" + mail[0], "mailto:" + mail[1]]
+        assert md['state'] == 1
+
+    # test case: must not remove contact info
+    def test_md_110_302(self, env):
+        assert env.a2md(["update", self.NAME1, "contacts", "test@greenbytes.de"]).exit_code == 0
+        assert env.a2md(["update", self.NAME1, "contacts"]).exit_code == 1
+
+    # test case: replace existing contact info
+    def test_md_110_303(self, env):
+        assert env.a2md(["update", self.NAME1, "contacts", "test@greenbytes.de"]).exit_code == 0
+        md = env.a2md(["update", self.NAME1, "contacts", "xxx@greenbytes.de"]).json['output'][0]
+        assert md['contacts'] == ["mailto:xxx@greenbytes.de"]
+        assert md['state'] == 1
+
+    # test case: use invalid mail address
+    @pytest.mark.parametrize("invalid_mail", [
+        "no.at.char", "with blank@test.com", "missing.host@", "@missing.localpart.de",
+        "double..dot@test.com", "double@at@test.com"
+    ])
+    def test_md_110_304(self, env, invalid_mail):
+        # SEI: Uhm, es ist nicht sinnvoll, eine komplette verification von
+        # https://tools.ietf.org/html/rfc822 zu bauen?
+        assert env.a2md(["update", self.NAME1, "contacts", invalid_mail]).exit_code == 1
+
+    # test case: respect urls as given
+    @pytest.mark.parametrize("url", [
+        "mailto:test@greenbytes.de", "wrong://schema@test.com"])
+    def test_md_110_305(self, env, url):
+        md = env.a2md(["update", self.NAME1, "contacts", url]).json['output'][0]
+        assert md['contacts'] == [url]
+        assert md['state'] == 1
+
+    # test case: add tos agreement
+    def test_md_110_400(self, env):
+        output = env.a2md(["update", self.NAME1, "agreement", env.acme_tos]).json['output']
+        assert len(output) == 1
+        env.check_json_contains(output[0], {
+            "name": self.NAME1,
+            "domains": [self.NAME1, "www.greenbytes2.de", "mail.greenbytes2.de"],
+            "contacts": [],
+            "ca": {
+                "url": env.acme_url,
+                "proto": "ACME",
+                "agreement": env.acme_tos
+            },
+            "state": env.MD_S_INCOMPLETE
+        })
+
+    # test case: remove tos agreement
+    def test_md_110_402(self, env):
+        assert env.a2md(["update", self.NAME1, "agreement", env.acme_tos]).exit_code == 0
+        md = env.a2md(["update", self.NAME1, "agreement"]).json['output'][0]
+        env.check_json_contains(md['ca'], {
+            "url": env.acme_url,
+            "proto": "ACME"
+        })
+        assert md['state'] == 1
+
+    # test case: ignore additional arguments
+    def test_md_110_403(self, env):
+        md = env.a2md(["update", self.NAME1, "agreement",
+                       env.acme_tos, "http://invalid.tos/"]).json['output'][0]
+        env.check_json_contains(md['ca'], {
+            "url": env.acme_url,
+            "proto": "ACME",
+            "agreement": env.acme_tos
+        })
+        assert md['state'] == 1
+
+    # test case: update agreement with invalid URL
+    @pytest.mark.parametrize("invalid_url", [
+        "no.schema/path", "http://white space/path", "http://bad.port:-1/path"
+    ])
+    def test_md_110_404(self, env, invalid_url):
+        assert env.a2md(["update", self.NAME1, "agreement", invalid_url]).exit_code == 1
diff --git a/test/modules/md/test_120_reg_list.py b/test/modules/md/test_120_reg_list.py
new file mode 100644 (file)
index 0000000..0c1ce8a
--- /dev/null
@@ -0,0 +1,87 @@
+# test mod_md acme terms-of-service handling
+
+from shutil import copyfile
+
+import pytest
+
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestRegAdd:
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env):
+        env.clear_store()
+
+    # test case: list empty store
+    def test_md_120_000(self, env):
+        assert env.a2md(["list"]).json == env.EMPTY_JOUT
+
+    # test case: list two managed domains
+    def test_md_120_001(self, env):
+        domains = [ 
+            ["test120-001.com", "test120-001a.com", "test120-001b.com"],
+            ["greenbytes2.de", "www.greenbytes2.de", "mail.greenbytes2.de"]
+        ]
+        for dns in domains:
+            assert env.a2md(["add"] + dns).exit_code == 0
+        #
+        # list all store content
+        jout = env.a2md(["list"]).json
+        assert len(jout['output']) == len(domains)
+        domains.reverse()
+        for i in range(0, len(jout['output'])):
+            env.check_json_contains(jout['output'][i], {
+                "name": domains[i][0],
+                "domains": domains[i],
+                "contacts": [],
+                "ca": {
+                    "url": env.acme_url,
+                    "proto": "ACME"
+                },
+                "state": env.MD_S_INCOMPLETE
+            })
+        # list md by name
+        for dns in ["test120-001.com", "greenbytes2.de"]:
+            md = env.a2md(["list", dns]).json['output'][0]
+            assert md['name'] == dns
+
+    # test case: validate md state in store
+    def test_md_120_002(self, env):
+        # check: md without pkey/cert -> INCOMPLETE
+        domain = f"test1.{env.http_tld}"
+        assert env.a2md(["add", domain]).exit_code == 0
+        assert env.a2md(["update", domain, "contacts", "admin@" + domain]).exit_code == 0
+        assert env.a2md(["update", domain, "agreement", env.acme_tos]).exit_code == 0
+        assert env.a2md(["list", domain]).json['output'][0]['state'] == env.MD_S_INCOMPLETE
+        # check: valid pkey/cert -> COMPLETE
+        cred = env.get_credentials_for_name(domain)[0]
+        copyfile(cred.pkey_file, env.store_domain_file(domain, 'privkey.pem'))
+        copyfile(cred.cert_file, env.store_domain_file(domain, 'pubcert.pem'))
+        assert env.a2md(["list", domain]).json['output'][0]['state'] == env.MD_S_COMPLETE
+        # check: expired cert -> EXPIRED
+        cred = env.get_credentials_for_name(f"expired.{env.http_tld}")[0]
+        copyfile(cred.pkey_file, env.store_domain_file(domain, 'privkey.pem'))
+        copyfile(cred.cert_file, env.store_domain_file(domain, 'pubcert.pem'))
+        out = env.a2md(["list", domain]).json['output'][0]
+        assert out['state'] == env.MD_S_INCOMPLETE
+        assert out['renew'] is True
+
+    # test case: broken cert file
+    def test_md_120_003(self, env):
+        domain = f"test1.{env.http_tld}"
+        assert env.a2md(["add", domain]).exit_code == 0
+        assert env.a2md(["update", domain, "contacts", "admin@" + domain]).exit_code == 0
+        assert env.a2md(["update", domain, "agreement", env.acme_tos]).exit_code == 0
+        # check: valid pkey/cert -> COMPLETE
+        cred = env.get_credentials_for_name(domain)[0]
+        copyfile(cred.pkey_file, env.store_domain_file(domain, 'privkey.pem'))
+        copyfile(cred.cert_file, env.store_domain_file(domain, 'pubcert.pem'))
+        assert env.a2md(["list", domain]).json['output'][0]['state'] == env.MD_S_COMPLETE
+        # check: replace cert by broken file -> ERROR
+        with open(env.store_domain_file(domain, 'pubcert.pem'), 'w') as fd:
+            fd.write("dummy\n")
+        assert env.a2md(["list", domain]).json['output'][0]['state'] == env.MD_S_INCOMPLETE
diff --git a/test/modules/md/test_202_acmev2_regs.py b/test/modules/md/test_202_acmev2_regs.py
new file mode 100644 (file)
index 0000000..97f093e
--- /dev/null
@@ -0,0 +1,132 @@
+# test mod_md ACMEv2 registrations
+
+import re
+import json
+import pytest
+
+from .md_conf import MDConf
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestAcmeAcc:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        acme.start(config='default')
+        env.check_acme()
+        env.APACHE_CONF_SRC = "data/test_drive"
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env):
+        env.check_acme()
+        env.clear_store()
+
+    # test case: register a new account, vary length to check base64 encoding
+    @pytest.mark.parametrize("contact", [
+        "x@not-forbidden.org", "xx@not-forbidden.org", "xxx@not-forbidden.org"
+    ])
+    def test_md_202_000(self, env, contact):
+        r = env.a2md(["-t", "accepted", "acme", "newreg", contact], raw=True)
+        assert r.exit_code == 0, r
+        m = re.match("registered: (.*)$", r.stdout)
+        assert m, "did not match: {0}".format(r.stdout)
+        acct = m.group(1)
+        print("newreg: %s" % m.group(1))
+        self._check_account(env, acct, ["mailto:" + contact])
+
+    # test case: register a new account without accepting ToS, must fail
+    def test_md_202_000b(self, env):
+        r = env.a2md(["acme", "newreg", "x@not-forbidden.org"], raw=True)
+        assert r.exit_code == 1
+        m = re.match(".*must agree to terms of service.*", r.stderr)
+        if m is None:
+            # the pebble variant
+            m = re.match(".*account did not agree to the terms of service.*", r.stderr)
+        assert m, "did not match: {0}".format(r.stderr)
+
+    # test case: respect 'mailto:' prefix in contact url
+    def test_md_202_001(self, env):
+        contact = "mailto:xx@not-forbidden.org"
+        r = env.a2md(["-t", "accepted", "acme", "newreg", contact], raw=True)
+        assert r.exit_code == 0
+        m = re.match("registered: (.*)$", r.stdout)
+        assert m
+        acct = m.group(1)
+        self._check_account(env, acct, [contact])
+
+    # test case: fail on invalid contact url
+    @pytest.mark.parametrize("invalid_contact", [
+        "mehlto:xxx@not-forbidden.org", "no.at.char", "with blank@test.com",
+        "missing.host@", "@missing.localpart.de",
+        "double..dot@test.com", "double@at@test.com"
+    ])
+    def test_md_202_002(self, env, invalid_contact):
+        assert env.a2md(["acme", "newreg", invalid_contact]).exit_code == 1
+
+    # test case: use contact list
+    def test_md_202_003(self, env):
+        contact = ["xx@not-forbidden.org", "aa@not-forbidden.org"]
+        r = env.a2md(["-t", "accepted", "acme", "newreg"] + contact, raw=True)
+        assert r.exit_code == 0
+        m = re.match("registered: (.*)$", r.stdout)
+        assert m
+        acct = m.group(1)
+        self._check_account(env, acct, ["mailto:" + contact[0], "mailto:" + contact[1]])
+
+    # test case: validate new account
+    def test_md_202_100(self, env):
+        acct = self._prepare_account(env, ["tmp@not-forbidden.org"])
+        assert env.a2md(["acme", "validate", acct]).exit_code == 0
+
+    # test case: fail on non-existing account
+    def test_md_202_101(self, env):
+        assert env.a2md(["acme", "validate", "ACME-localhost-1000"]).exit_code == 1
+
+    # test case: report fail on request signing problem
+    def test_md_202_102(self, env):
+        acct = self._prepare_account(env, ["tmp@not-forbidden.org"])
+        with open(env.path_account(acct)) as f:
+            acctj = json.load(f)
+        acctj['url'] = acctj['url'] + "0"
+        open(env.path_account(acct), "w").write(json.dumps(acctj))
+        assert env.a2md(["acme", "validate", acct]).exit_code == 1
+
+    # test case: register and try delete an account, will fail without persistence
+    def test_md_202_200(self, env):
+        acct = self._prepare_account(env, ["tmp@not-forbidden.org"])
+        assert env.a2md(["delreg", acct]).exit_code == 1
+
+    # test case: register and try delete an account with persistence
+    def test_md_202_201(self, env):
+        acct = self._prepare_account(env, ["tmp@not-forbidden.org"])
+        assert env.a2md(["acme", "delreg", acct]).exit_code == 0
+        # check that store is clean
+        r = env.run(["find", env.store_dir])
+        assert re.match(env.store_dir, r.stdout)
+
+    # test case: delete a persisted account without specifying url
+    def test_md_202_202(self, env):
+        acct = self._prepare_account(env, ["tmp@not-forbidden.org"])
+        assert env.run([env.a2md_bin, "-d", env.store_dir, "acme", "delreg", acct]).exit_code == 0
+
+    # test case: delete, then validate an account
+    def test_md_202_203(self, env):
+        acct = self._prepare_account(env, ["test014@not-forbidden.org"])
+        assert env.a2md(["acme", "delreg", acct]).exit_code == 0
+        # validate on deleted account fails
+        assert env.a2md(["acme", "validate", acct]).exit_code == 1
+
+    def _check_account(self, env, acct, contact):
+        with open(env.path_account(acct)) as f:
+            acctj = json.load(f)
+        assert acctj['registration']['contact'] == contact
+
+    def _prepare_account(self, env, contact):
+        r = env.a2md(["-t", "accepted", "acme", "newreg"] + contact, raw=True)
+        assert r.exit_code == 0
+        return re.match("registered: (.*)$", r.stdout).group(1)
diff --git a/test/modules/md/test_300_conf_validate.py b/test/modules/md/test_300_conf_validate.py
new file mode 100644 (file)
index 0000000..0efbb11
--- /dev/null
@@ -0,0 +1,344 @@
+# test mod_md basic configurations
+
+import re
+import time
+from datetime import datetime, timedelta
+
+import pytest
+
+from .md_conf import MDConf
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestConf:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env):
+        env.clear_store()
+
+    # test case: just one MDomain definition
+    def test_md_300_001(self, env):
+        MDConf(env, text="""
+            MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org
+            """).install()
+        assert env.apache_restart() == 0
+
+    # test case: two MDomain definitions, non-overlapping
+    def test_md_300_002(self, env):
+        MDConf(env, text="""
+            MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org
+            MDomain example2.org www.example2.org mail.example2.org
+            """).install()
+        assert env.apache_restart() == 0
+
+    # test case: two MDomain definitions, exactly the same
+    def test_md_300_003(self, env):
+        assert env.apache_stop() == 0
+        MDConf(env, text="""
+            MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org test3.not-forbidden.org
+            MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org test3.not-forbidden.org
+            """).install()
+        assert env.apache_fail() == 0
+
+    # test case: two MDomain definitions, overlapping
+    def test_md_300_004(self, env):
+        assert env.apache_stop() == 0
+        MDConf(env, text="""
+            MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org test3.not-forbidden.org
+            MDomain example2.org test3.not-forbidden.org www.example2.org mail.example2.org
+            """).install()
+        assert env.apache_fail() == 0
+
+    # test case: two MDomains, one inside a virtual host
+    def test_md_300_005(self, env):
+        MDConf(env, text="""
+            MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org test3.not-forbidden.org
+            <VirtualHost *:12346>
+                MDomain example2.org www.example2.org www.example3.org
+            </VirtualHost>
+            """).install()
+        assert env.apache_restart() == 0
+
+    # test case: two MDomains, one correct vhost name
+    def test_md_300_006(self, env):
+        MDConf(env, text="""
+            MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org test3.not-forbidden.org
+            <VirtualHost *:12346>
+                ServerName example2.org
+                MDomain example2.org www.example2.org www.example3.org
+            </VirtualHost>
+            """).install()
+        assert env.apache_restart() == 0
+
+    # test case: two MDomains, two correct vhost names
+    def test_md_300_007(self, env):
+        MDConf(env, text="""
+            MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org test3.not-forbidden.org
+            <VirtualHost *:12346>
+                ServerName example2.org
+                MDomain example2.org www.example2.org www.example3.org
+            </VirtualHost>
+            <VirtualHost *:12346>
+                ServerName www.example2.org
+            </VirtualHost>
+            """).install()
+        assert env.apache_restart() == 0
+
+    # test case: two MDomains, overlapping vhosts
+    def test_md_300_008(self, env):
+        MDConf(env, text="""
+            MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org test3.not-forbidden.org
+            <VirtualHost *:12346>
+                ServerName example2.org
+                ServerAlias www.example3.org
+                MDomain example2.org www.example2.org www.example3.org
+            </VirtualHost>
+
+            <VirtualHost *:12346>
+                ServerName www.example2.org
+                ServerAlias example2.org
+            </VirtualHost>
+            """).install()
+        assert env.apache_restart() == 0
+
+    # test case: vhosts with overlapping MDs
+    def test_md_300_009(self, env):
+        assert env.apache_stop() == 0
+        conf = MDConf(env)
+        conf.add("""
+            MDMembers manual
+            MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org test3.not-forbidden.org
+            MDomain example2.org www.example2.org www.example3.org
+            """)
+        conf.add_vhost(port=12346, domains=["example2.org", "www.example3.org"], with_ssl=True)
+        conf.add_vhost(port=12346, domains=["www.example2.org", "example2.org"], with_ssl=True)
+        conf.add_vhost(port=12346, domains=["not-forbidden.org", "example2.org"], with_ssl=True)
+        conf.install()
+        assert env.apache_fail() == 0
+        env.apache_stop()
+        env.httpd_error_log.ignore_recent()
+
+    # test case: MDomain, vhost with matching ServerAlias
+    def test_md_300_010(self, env):
+        conf = MDConf(env)
+        conf.add("""
+            MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org test3.not-forbidden.org
+
+            <VirtualHost *:12346>
+                ServerName not-forbidden.org
+                ServerAlias test3.not-forbidden.org
+            </VirtualHost>
+            """)
+        conf.install()
+        assert env.apache_restart() == 0
+
+    # test case: MDomain, misses one ServerAlias
+    def test_md_300_011a(self, env):
+        env.apache_stop()
+        conf = MDConf(env, text="""
+            MDomain not-forbidden.org manual www.not-forbidden.org mail.not-forbidden.org test3.not-forbidden.org
+        """)
+        conf.add_vhost(port=env.https_port, domains=[
+            "not-forbidden.org", "test3.not-forbidden.org", "test4.not-forbidden.org"
+        ])
+        conf.install()
+        assert env.apache_fail() == 0
+        env.apache_stop()
+
+    # test case: MDomain, misses one ServerAlias, but auto add enabled
+    def test_md_300_011b(self, env):
+        env.apache_stop()
+        MDConf(env, text="""
+            MDomain not-forbidden.org auto mail.not-forbidden.org
+
+            <VirtualHost *:%s>
+                ServerName not-forbidden.org
+                ServerAlias test3.not-forbidden.org
+                ServerAlias test4.not-forbidden.org
+            </VirtualHost>
+            """ % env.https_port).install()
+        assert env.apache_restart() == 0
+
+    # test case: MDomain does not match any vhost
+    def test_md_300_012(self, env):
+        MDConf(env, text="""
+            MDomain example012.org www.example012.org
+            <VirtualHost *:12346>
+                ServerName not-forbidden.org
+                ServerAlias test3.not-forbidden.org
+            </VirtualHost>
+            """).install()
+        assert env.apache_restart() == 0
+
+    # test case: one md covers two vhosts
+    def test_md_300_013(self, env):
+        MDConf(env, text="""
+            MDomain example2.org test-a.example2.org test-b.example2.org
+            <VirtualHost *:12346>
+                ServerName test-a.example2.org
+            </VirtualHost>
+            <VirtualHost *:12346>
+                ServerName test-b.example2.org
+            </VirtualHost>
+            """).install()
+        assert env.apache_restart() == 0
+
+    # test case: global server name as managed domain name
+    def test_md_300_014(self, env):
+        MDConf(env, text=f"""
+            MDomain www.{env.http_tld} www.example2.org
+
+            <VirtualHost *:12346>
+                ServerName www.example2.org
+            </VirtualHost>
+            """).install()
+        assert env.apache_restart() == 0
+
+    # test case: valid pkey specification
+    def test_md_300_015(self, env):
+        MDConf(env, text="""
+            MDPrivateKeys Default
+            MDPrivateKeys RSA
+            MDPrivateKeys RSA 2048
+            MDPrivateKeys RSA 3072
+            MDPrivateKeys RSA 4096
+            """).install()
+        assert env.apache_restart() == 0
+
+    # test case: invalid pkey specification
+    @pytest.mark.parametrize("line,exp_err_msg", [
+        ("MDPrivateKeys", "needs to specify the private key type"), 
+        ("MDPrivateKeys Default RSA 1024", "'Default' allows no other parameter"),
+        ("MDPrivateKeys RSA 1024", "must be 2048 or higher"),
+        ("MDPrivateKeys RSA 1024", "must be 2048 or higher"),
+        ("MDPrivateKeys rsa 2048 rsa 4096", "two keys of type 'RSA' are not possible"),
+        ("MDPrivateKeys p-256 secp384r1 P-256", "two keys of type 'P-256' are not possible"),
+        ])
+    def test_md_300_016(self, env, line, exp_err_msg):
+        MDConf(env, text=line).install()
+        assert env.apache_fail() == 0
+        assert exp_err_msg in env.apachectl_stderr
+
+    # test case: invalid renew window directive
+    @pytest.mark.parametrize("line,exp_err_msg", [
+        ("MDRenewWindow dec-31", "has unrecognized format"), 
+        ("MDRenewWindow 1y", "has unrecognized format"), 
+        ("MDRenewWindow 10 d", "takes one argument"), 
+        ("MDRenewWindow 102%", "a length of 100% or more is not allowed.")])
+    def test_md_300_017(self, env, line, exp_err_msg):
+        MDConf(env, text=line).install()
+        assert env.apache_fail() == 0
+        assert exp_err_msg in env.apachectl_stderr
+
+    # test case: invalid uri for MDProxyPass
+    @pytest.mark.parametrize("line,exp_err_msg", [
+        ("MDHttpProxy", "takes one argument"), 
+        ("MDHttpProxy localhost:8080", "scheme must be http or https"),
+        ("MDHttpProxy https://127.0.0.1:-443", "invalid port"),
+        ("MDHttpProxy HTTP localhost 8080", "takes one argument")])
+    def test_md_300_018(self, env, line, exp_err_msg):
+        MDConf(env, text=line).install()
+        assert env.apache_fail() == 0, "Server accepted test config {}".format(line)
+        assert exp_err_msg in env.apachectl_stderr
+
+    # test case: invalid parameter for MDRequireHttps
+    @pytest.mark.parametrize("line,exp_err_msg", [
+        ("MDRequireHTTPS yes", "supported parameter values are 'temporary' and 'permanent'"),
+        ("MDRequireHTTPS", "takes one argument")])
+    def test_md_300_019(self, env, line, exp_err_msg):
+        MDConf(env, text=line).install()
+        assert env.apache_fail() == 0, "Server accepted test config {}".format(line)
+        assert exp_err_msg in env.apachectl_stderr
+
+    # test case: invalid parameter for MDMustStaple
+    @pytest.mark.parametrize("line,exp_err_msg", [
+        ("MDMustStaple", "takes one argument"), 
+        ("MDMustStaple yes", "supported parameter values are 'on' and 'off'"),
+        ("MDMustStaple true", "supported parameter values are 'on' and 'off'")])
+    def test_md_300_020(self, env, line, exp_err_msg):
+        MDConf(env, text=line).install()
+        assert env.apache_fail() == 0, "Server accepted test config {}".format(line)
+        assert exp_err_msg in env.apachectl_stderr
+        env.httpd_error_log.ignore_recent()
+
+    # test case: alt-names incomplete detection, github isse #68
+    def test_md_300_021(self, env):
+        env.apache_stop()
+        conf = MDConf(env, text="""
+            MDMembers manual
+            MDomain secret.com
+            """)
+        conf.add_vhost(port=12344, domains=[
+            "not.secret.com", "secret.com"
+        ])
+        conf.install()
+        assert env.apache_fail() == 0
+        # this is unreliable on debian
+        #assert env.httpd_error_log.scan_recent(
+        #    re.compile(r'.*Virtual Host not.secret.com:0 matches Managed Domain \'secret.com\', '
+        #               'but the name/alias not.secret.com itself is not managed. A requested '
+        #               'MD certificate will not match ServerName.*'), timeout=10
+        #)
+
+    # test case: use MDRequireHttps in an <if> construct, but not in <Directory
+    def test_md_300_022(self, env):
+        MDConf(env, text="""
+            MDomain secret.com
+            <If "1 == 1">
+              MDRequireHttps temporary
+            </If>
+            <VirtualHost *:12344>
+                ServerName secret.com
+            </VirtualHost>
+            """).install()
+        assert env.apache_restart() == 0
+
+    # test case: use MDRequireHttps not in <Directory
+    def test_md_300_023(self, env):
+        conf = MDConf(env, text="""
+            MDomain secret.com
+            <Directory /tmp>
+              MDRequireHttps temporary
+            </Directory>
+            """)
+        conf.add_vhost(port=12344, domains=["secret.com"])
+        conf.install()
+        assert env.apache_fail() == 0
+
+    # test case: invalid parameter for MDCertificateAuthority
+    @pytest.mark.parametrize("ca,exp_err_msg", [
+        ("", "takes one argument"),
+        ("yes", "The CA name 'yes' is not known "),
+    ])
+    def test_md_300_024(self, env, ca, exp_err_msg):
+        conf = MDConf(env, text=f"""
+            MDCertificateAuthority {ca}
+            MDRenewMode manual  # lets not contact these in testing
+        """)
+        conf.install()
+        assert env.apache_fail() == 0
+        assert exp_err_msg in env.apachectl_stderr
+
+    # test case: valid parameter for MDCertificateAuthority
+    @pytest.mark.parametrize("ca, url", [
+        ("LetsEncrypt", "https://acme-v02.api.letsencrypt.org/directory"),
+        ("letsencrypt", "https://acme-v02.api.letsencrypt.org/directory"),
+        ("letsencrypt-test", "https://acme-staging-v02.api.letsencrypt.org/directory"),
+        ("LETSEncrypt-TESt", "https://acme-staging-v02.api.letsencrypt.org/directory"),
+        ("buypass", "https://api.buypass.com/acme/directory"),
+        ("buypass-test", "https://api.test4.buypass.no/acme/directory"),
+    ])
+    def test_md_300_025(self, env, ca, url):
+        domain = f"test1.{env.http_tld}"
+        conf = MDConf(env, text=f"""
+            MDCertificateAuthority {ca}
+            MDRenewMode manual
+        """)
+        conf.add_md([domain])
+        conf.install()
+        assert env.apache_restart() == 0, "Server did not accepted CA '{}'".format(ca)
+        md = env.get_md_status(domain)
+        assert md['ca']['url'] == url
+
diff --git a/test/modules/md/test_310_conf_store.py b/test/modules/md/test_310_conf_store.py
new file mode 100644 (file)
index 0000000..69a086e
--- /dev/null
@@ -0,0 +1,850 @@
+# test mod_md basic configurations
+import time
+
+import pytest
+import os
+
+from .md_conf import MDConf
+from .md_env import MDTestEnv
+
+SEC_PER_DAY = 24 * 60 * 60
+MS_PER_DAY = SEC_PER_DAY * 1000
+NS_PER_DAY = MS_PER_DAY * 1000
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestConf:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        acme.start(config='default')
+        env.check_acme()
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.clear_store()
+        self.test_domain = env.get_request_domain(request)
+
+    # test case: no md definitions in config
+    def test_md_310_001(self, env):
+        MDConf(env, text="").install()
+        assert env.apache_restart() == 0
+        r = env.a2md(["list"])
+        assert 0 == len(r.json["output"])
+
+    # test case: add md definitions on empty store
+    @pytest.mark.parametrize("confline,dns_lists,md_count", [
+        ("MDomain testdomain.org www.testdomain.org mail.testdomain.org", 
+            [["testdomain.org", "www.testdomain.org", "mail.testdomain.org"]], 1),
+        ("""MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            MDomain testdomain2.org www.testdomain2.org mail.testdomain2.org""", 
+            [["testdomain.org", "www.testdomain.org", "mail.testdomain.org"],
+             ["testdomain2.org", "www.testdomain2.org", "mail.testdomain2.org"]], 2)
+    ])
+    def test_md_310_100(self, env, confline, dns_lists, md_count):
+        MDConf(env, text=confline).install()
+        assert env.apache_restart() == 0
+        for i in range(0, len(dns_lists)):
+            env.check_md(dns_lists[i], state=1)
+
+    # test case: add managed domains as separate steps
+    def test_md_310_101(self, env):
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        env.check_md(["testdomain.org", "www.testdomain.org", "mail.testdomain.org"], state=1)
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            MDomain testdomain2.org www.testdomain2.org mail.testdomain2.org
+            """).install()
+        assert env.apache_restart() == 0
+        env.check_md(["testdomain.org", "www.testdomain.org", "mail.testdomain.org"], state=1)
+        env.check_md(["testdomain2.org", "www.testdomain2.org", "mail.testdomain2.org"], state=1)
+
+    # test case: add dns to existing md
+    def test_md_310_102(self, env):
+        assert env.a2md(["add", "testdomain.org", "www.testdomain.org"]).exit_code == 0
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        env.check_md(["testdomain.org", "www.testdomain.org", "mail.testdomain.org"], state=1)
+
+    # test case: add new md definition with acme url, acme protocol, acme agreement
+    def test_md_310_103(self, env):
+        MDConf(env, text="""
+            MDCertificateAuthority http://acme.test.org:4000/directory
+            MDCertificateProtocol ACME
+            MDCertificateAgreement http://acme.test.org:4000/terms/v1
+
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """, local_ca=False).install()
+        assert env.apache_restart() == 0
+        name = "testdomain.org"
+        env.check_md([name, "www.testdomain.org", "mail.testdomain.org"], state=1,
+                     ca="http://acme.test.org:4000/directory", protocol="ACME",
+                     agreement="http://acme.test.org:4000/terms/v1")
+
+    # test case: add to existing md: acme url, acme protocol
+    def test_md_310_104(self, env):
+        name = "testdomain.org"
+        MDConf(env, local_ca=False, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        env.check_md([name, "www.testdomain.org", "mail.testdomain.org"], state=1,
+                     ca="https://acme-v02.api.letsencrypt.org/directory", protocol="ACME")
+        MDConf(env, local_ca=False, text="""
+            MDCertificateAuthority http://acme.test.org:4000/directory
+            MDCertificateProtocol ACME
+            MDCertificateAgreement http://acme.test.org:4000/terms/v1
+
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        env.check_md([name, "www.testdomain.org", "mail.testdomain.org"], state=1,
+                     ca="http://acme.test.org:4000/directory", protocol="ACME",
+                     agreement="http://acme.test.org:4000/terms/v1")
+
+    # test case: add new md definition with server admin
+    def test_md_310_105(self, env):
+        MDConf(env, admin="admin@testdomain.org", text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        name = "testdomain.org"
+        env.check_md([name, "www.testdomain.org", "mail.testdomain.org"], state=1,
+                     contacts=["mailto:admin@testdomain.org"])
+
+    # test case: add to existing md: server admin
+    def test_md_310_106(self, env):
+        name = "testdomain.org"
+        assert env.a2md(["add", name, "www.testdomain.org", "mail.testdomain.org"]).exit_code == 0
+        MDConf(env, admin="admin@testdomain.org", text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        env.check_md([name, "www.testdomain.org", "mail.testdomain.org"], state=1,
+                     contacts=["mailto:admin@testdomain.org"])
+
+    # test case: assign separate contact info based on VirtualHost
+    def test_md_310_107(self, env):
+        MDConf(env, admin="", text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            MDomain testdomain2.org www.testdomain2.org mail.testdomain2.org
+
+            <VirtualHost *:12346>
+                ServerName testdomain.org
+                ServerAlias www.testdomain.org
+                ServerAdmin mailto:admin@testdomain.org
+            </VirtualHost>
+
+            <VirtualHost *:12346>
+                ServerName testdomain2.org
+                ServerAlias www.testdomain2.org
+                ServerAdmin mailto:admin@testdomain2.org
+            </VirtualHost>
+            """).install()
+        assert env.apache_restart() == 0
+        name1 = "testdomain.org"
+        name2 = "testdomain2.org"
+        env.check_md([name1, "www." + name1, "mail." + name1], state=1, contacts=["mailto:admin@" + name1])
+        env.check_md([name2, "www." + name2, "mail." + name2], state=1, contacts=["mailto:admin@" + name2])
+
+    # test case: normalize names - lowercase
+    def test_md_310_108(self, env):
+        MDConf(env, text="""
+            MDomain testdomain.org WWW.testdomain.org MAIL.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        env.check_md(["testdomain.org", "www.testdomain.org", "mail.testdomain.org"], state=1)
+
+    # test case: default drive mode - auto
+    def test_md_310_109(self, env):
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['renew-mode'] == 1
+
+    # test case: drive mode manual
+    def test_md_310_110(self, env):
+        MDConf(env, text="""
+            MDRenewMode manual
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['renew-mode'] == 0
+
+    # test case: drive mode auto
+    def test_md_310_111(self, env):
+        MDConf(env, text="""
+            MDRenewMode auto
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['renew-mode'] == 1
+
+    # test case: drive mode always
+    def test_md_310_112(self, env):
+        MDConf(env, text="""
+            MDRenewMode always
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['renew-mode'] == 2
+
+    # test case: renew window - 14 days
+    def test_md_310_113a(self, env):
+        MDConf(env, text="""
+            MDRenewWindow 14d
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['renew-window'] == '14d'
+
+    # test case: renew window - 10 percent
+    def test_md_310_113b(self, env):
+        MDConf(env, text="""
+            MDRenewWindow 10%
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['renew-window'] == '10%'
+        
+    # test case: ca challenge type - http-01
+    def test_md_310_114(self, env):
+        MDConf(env, text="""
+            MDCAChallenges http-01
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['ca']['challenges'] == ['http-01']
+
+    # test case: ca challenge type - http-01
+    def test_md_310_115(self, env):
+        MDConf(env, text="""
+            MDCAChallenges tls-alpn-01
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['ca']['challenges'] == ['tls-alpn-01']
+
+    # test case: ca challenge type - all
+    def test_md_310_116(self, env):
+        MDConf(env, text="""
+            MDCAChallenges http-01 tls-alpn-01
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['ca']['challenges'] == ['http-01', 'tls-alpn-01']
+
+    # test case: automatically collect md names from vhost config
+    def test_md_310_117(self, env):
+        conf = MDConf(env, text="""
+            MDMember auto
+            MDomain testdomain.org
+            """)
+        conf.add_vhost(port=12346, domains=[
+            "testdomain.org", "test.testdomain.org", "mail.testdomain.org",
+        ], with_ssl=True)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['domains'] == \
+               ['testdomain.org', 'test.testdomain.org', 'mail.testdomain.org']
+
+    # add renew window to existing md
+    def test_md_310_118(self, env):
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        MDConf(env, text="""
+            MDRenewWindow 14d
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        stat = env.get_md_status("testdomain.org")
+        assert stat['renew-window'] == '14d'
+
+    # test case: set RSA key length 2048
+    def test_md_310_119(self, env):
+        MDConf(env, text="""
+            MDPrivateKeys RSA 2048
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['privkey'] == {
+            "type": "RSA",
+            "bits": 2048
+        }
+
+    # test case: set RSA key length 4096
+    def test_md_310_120(self, env):
+        MDConf(env, text="""
+            MDPrivateKeys RSA 4096
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['privkey'] == {
+            "type": "RSA",
+            "bits": 4096
+        }
+
+    # test case: require HTTPS
+    def test_md_310_121(self, env):
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            MDRequireHttps temporary
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['require-https'] == "temporary"
+
+    # test case: require OCSP stapling
+    def test_md_310_122(self, env):
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            MDMustStaple on
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['must-staple'] is True
+
+    # test case: remove managed domain from config
+    def test_md_310_200(self, env):
+        dns_list = ["testdomain.org", "www.testdomain.org", "mail.testdomain.org"]
+        env.a2md(["add"] + dns_list)
+        env.check_md(dns_list, state=1)
+        conf = MDConf(env,)
+        conf.install()
+        assert env.apache_restart() == 0
+        # check: md stays in store
+        env.check_md(dns_list, state=1)
+
+    # test case: remove alias DNS from managed domain
+    def test_md_310_201(self, env):
+        dns_list = ["testdomain.org", "test.testdomain.org", "www.testdomain.org", "mail.testdomain.org"]
+        env.a2md(["add"] + dns_list)
+        env.check_md(dns_list, state=1)
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        # check: DNS has been removed from md in store
+        env.check_md(["testdomain.org", "www.testdomain.org", "mail.testdomain.org"], state=1)
+
+    # test case: remove primary name from managed domain
+    def test_md_310_202(self, env):
+        dns_list = ["name.testdomain.org", "testdomain.org", "www.testdomain.org", "mail.testdomain.org"]
+        env.a2md(["add"] + dns_list)
+        env.check_md(dns_list, state=1)
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        # check: md overwrite previous name and changes name
+        env.check_md(["testdomain.org", "www.testdomain.org", "mail.testdomain.org"],
+                     md="testdomain.org", state=1)
+
+    # test case: remove one md, keep another
+    def test_md_310_203(self, env):
+        dns_list1 = ["greenbytes2.de", "www.greenbytes2.de", "mail.greenbytes2.de"]
+        dns_list2 = ["testdomain.org", "www.testdomain.org", "mail.testdomain.org"]
+        env.a2md(["add"] + dns_list1)
+        env.a2md(["add"] + dns_list2)
+        env.check_md(dns_list1, state=1)
+        env.check_md(dns_list2, state=1)
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        # all mds stay in store
+        env.check_md(dns_list1, state=1)
+        env.check_md(dns_list2, state=1)
+
+    # test case: remove ca info from md, should switch over to new defaults
+    def test_md_310_204(self, env):
+        name = "testdomain.org"
+        MDConf(env, local_ca=False, text="""
+            MDCertificateAuthority http://acme.test.org:4000/directory
+            MDCertificateProtocol ACME
+            MDCertificateAgreement http://acme.test.org:4000/terms/v1
+
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        # setup: sync with ca info removed
+        MDConf(env, local_ca=False, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        env.check_md([name, "www.testdomain.org", "mail.testdomain.org"], state=1,
+                     ca="https://acme-v02.api.letsencrypt.org/directory", protocol="ACME")
+
+    # test case: remove server admin from md
+    def test_md_310_205(self, env):
+        name = "testdomain.org"
+        MDConf(env, admin="admin@testdomain.org", text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        # setup: sync with admin info removed
+        MDConf(env, admin="", text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        # check: md stays the same with previous admin info
+        env.check_md([name, "www.testdomain.org", "mail.testdomain.org"], state=1,
+                     contacts=["mailto:admin@testdomain.org"])
+
+    # test case: remove renew window from conf -> fallback to default
+    def test_md_310_206(self, env):
+        MDConf(env, text="""
+            MDRenewWindow 14d
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['renew-window'] == '14d'
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        # check: renew window not set
+        assert env.a2md(["list"]).json['output'][0]['renew-window'] == '33%'
+
+    # test case: remove drive mode from conf -> fallback to default (auto)
+    @pytest.mark.parametrize("renew_mode,exp_code", [
+        ("manual", 0), 
+        ("auto", 1), 
+        ("always", 2)
+    ])
+    def test_md_310_207(self, env, renew_mode, exp_code):
+        MDConf(env, text="""
+            MDRenewMode %s
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """ % renew_mode).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['renew-mode'] == exp_code
+        #
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['renew-mode'] == 1
+
+    # test case: remove challenges from conf -> fallback to default (not set)
+    def test_md_310_208(self, env):
+        MDConf(env, text="""
+            MDCAChallenges http-01
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['ca']['challenges'] == ['http-01']
+        #
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert 'challenges' not in env.a2md(["list"]).json['output'][0]['ca']
+
+    # test case: specify RSA key
+    @pytest.mark.parametrize("key_size", ["2048", "4096"])
+    def test_md_310_209(self, env, key_size):
+        MDConf(env, text="""
+            MDPrivateKeys RSA %s
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """ % key_size).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['privkey']['type'] == "RSA"
+        #
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert "privkey" not in env.a2md(["list"]).json['output'][0]
+
+    # test case: require HTTPS
+    @pytest.mark.parametrize("mode", ["temporary", "permanent"])
+    def test_md_310_210(self, env, mode):
+        MDConf(env, text="""
+            <MDomainSet testdomain.org>
+                MDMember www.testdomain.org mail.testdomain.org
+                MDRequireHttps %s
+            </MDomainSet>
+            """ % mode).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['require-https'] == mode, \
+            "Unexpected HTTPS require mode in store. config: {}".format(mode)
+        #
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert "require-https" not in env.a2md(["list"]).json['output'][0], \
+            "HTTPS require still persisted in store. config: {}".format(mode)
+
+    # test case: require OCSP stapling
+    def test_md_310_211(self, env):
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            MDMustStaple on
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['must-staple'] is True
+        #
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['must-staple'] is False
+
+    # test case: reorder DNS names in md definition
+    def test_md_310_300(self, env):
+        dns_list = ["testdomain.org", "mail.testdomain.org", "www.testdomain.org"]
+        env.a2md(["add"] + dns_list)
+        env.check_md(dns_list, state=1)
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        # check: dns list changes
+        env.check_md(["testdomain.org", "www.testdomain.org", "mail.testdomain.org"], state=1)
+
+    # test case: move DNS from one md to another
+    def test_md_310_301(self, env):
+        env.a2md(["add", "testdomain.org", "www.testdomain.org", "mail.testdomain.org", "mail.testdomain2.org"])
+        env.a2md(["add", "testdomain2.org", "www.testdomain2.org"])
+        env.check_md(["testdomain.org", "www.testdomain.org",
+                      "mail.testdomain.org", "mail.testdomain2.org"], state=1)
+        env.check_md(["testdomain2.org", "www.testdomain2.org"], state=1)        
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            MDomain testdomain2.org www.testdomain2.org mail.testdomain2.org
+            """).install()
+        assert env.apache_restart() == 0
+        env.check_md(["testdomain.org", "www.testdomain.org", "mail.testdomain.org"], state=1)
+        env.check_md(["testdomain2.org", "www.testdomain2.org", "mail.testdomain2.org"], state=1)
+
+    # test case: change ca info
+    def test_md_310_302(self, env):
+        name = "testdomain.org"
+        MDConf(env, local_ca=False, text="""
+            MDCertificateAuthority http://acme.test.org:4000/directory
+            MDCertificateProtocol ACME
+            MDCertificateAgreement http://acme.test.org:4000/terms/v1
+
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        # setup: sync with changed ca info
+        MDConf(env, local_ca=False, admin="webmaster@testdomain.org",
+                  text="""
+            MDCertificateAuthority http://somewhere.com:6666/directory
+            MDCertificateProtocol ACME
+            MDCertificateAgreement http://somewhere.com:6666/terms/v1
+
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        # check: md stays the same with previous ca info
+        env.check_md([name, "www.testdomain.org", "mail.testdomain.org"], state=1,
+                     ca="http://somewhere.com:6666/directory", protocol="ACME",
+                     agreement="http://somewhere.com:6666/terms/v1")
+
+    # test case: change server admin
+    def test_md_310_303(self, env):
+        name = "testdomain.org"
+        MDConf(env, admin="admin@testdomain.org", text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        # setup: sync with changed admin info
+        MDConf(env, local_ca=False, admin="webmaster@testdomain.org", text="""
+            MDCertificateAuthority http://somewhere.com:6666/directory
+            MDCertificateProtocol ACME
+            MDCertificateAgreement http://somewhere.com:6666/terms/v1
+
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        # check: md stays the same with previous admin info
+        env.check_md([name, "www.testdomain.org", "mail.testdomain.org"], state=1,
+                     contacts=["mailto:webmaster@testdomain.org"])
+
+    # test case: change drive mode - manual -> auto -> always
+    def test_md_310_304(self, env):
+        MDConf(env, text="""
+            MDRenewMode manual
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['renew-mode'] == 0
+        # test case: drive mode auto
+        MDConf(env, text="""
+            MDRenewMode auto
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['renew-mode'] == 1
+        # test case: drive mode always
+        MDConf(env, text="""
+            MDRenewMode always
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['renew-mode'] == 2
+
+    # test case: change config value for renew window, use various syntax alternatives
+    def test_md_310_305(self, env):
+        MDConf(env, text="""
+            MDRenewWindow 14d
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        md = env.a2md(["list"]).json['output'][0]
+        assert md['renew-window'] == '14d'
+        MDConf(env, text="""
+            MDRenewWindow 10
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        md = env.a2md(["list"]).json['output'][0]
+        assert md['renew-window'] == '10d'
+        MDConf(env, text="""
+            MDRenewWindow 10%
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        md = env.a2md(["list"]).json['output'][0]
+        assert md['renew-window'] == '10%'
+
+    # test case: change challenge types - http -> tls-sni -> all
+    def test_md_310_306(self, env):
+        MDConf(env, text="""
+            MDCAChallenges http-01
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['ca']['challenges'] == ['http-01']
+        # test case: drive mode auto
+        MDConf(env, text="""
+            MDCAChallenges tls-alpn-01
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['ca']['challenges'] == ['tls-alpn-01']
+        # test case: drive mode always
+        MDConf(env, text="""
+            MDCAChallenges http-01 tls-alpn-01
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['ca']['challenges'] == ['http-01', 'tls-alpn-01']
+
+    # test case:  RSA key length: 4096 -> 2048 -> 4096
+    def test_md_310_307(self, env):
+        MDConf(env, text="""
+            MDPrivateKeys RSA 4096
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['privkey'] == {
+            "type": "RSA",
+            "bits": 4096
+        }
+        MDConf(env, text="""
+            MDPrivateKeys RSA 2048
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['privkey'] == {
+            "type": "RSA",
+            "bits": 2048
+        }
+        MDConf(env, text="""
+            MDPrivateKeys RSA 4096
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['privkey'] == {
+            "type": "RSA",
+            "bits": 4096
+        }
+
+    # test case: change HTTPS require settings on existing md
+    def test_md_310_308(self, env):
+        # setup: nothing set
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert "require-https" not in env.a2md(["list"]).json['output'][0]
+        # test case: temporary redirect
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            MDRequireHttps temporary
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['require-https'] == "temporary"
+        # test case: permanent redirect
+        MDConf(env, text="""
+            <MDomainSet testdomain.org>
+                MDMember www.testdomain.org mail.testdomain.org
+                MDRequireHttps permanent
+            </MDomainSet>
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['require-https'] == "permanent"
+
+    # test case: change OCSP stapling settings on existing md
+    def test_md_310_309(self, env):
+        # setup: nothing set
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['must-staple'] is False
+        # test case: OCSP stapling on
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            MDMustStaple on
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['must-staple'] is True
+        # test case: OCSP stapling off
+        MDConf(env, text="""
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            MDMustStaple off
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'][0]['must-staple'] is False
+
+    # test case: change renew window parameter
+    @pytest.mark.parametrize("window", [
+        "0%", "33d", "40%"
+    ])
+    def test_md_310_310(self, env, window):
+        # non-default renewal setting
+        domain = self.test_domain
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.start_md([domain])
+        conf.add_drive_mode("manual")
+        conf.add_renew_window(window)
+        conf.end_md()
+        conf.add_vhost(domains=domain)
+        conf.install()
+        assert env.apache_restart() == 0
+        stat = env.get_md_status(domain)
+        assert stat["renew-window"] == window
+
+    # test case: add dns name on existing valid md
+    def test_md_310_400(self, env):
+        # setup: create complete md in store
+        domain = self.test_domain
+        name = "www." + domain
+        assert env.a2md(["add", name, "test1." + domain]).exit_code == 0
+        assert env.a2md(["update", name, "contacts", "admin@" + name]).exit_code == 0
+        assert env.a2md(["update", name, "agreement", env.acme_tos]).exit_code == 0
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+        # setup: drive it
+        r = env.a2md(["-v", "drive", name])
+        assert r.exit_code == 0, "drive not successfull: {0}".format(r.stderr)
+        assert env.a2md(["list", name]).json['output'][0]['state'] == env.MD_S_COMPLETE
+
+        # remove one domain -> status stays COMPLETE
+        assert env.a2md(["update", name, "domains", name]).exit_code == 0
+        assert env.a2md(["list", name]).json['output'][0]['state'] == env.MD_S_COMPLETE
+        
+        # add other domain -> status INCOMPLETE
+        assert env.a2md(["update", name, "domains", name, "test2." + domain]).exit_code == 0
+        assert env.a2md(["list", name]).json['output'][0]['state'] == env.MD_S_INCOMPLETE
+
+    # test case: change ca info
+    def test_md_310_401(self, env):
+        # setup: create complete md in store
+        domain = self.test_domain
+        name = "www." + domain
+        assert env.a2md(["add", name]).exit_code == 0
+        assert env.a2md(["update", name, "contacts", "admin@" + name]).exit_code == 0
+        assert env.a2md(["update", name, "agreement", env.acme_tos]).exit_code == 0
+        assert env.apache_restart() == 0
+        # setup: drive it
+        assert env.a2md(["drive", name]).exit_code == 0
+        assert env.a2md(["list", name]).json['output'][0]['state'] == env.MD_S_COMPLETE
+        # setup: change CA URL
+        assert env.a2md(["update", name, "ca", env.acme_url]).exit_code == 0
+        # check: state stays COMPLETE
+        assert env.a2md(["list", name]).json['output'][0]['state'] == env.MD_S_COMPLETE
+
+    # test case: change the store dir
+    def test_md_310_500(self, env):
+        MDConf(env, text="""
+            MDStoreDir md-other
+            MDomain testdomain.org www.testdomain.org mail.testdomain.org
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list"]).json['output'] == []
+        env.set_store_dir("md-other")
+        env.check_md(["testdomain.org", "www.testdomain.org", "mail.testdomain.org"], state=1)
+        env.clear_store()
+        env.set_store_dir_default()
+
+    # test case: place an unexpected file into the store, check startup survival, see #218
+    def test_md_310_501(self, env):
+        # setup: create complete md in store
+        domain = self.test_domain
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.start_md([domain])
+        conf.end_md()
+        conf.add_vhost(domains=[domain])
+        conf.install()
+        assert env.apache_restart() == 0
+        # add a file at top level
+        assert env.await_completion([domain])
+        fpath = os.path.join(env.store_domains(), "wrong.com")
+        with open(fpath, 'w') as fd:
+            fd.write("this does not belong here\n")
+        assert env.apache_restart() == 0
+
+    # test case: add external account binding
+    def test_md_310_601(self, env):
+        domain = self.test_domain
+        # directly set
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.start_md([domain])
+        conf.add_drive_mode("manual")
+        conf.add("MDExternalAccountBinding k123 hash123")
+        conf.end_md()
+        conf.add_vhost(domains=domain)
+        conf.install()
+        assert env.apache_restart() == 0
+        stat = env.get_md_status(domain)
+        assert stat["eab"] == {'kid': 'k123', 'hmac': '***'}
+        # eab inherited
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("MDExternalAccountBinding k456 hash456")
+        conf.start_md([domain])
+        conf.add_drive_mode("manual")
+        conf.end_md()
+        conf.add_vhost(domains=domain)
+        conf.install()
+        assert env.apache_restart() == 0
+        stat = env.get_md_status(domain)
+        assert stat["eab"] == {'kid': 'k456', 'hmac': '***'}
+        # override eab inherited
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("MDExternalAccountBinding k456 hash456")
+        conf.start_md([domain])
+        conf.add_drive_mode("manual")
+        conf.add("MDExternalAccountBinding none")
+        conf.end_md()
+        conf.add_vhost(domains=domain)
+        conf.install()
+        assert env.apache_restart() == 0
+        stat = env.get_md_status(domain)
+        assert "eab" not in stat
+
diff --git a/test/modules/md/test_502_acmev2_drive.py b/test/modules/md/test_502_acmev2_drive.py
new file mode 100644 (file)
index 0000000..eb754f2
--- /dev/null
@@ -0,0 +1,549 @@
+# test driving the ACMEv2 protocol
+
+import base64
+import json
+import os.path
+import re
+import time
+
+import pytest
+
+from .md_conf import MDConf, MDConf
+from .md_cert_util import MDCertUtil
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestDrivev2:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        acme.start(config='default')
+        env.check_acme()
+        env.APACHE_CONF_SRC = "data/test_drive"
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.clear_store()
+        MDConf(env).install()
+        self.test_domain = env.get_request_domain(request)
+
+    # --------- invalid precondition ---------
+
+    def test_md_502_000(self, env):
+        # test case: md without contact info
+        domain = self.test_domain
+        name = "www." + domain
+        assert env.a2md(["add", name]).exit_code == 0
+        r = env.a2md(["drive", name])
+        assert r.exit_code == 1
+        assert re.search("No contact information", r.stderr)
+
+    def test_md_502_001(self, env):
+        # test case: md with contact, but without TOS
+        domain = self.test_domain
+        name = "www." + domain
+        assert env.a2md(["add", name]).exit_code == 0
+        assert env.a2md( 
+            ["update", name, "contacts", "admin@test1.not-forbidden.org"]
+            ).exit_code == 0
+        r = env.a2md(["drive", name])
+        assert r.exit_code == 1
+        assert re.search("the CA requires you to accept the terms-of-service as specified in ", r.stderr)
+
+    # test_102 removed, was based on false assumption
+    def test_md_502_003(self, env):
+        # test case: md with unknown protocol FOO
+        domain = self.test_domain
+        name = "www." + domain
+        self._prepare_md(env, [name])
+        assert env.a2md(
+            ["update", name, "ca", env.acme_url, "FOO"]
+            ).exit_code == 0
+        r = env.a2md(["drive", name])
+        assert r.exit_code == 1
+        assert re.search("Unknown CA protocol", r.stderr)
+
+    # --------- driving OK ---------
+
+    def test_md_502_100(self, env):
+        # test case: md with one domain
+        domain = self.test_domain
+        name = "www." + domain
+        self._prepare_md(env, [name])
+        assert env.apache_restart() == 0
+        # drive
+        prev_md = env.a2md(["list", name]).json['output'][0]
+        r = env.a2md(["-vv", "drive", "-c", "http-01", name])
+        assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr)
+        env.check_md_credentials([name])
+        self._check_account_key(env, name)
+
+        # check archive content
+        store_md = json.loads(open(env.store_archived_file(name, 1, 'md.json')).read())
+        for f in ['name', 'ca', 'domains', 'contacts', 'renew-mode', 'renew-window', 'must-staple']:
+            assert store_md[f] == prev_md[f]
+        
+        # check file system permissions:
+        env.check_file_permissions(name)
+        # check: challenges removed
+        env.check_dir_empty(env.store_challenges())
+        # check how the challenge resources are answered in sevceral combinations 
+        r = env.get_meta(domain, "/.well-known/acme-challenge", False)
+        assert r.exit_code == 0
+        assert r.response['status'] == 404
+        r = env.get_meta(domain, "/.well-known/acme-challenge/", False)
+        assert r.exit_code == 0
+        assert r.response['status'] == 404
+        r = env.get_meta(domain, "/.well-known/acme-challenge/123", False)
+        assert r.exit_code == 0
+        assert r.response['status'] == 404
+        assert r.exit_code == 0
+        cdir = os.path.join(env.store_challenges(), domain)
+        os.makedirs(cdir)
+        open(os.path.join(cdir, 'acme-http-01.txt'), "w").write("content-of-123")
+        r = env.get_meta(domain, "/.well-known/acme-challenge/123", False)
+        assert r.exit_code == 0
+        assert r.response['status'] == 200
+        assert r.response['header']['content-length'] == '14'
+
+    def test_md_502_101(self, env):
+        # test case: md with 2 domains
+        domain = self.test_domain
+        name = "www." + domain
+        self._prepare_md(env, [name, "test." + domain])
+        assert env.apache_restart() == 0
+        # drive
+        r = env.a2md(["-vv", "drive", "-c", "http-01", name])
+        assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr)
+        env.check_md_credentials([name, "test." + domain])
+
+    # test_502_102 removed, as accounts without ToS are not allowed in ACMEv2
+
+    def test_md_502_103(self, env):
+        # test case: md with one domain, ACME account and TOS agreement on server
+        # setup: create md
+        domain = self.test_domain
+        name = "www." + domain
+        assert env.a2md(["add", name]).exit_code == 0
+        assert env.a2md(["update", name, "contacts", "admin@" + domain]).exit_code == 0
+        assert env.apache_restart() == 0
+        # setup: create account on server
+        r = env.a2md(["-t", "accepted", "acme", "newreg", "admin@" + domain], raw=True)
+        assert r.exit_code == 0
+        acct = re.match("registered: (.*)$", r.stdout).group(1)
+        # setup: link md to account
+        assert env.a2md(["update", name, "account", acct]).exit_code == 0
+        # drive
+        r = env.a2md(["-vv", "drive", name])
+        assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr)
+        env.check_md_credentials([name])
+
+    # test_502_104 removed, order are created differently in ACMEv2
+
+    def test_md_502_105(self, env):
+        # test case: md with one domain, local TOS agreement and ACME account that is deleted (!) on server
+        # setup: create md
+        domain = self.test_domain
+        name = "www." + domain
+        self._prepare_md(env, [name])
+        assert env.apache_restart() == 0
+        # setup: create account on server
+        r = env.a2md(["-t", "accepted", "acme", "newreg", "test@" + domain], raw=True)
+        assert r.exit_code == 0
+        acct = re.match("registered: (.*)$", r.stdout).group(1)
+        # setup: link md to account
+        assert env.a2md(["update", name, "account", acct]).exit_code == 0
+        # setup: delete account on server
+        assert env.a2md(["acme", "delreg", acct]).exit_code == 0
+        # drive
+        r = env.a2md(["drive", name])
+        assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr)
+        env.check_md_credentials([name])
+
+    def test_md_502_107(self, env):
+        # test case: drive again on COMPLETE md, then drive --force
+        # setup: prepare md in store
+        domain = self.test_domain
+        name = "www." + domain
+        self._prepare_md(env, [name])
+        assert env.apache_restart() == 0
+        # drive
+        r = env.a2md(["-vv", "drive", name])
+        assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr)
+        env.check_md_credentials([name])
+        orig_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
+
+        # drive again
+        assert env.a2md(["-vv", "drive", name]).exit_code == 0
+        env.check_md_credentials([name])
+        cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
+        # check: cert not changed
+        assert cert.same_serial_as(orig_cert)
+
+        # drive --force
+        assert env.a2md(["-vv", "drive", "--force", name]).exit_code == 0
+        env.check_md_credentials([name])
+        cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
+        # check: cert not changed
+        assert not cert.same_serial_as(orig_cert)
+        # check: previous cert was archived
+        cert = MDCertUtil(env.store_archived_file(name, 2, 'pubcert.pem'))
+        assert cert.same_serial_as(orig_cert)
+
+    def test_md_502_108(self, env):
+        # test case: drive via HTTP proxy
+        domain = self.test_domain
+        name = "www." + domain
+        self._prepare_md(env, [name])
+        conf = MDConf(env, proxy=True)
+        conf.add('LogLevel proxy:trace8')
+        conf.install()
+        assert env.apache_restart() == 0
+
+        # drive it, with wrong proxy url -> FAIL
+        r = env.a2md(["-p", "http://localhost:1", "drive", name])
+        assert r.exit_code == 1
+        assert "Connection refused" in r.stderr
+
+        # drive it, working proxy url -> SUCCESS
+        proxy_url = f"http://localhost:{env.proxy_port}"
+        r = env.a2md(["-vv", "-p", proxy_url, "drive", name])
+        assert 0 == r.exit_code, "a2md failed: {0}".format(r.stderr)
+        env.check_md_credentials([name])
+
+    def test_md_502_109(self, env):
+        # test case: redirect on SSL-only domain
+        # setup: prepare config
+        domain = self.test_domain
+        name = "www." + domain
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_drive_mode("manual")
+        conf.add_md([name])
+        conf.add_vhost(name, port=env.http_port, doc_root="htdocs/test")
+        conf.add_vhost(name, doc_root="htdocs/test")
+        conf.install()
+        # setup: create resource files
+        self._write_res_file(os.path.join(env.server_docs_dir, "test"), "name.txt", name)
+        self._write_res_file(os.path.join(env.server_docs_dir), "name.txt", "not-forbidden.org")
+        assert env.apache_restart() == 0
+
+        # drive it
+        assert env.a2md(["drive", name]).exit_code == 0
+        assert env.apache_restart() == 0
+        # test HTTP access - no redirect
+        jdata = env.get_json_content(f"test1.{env.http_tld}", "/alive.json", use_https=False)
+        assert jdata['host']== "test1"
+        assert env.get_content(name, "/name.txt", use_https=False) == name
+        r = env.get_meta(name, "/name.txt", use_https=False)
+        assert int(r.response['header']['content-length']) == len(name)
+        assert "Location" not in r.response['header']
+        # test HTTPS access
+        assert env.get_content(name, "/name.txt", use_https=True) == name
+
+        # test HTTP access again -> redirect to default HTTPS port
+        conf.add("MDRequireHttps temporary")
+        conf.install()
+        assert env.apache_restart() == 0
+        r = env.get_meta(name, "/name.txt", use_https=False)
+        assert r.response['status'] == 302
+        exp_location = "https://%s/name.txt" % name
+        assert r.response['header']['location'] == exp_location
+        # should not see this
+        assert 'strict-transport-security' not in r.response['header']
+        # test default HTTP vhost -> still no redirect
+        jdata = env.get_json_content(f"test1.{env.http_tld}", "/alive.json", use_https=False)
+        assert jdata['host']== "test1"
+        r = env.get_meta(name, "/name.txt", use_https=True)
+        # also not for this
+        assert 'strict-transport-security' not in r.response['header']
+
+        # test HTTP access again -> redirect permanent
+        conf.add("MDRequireHttps permanent")
+        conf.install()
+        assert env.apache_restart() == 0
+        r = env.get_meta(name, "/name.txt", use_https=False)
+        assert r.response['status'] == 301
+        exp_location = "https://%s/name.txt" % name
+        assert r.response['header']['location'] == exp_location
+        assert 'strict-transport-security' not in r.response['header']
+        # should see this
+        r = env.get_meta(name, "/name.txt", use_https=True)
+        assert r.response['header']['strict-transport-security'] == 'max-age=15768000'
+
+    def test_md_502_110(self, env):
+        # test case: SSL-only domain, override headers generated by mod_md 
+        # setup: prepare config
+        domain = self.test_domain
+        name = "www." + domain
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_drive_mode("manual")
+        conf.add("MDRequireHttps permanent")
+        conf.add_md([name])
+        conf.add_vhost(name, port=env.http_port)
+        conf.add_vhost(name)
+        conf.install()
+        assert env.apache_restart() == 0
+        # drive it
+        assert env.a2md(["drive", name]).exit_code == 0
+        assert env.apache_restart() == 0
+
+        # test override HSTS header
+        conf.add('Header set Strict-Transport-Security "max-age=10886400; includeSubDomains; preload"')
+        conf.install()
+        assert env.apache_restart() == 0
+        r = env.get_meta(name, "/name.txt", use_https=True)
+        assert 'strict-transport-security' in r.response['header'], r.response['header']
+        assert r.response['header']['strict-transport-security'] == \
+               'max-age=10886400; includeSubDomains; preload'
+
+        # test override Location header
+        conf.add('  Redirect /a /name.txt')
+        conf.add('  Redirect seeother /b /name.txt')
+        conf.install()
+        assert env.apache_restart() == 0
+        # check: default redirect by mod_md still works
+        exp_location = "https://%s/name.txt" % name
+        r = env.get_meta(name, "/name.txt", use_https=False)
+        assert r.response['status'] == 301
+        assert r.response['header']['location'] == exp_location
+        # check: redirect as given by mod_alias
+        exp_location = "https://%s/a" % name
+        r = env.get_meta(name, "/a", use_https=False)
+        assert r.response['status'] == 301    # FAIL: mod_alias generates Location header instead of mod_md
+        assert r.response['header']['location'] == exp_location
+
+    def test_md_502_111(self, env):
+        # test case: vhost with parallel HTTP/HTTPS, check mod_alias redirects
+        # setup: prepare config
+        domain = self.test_domain
+        name = "www." + domain
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_drive_mode("manual")
+        conf.add_md([name])
+        conf.add("  LogLevel alias:debug")
+        conf.add_vhost(name, port=env.http_port)
+        conf.add_vhost(name)
+        conf.install()
+        assert env.apache_restart() == 0
+        # drive it
+        r = env.a2md(["-v", "drive", name])
+        assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr)
+        assert env.apache_restart() == 0
+
+        # setup: place redirect rules
+        conf.add('  Redirect /a /name.txt')
+        conf.add('  Redirect seeother /b /name.txt')
+        conf.install()
+        assert env.apache_restart() == 0
+        # check: redirects on HTTP
+        exp_location = "http://%s:%s/name.txt" % (name, env.http_port)
+        r = env.get_meta(name, "/a", use_https=False)
+        assert r.response['status'] == 302
+        assert r.response['header']['location'] == exp_location
+        r = env.get_meta(name, "/b", use_https=False)
+        assert r.response['status'] == 303
+        assert r.response['header']['location'] == exp_location
+        # check: redirects on HTTPS
+        exp_location = "https://%s:%s/name.txt" % (name, env.https_port)
+        r = env.get_meta(name, "/a", use_https=True)
+        assert r.response['status'] == 302
+        assert r.response['header']['location'] == exp_location     # FAIL: expected 'https://...' but found 'http://...'
+        r = env.get_meta(name, "/b", use_https=True)
+        assert r.response['status'] == 303
+        assert r.response['header']['location'] == exp_location
+
+    def test_md_502_120(self, env):
+        # test case: NP dereference reported by Daniel Caminada <daniel.caminada@ergon.ch>
+        domain = self.test_domain
+        name = "www." + domain
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_drive_mode("manual")
+        conf.add_md([name])
+        conf.add_vhost(name)
+        conf.install()
+        assert env.apache_restart() == 0
+        env.run(["openssl", "s_client",
+                 f"-connect", "localhost:{env.https_port}",
+                 "-servername", "example.com", "-crlf"
+                 ], intext="GET https:// HTTP/1.1\nHost: example.com\n\n")
+        assert env.apache_restart() == 0
+
+    # --------- critical state change -> drive again ---------
+
+    def test_md_502_200(self, env):
+        # test case: add dns name on existing valid md
+        # setup: create md in store
+        domain = self.test_domain
+        name = "www." + domain
+        self._prepare_md(env, [name])
+        assert env.apache_restart() == 0
+        # setup: drive it
+        r = env.a2md(["drive", name])
+        assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr)
+        old_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
+        # setup: add second domain
+        assert env.a2md(["update", name, "domains", name, "test." + domain]).exit_code == 0
+        # drive
+        r = env.a2md(["-vv", "drive", name])
+        assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr)
+        # check new cert
+        env.check_md_credentials([name, "test." + domain])
+        new_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
+        assert not old_cert.same_serial_as(new_cert.get_serial)
+
+    @pytest.mark.parametrize("renew_window,test_data_list", [
+        ("14d", [
+            {"valid": {"notBefore": -5,   "notAfter": 180}, "renew": False},
+            {"valid": {"notBefore": -200, "notAfter": 15}, "renew": False},
+            {"valid": {"notBefore": -200, "notAfter": 13}, "renew": True},
+        ]),
+        ("30%", [
+            {"valid": {"notBefore": -0,   "notAfter": 180}, "renew": False},
+            {"valid": {"notBefore": -120, "notAfter": 60}, "renew": False},
+            {"valid": {"notBefore": -126, "notAfter": 53}, "renew": True},
+        ])
+    ])
+    def test_md_502_201(self, env, renew_window, test_data_list):
+        # test case: trigger cert renew when entering renew window 
+        # setup: prepare COMPLETE md
+        domain = self.test_domain
+        name = "www." + domain
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_drive_mode("manual")
+        conf.add_renew_window(renew_window)
+        conf.add_md([name])
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list", name]).json['output'][0]['state'] == env.MD_S_INCOMPLETE
+        # setup: drive it
+        r = env.a2md(["drive", name])
+        assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr)
+        cert1 = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
+        assert env.a2md(["list", name]).json['output'][0]['state'] == env.MD_S_COMPLETE
+
+        # replace cert by self-signed one -> check md status
+        print("TRACE: start testing renew window: %s" % renew_window)
+        for tc in test_data_list:
+            print("TRACE: create self-signed cert: %s" % tc["valid"])
+            env.create_self_signed_cert([name], tc["valid"])
+            cert2 = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
+            assert not cert2.same_serial_as(cert1)
+            md = env.a2md(["list", name]).json['output'][0]
+            assert md["renew"] == tc["renew"], \
+                "Expected renew == {} indicator in {}, test case {}".format(tc["renew"], md, tc)
+
+    @pytest.mark.parametrize("key_type,key_params,exp_key_length", [
+        ("RSA", [2048], 2048),
+        ("RSA", [3072], 3072),
+        ("RSA", [4096], 4096),
+        ("Default", [], 2048)
+    ])
+    def test_md_502_202(self, env, key_type, key_params, exp_key_length):
+        # test case: specify RSA key length and verify resulting cert key 
+        # setup: prepare md
+        domain = self.test_domain
+        name = "www." + domain
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_drive_mode("manual")
+        conf.add_private_key(key_type, key_params)
+        conf.add_md([name])
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.a2md(["list", name]).json['output'][0]['state'] == env.MD_S_INCOMPLETE
+        # setup: drive it
+        r = env.a2md(["-vv", "drive", name])
+        assert r.exit_code == 0, "drive for MDPrivateKeys {} {}: {}".format(key_type, key_params, r.stderr)
+        assert env.a2md(["list", name]).json['output'][0]['state'] == env.MD_S_COMPLETE
+        # check cert key length
+        cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
+        assert cert.get_key_length() == exp_key_length
+
+    # test_502_203 removed, as ToS agreement is not really checked in ACMEv2
+
+    # --------- non-critical state change -> keep data ---------
+
+    def test_md_502_300(self, env):
+        # test case: remove one domain name from existing valid md
+        # setup: create md in store
+        domain = self.test_domain
+        name = "www." + domain
+        self._prepare_md(env, [name, "test." + domain, "xxx." + domain])
+        assert env.apache_restart() == 0
+        # setup: drive it
+        r = env.a2md(["drive", name])
+        assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr)
+        old_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
+        # setup: remove one domain
+        assert env.a2md(["update", name, "domains"] + [name, "test." + domain]).exit_code == 0
+        # drive
+        assert env.a2md(["-vv", "drive", name]).exit_code == 0
+        # compare cert serial
+        new_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
+        assert old_cert.same_serial_as(new_cert)
+
+    def test_md_502_301(self, env):
+        # test case: change contact info on existing valid md
+        # setup: create md in store
+        domain = self.test_domain
+        name = "www." + domain
+        self._prepare_md(env, [name])
+        assert env.apache_restart() == 0
+        # setup: drive it
+        r = env.a2md(["drive", name])
+        assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr)
+        old_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
+        # setup: add second domain
+        assert env.a2md(["update", name, "contacts", "test@" + domain]).exit_code == 0
+        # drive
+        assert env.a2md(["drive", name]).exit_code == 0
+        # compare cert serial
+        new_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
+        assert old_cert.same_serial_as(new_cert)
+
+    # --------- network problems ---------
+
+    def test_md_502_400(self, env):
+        # test case: server not reachable
+        domain = self.test_domain
+        name = "www." + domain
+        self._prepare_md(env, [name])
+        assert env.a2md(
+            ["update", name, "ca", "http://localhost:4711/directory"]
+            ).exit_code == 0
+        # drive
+        r = env.a2md(["drive", name])
+        assert r.exit_code == 1
+        assert r.json['status'] != 0
+        assert r.json['description'] == 'Connection refused'
+
+    # --------- _utils_ ---------
+
+    def _prepare_md(self, env, domains):
+        assert env.a2md(["add"] + domains).exit_code == 0
+        assert env.a2md(
+            ["update", domains[0], "contacts", "admin@" + domains[0]]
+            ).exit_code == 0
+        assert env.a2md( 
+            ["update", domains[0], "agreement", env.acme_tos]
+            ).exit_code == 0
+
+    def _write_res_file(self, doc_root, name, content):
+        if not os.path.exists(doc_root):
+            os.makedirs(doc_root)
+        open(os.path.join(doc_root, name), "w").write(content)
+
+    RE_MSG_OPENSSL_BAD_DECRYPT = re.compile('.*\'bad decrypt\'.*')
+
+    def _check_account_key(self, env, name):
+        # read encryption key
+        md_store = json.loads(open(env.path_store_json(), 'r').read())
+        encrypt_key = base64.urlsafe_b64decode(str(md_store['key']))
+        # check: key file is encrypted PEM
+        md = env.a2md(["list", name]).json['output'][0]
+        acc = md['ca']['account']
+        MDCertUtil.validate_privkey(env.path_account_key(acc), lambda *args: encrypt_key)
diff --git a/test/modules/md/test_602_roundtrip.py b/test/modules/md/test_602_roundtrip.py
new file mode 100644 (file)
index 0000000..9ff87e5
--- /dev/null
@@ -0,0 +1,143 @@
+# test mod_md basic configurations
+
+import os
+
+import pytest
+
+from .md_conf import MDConf
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestRoundtripv2:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        acme.start(config='default')
+        env.APACHE_CONF_SRC = "data/test_roundtrip"
+        env.clear_store()
+        MDConf(env).install()
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.check_acme()
+        self.test_domain = env.get_request_domain(request)
+
+    # --------- add to store ---------
+
+    def test_md_602_000(self, env):
+        # test case: generate config with md -> restart -> drive -> generate config
+        # with vhost and ssl -> restart -> check HTTPS access
+        domain = self.test_domain
+        domains = [domain, "www." + domain]
+
+        # - generate config with one md
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_drive_mode("manual")
+        conf.add_md(domains)
+        conf.install()
+        # - restart, check that md is in store
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        # - drive
+        assert env.a2md(["-v", "drive", domain]).exit_code == 0
+        assert env.apache_restart() == 0
+        env.check_md_complete(domain)
+        # - append vhost to config
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        # check: SSL is running OK
+        cert = env.get_cert(domain)
+        assert domain in cert.get_san_list()
+
+        # check file system permissions:
+        env.check_file_permissions(domain)
+
+    def test_md_602_001(self, env):
+        # test case: same as test_600_000, but with two parallel managed domains
+        domain_a = "a-" + self.test_domain
+        domain_b = "b-" + self.test_domain
+        # - generate config with one md
+        domains_a = [domain_a, "www." + domain_a]
+        domains_b = [domain_b, "www." + domain_b]
+
+        conf = MDConf(env)
+        conf.add_drive_mode("manual")
+        conf.add_md(domains_a)
+        conf.add_md(domains_b)
+        conf.install()
+
+        # - restart, check that md is in store
+        assert env.apache_restart() == 0
+        env.check_md(domains_a)
+        env.check_md(domains_b)
+
+        # - drive
+        assert env.a2md(["drive", domain_a]).exit_code == 0
+        assert env.a2md(["drive", domain_b]).exit_code == 0
+        assert env.apache_restart() == 0
+        env.check_md_complete(domain_a)
+        env.check_md_complete(domain_b)
+
+        # - append vhost to config
+        conf.add_vhost(domains_a)
+        conf.add_vhost(domains_b)
+        conf.install()
+
+        # check: SSL is running OK
+        assert env.apache_restart() == 0
+        cert_a = env.get_cert(domain_a)
+        assert domains_a == cert_a.get_san_list()
+        cert_b = env.get_cert(domain_b)
+        assert domains_b == cert_b.get_san_list()
+
+    def test_md_602_002(self, env):
+        # test case: one md, that covers two vhosts
+        domain = self.test_domain
+        name_a = "a." + domain
+        name_b = "b." + domain
+        domains = [domain, name_a, name_b]
+
+        # - generate config with one md
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_drive_mode("manual")
+        conf.add_md(domains)
+        conf.install()
+        
+        # - restart, check that md is in store
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+
+        # - drive
+        assert env.a2md(["drive", domain]).exit_code == 0
+        assert env.apache_restart() == 0
+        env.check_md_complete(domain)
+
+        # - append vhost to config
+        conf.add_vhost(name_a, doc_root="htdocs/a")
+        conf.add_vhost(name_b, doc_root="htdocs/b")
+        conf.install()
+        
+        # - create docRoot folder
+        self._write_res_file(os.path.join(env.server_docs_dir, "a"), "name.txt", name_a)
+        self._write_res_file(os.path.join(env.server_docs_dir, "b"), "name.txt", name_b)
+
+        # check: SSL is running OK
+        assert env.apache_restart() == 0
+        cert_a = env.get_cert(name_a)
+        assert name_a in cert_a.get_san_list()
+        cert_b = env.get_cert(name_b)
+        assert name_b in cert_b.get_san_list()
+        assert cert_a.same_serial_as(cert_b)
+        assert env.get_content(name_a, "/name.txt") == name_a
+        assert env.get_content(name_b, "/name.txt") == name_b
+
+    # --------- _utils_ ---------
+
+    def _write_res_file(self, doc_root, name, content):
+        if not os.path.exists(doc_root):
+            os.makedirs(doc_root)
+        open(os.path.join(doc_root, name), "w").write(content)
diff --git a/test/modules/md/test_702_auto.py b/test/modules/md/test_702_auto.py
new file mode 100644 (file)
index 0000000..6864b0d
--- /dev/null
@@ -0,0 +1,753 @@
+import os
+import pytest
+
+from pyhttpd.conf import HttpdConf
+from pyhttpd.env import HttpdTestEnv
+from .md_cert_util import MDCertUtil
+from .md_env import MDTestEnv
+from .md_conf import MDConf
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestAutov2:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        env.APACHE_CONF_SRC = "data/test_auto"
+        acme.start(config='default')
+        env.check_acme()
+        env.clear_store()
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.clear_store()
+        self.test_domain = env.get_request_domain(request)
+
+    def _write_res_file(self, doc_root, name, content):
+        if not os.path.exists(doc_root):
+            os.makedirs(doc_root)
+        open(os.path.join(doc_root, name), "w").write(content)
+
+    # create a MD not used in any virtual host, auto drive should NOT pick it up
+    def test_md_702_001(self, env):
+        domain = self.test_domain
+        # generate config with one MD
+        domains = [domain, "www." + domain]
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_drive_mode("auto")
+        conf.add_md(domains)
+        conf.install()
+        #
+        # restart, check that MD is synched to store
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        stat = env.get_md_status(domain)
+        assert stat["watched"] == 0
+        #
+        # add vhost for MD, restart should drive it
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain])
+        env.check_md_complete(domain)
+        stat = env.get_md_status(domain)
+        assert stat["watched"] == 1
+        cert = env.get_cert(domain)
+        assert domain in cert.get_san_list()
+        #
+        # challenges should have been removed
+        # file system needs to have correct permissions
+        env.check_dir_empty(env.store_challenges())
+        env.check_file_permissions(domain)
+
+    # test case: same as test_702_001, but with two parallel managed domains
+    def test_md_702_002(self, env):
+        domain = self.test_domain
+        domain_a = "a-" + domain
+        domain_b = "b-" + domain
+        #        
+        # generate config with two MDs
+        domains_a = [domain_a, "www." + domain_a]
+        domains_b = [domain_b, "www." + domain_b]
+        conf = MDConf(env)
+        conf.add_drive_mode("auto")
+        conf.add_md(domains_a)
+        conf.add_md(domains_b)
+        conf.add_vhost(domains_a)
+        conf.add_vhost(domains_b)
+        conf.install()
+        #
+        # restart, check that md is in store
+        assert env.apache_restart() == 0
+        env.check_md(domains_a)
+        env.check_md(domains_b)
+        #
+        # await drive completion, do not restart
+        assert env.await_completion([domain_a, domain_b], restart=False)
+        # staged certificates are now visible on the status resources
+        status = env.get_md_status(domain_a)
+        assert 'renewal' in status
+        assert 'cert' in status['renewal']
+        assert 'rsa' in status['renewal']['cert']
+        assert 'sha256-fingerprint' in status['renewal']['cert']['rsa']
+        # check the non-staged status
+        assert status['state'] == 1
+        assert status['state-descr'] == "certificate(rsa) is missing"
+
+        # restart and activate
+        assert env.apache_restart() == 0
+        # check: SSL is running OK
+        cert_a = env.get_cert(domain_a)
+        assert domains_a == cert_a.get_san_list()
+        cert_b = env.get_cert(domain_b)
+        assert domains_b == cert_b.get_san_list()
+        # check that we created only one account
+        md_a = env.get_md_status(domain_a)
+        md_b = env.get_md_status(domain_b)
+        assert md_a['ca'] == md_b['ca']
+
+    # test case: one MD, that covers two vhosts
+    def test_md_702_003(self, env):
+        domain = self.test_domain
+        name_a = "test-a." + domain
+        name_b = "test-b." + domain
+        domains = [domain, name_a, name_b]
+        #
+        # generate 1 MD and 2 vhosts
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_md(domains)
+        conf.add_vhost(name_a, doc_root="htdocs/a")
+        conf.add_vhost(name_b, doc_root="htdocs/b")
+        conf.install()
+        #
+        # create docRoot folder
+        self._write_res_file(os.path.join(env.server_docs_dir, "a"), "name.txt", name_a)
+        self._write_res_file(os.path.join(env.server_docs_dir, "b"), "name.txt", name_b)
+        #
+        # restart (-> drive), check that MD was synched and completes
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        assert env.await_completion([domain])
+        env.check_md_complete(domain)
+        #
+        # check: SSL is running OK
+        cert_a = env.get_cert(name_a)
+        assert name_a in cert_a.get_san_list()
+        cert_b = env.get_cert(name_b)
+        assert name_b in cert_b.get_san_list()
+        assert cert_a.same_serial_as(cert_b)
+        #
+        assert env.get_content(name_a, "/name.txt") == name_a
+        assert env.get_content(name_b, "/name.txt") == name_b
+
+    # test case: drive with using single challenge type explicitly
+    @pytest.mark.parametrize("challenge_type", [
+        "tls-alpn-01", "http-01",
+    ])
+    def test_md_702_004(self, env, challenge_type):
+        domain = self.test_domain
+        domains = [domain, "www." + domain]
+        #
+        # generate 1 MD and 1 vhost
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("Protocols http/1.1 acme-tls/1")
+        conf.add_drive_mode("auto")
+        conf.add(f"MDCAChallenges {challenge_type}")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        #
+        # restart (-> drive), check that MD was synched and completes
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        assert env.await_completion([domain])
+        env.check_md_complete(domain)
+        #        
+        # check SSL running OK
+        cert = env.get_cert(domain)
+        assert domain in cert.get_san_list()
+
+    # test case: drive_mode manual, check that server starts, but requests to domain are 503'd
+    def test_md_702_005(self, env):
+        domain = self.test_domain
+        name_a = "test-a." + domain
+        domains = [domain, name_a]
+        #
+        # generate 1 MD and 1 vhost
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_drive_mode("manual")
+        conf.add_md(domains)
+        conf.add_vhost(name_a, doc_root="htdocs/a")
+        conf.install()
+        #
+        # create docRoot folder
+        self._write_res_file(os.path.join(env.server_docs_dir, "a"), "name.txt", name_a)
+        #
+        # restart, check that md is in store
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        #        
+        # check: that request to domains give 503 Service Unavailable
+        cert1 = env.get_cert(name_a)
+        assert name_a in cert1.get_san_list()
+        assert env.get_http_status(name_a, "/name.txt") == 503
+        #
+        # check temporary cert from server
+        cert2 = MDCertUtil(env.path_fallback_cert(domain))
+        assert cert1.same_serial_as(cert2), \
+            "Unexpected temporary certificate on vhost %s. Expected cn: %s , "\
+            "but found cn: %s" % (name_a, cert2.get_cn(), cert1.get_cn())
+
+    # test case: drive MD with only invalid challenges, domains should stay 503'd
+    def test_md_702_006(self, env):
+        domain = self.test_domain
+        name_a = "test-a." + domain
+        domains = [domain, name_a]
+        #
+        # generate 1 MD, 1 vhost
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("MDCAChallenges invalid-01 invalid-02")
+        conf.add_md(domains)
+        conf.add_vhost(name_a, doc_root="htdocs/a")
+        conf.install()
+        #
+        # create docRoot folder
+        self._write_res_file(os.path.join(env.server_docs_dir, "a"), "name.txt", name_a)
+        #
+        # restart, check that md is in store
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        # await drive completion
+        md = env.await_error(domain)
+        assert md
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'challenge-mismatch'
+        assert 'account' not in md['ca']
+        #
+        # check: that request to domains give 503 Service Unavailable
+        cert = env.get_cert(name_a)
+        assert name_a in cert.get_san_list()
+        assert env.get_http_status(name_a, "/name.txt") == 503
+
+    # Specify a non-working http proxy
+    def test_md_702_008(self, env):
+        domain = self.test_domain
+        domains = [domain]
+        #
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_drive_mode("always")
+        conf.add("MDHttpProxy http://localhost:1")
+        conf.add_md(domains)
+        conf.install()
+        #
+        # - restart (-> drive)
+        assert env.apache_restart() == 0
+        # await drive completion
+        md = env.await_error(domain)
+        assert md
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['status-description'] == 'Connection refused'
+        assert 'account' not in md['ca']
+
+    # Specify a valid http proxy
+    def test_md_702_008a(self, env):
+        domain = self.test_domain
+        domains = [domain]
+        #
+        conf = MDConf(env, admin=f"admin@{domain}", proxy=True)
+        conf.add_drive_mode("always")
+        conf.add(f"MDHttpProxy http://localhost:{env.proxy_port}")
+        conf.add_md(domains)
+        conf.install()
+        #
+        # - restart (-> drive), check that md is in store
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain])
+        assert env.apache_restart() == 0
+        env.check_md_complete(domain)
+
+    # Force cert renewal due to critical remaining valid duration
+    # Assert that new cert activation is delayed
+    def test_md_702_009(self, env):
+        domain = self.test_domain
+        domains = [domain]
+        #
+        # prepare md
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_drive_mode("auto")
+        conf.add_renew_window("10d")
+        conf.add_md(domains)
+        conf.add_vhost(domain)
+        conf.install()
+        #
+        # restart (-> drive), check that md+cert is in store, TLS is up
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain])
+        env.check_md_complete(domain)
+        cert1 = MDCertUtil(env.store_domain_file(domain, 'pubcert.pem'))
+        # compare with what md reports as status
+        stat = env.get_certificate_status(domain)
+        assert cert1.same_serial_as(stat['rsa']['serial'])
+        #
+        # create self-signed cert, with critical remaining valid duration -> drive again
+        env.create_self_signed_cert([domain], {"notBefore": -120, "notAfter": 2}, serial=7029)
+        cert3 = MDCertUtil(env.store_domain_file(domain, 'pubcert.pem'))
+        assert cert3.same_serial_as('1B75')
+        assert env.apache_restart() == 0
+        stat = env.get_certificate_status(domain)
+        assert cert3.same_serial_as(stat['rsa']['serial'])
+        #
+        # cert should renew and be different afterwards
+        assert env.await_completion([domain], must_renew=True)
+        stat = env.get_certificate_status(domain)
+        assert not cert3.same_serial_as(stat['rsa']['serial'])
+        
+    # test case: drive with an unsupported challenge due to port availability 
+    def test_md_702_010(self, env):
+        domain = self.test_domain
+        domains = [domain, "www." + domain]
+        #
+        # generate 1 MD and 1 vhost, map port 80 to where the server does not listen
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_drive_mode("auto")
+        conf.add("MDPortMap 80:99")        
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        md = env.await_error(domain)
+        assert md["renewal"]["errors"] > 0
+        #
+        # now the same with a 80 mapped to a supported port 
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_drive_mode("auto")
+        conf.add("MDCAChallenges http-01")
+        conf.add("MDPortMap 80:%s" % env.http_port)
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        assert env.await_completion([domain])
+
+    def test_md_702_011(self, env):
+        domain = self.test_domain
+        domains = [domain, "www." + domain]
+        #
+        # generate 1 MD and 1 vhost, map port 443 to where the server does not listen
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("Protocols http/1.1 acme-tls/1")
+        conf.add_drive_mode("auto")
+        conf.add("MDPortMap https:99 http:99")        
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        md = env.await_error(domain)
+        assert md["renewal"]["errors"] > 0
+        #
+        # now the same with a 443 mapped to a supported port 
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("Protocols http/1.1 acme-tls/1")
+        conf.add_drive_mode("auto")
+        conf.add("MDCAChallenges tls-alpn-01")
+        conf.add("MDPortMap https:%s" % env.https_port)
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        assert env.await_completion([domain])
+
+    # test case: one MD with several dns names. sign up. remove the *first* name
+    # in the MD. restart. should find and keep the existing MD.
+    # See: https://github.com/icing/mod_md/issues/68
+    def test_md_702_030(self, env):
+        domain = self.test_domain
+        name_x = "test-x." + domain
+        name_a = "test-a." + domain
+        name_b = "test-b." + domain
+        domains = [name_x, name_a, name_b]
+        #
+        # generate 1 MD and 2 vhosts
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_md(domains)
+        conf.add_vhost(name_a)
+        conf.add_vhost(name_b)
+        conf.install()
+        #
+        # restart (-> drive), check that MD was synched and completes
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        assert env.await_completion([name_x])
+        env.check_md_complete(name_x)
+        #
+        # check: SSL is running OK
+        cert_a = env.get_cert(name_a)
+        assert name_a in cert_a.get_san_list()
+        cert_b = env.get_cert(name_b)
+        assert name_b in cert_b.get_san_list()
+        assert cert_a.same_serial_as(cert_b)
+        #        
+        # change MD by removing 1st name
+        new_list = [name_a, name_b]
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_md(new_list)
+        conf.add_vhost(name_a)
+        conf.add_vhost(name_b)
+        conf.install()
+        # restart, check that host still works and kept the cert
+        assert env.apache_restart() == 0
+        env.check_md(new_list)
+        status = env.get_certificate_status(name_a)
+        assert cert_a.same_serial_as(status['rsa']['serial'])
+
+    # test case: Same as 7030, but remove *and* add another at the same time.
+    # restart. should find and keep the existing MD and renew for additional name.
+    # See: https://github.com/icing/mod_md/issues/68
+    def test_md_702_031(self, env):
+        domain = self.test_domain
+        name_x = "test-x." + domain
+        name_a = "test-a." + domain
+        name_b = "test-b." + domain
+        name_c = "test-c." + domain
+        domains = [name_x, name_a, name_b]
+        #
+        # generate 1 MD and 2 vhosts
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_md(domains)
+        conf.add_vhost(name_a)
+        conf.add_vhost(name_b)
+        conf.install()
+        #
+        # restart (-> drive), check that MD was synched and completes
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        assert env.await_completion([name_x])
+        env.check_md_complete(name_x)
+        #
+        # check: SSL is running OK
+        cert_a = env.get_cert(name_a)
+        assert name_a in cert_a.get_san_list()
+        cert_b = env.get_cert(name_b)
+        assert name_b in cert_b.get_san_list()
+        assert cert_a.same_serial_as(cert_b)
+        #        
+        # change MD by removing 1st name and adding another
+        new_list = [name_a, name_b, name_c]
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add_md(new_list)
+        conf.add_vhost(name_a)
+        conf.add_vhost(name_b)
+        conf.install()
+        # restart, check that host still works and have new cert
+        assert env.apache_restart() == 0
+        env.check_md(new_list)
+        assert env.await_completion([name_a])
+        #
+        cert_a2 = env.get_cert(name_a)
+        assert name_a in cert_a2.get_san_list()
+        assert not cert_a.same_serial_as(cert_a2)
+
+    # test case: create two MDs, move them into one
+    # see: <https://bz.apache.org/bugzilla/show_bug.cgi?id=62572>
+    def test_md_702_032(self, env):
+        domain = self.test_domain
+        name1 = "server1." + domain
+        name2 = "server2.b" + domain  # need a separate TLD to avoid rate limites
+        #
+        # generate 2 MDs and 2 vhosts
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("MDMembers auto")
+        conf.add_md([name1])
+        conf.add_md([name2])
+        conf.add_vhost(name1)
+        conf.add_vhost(name2)
+        conf.install()
+        #
+        # restart (-> drive), check that MD was synched and completes
+        assert env.apache_restart() == 0
+        env.check_md([name1])
+        env.check_md([name2])
+        assert env.await_completion([name1, name2])
+        env.check_md_complete(name2)
+        #
+        # check: SSL is running OK
+        cert1 = env.get_cert(name1)
+        assert name1 in cert1.get_san_list()
+        cert2 = env.get_cert(name2)
+        assert name2 in cert2.get_san_list()
+        #        
+        # remove second md and vhost, add name2 to vhost1
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("MDMembers auto")
+        conf.add_md([name1])
+        conf.add_vhost([name1, name2])
+        conf.install()
+        assert env.apache_restart() == 0
+        env.check_md([name1, name2])
+        assert env.await_completion([name1])
+        #
+        cert1b = env.get_cert(name1)
+        assert name1 in cert1b.get_san_list()
+        assert name2 in cert1b.get_san_list()
+        assert not cert1.same_serial_as(cert1b)
+
+    # test case: test "tls-alpn-01" challenge handling
+    def test_md_702_040(self, env):
+        domain = self.test_domain
+        domains = [domain, "www." + domain]
+        #
+        # generate 1 MD and 1 vhost
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("LogLevel core:debug")
+        conf.add("Protocols http/1.1 acme-tls/1")
+        conf.add_drive_mode("auto")
+        conf.add("MDCAChallenges tls-alpn-01")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        #
+        # restart (-> drive), check that MD was synched and completes
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        # check that acme-tls/1 is available for all domains
+        stat = env.get_md_status(domain)
+        assert stat["proto"]["acme-tls/1"] == domains
+        assert env.await_completion([domain])
+        env.check_md_complete(domain)
+        #        
+        # check SSL running OK
+        cert = env.get_cert(domain)
+        assert domain in cert.get_san_list()
+
+    # test case: test "tls-alpn-01" without enabling 'acme-tls/1' challenge protocol
+    def test_md_702_041(self, env):
+        domain = self.test_domain
+        domains = [domain, "www." + domain]
+        #
+        # generate 1 MD and 1 vhost
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("LogLevel core:debug")
+        conf.add_drive_mode("auto")
+        conf.add("MDCAChallenges tls-alpn-01")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        #
+        # restart (-> drive), check that MD job shows errors 
+        # and that missing proto is detected
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        # check that acme-tls/1 is available for none of the domains
+        stat = env.get_md_status(domain)
+        assert stat["proto"]["acme-tls/1"] == []
+
+    # test case: 2.4.40 mod_ssl stumbles over a SSLCertificateChainFile when installing
+    # a fallback certificate
+    @pytest.mark.skipif(HttpdTestEnv.get_ssl_module() != "mod_ssl", reason="only for mod_ssl")
+    def test_md_702_042(self, env):
+        domain = self.test_domain
+        dns_list = [domain]
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("LogLevel core:debug")
+        cred = env.get_credentials_for_name(f"test1.{env.http_tld}")[0]
+        conf.add(f"SSLCertificateChainFile {cred.cert_file}")
+        conf.add_drive_mode("auto")
+        conf.add_md(dns_list)
+        conf.add_vhost(dns_list)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain])
+
+    # test case: test "tls-alpn-01" without enabling 'acme-tls/1' challenge protocol
+    # and fallback "http-01" configured, see https://github.com/icing/mod_md/issues/255
+    def test_md_702_043(self, env):
+        domain = self.test_domain
+        domains = [domain, "www." + domain]
+        #
+        # generate 1 MD and 1 vhost
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("LogLevel core:debug")
+        conf.add_drive_mode("auto")
+        conf.add("MDPortMap 80:%s" % env.http_port)
+        conf.add("MDCAChallenges tls-alpn-01 http-01")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        #
+        # restart (-> drive), check that MD job shows errors
+        # and that missing proto is detected
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        # check that acme-tls/1 is available for none of the domains
+        stat = env.get_md_status(domain)
+        assert stat["proto"]["acme-tls/1"] == []
+        # but make sure it completes nevertheless
+        assert env.await_completion([domain])
+
+    # test case: drive with using single challenge type explicitly
+    # and make sure that dns names not mapped to a VirtualHost also work
+    @pytest.mark.parametrize("challenge_type", [
+        "tls-alpn-01"  # , "http-01",
+    ])
+    def test_md_702_044(self, env, challenge_type):
+        domain = self.test_domain
+        md_domains = [domain, "mail." + domain]
+        domains = [domain]
+        #
+        # generate 1 MD and 1 vhost
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("Protocols http/1.1 acme-tls/1")
+        conf.add_drive_mode("auto")
+        conf.add(f"MDCAChallenges {challenge_type}")
+        conf.add_md(md_domains)
+        conf.add_vhost(domains)
+        conf.install()
+        #
+        # restart (-> drive), check that MD was synched and completes
+        assert env.apache_restart() == 0
+        env.check_md(md_domains)
+        assert env.await_completion([domain])
+        env.check_md_complete(domain)
+        #
+        # check SSL running OK
+        cert = env.get_cert(domain)
+        assert md_domains[0] in cert.get_san_list()
+        assert md_domains[1] in cert.get_san_list()
+
+    # Make a setup using the base server. It will use http-01 challenge.
+    def test_md_702_050(self, env):
+        domain = self.test_domain
+        conf = MDConf(env, admin=f"admin@{domain}")
+        conf.add(f"""
+            MDBaseServer on
+            ServerName {domain}
+            """)
+        conf.add_md([domain])
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain])
+
+    # Make a setup using the base server without http:, will fail.
+    def test_md_702_051(self, env):
+        domain = self.test_domain
+        conf = MDConf(env, admin=f"admin@{domain}")
+        conf.add(f"""
+            MDBaseServer on
+            MDPortMap http:-
+            ServerName {domain}
+            """)
+        conf.add_md([domain])
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_error(domain)
+
+    # Make a setup using the base server without http:, but with acme-tls/1, should work.
+    def test_md_702_052(self, env):
+        domain = self.test_domain
+        conf = MDConf(env, std_vhosts=False, admin=f"admin@{domain}")
+        conf.add([
+            "MDBaseServer on",
+            "MDPortMap http:-",
+            "Protocols h2 http/1.1 acme-tls/1",
+            f"ServerName {domain}",
+            "<IfModule ssl_module>",
+            "  SSLEngine on",
+            "</IfModule>",
+            "<IfModule tls_module>",
+            f"  TLSEngine {env.https_port}",
+            "</IfModule>",
+            ])
+        conf.add_md([domain])
+        conf.install()
+        assert env.apache_restart() == 0
+        stat = env.get_md_status(domain, via_domain=env.http_addr, use_https=False)
+        assert stat["proto"]["acme-tls/1"] == [domain]
+        assert env.await_completion([domain], via_domain=env.http_addr, use_https=False)
+
+    # Test a domain name longer than 64 chars, but components < 64, see #227
+    # Background: DNS has an official limit of 253 ASCII chars and components must be
+    # of length [1, 63].
+    # However the CN in a certificate is restricted too, see
+    # <https://github.com/letsencrypt/boulder/issues/2093>.
+    @pytest.mark.skipif(MDTestEnv.is_pebble(), reason="pebble differs here from boulder")
+    @pytest.mark.parametrize("challenge_type", [
+        "tls-alpn-01", "http-01"
+    ])
+    def test_md_702_060(self, env, challenge_type):
+        domain = self.test_domain
+        # use only too long names, this is expected to fail:
+        # see <https://github.com/jetstack/cert-manager/issues/1462>
+        long_domain = ("x" * (65 - len(domain))) + domain
+        domains = [long_domain, "www." + long_domain]
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("Protocols http/1.1 acme-tls/1")
+        conf.add_drive_mode("auto")
+        conf.add(f"MDCAChallenges {challenge_type}")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        assert env.await_error(long_domain)
+        # add a short domain to the SAN list, the CA should now use that one
+        # and issue a cert.
+        long_domain = ("y" * (65 - len(domain))) + domain
+        domains = [long_domain, "www." + long_domain, "xxx." + domain]
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add("Protocols http/1.1 acme-tls/1")
+        conf.add_drive_mode("auto")
+        conf.add(f"MDCAChallenges {challenge_type}")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([long_domain])
+        env.check_md_complete(long_domain)
+        #
+        # check SSL running OK
+        cert = env.get_cert(long_domain)
+        assert long_domain in cert.get_san_list()
+
+    # test case: fourth level domain
+    def test_md_702_070(self, env):
+        domain = self.test_domain
+        name_a = "one.test." + domain
+        name_b = "two.test." + domain
+        domains = [name_a, name_b]
+        #
+        # generate 1 MD and 2 vhosts
+        conf = MDConf(env)
+        conf.add_admin("admin@" + domain)
+        conf.add_md(domains)
+        conf.add_vhost(name_a)
+        conf.install()
+        #
+        # restart (-> drive), check that MD was synched and completes
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        env.check_md_complete(domains[0])
+
+    # test case: fifth level domain
+    def test_md_702_071(self, env):
+        domain = self.test_domain
+        name_a = "one.more.test." + domain
+        name_b = "two.more.test." + domain
+        domains = [name_a, name_b]
+        #
+        # generate 1 MD and 2 vhosts
+        conf = MDConf(env)
+        conf.add_admin("admin@" + domain)
+        conf.add_md(domains)
+        conf.add_vhost(name_a)
+        conf.install()
+        #
+        # restart (-> drive), check that MD was synched and completes
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        env.check_md_complete(domains[0])
+
diff --git a/test/modules/md/test_720_wildcard.py b/test/modules/md/test_720_wildcard.py
new file mode 100644 (file)
index 0000000..c440a0c
--- /dev/null
@@ -0,0 +1,233 @@
+# test wildcard certifcates
+import os
+
+import pytest
+
+from .md_conf import MDConf, MDConf
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestWildcard:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        env.APACHE_CONF_SRC = "data/test_auto"
+        acme.start(config='default')
+        env.check_acme()
+        env.clear_store()
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.clear_store()
+        self.test_domain = env.get_request_domain(request)
+
+    # -----------------------------------------------------------------------------------------------
+    # test case: a wildcard certificate with ACMEv2, no dns-01 supported
+    #
+    def test_md_720_001(self, env):
+        domain = self.test_domain
+        
+        # generate config with DNS wildcard
+        domains = [domain, "*." + domain]
+        conf = MDConf(env)
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+
+        # restart, check that md is in store
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        # await drive completion
+        md = env.await_error(domain)
+        assert md
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'challenge-mismatch'
+
+    # -----------------------------------------------------------------------------------------------
+    # test case: a wildcard certificate with ACMEv2, only dns-01 configured, invalid command path 
+    #
+    def test_md_720_002(self, env):
+        dns01cmd = os.path.join(env.test_dir, "../modules/md/dns01-not-found.py")
+
+        domain = self.test_domain
+        domains = [domain, "*." + domain]
+        
+        conf = MDConf(env)
+        conf.add("MDCAChallenges dns-01")
+        conf.add(f"MDChallengeDns01 {dns01cmd}")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+
+        # restart, check that md is in store
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        # await drive completion
+        md = env.await_error(domain)
+        assert md
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'challenge-setup-failure'
+
+    # variation, invalid cmd path, other challenges still get certificate for non-wildcard
+    def test_md_720_002b(self, env):
+        dns01cmd = os.path.join(env.test_dir, "../modules/md/dns01-not-found.py")
+        domain = self.test_domain
+        domains = [domain, "xxx." + domain]
+        
+        conf = MDConf(env)
+        conf.add(f"MDChallengeDns01 {dns01cmd}")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+
+        # restart, check that md is in store
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        # await drive completion
+        assert env.await_completion([domain])
+        env.check_md_complete(domain)
+        # check: SSL is running OK
+        cert_a = env.get_cert(domain)
+        altnames = cert_a.get_san_list()
+        for domain in domains:
+            assert domain in altnames
+
+    # -----------------------------------------------------------------------------------------------
+    # test case: a wildcard certificate with ACMEv2, only dns-01 configured, invalid command option 
+    #
+    def test_md_720_003(self, env):
+        dns01cmd = os.path.join(env.test_dir, "../modules/md/dns01.py fail")
+        domain = self.test_domain
+        domains = [domain, "*." + domain]
+        
+        conf = MDConf(env)
+        conf.add("MDCAChallenges dns-01")
+        conf.add(f"MDChallengeDns01 {dns01cmd}")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+
+        # restart, check that md is in store
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        # await drive completion
+        md = env.await_error(domain)
+        assert md
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'challenge-setup-failure'
+
+    # -----------------------------------------------------------------------------------------------
+    # test case: a wildcard name certificate with ACMEv2, only dns-01 configured 
+    #
+    def test_md_720_004(self, env):
+        dns01cmd = os.path.join(env.test_dir, "../modules/md/dns01.py")
+        domain = self.test_domain
+        domains = [domain, "*." + domain]
+        
+        conf = MDConf(env)
+        conf.add("MDCAChallenges dns-01")
+        conf.add(f"MDChallengeDns01 {dns01cmd}")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+
+        # restart, check that md is in store
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        # await drive completion
+        assert env.await_completion([domain])
+        env.check_md_complete(domain)
+        # check: SSL is running OK
+        cert_a = env.get_cert(domain)
+        altnames = cert_a.get_san_list()
+        for domain in domains:
+            assert domain in altnames
+
+    # -----------------------------------------------------------------------------------------------
+    # test case: a wildcard name and 2nd normal vhost, not overlapping
+    #
+    def test_md_720_005(self, env):
+        dns01cmd = os.path.join(env.test_dir, "../modules/md/dns01.py")
+        domain = self.test_domain
+        domain2 = "www.x" + domain
+        domains = [domain, "*." + domain, domain2]
+        
+        conf = MDConf(env)
+        conf.add("MDCAChallenges dns-01")
+        conf.add(f"MDChallengeDns01 {dns01cmd}")
+        conf.add_md(domains)
+        conf.add_vhost(domain2)
+        conf.add_vhost(domains)
+        conf.install()
+
+        # restart, check that md is in store
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        # await drive completion
+        assert env.await_completion([domain])
+        env.check_md_complete(domain)
+        # check: SSL is running OK
+        cert_a = env.get_cert(domain)
+        altnames = cert_a.get_san_list()
+        for domain in domains:
+            assert domain in altnames
+
+    # -----------------------------------------------------------------------------------------------
+    # test case: a wildcard name and 2nd normal vhost, overlapping
+    def test_md_720_006(self, env):
+        dns01cmd = os.path.join(env.test_dir, "../modules/md/dns01.py")
+        domain = self.test_domain
+        dwild = "*." + domain
+        domain2 = "www." + domain
+        domains = [domain, dwild, domain2]
+        
+        conf = MDConf(env)
+        conf.add("MDCAChallenges dns-01")
+        conf.add(f"MDChallengeDns01 {dns01cmd}")
+        conf.add_md(domains)
+        conf.add_vhost(domain2)
+        conf.add_vhost([domain, dwild])
+        conf.install()
+
+        # restart, check that md is in store
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        # await drive completion
+        assert env.await_completion([domain])
+        env.check_md_complete(domain)
+        # check: SSL is running OK
+        cert_a = env.get_cert(domain)
+        altnames = cert_a.get_san_list()
+        for domain in [domain, dwild]:
+            assert domain in altnames
+
+    # -----------------------------------------------------------------------------------------------
+    # test case: a MDomain with just a wildcard, see #239
+    def test_md_720_007(self, env):
+        dns01cmd = os.path.join(env.test_dir, "../modules/md/dns01.py")
+        domain = self.test_domain
+        dwild = "*." + domain
+        wwwdomain = "www." + domain
+        domains = [dwild]
+
+        conf = MDConf(env)
+        conf.add("MDCAChallenges dns-01")
+        conf.add(f"MDChallengeDns01 {dns01cmd}")
+        conf.add_md(domains)
+        conf.add_vhost(wwwdomain)
+        conf.install()
+
+        # restart, check that md is in store
+        assert env.apache_restart() == 0
+        env.check_md(domains)
+        # await drive completion
+        assert env.await_completion([wwwdomain])
+        env.check_md_complete(dwild)
+        # check: SSL is running OK
+        cert_a = env.get_cert(wwwdomain)
+        altnames = cert_a.get_san_list()
+        assert domains == altnames
diff --git a/test/modules/md/test_730_static.py b/test/modules/md/test_730_static.py
new file mode 100644 (file)
index 0000000..f7f7b4b
--- /dev/null
@@ -0,0 +1,117 @@
+import os
+
+import pytest
+
+from .md_conf import MDConf
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestStatic:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        env.APACHE_CONF_SRC = "data/test_auto"
+        acme.start(config='default')
+        env.check_acme()
+        env.clear_store()
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.clear_store()
+        self.test_domain = env.get_request_domain(request)
+
+    def test_md_730_001(self, env):
+        # MD with static cert files, will not be driven
+        domain = self.test_domain
+        domains = [domain, 'www.%s' % domain]
+        testpath = os.path.join(env.gen_dir, 'test_920_001')
+        # cert that is only 10 more days valid
+        env.create_self_signed_cert(domains, {"notBefore": -80, "notAfter": 10},
+                                    serial=730001, path=testpath)
+        cert_file = os.path.join(testpath, 'pubcert.pem')
+        pkey_file = os.path.join(testpath, 'privkey.pem')
+        assert os.path.exists(cert_file)
+        assert os.path.exists(pkey_file)
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add(f"MDCertificateFile {cert_file}")
+        conf.add(f"MDCertificateKeyFile {pkey_file}")
+        conf.end_md()
+        conf.add_vhost(domain)
+        conf.install()
+        assert env.apache_restart() == 0
+        
+        # check if the domain uses it, it appears in our stats and renewal is off
+        cert = env.get_cert(domain)
+        assert cert.same_serial_as(730001)
+        stat = env.get_md_status(domain)
+        assert stat
+        assert 'cert' in stat
+        assert stat['renew'] is True
+        assert 'renewal' not in stat
+
+    def test_md_730_002(self, env):
+        # MD with static cert files, force driving
+        domain = self.test_domain
+        domains = [domain, 'www.%s' % domain]
+        testpath = os.path.join(env.gen_dir, 'test_920_001')
+        # cert that is only 10 more days valid
+        env.create_self_signed_cert(domains, {"notBefore": -80, "notAfter": 10},
+                                    serial=730001, path=testpath)
+        cert_file = os.path.join(testpath, 'pubcert.pem')
+        pkey_file = os.path.join(testpath, 'privkey.pem')
+        assert os.path.exists(cert_file)
+        assert os.path.exists(pkey_file)
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add(f"MDPrivateKeys secp384r1 rsa3072")
+        conf.add(f"MDCertificateFile {cert_file}")
+        conf.add(f"MDCertificateKeyFile {pkey_file}")
+        conf.add("MDRenewMode always")
+        conf.end_md()
+        conf.add_vhost(domain)
+        conf.install()
+        assert env.apache_restart() == 0
+        # this should enforce a renewal
+        stat = env.get_md_status(domain)
+        assert stat['renew'] is True, stat
+        assert env.await_completion(domains, restart=False)
+        # and show the newly created certificates
+        stat = env.get_md_status(domain)
+        assert 'renewal' in stat
+        assert 'cert' in stat['renewal']
+        assert 'secp384r1' in stat['renewal']['cert']
+        assert 'rsa' in stat['renewal']['cert']
+
+    def test_md_730_003(self, env):
+        # just configuring one file will not work
+        domain = self.test_domain
+        domains = [domain, 'www.%s' % domain]
+        testpath = os.path.join(env.gen_dir, 'test_920_001')
+        # cert that is only 10 more days valid
+        env.create_self_signed_cert(domains, {"notBefore": -80, "notAfter": 10},
+                                    serial=730001, path=testpath)
+        cert_file = os.path.join(testpath, 'pubcert.pem')
+        pkey_file = os.path.join(testpath, 'privkey.pem')
+        assert os.path.exists(cert_file)
+        assert os.path.exists(pkey_file)
+        
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add(f"MDCertificateFile {cert_file}")
+        conf.end_md()
+        conf.add_vhost(domain)
+        conf.install()
+        assert env.apache_fail() == 0
+        
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add(f"MDCertificateKeyFile {pkey_file}")
+        conf.end_md()
+        conf.add_vhost(domain)
+        conf.install()
+        assert env.apache_fail() == 0
diff --git a/test/modules/md/test_740_acme_errors.py b/test/modules/md/test_740_acme_errors.py
new file mode 100644 (file)
index 0000000..670c9ab
--- /dev/null
@@ -0,0 +1,72 @@
+# test ACME error responses and their processing
+import pytest
+
+from .md_conf import MDConf
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestAcmeErrors:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        env.APACHE_CONF_SRC = "data/test_auto"
+        acme.start(config='default')
+        env.check_acme()
+        env.clear_store()
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.clear_store()
+        self.test_domain = env.get_request_domain(request)
+
+    # -----------------------------------------------------------------------------------------------
+    # test case: MD with 2 names, one invalid
+    #
+    def test_md_740_000(self, env):
+        domain = self.test_domain
+        domains = [domain, "invalid!." + domain]
+        conf = MDConf(env)
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        md = env.await_error(domain)
+        assert md
+        assert md['renewal']['errors'] > 0
+        if env.acme_server == 'pebble':
+            assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:malformed'
+            assert md['renewal']['last']['detail'] == \
+                   "Order included DNS identifier with a value containing an illegal character: '!'"
+        else:
+            assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:rejectedIdentifier'
+            assert md['renewal']['last']['detail'] == (
+                    "Error creating new order :: Cannot issue for "
+                    "\"%s\": Domain name contains an invalid character" % domains[1])
+
+    # test case: MD with 3 names, 2 invalid
+    #
+    def test_md_740_001(self, env):
+        domain = self.test_domain
+        domains = [domain, "invalid1!." + domain, "invalid2!." + domain]
+        conf = MDConf(env)
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        md = env.await_error(domain)
+        assert md
+        assert md['renewal']['errors'] > 0
+        if env.acme_server == 'pebble':
+            assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:malformed'
+            assert md['renewal']['last']['detail'].startswith(
+                "Order included DNS identifier with a value containing an illegal character")
+        else:
+            assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:rejectedIdentifier'
+            assert md['renewal']['last']['detail'].startswith(
+                "Error creating new order :: Cannot issue for")
+            assert md['renewal']['last']['subproblems']
+            assert len(md['renewal']['last']['subproblems']) == 2
diff --git a/test/modules/md/test_741_setup_errors.py b/test/modules/md/test_741_setup_errors.py
new file mode 100644 (file)
index 0000000..49b4e78
--- /dev/null
@@ -0,0 +1,48 @@
+# test ACME error responses and their processing
+import os
+
+import pytest
+
+from .md_conf import MDConf
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestSetupErrors:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        env.APACHE_CONF_SRC = "data/test_auto"
+        acme.start(config='default')
+        env.check_acme()
+        env.clear_store()
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.clear_store()
+        self.mcmd = os.path.join(env.test_dir, "../modules/md/http_challenge_foobar.py")
+        self.test_domain = env.get_request_domain(request)
+
+    def test_md_741_001(self, env):
+        # setup an MD with a MDMessageCmd that make the http-01 challenge file invalid
+        # before the ACME server is asked to retrieve it. This will result in
+        # an "invalid" domain authorization.
+        # The certificate sign-up will be attempted again after 4 seconds and
+        # of course fail again.
+        # Verify that the error counter for the staging job increments, so
+        # that our retry logic goes into proper delayed backoff.
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDCAChallenges http-01")
+        conf.add(f"MDMessageCmd {self.mcmd} {env.store_dir}")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        md = env.await_error(domain, errors=2, timeout=10)
+        assert md
+        assert md['renewal']['errors'] > 0
diff --git a/test/modules/md/test_750_eab.py b/test/modules/md/test_750_eab.py
new file mode 100644 (file)
index 0000000..af1be95
--- /dev/null
@@ -0,0 +1,337 @@
+import json.encoder
+import os
+import re
+
+import pytest
+
+from .md_conf import MDConf
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_eab(),
+                    reason="ACME test server does not support External Account Binding")
+class TestEab:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        acme.start(config='eab')
+        env.check_acme()
+        env.clear_store()
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.clear_store()
+        self.test_domain = env.get_request_domain(request)
+
+    def test_md_750_001(self, env):
+        # md without EAB configured
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        md = env.await_error(domain)
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:externalAccountRequired'
+
+    def test_md_750_002(self, env):
+        # md with known EAB KID and non base64 hmac key configured
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDExternalAccountBinding kid-1 Ã¤Ã¶Ã¼ÃŸ")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        md = env.await_error(domain)
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'apache:eab-hmac-invalid'
+
+    def test_md_750_003(self, env):
+        # md with empty EAB KID configured
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDExternalAccountBinding \" \" bm90IGEgdmFsaWQgaG1hYwo=")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        md = env.await_error(domain)
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:unauthorized'
+
+    def test_md_750_004(self, env):
+        # md with unknown EAB KID configured
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDExternalAccountBinding key-x bm90IGEgdmFsaWQgaG1hYwo=")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        md = env.await_error(domain)
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:unauthorized'
+
+    def test_md_750_005(self, env):
+        # md with known EAB KID but wrong HMAC configured
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDExternalAccountBinding kid-1 bm90IGEgdmFsaWQgaG1hYwo=")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        md = env.await_error(domain)
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:unauthorized'
+
+    def test_md_750_010(self, env):
+        # md with correct EAB configured
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        # this is one of the values in conf/pebble-eab.json
+        conf.add("MDExternalAccountBinding kid-1 zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+
+    def test_md_750_011(self, env):
+        # first one md with EAB, then one without, works only for the first
+        # as the second is unable to reuse the account
+        domain_a = f"a{self.test_domain}"
+        domain_b = f"b{self.test_domain}"
+        conf = MDConf(env)
+        conf.start_md([domain_a])
+        conf.add("MDExternalAccountBinding kid-1 zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W")
+        conf.end_md()
+        conf.add_vhost(domains=[domain_a])
+        conf.add_md([domain_b])
+        conf.add_vhost(domains=[domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain_a], restart=False)
+        md = env.await_error(domain_b)
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:externalAccountRequired'
+
+    def test_md_750_012(self, env):
+        # first one md without EAB, then one with
+        # first one fails, second works
+        domain_a = f"a{self.test_domain}"
+        domain_b = f"b{self.test_domain}"
+        conf = MDConf(env)
+        conf.add_md([domain_a])
+        conf.add_vhost(domains=[domain_a])
+        conf.start_md([domain_b])
+        conf.add("MDExternalAccountBinding kid-1 zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W")
+        conf.end_md()
+        conf.add_vhost(domains=[domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain_b], restart=False)
+        md = env.await_error(domain_a)
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:externalAccountRequired'
+
+    def test_md_750_013(self, env):
+        # 2 mds with the same EAB, should one create a single account
+        domain_a = f"a{self.test_domain}"
+        domain_b = f"b{self.test_domain}"
+        conf = MDConf(env)
+        conf.start_md([domain_a])
+        conf.add("MDExternalAccountBinding kid-1 zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W")
+        conf.end_md()
+        conf.add_vhost(domains=[domain_a])
+        conf.start_md([domain_b])
+        conf.add("MDExternalAccountBinding kid-1 zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W")
+        conf.end_md()
+        conf.add_vhost(domains=[domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain_a, domain_b])
+        md_a = env.get_md_status(domain_a)
+        md_b = env.get_md_status(domain_b)
+        assert md_a['ca'] == md_b['ca']
+
+    def test_md_750_014(self, env):
+        # md with correct EAB, get cert, change to another correct EAB
+        # needs to create a new account
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDExternalAccountBinding kid-1 zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        md_1 = env.get_md_status(domain)
+        conf = MDConf(env)
+        # this is another one of the values in conf/pebble-eab.json
+        # add a dns name to force renewal
+        domains = [domain, f'www.{domain}']
+        conf.add("MDExternalAccountBinding kid-2 b10lLJs8l1GPIzsLP0s6pMt8O0XVGnfTaCeROxQM0BIt2XrJMDHJZBM5NuQmQJQH")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        md_2 = env.get_md_status(domain)
+        assert md_1['ca'] != md_2['ca']
+
+    def test_md_750_015(self, env):
+        # md with correct EAB, get cert, change to no EAB
+        # needs to fail
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDExternalAccountBinding kid-1 zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        conf = MDConf(env)
+        # this is another one of the values in conf/pebble-eab.json
+        # add a dns name to force renewal
+        domains = [domain, f'www.{domain}']
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_error(domain)
+        md = env.await_error(domain)
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:externalAccountRequired'
+
+    def test_md_750_016(self, env):
+        # md with correct EAB, get cert, change to invalid EAB
+        # needs to fail
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDExternalAccountBinding kid-1 zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        conf = MDConf(env)
+        # this is another one of the values in conf/pebble-eab.json
+        # add a dns name to force renewal
+        domains = [domain, f'www.{domain}']
+        conf.add("MDExternalAccountBinding kid-invalud blablabalbalbla")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_error(domain)
+        md = env.await_error(domain)
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:unauthorized'
+
+    def test_md_750_017(self, env):
+        # md without EAB explicitly set to none
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDExternalAccountBinding kid-1 zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W")
+        conf.start_md(domains)
+        conf.add("MDExternalAccountBinding none")
+        conf.end_md()
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        md = env.await_error(domain)
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:externalAccountRequired'
+
+    def test_md_750_018(self, env):
+        # md with EAB file that does not exist
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDExternalAccountBinding does-not-exist")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_fail() == 0
+        assert re.search(r'.*file not found:', env.apachectl_stderr), env.apachectl_stderr
+
+    def test_md_750_019(self, env):
+        # md with EAB file that is not valid JSON
+        domain = self.test_domain
+        domains = [domain]
+        eab_file = os.path.join(env.server_dir, 'eab.json')
+        with open(eab_file, 'w') as fd:
+            fd.write("something not JSON\n")
+        conf = MDConf(env)
+        conf.add("MDExternalAccountBinding eab.json")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_fail() == 0
+        assert re.search(r'.*error reading JSON file.*', env.apachectl_stderr), env.apachectl_stderr
+
+    def test_md_750_020(self, env):
+        # md with EAB file that is JSON, but missind kid
+        domain = self.test_domain
+        domains = [domain]
+        eab_file = os.path.join(env.server_dir, 'eab.json')
+        with open(eab_file, 'w') as fd:
+            eab = {'something': 1, 'other': 2}
+            fd.write(json.encoder.JSONEncoder().encode(eab))
+        conf = MDConf(env)
+        conf.add("MDExternalAccountBinding eab.json")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_fail() == 0
+        assert re.search(r'.*JSON does not contain \'kid\' element.*', env.apachectl_stderr), env.apachectl_stderr
+
+    def test_md_750_021(self, env):
+        # md with EAB file that is JSON, but missind hmac
+        domain = self.test_domain
+        domains = [domain]
+        eab_file = os.path.join(env.server_dir, 'eab.json')
+        with open(eab_file, 'w') as fd:
+            eab = {'kid': 'kid-1', 'other': 2}
+            fd.write(json.encoder.JSONEncoder().encode(eab))
+        conf = MDConf(env)
+        conf.add("MDExternalAccountBinding eab.json")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_fail() == 0
+        assert re.search(r'.*JSON does not contain \'hmac\' element.*', env.apachectl_stderr), env.apachectl_stderr
+
+    def test_md_750_022(self, env):
+        # md with EAB file that has correct values
+        domain = self.test_domain
+        domains = [domain]
+        eab_file = os.path.join(env.server_dir, 'eab.json')
+        with open(eab_file, 'w') as fd:
+            eab = {'kid': 'kid-1', 'hmac': 'zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W'}
+            fd.write(json.encoder.JSONEncoder().encode(eab))
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        # this is one of the values in conf/pebble-eab.json
+        conf.add("MDExternalAccountBinding eab.json")
+        conf.add_md(domains)
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
diff --git a/test/modules/md/test_751_sectigo.py b/test/modules/md/test_751_sectigo.py
new file mode 100644 (file)
index 0000000..5cbd642
--- /dev/null
@@ -0,0 +1,181 @@
+import os
+import re
+import time
+
+import pytest
+
+from .md_conf import MDConf
+
+# set the environment variables
+#   SECTIGO_EAB="$kid $hmac" for
+#   SECTIGO_TLD="<your registered dns name>"
+# these tests to become active
+#
+
+DEMO_ACME = "https://acme.demo.sectigo.com/"
+DEMO_TLD = None
+
+EABS = [
+    {'kid': '0123', 'hmac': 'abcdef'},
+]
+
+
+def missing_eab():
+    global EABS
+    if len(EABS) == 1 and 'SECTIGO_EAB' in os.environ:
+        m = re.match(r'^\s*(\S+)\s+(\S+)\s*$', os.environ['SECTIGO_EAB'])
+        if m:
+            EABS.append({'kid': m.group(1), 'hmac': m.group(2)})
+    return len(EABS) == 1
+
+
+def missing_tld():
+    global DEMO_TLD
+    if 'SECTIGO_TLD' in os.environ:
+        DEMO_TLD = os.environ['SECTIGO_TLD']
+    return DEMO_TLD is None
+
+
+@pytest.mark.skipif(condition=missing_tld(), reason="env var SECTIGO_TLD not set")
+@pytest.mark.skipif(condition=missing_eab(), reason="env var SECTIGO_EAB not set")
+class TestSectigo:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        acme.start(config='eab')
+        env.check_acme()
+        env.clear_store()
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.clear_store()
+        self.test_domain = env.get_request_domain(request)
+
+    def test_md_751_001(self, env):
+        # valid config, expect cert with correct chain
+        domain = f"test1.{DEMO_TLD}"
+        domains = [domain]
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add(f"MDCertificateAuthority {DEMO_ACME}")
+        conf.add("MDCACertificateFile none")
+        conf.add(f"MDExternalAccountBinding {EABS[1]['kid']} {EABS[1]['hmac']}")
+        conf.end_md()
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        r = env.curl_get(f"https://{domain}:{env.https_port}", options=[
+            "--cacert", f"{env.test_dir}/data/sectigo-demo-root.pem"
+        ])
+        assert r.response['status'] == 200
+
+    def test_md_751_002(self, env):
+        # without EAB set
+        domain = f"test1.{DEMO_TLD}"
+        domains = [domain]
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add(f"MDCertificateAuthority {DEMO_ACME}")
+        conf.add("MDCACertificateFile none")
+        conf.end_md()
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_error(domain)
+        md = env.get_md_status(domain)
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:externalAccountRequired'
+
+    def test_md_751_003(self, env):
+        # with wrong EAB set
+        domain = f"test1.{DEMO_TLD}"
+        domains = [domain]
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add(f"MDCertificateAuthority {DEMO_ACME}")
+        conf.add("MDCACertificateFile none")
+        conf.add(f"MDExternalAccountBinding xxxxxx aaaaaaaaaaaaasdddddsdasdsadsadsadasdsadsa")
+        conf.end_md()
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_error(domain)
+        md = env.get_md_status(domain)
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:unauthorized'
+
+    def test_md_751_004(self, env):
+        # valid config, get cert, add dns name, renew cert
+        domain = f"test1.{DEMO_TLD}"
+        domain2 = f"test2.{DEMO_TLD}"
+        domains = [domain]
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add(f"MDCertificateAuthority {DEMO_ACME}")
+        conf.add("MDCACertificateFile none")
+        conf.add(f"MDExternalAccountBinding {EABS[1]['kid']} {EABS[1]['hmac']}")
+        conf.end_md()
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        r = env.curl_get(f"https://{domain}:{env.https_port}", options=[
+            "--cacert", f"{env.test_dir}/data/sectigo-demo-root.pem"
+        ])
+        assert r.response['status'] == 200
+        r = env.curl_get(f"https://{domain2}:{env.https_port}", options=[
+            "--cacert", f"{env.test_dir}/data/sectigo-demo-root.pem"
+        ])
+        assert r.exit_code != 0
+        md1 = env.get_md_status(domain)
+        acct1 = md1['ca']['account']
+        # add the domain2 to the dns names
+        domains = [domain, domain2]
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add(f"MDCertificateAuthority {DEMO_ACME}")
+        conf.add("MDCACertificateFile none")
+        conf.add(f"MDExternalAccountBinding {EABS[1]['kid']} {EABS[1]['hmac']}")
+        conf.end_md()
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        r = env.curl_get(f"https://{domain2}:{env.https_port}", options=[
+            "--cacert", f"{env.test_dir}/data/sectigo-demo-root.pem"
+        ])
+        assert r.response['status'] == 200
+        md2 = env.get_md_status(domain)
+        acct2 = md2['ca']['account']
+        assert acct2 == acct1, f"ACME account was not reused: {acct1} became {acct2}"
+
+    def test_md_751_020(self, env):
+        # valid config, get cert, check OCSP status
+        domain = f"test1.{DEMO_TLD}"
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDStapling on")
+        conf.start_md(domains)
+        conf.add(f"""
+            MDCertificateAuthority {DEMO_ACME}
+            MDCACertificateFile none
+            MDExternalAccountBinding {EABS[1]['kid']} {EABS[1]['hmac']}
+            """)
+        conf.end_md()
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        r = env.curl_get(f"https://{domain}:{env.https_port}", options=[
+            "--cacert", f"{env.test_dir}/data/sectigo-demo-root.pem"
+        ])
+        assert r.response['status'] == 200
+        time.sleep(1)
+        for domain in domains:
+            stat = env.await_ocsp_status(domain,
+                                         ca_file=f"{env.test_dir}/data/sectigo-demo-root.pem")
+            assert stat['ocsp'] == "successful (0x0)"
+            assert stat['verify'] == "0 (ok)"
diff --git a/test/modules/md/test_752_zerossl.py b/test/modules/md/test_752_zerossl.py
new file mode 100644 (file)
index 0000000..1884665
--- /dev/null
@@ -0,0 +1,202 @@
+import os
+import time
+
+import pytest
+
+from .md_conf import MDConf
+
+# set the environment variables
+#   ZEROSSL_TLD="<your registered dns name>"
+# these tests to become active
+#
+
+DEMO_ACME = "https://acme.zerossl.com/v2/DV90"
+DEMO_EAB_URL = "http://api.zerossl.com/acme/eab-credentials-email"
+DEMO_TLD = None
+
+
+def missing_tld():
+    global DEMO_TLD
+    if 'ZEROSSL_TLD' in os.environ:
+        DEMO_TLD = os.environ['ZEROSSL_TLD']
+    return DEMO_TLD is None
+
+
+def get_new_eab(env):
+    r = env.curl_raw(DEMO_EAB_URL, options=[
+        "-d", f"email=admin@zerossl.{DEMO_TLD}"
+    ], force_resolve=False)
+    assert r.exit_code == 0
+    assert r.json
+    assert r.json['success'] is True
+    assert r.json['eab_kid']
+    assert r.json['eab_hmac_key']
+    return {'kid': r.json['eab_kid'], 'hmac': r.json['eab_hmac_key']}
+
+
+@pytest.mark.skipif(condition=missing_tld(), reason="env var ZEROSSL_TLD not set")
+class TestZeroSSL:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        acme.start(config='eab')
+        env.check_acme()
+        env.clear_store()
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        self.test_domain = env.get_request_domain(request)
+
+    def test_md_752_001(self, env):
+        # valid config, expect cert with correct chain
+        domain = f"test1.{DEMO_TLD}"
+        domains = [domain]
+        eab = get_new_eab(env)
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add(f"""
+            MDCertificateAuthority {DEMO_ACME}
+            MDCertificateAgreement accepted
+            MDContactEmail admin@zerossl.{DEMO_TLD}
+            MDCACertificateFile none
+            MDExternalAccountBinding {eab['kid']} {eab['hmac']}
+        """)
+        conf.end_md()
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        r = env.curl_get(f"https://{domain}:{env.https_port}", options=[
+            "--cacert", f"{env.test_dir}/data/sectigo-demo-root.pem"
+        ])
+        assert r.response['status'] == 200
+
+    def test_md_752_002(self, env):
+        # without EAB set
+        domain = f"test1.{DEMO_TLD}"
+        domains = [domain]
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add(f"""
+            MDCertificateAuthority {DEMO_ACME}
+            MDCertificateAgreement accepted
+            MDContactEmail admin@zerossl.{DEMO_TLD}
+            MDCACertificateFile none
+        """)
+        conf.end_md()
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_error(domain)
+        md = env.get_md_status(domain)
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:externalAccountRequired'
+
+    def test_md_752_003(self, env):
+        # with wrong EAB set
+        domain = f"test1.{DEMO_TLD}"
+        domains = [domain]
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add(f"""
+            MDCertificateAuthority {DEMO_ACME}
+            MDCertificateAgreement accepted
+            MDContactEmail admin@zerossl.{DEMO_TLD}
+            MDCACertificateFile none
+        """)
+        conf.add(f"MDExternalAccountBinding YmxhYmxhYmxhCg YmxhYmxhYmxhCg")
+        conf.end_md()
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_error(domain)
+        md = env.get_md_status(domain)
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:malformed'
+
+    def test_md_752_004(self, env):
+        # valid config, get cert, add dns name, renew cert
+        domain = f"test1.{DEMO_TLD}"
+        domain2 = f"test2.{DEMO_TLD}"
+        domains = [domain]
+        eab = get_new_eab(env)
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add(f"""
+            MDCertificateAuthority {DEMO_ACME}
+            MDCertificateAgreement accepted
+            MDContactEmail admin@zerossl.{DEMO_TLD}
+            MDCACertificateFile none
+            MDExternalAccountBinding {eab['kid']} {eab['hmac']}
+        """)
+        conf.end_md()
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        r = env.curl_get(f"https://{domain}:{env.https_port}", options=[
+            "--cacert", f"{env.test_dir}/data/sectigo-demo-root.pem"
+        ])
+        assert r.response['status'] == 200
+        r = env.curl_get(f"https://{domain2}:{env.https_port}", options=[
+            "--cacert", f"{env.test_dir}/data/sectigo-demo-root.pem"
+        ])
+        assert r.exit_code != 0
+        md1 = env.get_md_status(domain)
+        acct1 = md1['ca']['account']
+        # add the domain2 to the dns names
+        domains = [domain, domain2]
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add(f"""
+            MDCertificateAuthority {DEMO_ACME}
+            MDCertificateAgreement accepted
+            MDContactEmail admin@zerossl.{DEMO_TLD}
+            MDCACertificateFile none
+            MDExternalAccountBinding {eab['kid']} {eab['hmac']}
+        """)
+        conf.end_md()
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        r = env.curl_get(f"https://{domain2}:{env.https_port}", options=[
+            "--cacert", f"{env.test_dir}/data/sectigo-demo-root.pem"
+        ])
+        assert r.response['status'] == 200
+        md2 = env.get_md_status(domain)
+        acct2 = md2['ca']['account']
+        assert acct2 == acct1, f"ACME account was not reused: {acct1} became {acct2}"
+
+    def test_md_752_020(self, env):
+        # valid config, get cert, check OCSP status
+        domain = f"test1.{DEMO_TLD}"
+        domains = [domain]
+        eab = get_new_eab(env)
+        conf = MDConf(env)
+        conf.add("MDStapling on")
+        conf.start_md(domains)
+        conf.add(f"""
+            MDCertificateAuthority {DEMO_ACME}
+            MDCertificateAgreement accepted
+            MDContactEmail admin@zerossl.{DEMO_TLD}
+            MDCACertificateFile none
+            MDExternalAccountBinding {eab['kid']} {eab['hmac']}
+        """)
+        conf.end_md()
+        conf.add_vhost(domains=domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        r = env.curl_get(f"https://{domain}:{env.https_port}", options=[
+            "--cacert", f"{env.test_dir}/data/sectigo-demo-root.pem"
+        ])
+        assert r.response['status'] == 200
+        time.sleep(1)
+        for domain in domains:
+            stat = env.await_ocsp_status(domain,
+                                         ca_file=f"{env.test_dir}/data/sectigo-demo-root.pem")
+            assert stat['ocsp'] == "successful (0x0)"
+            assert stat['verify'] == "0 (ok)"
diff --git a/test/modules/md/test_800_must_staple.py b/test/modules/md/test_800_must_staple.py
new file mode 100644 (file)
index 0000000..06e881c
--- /dev/null
@@ -0,0 +1,84 @@
+# test mod_md must-staple support
+import pytest
+
+from .md_conf import MDConf
+from .md_cert_util import MDCertUtil
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestMustStaple:
+    domain = None
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        acme.start(config='default')
+        env.check_acme()
+        env.clear_store()
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        self.domain = env.get_class_domain(self.__class__)
+
+    def configure_httpd(self, env, domain, add_lines=""):
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.add(add_lines)
+        conf.add_md([domain])
+        conf.add_vhost(domain)
+        conf.install()
+
+    # MD with default, e.g. not staple
+    def test_md_800_001(self, env):
+        self.configure_httpd(env, self.domain)
+        assert env.apache_restart() == 0
+        assert env.await_completion([self.domain])
+        env.check_md_complete(self.domain)
+        cert1 = MDCertUtil(env.store_domain_file(self.domain, 'pubcert.pem'))
+        assert not cert1.get_must_staple()
+
+    # MD that should explicitly not staple
+    def test_md_800_002(self, env):
+        self.configure_httpd(env, self.domain, "MDMustStaple off")
+        assert env.apache_restart() == 0
+        env.check_md_complete(self.domain)
+        cert1 = MDCertUtil(env.store_domain_file(self.domain, 'pubcert.pem'))
+        assert not cert1.get_must_staple()
+        stat = env.get_ocsp_status(self.domain)
+        assert stat['ocsp'] == "no response sent" 
+
+    # MD that must staple and toggle off again
+    @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+    def test_md_800_003(self, env):
+        self.configure_httpd(env, self.domain, "MDMustStaple on")
+        assert env.apache_restart() == 0
+        assert env.await_completion([self.domain])
+        env.check_md_complete(self.domain)
+        cert1 = MDCertUtil(env.store_domain_file(self.domain, 'pubcert.pem'))
+        assert cert1.get_must_staple()
+        self.configure_httpd(env, self.domain, "MDMustStaple off")
+        assert env.apache_restart() == 0
+        assert env.await_completion([self.domain])
+        env.check_md_complete(self.domain)
+        cert1 = MDCertUtil(env.store_domain_file(self.domain, 'pubcert.pem'))
+        assert not cert1.get_must_staple()
+
+    # MD that must staple
+    @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+    @pytest.mark.skipif(MDTestEnv.get_ssl_module() != "mod_ssl", reason="only for mod_ssl")
+    def test_md_800_004(self, env):
+        # mod_ssl stapling is off, expect no stapling
+        stat = env.get_ocsp_status(self.domain)
+        assert stat['ocsp'] == "no response sent" 
+        # turn mod_ssl stapling on, expect an answer
+        self.configure_httpd(env, self.domain, """
+            LogLevel ssl:trace2
+            SSLUseStapling On
+            SSLStaplingCache shmcb:stapling_cache(128000)
+            """)
+        assert env.apache_restart() == 0
+        stat = env.get_ocsp_status(self.domain)
+        assert stat['ocsp'] == "successful (0x0)" 
+        assert stat['verify'] == "0 (ok)"
diff --git a/test/modules/md/test_801_stapling.py b/test/modules/md/test_801_stapling.py
new file mode 100644 (file)
index 0000000..5c03602
--- /dev/null
@@ -0,0 +1,391 @@
+# test mod_md stapling support
+
+import os
+import time
+import pytest
+
+from .md_conf import MDConf
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+@pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+class TestStapling:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        acme.start(config='default')
+        env.check_acme()
+        env.clear_store()
+        domain = env.get_class_domain(self.__class__)
+        mdA = "a-" + domain
+        mdB = "b-" + domain
+        self.configure_httpd(env, [mdA, mdB]).install()
+        env.apache_stop()
+        assert env.apache_restart() == 0
+        assert env.await_completion([mdA, mdB])
+        env.check_md_complete(mdA)
+        env.check_md_complete(mdB)
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        self.domain = env.get_class_domain(self.__class__)
+        self.mdA = "a-" + self.domain
+        self.mdB = "b-" + self.domain
+        yield
+        env.apache_stop()
+
+    def configure_httpd(self, env, domains=None, add_lines="", ssl_stapling=False):
+        if not isinstance(domains, list):
+            domains = [domains] if domains else []
+        conf = MDConf(env)
+        conf.add("""
+        <IfModule tls_module>
+            LogLevel tls:trace4
+        </IfModule>
+        <IfModule ssl_module>
+            LogLevel ssl:trace4
+        </IfModule>
+            """)
+        if ssl_stapling:
+            conf.add("""
+            <IfModule ssl_module>
+                SSLUseStapling On
+                SSLStaplingCache shmcb:stapling_cache(128000)
+            </IfModule>
+                """)
+        conf.add(add_lines)
+        for domain in domains:
+            conf.add_md([domain])
+            conf.add_vhost(domain)
+        return conf
+
+    # MD with stapling on/off and mod_ssl stapling off
+    # expect to only see stapling response when MD stapling is on
+    def test_md_801_001(self, env):
+        md = self.mdA
+        self.configure_httpd(env, md).install()
+        assert env.apache_restart() == 0
+        stat = env.get_ocsp_status(md)
+        assert stat['ocsp'] == "no response sent" 
+        stat = env.get_md_status(md)
+        assert not stat["stapling"]
+        #
+        # turn stapling on, wait for it to appear in connections
+        self.configure_httpd(env, md, """
+            MDStapling on
+            LogLevel md:trace5
+            """).install()
+        assert env.apache_restart() == 0
+        stat = env.await_ocsp_status(md)
+        assert stat['ocsp'] == "successful (0x0)" 
+        assert stat['verify'] == "0 (ok)"
+        stat = env.get_md_status(md)
+        assert stat["stapling"]
+        pkey = 'rsa'
+        assert stat["cert"][pkey]["ocsp"]["status"] == "good"
+        assert stat["cert"][pkey]["ocsp"]["valid"]
+        #
+        # turn stapling off (explicitly) again, should disappear
+        self.configure_httpd(env, md, "MDStapling off").install()
+        assert env.apache_restart() == 0
+        stat = env.get_ocsp_status(md)
+        assert stat['ocsp'] == "no response sent" 
+        stat = env.get_md_status(md)
+        assert not stat["stapling"]
+        
+    # MD with stapling on/off and mod_ssl stapling on
+    # expect to see stapling response in all cases
+    def test_md_801_002(self, env):
+        md = self.mdA
+        self.configure_httpd(env, md, ssl_stapling=True).install()
+        assert env.apache_restart() == 0
+        stat = env.get_ocsp_status(md)
+        assert stat['ocsp'] == "successful (0x0)" if \
+            env.ssl_module == "mod_ssl" else "no response sent"
+        stat = env.get_md_status(md)
+        assert not stat["stapling"]
+        #
+        # turn stapling on, wait for it to appear in connections
+        self.configure_httpd(env, md, "MDStapling on", ssl_stapling=True).install()
+        assert env.apache_restart() == 0
+        stat = env.await_ocsp_status(md)
+        assert stat['ocsp'] == "successful (0x0)" 
+        assert stat['verify'] == "0 (ok)"
+        stat = env.get_md_status(md)
+        assert stat["stapling"]
+        pkey = 'rsa'
+        assert stat["cert"][pkey]["ocsp"]["status"] == "good"
+        assert stat["cert"][pkey]["ocsp"]["valid"]
+        #
+        # turn stapling off (explicitly) again, should disappear
+        self.configure_httpd(env, md, "MDStapling off", ssl_stapling=True).install()
+        assert env.apache_restart() == 0
+        stat = env.get_ocsp_status(md)
+        assert stat['ocsp'] == "successful (0x0)" if \
+            env.ssl_module == "mod_ssl" else "no response sent"
+        stat = env.get_md_status(md)
+        assert not stat["stapling"]
+        
+    # 2 MDs, one with md stapling on, one with default (off)
+    def test_md_801_003(self, env):
+        md_a = self.mdA
+        md_b = self.mdB
+        conf = self.configure_httpd(env)
+        conf.add("""
+            <MDomain %s>
+                MDStapling on
+            </MDomain>
+            <MDomain %s>
+            </MDomain>
+            """ % (md_a, md_b))
+        conf.add_vhost(md_a)
+        conf.add_vhost(md_b)
+        conf.install()
+        assert env.apache_restart() == 0
+        # mdA has stapling
+        stat = env.await_ocsp_status(md_a)
+        assert stat['ocsp'] == "successful (0x0)" 
+        assert stat['verify'] == "0 (ok)"
+        stat = env.get_md_status(md_a)
+        assert stat["stapling"]
+        pkey = 'rsa'
+        assert stat["cert"][pkey]["ocsp"]["status"] == "good"
+        assert stat["cert"][pkey]["ocsp"]["valid"]
+        # mdB has no stapling
+        stat = env.get_ocsp_status(md_b)
+        assert stat['ocsp'] == "no response sent" 
+        stat = env.get_md_status(md_b)
+        assert not stat["stapling"]
+
+    # 2 MDs, md stapling on+off, ssl stapling on
+    def test_md_801_004(self, env):
+        md_a = self.mdA
+        md_b = self.mdB
+        conf = self.configure_httpd(env, ssl_stapling=True)
+        conf.add("""
+            <MDomain %s>
+                MDStapling on
+            </MDomain>
+            <MDomain %s>
+            </MDomain>
+            """ % (md_a, md_b))
+        conf.add_vhost(md_a)
+        conf.add_vhost(md_b)
+        conf.install()
+        assert env.apache_restart() == 0
+        # mdA has stapling
+        stat = env.await_ocsp_status(md_a)
+        assert stat['ocsp'] == "successful (0x0)"
+        assert stat['verify'] == "0 (ok)"
+        stat = env.get_md_status(md_a)
+        assert stat["stapling"]
+        pkey = 'rsa'
+        assert stat["cert"][pkey]["ocsp"]["status"] == "good"
+        assert stat["cert"][pkey]["ocsp"]["valid"]
+        # mdB has no md stapling, but mod_ssl kicks in
+        stat = env.get_ocsp_status(md_b)
+        assert stat['ocsp'] == "successful (0x0)" if \
+            env.ssl_module == "mod_ssl" else "no response sent"
+        stat = env.get_md_status(md_b)
+        assert not stat["stapling"]
+
+    # MD, check that restart leaves response unchanged, reconfigure keep interval, 
+    # should remove the file on restart and get a new one
+    def test_md_801_005(self, env):
+        # TODO: mod_watchdog seems to have problems sometimes with fast restarts
+        # turn stapling on, wait for it to appear in connections
+        md = self.mdA
+        self.configure_httpd(env, md, "MDStapling on").install()
+        assert env.apache_restart() == 0
+        stat = env.await_ocsp_status(md)
+        assert stat['ocsp'] == "successful (0x0)" 
+        assert stat['verify'] == "0 (ok)"
+        # fine the file where the ocsp response is stored
+        dirpath = os.path.join(env.store_dir, 'ocsp', md)
+        files = os.listdir(dirpath)
+        ocsp_file = None
+        for name in files:
+            if name.startswith("ocsp-"):
+                ocsp_file = os.path.join(dirpath, name)
+        assert ocsp_file
+        mtime1 = os.path.getmtime(ocsp_file)
+        # wait a sec, restart and check that file does not change
+        time.sleep(1)
+        assert env.apache_restart() == 0
+        stat = env.await_ocsp_status(md)
+        assert stat['ocsp'] == "successful (0x0)" 
+        mtime2 = os.path.getmtime(ocsp_file)
+        assert mtime1 == mtime2
+        # configure a keep time of 1 second, restart, the file is gone
+        # (which is a side effec that we load it before the cleanup removes it.
+        #  since it was valid, no new one needed fetching
+        self.configure_httpd(env, md, """
+            MDStapling on
+            MDStaplingKeepResponse 1s
+            """).install()
+        assert env.apache_restart() == 0
+        stat = env.await_ocsp_status(md)
+        assert stat['ocsp'] == "successful (0x0)"
+        assert not os.path.exists(ocsp_file)
+        # if we restart again, a new file needs to appear
+        assert env.apache_restart() == 0
+        stat = env.await_ocsp_status(md)
+        assert stat['ocsp'] == "successful (0x0)"
+        mtime3 = os.path.getmtime(ocsp_file)
+        assert mtime1 != mtime3
+
+    # MD, check that stapling renew window works. Set a large window
+    # that causes response to be retrieved all the time.
+    def test_md_801_006(self, env):
+        # turn stapling on, wait for it to appear in connections
+        md = self.mdA
+        self.configure_httpd(env, md, "MDStapling on").install()
+        assert env.apache_restart() == 0
+        stat = env.await_ocsp_status(md)
+        assert stat['ocsp'] == "successful (0x0)" 
+        assert stat['verify'] == "0 (ok)"
+        # fine the file where the ocsp response is stored
+        dirpath = os.path.join(env.store_dir, 'ocsp', md)
+        files = os.listdir(dirpath)
+        ocsp_file = None
+        for name in files:
+            if name.startswith("ocsp-"):
+                ocsp_file = os.path.join(dirpath, name)
+        assert ocsp_file
+        mtime1 = os.path.getmtime(ocsp_file)
+        assert env.apache_restart() == 0
+        stat = env.await_ocsp_status(md)
+        assert stat['ocsp'] == "successful (0x0)" 
+        # wait a sec, restart and check that file does not change
+        time.sleep(1)
+        mtime2 = os.path.getmtime(ocsp_file)
+        assert mtime1 == mtime2
+        # configure a renew window of 10 days, restart, larger than any life time.
+        self.configure_httpd(env, md, """
+            MDStapling on
+            MDStaplingRenewWindow 10d
+            """).install()
+        assert env.apache_restart() == 0
+        stat = env.await_ocsp_status(md)
+        assert stat['ocsp'] == "successful (0x0)"
+        # wait a sec, restart and check that file does change
+        time.sleep(1)
+        mtime3 = os.path.getmtime(ocsp_file)
+        assert mtime1 != mtime3
+
+    # MD, make a MDomain with static files, check that stapling works
+    def test_md_801_007(self, env):
+        # turn stapling on, wait for it to appear in connections
+        md = self.mdA
+        conf = self.configure_httpd(env)
+        conf.add("""
+            <MDomain %s>
+                MDCertificateKeyFile %s
+                MDCertificateFile %s
+                MDStapling on
+            </MDomain>
+            """ % (md, env.store_domain_file(md, 'privkey.pem'),
+                   env.store_domain_file(md, 'pubcert.pem')))
+        conf.add_vhost(md)
+        conf.install()
+        assert env.apache_restart() == 0
+        stat = env.await_ocsp_status(md)
+        assert stat['ocsp'] == "successful (0x0)" 
+        assert stat['verify'] == "0 (ok)"
+        # fine the file where the ocsp response is stored
+        dirpath = os.path.join(env.store_dir, 'ocsp', md)
+        files = os.listdir(dirpath)
+        ocsp_file = None
+        for name in files:
+            if name.startswith("ocsp-"):
+                ocsp_file = os.path.join(dirpath, name)
+        assert ocsp_file
+
+    # Use certificate files in direct config, check that stapling works
+    def test_md_801_008(self, env):
+        # turn stapling on, wait for it to appear in connections
+        md = self.mdA
+        conf = self.configure_httpd(env)
+        conf.add("MDStapling on")
+        conf.start_vhost(md)
+        conf.add_certificate(env.store_domain_file(md, 'pubcert.pem'),
+                             env.store_domain_file(md, 'privkey.pem'))
+        conf.end_vhost()
+        conf.install()
+        assert env.apache_restart() == 0
+        stat = env.await_ocsp_status(md)
+        assert stat['ocsp'] == "successful (0x0)" 
+        assert stat['verify'] == "0 (ok)"
+        # fine the file where the ocsp response is stored
+        dirpath = os.path.join(env.store_dir, 'ocsp', 'other')
+        files = os.listdir(dirpath)
+        ocsp_file = None
+        for name in files:
+            if name.startswith("ocsp-"):
+                ocsp_file = os.path.join(dirpath, name)
+        assert ocsp_file
+
+    # Turn on stapling for a certificate without OCSP responder and issuer
+    # (certificates without issuer prevent mod_ssl asking around for stapling)
+    def test_md_801_009(self, env):
+        md = self.mdA
+        domains = [md]
+        testpath = os.path.join(env.gen_dir, 'test_801_009')
+        # cert that is 30 more days valid
+        env.create_self_signed_cert(domains, {"notBefore": -60, "notAfter": 30},
+                                        serial=801009, path=testpath)
+        cert_file = os.path.join(testpath, 'pubcert.pem')
+        pkey_file = os.path.join(testpath, 'privkey.pem')
+        assert os.path.exists(cert_file)
+        assert os.path.exists(pkey_file)
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add("MDCertificateFile %s" % cert_file)
+        conf.add("MDCertificateKeyFile %s" % pkey_file)
+        conf.add("MDStapling on")
+        conf.end_md()
+        conf.add_vhost(md)
+        conf.install()
+        assert env.apache_restart() == 0
+        time.sleep(1)
+        stat = env.get_ocsp_status(md)
+        assert stat['ocsp'] == "no response sent" 
+
+    # Turn on stapling for an MDomain not used in any virtualhost
+    # There was a crash in server-status in this case
+    def test_md_801_010(self, env):
+        env.clear_ocsp_store()
+        md = self.mdA
+        domains = [md]
+        conf = MDConf(env)
+        conf.start_md(domains)
+        conf.add("MDStapling on")
+        conf.end_md()
+        conf.install()
+        assert env.apache_restart() == 0
+        stat = env.get_server_status()
+        assert stat
+
+    # add 7 mdomains that need OCSP stapling, once activated
+    # we use at max 6 connections against the same OCSP responder and
+    # this triggers our use of curl_multi_perform with iterative
+    # scheduling.
+    # This checks the mistaken assert() reported in
+    # <https://bz.apache.org/bugzilla/show_bug.cgi?id=65567>
+    def test_md_801_011(self, env):
+        domains = [ f'test-801-011-{i}-{env.DOMAIN_SUFFIX}' for i in range(7)]
+        self.configure_httpd(env, domains, """
+            MDStapling on
+            LogLevel md:trace2 ssl:warn
+            """).install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains, restart=False, timeout=120)
+        assert env.apache_restart() == 0
+        # now the certs are installed and ocsp will be retrieved
+        time.sleep(1)
+        for domain in domains:
+            stat = env.await_ocsp_status(domain)
+            assert stat['ocsp'] == "successful (0x0)"
+            assert stat['verify'] == "0 (ok)"
diff --git a/test/modules/md/test_810_ec.py b/test/modules/md/test_810_ec.py
new file mode 100644 (file)
index 0000000..f8480dd
--- /dev/null
@@ -0,0 +1,153 @@
+# tests with elliptic curve keys and certificates
+import logging
+
+import pytest
+
+from .md_conf import MDConf
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestAutov2:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        env.APACHE_CONF_SRC = "data/test_auto"
+        acme.start(config='default')
+        env.check_acme()
+        env.clear_store()
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.clear_store()
+        self.test_domain = env.get_request_domain(request)
+
+    def set_get_pkeys(self, env, domain, pkeys, conf=None):
+        domains = [domain]
+        if conf is None:
+            conf = MDConf(env)
+            conf.add("MDPrivateKeys {0}".format(" ".join([p['spec'] for p in pkeys])))
+            conf.add_md(domains)
+            conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain])
+
+    def check_pkeys(self, env, domain, pkeys):
+        # check that files for all types have been created
+        for p in [p for p in pkeys if len(p['spec'])]:
+            env.check_md_complete(domain, p['spec'])
+        # check that openssl client sees the cert with given keylength for cipher
+        env.verify_cert_key_lenghts(domain, pkeys)
+    
+    def set_get_check_pkeys(self, env, domain, pkeys, conf=None):
+        self.set_get_pkeys(env, domain, pkeys, conf=conf)
+        self.check_pkeys(env, domain, pkeys)
+        
+    # one EC key, no RSA
+    def test_md_810_001(self, env):
+        domain = self.test_domain
+        self.set_get_check_pkeys(env, domain, [
+            {'spec': "secp256r1", 'ciphers': "ECDSA", 'keylen': 256},
+            {'spec': "", 'ciphers': "RSA", 'keylen': 0},
+        ])
+
+    # set EC key type override on MD and get certificate
+    def test_md_810_002(self, env):
+        domain = self.test_domain
+        # generate config with one MD
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDPrivateKeys secp256r1")
+        conf.start_md(domains)
+        conf.add("    MDPrivateKeys secp384r1")
+        conf.end_md()
+        conf.add_vhost(domains)
+        self.set_get_check_pkeys(env, domain, [
+            {'spec': "secp384r1", 'ciphers': "ECDSA", 'keylen': 384},
+            {'spec': "", 'ciphers': "RSA", 'keylen': 0},
+        ])
+
+    # set two key spec, ec before rsa
+    def test_md_810_003a(self, env):
+        domain = self.test_domain
+        self.set_get_check_pkeys(env, domain, [
+            {'spec': "P-256", 'ciphers': "ECDSA", 'keylen': 256},
+            {'spec': "RSA 3072", 'ciphers': "ECDHE-RSA-CHACHA20-POLY1305", 'keylen': 3072},
+        ])
+
+    # set two key spec, rsa before ec
+    def test_md_810_003b(self, env):
+        domain = self.test_domain
+        self.set_get_check_pkeys(env, domain, [
+            {'spec': "RSA 3072", 'ciphers': "ECDHE-RSA-CHACHA20-POLY1305", 'keylen': 3072},
+            {'spec': "secp384r1", 'ciphers': "ECDSA", 'keylen': 384},
+        ])
+
+    # use a curve unsupported by LE
+    # only works with mod_ssl as rustls refuses to load such a weak key
+    @pytest.mark.skipif(MDTestEnv.get_ssl_module() != "mod_ssl", reason="only for mod_ssl")
+    @pytest.mark.skipif(MDTestEnv.get_acme_server() != 'boulder', reason="onyl boulder rejects this")
+    def test_md_810_004(self, env):
+        domain = self.test_domain
+        # generate config with one MD
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDPrivateKeys secp192r1")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        md = env.await_error(domain)
+        assert md
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['problem'] == 'urn:ietf:params:acme:error:malformed'
+
+    # set three key specs
+    def test_md_810_005(self, env):
+        domain = self.test_domain
+        # behaviour differences, mod_ssl selects the strongest suitable,
+        # mod_tls selects the first suitable
+        ec_key_len = 384 if env.ssl_module == "mod_ssl" else 256
+        self.set_get_check_pkeys(env, domain, [
+            {'spec': "secp256r1", 'ciphers': "ECDSA", 'keylen': ec_key_len},
+            {'spec': "RSA 4096", 'ciphers': "ECDHE-RSA-CHACHA20-POLY1305", 'keylen': 4096},
+            {'spec': "P-384", 'ciphers': "ECDSA", 'keylen': ec_key_len},
+        ])
+
+    # set three key specs
+    def test_md_810_006(self, env):
+        domain = self.test_domain
+        self.set_get_check_pkeys(env, domain, [
+            {'spec': "rsa2048", 'ciphers': "ECDHE-RSA-CHACHA20-POLY1305", 'keylen': 2048},
+            {'spec': "secp256r1", 'ciphers': "ECDSA", 'keylen': 256},
+        ])
+
+    # start with one pkey and add another one
+    def test_md_810_007(self, env):
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDPrivateKeys rsa3072")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        conf = MDConf(env)
+        conf.add("MDPrivateKeys rsa3072 secp384r1")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        mds = env.get_md_status(domain, via_domain=domain, use_https=True)
+        assert 'renew' in mds and mds['renew'] is True, f"{mds}"
+        assert env.await_completion(domains)
+        self.check_pkeys(env, domain, [
+            {'spec': "rsa3072", 'ciphers': "ECDHE-RSA-CHACHA20-POLY1305", 'keylen': 3072},
+            {'spec': "secp384r1", 'ciphers': "ECDSA", 'keylen': 384},
+        ])
+
diff --git a/test/modules/md/test_900_notify.py b/test/modules/md/test_900_notify.py
new file mode 100644 (file)
index 0000000..30e0742
--- /dev/null
@@ -0,0 +1,122 @@
+# test mod_md notify support
+
+import os
+import time
+
+import pytest
+
+from .md_conf import MDConf, MDConf
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestNotify:
+    notify_cmd = None
+    notify_log = None
+    domain = None
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        acme.start(config='default')
+        env.check_acme()
+        env.clear_store()
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        self.domain = env.get_request_domain(request)
+        self.notify_cmd = os.path.join(env.test_dir, "../modules/md/notify.py")
+        self.notify_log = os.path.join(env.gen_dir, "notify.log")
+        if os.path.isfile(self.notify_log):
+            os.remove(self.notify_log)
+
+    def configure_httpd(self, env, domain, add_lines=""):
+        conf = MDConf(env)
+        conf.add(add_lines)
+        conf.add_md([domain])
+        conf.add_vhost(domain)
+        conf.install()
+        return domain
+    
+    # test: invalid notify cmd, check error
+    def test_md_900_001(self, env):
+        command = "blablabla"
+        args = ""
+        self.configure_httpd(env, self.domain, f"""
+            MDNotifyCmd {command} {args}
+            """)
+        assert env.apache_restart() == 0
+        assert env.await_error(self.domain)
+        stat = env.get_md_status(self.domain)
+        assert stat["renewal"]["last"]["problem"] == "urn:org:apache:httpd:log:AH10108:"
+
+    # test: valid notify cmd that fails, check error
+    def test_md_900_002(self, env):
+        command = "%s/notifail.py" % env.test_dir
+        args = ""
+        self.configure_httpd(env, self.domain, f"""
+            MDNotifyCmd {command} {args}
+            """)
+        assert env.apache_restart() == 0
+        assert env.await_error(self.domain)
+        stat = env.get_md_status(self.domain)
+        assert stat["renewal"]["last"]["problem"] == "urn:org:apache:httpd:log:AH10108:"
+
+    # test: valid notify that logs to file
+    def test_md_900_010(self, env):
+        command = self.notify_cmd
+        args = self.notify_log
+        self.configure_httpd(env, self.domain, f"""
+            MDNotifyCmd {command} {args}
+            """)
+        assert env.apache_restart() == 0
+        assert env.await_completion([self.domain], restart=False)
+        time.sleep(1)
+        stat = env.get_md_status(self.domain)
+        assert stat["renewal"]["last"]["status"] == 0
+        time.sleep(1)
+        nlines = open(self.notify_log).readlines()
+        assert 1 == len(nlines)
+        assert ("['%s', '%s', '%s']" % (command, args, self.domain)) == nlines[0].strip()
+
+    # test: signup with working notify cmd and see that it is called with the 
+    #       configured extra arguments
+    def test_md_900_011(self, env):
+        command = self.notify_cmd
+        args = self.notify_log
+        extra_arg = "test_900_011_extra"
+        self.configure_httpd(env, self.domain, f"""
+            MDNotifyCmd {command} {args} {extra_arg}
+            """)
+        assert env.apache_restart() == 0
+        assert env.await_completion([self.domain], restart=False)
+        time.sleep(1)
+        stat = env.get_md_status(self.domain)
+        assert stat["renewal"]["last"]["status"] == 0
+        nlines = open(self.notify_log).readlines()
+        assert ("['%s', '%s', '%s', '%s']" % (command, args, extra_arg, self.domain)) == nlines[0].strip()
+
+    # test: signup with working notify cmd for 2 MD and expect it to be called twice
+    def test_md_900_012(self, env):
+        md1 = "a-" + self.domain
+        domains1 = [md1, "www." + md1]
+        md2 = "b-" + self.domain
+        domains2 = [md2, "www." + md2]
+        command = self.notify_cmd
+        args = self.notify_log
+        conf = MDConf(env)
+        conf.add(f"MDNotifyCmd {command} {args}")
+        conf.add_md(domains1)
+        conf.add_md(domains2)
+        conf.add_vhost(domains1)
+        conf.add_vhost(domains2)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([md1, md2], restart=False)
+        time.sleep(1)
+        stat = env.get_md_status(md1)
+        assert stat["renewal"]["last"]["status"] == 0
+        stat = env.get_md_status(md2)
+        assert stat["renewal"]["last"]["status"] == 0
+        nlines = open(args).readlines()
+        assert 2 == len(nlines)
diff --git a/test/modules/md/test_901_message.py b/test/modules/md/test_901_message.py
new file mode 100644 (file)
index 0000000..8d03bfd
--- /dev/null
@@ -0,0 +1,297 @@
+# test mod_md message support
+
+import json
+import os
+import time
+import pytest
+
+from .md_conf import MDConf, MDConf
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestMessage:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        env.APACHE_CONF_SRC = "data/test_auto"
+        acme.start(config='default')
+        env.check_acme()
+        env.clear_store()
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.clear_store()
+        self.test_domain = env.get_request_domain(request)
+        self.mcmd = os.path.join(env.test_dir, "../modules/md/message.py")
+        self.mlog = os.path.join(env.gen_dir, "message.log")
+        if os.path.isfile(self.mlog):
+            os.remove(self.mlog)
+
+    # test: signup with configured message cmd that is invalid
+    def test_md_901_001(self, env):
+        domain = self.test_domain
+        domains = [domain, "www." + domain]
+        conf = MDConf(env)
+        conf.add("MDMessageCmd blablabla")
+        conf.add_drive_mode("auto")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_file(env.store_staged_file(domain, 'job.json'))
+        stat = env.get_md_status(domain)
+        # this command should have failed and logged an error
+        assert stat["renewal"]["last"]["problem"] == "urn:org:apache:httpd:log:AH10109:"
+
+    # test: signup with configured message cmd that is valid but returns != 0
+    def test_md_901_002(self, env):
+        mcmd = os.path.join(env.test_dir, "../modules/md/notifail.py")
+        domain = self.test_domain
+        domains = [domain, "www." + domain]
+        conf = MDConf(env)
+        conf.add(f"MDMessageCmd {mcmd} {self.mlog}")
+        conf.add_drive_mode("auto")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_error(domain)
+        stat = env.get_md_status(domain)
+        # this command should have failed and logged an error
+        assert stat["renewal"]["last"]["problem"] == "urn:org:apache:httpd:log:AH10109:"
+
+    # test: signup with working message cmd and see that it logs the right things
+    def test_md_901_003(self, env):
+        domain = self.test_domain
+        domains = [domain, "www." + domain]
+        conf = MDConf(env)
+        conf.add(f"MDMessageCmd {self.mcmd} {self.mlog}")
+        conf.add_drive_mode("auto")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain], restart=False)
+        time.sleep(1)
+        stat = env.get_md_status(domain)
+        # this command did not fail and logged itself the correct information
+        assert stat["renewal"]["last"]["status"] == 0
+        assert stat["renewal"]["log"]["entries"]
+        assert stat["renewal"]["log"]["entries"][0]["type"] == "message-renewed"
+        # shut down server to make sure that md has completed 
+        assert env.apache_stop() == 0
+        nlines = open(self.mlog).readlines()
+        assert 3 == len(nlines)
+        nlines = [s.strip() for s in nlines]
+        assert "['{cmd}', '{logfile}', 'challenge-setup:http-01:{dns}', '{mdomain}']".format(
+            cmd=self.mcmd, logfile=self.mlog, mdomain=domain, dns=domains[0]) in nlines
+        assert "['{cmd}', '{logfile}', 'challenge-setup:http-01:{dns}', '{mdomain}']".format(
+            cmd=self.mcmd, logfile=self.mlog, mdomain=domain, dns=domains[1]) in nlines
+        assert nlines[2].strip() == "['{cmd}', '{logfile}', 'renewed', '{mdomain}']".format(
+            cmd=self.mcmd, logfile=self.mlog, mdomain=domain)
+
+    # test issue #145: 
+    # - a server renews a valid certificate and is not restarted when recommended
+    # - the job did not clear its next_run and was run over and over again
+    # - the job logged the re-verifications again and again. which was saved.
+    # - this eventually flushed out the "message-renew" log entry
+    # - which caused the renew message handling to trigger again and again
+    # the fix does:
+    # - reset the next run
+    # - no longer adds the re-validations to the log
+    # - messages only once
+    @pytest.mark.skipif(MDTestEnv.is_pebble(), reason="ACME server certs valid too long")
+    def test_md_901_004(self, env):
+        domain = self.test_domain
+        domains = [domain, "www." + domain]
+        conf = MDConf(env)
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain])
+        # force renew
+        conf = MDConf(env)
+        conf.add(f"MDMessageCmd {self.mcmd} {self.mlog}")
+        conf.add("MDRenewWindow 120d")
+        conf.add("MDActivationDelay -7d")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain], restart=False)
+        env.get_md_status(domain)
+        assert env.await_file(self.mlog)
+        nlines = open(self.mlog).readlines()
+        assert len(nlines) == 1
+        assert nlines[0].strip() == f"['{self.mcmd}', '{self.mlog}', 'renewed', '{domain}']"
+    
+    def test_md_901_010(self, env):
+        #  MD with static cert files, lifetime in renewal window, no message about renewal
+        domain = self.test_domain
+        domains = [domain, 'www.%s' % domain]
+        testpath = os.path.join(env.gen_dir, 'test_901_010')
+        # cert that is only 10 more days valid
+        env.create_self_signed_cert(domains, {"notBefore": -70, "notAfter": 20},
+                                    serial=901010, path=testpath)
+        cert_file = os.path.join(testpath, 'pubcert.pem')
+        pkey_file = os.path.join(testpath, 'privkey.pem')
+        assert os.path.exists(cert_file)
+        assert os.path.exists(pkey_file)
+        conf = MDConf(env)
+        conf.add(f"MDMessageCmd {self.mcmd} {self.mlog}")
+        conf.start_md(domains)
+        conf.add(f"MDCertificateFile {cert_file}")
+        conf.add(f"MDCertificateKeyFile {pkey_file}")
+        conf.end_md()
+        conf.add_vhost(domain)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert not os.path.isfile(self.mlog)
+        
+    def test_md_901_011(self, env):
+        # MD with static cert files, lifetime in warn window, check message
+        domain = self.test_domain
+        domains = [domain, f'www.{domain}']
+        testpath = os.path.join(env.gen_dir, 'test_901_011')
+        # cert that is only 10 more days valid
+        env.create_self_signed_cert(domains, {"notBefore": -85, "notAfter": 5},
+                                    serial=901011, path=testpath)
+        cert_file = os.path.join(testpath, 'pubcert.pem')
+        pkey_file = os.path.join(testpath, 'privkey.pem')
+        assert os.path.exists(cert_file)
+        assert os.path.exists(pkey_file)
+        conf = MDConf(env)
+        conf.add(f"MDMessageCmd {self.mcmd} {self.mlog}")
+        conf.start_md(domains)
+        conf.add(f"MDCertificateFile {cert_file}")
+        conf.add(f"MDCertificateKeyFile {pkey_file}")
+        conf.end_md()
+        conf.add_vhost(domain)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_file(self.mlog)
+        nlines = open(self.mlog).readlines()
+        assert len(nlines) == 1
+        assert nlines[0].strip() == f"['{self.mcmd}', '{self.mlog}', 'expiring', '{domain}']"
+        # check that we do not get it resend right away again
+        assert env.apache_restart() == 0
+        time.sleep(1)
+        nlines = open(self.mlog).readlines()
+        assert len(nlines) == 1
+        assert nlines[0].strip() == f"['{self.mcmd}', '{self.mlog}', 'expiring', '{domain}']"
+
+    # MD, check messages from stapling
+    @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+    def test_md_901_020(self, env):
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add(f"MDMessageCmd {self.mcmd} {self.mlog}")
+        conf.add_drive_mode("auto")
+        conf.add_md(domains)
+        conf.add("MDStapling on")
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain])
+        env.await_ocsp_status(domain)
+        assert env.await_file(self.mlog)
+        time.sleep(1)
+        nlines = open(self.mlog).readlines()
+        assert len(nlines) == 4
+        assert nlines[0].strip() == \
+               f"['{self.mcmd}', '{self.mlog}', 'challenge-setup:http-01:{domain}', '{domain}']"
+        assert nlines[1].strip() == \
+               f"['{self.mcmd}', '{self.mlog}', 'renewed', '{domain}']"
+        assert nlines[2].strip() == \
+               f"['{self.mcmd}', '{self.mlog}', 'installed', '{domain}']"
+        assert nlines[3].strip() == \
+               f"['{self.mcmd}', '{self.mlog}', 'ocsp-renewed', '{domain}']"
+
+    # test: while testing gh issue #146, it was noted that a failed renew notification never
+    # resets the MD activity.
+    @pytest.mark.skipif(MDTestEnv.is_pebble(), reason="ACME server certs valid too long")
+    def test_md_901_030(self, env):
+        domain = self.test_domain
+        domains = [domain, "www." + domain]
+        conf = MDConf(env)
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain])
+        # set the warn window that triggers right away and a failing message command
+        conf = MDConf(env)
+        conf.add(f"MDMessageCmd {env.test_dir}../modules/md/notifail.py {self.mlog}")
+        conf.add_md(domains)
+        conf.add("""
+            MDWarnWindow 100d
+            """)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        env.get_md_status(domain)
+        # this command should have failed and logged an error
+        # shut down server to make sure that md has completed
+        assert env.await_file(env.store_staged_file(domain, 'job.json'))
+        while True:
+            with open(env.store_staged_file(domain, 'job.json')) as f:
+                job = json.load(f)
+                if job["errors"] > 0:
+                    assert job["errors"] > 0,  "unexpected job result: {0}".format(job)
+                    assert job["last"]["problem"] == "urn:org:apache:httpd:log:AH10109:"
+                    break
+            time.sleep(0.1)
+        env.httpd_error_log.ignore_recent()
+
+        # reconfigure to a working notification command and restart
+        conf = MDConf(env)
+        conf.add(f"MDMessageCmd {self.mcmd} {self.mlog}")
+        conf.add_md(domains)
+        conf.add("""
+            MDWarnWindow 100d
+            """)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_file(self.mlog)
+        # we see the notification logged by the command
+        nlines = open(self.mlog).readlines()
+        assert len(nlines) == 1
+        assert nlines[0].strip() == f"['{self.mcmd}', '{self.mlog}', 'expiring', '{domain}']"
+        # the error needs to be gone
+        assert env.await_file(env.store_staged_file(domain, 'job.json'))
+        with open(env.store_staged_file(domain, 'job.json')) as f:
+            job = json.load(f)
+            assert job["errors"] == 0
+
+    # MD, check a failed challenge setup
+    def test_md_901_040(self, env):
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        mcmd = os.path.join(env.test_dir, "../modules/md/msg_fail_on.py")
+        conf.add(f"MDMessageCmd {mcmd} {self.mlog} challenge-setup")
+        conf.add_drive_mode("auto")
+        conf.add_md(domains)
+        conf.add_vhost(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_error(domain)
+        assert env.await_file(self.mlog)
+        time.sleep(1)
+        nlines = open(self.mlog).readlines()
+        assert len(nlines) == 2
+        assert nlines[0].strip() == \
+               f"['{mcmd}', '{self.mlog}', 'challenge-setup:http-01:{domain}', '{domain}']"
+        assert nlines[1].strip() == \
+               f"['{mcmd}', '{self.mlog}', 'errored', '{domain}']"
+        stat = env.get_md_status(domain)
+        # this command should have failed and logged an error
+        assert stat["renewal"]["last"]["problem"] == "challenge-setup-failure"
+
diff --git a/test/modules/md/test_910_cleanups.py b/test/modules/md/test_910_cleanups.py
new file mode 100644 (file)
index 0000000..1971fda
--- /dev/null
@@ -0,0 +1,54 @@
+# test mod_md cleanups and sanitation
+
+import os
+
+import pytest
+
+from .md_conf import MDConf
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestCleanups:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        env.APACHE_CONF_SRC = "data/test_auto"
+        acme.start(config='default')
+        env.check_acme()
+        env.clear_store()
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.clear_store()
+        self.test_domain = env.get_request_domain(request)
+
+    def teardown_method(self, method):
+        print("teardown_method: %s" % method.__name__)
+
+    def test_md_910_01(self, env):
+        # generate a simple MD
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add_drive_mode("manual")
+        conf.add_md(domains)
+        conf.add_vhost(domain)
+        conf.install()
+
+        # create valid/invalid challenges subdirs
+        challenges_dir = env.store_challenges()
+        dirs_before = ["aaa", "bbb", domain, "zzz"]
+        for name in dirs_before:
+            os.makedirs(os.path.join(challenges_dir, name))
+
+        assert env.apache_restart() == 0
+        # the one we use is still there
+        assert os.path.isdir(os.path.join(challenges_dir, domain))
+        # and the others are gone
+        missing_after = ["aaa", "bbb", "zzz"]
+        for name in missing_after:
+            assert not os.path.exists(os.path.join(challenges_dir, name))
diff --git a/test/modules/md/test_920_status.py b/test/modules/md/test_920_status.py
new file mode 100644 (file)
index 0000000..d8b2ede
--- /dev/null
@@ -0,0 +1,241 @@
+# test mod_md status resources
+
+import os
+import re
+import time
+
+import pytest
+
+from .md_conf import MDConf
+from shutil import copyfile
+
+from .md_env import MDTestEnv
+
+
+@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
+                    reason="no ACME test server configured")
+class TestStatus:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        acme.start(config='default')
+        env.check_acme()
+        env.clear_store()
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.clear_store()
+        self.test_domain = env.get_request_domain(request)
+
+    # simple MD, drive it, check status before activation
+    def test_md_920_001(self, env):
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add_md(domains)
+        conf.add_vhost(domain)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain], restart=False)
+        # we started without a valid certificate, so we expect /.httpd/certificate-status
+        # to not give information about one and - since we waited for the ACME signup
+        # to complete - to give information in 'renewal' about the new cert.
+        status = env.get_certificate_status(domain)
+        assert 'sha256-fingerprint' not in status
+        assert 'valid' not in status
+        assert 'renewal' in status
+        assert 'valid' in status['renewal']['cert']
+        assert 'sha256-fingerprint' in status['renewal']['cert']['rsa']
+        # restart and activate
+        # once activated, the staging must be gone and attributes exist for the active cert
+        assert env.apache_restart() == 0
+        status = env.get_certificate_status(domain)
+        assert 'renewal' not in status
+        assert 'sha256-fingerprint' in status['rsa']
+        assert 'valid' in status['rsa']
+        assert 'from' in status['rsa']['valid']
+
+    # simple MD, drive it, manipulate staged credentials and check status
+    def test_md_920_002(self, env):
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add_md(domains)
+        conf.add_vhost(domain)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain], restart=False)
+        # copy a real certificate from LE over to staging
+        staged_cert = os.path.join(env.store_dir, 'staging', domain, 'pubcert.pem')
+        real_cert = os.path.join(env.test_dir, '../modules/md/data', 'test_920', '002.pubcert')
+        assert copyfile(real_cert, staged_cert)
+        status = env.get_certificate_status(domain)
+        # status shows the copied cert's properties as staged
+        assert 'renewal' in status
+        assert 'Thu, 29 Aug 2019 16:06:35 GMT' == status['renewal']['cert']['rsa']['valid']['until']
+        assert 'Fri, 31 May 2019 16:06:35 GMT' == status['renewal']['cert']['rsa']['valid']['from']
+        assert '03039C464D454EDE79FCD2CAE859F668F269' == status['renewal']['cert']['rsa']['serial']
+        assert 'sha256-fingerprint' in status['renewal']['cert']['rsa']
+
+    # test if switching status off has effect
+    def test_md_920_003(self, env):
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add_md(domains)
+        conf.add("MDCertificateStatus off")
+        conf.add_vhost(domain)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain], restart=False)
+        status = env.get_certificate_status(domain)
+        assert not status
+
+    def test_md_920_004(self, env):
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add_md(domains)
+        conf.add("MDCertificateStatus off")
+        conf.add_vhost(domain)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain])
+        status = env.get_md_status("")
+        assert "version" in status
+        assert "managed-domains" in status
+        assert 1 == len(status["managed-domains"])
+
+    # get the status of a domain on base server
+    def test_md_920_010(self, env):
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env, std_vhosts=False, std_ports=False, text=f"""
+MDBaseServer on
+MDPortMap http:- https:{env.https_port}
+
+ServerName {domain}
+<IfModule ssl_module>
+SSLEngine on
+</IfModule>
+<IfModule tls_module>
+TLSListen {env.https_port}
+TLSStrictSNI off
+</IfModule>
+Protocols h2 http/1.1 acme-tls/1
+
+<Location "/server-status">
+    SetHandler server-status
+</Location>
+<Location "/md-status">
+    SetHandler md-status
+</Location>
+<VirtualHost *:{env.http_port}>
+  SSLEngine off
+</VirtualHost>
+            """)
+        conf.add_md(domains)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain], restart=False,
+                                    via_domain=env.http_addr, use_https=False)
+        status = env.get_md_status("", via_domain=env.http_addr, use_https=False)
+        assert "version" in status
+        assert "managed-domains" in status
+        assert 1 == len(status["managed-domains"])
+        # get the html page
+        status = env.get_server_status(via_domain=env.http_addr, use_https=False)
+        assert re.search(r'<h3>Managed Certificates</h3>', status, re.MULTILINE)
+        # get the ascii summary
+        status = env.get_server_status(query="?auto", via_domain=env.http_addr, use_https=False)
+        m = re.search(r'Managed Certificates: total=(\d+), ok=(\d+) renew=(\d+) errored=(\d+) ready=(\d+)',
+                      status, re.MULTILINE)
+        assert int(m.group(1)) == 1
+        assert int(m.group(2)) == 0
+        assert int(m.group(3)) == 1
+        assert int(m.group(4)) == 0
+        assert int(m.group(5)) == 1
+
+    def test_md_920_011(self, env):
+        # MD with static cert files in base server, see issue #161
+        domain = self.test_domain
+        domains = [domain, 'www.%s' % domain]
+        testpath = os.path.join(env.gen_dir, 'test_920_011')
+        # cert that is only 10 more days valid
+        env.create_self_signed_cert(domains, {"notBefore": -70, "notAfter": 20},
+                                    serial=920011, path=testpath)
+        cert_file = os.path.join(testpath, 'pubcert.pem')
+        pkey_file = os.path.join(testpath, 'privkey.pem')
+        assert os.path.exists(cert_file)
+        assert os.path.exists(pkey_file)
+        conf = MDConf(env, std_vhosts=False, std_ports=False, text=f"""
+        MDBaseServer on
+        MDPortMap http:- https:{env.https_port}
+
+        ServerName {domain}
+        <IfModule ssl_module>
+        SSLEngine on
+        </IfModule>
+        <IfModule tls_module>
+        TLSListen {env.https_port}
+        TLSStrictSNI off
+        </IfModule>
+        Protocols h2 http/1.1 acme-tls/1
+
+        <Location "/server-status">
+            SetHandler server-status
+        </Location>
+        <Location "/md-status">
+            SetHandler md-status
+        </Location>
+            """)
+        conf.start_md(domains)
+        conf.add(f"MDCertificateFile {cert_file}")
+        conf.add(f"MDCertificateKeyFile {pkey_file}")
+        conf.end_md()
+        conf.start_vhost([env.http_addr], port=env.http_port)
+        conf.add("SSLEngine off")
+        conf.end_vhost()
+        conf.install()
+        assert env.apache_restart() == 0
+        status = env.get_md_status(domain, via_domain=env.http_addr, use_https=False)
+        assert status
+        assert 'renewal' not in status
+        print(status)
+        assert status['state'] == env.MD_S_COMPLETE
+        assert status['renew-mode'] == 1  # manual
+
+    # MD with 2 certificates
+    def test_md_920_020(self, env):
+        domain = self.test_domain
+        domains = [domain]
+        conf = MDConf(env)
+        conf.add("MDStapling on")
+        conf.add("MDPrivateKeys secp256r1 RSA")
+        conf.add_md(domains)
+        conf.add_vhost(domain)
+        conf.install()
+        assert env.apache_restart() == 0
+        assert env.await_completion([domain], restart=False)
+        # In the stats JSON, we excpect 2 certificates under 'renewal'
+        stat = env.get_md_status(domain)
+        assert 'renewal' in stat
+        assert 'cert' in stat['renewal']
+        assert 'rsa' in stat['renewal']['cert']
+        assert 'secp256r1' in stat['renewal']['cert']
+        # In /.httpd/certificate-status 'renewal' we excpect 2 certificates
+        status = env.get_certificate_status(domain)
+        assert 'renewal' in status
+        assert 'cert' in status['renewal']
+        assert 'secp256r1' in status['renewal']['cert']
+        assert 'rsa' in status['renewal']['cert']
+        # restart and activate
+        # once activated, certs are listed in status
+        assert env.apache_restart() == 0
+        stat = env.get_md_status(domain)
+        assert 'cert' in stat
+        assert 'valid' in stat['cert']
+        for ktype in ['rsa', 'secp256r1']:
+            assert ktype in stat['cert']
+            if env.acme_server == 'boulder':
+                assert 'ocsp' in stat['cert'][ktype]