diff --git a/bin/get_ips b/bin/get_ips index e2daddbb..0a9b0bf2 100755 --- a/bin/get_ips +++ b/bin/get_ips @@ -6,10 +6,13 @@ [ "$IOTEMPOWER_ACTIVE" = "yes" ] || { echo "IoTempower not active, aborting." 1>&2;exit 1; } source <( iot_env ignore_system ) +source "$IOTEMPOWER_ROOT/bin/mqtt_client_options" + 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) + result=$(env "${mqtt_client_env[@]}" timeout --foreground 2 mosquitto_sub -C 1 $filter -v \ + -h "$IOTEMPOWER_MQTT_HOST" -p "$mqtt_port" "${mqtt_client_options[@]}" \ + -t "$topic"|cut -d/ -f3-|cut -d\ -f2) if [[ "$result" ]]; then echo "IP for $1:" >&2 echo "$result" @@ -25,8 +28,9 @@ else # find all ending in /ip filter="" while true; do echo -n "." - timeout --foreground 2 mosquitto_sub -C 1 $filter -v \ - -h "$IOTEMPOWER_MQTT_HOST" -t "$topic" | grep "/ip " > "$iplog" + env "${mqtt_client_env[@]}" timeout --foreground 2 mosquitto_sub -C 1 $filter -v \ + -h "$IOTEMPOWER_MQTT_HOST" -p "$mqtt_port" "${mqtt_client_options[@]}" \ + -t "$topic" | grep "/ip " > "$iplog" t=$(cat "$iplog"|cut -d\ -f1) ip=$(cat "$iplog"|cut -d\ -f2) if [[ ! "$t" ]]; then @@ -40,4 +44,3 @@ else # find all ending in /ip cat "$iplogall" | cut -d/ -f3- rm "$iplog" "$iplogall" fi - 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 diff --git a/bin/mqtt_action b/bin/mqtt_action index 473d9214..63ec67d2 100755 --- a/bin/mqtt_action +++ b/bin/mqtt_action @@ -55,22 +55,15 @@ else fi 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 +source "$IOTEMPOWER_ROOT/bin/mqtt_client_options" 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" \ +env "${mqtt_client_env[@]}" mosquitto_sub -h "$IOTEMPOWER_MQTT_HOST" -p "$mqtt_port" "${mqtt_client_options[@]}" -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..6cd3d2fa 100755 --- a/bin/mqtt_broker +++ b/bin/mqtt_broker @@ -21,6 +21,161 @@ APTEMP="$IOTEMPOWER_LOCAL/tmp/mosquitto" rm -rf "$APTEMP" &> /dev/null mkdir -p "$APTEMP" +mqtt_auth_enabled=false + +mqtt_fail() { + echo "$1" 1>&2 + exit 1 +} + +mqtt_create_password_file() { + local password_file="$1" + local password_file_tmp="${password_file}.tmp" + local old_umask="" + local result=0 + + rm -f "$password_file_tmp" + old_umask="$(umask)" + umask 077 + if printf '%s\n%s\n' "$IOTEMPOWER_MQTT_PW" "$IOTEMPOWER_MQTT_PW" \ + | mosquitto_passwd -c "$password_file_tmp" "$IOTEMPOWER_MQTT_USER" >/dev/null; then + chmod 600 "$password_file_tmp" || result=1 + mv -f "$password_file_tmp" "$password_file" || result=1 + chmod 600 "$password_file" || result=1 + else + result=1 + fi + umask "$old_umask" + if [[ "$result" != 0 ]]; then + rm -f "$password_file_tmp" + return 1 + fi +} + +mqtt_write_acl_file() { + local acl_file="$1" + local system_dir="" + local node_conf="" + local node_topic="" + + { + echo "user $IOTEMPOWER_MQTT_USER" + echo "topic read #" + echo "topic write iotempower/_cfg_/#" + if [[ "$IOTEMPOWER_MQTT_DISCOVERY_PREFIX" ]]; then + echo "topic write $IOTEMPOWER_MQTT_DISCOVERY_PREFIX/#" + fi + + if [[ "$IOTEMPOWER_SYSTEM_CONFIG" && -e "$IOTEMPOWER_SYSTEM_CONFIG" ]]; then + system_dir="$(dirname "$IOTEMPOWER_SYSTEM_CONFIG")" + while IFS= read -r -d '' node_conf; do + node_topic="$(mqtt_node_topic_from_path "$system_dir" "$node_conf")" || exit 1 + if [[ "$node_topic" ]]; then + echo "topic write $node_topic/#" + fi + done < <(find "$system_dir" -name node.conf -print0) + fi + } > "$acl_file" +} + +mqtt_node_topic_from_path() { + local system_dir="$1" + local node_conf="$2" + local node_dir="" + local rel_topic="" + local configured_topic="" + + system_dir="$(cd "$system_dir" && pwd -P)" || return 1 + node_dir="$(cd "$(dirname "$node_conf")" && pwd -P)" || return 1 + + if [[ "$node_dir" == "$system_dir" ]]; then + rel_topic="" + else + case "$node_dir" in + "$system_dir"/*) + rel_topic="${node_dir#"$system_dir"/}" + ;; + *) + return 1 + ;; + esac + fi + + configured_topic="$(mqtt_read_node_topic "$node_conf")" || return 1 + if [[ "$configured_topic" ]]; then + printf '%s' "$configured_topic" + return 0 + fi + + printf '%s' "$rel_topic" +} + +mqtt_read_node_topic() { + local node_conf="$1" + local line="" + local value="" + local parsed_topic="" + + while IFS= read -r line || [[ "$line" ]]; do + [[ "$line" =~ ^[[:space:]]*topic[[:space:]]*= ]] || continue + value="${line#*=}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + parsed_topic="$(mqtt_parse_config_value "$value")" || return 1 + done < "$node_conf" + + printf '%s' "$parsed_topic" +} + +mqtt_parse_config_value() { + local value="$1" + local result="" + local char="" + local next_char="" + local rest="" + + if [[ "$value" == \"* ]]; then + value="${value:1}" + while [[ "$value" ]]; do + char="${value:0:1}" + value="${value:1}" + if [[ "$char" == "\\" && "$value" ]]; then + next_char="${value:0:1}" + value="${value:1}" + result+="$next_char" + elif [[ "$char" == '"' ]]; then + rest="$value" + rest="${rest#"${rest%%[![:space:]]*}"}" + [[ ! "$rest" || "$rest" == \#* ]] || return 1 + printf '%s' "$result" + return 0 + else + result+="$char" + fi + done + return 1 + elif [[ "$value" == \'* ]]; then + value="${value:1}" + [[ "$value" == *"'"* ]] || return 1 + result="${value%%\'*}" + rest="${value#*\'}" + rest="${rest#"${rest%%[![:space:]]*}"}" + [[ ! "$rest" || "$rest" == \#* ]] || return 1 + printf '%s' "$result" + else + case "$value" in + *[[:space:]]*|*[\`\$\\]*) + return 1 + ;; + *) + value="${value%%#*}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" + ;; + esac + fi +} + while true; do IF1="$1" IP1="$2" @@ -61,6 +216,22 @@ while true; do echo "Listening on $IOTEMPOWER_MQTT_HOST." [[ "$IOTEMPOWER_MQTT_HOST2" ]] && echo "Also listening on $IOTEMPOWER_MQTT_HOST2." + mqtt_auth_enabled=false + if [[ "$IOTEMPOWER_MQTT_USER" || "$IOTEMPOWER_MQTT_PW" ]]; then + mqtt_auth_enabled=true + [[ "$IOTEMPOWER_MQTT_USER" && "$IOTEMPOWER_MQTT_PW" ]] \ + || mqtt_fail "MQTT user and password must both be set when MQTT auth is configured. Aborting." + [[ "$IOTEMPOWER_MQTT_USE_TLS" == 1 ]] || mqtt_fail "MQTT auth requires TLS. Aborting." + [[ "$IOTEMPOWER_MQTT_USER" != *[[:space:]]* ]] || mqtt_fail "MQTT username may not contain whitespace. Aborting." + [[ "$IOTEMPOWER_MQTT_USER" != *:* ]] || mqtt_fail "MQTT username may not contain colon. Aborting." + command -v mosquitto_passwd >/dev/null || mqtt_fail "MQTT auth requires mosquitto_passwd. Aborting." + + mqtt_create_password_file "$APTEMP/password_file" \ + || mqtt_fail "Failed to generate Mosquitto password file. Aborting." + mqtt_write_acl_file "$APTEMP/acl_file" || mqtt_fail "Failed to generate Mosquitto ACL file. Aborting." + chmod 600 "$APTEMP/acl_file" + fi + # Create mosquitto config - user is needed for root on docker MQTT_BROKER_USER="${USER:-$(id -un 2>/dev/null)}" MQTT_BROKER_USER_LINE="" @@ -73,14 +244,17 @@ 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 + if [[ "$mqtt_auth_enabled" == true ]]; then cat << EOF >> "$APTEMP/mosquitto.conf" -listener 1883 $IOTEMPOWER_MQTT_HOST2 +allow_anonymous false +password_file $APTEMP/password_file +acl_file $APTEMP/acl_file +EOF + else + cat << EOF >> "$APTEMP/mosquitto.conf" +allow_anonymous true EOF fi @@ -97,8 +271,26 @@ 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 + 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 +EOF + fi + else + cat << EOF >> "$APTEMP/mosquitto.conf" +listener 1883 127.0.0.1 +listener 1883 $IOTEMPOWER_MQTT_HOST +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_client_options b/bin/mqtt_client_options new file mode 100755 index 00000000..4c75b57a --- /dev/null +++ b/bin/mqtt_client_options @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +# Source after IoTempower MQTT configuration has been loaded. +# Produces: +# mqtt_port +# mqtt_client_options +# mqtt_client_env + +mqtt_port=1883 +mqtt_client_options=() +mqtt_client_env=() +_iotempower_mqtt_client_config_dir="" + +_iotempower_mqtt_client_cleanup() { + local status=$? + if [[ "$_iotempower_mqtt_client_config_dir" ]]; then + rm -rf -- "$_iotempower_mqtt_client_config_dir" + _iotempower_mqtt_client_config_dir="" + fi + return "$status" +} + +_iotempower_mqtt_client_signal_exit() { + local status="$1" + trap - INT TERM + exit "$status" +} + +_iotempower_mqtt_write_client_config() { + local config_file="$1" + + ( umask 077; : > "$config_file" ) || return 1 + { + printf -- '--username %s\n' "$IOTEMPOWER_MQTT_USER" + printf -- '--pw %s\n' "$IOTEMPOWER_MQTT_PW" + } > "$config_file" || return 1 + chmod 600 "$config_file" || return 1 +} + +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 + mqtt_client_options+=(--cafile "$IOTEMPOWER_MQTT_CERT_FOLDER/ca.crt") +fi + +if [[ "$IOTEMPOWER_MQTT_USER" || "$IOTEMPOWER_MQTT_PW" ]]; then + if [[ ! "$IOTEMPOWER_MQTT_USER" || ! "$IOTEMPOWER_MQTT_PW" ]]; then + echo "MQTT user and password must both be set when MQTT auth is configured. Aborting." 1>&2 + exit 1 + fi + if [[ "$IOTEMPOWER_MQTT_USE_TLS" != 1 ]]; then + echo "MQTT auth requires TLS. Aborting." 1>&2 + exit 1 + fi + _iotempower_mqtt_client_config_dir="$(mktemp -d "${TMPDIR:-/tmp}/iotempower-mqtt-client.XXXXXXXXXX")" || { + echo "Failed to create private MQTT client config directory. Aborting." 1>&2 + exit 1 + } + trap _iotempower_mqtt_client_cleanup EXIT + trap '_iotempower_mqtt_client_signal_exit 130' INT + trap '_iotempower_mqtt_client_signal_exit 143' TERM + + chmod 700 "$_iotempower_mqtt_client_config_dir" || { + echo "Failed to secure MQTT client config directory. Aborting." 1>&2 + exit 1 + } + + _iotempower_mqtt_write_client_config "$_iotempower_mqtt_client_config_dir/mosquitto_pub" || { + echo "Failed to write private mosquitto_pub config. Aborting." 1>&2 + exit 1 + } + _iotempower_mqtt_write_client_config "$_iotempower_mqtt_client_config_dir/mosquitto_sub" || { + echo "Failed to write private mosquitto_sub config. Aborting." 1>&2 + exit 1 + } + mqtt_client_env=( + IOTEMPOWER_MQTT_USER= + IOTEMPOWER_MQTT_PW= + XDG_CONFIG_HOME="$_iotempower_mqtt_client_config_dir" + ) +fi 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/bin/mqtt_listen b/bin/mqtt_listen index 1c5c671b..966582c5 100755 --- a/bin/mqtt_listen +++ b/bin/mqtt_listen @@ -42,14 +42,7 @@ else topic="#" 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 +source "$IOTEMPOWER_ROOT/bin/mqtt_client_options" -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 +env "${mqtt_client_env[@]}" mosquitto_sub -v -h "$IOTEMPOWER_MQTT_HOST" -p "$mqtt_port" "${mqtt_client_options[@]}" -t "$topic" diff --git a/bin/mqtt_send b/bin/mqtt_send index 987d2e48..ad5831c2 100755 --- a/bin/mqtt_send +++ b/bin/mqtt_send @@ -37,24 +37,7 @@ fi shift -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 - - -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 +source "$IOTEMPOWER_ROOT/bin/mqtt_client_options" -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 +env "${mqtt_client_env[@]}" mosquitto_pub -h "$IOTEMPOWER_MQTT_HOST" -p "$mqtt_port" "${mqtt_client_options[@]}" -t "$topic" -m "$*" diff --git a/bin/prepare_build_dir b/bin/prepare_build_dir index 167da6b4..520ca8a2 100755 --- a/bin/prepare_build_dir +++ b/bin/prepare_build_dir @@ -46,6 +46,15 @@ replace_if_new() { rm -rf "$1" } +c_string_literal() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\r'/\\r}" + value="${value//$'\n'/\\n}" + printf '%s' "$value" +} + copy_type_through_base() { # $1: source directory # $2: type (folder to copy) @@ -87,6 +96,17 @@ if [[ -e "node.conf" ]]; then # started from node-directory source "$IOTEMPOWER_ROOT/bin/read_node_config" node_type="$board" + if [[ "$mqtt_user" || "$mqtt_password" ]]; then + if [[ ! "$mqtt_user" || ! "$mqtt_password" ]]; then + echo "MQTT user and password must both be set when MQTT auth is configured." 1>&2 + exit 1 + fi + if [[ "$IOTEMPOWER_MQTT_USE_TLS" != 1 ]]; then + echo "MQTT auth requires TLS. Aborting." 1>&2 + exit 1 + fi + fi + # echo DEBUG prepare_build_dir # echo $IOTEMPOWER_AP_NAME_FULL # echo $IOTEMPOWER_AP_NAME @@ -145,24 +165,27 @@ if [[ -e "node.conf" ]]; then # started from node-directory fname="$dest_dir/src/config.h" cat > "$fname.new" << EOF -#define HOSTNAME "$node_name" -#define mqtt_topic "$topic" +#define HOSTNAME "$(c_string_literal "$node_name")" +#define mqtt_topic "$(c_string_literal "$topic")" EOF if [[ "$mqtt_server" && "$mqtt_server" != "_gateway" ]]; then cat >> "$fname.new" << EOF -#define mqtt_server "$mqtt_server" +#define mqtt_server "$(c_string_literal "$mqtt_server")" EOF fi - cat >> "$fname.new" << EOF -#define mqtt_user "$mqtt_user" -#define mqtt_password "$mqtt_password" + if [[ "$mqtt_user" || "$mqtt_password" ]]; then + cat >> "$fname.new" << EOF +#define mqtt_user "$(c_string_literal "$mqtt_user")" +#define mqtt_password "$(c_string_literal "$mqtt_password")" EOF + fi if [[ "$IOTEMPOWER_MQTT_DISCOVERY_PREFIX" ]]; then - echo '#define mqtt_discovery_prefix "'$IOTEMPOWER_MQTT_DISCOVERY_PREFIX'"' >> "$fname.new" + printf '#define mqtt_discovery_prefix "%s"\n' \ + "$(c_string_literal "$IOTEMPOWER_MQTT_DISCOVERY_PREFIX")" >> "$fname.new" fi if [[ "$IOTEMPOWER_MQTT_USE_TLS" == 1 ]]; then @@ -201,8 +224,8 @@ EOF # echo "// no wifi credentials" > "$dest_dir/src/wifi-config.h" fname="$dest_dir/src/wifi-config.h" cat > "$fname.new" << EOF -#define WIFI_SSID "$IOTEMPOWER_AP_NAME_FULL" -#define WIFI_PASSWORD "$IOTEMPOWER_AP_PASSWORD" +#define WIFI_SSID "$(c_string_literal "$IOTEMPOWER_AP_NAME_FULL")" +#define WIFI_PASSWORD "$(c_string_literal "$IOTEMPOWER_AP_PASSWORD")" EOF replace_if_new "$fname.new" "$fname" ## end: wifi-config.h creation diff --git a/bin/setup_iotempower_config b/bin/setup_iotempower_config index 564f719c..a597fd27 100644 --- a/bin/setup_iotempower_config +++ b/bin/setup_iotempower_config @@ -101,4 +101,4 @@ IOTEMPOWER_AP_HOSTNAME="iotgateway" EOF -echo "WiFi configuration has been written to wifi_config.conf" \ No newline at end of file +echo "WiFi configuration has been written to wifi_config.conf" diff --git a/bin/setup_iotempowerconf b/bin/setup_iotempowerconf index 46592475..1d79944a 100755 --- a/bin/setup_iotempowerconf +++ b/bin/setup_iotempowerconf @@ -98,4 +98,4 @@ IOTEMPOWER_AP_HOSTNAME="iotgateway" EOF -echo "WiFi configuration has been written to iotempower.conf" \ No newline at end of file +echo "WiFi configuration has been written to iotempower.conf" 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/lib/node_types/esp/src/main.cpp b/lib/node_types/esp/src/main.cpp index d0b0b899..ce0d19a5 100644 --- a/lib/node_types/esp/src/main.cpp +++ b/lib/node_types/esp/src/main.cpp @@ -766,7 +766,7 @@ void init_mqtt() { mqttClient.setClientId(my_hostname); // Set credentials if provided - #ifdef mqtt_user + #if defined(mqtt_user) && defined(mqtt_password) mqttClient.setCredentials(mqtt_user, mqtt_password); #endif diff --git a/tests/test_mqtt_auth_acl_config.py b/tests/test_mqtt_auth_acl_config.py new file mode 100644 index 00000000..e353590f --- /dev/null +++ b/tests/test_mqtt_auth_acl_config.py @@ -0,0 +1,585 @@ +import os +import subprocess +import time +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 _auth_system(tmp_path: Path, fakebin: Path, *, tls: bool = True) -> tuple[Path, Path, dict[str, str]]: + system_dir = tmp_path / "auth-system" + cert_dir = system_dir / "certs" + local_dir = tmp_path / "local" + node_dir = system_dir / "allowed-node" + cert_dir.mkdir(parents=True) + local_dir.mkdir() + node_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") + (node_dir / "node.conf").write_text('board="wemos d1 mini"\n', encoding="utf-8") + (system_dir / "system.conf").write_text( + f'IOTEMPOWER_MQTT_HOST="127.0.0.1"\n' + f"IOTEMPOWER_MQTT_USE_TLS={1 if tls else 0}\n" + f'IOTEMPOWER_MQTT_CERT_FOLDER="{cert_dir}"\n' + 'IOTEMPOWER_MQTT_USER="homeassistant"\n' + 'IOTEMPOWER_MQTT_PW="secretpw"\n' + 'IOTEMPOWER_MQTT_DISCOVERY_PREFIX="iotempower"\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 _fake_broker_tools(fakebin: Path) -> None: + _write_executable(fakebin / "pkill", "#!/usr/bin/env bash\nexit 0\n") + _write_executable( + fakebin / "mosquitto", + '#!/usr/bin/env bash\nprintf "%s\\n" "$@" > "$FAKE_MOSQUITTO_ARGS"\nsleep 0.1\nexit 0\n', + ) + _write_executable( + fakebin / "mosquitto_passwd", + '#!/usr/bin/env bash\nprintf "%s\\n" "$@" > "$FAKE_PASSWD_ARGS"\n' + 'if [[ "$1" == "-c" ]]; then read -r password1; read -r password2; ' + '[[ "$password1" == "$password2" ]] || exit 2; ' + 'printf "%s:hashed:%s\\n" "$3" "$password1" > "$2"; fi\n' + "exit 0\n", + ) + + +def _fake_mqtt_clients(fakebin: Path) -> Path: + args_file = fakebin / "mqtt.args" + client_prelude = ( + '#!/usr/bin/env bash\n' + 'printf "%s\\n" "$@" > "$FAKE_MQTT_ARGS"\n' + 'if [[ "$FAKE_MQTT_CONFIG_REPORT" ]]; then\n' + ' client="$(basename "$0")"\n' + ' config_file="${XDG_CONFIG_HOME:-}/$client"\n' + ' {\n' + ' printf "client=%s\\n" "$client"\n' + ' printf "xdg=%s\\n" "${XDG_CONFIG_HOME:-}"\n' + ' printf "env_user=%s\\n" "${IOTEMPOWER_MQTT_USER:-}"\n' + ' printf "env_pw=%s\\n" "${IOTEMPOWER_MQTT_PW:-}"\n' + ' if [[ -d "${XDG_CONFIG_HOME:-}" ]]; then\n' + ' printf "dir_mode=%s\\n" "$(stat -c "%a" "$XDG_CONFIG_HOME")"\n' + ' fi\n' + ' if [[ -f "$config_file" ]]; then\n' + ' printf "config=%s\\n" "$config_file"\n' + ' printf "config_mode=%s\\n" "$(stat -c "%a" "$config_file")"\n' + ' printf "config_body_begin\\n"\n' + ' cat "$config_file"\n' + ' fi\n' + ' } > "$FAKE_MQTT_CONFIG_REPORT"\n' + 'fi\n' + 'if [[ "$FAKE_MQTT_PID_FILE" ]]; then\n' + ' printf "%s\\n" "$$" > "$FAKE_MQTT_PID_FILE"\n' + ' while [[ ! -e "$FAKE_MQTT_RELEASE_FILE" ]]; do sleep 0.05; done\n' + 'fi\n' + ) + _write_executable( + fakebin / "mosquitto_pub", + client_prelude + 'exit "${FAKE_MQTT_EXIT_CODE:-0}"\n', + ) + _write_executable( + fakebin / "mosquitto_sub", + client_prelude + + '[[ "$FAKE_MQTT_OUTPUT" ]] && printf "%s\\n" "$FAKE_MQTT_OUTPUT"\n' + + 'exit "${FAKE_MQTT_EXIT_CODE:-0}"\n', + ) + return args_file + + +def _read_config_report(report_file: Path) -> tuple[dict[str, str], list[str]]: + lines = report_file.read_text(encoding="utf-8").splitlines() + body_start = lines.index("config_body_begin") + data = dict(line.split("=", 1) for line in lines[:body_start]) + return data, lines[body_start + 1 :] + + +def _assert_private_auth_config(report_file: Path, expected_client: str, *, cleaned_up: bool = True) -> None: + data, config_body = _read_config_report(report_file) + config_dir = Path(data["xdg"]) + + assert data["client"] == expected_client + assert data["env_user"] == "" + assert data["env_pw"] == "" + assert data["dir_mode"] == "700" + assert data["config"] == str(config_dir / expected_client) + assert data["config_mode"] == "600" + assert config_body == ["--username homeassistant", "--pw secretpw"] + if cleaned_up: + assert not config_dir.exists() + else: + assert config_dir.exists() + + +def _assert_client_uses_tls_auth_config( + args_file: Path, + cert_dir: Path, + report_file: Path, + expected_client: str, + *, + cleaned_up: bool = True, +) -> None: + args = args_file.read_text(encoding="utf-8").splitlines() + assert args[args.index("-p") + 1] == "8883" + assert args[args.index("--cafile") + 1] == str(cert_dir / "ca.crt") + assert "-u" not in args + assert "--username" not in args + assert "homeassistant" not in args + assert "-P" not in args + assert "--pw" not in args + assert "secretpw" not in args + assert "1883" not in args + _assert_private_auth_config(report_file, expected_client, cleaned_up=cleaned_up) + + +def test_mqtt_broker_auth_generates_password_acl_and_tls_only_config(tmp_path): + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + _fake_broker_tools(fakebin) + system_dir, _, env = _auth_system(tmp_path, fakebin) + env["FAKE_MOSQUITTO_ARGS"] = str(tmp_path / "mosquitto.args") + env["FAKE_PASSWD_ARGS"] = str(tmp_path / "passwd.args") + + result = subprocess.run( + ["timeout", "1", "mqtt_broker", "lo", "127.0.0.1"], + cwd=system_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + assert result.returncode in {0, 124} + mosquitto_dir = tmp_path / "local" / "tmp" / "mosquitto" + conf = (mosquitto_dir / "mosquitto.conf").read_text(encoding="utf-8") + acl = (mosquitto_dir / "acl_file").read_text(encoding="utf-8") + password_file = (mosquitto_dir / "password_file").read_text(encoding="utf-8") + passwd_args = (tmp_path / "passwd.args").read_text(encoding="utf-8").splitlines() + + assert "allow_anonymous false" in conf + assert f"password_file {mosquitto_dir / 'password_file'}" in conf + assert f"acl_file {mosquitto_dir / 'acl_file'}" in conf + assert "listener 8883 127.0.0.1" in conf + assert "listener 1883" not in conf + assert "user homeassistant" in acl + assert "topic read #" in acl + assert "topic write iotempower/_cfg_/#" in acl + assert "topic write iotempower/#" in acl + assert "topic write allowed-node/#" in acl + assert password_file == "homeassistant:hashed:secretpw\n" + assert passwd_args == ["-c", str(mosquitto_dir / "password_file.tmp"), "homeassistant"] + assert "secretpw" not in passwd_args + + +def test_mqtt_broker_acl_generation_does_not_source_node_conf(tmp_path): + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + _fake_broker_tools(fakebin) + system_dir, _, env = _auth_system(tmp_path, fakebin) + marker = tmp_path / "node_conf_marker" + (system_dir / "allowed-node" / "node.conf").write_text( + f'topic="$(touch${{IFS}}{marker})"\n', + encoding="utf-8", + ) + env["FAKE_MOSQUITTO_ARGS"] = str(tmp_path / "mosquitto.args") + env["FAKE_PASSWD_ARGS"] = str(tmp_path / "passwd.args") + + result = subprocess.run( + ["timeout", "1", "mqtt_broker", "lo", "127.0.0.1"], + cwd=system_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + assert result.returncode in {0, 124} + assert not marker.exists() + acl = (tmp_path / "local" / "tmp" / "mosquitto" / "acl_file").read_text(encoding="utf-8") + assert f"topic write $(touch${{IFS}}{marker})/#" in acl + + +def test_mqtt_broker_acl_honors_simple_custom_node_topic_without_sourcing(tmp_path): + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + _fake_broker_tools(fakebin) + system_dir, _, env = _auth_system(tmp_path, fakebin) + (system_dir / "allowed-node" / "node.conf").write_text( + 'topic="custom/node/topic"\n', + encoding="utf-8", + ) + env["FAKE_MOSQUITTO_ARGS"] = str(tmp_path / "mosquitto.args") + env["FAKE_PASSWD_ARGS"] = str(tmp_path / "passwd.args") + + result = subprocess.run( + ["timeout", "1", "mqtt_broker", "lo", "127.0.0.1"], + cwd=system_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + assert result.returncode in {0, 124} + acl = (tmp_path / "local" / "tmp" / "mosquitto" / "acl_file").read_text(encoding="utf-8") + assert "topic write custom/node/topic/#" in acl + assert "topic write allowed-node/#" not in acl + + +def test_mqtt_broker_acl_accepts_quoted_topic_with_trailing_comment(tmp_path): + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + _fake_broker_tools(fakebin) + system_dir, _, env = _auth_system(tmp_path, fakebin) + (system_dir / "allowed-node" / "node.conf").write_text( + 'topic="custom/topic" # comment\n', + encoding="utf-8", + ) + env["FAKE_MOSQUITTO_ARGS"] = str(tmp_path / "mosquitto.args") + env["FAKE_PASSWD_ARGS"] = str(tmp_path / "passwd.args") + + result = subprocess.run( + ["timeout", "1", "mqtt_broker", "lo", "127.0.0.1"], + cwd=system_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + assert result.returncode in {0, 124} + acl = (tmp_path / "local" / "tmp" / "mosquitto" / "acl_file").read_text(encoding="utf-8") + assert "topic write custom/topic/#" in acl + + +def test_mqtt_auth_rejects_password_without_user(tmp_path): + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + _fake_broker_tools(fakebin) + system_dir, cert_dir, env = _auth_system(tmp_path, fakebin) + (system_dir / "system.conf").write_text( + 'IOTEMPOWER_MQTT_HOST="127.0.0.1"\n' + 'IOTEMPOWER_MQTT_USE_TLS=1\n' + f'IOTEMPOWER_MQTT_CERT_FOLDER="{cert_dir}"\n' + 'IOTEMPOWER_MQTT_PW="password-without-user"\n', + encoding="utf-8", + ) + env["FAKE_MOSQUITTO_ARGS"] = str(tmp_path / "mosquitto.args") + env["FAKE_PASSWD_ARGS"] = str(tmp_path / "passwd.args") + + broker_result = subprocess.run( + ["mqtt_broker", "lo", "127.0.0.1"], + cwd=system_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + assert broker_result.returncode != 0 + assert "MQTT user and password must both be set" in broker_result.stderr + + args_file = _fake_mqtt_clients(fakebin) + env["FAKE_MQTT_ARGS"] = str(args_file) + helper_result = subprocess.run( + ["mqtt_send", "/allowed-node/probe", "hello"], + cwd=system_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + assert helper_result.returncode != 0 + assert "MQTT user and password must both be set" in helper_result.stderr + + +def test_mqtt_auth_requires_tls(tmp_path): + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + _fake_broker_tools(fakebin) + system_dir, _, env = _auth_system(tmp_path, fakebin, tls=False) + env["FAKE_MOSQUITTO_ARGS"] = str(tmp_path / "mosquitto.args") + env["FAKE_PASSWD_ARGS"] = str(tmp_path / "passwd.args") + + result = subprocess.run( + ["mqtt_broker", "lo", "127.0.0.1"], + cwd=system_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + assert result.returncode != 0 + assert "MQTT auth requires TLS" in result.stderr + + +def test_mqtt_helpers_include_tls_auth_options(tmp_path): + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + args_file = _fake_mqtt_clients(fakebin) + report_file = tmp_path / "mqtt-client.report" + system_dir, cert_dir, env = _auth_system(tmp_path, fakebin) + env["FAKE_MQTT_ARGS"] = str(args_file) + env["FAKE_MQTT_CONFIG_REPORT"] = str(report_file) + + subprocess.run(["mqtt_send", "/allowed-node/probe", "hello"], cwd=system_dir, env=env, check=True) + _assert_client_uses_tls_auth_config(args_file, cert_dir, report_file, "mosquitto_pub") + + subprocess.run(["mqtt_listen", "/allowed-node/probe"], cwd=system_dir, env=env, check=True) + _assert_client_uses_tls_auth_config(args_file, cert_dir, report_file, "mosquitto_sub") + + env["FAKE_MQTT_OUTPUT"] = "on" + subprocess.run( + ["mqtt_action", "/allowed-node/probe", "payload", "on", "true"], + cwd=system_dir, + env=env, + check=True, + ) + _assert_client_uses_tls_auth_config(args_file, cert_dir, report_file, "mosquitto_sub") + + env["FAKE_MQTT_OUTPUT"] = "iotempower/_cfg_/test-node/ip 192.0.2.55" + subprocess.run(["get_ips", "test-node"], cwd=system_dir, env=env, check=True) + _assert_client_uses_tls_auth_config(args_file, cert_dir, report_file, "mosquitto_sub") + + +def test_mqtt_helper_cleans_private_config_after_client_failure(tmp_path): + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + args_file = _fake_mqtt_clients(fakebin) + report_file = tmp_path / "mqtt-client.report" + system_dir, cert_dir, env = _auth_system(tmp_path, fakebin) + env["FAKE_MQTT_ARGS"] = str(args_file) + env["FAKE_MQTT_CONFIG_REPORT"] = str(report_file) + env["FAKE_MQTT_EXIT_CODE"] = "7" + + result = subprocess.run( + ["mqtt_send", "/allowed-node/probe", "hello"], + cwd=system_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + assert result.returncode == 7 + _assert_client_uses_tls_auth_config(args_file, cert_dir, report_file, "mosquitto_pub") + + +def test_mqtt_listen_process_argv_does_not_expose_password(tmp_path): + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + args_file = _fake_mqtt_clients(fakebin) + report_file = tmp_path / "mqtt-client.report" + pid_file = tmp_path / "mqtt-client.pid" + release_file = tmp_path / "mqtt-client.release" + system_dir, cert_dir, env = _auth_system(tmp_path, fakebin) + env["FAKE_MQTT_ARGS"] = str(args_file) + env["FAKE_MQTT_CONFIG_REPORT"] = str(report_file) + env["FAKE_MQTT_PID_FILE"] = str(pid_file) + env["FAKE_MQTT_RELEASE_FILE"] = str(release_file) + + proc = subprocess.Popen( + ["mqtt_listen", "/allowed-node/probe"], + cwd=system_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + for _ in range(100): + if pid_file.exists(): + break + if proc.poll() is not None: + raise AssertionError(proc.stderr.read()) + time.sleep(0.05) + else: + raise AssertionError("fake mosquitto_sub did not start") + + cmdline = Path(f"/proc/{pid_file.read_text(encoding='utf-8').strip()}/cmdline").read_bytes() + assert b"secretpw" not in cmdline + assert b"homeassistant" not in cmdline + assert b"-P" not in cmdline + assert b"--pw" not in cmdline + _assert_client_uses_tls_auth_config( + args_file, + cert_dir, + report_file, + "mosquitto_sub", + cleaned_up=False, + ) + finally: + release_file.write_text("", encoding="utf-8") + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.terminate() + proc.wait(timeout=5) + + assert proc.returncode == 0 + _assert_private_auth_config(report_file, "mosquitto_sub") + + +def test_prepare_build_dir_escapes_generated_mqtt_and_wifi_config(tmp_path): + system_dir = tmp_path / "system" + node_dir = system_dir / "review-node" + cert_dir = system_dir / "certs" + local_dir = tmp_path / "local" + node_dir.mkdir(parents=True) + cert_dir.mkdir() + local_dir.mkdir() + (cert_dir / "ca.crt").write_text( + "-----BEGIN CERTIFICATE-----\nREVIEWTEST\n-----END CERTIFICATE-----\n", + encoding="utf-8", + ) + (system_dir / "system.conf").write_text( + 'IOTEMPOWER_AP_NAME="review-ap"\n' + 'IOTEMPOWER_AP_PASSWORD="wifi\\"pass\\\\test"\n' + 'IOTEMPOWER_AP_IP="192.0.2.1"\n' + 'IOTEMPOWER_MQTT_HOST="broker\\"host"\n' + "IOTEMPOWER_MQTT_USE_TLS=1\n" + f'IOTEMPOWER_MQTT_CERT_FOLDER="{cert_dir}"\n' + 'IOTEMPOWER_MQTT_USER="user\\"name"\n' + 'IOTEMPOWER_MQTT_PW="pw\\\\with\\"quotes"\n' + 'IOTEMPOWER_MQTT_DISCOVERY_PREFIX="disc\\"prefix"\n', + encoding="utf-8", + ) + (node_dir / "node.conf").write_text('board="wemos d1 mini"\ntopic="review/node"\n', encoding="utf-8") + (node_dir / "setup.cpp").write_text("// minimal review setup\nvoid setup_iot() {}\n", encoding="utf-8") + (node_dir / "key.txt").write_text("0" * 64 + "\n", encoding="utf-8") + env = os.environ.copy() + env.update( + { + "IOTEMPOWER_ACTIVE": "yes", + "IOTEMPOWER_ROOT": str(REPO_ROOT), + "IOTEMPOWER_LOCAL": str(local_dir), + "IOTEMPOWER_COMPILE_CACHE": str(tmp_path / "compile_cache"), + } + ) + + subprocess.run([str(REPO_ROOT / "bin" / "prepare_build_dir")], cwd=node_dir, env=env, check=True) + + config_h = (node_dir / "build" / "src" / "config.h").read_text(encoding="utf-8") + wifi_config_h = (node_dir / "build" / "src" / "wifi-config.h").read_text(encoding="utf-8") + assert '#define mqtt_server "broker\\"host"' in config_h + assert '#define mqtt_user "user\\"name"' in config_h + assert '#define mqtt_password "pw\\\\with\\"quotes"' in config_h + assert '#define mqtt_discovery_prefix "disc\\"prefix"' in config_h + assert '#define WIFI_PASSWORD "wifi\\"pass\\\\test"' in wifi_config_h + + +def test_prepare_build_dir_rejects_partial_mqtt_credentials(tmp_path): + system_dir = tmp_path / "system" + node_dir = system_dir / "partial-node" + local_dir = tmp_path / "local" + node_dir.mkdir(parents=True) + local_dir.mkdir() + (system_dir / "system.conf").write_text( + 'IOTEMPOWER_AP_NAME="review-ap"\n' + 'IOTEMPOWER_AP_PASSWORD="review-pass"\n' + 'IOTEMPOWER_AP_IP="192.0.2.1"\n' + 'IOTEMPOWER_MQTT_HOST="broker"\n' + "IOTEMPOWER_MQTT_USE_TLS=0\n" + 'IOTEMPOWER_MQTT_USER="user-without-password"\n', + encoding="utf-8", + ) + (node_dir / "node.conf").write_text('board="wemos d1 mini"\ntopic="partial/node"\n', encoding="utf-8") + (node_dir / "setup.cpp").write_text("void setup_iot() {}\n", encoding="utf-8") + (node_dir / "key.txt").write_text("0" * 64 + "\n", encoding="utf-8") + env = os.environ.copy() + env.update( + { + "IOTEMPOWER_ACTIVE": "yes", + "IOTEMPOWER_ROOT": str(REPO_ROOT), + "IOTEMPOWER_LOCAL": str(local_dir), + "IOTEMPOWER_COMPILE_CACHE": str(tmp_path / "compile_cache"), + } + ) + + result = subprocess.run( + [str(REPO_ROOT / "bin" / "prepare_build_dir")], + cwd=node_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + assert result.returncode != 0 + assert "MQTT user and password must both be set" in result.stderr + assert not (node_dir / "build" / "src" / "config.h").exists() + assert not (node_dir / "build" / "src" / "config.h.new").exists() + + +def test_prepare_build_dir_rejects_mqtt_credentials_without_tls(tmp_path): + system_dir = tmp_path / "system" + node_dir = system_dir / "auth-without-tls-node" + local_dir = tmp_path / "local" + node_dir.mkdir(parents=True) + local_dir.mkdir() + (system_dir / "system.conf").write_text( + 'IOTEMPOWER_AP_NAME="review-ap"\n' + 'IOTEMPOWER_AP_PASSWORD="review-pass"\n' + 'IOTEMPOWER_AP_IP="192.0.2.1"\n' + 'IOTEMPOWER_MQTT_HOST="broker"\n' + "IOTEMPOWER_MQTT_USE_TLS=0\n" + 'IOTEMPOWER_MQTT_USER="firmware-user"\n' + 'IOTEMPOWER_MQTT_PW="firmware-password"\n', + encoding="utf-8", + ) + (node_dir / "node.conf").write_text( + 'board="wemos d1 mini"\ntopic="auth/without/tls"\n', + encoding="utf-8", + ) + (node_dir / "setup.cpp").write_text("void setup_iot() {}\n", encoding="utf-8") + (node_dir / "key.txt").write_text("0" * 64 + "\n", encoding="utf-8") + env = os.environ.copy() + env.update( + { + "IOTEMPOWER_ACTIVE": "yes", + "IOTEMPOWER_ROOT": str(REPO_ROOT), + "IOTEMPOWER_LOCAL": str(local_dir), + "IOTEMPOWER_COMPILE_CACHE": str(tmp_path / "compile_cache"), + } + ) + + result = subprocess.run( + [str(REPO_ROOT / "bin" / "prepare_build_dir")], + cwd=node_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + assert result.returncode != 0 + assert "MQTT auth requires TLS" in result.stderr + assert not (node_dir / "build" / "src" / "config.h").exists() + assert not (node_dir / "build" / "src" / "config.h.new").exists() 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, + ) 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)