1 Punkte von GN⁺ 2025-03-05 | 1 Kommentare | Auf WhatsApp teilen
  • Als erster Schritt einer Reihe, in der man unter Linux einen TCP/IP-Stack im User Space selbst baut und dabei Netzwerkschichten lernt, werden zunächst die Verarbeitung von Ethernet-Frames auf Layer 2 und ARP-Antworten implementiert
  • Low-Level-Netzwerkverkehr des Kernels wird über ein TAP-Gerät abgegriffen; über den zurückgegebenen Dateideskriptor lassen sich Ethernet-Puffer des virtuellen Geräts per read/write lesen und schreiben
  • Der Ethernet-Header wird über dmac, smac, ethertype und payload behandelt; ist der ethertype-Wert 1536 oder größer, steht er für den Payload-Typ, andernfalls für die Payload-Länge
  • ARP ordnet Protokolladressen wie IPv4-Adressen dynamisch 48-Bit-MAC-Adressen zu und wird als Ablauf implementiert, der auf Anfragen antwortet und dabei die Übersetzungstabelle aktualisiert
  • Im arping-Test erkennt der Linux-Kernel die ARP-Antwort des eigenen Stacks, sodass im ARP-Cache des Interfaces tap0 ein Eintrag für 10.0.0.4 hinzugefügt wird

Ausgangspunkt für einen TCP/IP-Stack im User Space

  • Ziel ist es, unter Linux einen minimalen TCP/IP-Stack im User Space zu implementieren und dabei Netzwerk- und Systemprogrammierung tiefer zu verstehen
  • TCP ist komplex, weil sich über mehr als 30 Jahre viele Spezifikationen angesammelt haben; die zentralen Implementierungsbestandteile lassen sich jedoch auf TCP-Header-Parsing, Zustandsmaschine, Congestion Control und Berechnung von Retransmission-Timeouts eingrenzen
  • Ethernet und IP sind weniger komplex als TCP, daher beginnt die Reihe mit der Implementierung auf Layer 2

Ethernet-Traffic über ein TAP-Gerät empfangen

  • Um Low-Level-Netzwerkverkehr des Linux-Kernels abzufangen, wird ein Linux-TAP-Gerät verwendet
  • TUN/TAP-Geräte werden häufig von Networking-Anwendungen im User Space genutzt, um L3- bzw. L2-Traffic zu manipulieren
    • Tunneling verpackt Pakete als Payload in anderen Paketen
    • Auch Programme wie OpenVPN verwenden TUN/TAP-Geräte
  • Da der Netzwerk-Stack ab Layer 2 aufgebaut wird, wird kein TUN-, sondern ein TAP-Gerät benötigt
  • Ein TAP-Gerät wird erstellt, indem /dev/net/tap geöffnet und ioctl(fd, TUNSETIFF, ...) aufgerufen wird
    • IFF_TAP wählt ein TAP-Gerät aus
    • IFF_NO_PI verhindert, dass vor Ethernet-Frames unnötige Paketinformationen eingefügt werden
  • Über den nach der Erstellung zurückgegebenen Dateideskriptor fd werden die Ethernet-Puffer des virtuellen Geräts gelesen und geschrieben

Format von Ethernet-Frames

  • Ethernet ist eine Basistechnologie zur Verbindung von Computern in LANs; der erste Ethernet-Standard wurde 1980 von Digital Equipment Corporation, Intel und Xerox veröffentlicht
  • Die erste Version nutzte eine Geschwindigkeit von etwa 10 Mb/s und Halbduplex-Kommunikation, daher war ein MAC-Protokoll zur Steuerung des Datenflusses nötig
    • Bei Halbduplex-Ethernet-Interfaces ist CSMA/CD als MAC-Verfahren erforderlich
    • 100BASE-T nutzt Twisted-Pair-Verkabelung und ermöglicht Vollduplex-Kommunikation sowie höheren Durchsatz
    • Mit der weiten Verbreitung von Ethernet-Switches nahm die Notwendigkeit von CSMA/CD im Allgemeinen ab
  • Der Ethernet-Standard wird von der Arbeitsgruppe IEEE 802.3 gepflegt

Der in der Implementierung verwendete Ethernet-Header

  • Die Implementierung bindet unter Linux if_ether.h ein und verwendet die Zuordnung von Ethertypes zu Hexadezimalwerten
  • Der Ethernet-Header wird in einer C-Struktur durch folgende Felder dargestellt
    • dmac: Ziel-MAC-Adresse
    • smac: Quell-MAC-Adresse
    • ethertype: Payload-Länge oder -Typ
    • payload: Payload-Zeiger, der ARP- oder IPv4-Pakete enthält
  • ethertype ist ein 2-Oktett-Feld, dessen Bedeutung vom Wert abhängt
    • Ist der Wert 1536 oder größer, bezeichnet er einen Payload-Typ wie IPv4 oder ARP
    • Ist der Wert kleiner, bezeichnet er die Payload-Länge
  • Ethernet-Frames können Tags enthalten, die VLAN oder QoS angeben; in dieser Implementierung werden Frame-Tags jedoch ausgeschlossen
  • Wenn die Payload-Länge ohne Tags kleiner als die geforderte Mindestgröße von 48 Byte ist, werden am Ende Padding-Bytes angefügt
  • Am Ende des Ethernet Frame Format gibt es ein Frame-Check-Sequence-Feld, das die Integrität per CRC prüft; in dieser Implementierung wird es jedoch nicht verarbeitet

Vorgehen beim Parsen von Ethernet-Frames

  • Das Attribut packed in der Strukturdeklaration verhindert, dass der GNU-C-Compiler das Speicherlayout der Struktur durch Padding-Bytes zur Datenausrichtung optimiert
  • Das Parsing in der Implementierung erfolgt, indem ein Puffer per Type Cast in die passende Protokollstruktur umgewandelt wird
    • Beispiel: struct eth_hdr *hdr = (struct eth_hdr *) buf;
  • Ein portablerer Ansatz wäre, Protokolldaten manuell zu serialisieren
    • In diesem Fall kann der Compiler Padding-Bytes entsprechend den prozessorspezifischen Anforderungen an die Datenausrichtung hinzufügen
  • Die Verarbeitung empfangener Ethernet-Frames folgt einem einfachen Ablauf
    • Den Puffer vom TAP-Gerät lesen
    • Den Ethernet-Header mit init_eth_hdr(buf) initialisieren
    • handle_frame(&netdev, hdr) entscheidet anhand des ethertype-Werts über die nächste Aktion

ARP-Paketstruktur und Rolle

  • ARP (Address Resolution Protocol) ordnet Protokolladressen wie IPv4-Adressen dynamisch MAC-Adressen zu, also 48-Bit-Ethernet-Adressen
  • ARP ist nicht auf IPv4 beschränkt und kann mit verschiedenen L3-Protokollen verwendet werden
    • Als Beispiel deklariert CHAOS 16-Bit-Protokolladressen
  • Bei typischer LAN-Kommunikation ist zwar die IP-Adresse eines Dienstes bekannt, für die tatsächliche Übertragung wird aber die MAC-Adresse benötigt
  • ARP sendet eine Broadcast-Anfrage ins Netzwerk, damit der Besitzer der betreffenden IP-Adresse seine Hardwareadresse bekanntgibt

ARP-Header und Payload für IPv4

  • Der ARP-Header besteht aus folgenden Feldern
    • hwtype: 2-Oktett-Feld für den Link-Layer-Typ; der Wert für Ethernet ist 0x0001
    • protype: 2-Oktett-Feld für den Protokolltyp; der Wert für IPv4 ist 0x0800
    • hwsize: 1-Oktett-Feld für die Größe der Hardwareadresse; eine MAC-Adresse hat 6 Byte
    • prosize: 1-Oktett-Feld für die Größe der Protokolladresse; eine IPv4-Adresse hat 4 Byte
    • opcode: 2-Oktett-Feld für den Typ der ARP-Nachricht
  • opcode-Werte werden in vier Arten unterteilt
    • ARP request: 1
    • ARP reply: 2
    • RARP request: 3
    • RARP reply: 4
  • ARP-Daten für IPv4 werden mit der Struktur arp_ipv4 behandelt
    • smac: MAC-Adresse des Senders
    • sip: IP-Adresse des Senders
    • dmac: MAC-Adresse des Empfängers
    • dip: IP-Adresse des Empfängers

Algorithmus zur Adressauflösung und Cache

  • Der Algorithmus zur Adressauflösung aus RFC 826 prüft Hardware- und Protokolltyp, aktualisiert anschließend die Übersetzungstabelle und erzeugt eine Antwort, wenn die Zieladresse die eigene ist
  • Die translation table speichert ARP-Ergebnisse, damit ein Host bereits vorhandene Einträge aus dem Cache nachschlagen kann
  • Dieser Cache reduziert, dass das Netzwerk unnötig durch doppelte ARP-Anfragen gefüllt wird
  • Der Implementierungscode befindet sich in arp.c

ARP-Antwort testen und nächste Schritte

  • Der abschließende Test der ARP-Implementierung prüft, ob auf Anfragen korrekt geantwortet wird
  • Nach Ausführung von arping -I tap0 10.0.0.4 kommt von 10.0.0.4 eine Unicast-Antwort mit der MAC-Adresse 00:0C:29:6D:50:25 zurück
  • Anschließend zeigt die Ausgabe von arp, dass im ARP-Cache des Linux-Kernels der Eintrag 10.0.0.4 ether 00:0c:29:6d:50:25 tap0 entstanden ist
  • Schon mit minimaler Ethernet-Frame-Verarbeitung und ARP-Implementierung lässt sich beobachten, dass ein eigenes Ethernet-Gerät den ARP-Cache des Linux-Hosts füllt
  • Der Quellcode des Projekts ist auf GitHub verfügbar; der nächste Schritt ist die Implementierung von ICMP echo/reply, also ping, sowie des Parsings von IPv4-Paketen

1 Kommentare

 
GN⁺ 2025-03-05
Meinungen auf Hacker News
  • Vor ein paar Jahren habe ich in C einen Netzwerk-Stack im Userspace gebaut und Rohpakete über ein TUN-Interface verarbeitet, sodass er einigermaßen funktionierte.
    Inzwischen enthält er eine einfache Shell, mit der man IP-Adressen, Routen usw. konfigurieren kann, und Netzwerkpakete werden in einer hybriden Struktur abgelegt, die wie eine Mischung aus mbuf und sk_buf wirkt.
    Allerdings hatte ich nach der UDP-Implementierung weder Zeit noch Motivation, TCP umzusetzen; der Code ist hier: https://github.com/cakturk/unet

    • Vor sehr langer Zeit habe ich einmal einen pcap/tcpdump-Parser in reinem bash geschrieben, weil das damals das einzige Werkzeug war, mit dem ich „Programme“ schreiben konnte.
      Natürlich war es vermutlich eines der langsamsten und fragilsten Dinge der Geschichte, aber es funktionierte tatsächlich und machte ziemlich viel Spaß. Ich hoffe, der Code liegt noch irgendwo herum.
    • Viele Embedded-Geräte verwenden lwip als TCP/IP-Implementierung.
      Auch der „POSIX-Port“ von lwip holt sich auf dieselbe Weise rohe Ethernet-Bytes von einem TUN/TAP-Gerät.
      https://github.com/lwip-tcpip/lwip/blob/master/contrib/ports...
  • Wenn man einen minimalen Linux-Kernel ohne TCP/IP-Stack kompiliert, ist er 400 KB groß; mit TCP/IP-Stack werden daraus 800 KB.
    In einem Projekt, das nur Temperaturwerte senden musste, habe ich die Werte in selbst gebaute UDP-Nachrichten aus einem kleinen C-Programm im Userspace gepackt und so viel Platz und Komplexität sparen können.

    • Aus der Perspektive von jemandem, der davon nichts weiß, ist das ziemlich überraschend; ich frage mich aber, ob das nicht bedeutet, dass der TCP/IP-Teil die Hälfte des gesamten Kernel-Quellcodes ausmacht.
    • Ich frage mich, warum der IP-Stack so groß ist. Ein 400-KB-Binary ist schon eine ganze Menge Code; liegt das daran, dass er für den Einsatz auf großen Servern stark optimiert ist?
  • Wenn man ARP deaktiviert, kann man mehreren Servern im selben Netzwerk dieselbe IP zuweisen.
    Wenn ein Server als Routing-Frontend Pakete anhand der MAC-Adresse an die Netzwerkschnittstelle eines Backend-Servers weiterleiten kann, erkennt dieses Backend sich als Ziel und kann direkt dem Client antworten, indem es Quell- und Ziel-IP vertauscht. Das Routing-Frontend wird dabei nicht erneut durchlaufen.
    Alternativ kann man auch, ohne ARP abzuschalten, die gemeinsame IP-Adresse als Alias auf dem Loopback-Interface hinzufügen und denselben Effekt erzielen; das Backend erkennt sich dann als Ziel und vermeidet ARP-Konflikte. Das war ein Trick, den der IBM-WebSphere-Software-Load-Balancer in den 90er- bis 00er-Jahren nutzte.

    • Cisco IOS SLB kann ähnlich arbeiten: Auf dem Loopback jedes Servers in der Serverfarm wird eine virtuelle IP als Alias hinzugefügt.
      Der Vorteil gegenüber dem weiter verbreiteten L3-Load-Balancing ist, dass der IP-Paket-Header nicht neu geschrieben werden muss.
    • Das ist auch als DSR (Direct Server Return) bekannt: https://www.haproxy.com/blog/layer-4-load-balancing-direct-s...
    • Wenn man ARP deaktiviert und mehreren Servern im selben Netzwerk dieselbe IP zuweist, kann der Switch bzw. die Bridge die MAC-Adresse nicht lernen und flutet bzw. broadcastet Pakete dauerhaft an alle Ports in diesem Segment.
      Wenn man diese Methode verwendet, sollte man daher ein dediziertes VLAN anlegen.
    • F5 hat eine ARP-Proxy-Einstellung, sodass man das nicht tun muss. Der Nachteil ist, dass sie häufig DHCP kaputtmacht.
    • Für solche Low-Level-Spielereien kann man sich auch DPDK ansehen. ARP ist dort standardmäßig deaktiviert.
  • Ich habe etwas Ähnliches einmal in Python gemacht: https://github.com/georgek/notebooks/blob/master/internet.ip...
    Die Codequalität ist wahrscheinlich schlechter, und ehrlich gesagt habe ich mir den Algorithmus zur Adressauflösung einfach ausgedacht. Bis zum Pingen von Internet-Hosts per ICMP bin ich gekommen.
    Mir gefällt, dass alles vollständig in ein kurzes Notebook passt. Der Originalartikel lässt im Text viele Details weg, die im referenzierten größeren Quellcode stecken.
    Ich hatte diesen Artikel nicht gesehen und habe es nur anhand von Wikipedia gebaut. Ab TCP steigt die Komplexität allerdings stark an, wodurch mein Interesse etwas nachließ. Da Teil 3 diesen Bereich behandelt, lese ich ihn vielleicht irgendwann und bringe es zu Ende. Wenn man sich für Networking interessiert, halte ich das für eine lohnende Aufgabe für Programmierer auf jedem Niveau.

  • Vor ein paar Jahren habe ich an Instrumentierung für Kernkraftwerke gearbeitet. Die clientseitige Entwicklung lief auf Sun-Workstations, und tatsächlich wurde ich wegen meiner TCP/IP-Erfahrung eingestellt; diese Erfahrung hatte ich in CMUs Kurs „Betriebssysteme“ gesammelt.
    Die Computer im Kraftwerk waren dagegen Minicomputer ohne TCP/IP-Stack, daher musste das Team den Stack selbst bauen.

  • Etwa eine Minute nach Beginn des Artikels heißt es: „dmac und smac sind ziemlich selbsterklärende Felder“; Leser, die nicht wissen, was das ist, könnten an dieser Stelle sofort aussteigen.
    Man denkt dann: „Dieser Artikel ist offenbar für Leute gedacht, für die diese Felder selbsterklärend sind. Er ist nicht für mich, also sollte ich aufhören zu lesen.“

    • Der vollständige Satz lautet: „dmac und smac sind ziemlich selbsterklärende Felder. Sie enthalten die MAC-Adressen der Kommunikationspartner (Ziel bzw. Quelle)“, er erklärt es also tatsächlich.
      Außerdem ist es bei einem Artikel über das Bauen eines Netzwerk-Stacks durchaus vertretbar anzunehmen, dass die Leser ein gewisses Maß an Networking-Wissen mitbringen.
    • Falls es nicht gerade aktualisiert wurde, erklärt der direkt folgende Satz: „Sie enthalten die MAC-Adressen der Kommunikationspartner (Ziel bzw. Quelle)“.
  • Verwandte Beiträge:
    Let’s code a TCP/IP stack (2016) - https://news.ycombinator.com/item?id=27654182 - Juni 2021, 49 Kommentare
    Let’s code a TCP/IP stack, 1: Ethernet & ARP (2016) - https://news.ycombinator.com/item?id=17316487 - Juni 2018, 47 Kommentare
    Let’s Code a TCP/IP Stack: TCP Retransmission - https://news.ycombinator.com/item?id=14701199 - Juli 2017, 30 Kommentare
    Let’s code a TCP/IP stack, 1: Ethernet and ARP - https://news.ycombinator.com/item?id=11234229 - März 2016, 49 Kommentare

  • Ich weiß nicht, woher der Autor die IP-Adresse 10.0.0.4 nimmt, die er für den ARP-Auflösungstest verwendet
    Wessen Adresse soll das sein? Ist es ein Fake-Gerät, das von dem hier erstellten Fake-Ethernet-Gerät aus erreichbar ist, oder ein tatsächlich im Netzwerk des Autors vorhandenes Gerät?

    • Es steht nicht im Artikel, aber es ist ein Wert, den der Autor bei der Interface-Initialisierung hardcodiert hat: https://github.com/saminiir/level-ip/blob/e9ceb08f01a5499b85...
      Ein TAP-Gerät ist so etwas wie ein per Software emulierter Ethernet-Link. Wenn man Pakete dorthin sendet, werden sie direkt an ein User-Level-Programm weitergereicht; dieses Programm entscheidet, welche IP-Adresse es haben soll und wie es auf ARP antwortet
      Normalerweise übernimmt das Betriebssystem solche Dinge, und um einem Interface eine IP-Adresse hinzuzufügen, braucht man Root-Rechte. Dasselbe gilt für das Öffnen eines TAP-Geräts. Networking funktioniert im Allgemeinen kooperativ, und ein böswilliger Akteur mit Root-Rechten im Netzwerk kann Unfug treiben
  • Soweit ich mich erinnere, funktioniert ARP nur im lokalen Segment. Der Router trägt seine eigene Adresse ein und leitet das Paket weiter
    Außerdem gibt es auch RARP, eine Möglichkeit, das „Netzwerk“ nach der eigenen IP-Adresse zu fragen. Ich weiß nicht, ob RARP heute noch in realen Umgebungen funktioniert