diff options
Diffstat (limited to '')
56 files changed, 8616 insertions, 0 deletions
diff --git a/test/modules/md/__init__.py b/test/modules/md/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/test/modules/md/__init__.py diff --git a/test/modules/md/conftest.py b/test/modules/md/conftest.py new file mode 100755 index 0000000..04165a2 --- /dev/null +++ b/test/modules/md/conftest.py @@ -0,0 +1,92 @@ +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 + 'AH10373', # SSL errors on uncompleted handshakes + 'AH10398', # test on global store lock + ]) + + 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 .*'), + re.compile(r'.*AH\d*: unable to obtain global registry lock, .*'), + ]) + 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 index 0000000..37c23c3 --- /dev/null +++ b/test/modules/md/data/store_migrate/1.0/sample1/accounts/ACME-localhost-0000/account.json @@ -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 index 0000000..c4da46b --- /dev/null +++ b/test/modules/md/data/store_migrate/1.0/sample1/accounts/ACME-localhost-0000/account.pem @@ -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 index 0000000..33c771b --- /dev/null +++ b/test/modules/md/data/store_migrate/1.0/sample1/archive/7007-1502285564.org.1/md.json @@ -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 index 0000000..1a72637 --- /dev/null +++ b/test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/cert.pem @@ -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 index 0000000..267866e --- /dev/null +++ b/test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/chain.pem @@ -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 index 0000000..12e4e48 --- /dev/null +++ b/test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/md.json @@ -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 index 0000000..0438ddd --- /dev/null +++ b/test/modules/md/data/store_migrate/1.0/sample1/domains/7007-1502285564.org/pkey.pem @@ -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 index 0000000..a5bd7fb --- /dev/null +++ b/test/modules/md/data/store_migrate/1.0/sample1/httpd.json @@ -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 index 0000000..157782b --- /dev/null +++ b/test/modules/md/data/store_migrate/1.0/sample1/md_store.json @@ -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 index 0000000..02c9e87 --- /dev/null +++ b/test/modules/md/data/test_920/002.pubcert @@ -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 index 0000000..c1a8e06 --- /dev/null +++ b/test/modules/md/data/test_conf_validate/test_014.conf @@ -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 index 0000000..dd42072 --- /dev/null +++ b/test/modules/md/data/test_drive/test1.example.org.conf @@ -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 index 0000000..eb7b75f --- /dev/null +++ b/test/modules/md/data/test_roundtrip/temp.conf @@ -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 index 0000000..3afa467 --- /dev/null +++ b/test/modules/md/dns01.py @@ -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 index 0000000..557f907 --- /dev/null +++ b/test/modules/md/http_challenge_foobar.py @@ -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 index 0000000..36be347 --- /dev/null +++ b/test/modules/md/md_acme.py @@ -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 index 0000000..8cd99aa --- /dev/null +++ b/test/modules/md/md_cert_util.py @@ -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 index 0000000..2501d25 --- /dev/null +++ b/test/modules/md/md_certs.py @@ -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 index 0000000..19d4977 --- /dev/null +++ b/test/modules/md/md_conf.py @@ -0,0 +1,83 @@ +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) + self.add([ + "MDRetryDelay 1s", # speed up testing a little + ]) + 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 == "mod_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 index 0000000..e8e36e5 --- /dev/null +++ b/test/modules/md/md_env.py @@ -0,0 +1,613 @@ +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._tailscale_domain = "test.headless-chicken.ts.net" + 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'), + CertificateSpec(domains=[self._tailscale_domain]), + ]) + + 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 + + @property + def tailscale_domain(self): + return self._tailscale_domain + + 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 len(md['ca']['urls']) == 1 + assert md['ca']['urls'][0] == 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}" + return md + + 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 index 0000000..578289c --- /dev/null +++ b/test/modules/md/message.py @@ -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 index 0000000..fec95d4 --- /dev/null +++ b/test/modules/md/msg_fail_on.py @@ -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 index 0000000..a02cd39 --- /dev/null +++ b/test/modules/md/notifail.py @@ -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 index 0000000..c5971c8 --- /dev/null +++ b/test/modules/md/notify.py @@ -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 index 0000000..dd5bee5 --- /dev/null +++ b/test/modules/md/pebble/pebble-eab.json.template @@ -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 index 0000000..9c41271 --- /dev/null +++ b/test/modules/md/pebble/pebble.json.template @@ -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 index 0000000..995d40d --- /dev/null +++ b/test/modules/md/test_001_store.py @@ -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": { + "urls": [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": { + "urls": [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": { + "urls": [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": { + "urls": [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']['urls'][0] == 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']['urls'][0] == 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 index 0000000..d734b29 --- /dev/null +++ b/test/modules/md/test_010_store_migrate.py @@ -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 index 0000000..1a6d3fe --- /dev/null +++ b/test/modules/md/test_100_reg_add.py @@ -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": { + "urls": [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": { + "urls": [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": { + "urls": [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 index 0000000..71b50f8 --- /dev/null +++ b/test/modules/md/test_110_reg_update.py @@ -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": { + "urls": [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": { + "urls": [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'], { + "urls": [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, + "urls": [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'], { + "urls": [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", + "urls": [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", + "urls": [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": { + "urls": [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": { + "urls": [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'], { + "urls": [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'], { + "urls": [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 index 0000000..82e109f --- /dev/null +++ b/test/modules/md/test_120_reg_list.py @@ -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": { + "urls": [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 index 0000000..97f093e --- /dev/null +++ b/test/modules/md/test_202_acmev2_regs.py @@ -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 index 0000000..85371ba --- /dev/null +++ b/test/modules/md/test_300_conf_validate.py @@ -0,0 +1,390 @@ +# 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']['urls'][0] == url, f"CA url '{url}' not set in {md}" + + # vhost on another address, see #278 + def test_md_300_026(self, env): + assert env.apache_stop() == 0 + conf = MDConf(env) + domain = f"t300_026.{env.http_tld}" + conf.add(f""" + MDomain {domain} + """) + conf.add_vhost(port=env.http_port, domains=[domain], with_ssl=False) + conf.add(f""" + <VirtualHost 10.0.0.1:{env.https_port}> + ServerName {domain} + ServerAlias xxx.{env.http_tld} + SSLEngine on + </VirtualHost> + <VirtualHost 10.0.0.1:12345> + ServerName {domain} + SSLEngine on + </VirtualHost> + """) + conf.install() + assert env.apache_restart() == 0 + + # test case: configure more than 1 CA + @pytest.mark.parametrize("cas, should_work", [ + (["https://acme-v02.api.letsencrypt.org/directory"], True), + (["https://acme-v02.api.letsencrypt.org/directory", "buypass"], True), + (["x", "buypass"], False), + (["letsencrypt", "abc"], False), + (["letsencrypt", "buypass"], True), + ]) + def test_md_300_027(self, env, cas, should_work): + domain = f"test1.{env.http_tld}" + conf = MDConf(env, text=f""" + MDCertificateAuthority {' '.join(cas)} + MDRenewMode manual + """) + conf.add_md([domain]) + conf.install() + rv = env.apache_restart() + if should_work: + assert rv == 0, "Server did not accepted CAs '{}'".format(cas) + md = env.get_md_status(domain) + assert len(md['ca']['urls']) == len(cas) + else: + assert rv != 0, "Server should not have accepted CAs '{}'".format(cas) diff --git a/test/modules/md/test_310_conf_store.py b/test/modules/md/test_310_conf_store.py new file mode 100644 index 0000000..f2bb9c7 --- /dev/null +++ b/test/modules/md/test_310_conf_store.py @@ -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 successful: {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 index 0000000..eb754f2 --- /dev/null +++ b/test/modules/md/test_502_acmev2_drive.py @@ -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 index 0000000..9ff87e5 --- /dev/null +++ b/test/modules/md/test_602_roundtrip.py @@ -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 index 0000000..8e8f5f1 --- /dev/null +++ b/test/modules/md/test_702_auto.py @@ -0,0 +1,756 @@ +import os +import time + +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]) + md = env.check_md_complete(domain) + assert md['ca']['url'], f"URL of CA used not set in md: {md}" + # + # 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 index 0000000..23b311c --- /dev/null +++ b/test/modules/md/test_720_wildcard.py @@ -0,0 +1,254 @@ +# 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 + + # test case: a plain name, only dns-01 configured, + # http-01 should not be intercepted. See #279 + def test_md_720_008(self, env): + dns01cmd = os.path.join(env.test_dir, "../modules/md/dns01.py") + domain = self.test_domain + domains = [domain] + + conf = MDConf(env) + conf.add("MDCAChallenges dns-01") + conf.add(f"MDChallengeDns01 {dns01cmd}") + conf.add_md(domains) + conf.add_vhost(domains) + conf.add("LogLevel http:trace4") + conf.install() + + challengedir = os.path.join(env.server_dir, "htdocs/test1/.well-known/acme-challenge") + env.mkpath(challengedir) + content = b'not a challenge' + with open(os.path.join(challengedir, "123456"), "wb") as fd: + fd.write(content) + + # restart, check that md is in store + assert env.apache_restart() == 0 + env.check_md(domains) + # await drive completion + assert env.await_completion([domain], restart=False) + # access a fake http-01 challenge on the domain + r = env.curl_get(f"http://{domain}:{env.http_port}/.well-known/acme-challenge/123456") + assert r.response['status'] == 200 + assert r.response['body'] == content + assert env.apache_restart() == 0 + env.check_md_complete(domain) diff --git a/test/modules/md/test_730_static.py b/test/modules/md/test_730_static.py new file mode 100644 index 0000000..f7f7b4b --- /dev/null +++ b/test/modules/md/test_730_static.py @@ -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 index 0000000..670c9ab --- /dev/null +++ b/test/modules/md/test_740_acme_errors.py @@ -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 index 0000000..49b4e78 --- /dev/null +++ b/test/modules/md/test_741_setup_errors.py @@ -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 index 0000000..af1be95 --- /dev/null +++ b/test/modules/md/test_750_eab.py @@ -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 index 0000000..5cbd642 --- /dev/null +++ b/test/modules/md/test_751_sectigo.py @@ -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 index 0000000..1884665 --- /dev/null +++ b/test/modules/md/test_752_zerossl.py @@ -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_780_tailscale.py b/test/modules/md/test_780_tailscale.py new file mode 100644 index 0000000..84a266b --- /dev/null +++ b/test/modules/md/test_780_tailscale.py @@ -0,0 +1,186 @@ +import os +import re +import socket +import sys +from threading import Thread + +import pytest + +from .md_conf import MDConf + + +class TailscaleFaker: + + def __init__(self, env, path): + self.env = env + self._uds_path = path + self._done = False + + def start(self): + def process(self): + self._socket.listen(1) + self._process() + + try: + os.unlink(self._uds_path) + except OSError: + if os.path.exists(self._uds_path): + raise + self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._socket.bind(self._uds_path) + self._thread = Thread(target=process, daemon=True, args=[self]) + self._thread.start() + + def stop(self): + self._done = True + self._socket.close() + + def send_error(self, c, status, reason): + c.sendall(f"""HTTP/1.1 {status} {reason}\r +Server: TailscaleFaker\r +Content-Length: 0\r +Connection: close\r +\r +""".encode()) + + def send_data(self, c, ctype: str, data: bytes): + c.sendall(f"""HTTP/1.1 200 OK\r +Server: TailscaleFaker\r +Content-Type: {ctype}\r +Content-Length: {len(data)}\r +Connection: close\r +\r +""".encode() + data) + + def _process(self): + # a http server written on a sunny afternooon + while self._done is False: + try: + c, client_address = self._socket.accept() + try: + data = c.recv(1024) + lines = data.decode().splitlines() + m = re.match(r'^(?P<method>\w+)\s+(?P<uri>\S+)\s+HTTP/1.1', lines[0]) + if m is None: + self.send_error(c, 400, "Bad Request") + continue + uri = m.group('uri') + m = re.match(r'/localapi/v0/cert/(?P<domain>\S+)\?type=(?P<type>\w+)', uri) + if m is None: + self.send_error(c, 404, "Not Found") + continue + domain = m.group('domain') + cred_type = m.group('type') + creds = self.env.get_credentials_for_name(domain) + sys.stderr.write(f"lookup domain={domain}, type={cred_type} -> {creds}\n") + if creds is None or len(creds) == 0: + self.send_error(c, 404, "Not Found") + continue + if cred_type == 'crt': + self.send_data(c, "text/plain", creds[0].cert_pem) + pass + elif cred_type == 'key': + self.send_data(c, "text/plain", creds[0].pkey_pem) + else: + self.send_error(c, 404, "Not Found") + continue + finally: + c.close() + + except ConnectionAbortedError: + self._done = True + + +class TestTailscale: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env, acme): + UDS_PATH = f"{env.gen_dir}/tailscale.sock" + TestTailscale.UDS_PATH = UDS_PATH + faker = TailscaleFaker(env=env, path=UDS_PATH) + faker.start() + env.APACHE_CONF_SRC = "data/test_auto" + acme.start(config='default') + env.clear_store() + MDConf(env).install() + assert env.apache_restart() == 0 + yield + faker.stop() + + @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 using `tailscale` as protocol, wrong path + def test_md_780_001(self, env): + domain = env.tailscale_domain + # generate config with one MD + domains = [domain] + socket_path = '/xxx' + conf = MDConf(env, admin="admin@" + domain) + conf.start_md(domains) + conf.add([ + "MDCertificateProtocol tailscale", + f"MDCertificateAuthority file://{socket_path}", + ]) + conf.end_md() + conf.add_vhost(domains) + conf.install() + # restart and watch it fail due to wrong tailscale unix socket path + assert env.apache_restart() == 0 + md = env.await_error(domain) + assert md + assert md['renewal']['errors'] > 0 + assert md['renewal']['last']['status-description'] == 'No such file or directory' + assert md['renewal']['last']['detail'] == \ + f"tailscale socket not available, may not be up: {socket_path}" + + # create a MD using `tailscale` as protocol, path to faker, should succeed + def test_md_780_002(self, env): + domain = env.tailscale_domain + # generate config with one MD + domains = [domain] + socket_path = '/xxx' + conf = MDConf(env, admin="admin@" + domain) + conf.start_md(domains) + conf.add([ + "MDCertificateProtocol tailscale", + f"MDCertificateAuthority file://{self.UDS_PATH}", + ]) + conf.end_md() + conf.add_vhost(domains) + conf.install() + # restart and watch it fail due to wrong tailscale unix socket path + assert env.apache_restart() == 0 + assert env.await_completion(domains) + assert env.apache_restart() == 0 + env.check_md_complete(domain) + + # create a MD using `tailscale` as protocol, but domain name not assigned by tailscale + def test_md_780_003(self, env): + domain = "test.not-correct.ts.net" + # generate config with one MD + domains = [domain] + socket_path = '/xxx' + conf = MDConf(env, admin="admin@" + domain) + conf.start_md(domains) + conf.add([ + "MDCertificateProtocol tailscale", + f"MDCertificateAuthority file://{self.UDS_PATH}", + ]) + conf.end_md() + conf.add_vhost(domains) + conf.install() + # restart and watch it fail due to wrong tailscale unix socket path + assert env.apache_restart() == 0 + md = env.await_error(domain) + assert md + assert md['renewal']['errors'] > 0 + assert md['renewal']['last']['status-description'] == 'No such file or directory' + assert md['renewal']['last']['detail'] == "retrieving certificate from tailscale" diff --git a/test/modules/md/test_790_failover.py b/test/modules/md/test_790_failover.py new file mode 100644 index 0000000..a939912 --- /dev/null +++ b/test/modules/md/test_790_failover.py @@ -0,0 +1,87 @@ +import pytest + +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 TestFailover: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env, acme): + acme.start(config='default') + env.check_acme() + env.clear_store() + conf = MDConf(env) + conf.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) + + # set 2 ACME certificata authority, valid + invalid + def test_md_790_001(self, env): + domain = self.test_domain + # generate config with one MD + domains = [domain, "www." + domain] + conf = MDConf(env) + conf.add([ + "MDRetryDelay 200ms", # speed up failovers + ]) + conf.start_md(domains) + conf.add([ + f"MDCertificateAuthority {env.acme_url} https://does-not-exist/dir" + ]) + conf.end_md() + conf.add_vhost(domains) + conf.install() + assert env.apache_restart() == 0 + assert env.await_completion([domain]) + env.check_md_complete(domain) + + # set 2 ACME certificata authority, invalid + valid + def test_md_790_002(self, env): + domain = self.test_domain + # generate config with one MD + domains = [domain, "www." + domain] + conf = MDConf(env) + conf.add([ + "MDRetryDelay 100ms", # speed up failovers + "MDRetryFailover 2", + ]) + conf.start_md(domains) + conf.add([ + f"MDCertificateAuthority https://does-not-exist/dir {env.acme_url} " + ]) + conf.end_md() + conf.add_vhost(domains) + conf.install() + assert env.apache_restart() == 0 + assert env.await_completion([domain]) + env.check_md_complete(domain) + + # set 3 ACME certificata authority, invalid + invalid + valid + def test_md_790_003(self, env): + domain = self.test_domain + # generate config with one MD + domains = [domain, "www." + domain] + conf = MDConf(env) + conf.add([ + "MDRetryDelay 100ms", # speed up failovers + "MDRetryFailover 2", + ]) + conf.start_md(domains) + conf.add([ + f"MDCertificateAuthority https://does-not-exist/dir https://does-not-either/ " + f"{env.acme_url} " + ]) + conf.end_md() + conf.add_vhost(domains) + conf.install() + assert env.apache_restart() == 0 + assert env.await_completion([domain]) + env.check_md_complete(domain) diff --git a/test/modules/md/test_800_must_staple.py b/test/modules/md/test_800_must_staple.py new file mode 100644 index 0000000..32edee3 --- /dev/null +++ b/test/modules/md/test_800_must_staple.py @@ -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 'ocsp' not in stat or 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 index 0000000..5c03602 --- /dev/null +++ b/test/modules/md/test_801_stapling.py @@ -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 index 0000000..5c31018 --- /dev/null +++ b/test/modules/md/test_810_ec.py @@ -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="only 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_820_locks.py b/test/modules/md/test_820_locks.py new file mode 100644 index 0000000..9436912 --- /dev/null +++ b/test/modules/md/test_820_locks.py @@ -0,0 +1,73 @@ +import os + +import pytest +from filelock import Timeout, FileLock + +from .md_cert_util import MDCertUtil +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 TestLocks: + + @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() + + @pytest.fixture(autouse=True, scope='function') + def _method_scope(self, env, request): + env.clear_store() + self.test_domain = env.get_request_domain(request) + + def configure_httpd(self, env, domains, add_lines=""): + conf = MDConf(env) + conf.add(add_lines) + conf.add_md(domains) + conf.add_vhost(domains) + conf.install() + + # normal renewal with store locks activated + def test_md_820_001(self, env): + domain = self.test_domain + self.configure_httpd(env, [domain], add_lines=[ + "MDStoreLocks 1s" + ]) + assert env.apache_restart() == 0 + assert env.await_completion([domain]) + + # renewal, with global lock held during restert + @pytest.mark.skip("does not work in our CI") + def test_md_820_002(self, env): + domain = self.test_domain + self.configure_httpd(env, [domain], add_lines=[ + "MDStoreLocks 1s" + ]) + assert env.apache_restart() == 0 + assert env.await_completion([domain]) + # we have a cert now, add a dns name to force renewal + certa = MDCertUtil(env.store_domain_file(domain, 'pubcert.pem')) + self.configure_httpd(env, [domain, f"x.{domain}"], add_lines=[ + "MDStoreLocks 1s" + ]) + assert env.apache_restart() == 0 + # await new cert, but do not restart, keeps the cert in staging + assert env.await_completion([domain], restart=False) + # obtain global lock and restart + lockfile = os.path.join(env.store_dir, "store.lock") + with FileLock(lockfile): + assert env.apache_restart() == 0 + # lock should have prevented staging from being activated, + # meaning we will have the same cert + certb = MDCertUtil(env.store_domain_file(domain, 'pubcert.pem')) + assert certa.same_serial_as(certb) + # now restart without lock + assert env.apache_restart() == 0 + certc = MDCertUtil(env.store_domain_file(domain, 'pubcert.pem')) + assert not certa.same_serial_as(certc) + + diff --git a/test/modules/md/test_900_notify.py b/test/modules/md/test_900_notify.py new file mode 100644 index 0000000..30e0742 --- /dev/null +++ b/test/modules/md/test_900_notify.py @@ -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 index 0000000..8d03bfd --- /dev/null +++ b/test/modules/md/test_901_message.py @@ -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 index 0000000..1971fda --- /dev/null +++ b/test/modules/md/test_910_cleanups.py @@ -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 index 0000000..c89ce6d --- /dev/null +++ b/test/modules/md/test_920_status.py @@ -0,0 +1,245 @@ +# 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'ManagedCertificatesTotal: (\d+)', status, re.MULTILINE) + assert m, status + assert int(m.group(1)) == 1 + m = re.search(r'ManagedCertificatesOK: (\d+)', status, re.MULTILINE) + assert int(m.group(1)) == 0 + m = re.search(r'ManagedCertificatesRenew: (\d+)', status, re.MULTILINE) + assert int(m.group(1)) == 1 + m = re.search(r'ManagedCertificatesErrored: (\d+)', status, re.MULTILINE) + assert int(m.group(1)) == 0 + m = re.search(r'ManagedCertificatesReady: (\d+)', status, re.MULTILINE) + assert int(m.group(1)) == 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 expect 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 expect 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] |