C-Schnipsel: Datagramm-Sockets mit IPv4 und IPv6

Tue, 08 Dec 2020 12:00:00 +0100
c, programmieren, software

Datagramm-Sockets mit IPv4 und IPv6

Netzwerkprogrammierung mit Sockets ist seit Jahrzehnten bekannt und bewährt. Etliche Funktionen, die heute noch verwendet werden, sind schon in den 1990er Jahren ausführlich beschrieben und werden immer noch so eingesetzt.

Und dennoch gibt es Stellen, bei denen es gilt, aufzupassen und sich der darunter liegenden Mechanismen bewusst zu werden. Einer dieser Punkte ist die Programmierung für IPv4 und IPv6 gleichzeitig.

IPv4 wird uns noch auf längere Zeit erhalten bleiben und IPv6 hat inzwischen so viel Traktion gewonnen, dass es nicht mehr ignoriert werden kann.

Glücklicherweise muss ich nicht mit zwei Sockets hantieren, wenn ich am selben TCP- oder UDP-Port sowohl mit IPv4 als auch mit IPv6 Daten senden und empfangen möchte. RFC3493 beschreibt Basic Socket Interface Extensions for IPv6 und darauf aufbauend beschreibt RFC3542 das Advanced Sockets Application Program Interface (API) for IPv6. Insbesondere Sektion 3.7 von RFC3493 beschreibt Compatibility with IPv4 Nodes für das IPv6-Socket-Interface.

Verwende ich TCP, ist es sehr einfach, Antworten an den Partner einer Verbindung zu senden, da der von accept() gelieferte Socket-Deskriptor genau die TCP-Verbindung referenziert, über die ich senden will.

Komplizierter ist es bei UDP, weil es hier keine Verbindung wie bei TCP gibt. Habe ich mit recvfrom() oder recvmsg() ein Datagramm erhalten, so muss ich bei sendto() beziehungsweise sendmsg() explizit die Zieladresse und den Port angeben. Glücklicherweise bekomme ich bei beiden Empfangsfunktionen die Adresse genau in der Form, die ich für sendto() und sendmsg() benötige und brauche mich auch hier nicht um die Adresse zu kümmern, um eine Antwort an den Absender eines erhaltenen Datagramms zu senden. Es sei denn, mein Rechner hat mehrere Adressen und ich will sichergehen, dass die Antwort mit genau der Adresse gesendet wird, an die das empfangene Datagramm ging. Dann muss ich die lokale Adresse ermitteln und an sendmsg() übergeben.

Noch komplizierter wird es, wenn ich die Sender- und/oder Empfänger-Adressen und -Ports benötige um damit Berechnungen anzustellen, wie zum Beispiel für NAT-Detection bei IKE. Hier muss ich nicht nur die beiden Adressen und Ports ermitteln, sondern auch unterscheiden, ob es sich um IPv4- oder IPv6-Adressen handelt.

Bei IPv6-Sockets werden IPv4-Adressen mit einem speziellen Präfix versehen, so dass ich diese erkennen kann und für meine Berechnungen nur auf die letzten 32 Bit der Adresse zugreife. IPv4-Mapped-IPv6-Adressen haben die Form ::FFFF:a.b.c.d, das heißt sie beginnen mit zehnmal 0x00 gefolgt von zweimal 0xFF. Finde ich diesen Prefix bei der IPv6-Adresse, dann verwende ich für meine Berechnungen die 4 Byte an der Stelle &sin6_addr.s6_addr[12].

Wie ermittle ich Adresse und Port des Peers?

Bei TCP-Verbindungen benötige ich normalerweise nicht Adresse und Port des Peers, da TCP verbindungsorientiert arbeitet und der TCP-Stack sich darum kümmert, dass die Datagramme beim Peer ankommen. Hier kann ich mit der Funktion recv() Daten empfangen und mit send() Daten senden, ohne mich um die Adressen zu kümmern.

Anders sieht es bei UDP aus. Hier verwende ich recvfrom() oder recvmsg() um Daten zu empfangen und sendto() beziehungsweise sendmsg() zum Senden.

Allen Funktionen kann ich eine struct sockaddr mitgeben, in der ich bei recvfrom() und recvmsg() Peeradresse und -port bekomme, die ich bei sendto() beziehungsweise sendmsg() benötige.

Wie ermittle ich meine Adresse und Port?

Komplizierter wird es, wenn ich die eigene Adresse und den eigenen Port benötige. Dann kann ich nur recvmsg() und sendmsg() verwenden, denen ich eine struct msghdr mitgebe, die wiederum die struct sockaddr für die Peeradresse enthält. Aus der struct msghdr kann ich mit den in der Handbuchseite cmsg beschriebenen Makros die lokale Adresse ermitteln, an die das Datagramm gesendet wurde.

Damit habe ich die lokale Adresse, aber noch nicht den Port. Normalerweise kenne ich den Port, weil ich den Socket ursprünglich an diesen gebunden habe. Arbeite ich jedoch mit mehreren Ports, wie zum Beispiel bei IKE mit Port 500 und 4500, dann muss ich den Port des aktuell verwendeten Sockets ermitteln. Dazu verwende ich die Funktion getsockname(), die mir den Port und die Adresse liefert, an die der Socket ursprünglich gebunden wurde. Zwar wäre es möglich, daraus auch die lokale Adresse zu entnehmen. Allerdings ist diese ::, wenn ich ursprünglich keine spezifische Adresse an den Socket gebunden hatte, so dass ich doch wieder auf recvmsg() zurückgreifen muss.

Beispiel

Ein kleines Programm soll zeigen, wie ich die einzelnen Informationen ermitteln kann.

Zunächst binde ich einen Datagramm-Socket an die UDP-Ports 2000 und 4000. Dazu verwende ich die Funktionen socket() um einen Socket zu erzeugen und bind() um einen Namen - in diesem Fall die UDP-Portnummer an den Socket zu binden. Die Funktion bind() benötigt eine Struktur sockaddr als Argument, die ich mit getaddrinfo() bekomme.

Weil das ganze etwas aufwendig ist und ich zwei Sockets an verschiedenen Ports binden will, lagere ich diesen Teil in eine Funktion aus:

int bind_ip6_dgram_socket(const char * port) {
        int rv, sockfd;
        struct addrinfo *p, *servinfo;
        struct addrinfo hints = {};

        hints.ai_family = AF_INET6;
        hints.ai_socktype = SOCK_DGRAM;
        hints.ai_flags = AI_PASSIVE;

        if ((rv = getaddrinfo(NULL, port, &hints, &servinfo)) != 0) {
                fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rv));
                abort();
        }
        for (p = servinfo; NULL != p; p = p->ai_next) {
                if ((sockfd = socket(p->ai_family, p->ai_socktype,
                                     p->ai_protocol)) == -1) {
                        perror("bind_socket: socket");
                        continue;
                }
                if (bind(sockfd, p->ai_addr, p->ai_addrlen) == -1) {
                        close(sockfd);
                        perror("bind_socket: bind");
                        continue;
                }
                break;
        }
        freeaddrinfo(servinfo);
        return sockfd;
} // bind_ip6_dgram_socket()

Diese Funktion rufe ich wie folgt auf:

int sock2000 = bind_ip6_dgram_socket("2000");
int sock4000 = bind_ip6_dgram_socket("4000");

Danach warte ich mit epoll_wait() auf ankommende Datagramme. Die Details lasse ich hier aus, um den Rahmen dieses Artikels nicht zu sprengen.

Kommt ein Datagramm an einem der beiden Ports an, rufe ich eine Callback-Funktion auf und übergebe ihr den zugehörigen Socket.

Als erstes setze ich am Socket die Option IPV6_RECVPKTINFO, damit beim Aufruf von recvmsg() die Zieladresse und der Interface-Index mit geliefert werden.

int result;
int opt = 1;
result = setsockopt(sockfd, IPPROTO_IPV6, IPV6_RECVPKTINFO, &opt, sizeof(opt));

Anschließend kann ich das Datagramm mit recvmsg() einlesen.

struct sockaddr_in6 raddr = {};
char msgbuf[1500];
struct iovec iov = { .iov_base = &msgbuf, .iov_len = sizeof(msgbuf) };
union {
        struct cmsghdr cm; // this is to control the alignment
        char   control[1000];
} control_un;
struct msghdr msg = { .msg_name = &raddr,
                      .msg_namelen = sizeof(raddr),
                      .msg_control = control_un.control,
                      .msg_controllen = sizeof(control_un),
                      .msg_iov = &iov,
                      .msg_iovlen = 1
                    };

result = recvmsg(sockfd, &msg, 0);

Nun habe ich die angekommenen Daten in msgbuf und kann mich daran machen, die benötigten Adressen und Ports aus msg zu extrahieren.

Die Adresse des Senders kann ich direkt verwenden und mit inet_ntop() in lesbare Form bringen:

char raddrbuf[INET6_ADDRSTRLEN];
inet_ntop(AF_INET6, raddr.sin6_addr.s6_addr, raddrbuf, sizeof(raddrbuf));

Komplizierter wird es mit der eigenen Adresse. Ich finde diese in einer struct cmsghdr, die ich mit den Makros CMSG_FIRSTHDR(), CMSG_NXTHDR() und CMSG_DATA() bearbeiten kann.

char laddrbuf[INET6_ADDRSTRLEN];
struct cmsghdr *cmptr;
for (cmptr = CMSG_FIRSTHDR(&msg); cmptr != NULL;
     cmptr = CMSG_NXTHDR(&msg, cmptr)) {
        if (cmptr->cmsg_level == IPPROTO_IPV6 &&
            cmptr->cmsg_type  == IPV6_PKTINFO) {
                struct in6_pktinfo *pkt = (struct in6_pktinfo*)CMSG_DATA(cmptr);
                struct in6_addr *dap = &(pkt->ipi6_addr);
                inet_ntop(AF_INET6,
                          (void*)dap,
                          laddrbuf,
                          sizeof(laddrbuf));
        }
}

Damit habe ich die beiden Adressen, fehlen noch die Ports.

Der Port des Senders ist wieder einfach, diesen kann ich aus raddr gewinnen:

int rport = ntohs(raddr.sin6_port);

Komplizierter wird es für den lokalen Port. Wir haben zwei Sockets an unterschiedliche Ports gebunden und nur den Socket übergeben bekommen, über den ein Datagramm empfangen wurde. Aus der struct cmsghdr bekommen wir zwar die eigene Adresse aber nicht den Port. Für den Port kann ich auf die Funktion getsockname() zurückgreifen, die mir die Adresse inklusive Port liefert, an die der Socket gebunden ist.

struct sockaddr_in6 laddr = {};
socklen_t laddrlen = sizeof(laddr);
getsockname(sockfd, &laddr, &laddrlen);
int lport = ntohs(laddr.sin6_port);

Leider kann ich davon nur den Port verwenden, weil die Adresse hier :: ist, denn ich habe den Socket an keine feste Adresse gebunden. Aus diesem Grund brauche ich für die eigene Adresse die struct cmsghdr und die zugehörigen Makros.

Nun habe ich alle benötigten Informationen beieinander und kann eine Antwort an den Sender schicken und eine qualifizierte Information im Log ausgeben.

printf("rcvd %d bytes [%s]:%hu to [%s]:%hu\n",
       result,
       raddrbuf,
       rport,
       laddrbuf,
       lport);
iov.iov_len= result;
result = sendmsg(sockfd, &msg, MSG_DONTWAIT);

Ich kann das Programm zum Beispiel mit socat testen. Dazu starte ich es auf einem Rechner und kontaktiere es von einem zweiten.

Die nachfolgenden Aufrufe von socat produzierten diese Ausgabe:

$ ./udp-echo
rcvd 7 bytes [::ffff:192.168.1.3]:39718 to [::ffff:192.168.1.101]:2000
rcvd 6 bytes [::ffff:192.168.1.3]:42653 to [::ffff:192.168.1.101]:4000
rcvd 19 bytes [fe80::e072:d56:b3c9:f4ad]:45719 to [fe80::8eed:cdfb:c843:254b]:2000
rcvd 24 bytes [fe80::e072:d56:b3c9:f4ad]:36904 to [fe80::8eed:cdfb:c843:254b]:4000

Auf dem Testrechner ist neben der eigenen Eingabe auch das Echo aus den zurückgesendeten Datagrammen zu sehen.

~$ socat stdin udp-sendto:192.168.1.101:2000
dsfkla
dsfkla
~$ socat stdin udp-sendto:192.168.1.101:4000
asdkl
asdkl
~$ socat stdin udp-sendto:[fe80::8eed:cdfb:c843:254b]:2000
kasdljfalksjfgdlak
kasdljfalksjfgdlak
~$ socat stdin udp-sendto:[fe80::8eed:cdfb:c843:254b]:4000
asklfjakoljertio4aqwet5
asklfjakoljertio4aqwet5

Der Vollständigkeit halber ist hier der gesamte Quellcode des Programms.

Dieses kann mit folgendem Befehl auf Linux übersetzt werden:

cc -std=gnu11 -D_GNU_SOURCE -g -o udp-echo udp-echo.c