196 lines
6.7 KiB
Python
196 lines
6.7 KiB
Python
"""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") |