diff --git a/README.md b/README.md index 3fb437f..cc781b4 100644 --- a/README.md +++ b/README.md @@ -538,6 +538,12 @@ cp mqtt_config_example.py mqtt_config.py # Broker, Port, ggf. Benutzer/Passw **Fehlt die Datei, läuft die Anzeige einfach ohne MQTT** — `deploy.sh` weist nur darauf hin und kopiert sie nicht. +**TLS:** `mqtt_config.SSL = True` (üblicherweise mit `PORT = 8883`) verschlüsselt +die Verbindung, prüft aber **nicht** das Serverzertifikat — auf dem Gerät liegt +kein CA-Bundle. Schützt die Zugangsdaten auf der Leitung, nicht vor einem +aktiven Man-in-the-Middle. Details in `mqtt_config_example.py` und +`mqtt.py` (`_neuer_client`). + ### Topics Empfangen (`/set/`): diff --git a/mqtt.py b/mqtt.py index 80ccdb0..44b5e9b 100644 --- a/mqtt.py +++ b/mqtt.py @@ -31,6 +31,10 @@ kehrt es sofort zurueck. Ohne mqtt_config.py (oder ohne umqtt) gibt connect_from_config() None zurueck -- die Anzeige laeuft dann ohne MQTT weiter. + +TLS (mqtt_config.SSL = True, ueblicherweise mit PORT = 8883): Serverzertifikat +wird NICHT geprueft (kein CA-Bundle auf dem Geraet) -- schuetzt die Zugangsdaten +auf der Leitung, nicht vor einem aktiven Man-in-the-Middle. Siehe _neuer_client. """ import settings @@ -61,7 +65,8 @@ class MqttBridge: def __init__(self, broker=None, port=1883, user="", password="", prefix="grossanzeige", client_id="grossanzeige", - client=None, log=None, on_change=None, on_ota=None): + client=None, log=None, on_change=None, on_ota=None, + ssl=False): self._broker = broker self._port = port self._user = user @@ -72,6 +77,7 @@ class MqttBridge: self._log = log or (lambda *a: None) self._on_change = on_change # wird nach jeder Aenderung gerufen self._on_ota = on_ota # wird bei cmd/ota gerufen (Payload = URL) + self._ssl = ssl self.connected = False self._retry = RETRY_START @@ -92,10 +98,20 @@ class MqttBridge: def _neuer_client(self): if MQTTClient is None: raise OSError("umqtt.simple nicht verfuegbar") - return MQTTClient(self._client_id, self._broker, port=self._port, - user=self._user or None, - password=self._password or None, - keepalive=60) + kwargs = dict(port=self._port, user=self._user or None, + password=self._password or None, keepalive=60) + if self._ssl: + # server_hostname fuers SNI -- ohne das weisen manche TLS-Server + # (virtuelle Hosts hinter einem Reverse-Proxy) die Verbindung ab. + # CERT_NONE, weil auf dem Geraet kein CA-Bundle liegt: die Leitung + # ist verschluesselt, aber ein aktiver Man-in-the-Middle waere + # nicht nachweisbar. Reicht fuer den hiesigen Zweck (Zugangsdaten + # nicht im Klartext), ist aber keine volle Zertifikatspruefung. + import ssl as _ssl + kwargs["ssl"] = True + kwargs["ssl_params"] = {"server_hostname": self._broker, + "cert_reqs": _ssl.CERT_NONE} + return MQTTClient(self._client_id, self._broker, **kwargs) def connect(self): """Verbinden, Set-Topics abonnieren, online melden. @@ -288,6 +304,7 @@ def connect_from_config(log=None, on_change=None, on_ota=None): log=log, on_change=on_change, on_ota=on_ota, + ssl=getattr(mqtt_config, "SSL", False), ) bridge.connect() # scheitert es, versucht check() es spaeter erneut return bridge diff --git a/mqtt_config_example.py b/mqtt_config_example.py index 048f463..eca25d5 100644 --- a/mqtt_config_example.py +++ b/mqtt_config_example.py @@ -9,7 +9,12 @@ Fehlt die Datei, laeuft die Anzeige einfach ohne MQTT weiter. """ BROKER = "192.168.178.30" # IP oder Hostname des Brokers -PORT = 1883 # 1883 = unverschluesselt, 8883 = TLS (nicht unterstuetzt) +PORT = 1883 # 1883 = unverschluesselt, 8883 = ueblicher TLS-Port + +# True fuer TLS (dann meist PORT = 8883). Das Serverzertifikat wird dabei NICHT +# geprueft (kein CA-Bundle auf dem Geraet) -- schuetzt die Zugangsdaten auf der +# Leitung, aber nicht vor einem aktiven Man-in-the-Middle. Siehe mqtt.py. +SSL = False # Leer lassen, wenn der Broker keine Anmeldung verlangt. USER = "" diff --git a/test_mqtt.py b/test_mqtt.py index c8bc613..8a9c57b 100644 --- a/test_mqtt.py +++ b/test_mqtt.py @@ -132,6 +132,46 @@ class TestVerbinden(MqttTest): self.assertFalse(self.bridge.connected) +class FakeMQTTClient: + """Ersatz fuer umqtt.simple.MQTTClient selbst (nicht fuer eine Instanz) -- + zeichnet nur auf, mit welchen Argumenten mqtt._neuer_client() ihn baut.""" + + aufrufe = [] + + def __init__(self, client_id, server, **kwargs): + FakeMQTTClient.aufrufe.append((client_id, server, kwargs)) + + +class TestNeuerClient(unittest.TestCase): + """mqtt.MqttBridge._neuer_client(): baut den echten MQTTClient auf (kein + FakeClient injiziert). Schwerpunkt TLS -- siehe mqtt_config_example.py.""" + + def setUp(self): + FakeMQTTClient.aufrufe = [] + self._alt = mqtt.MQTTClient + mqtt.MQTTClient = FakeMQTTClient + self.addCleanup(lambda: setattr(mqtt, "MQTTClient", self._alt)) + + def test_ohne_ssl_werden_keine_ssl_argumente_gesetzt(self): + bridge = mqtt.MqttBridge(broker="b", port=1883) + bridge._neuer_client() + _, _, kwargs = FakeMQTTClient.aufrufe[0] + self.assertNotIn("ssl", kwargs) + self.assertNotIn("ssl_params", kwargs) + + def test_ssl_setzt_server_hostname_und_cert_none(self): + import ssl as echtes_ssl + bridge = mqtt.MqttBridge(broker="mqtt.example.org", port=8883, ssl=True) + bridge._neuer_client() + client_id, server, kwargs = FakeMQTTClient.aufrufe[0] + self.assertEqual(server, "mqtt.example.org") + self.assertTrue(kwargs["ssl"]) + self.assertEqual(kwargs["ssl_params"]["server_hostname"], + "mqtt.example.org") + self.assertEqual(kwargs["ssl_params"]["cert_reqs"], + echtes_ssl.CERT_NONE) + + class TestEmpfang(MqttTest): def setUp(self):