MQTT, Discord und Python müssen noch sicher nach Hause

In diesem Epilog zur MQTT, Discord, Python Serie geht es darum, den Umgang sicherer zu gestalten, damit du nicht jedem Mitglied deines Servers gleich Zugriff auf deine Toilettenspülung gibst (ich kann weder bestätigen noch verneinen, dass diesem Beispiel eine echte Geschichte zu Grunde liegt).

Dauer: ~0,5-1,5 Stunden
Themen: Python-Env, Discord.py, MQTT-Zertifikate
Anwendungsgebiet: Beschränken des Zugriffs auf Kommandos durch Benutzer-IDs und -Rollen. Verwendung von Transport-Verschlüsselung in MQTT mit Python
Anmerkung: Es schadet nicht ein paar Vorkenntnisse in Python mitzubringen. Wenn das bei dir nicht der Fall ist, empfehle ich den Code wie Prosa zu lesen und sich nicht zu viele Gedanken über die Details zu machen. Der Code baut auf den anderen drei Teilen (eins, zwei, drei) auf, sodass du diese gelesen haben solltest.

Berechtigungen einschränken - Benutzer

Start beim Code, der die Nachrichten entgegennimmt, im Discord Bot:

@client.event
async def on_message(message):
    if not message.content.startswith("!"):
        return
    await run_command(message)

Momentan überprüft dieser Python-Code nur, dass die Nachricht mit einem ! beginnt. Genauso wie die Funktion also alle anderen Nachrichten ignoriert, baust du als nächstes eine weitere Überprüfung ein, die die Nachricht auch dann ignoriert, wenn sie zwar ein Kommando ist, aber dem Benutzer die Berechtigung fehlt (Achtung: Wir bauen hier eine/n kategorische/n Ausschluss bzw. Berechtigung. Die Berechtigungen an bestimmte Kommandos zu knüpfen ist mehr Fleißarbeit und sprengt den Zeitrahmen dieses Posts). Zuerst geben wir der Überprüfung mal einen Namen und überlegen, was passieren soll, bevor wir die eigentliche Überprüfung programmieren. Die Überprüfung muss wissen, welcher User das Kommando abgesetzt hat. Daher mein Vorschlag:

@client.event
async def on_message(message):
    if not message.content.startswith("!"):
        return
    if not check_user_or_role_permissions(message.author):
        await send_message("Das ist nicht erlaubt!", channel=message.channel)
        return
    await run_command(message)

In Zeile 5 wird jetzt eine Funktion aufgerufen, die wir gleich noch programmieren müssen und die als Übergabeparameter den Autor des Kommandos bekommt. Ist es kein Kommando wurde die on_message-Funktion ja bereits in Zeile 4 frühzeitig beendet.
Wenn die check_...-Funktion jetzt False zurück gibt, schreibt der Bot eine Nachricht ("Das ist nicht erlaubt!") zurück in den Kanal (Zeile 6) und beendet die on_message-Funktion frühzeitig (Zeile 7)
Zu guter Letzt, wenn die on_message-Funktion weder durch die erste Bedingung noch durch die zweite frühzeitig beendet wurde, wird die Nachricht ausgewertet.

Beginnen wir mit der einfachen Implementierung der Überprüfung. Der Code den du schreibst, wird die Frage beantworten "Ist der Absender des Kommandos einer der berechtigten Benutzer aus der Config?" Dafür ist es zu allererst wichtig, eine Config-Variable anzulegen, in deiner Config-Datei. Zum Beispiel eine Liste mit IDs  REQUIRED_USER_IDS = [172691708851650560, ...] die angegebene Zahl ist meine User-ID also überleg dir gut, ob du die übernehmen möchtest ;) Woher kommt diese ID? Wenn Discord im Entwicklermodus ist (unter Einstellungen->Erweitert), kannst du auf einen Benutzer rechtsklicken und "ID kopieren" auswählen.

In der Discord-Bot Datei kannst du jetzt die check-Funktion definieren:

def check_user_or_role_permissions(author):
    if len(config.REQUIRED_USER_IDS) > 0:
        if author.id in config.REQUIRED_USER_IDS:
            return True
    
    return False

in Zeile 2 wird als allererstes überprüft, ob überhaupt REQUIRED_USER_IDS in der Config angegeben sind. Dadurch machst du die Überprüfung abschaltbar, wenn die Liste in der Config leer ist.
Danach wird die ID des Nachrichtenautors mit der Liste verglichen und wenn sie vorhanden ist, True zurückgegeben (Zeile 3-4).
Anderenfalls wird False zurückgegeben.

Hmm, das ist problematisch. Wenn du keine REQUIRED_USER_IDS angibst, wird der erste if-Block nicht ausgeführt und False zurückgegeben. Es lohnt sich also die Logik genau umzudrehen und immer True zurückzugeben, es sei denn es "spricht etwas dagegen". Versuch das gerne alleine. Wenn du fertig bist, wird es ungefähr so aussehen:

def check_user_or_role_permissions(author):
    if len(config.REQUIRED_USER_IDS) > 0:
        if author.id not in config.REQUIRED_USER_IDS:
            return False
    
    # Hier folgt gleich der Code für Rollen
    
    return True

In Zeile 3 ist ein not eingefügt und die beiden Rückgabewerte getauscht, so lautet die Logik jetzt, wenn USER_IDS verlangt werden, der Autor aber nicht in der Liste ist, verweigere den Zugriff (lies: gib False zurück) ansonsten ist alles fein, gib True zurück.

Berechtigungen einschränken - Gruppen/Rollen

Das gleiche funktioniert auch für Gruppen (im Discord-Jargon Roles genannt). Allerdings ist der Code hier etwas komplizierter, weil es mehr Möglichkeiten gibt, wie sich der Zusammenhang Channel <-> Role <-> User darstellt. Genauso wie du oben eine Liste von IDs in der Config definiert hast, kannst du jetzt auch Rollen definieren, es ist deine Entscheidung, ob du mehrere Rollen angegeben möchtest und ein Benutzer dann alle oder mindestens eine Rolle innehaben muss, usw. Ich gehe auf die entsprechende Stelle im Code später ein, an der du im Zweifelsfall ein bisschen herumprobieren musst. Zum Start lege bitte zwei neue Variablen an REQUIRED_ROLES und REQUIRED_ROLE. Beides sind Listen von Rollen-IDs, die erste erfordert, dass ein Benutzer ALLE Rollen hat und die zweite, dass ein Benutzer EINE Rolle aus der Liste hat. Rollen-Ids kannst du ähnlich wie User-Ids im Entwicklermodus kopieren. Klicke zum Beispiel auf einen Benutzer, der die Rolle hat und klicke mit der rechten Maustaste im Popup auf die Rolle, deren ID du kopieren möchtest.

Wenn die Config jetzt Definitionen für REQUIRED_ROLES und REQUIRED_ROLE hat, kannst du den Code dort einfügen, wo im letzten Code-Schnipsel der Platzhalter stand.

    # Hier folgt gleich der Code für Rollen
    author_role_ids = [role.id for role in author.roles]
    
    if len(config.REQUIRED_ROLES) > 0:
        for req_role in config.REQUIRED_ROLES:
            if req_role not in author_role_ids:
                return False

    if len(config.REQUIRED_ROLE) > 0:
        if all(
            role_id not in config.DISCORD_ROLE_REQUIRED for role_id in author_role_ids
        ):
            return False

Zeile 7 ist eine Hilfszeile, die statt der Rollen-Objekte die IDs in einer Liste sammelt, damit du danach einfacher testen kannst, ob bestimmte IDs in der Liste sind.
In Zeilen 9 - 12 wird erst geprüft, gibt es REQUIRED_ROLES und falls ja wird für jede dieser Rollen geprüft, ob der Autor auch nur eine nicht hat. Oder anders formuliert, der Autor muss alle Rollen haben die in REQUIRED_ROLES stehen, anderenfalls wird False zurückgegeben und der Autor ist nicht berechtigt, das Kommando auszuführen.
Zeilen 14-18 ergänzen das System um "Eine dieser Rollen muss vorhanden sein". Hierfür musst du etwas um die Ecke denken. Der Code schaut natürlich zuerst, ob REQUIRED_ROLE überhaupt Rollen beinhaltet (Zeile 14). Die darauf folgende Bedingung bedeutet in etwa "Alle Autor-Rollen sind nicht in der Liste definiert" oder umgangssprachlicher formuliert "Keine der Autoren-Rollen entspricht einem Eintrag in der Liste". Wenn das der Fall ist, wird wieder False zurückgegeben.

Die gesamte Funktion funktioniert jetzt also nach dem Ausschlussprinzip und wenn es keinen Grund zum Ausschluss gibt, wird der Benutzer durchgelassen. Außerdem spielt die Reihenfolge jetzt eine Rolle, da die Bedingungen die Funktion frühzeitig mit False quittieren und nur alle übrigen Fälle die Anweisung return True überhaupt erreichen. Daraus ergibt sich, dass die Regel REQUIRED_USER_IDS Vorrang gegenüber REQUIRED_ROLES (Autor muss alle haben) und diese Regel wiederum Vorrang gegenüber REQUIRED_ROLE (Autor muss eine haben) hat. Den Code musst du dafür auch nie wieder anfassen, weil es jetzt nur von der Konfiguration in der config-Datei abhängt.

ANMERKUNG: Im Moment werden alle Einschränkungen der Reihe nach durchgegangen. Wenn du möchtest, dass sobald REQUIRED_USER_IDS angegeben ist  REQUIRED_ROLE(S) ignoriert werden soll, ersetze das if in Zeilen 9 & 14 durch ein elif. Anderenfalls muss ein Autor allen komplementären Berechtigungen gleichzeitig entsprechen.

Geheime Daten in der Config?

Dieser Abschnitt ist für dich interessant, wenn du das was du programmiert hast mit anderen Teilen möchtest, aber nicht versehentlich deine Passwörter und Tokens verraten möchtest. Anderenfalls kannst du direkt zur TLS-Einrichtung springen.

Damit du so wie ich deine Config z.B. auf github teilen kannst, ohne deine geheimen Daten preiszugeben, kannst du sogenannte env-files verwenden. Da die Config-Datei ein ganz normales Python-Skript ist, kannst du im Kopf der Datei die entsprechende Python-Bibliothek laden: from dotenv import load_dotenv

In der Config kannst du jetzt beliebige Variablen wie folgt (nur ein Auszug als Beispiel) aus einer Datei namens .env laden (dazu gleich mehr):

import os
from dotenv import load_dotenv

load_dotenv()

MQTT_HOST = os.getenv("MQTT_HOST")
MQTT_PORT = int(os.getenv("MQTT_PORT"))
MQTT_USER = os.getenv("MQTT_USER")
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD")
DISCORD_BOT_TOPIC = os.getenv("DISCORD_BOT_TOPIC", "bot/discord/state")
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")


Ich möchte vor allem die folgenden Zeilen hervorheben, weil sie jeweils etwas neues zeigen:
Zeile 4 lädt die Inhalte der Datei .env in die Umgebungsvariablen des Systems (nur für dieses Pythonskript, nicht global)
Zeile 6 schaut in den Umgebungsvariablen des Systems nach einem Eintrag mit dem Namen MQTT_HOST und speichert den Wert in die Python-Variable MQTT_HOST, die von den anderen Skripten verwendet wird. Ich nenne die Umgebungsvariablen und die Pythonvariablen identisch, damit ich es besser zuordnen kann, du darfst aber die Namen frei wählen. solange die in os.getenv() verwendeten Namen mit der .env-Datei (weiter unten) übereinstimmen.
Zeile 7 holt auch einen Wert aus der Umgebungsvariable MQTT_PORT, da Python diesen Wert aber nachher als Ganzzahl benötigt, wird der Wert direkt mittels int() konvertiert.
Zeile 10 zeigt noch, wie du einen Wert als Standard angeben kannst, falls die Umgebungsvariable nicht existiert (weil nicht definiert oder in der .env-Datei vorhanden). Diesen Standardwert kannst du einfach als zweiten Parameter in die os.getenv()-Funktion geben.

Die .env-Datei (es liegt im Github-Projekt auch eine Beispieldatei) kann zum Beispiel so aussehen:

# Discord
DISCORD_TOKEN=abcdefgh12345678

# MQTT
MQTT_HOST=10.0.0.123
MQTT_PORT=1883
MQTT_USER=my
MQTT_PASSWORD=user

Wie du siehst, kannst du auch in dieser Datei Kommentare mit # am Anfang einfügen. Außerdem benötigst du in der .env-Datei keine " um Strings zu kennzeichnen. Wenn die Datei abgespeichert ist und die Änderungen in der Config vorgenommen, solltest du keine Änderungen zu vorher feststellen. Der Vorteil, du kannst jetzt in deiner .gitignore oder beim Teilen deines Codes mit anderen die .env-Datei ausschließen, sodass deine geheimen Daten auch geheim bleiben.

TLS-Verschlüsselung für MQTT in Python

Wenn du deinen Mosquitto-Broker nach meiner Anleitung auf verschlüsselte Kommunikation umgestellt hast, dann musst du auch deinen Clients (in dem Fall dem Discord-Bot) beibringen ihre Zertifikate zu nutzen. Dieser Post geht davon aus, dass du die Zertifikate (ca.crt, client.crt und client.key) schon im Projektordner liegen hast. Falls du Hilfe bei der Erstellung brauchst, lies nochmal den zweiten Blogpost der Mosquitto Reihe, dort steht wie das geht.

In der Pythondatei, in der die MQTT-Verbindung definiert ist, musst du nur eine einzige Zeile einfügen, ich zeige dir aber verschiedene Alternativen und du entscheidest, welche dir am besten gefällt.

def __init__(self):
    self.client = mqtt.Client("Discord Bot")
    if blog003_config.MQTT_USER != "":
        self.client.username_pw_set(blog003_config.MQTT_USER, blog003_config.MQTT_PASSWORD)
    # Hier müssen die Zertifikate angegeben werden
    if blog003_config.USE_TLS:
        self.client.tls_set("ca.crt", "client.crt", "client.key")
    
    self.client.connect(blog003_config.MQTT_HOST, blog003_config.MQTT_PORT)

Die Zeile 6 ist optional, ermöglicht dir aber in der config USE_TLS = True oder False zu setzen.
Wenn TLS benutzt werden soll, benötigt die Funktion tls_set in Zeile 7 mindestens mal das CA-Zertifikat, mit dem das Broker-Zertifikat signiert wurde, damit der Client validieren kann, dass der Broker rechtmäßig ist. Die anderen beiden Parameter sind das öffentliche Zertifikat (.crt) und der private Schlüssel (.key) für den Client angegeben. Letztere Datei ist auch dringend geheimzuhalten.
Die drei Dateipfade zu den Zertifikaten (ca.crt, client.crt und client.key) kannst du natürlich auch in der Config definieren, sodass du nur per Konfiguration auch andere Dateispeicherorte auf deinem Computer angeben kannst.
ANMERKUNG: Es ist wichtig, dass der Aufruf von .tls_set vor dem Aufruf .connect() passiert.
ANMERKUNG2: Neben .tls_set() gibt es auch noch die Möglichkeit .tls_set_context() zu verwenden, das ist aber ungleich komplizierter und sprengt hier den Rahmen.
ANMERKUNG3: Es ist absolut nicht notwendig, die MQTT-Kommunikation zu verschlüsseln, wenn der Discord Bot auf dem gleichen Gerät läuft, wie der Broker, also immer dann wenn der MQTT_HOST als 127.0.0.1 oder localhost angegeben ist. Die Daten laufen dann ohnehin nicht übers Netzwerk.

Zusammenfassung

In diesem Blogpost hast du gelernt, wie du auf Programmierseite den Aufruf von Kommandos auf Basis von Benutzer-ID oder -Rolle(n) beschränkst, damit nur autorisierte Personen Zugriff auf deine MQTT-Geräte bekommen. Darüberhinaus weißt du jetzt, wie du die Netzwerkkommunikation zum MQTT-Broker verschlüsseln kannst. Der letzte Part an dem Sicherheitseinstellungen vorgenommen werden können, ist die Berechtigungen des Bots selbst zu gestalten wie bereits in Teil 2 beschrieben.

Vielen Dank, dass du wieder dabei warst, bei meinen unkonventionellen IT-Lösungen :D Wenn es dir gefallen hat, teile den Post gerne mit Freunden und Bekannten, die sich auch für das Thema interessieren könnten. Wenn nicht, teile es mit deinen ärgsten Feinden. Um diese kostenlosen Anleitungen zu unterstützen, kauf deine Hardware gerne über meine Affiliate-Links in Teil 1, unterstütze mich auf Patreon oder gib mir einen Kaffee auf ko-fi aus . Bei Fragen schreib mir eine E-Mail an info@ oder auf Twitter.