"""E2E: client-certificate mTLS publish chain (spec sections 20, 24). Phase deploy: CA -> MQTT client -> role with publish ACL -> client cert (CN=username) -> server cert -> mTLS listener -> deploy. State is saved to /tmp/e2e_state.json; the caller then RESTARTS mosquitto (a new listener needs a restart, not a reload — spec section 28) and runs phase verify. Phase verify: connects to mosquitto:8884 with the client certificate, publishes to an allowed topic (expect OK) and a forbidden topic (expect deny), then cleans everything up. Usage: python /tmp/e2e.py [--phase deploy|verify|all] """ import http.cookiejar import json import os import ssl import sys import tempfile import time import urllib.request BASE = "http://127.0.0.1:8000/api/v1" STATE = "/tmp/e2e_state.json" phase = "all" if "--phase" in sys.argv: phase = sys.argv[sys.argv.index("--phase") + 1] cj = http.cookiejar.CookieJar() opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) def call(method, path, body=None): data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request( f"{BASE}{path}", data=data, headers={"Content-Type": "application/json"}, method=method, ) resp = opener.open(req) return json.load(resp) if resp.status != 204 else None call("POST", "/auth/login", { "username": os.environ["ADMIN_INITIAL_USERNAME"], "password": os.environ["ADMIN_INITIAL_PASSWORD"], }) state = {} if phase == "verify": with open(STATE) as f: state = json.load(f) # 1. CA if phase in ("deploy", "all"): ca = call("POST", "/pki/ca", { "common_name": "E2E mTLS CA", "validity_days": 3650, "key_algorithm": "ecdsa-p256", }) print("CA:", ca["id"][:8]) # 2. MQTT client + role with a publish ACL (idempotent: clear any # residue from a previous aborted run first). for path in ( "/mqtt/clients/e2e-device", "/mqtt/roles/e2e-publisher", ): try: call("DELETE", path) except urllib.error.HTTPError: pass call("POST", "/mqtt/clients", {"username": "e2e-device"}) call("POST", "/mqtt/roles", { "rolename": "e2e-publisher", "acls": [{ "acltype": "publishClientSend", "topic": "e2e/%u/data/#", "allow": True, "priority": 1, }], }) call("POST", "/mqtt/clients/e2e-device/roles", {"rolename": "e2e-publisher", "priority": 1}) print("client + role + ACL ready") # 3. Client certificate (CN = username) bundle = call("POST", "/pki/certificates/client", { "username": "e2e-device", "ca_id": ca["id"], "validity_days": 365, }) print("client cert issued:", bundle["certificate_id"][:8]) # 4. Server certificate for the listener server_cert = call("POST", "/pki/certificates", { "ca_id": ca["id"], "common_name": "mosquitto", "validity_days": 365, "dns_sans": ["mosquitto", "localhost"], }) print("server cert issued:", server_cert["id"][:8]) # 5. mTLS listener on 8884 + deploy listener = call("POST", "/broker/listeners", { "name": "E2E mTLS", "port": 8884, "protocol": "mqtt", "tls_enabled": True, "tls_min_version": "tlsv1.2", "require_certificate": True, "use_identity_as_username": True, "cert_id": server_cert["id"], }) deploy = call("POST", "/broker/config/deploy", {"comment": "E2E mTLS test"}) print("deployed revision:", deploy.get("revision"), "broker_healthy:", deploy.get("broker_healthy")) state = { "ca": ca["id"], "bundle": bundle, "server_cert": server_cert["id"], "listener": listener["id"], } with open(STATE, "w") as f: json.dump(state, f) if phase == "deploy": print("state saved — restart mosquitto, then run --phase verify") sys.exit(0) # A NEW listener cannot be added by config reload (spec section 28) — # the broker must be restarted for it to start listening. The restart is # performed externally (docker compose restart mosquitto) between the # deploy phase and the connect phase; this script is invoked with # --phase deploy|verify to allow that. import sys import time phase = "all" if len(sys.argv) > 1 and sys.argv[1] == "--phase": phase = sys.argv[2] # 6. Connect with the client certificate and publish if phase in ("verify", "all"): import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion bundle = state["bundle"] tmp = tempfile.mkdtemp() cert_file = os.path.join(tmp, "dev.crt") key_file = os.path.join(tmp, "dev.key") ca_file = os.path.join(tmp, "ca.crt") open(cert_file, "w").write(bundle["certificate_pem"]) open(key_file, "w").write(bundle["private_key_pem"]) open(ca_file, "w").write(bundle["ca_chain_pem"]) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ctx.load_verify_locations(ca_file) ctx.load_cert_chain(cert_file, key_file) ctx.check_hostname = False # CN is "mosquitto"; connect by DNS name ctx.verify_mode = ssl.CERT_REQUIRED results = {} client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id="e2e-test") def on_connect(cl, u, f, rc, props): results["connack"] = rc client.on_connect = on_connect client.tls_set_context(ctx) # The backend container shares backend-net with mosquitto; Docker # DNS resolves the name (127.0.0.1 here is the BACKEND, not broker). client.connect("mosquitto", 8884, keepalive=30) client.loop_start() time.sleep(2) if results.get("connack") == 0: print("CONNECT: OK — certificate accepted, CN mapped to identity") allowed = client.publish("e2e/e2e-device/data/1", b"hello", qos=1) allowed.wait_for_publish(timeout=5) print("PUBLISH allowed topic rc:", allowed.rc, "(0 = OK)") denied = client.publish("forbidden/secret", b"x", qos=1) denied.wait_for_publish(timeout=5) print("PUBLISH forbidden topic rc:", denied.rc, "(non-zero = ACL deny enforced)") else: print("CONNECT FAILED rc:", results.get("connack")) client.loop_stop() # 7. Cleanup if phase in ("verify", "all"): def try_call(method, path, body=None): try: call(method, path, body) except urllib.error.HTTPError: pass try_call("DELETE", f"/broker/listeners/{state['listener']}") try_call("POST", "/broker/config/deploy", {"comment": "E2E cleanup"}) try_call("DELETE", f"/pki/certificates/{state['bundle']['certificate_id']}") try_call("DELETE", f"/pki/certificates/{state['server_cert']}") try_call("DELETE", f"/pki/certificates/{state['ca']}") try_call("DELETE", "/mqtt/clients/e2e-device") try_call("DELETE", "/mqtt/roles/e2e-publisher") print("cleanup done")