From d0200b5d9743553d9d7a59382252da1af8e045be Mon Sep 17 00:00:00 2001 From: Ethan Shold Date: Sat, 6 Dec 2025 17:42:04 -0600 Subject: [PATCH] feat: add tls support --- README.md | 6 +- linux2mqtt/const.py | 6 ++ linux2mqtt/linux2mqtt.py | 146 ++++++++++++++++++++++++++++++++- linux2mqtt/type_definitions.py | 15 ++++ 4 files changed, 168 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 85b6eb9..b7614a1 100755 --- a/README.md +++ b/README.md @@ -130,12 +130,16 @@ A few assumptions: * **Home Assistant is already configured to use a MQTT broker.** Setting up MQTT and HA is beyond the scope of this documentation. However, there are a lot of great tutorials on YouTube. An external broker (or as add-on) like [Mosquitto](https://mosquitto.org/) will need to be installed and the HA MQTT integration configured. * **The HA MQTT integration is configured to use `homeassistant` as the MQTT autodiscovery prefix.** This is the default for the integration and also the default for `linux2mqtt`. If you have changed this from the default, use the `--homeassistant-prefix` parameter to specify the correct one. -* **You're not using TLS to connect to the MQTT broker.** Currently `linux2mqtt` only works with unencrypted connections. Username / password authentication can be specified with the `--username` and `--password` parameters, but TLS encryption is not yet supported. +* **TLS is optional and disabled by default.** Enable encryption with `--tls` when your broker requires it (see _TLS Support_ below). Username / password authentication can be combined with TLS. Using the default prefix and a system name of `NUC` (the name of the server), the following state can be found in the "States" section of Developer Tools in HA: ![Home Assistant Developer Tools screenshot](https://github.com/miaucl/linux2mqtt/blob/master/media/dev_tools_example.png?raw=true) +### TLS Support + +Enable encrypted MQTT connections with `--tls`. When TLS is enabled and `--port` is omitted, the client defaults to port `8883`; override it with `--port` if your broker listens elsewhere. The system certificate store is trusted by default, but you can point `--tls-ca` to a custom bundle. Provide both `--tls-cert` and `--tls-key` to authenticate with client certificates. Use `--tls-insecure` only for testing, as it disables certificate verification. + ### Lovelace Dashboards To visualize, use the excellent [mini-graph-card](https://github.com/kalkih/mini-graph-card) custom card for Lovelace dashboards. It's highly-customizable and fairly easy to make great looking charts in HA. Here is a very basic config example of using the metrics produced by `linux2mqtt` to display the past 12 hours of CPU and memory utilization on an Intel NUC server: diff --git a/linux2mqtt/const.py b/linux2mqtt/const.py index 94d95f0..502d9a3 100644 --- a/linux2mqtt/const.py +++ b/linux2mqtt/const.py @@ -9,6 +9,7 @@ HOMEASSISTANT_DISABLE_ATTRIBUTES_DEFAULT = False MQTT_CLIENT_ID_DEFAULT = "linux2mqtt" MQTT_PORT_DEFAULT = 1883 +MQTT_TLS_PORT_DEFAULT = 8883 MQTT_TIMEOUT_DEFAULT = 30 # s MQTT_TOPIC_PREFIX_DEFAULT = "linux" MQTT_QOS_DEFAULT = 1 @@ -44,5 +45,10 @@ "mqtt_topic_prefix": MQTT_TOPIC_PREFIX_DEFAULT, "mqtt_qos": MQTT_QOS_DEFAULT, "interval": DEFAULT_INTERVAL, + "mqtt_tls_enabled": False, + "mqtt_tls_ca_cert": None, + "mqtt_tls_client_cert": None, + "mqtt_tls_client_key": None, + "mqtt_tls_insecure": False, } ) diff --git a/linux2mqtt/linux2mqtt.py b/linux2mqtt/linux2mqtt.py index 0451a01..b9d65e6 100755 --- a/linux2mqtt/linux2mqtt.py +++ b/linux2mqtt/linux2mqtt.py @@ -9,9 +9,10 @@ from queue import Empty, Queue import signal import socket +import ssl import sys import time -from typing import Any +from typing import Any, cast import paho.mqtt.client import psutil @@ -40,6 +41,7 @@ MQTT_PORT_DEFAULT, MQTT_QOS_DEFAULT, MQTT_TIMEOUT_DEFAULT, + MQTT_TLS_PORT_DEFAULT, ) from .exceptions import Linux2MqttConfigException, Linux2MqttConnectionException from .helpers import clean_for_discovery, sanitize @@ -197,6 +199,7 @@ def connect(self) -> None: qos=self.cfg["mqtt_qos"], retain=True, ) + self._configure_tls() self.mqtt.connect( self.cfg["mqtt_host"], self.cfg["mqtt_port"], self.cfg["mqtt_timeout"] ) @@ -273,6 +276,52 @@ def _mqtt_send(self, topic: str, payload: str, retain: bool = False) -> None: main_logger.debug(ex) raise Linux2MqttConnectionException() from ex + def _configure_tls(self) -> None: + """Configure TLS settings on the MQTT client when enabled. + + When TLS is active the method prepares an SSL context that mirrors the + CLI configuration: custom CA bundle, optional client certificate pair, + and an insecure mode that disables verification. Errors while loading + any of these assets are surfaced as config exceptions so the caller can + fail fast before attempting the actual network connection. + + """ + + if not self.cfg["mqtt_tls_enabled"]: + return + + try: + context = ssl.create_default_context() + + if self.cfg["mqtt_tls_ca_cert"]: + context.load_verify_locations(cafile=self.cfg["mqtt_tls_ca_cert"]) + + has_cert = bool(self.cfg["mqtt_tls_client_cert"]) + has_key = bool(self.cfg["mqtt_tls_client_key"]) + if has_cert or has_key: + if not (has_cert and has_key): + raise Linux2MqttConfigException( + "TLS client certificate and key must both be provided" + ) + certfile = self.cfg["mqtt_tls_client_cert"] + keyfile = self.cfg["mqtt_tls_client_key"] + if certfile is None or keyfile is None: + raise Linux2MqttConfigException( + "TLS client certificate and key must both be provided" + ) + context.load_cert_chain(certfile=certfile, keyfile=keyfile) + + if self.cfg["mqtt_tls_insecure"]: + main_logger.warning( + "TLS verification disabled via --tls-insecure. Proceed with caution." + ) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + self.mqtt.tls_set_context(context) + except (OSError, ssl.SSLError) as ex: + raise Linux2MqttConfigException("Invalid TLS configuration") from ex + def _device_definition(self) -> LinuxDeviceEntry: """Create device definition of a container for each entity for home assistant. @@ -455,6 +504,62 @@ def loop_busy(self, raise_known_exceptions: bool = False) -> None: x += 1 +def _validate_tls_args(args: argparse.Namespace) -> None: + """Validate combinations of TLS command-line options. + + Parameters + ---------- + args + The parsed ``argparse`` namespace that contains TLS options passed via + the CLI. The function raises :class:`Linux2MqttConfigException` when + incompatible settings are detected (e.g. supplying certificate paths + without enabling TLS). + + """ + + tls_specific_flags = any( + ( + args.tls_ca, + args.tls_cert, + args.tls_key, + args.tls_insecure, + ) + ) + + if tls_specific_flags and not args.tls: + raise Linux2MqttConfigException( + "TLS-related arguments require --tls to be enabled" + ) + + if args.tls and bool(args.tls_cert) ^ bool(args.tls_key): + raise Linux2MqttConfigException( + "Both --tls-cert and --tls-key must be provided for client authentication" + ) + + +def _derive_mqtt_port(args: argparse.Namespace) -> int: + """Derive the MQTT port, defaulting to 8883 when TLS is enabled. + + Parameters + ---------- + args + The parsed ``argparse`` namespace that may contain an explicit + ``--port`` override. + + Returns + ------- + int + The user-specified port when provided, otherwise the TLS-aware default + (``8883`` when TLS is enabled, ``1883`` otherwise). + + """ + + port_value = cast(int | None, args.port) + if port_value is not None: + return port_value + return MQTT_TLS_PORT_DEFAULT if args.tls else MQTT_PORT_DEFAULT + + def main() -> None: """Run main entry for the linux2mqtt executable. @@ -482,9 +587,9 @@ def main() -> None: ) parser.add_argument( "--port", - default=MQTT_PORT_DEFAULT, + default=None, type=int, - help="Port or IP address of the MQTT broker (default: 1883)", + help="Port for the MQTT broker (default: 1883, or 8883 when --tls is set)", ) parser.add_argument( "--client", @@ -501,6 +606,31 @@ def main() -> None: default=None, help="Password for MQTT broker authentication (default: None)", ) + parser.add_argument( + "--tls", + action="store_true", + help="Enable TLS encryption for MQTT connections (default: disabled)", + ) + parser.add_argument( + "--tls-ca", + default=None, + help="Path to a CA bundle for broker verification (default: system store)", + ) + parser.add_argument( + "--tls-cert", + default=None, + help="Path to client TLS certificate in PEM format (requires --tls-key)", + ) + parser.add_argument( + "--tls-key", + default=None, + help="Path to client TLS private key in PEM format (requires --tls-cert)", + ) + parser.add_argument( + "--tls-insecure", + action="store_true", + help="Disable TLS certificate validation and hostname checks (not recommended)", + ) parser.add_argument( "--qos", default=MQTT_QOS_DEFAULT, @@ -610,6 +740,9 @@ def main() -> None: "Cannot start due to bad config data type" ) from ex + _validate_tls_args(args) + mqtt_port = _derive_mqtt_port(args) + if args.verbosity >= 5: main_logger.setLevel(logging.DEBUG) elif args.verbosity == 4: @@ -634,11 +767,16 @@ def main() -> None: "mqtt_user": args.username, "mqtt_password": args.password, "mqtt_host": args.host, - "mqtt_port": args.port, + "mqtt_port": mqtt_port, "mqtt_timeout": args.timeout, "mqtt_topic_prefix": args.topic_prefix, "mqtt_qos": args.qos, "interval": args.interval, + "mqtt_tls_enabled": args.tls, + "mqtt_tls_ca_cert": args.tls_ca, + "mqtt_tls_client_cert": args.tls_cert, + "mqtt_tls_client_key": args.tls_key, + "mqtt_tls_insecure": args.tls_insecure, } ) diff --git a/linux2mqtt/type_definitions.py b/linux2mqtt/type_definitions.py index 276a15f..034afac 100644 --- a/linux2mqtt/type_definitions.py +++ b/linux2mqtt/type_definitions.py @@ -40,6 +40,16 @@ class Linux2MqttConfig(TypedDict): QOS for standard MQTT messages interval Publish metrics to MQTT broker every n seconds + mqtt_tls_enabled + Enable TLS encryption for the MQTT connection + mqtt_tls_ca_cert + Custom CA bundle for broker certificate verification + mqtt_tls_client_cert + Client certificate for mutual TLS authentication + mqtt_tls_client_key + Client key for mutual TLS authentication + mqtt_tls_insecure + Disable TLS certificate verification and hostname checks """ @@ -56,6 +66,11 @@ class Linux2MqttConfig(TypedDict): mqtt_topic_prefix: str mqtt_qos: int interval: int + mqtt_tls_enabled: bool + mqtt_tls_ca_cert: str | None + mqtt_tls_client_cert: str | None + mqtt_tls_client_key: str | None + mqtt_tls_insecure: bool class LinuxDeviceEntry(TypedDict):