Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions linux2mqtt/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
)
146 changes: 142 additions & 4 deletions linux2mqtt/linux2mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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,
}
)

Expand Down
15 changes: 15 additions & 0 deletions linux2mqtt/type_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

"""

Expand All @@ -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):
Expand Down