"""Local Pebble ACME authority for real NPM issuance/renewal tests only. No production CA requests, DNS changes, or system trust-store modifications. See https://github.com/letsencrypt/pebble for the test server's protocol/limits. """ import hashlib import http.client import json import socket import ssl import subprocess import time import urllib.request import uuid class AcmeFixture: def __init__(self, root, http_port, run, port): self.root = root / 'acme' self.root.mkdir(mode=0o700) self.http_port, self.run = http_port, run self.api_port, self.management_port, self.dns_management = port(), port(), port() with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: probe.bind(('127.0.0.1', 0)) self.dns_port = probe.getsockname()[1] suffix = ''.join(chr(ord('a') + int(char, 16)) for char in uuid.uuid4().hex) self.ca_name, self.dns_name = 'credential_acme' + suffix, 'credential_dns' + suffix self.root_pem = b'' def start(self): self.run('openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '2', '-subj', '/CN=acme-fixture.test', '-addext', 'subjectAltName=DNS:acme-fixture.test,IP:127.0.0.1', '-addext', 'basicConstraints=critical,CA:TRUE', '-keyout', str(self.root/'api-key.pem'), '-out', str(self.root/'api-cert.pem')) config = {'pebble': { 'listenAddress': f'127.0.0.1:{self.api_port}', 'managementListenAddress': f'127.0.0.1:{self.management_port}', 'certificate': '/fixture/api-cert.pem', 'privateKey': '/fixture/api-key.pem', 'httpPort': self.http_port, 'tlsPort': 5001, 'externalAccountBindingRequired': False, 'retryAfter': {'authz': 1, 'order': 1}}} (self.root/'pebble.json').write_text(json.dumps(config)) self.run('podman', 'run', '-d', '--name', self.dns_name, '--network', 'host', '--memory', '128m', 'ghcr.io/letsencrypt/pebble-challtestsrv:latest', '-dnsserver', f'127.0.0.1:{self.dns_port}', '-management', f'127.0.0.1:{self.dns_management}', '-http01', '', '-https01', '', '-tlsalpn01', '', '-doh', '', '-defaultIPv4', '127.0.0.1', '-defaultIPv6', '') self.run('podman', 'run', '-d', '--name', self.ca_name, '--network', 'host', '--memory', '192m', '-e', 'PEBBLE_VA_NOSLEEP=1', '-e', 'PEBBLE_AUTHZREUSE=0', '-e', 'PEBBLE_WFE_NONCEREJECT=0', '-v', str(self.root)+':/fixture:ro', 'ghcr.io/letsencrypt/pebble:latest', '-config', '/fixture/pebble.json', '-dnsserver', f'127.0.0.1:{self.dns_port}', '-strict=false') context = ssl.create_default_context(cafile=str(self.root/'api-cert.pem')) deadline = time.monotonic() + 30 while True: try: with urllib.request.urlopen(f'https://127.0.0.1:{self.management_port}/roots/0', context=context, timeout=5) as response: self.root_pem = response.read() assert b'BEGIN CERTIFICATE' in self.root_pem (self.root/'root-ca.pem').write_bytes(self.root_pem) break except OSError: if time.monotonic() >= deadline: raise RuntimeError('Local ACME authority did not become ready') from None time.sleep(.2) def npm_args(self): # Explicit slirp host-loopback gateway: Podman's automatic host alias # can resolve to a LAN address where the loopback-only CA does not listen. return ['--add-host', 'acme-fixture.test:169.254.1.2', '-e', f'LE_SERVER=https://acme-fixture.test:{self.api_port}/dir', '-e', 'REQUESTS_CA_BUNDLE=/acme-fixture/api-cert.pem', '-v', str(self.root)+':/acme-fixture:ro'] def verify(self, api, sync, public, payload, tls_port, log): # Issue before creating this NPM host: challenge must use the default # ACME location, without making the management vhost publicly available. domain = 'prehost.example' certificate = api('/nginx/certificates', { 'provider': 'letsencrypt', 'domain_names': [domain], 'meta': {'dns_challenge': False}}, 'POST') assert certificate['provider'] == 'letsencrypt' host = api('/nginx/proxy-hosts', { **payload, 'domain_names': [domain], 'certificate_id': certificate['id'], 'ssl_forced': True}, 'POST') assert sync() time.sleep(.4) context = ssl.create_default_context(cafile=str(self.root/'root-ca.pem')) def served(): stream = context.wrap_socket(socket.create_connection(('127.0.0.1', tls_port), timeout=15), server_hostname=domain) fingerprint = hashlib.sha256(stream.getpeercert(binary_form=True)).hexdigest() connection = http.client.HTTPConnection(domain, tls_port, timeout=15) connection.sock = stream try: connection.request('GET', '/', headers={'Host': domain}) response = connection.getresponse() assert response.status == 200, 'Issued certificate route did not reach the app' response.read() return fingerprint finally: connection.close() before = served() assert public(host=domain)[0] == 301 initial_challenges = log.read_text().count('/.well-known/acme-challenge/') assert initial_challenges > 0, 'No actual ACME HTTP validation reached the bridge' api(f'/nginx/certificates/{certificate["id"]}/renew', {}, 'POST') assert sync(), 'Renewed certificate did not trigger bridge reload' time.sleep(.4) assert served() != before, 'Public TLS still serves the pre-renewal certificate' assert log.read_text().count('/.well-known/acme-challenge/') > initial_challenges assert public('/rpc/v1', host='unknown.example')[0] == 404 print('PASS actual local Pebble ACME: pre-host issuance, HTTP validation, forced-HTTPS renewal, new served certificate, unknown management route404', flush=True) api(f'/nginx/proxy-hosts/{host["id"]}', method='DELETE') assert sync() def close(self): for name in [self.ca_name, self.dns_name]: subprocess.run(['podman', 'rm', '-f', '--ignore', '--time', '3', name], check=True, capture_output=True, timeout=90)