html> Messdaten übertragen per UDP

Raspberry-Pi-Projekt: Messdaten übertragen per UDP

Prof. Jürgen Plate

Raspberry Pi: Messdaten übertragen per UDP

Allgemeines

Zur Datenübertragung über das Netzwerk, egal ob LAN, WLAN oder Internet, werden standardisierte Protokolle eingesetzt. Am bekanntesten ist wohl TCP/IP, das von fast allen Internet-Diensten verwendet wird. Oft ist aber für die Übermittlung weniger Bytes, wie es bei der Messwerterfassung die Regel ist, gar kein aufwendiges Protokoll notwendig. Hier wird gezeigt, wie sich das User Datagram Protocol (UDP), das auch auf IP aufsetzt, dafür verwenden läßt.

Das socket-Modul der Standardbibliothek bietet grundlegende Funktionen zur Netzwerkkommunikation. Es bildet dabei das standardisierte Socket-API ab, das in ähnlicher Form auch in vielen anderen Programmiersprachen implementiert ist.

Hinter der Socket-API steht die Idee, dass ein Programm zur Kommunikation über das Netz vom Betriebssystem einen sogenannten Socket (dt. "Steckdose") zur Verfügung gestellt bekommt. Über diesen Socket kann das Programm dann eine Netzwerkverbindung zu einem anderen Socket aufbauen. Dabei spielt es keine Rolle, ob sich der Zielsocket auf demselben oder einem fernen Rechner befindet.

Auf einem Rechner könnten durchaus mehrere Programme gleichzeitig Daten über das Netz senden und empfangen. Damit Sende- und Empfangsprogramm eindeutig identifizierbar sind, wird eine Netzwerkkommunikation zusätzlich an einen "Port" gebunden, der es ermöglicht, ein bestimmtes Programm anzusprechen. Bei einem Port handelt es sich um einen 16-Bit-Wert, es sind also maimal 65.535 verschiedene Ports möglich. Viele Ports sind für bestimmte Protokolle registriert ("well known ports"), etwas Port 80 für HTTP und Port 22 für SSH. Diese sollten nicht verwendet werden, weshalb die Ports bis 1024 auch dem User "root" vorbehalten sind. Viele Ports mit höheren Nummern sind bestimmten Anwendungen vorbehalten, z. B. Backup-Systemen oder der MySQL-Datenbank. Je nach Rechner sind sie oft frei verwendbar. Grundsätzlich können Sie Ports ab 49152 bedenkenlos verwenden.

Ein Server ist unter einer bestimmten Adresse erreichbar und wartet in der Regel auf eingehende Verbindungen. Sobald ein Client sich verbinden will und der Server die Anfrage akzeptiert, wird ein neuer Socket erzeugt, über den die Kommunikation mit diesem speziellen Client abläuft. Da es hier um das UDP-Protokoll geht, genügt es, wenn der Server immer nur eine Verbindung auf einmal behandelt. Der Client stellt den aktiven Partner dar. Er sendet eine Verbindungsanfrage an den Server und nimmt dann aktiv dessen Dienstleistungen in Anspruch.

Senden und Empfangen mit UDP

Für das Senden kurzer Informationen eigent sich das UDP (User Datagram Protocol) sehr gut. Seine Eigenschaften sind:

Python eignet sich gut für die Netzwerkprogrammierung, da es recht schnell zu Ergebnissen führt. Um die oben beschriebene Funktionalität nutzen zu können benötigen Sie die Socket-Module, die mittels import socket ins Programm eingebunden werden. Mit der Anweisung

Sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
Wird ein Socket angefordert. Die beiden Parameter geben die Protokollfamilie (Internet-Protokolle) und das gewünschte Protokoll (UDP = Datagram) an. AF_INET gibt an, dass es sich um einen Internetsocket handelt, der zweite Parameter kann statt SOCK_DGRAM auch SOCK_STREAM für (TCP, Bitstream) lauten.

Der Server muss sich nun an einen Port binden und dann am Socket lauschen, bis ein Verbindungswunsch eintrifft:

host = ""
port = 1248
addr = (host, port)
UDPSock.bind(addr)
Sollen Verbindungen von jedem Client angenommen werden, wird als host der leere String genommen. bind() bindet den Socket außerdem an den Port 1248. Wenn bereits ein Programm an dem Port lauscht, erhält man eine Fehlermeldung. Wenn eine UDP-Nachricht eintrifft, wird der Server aktiv und liest die empfangenen Daten mit der Funktion recvfrom():
(data, addr) = UDPSock.recvfrom(bufsize)
Der einzige Parameter der Funktion ist die Maximalgröße des Empfangspuffers. Kommen mehr Daten, wird der Rest verworfen. Die Funktion gibt die zwei Werte zurück, die empfangene Nachricht und ein Tupel mit IP-Adresse und Port des Clients.

Auch der Client muss einen Socket öffnen. Zum Senden verwendet er die Funktion sendto(), die zwei Parameter besitzt: Erster Parameter ist die zu sendende Nachricht, zweiter Parameter ein Tupel aus Host-IP-Adresse und Portnummer). Hier muss natürlich die IP-Adresse des Servers zwingend angegeben werden:

host = "192.168.12.1"
port = 1248
addr = (host, port)
UDPSock.sendto(data, addr)

Aufbauend auf diesem Infos lassen sich zwei Basisprogramme zum Senden und Empfangen erstellen. Benötigt wird die socket-Bibliothek. Der Sender öffnet einen UDP-Socket und schickt einen String auf die Reise.

# !/usr/bin/env python
# -*- coding: utf-8 -*-
import socket

# Voreinstellungen für Host und Port
host = "127.0.0.1"
port = 1248

# Die zu sendenden Daten
data = "the quick brown fox jumps over the lazy dog"

# # UDP-Socket oeffnen
addr = (host, port)
UDPSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

# Nachricht senden
UDPSock.sendto(data, addr)
UDPSock.close()
Der Empfänger ist etwas komplexer, er wartet in einer Endlosschleife auf ein Datenpaket und zeigt dieses dann an. Beim Programmstart werden Host und Port an den Socket gebunden. Ist die Hostangabe leer, nimmt der Empfänger Daten von allen Sendern an. Die Variable bufsize begrenzt die Größe es akzeptierten Datenpakets. Ist das empfangen Paket größer, wird der Rest verworfen. Damit man das Programm sauber beenden kann, ist noch ein Tastaturinterrupt vorgesehen.
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import socket

# Voreinstellungen für Host und Port
# akzeptiert Nachrichten von jedem Host auf dem angeg. Port
host = ""
port = 1248

# Groesse Empfangspuffer (begrenzt die empfangene Nachricht)
bufsize = 8192  # 8 kByte

# UDP-Socket oeffnen
addr = (host, port)
UDPSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
UDPSock.bind(addr)
print >>sys.stderr, "waiting for messages (port: " + str(port) + ") ..."

# Endlosschleife fuer Empfang, Abbruch durch Strg-C
try:
  while True:
    (data, addr) = UDPSock.recvfrom(bufsize)
    print >>sys.stderr, 'received %s bytes from %s:%d' % (len(data), addr[0], addr[1])
    # weiter verarbeiten
    print data
except KeyboardInterrupt:
  UDPSock.close()
  print >>sys.stderr, "terminating ..."
  exit(0)

In dieser Form sind die Programme aber noch nicht praxistauglich - nicht nur wegen der fest im Sendeprogramm verankerten Nachricht. Nachdem der Empfänger jeden Absender akzeptiert, können natürlich auch Hinz und Kunz UDP-Messages an den Empfänger schicken. Angenommen, es geht um verteilte Sensoren, die ihre Daten an einen zentralen Rechner schicken, bestände so die Möglichkeit, dass (bewusst oder zufällig erzeugte) falsche Daten zum Empfänger gelangen und dort verarbeitet werden. Da es UDP verbindungslos ist und keinerlei Athentisierung existiert, müssen die Programme oben erweitert werden.

Erweiterung für die Praxis

Zudem sollten Angaben wie Host und Port als Parameter übergeben werden können und beim Sender wäre es günstig, die Nachricht wahlweise auf der Kommandozeile, über die Standardeingabe oder aus einer Datei zu übergeben. Beim Empfänger reicht die Ausgabe der Nachricht auf der Standardausgabe, was sich per Pipe etc. von anderen Programmen verarbeiten läßt. Glücklicher Weise stellt Python eine komfortable Bibliothek für die Bearbeitung der Parameter auf der Kommandozeile zur Verfügung, so dass sich mit wenigen Zeilen Code die Parameter verarbeiten lassen. Beim Empfänger kann so die Portnummer angegeben werden, beispielsweise mittels "-p 7777" oder "--port=7777". Der Sender hat weitere Parameter:

Fehlt der Dateiparameter (-f bzw. --file=...), wird die Nachricht von der Kommandozeile gelesen, z. B. send.py -p 4711 "Hallo Welt".

Nun zur Lösung des oben genannten Sicherheitsproblems, das jedermann UDP-Nachrichten an den Server senden kann - und dieser alles akzeptiert. Hier hilft nur das, was schon Cäsar für den Schutz seiner Nachrichten vor unberufenen Augen verwendet hat: Kryptografie. Der Sender verschüsselt die Nachricht und der Empfänger entschlüsselt sie. Natürlich kann jetzt immer noch jemand irgendetwas senden, weshalb eine Prüfung der empfangen Daten auf Korrektheit und Plausibilität weiter notwendig ist. Das kann aber auf einer höhren Ebene geschehen. Auch hier hilft Python mit einer Bibliothek (Crypto.Cipher). Ich habe mich für AES als Verschlüsselungsalgorithmus entschieden. Die Anwendung ist recht einfach, wenn man die Randbedingungen beachtet: Der AES-Schlüssel muss 16, 24, oder 32 Bytes lang sein und die Nachrichtenlaenge muss immer ein Vielfaches von 16 betragen.

Es müssen also sowohl Schlüssel wie Nachricht auf die passende Länge justiert werden. Beim Schlüssel ist das einfach, er wird einfach immer auf die Maximallänge von 32 Bytes justiert. Bei der Nachrichtenlänge wird etwas gerechnet:

Damit kann dann auch die Nachricht passend justiert werden. Das Ergebnis der Verschlüsselung ist eine binäre Folge von Bytes. Um zu vermeinden, dass verschieden System den Binärcode unterschiedlich interpretieren (Zeichesatz, Big-/Little-Endian u. a.), wird das Ergebnis der Verschlüsselung noch base64-codiert, wie es auch beim E-Mail-Anhängen oder Webanwendungen üblich ist. Dann geht nur lesbares ASCII auf die Reise. Als Ergebnis sind so zwei Funkionen für das Ver- und Entschlüsseln entstanden, die sich auch anderweit nutzen lassen:
  ...
from Crypto.Cipher import AES
import base64

#  ...

# das geheime Wort
secret = "TopSecret"

#  ...

# Nachricht verschluesseln
def encrypt(msg_text, secret_key):
  # AES key must be either 16, 24, or 32 bytes long
  secret_key = secret_key.rjust(32)
  msg_len = len(msg_text)
  # Nachrichtenlaenge auf Vielfachens von 16 bringen
  if ((msg_len % 16) != 0):
     msg_len = ((msg_len >> 4) + 1)*16
  msg_text = msg_text.rjust(msg_len)
  # verschluesseln
  cipher = AES.new(secret_key,AES.MODE_ECB)
  # damit es ASCII-sicher ist, base64-codieren
  return base64.b64encode(cipher.encrypt(msg_text))

# Nachricht entschluesseln
def decrypt(secret_msg, secret_key):
  # AES key must be either 16, 24, or 32 bytes long
  secret_key = secret_key.rjust(32)
  # entschluesseln
  cipher = AES.new(secret_key,AES.MODE_ECB)
  decoded = cipher.decrypt(base64.b64decode(secret_msg))
  return decoded.strip()

# Test
nachricht = 'Temperatur innen: 21, aussen: -12'

encoded = encrypt(nachricht, secret)
print encoded
decoded = decrypt(encoded, secret)
print decoded
Ein Programmaufruf ergibt:
$ python krypto.py
UjDYwDzzELAVi3GyPaVk3vWbfvaS0I8EAjGc58iEx2D/v2KcvfvO9IWQNmwKbzQ7
Temperatur innen: 21, aussen: -12
Statt das Passwort im Programmcode zu speichern, kann man es alternativ aus einer Datei einlesen oder sogar die Eingabe von Hand vorsehen.

Was leider immer noch möglich wäre, ist eine sogenannte "replay attacke", bei der einfach ein auf irgend eine Weise aufgefangener Datenblock nochmals an den Empfänger geschickt wird. Die Datenintegrität ist dabei nicht betroffen, da ja der Datensatz unverändert wiederholt wird. Sollte eine solche Wiederholung problematisch sein, könnte man den Daten einen varaiblen Wert hinzufügen, z. B. einen Nachrichtenzähler, der vom Sender jedesmal um 1 erhöht wird. Wenn das nicht ausreicht, bleibt nur eine Alternative auf TCP-Basis, etwa mit einem Challenge-Response-Protokoll.

Der Sender wird mit der Funktion encrypt() ausgerüstet, der Empfänger mit decrypt(). Zusammen mit der oben erwähnten Auswertung der Kommandozeile ergeben sich dann die folgenden Programme:

Sender (Client)

# !/usr/bin/env python
# -*- coding: utf-8 -*-

# Sendet UDP-Nachricht an den angegebenen Host. Die Nachricht kann
# entweder auf der Kommandozeile angegeben werden oder sie steht in
# einer Textdatei, die dann per Option '-f <dateiname>' bzw. 
# '--file <dateiname>' angegeben wird. Wird als Dateiname 'stdin' 
# angegeben, liest das Programm von der Standardeingabe.
# Der Zielhost kann mittels '-s <host-ip>' bzw. '--server <host-ip>' 
# spezifiziert werden. Mit '-p <port>' bzw. '--port <port>' kann 
# ein Port angegeben werden.
# Der Sender authentisiert die Nachricht mittels eines MD5-Hash
# ueber den Nachrichtentext und ein geheimes Passwort (secret).

import os
import sys
import argparse
import socket
from Crypto.Cipher import AES
import base64

# Voreinstellungen für Host und Port
host = "127.0.0.1"
port = 1248

# das geheime Wort
secret = "Rembremerdeng"

# zu sendende Nachricht verschluesseln
def encrypt(msg_text, secret_key):
  # AES key muss 16, 24, oder 32 bytes lang sein
  secret_key = secret_key.rjust(32)
  msg_len = len(msg_text)
  # Nachrichtenlaenge auf Vielfachens von 16 bringen
  if ((msg_len % 16) != 0):
     msg_len = ((msg_len >> 4) + 1)*16
  msg_text = msg_text.rjust(msg_len)
  # verschluesseln
  cipher = AES.new(secret_key,AES.MODE_ECB)
  # damit es ASCII-sicher ist, base64-codieren
  return base64.b64encode(cipher.encrypt(msg_text))

# Kommandozeilen-Parser anwerfen
parser = argparse.ArgumentParser(description="UDP sender script")
parser.add_argument("-f", "--file", dest="filename", default="",
                    help="read data from FILE", metavar="FILE")
parser.add_argument("-s", "--server", dest="server", default="",
                    help="destination host HOST", metavar="HOST")
parser.add_argument("-p", "--port", dest="port", default="",
                    help="destination port PORT", metavar="PORT")
parser.add_argument('cmd', nargs=argparse.REMAINDER)
args = parser.parse_args()

# Host-IP-Adresse festlegen
if args.server == "":
  host = "127.0.0.1"
else:
  host = args.server

# Host-Port festlegen
if args.port == "":
  port = 1248
else:
  port = args.port

# keine Datei angegeben -> Daten von der Kommandozeile
if args.filename == "":
  data = ' '.join(args.cmd)
# Dateiname == 'stdin' -> Daten von der Standardeingabe
elif args.filename == "stdin":
  data = sys.stdin.readlines()
# Dateiname != 'stdin' -> Daten aus einer Datei
else:
  filename = os.path.abspath(args.filename)
  if not os.path.exists(filename):
    print >>sys.stderr, "The file %s does not exist!" % filename
    os._exit(1)
  with open(filename) as fh:
    data = fh.read()

# # UDP-Socket oeffnen
addr = (host, port)
UDPSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

# Nachricht senden
cdata = encrypt(data, secret)
print >>sys.stderr, "Destination Host: " + host
print >>sys.stderr, "Destination Port: " + str(port)
print >>sys.stderr, "Sending: " + cdata
UDPSock.sendto(cdata, addr)
UDPSock.close()
exit(0)

Empfänger (Server)

#!/usr/bin/env python
# -*- coding: utf-8 -*-

# Empfaengt UDP-Message auf dem angegebenen Port.
# Mit -p <port> bzw. --port <port> kann ein Port angegeben werden.
# Ausgabe der Nachricht auf der Standardausgabe.
# Der Sender authentisiert die Nachricht mittels eines MD5-Hash
# ueber den Nachrichtentext und ein geheimes Passwort (secret).

import os
import sys
import argparse
import socket
from Crypto.Cipher import AES
import base64

# Voreinstellungen für Host und Port
# akzeptiert Nachrichten von jedem Host auf dem angeg. Port
host = ""
port = 1248

# das geheime Wort
secret = "Rembremerdeng"

# Groesse Empfangspuffer (begrenzt die empfangene Nachricht)
bufsize = 8192  # 8 kByte

# Empfangenen Datensatz entschluesseln
def decrypt(secret_msg, secret_key):
  # AES key muss 16, 24, oder 32 bytes lang sein
  secret_key = secret_key.rjust(32)
  # entschluesseln
  cipher = AES.new(secret_key,AES.MODE_ECB)
  decoded = cipher.decrypt(base64.b64decode(secret_msg))
  return decoded.strip()

# Kommandozeilen-Parser anwerfen
parser = argparse.ArgumentParser(description="UDP receiver script")
parser.add_argument("-p", "--port", dest="port", default="",
                    help="receive port PORT", metavar="PORT")
parser.add_argument('cmd', nargs=argparse.REMAINDER)
args = parser.parse_args()

# Host-Port festlegen
if args.port == "":
  port = 1248
else:
  port = args.port

# UDP-Socket oeffnen
addr = (host, port)
UDPSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
UDPSock.bind(addr)
print >>sys.stderr, "waiting for messages (port: " + str(port) + ") ..."


# Endlosschleife fuer Empfang, Abbruch durch Strg-C
try:
  while True:
    (cdata, addr) = UDPSock.recvfrom(bufsize)
    print >>sys.stderr, 'received %s bytes from %s:%d' % (len(cdata), addr[0], addr[1])
    # Nachricht entschlüsseln
    data = decrypt(cdata, secret)
    # weiter verarbeiten
    print data
except KeyboardInterrupt:
  UDPSock.close()
  print >>sys.stderr, "terminating ..."
  exit(0)

Links


Copyright © Hochschule München, FK 04, Prof. Jürgen Plate und die Autoren
Letzte Aktualisierung: