From 81ba4d63ac9a9eeee81051d13556e8d6aea3367f Mon Sep 17 00:00:00 2001 From: Oliver Vainikko Date: Thu, 7 May 2026 17:16:40 +0000 Subject: [PATCH 1/3] Fix MQTT TLS certificate identity generation --- bin/mqtt_generate_certificates | 31 +++++++++-- tests/test_mqtt_tls_cert_generation.py | 71 ++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 tests/test_mqtt_tls_cert_generation.py diff --git a/bin/mqtt_generate_certificates b/bin/mqtt_generate_certificates index 5f91736e..28128ecd 100755 --- a/bin/mqtt_generate_certificates +++ b/bin/mqtt_generate_certificates @@ -20,18 +20,41 @@ source "$IOTEMPOWER_ROOT/bin/read_system_config" # to get the IOTEMPOWER_MQTT_HO cert_path="$IOTEMPOWER_MQTT_CERT_FOLDER" +[[ "$IOTEMPOWER_MQTT_HOST" ]] || { echo "IOTEMPOWER_MQTT_HOST needs to be set. Aborting." 1>&2; exit 1; } + echo "$system_name" mkdir -p "$cert_path" cd "$cert_path" || { echo "Can't change to $cert_path. Aborting." 1>&2; exit 1; } +server_ext="$(mktemp)" +trap 'rm -f "$server_ext"' EXIT + +if [[ "$IOTEMPOWER_MQTT_HOST" =~ ^[0-9]+(\.[0-9]+){3}$ ]]; then + # BearSSL gets the MQTT host as a string, so keep a DNS copy for IPv4 hosts. + san="IP:$IOTEMPOWER_MQTT_HOST,DNS:$IOTEMPOWER_MQTT_HOST" + verify_name_arg="-verify_ip" +else + san="DNS:$IOTEMPOWER_MQTT_HOST" + verify_name_arg="-verify_hostname" +fi + +cat > "$server_ext" << EOF +[server_cert] +basicConstraints = CA:FALSE +keyUsage = critical,digitalSignature +extendedKeyUsage = serverAuth +subjectAltName = $san +EOF + openssl ecparam -genkey -name prime256v1 -out ca.key -openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt -subj "/CN=${system_name}CA" +openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt -subj "/CN=${system_name}CA" \ + -addext "basicConstraints = critical,CA:TRUE,pathlen:0" \ + -addext "keyUsage = critical,keyCertSign,cRLSign" openssl ecparam -genkey -name prime256v1 -out server.key openssl req -new -key server.key -out server.csr -subj "/CN=$IOTEMPOWER_MQTT_HOST" -openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 3650 -sha256 - -openssl verify -CAfile ca.crt server.crt +openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 3650 -sha256 -extensions server_cert -extfile "$server_ext" +openssl verify -CAfile ca.crt -purpose sslserver "$verify_name_arg" "$IOTEMPOWER_MQTT_HOST" server.crt diff --git a/tests/test_mqtt_tls_cert_generation.py b/tests/test_mqtt_tls_cert_generation.py new file mode 100644 index 00000000..11a823a3 --- /dev/null +++ b/tests/test_mqtt_tls_cert_generation.py @@ -0,0 +1,71 @@ +import os +import subprocess +from pathlib import Path + +import pytest + + +def _run(command, cwd, **kwargs): + return subprocess.run( + command, + cwd=cwd, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=True, + **kwargs, + ) + + +def _generate_certificates(tmp_path: Path, host: str) -> Path: + system_dir = tmp_path / "tls-system" + cert_dir = system_dir / "certs" + system_dir.mkdir() + (system_dir / "system.conf").write_text( + f'IOTEMPOWER_MQTT_HOST="{host}"\n' + f'IOTEMPOWER_MQTT_CERT_FOLDER="{cert_dir}"\n', + encoding="utf-8", + ) + + env = os.environ.copy() + if env.get("IOTEMPOWER_ACTIVE") != "yes": + pytest.skip("IoTempower environment is not active") + + _run(["mqtt_generate_certificates"], cwd=system_dir, env=env) + return cert_dir + + +@pytest.mark.parametrize( + ("host", "verify_arg", "expected_sans"), + [ + ("192.0.2.10", "-verify_ip", ["IP Address:192.0.2.10", "DNS:192.0.2.10"]), + ("mqtt-test.local", "-verify_hostname", ["DNS:mqtt-test.local"]), + ], +) +def test_mqtt_tls_certificates_include_verifiable_san(tmp_path, host, verify_arg, expected_sans): + cert_dir = _generate_certificates(tmp_path, host) + + cert_text = _run( + ["openssl", "x509", "-in", "server.crt", "-noout", "-text"], + cwd=cert_dir, + ).stdout + for expected_san in expected_sans: + assert expected_san in cert_text + assert "TLS Web Server Authentication" in cert_text + assert "Digital Signature" in cert_text + assert "Public Key Algorithm: id-ecPublicKey" in cert_text + + _run( + [ + "openssl", + "verify", + "-CAfile", + "ca.crt", + "-purpose", + "sslserver", + verify_arg, + host, + "server.crt", + ], + cwd=cert_dir, + ) From 62661c2c6e9bfe85758d53e59626ea15584494aa Mon Sep 17 00:00:00 2001 From: Oliver Vainikko Date: Sat, 9 May 2026 17:37:46 +0000 Subject: [PATCH 2/3] Use TLS-only MQTT mode for broker and helpers --- bin/get_ips | 18 ++++- bin/mqtt_action | 8 +- bin/mqtt_broker | 30 ++++--- bin/mqtt_listen | 8 +- bin/mqtt_send | 18 ++--- doc/mqtt-with-tls.rst | 3 +- tests/test_mqtt_tls_only_mode.py | 132 +++++++++++++++++++++++++++++++ 7 files changed, 185 insertions(+), 32 deletions(-) create mode 100644 tests/test_mqtt_tls_only_mode.py diff --git a/bin/get_ips b/bin/get_ips index e2daddbb..3258df4e 100755 --- a/bin/get_ips +++ b/bin/get_ips @@ -6,10 +6,22 @@ [ "$IOTEMPOWER_ACTIVE" = "yes" ] || { echo "IoTempower not active, aborting." 1>&2;exit 1; } source <( iot_env ignore_system ) +mqtt_port=1883 +ca_file_option=() +if [[ "$IOTEMPOWER_MQTT_USE_TLS" == 1 ]]; then + if [[ ! "$IOTEMPOWER_MQTT_CERT_FOLDER" ]]; then + echo "MQTT TLS enabled, but no certificate folder set. Aborting." 1>&2 + exit 1 + fi + mqtt_port=8883 + ca_file_option=(--cafile "$IOTEMPOWER_MQTT_CERT_FOLDER/ca.crt") +fi + if [[ "$1" ]]; then topic="iotempower/_cfg_/$1/ip" result=$(timeout --foreground 2 mosquitto_sub -C 1 $filter -v \ - -h "$IOTEMPOWER_MQTT_HOST" -t "$topic"|cut -d/ -f3-|cut -d\ -f2) + -h "$IOTEMPOWER_MQTT_HOST" -p "$mqtt_port" "${ca_file_option[@]}" \ + -t "$topic"|cut -d/ -f3-|cut -d\ -f2) if [[ "$result" ]]; then echo "IP for $1:" >&2 echo "$result" @@ -26,7 +38,8 @@ else # find all ending in /ip while true; do echo -n "." timeout --foreground 2 mosquitto_sub -C 1 $filter -v \ - -h "$IOTEMPOWER_MQTT_HOST" -t "$topic" | grep "/ip " > "$iplog" + -h "$IOTEMPOWER_MQTT_HOST" -p "$mqtt_port" "${ca_file_option[@]}" \ + -t "$topic" | grep "/ip " > "$iplog" t=$(cat "$iplog"|cut -d\ -f1) ip=$(cat "$iplog"|cut -d\ -f2) if [[ ! "$t" ]]; then @@ -40,4 +53,3 @@ else # find all ending in /ip cat "$iplogall" | cut -d/ -f3- rm "$iplog" "$iplogall" fi - diff --git a/bin/mqtt_action b/bin/mqtt_action index 473d9214..0253a2f6 100755 --- a/bin/mqtt_action +++ b/bin/mqtt_action @@ -55,22 +55,24 @@ else fi fi -ca_file_option="" +mqtt_port=1883 +ca_file_option=() if [[ "$IOTEMPOWER_MQTT_USE_TLS" == 1 ]]; then if [[ ! "$IOTEMPOWER_MQTT_CERT_FOLDER" ]]; then echo "MQTT TLS enabled, but no certificate folder set. Aborting." 1>&2 exit 1 fi + mqtt_port=8883 ca_file_option=(--cafile "$IOTEMPOWER_MQTT_CERT_FOLDER/ca.crt") fi last_data="123ulno.net321" -echo "Subscribing to mqtt://$IOTEMPOWER_MQTT_HOST/$topic " >&2 +echo "Subscribing to mqtt://$IOTEMPOWER_MQTT_HOST:$mqtt_port/$topic " >&2 echo "with trigger $trigger_type for data $trigger_data" >&2 echo "executing: $@ " >&2 -mosquitto_sub -h "$IOTEMPOWER_MQTT_HOST" "${ca_file_option[@]}" -t "$topic" \ +mosquitto_sub -h "$IOTEMPOWER_MQTT_HOST" -p "$mqtt_port" "${ca_file_option[@]}" -t "$topic" \ | while read data; do echo "mqtt_action: received $data" >&2 changed="" diff --git a/bin/mqtt_broker b/bin/mqtt_broker index 8a68dbe1..daf2d949 100755 --- a/bin/mqtt_broker +++ b/bin/mqtt_broker @@ -73,17 +73,8 @@ persistence_location $APTEMP/mosquitto.db log_dest stdout log_type error log_type warning -listener 1883 127.0.0.1 -listener 1883 $IOTEMPOWER_MQTT_HOST -allow_anonymous true EOF - if [[ "$IOTEMPOWER_MQTT_HOST2" ]]; then - cat << EOF >> "$APTEMP/mosquitto.conf" -listener 1883 $IOTEMPOWER_MQTT_HOST2 -EOF - fi - if [[ "$IOTEMPOWER_MQTT_USE_TLS" == 1 ]]; then if [[ ! "$IOTEMPOWER_MQTT_CERT_FOLDER" ]]; then echo "MQTT TLS enabled, but no certificate folder set. Aborting." 1>&2 @@ -99,6 +90,27 @@ keyfile $IOTEMPOWER_MQTT_CERT_FOLDER/server.key require_certificate false allow_anonymous true EOF + if [[ "$IOTEMPOWER_MQTT_HOST2" ]]; then + cat << EOF >> "$APTEMP/mosquitto.conf" +listener 8883 $IOTEMPOWER_MQTT_HOST2 +cafile $IOTEMPOWER_MQTT_CERT_FOLDER/ca.crt +certfile $IOTEMPOWER_MQTT_CERT_FOLDER/server.crt +keyfile $IOTEMPOWER_MQTT_CERT_FOLDER/server.key +require_certificate false +allow_anonymous true +EOF + fi + else + cat << EOF >> "$APTEMP/mosquitto.conf" +listener 1883 127.0.0.1 +listener 1883 $IOTEMPOWER_MQTT_HOST +allow_anonymous true +EOF + if [[ "$IOTEMPOWER_MQTT_HOST2" ]]; then + cat << EOF >> "$APTEMP/mosquitto.conf" +listener 1883 $IOTEMPOWER_MQTT_HOST2 +EOF + fi fi # Create bridge if needed diff --git a/bin/mqtt_listen b/bin/mqtt_listen index 1c5c671b..136f8e6b 100755 --- a/bin/mqtt_listen +++ b/bin/mqtt_listen @@ -42,14 +42,16 @@ else topic="#" fi -ca_file_option="" +mqtt_port=1883 +ca_file_option=() if [[ "$IOTEMPOWER_MQTT_USE_TLS" == 1 ]]; then if [[ ! "$IOTEMPOWER_MQTT_CERT_FOLDER" ]]; then echo "MQTT TLS enabled, but no certificate folder set. Aborting." 1>&2 exit 1 fi + mqtt_port=8883 ca_file_option=(--cafile "$IOTEMPOWER_MQTT_CERT_FOLDER/ca.crt") fi -echo "Subscribing and listening to mqtt://$IOTEMPOWER_MQTT_HOST/$topic." >&2 -mosquitto_sub -v -h "$IOTEMPOWER_MQTT_HOST" ${ca_file_option[@]} -t "$topic" +echo "Subscribing and listening to mqtt://$IOTEMPOWER_MQTT_HOST:$mqtt_port/$topic." >&2 +mosquitto_sub -v -h "$IOTEMPOWER_MQTT_HOST" -p "$mqtt_port" "${ca_file_option[@]}" -t "$topic" diff --git a/bin/mqtt_send b/bin/mqtt_send index 987d2e48..76a18858 100755 --- a/bin/mqtt_send +++ b/bin/mqtt_send @@ -37,24 +37,16 @@ fi shift -ca_file_option="" +mqtt_port=1883 +ca_file_option=() if [[ "$IOTEMPOWER_MQTT_USE_TLS" == 1 ]]; then if [[ ! "$IOTEMPOWER_MQTT_CERT_FOLDER" ]]; then echo "MQTT TLS enabled, but no certificate folder set. Aborting." 1>&2 exit 1 fi + mqtt_port=8883 ca_file_option=(--cafile "$IOTEMPOWER_MQTT_CERT_FOLDER/ca.crt") fi - -ca_file_option="" -if [[ "$IOTEMPOWER_MQTT_USE_TLS" == 1 ]]; then - if [[ ! "$IOTEMPOWER_MQTT_CERT_FOLDER" ]]; then - echo "MQTT TLS enabled, but no certificate folder set. Aborting." 1>&2 - exit 1 - fi - ca_file_option=(--cafile "$IOTEMPOWER_MQTT_CERT_FOLDER/ca.crt") -fi - -echo "Trying to send $@ to mqtt://$IOTEMPOWER_MQTT_HOST/$topic." >&2 -exec mosquitto_pub -h "$IOTEMPOWER_MQTT_HOST" ${ca_file_option[@]} -t "$topic" -m "$*" +echo "Trying to send $@ to mqtt://$IOTEMPOWER_MQTT_HOST:$mqtt_port/$topic." >&2 +exec mosquitto_pub -h "$IOTEMPOWER_MQTT_HOST" -p "$mqtt_port" "${ca_file_option[@]}" -t "$topic" -m "$*" diff --git a/doc/mqtt-with-tls.rst b/doc/mqtt-with-tls.rst index 443af5ac..0c0f4fe7 100644 --- a/doc/mqtt-with-tls.rst +++ b/doc/mqtt-with-tls.rst @@ -14,7 +14,8 @@ Using the IoTempower MQTT Broker If using the IoTempower's included MQTT broker (i.e. with ``mqtt_starter``), then if run in a system folder, where the ``IOTEMPOWER_MQTT_USE_TLS`` is set to ``1``, the broker will automatically use the certificates from the ``IOTEMPOWER_MQTT_CERT_FOLDER`` -environment variable and expose port ``8883`` for secure MQTT communication. +environment variable and expose port ``8883`` for secure MQTT communication. +In this mode, the included broker does not expose the plaintext MQTT listener on port ``1883``. The ``mqtt_listen`` and ``mqtt_send`` commands will also use the same port and certificate, if the variables are set. diff --git a/tests/test_mqtt_tls_only_mode.py b/tests/test_mqtt_tls_only_mode.py new file mode 100644 index 00000000..7239be22 --- /dev/null +++ b/tests/test_mqtt_tls_only_mode.py @@ -0,0 +1,132 @@ +import os +import subprocess +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _write_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def _system_env(tmp_path: Path, fakebin: Path) -> tuple[Path, Path, dict[str, str]]: + system_dir = tmp_path / "tls-system" + cert_dir = system_dir / "certs" + local_dir = tmp_path / "local" + cert_dir.mkdir(parents=True) + local_dir.mkdir() + (cert_dir / "ca.crt").write_text("test-ca\n", encoding="utf-8") + (cert_dir / "server.crt").write_text("test-server-cert\n", encoding="utf-8") + (cert_dir / "server.key").write_text("test-server-key\n", encoding="utf-8") + (system_dir / "system.conf").write_text( + f'IOTEMPOWER_MQTT_HOST="192.0.2.10"\n' + 'IOTEMPOWER_MQTT_USE_TLS=1\n' + f'IOTEMPOWER_MQTT_CERT_FOLDER="{cert_dir}"\n', + encoding="utf-8", + ) + + env = os.environ.copy() + env.update( + { + "IOTEMPOWER_ACTIVE": "yes", + "IOTEMPOWER_ROOT": str(REPO_ROOT), + "IOTEMPOWER_LOCAL": str(local_dir), + "PATH": f"{fakebin}:{REPO_ROOT / 'bin'}:{env['PATH']}", + } + ) + return system_dir, cert_dir, env + + +def _write_fake_mqtt_clients(fakebin: Path) -> Path: + args_file = fakebin / "mqtt.args" + _write_executable( + fakebin / "mosquitto_pub", + '#!/usr/bin/env bash\nprintf "%s\\n" "$@" > "$FAKE_MQTT_ARGS"\n', + ) + _write_executable( + fakebin / "mosquitto_sub", + '#!/usr/bin/env bash\nprintf "%s\\n" "$@" > "$FAKE_MQTT_ARGS"\n' + '[[ "$FAKE_MQTT_OUTPUT" ]] && printf "%s\\n" "$FAKE_MQTT_OUTPUT"\n' + "exit 0\n", + ) + return args_file + + +def _assert_uses_tls_port(args_file: Path, cert_dir: Path) -> None: + args = args_file.read_text(encoding="utf-8").splitlines() + assert "-p" in args + assert args[args.index("-p") + 1] == "8883" + assert "--cafile" in args + assert args[args.index("--cafile") + 1] == str(cert_dir / "ca.crt") + assert "1883" not in args + + +def test_mqtt_broker_tls_mode_has_no_plaintext_listeners(tmp_path): + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + _write_executable(fakebin / "pkill", "#!/usr/bin/env bash\nexit 0\n") + _write_executable(fakebin / "mosquitto", "#!/usr/bin/env bash\nsleep 0.1\nexit 0\n") + system_dir, _, env = _system_env(tmp_path, fakebin) + + result = subprocess.run( + ["timeout", "1", "mqtt_broker", "eth0", "192.0.2.10"], + cwd=system_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + assert result.returncode in {0, 124} + conf = tmp_path / "local" / "tmp" / "mosquitto" / "mosquitto.conf" + conf_text = conf.read_text(encoding="utf-8") + assert "listener 8883 192.0.2.10" in conf_text + assert "listener 1883" not in conf_text + + +def test_tls_helpers_use_8883_and_cafile(tmp_path): + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + args_file = _write_fake_mqtt_clients(fakebin) + system_dir, cert_dir, env = _system_env(tmp_path, fakebin) + env["FAKE_MQTT_ARGS"] = str(args_file) + + subprocess.run(["mqtt_send", "/audit/topic", "hello"], cwd=system_dir, env=env, check=True) + _assert_uses_tls_port(args_file, cert_dir) + + subprocess.run(["mqtt_listen", "/audit/topic"], cwd=system_dir, env=env, check=True) + _assert_uses_tls_port(args_file, cert_dir) + + env["FAKE_MQTT_OUTPUT"] = "on" + subprocess.run( + ["mqtt_action", "/audit/topic", "payload", "on", "true"], + cwd=system_dir, + env=env, + check=True, + ) + _assert_uses_tls_port(args_file, cert_dir) + + +def test_get_ips_uses_tls_port_for_discovery(tmp_path): + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + args_file = _write_fake_mqtt_clients(fakebin) + system_dir, cert_dir, env = _system_env(tmp_path, fakebin) + env["FAKE_MQTT_ARGS"] = str(args_file) + env["FAKE_MQTT_OUTPUT"] = "iotempower/_cfg_/test-node/ip 192.0.2.55" + + result = subprocess.run( + ["get_ips", "test-node"], + cwd=system_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + + assert result.stdout.strip() == "192.0.2.55" + _assert_uses_tls_port(args_file, cert_dir) From a65d8041987d8c70350572e975c53a1ea702fc1c Mon Sep 17 00:00:00 2001 From: Oliver Vainikko Date: Mon, 11 May 2026 15:50:24 +0000 Subject: [PATCH 3/3] Fix iot_env config variable propagation --- bin/iot_env | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/iot_env b/bin/iot_env index 1f70a142..24b464a9 100755 --- a/bin/iot_env +++ b/bin/iot_env @@ -28,4 +28,6 @@ else fi # output and quote result -env|grep -E "^IOTEMPOWER"|sed 's/^\([^=]*\)=\(.*\)$/export\ \1=\"\2\"/g' +for var_name in $(compgen -A variable IOTEMPOWER); do + printf 'export %s=%q\n' "$var_name" "${!var_name}" +done