[Перевод] Пишем стек TCP/IP с нуля: Ethernet, ARP, IPv4 и ICMPv4

Часть 1: Ethernet и ARP
Написание собственного стека TCP/IP поначалу может показаться пугающей задачей. И в самом деле, за свой тридцатилетний срок жизни TCP впитал в себя множество спецификаций. Однако базовая спецификация остаётся относительно компактной1 — из важных частей в ней можно выделить парсинг заголовков TCP, машину состояний, отслеживание перегрузок и вычисление таймаута повторной передачи.
Самые распространённые протоколы слоя 2 и слоя 3, Ethernet и IP, скромны по сравнению с сложностью TCP. В этой серии статей мы реализуем минимальный стек TCP/IP пользовательского пространства для Linux.
Посты и код служат исключительно в образовательных целях, они позволят вам глубже изучить сетевое и системное программирование.
Устройства TUN/TAP
Для перехвата низкоуровневого сетевого трафика из ядра Linux мы используем устройство Linux TAP. Если вкратце, устройство TUN/TAP часто используется сетевыми приложениями пользовательского пространства для перехвата, соответственно, трафика L3/L2. Популярным примером этого может служить туннелирование, при котором пакет обёртывается в полезную нагрузку другого пакета.
Преимущество устройств TUN/TAP заключается в простоте их настройки как программ пользовательского пространства и в активном их использовании во множестве программ, например, в OpenVPN.
Так как мы хотим создавать сетевой стек, начиная со слоя 2 и выше, нам понадобится устройство TAP. Его экземпляр мы реализуем следующим образом:
/*
* Взято из Kernel Documentation/networking/tuntap.txt
*/
int tun_alloc(char *dev)
{
struct ifreq ifr;
int fd, err;
if( (fd = open("/dev/net/tap", O_RDWR)) < 0 ) {
print_error("Cannot open TUN/TAP dev");
exit(1);
}
CLEAR(ifr);
/* Flags: IFF_TUN - устройство TUN (без заголовков Ethernet)
* IFF_TAP - устройство TAP
*
* IFF_NO_PI - не предоставлять информацию пакетов
*/
ifr.ifr_flags = IFF_TAP | IFF_NO_PI;
if( *dev ) {
strncpy(ifr.ifr_name, dev, IFNAMSIZ);
}
if( (err = ioctl(fd, TUNSETIFF, (void *) &ifr)) < 0 ){
print_error("ERR: Could not ioctl tun: %s\n", strerror(errno));
close(fd);
return err;
}
strcpy(dev, ifr.ifr_name);
return fd;
}
После этого возвращаемый дескриптор файла fd можно использовать для чтения и записи данных в Ethernet-буфер виртуального устройства.
Флаг IFF_NO_PI здесь очень важен, если его не задать, то перед кадром Ethernet будет следовать ненужная информация пакета. Вы можете взглянуть на исходный код драйвера tun-device в ядре, чтобы убедиться в этом самостоятельно.
Формат кадра Ethernet
Множество различных сетевых технологий Ethernet — это фундамент для соединения компьютеров через локальные сети (Local Area Network, LAN). Как и в случае с любыми другими физическими технологиями, стандарт Ethernet сильно эволюционировал со своей первой версии2, опубликованной в 1980 году Digital Equipment Corporation, Intel и Xerox.
Первая версия Ethernet была медленной по современным стандартам, примерно 10 Мбит/с, и использовала полудуплексную связь, то есть возможна была или передача, или получение данных, но не одновременно. Именно поэтому для организации потока данных необходим был протокол Media Access Control (MAC). Даже сегодня в качестве способа MAC должен использоваться Carrier Sense, Multiple Access with Collision Detection (CSMA/CD), если интерфейс Ethernet работает в полудуплексном режиме.
Благодаря изобретению стандарта Ethernet 100BASE-T стали возможны использование витой пары и полнодуплексной передачи, а также повышение пропускной способности. Кроме того, рост популярности Ethernet-коммутаторов привёл к устареванию технологии CSMA/CD.
Рабочей группой IEEE 802.33 поддерживаются различные стандарты Ethernet.
Теперь мы рассмотрим заголовок кадра Ethernet. Его можно объявить как struct языка C:
#include
struct eth_hdr
{
unsigned char dmac[6];
unsigned char smac[6];
uint16_t ethertype;
unsigned char payload[];
} __attribute__((packed));
Назначение полей dmac
и smac
вполне очевидно: они содержат MAC-адреса обменивающихся данными сторон (соответственно, конечной точки (destination) и источника (source)).
Перегруженное поле ethertype
— это двухоктетное поле, которое в зависимости от своего значения обозначает или длину, или тип полезной нагрузки. В частности, если значение поля больше или равно 1536, то оно содержит тип полезной нагрузки (например, IPv4, ARP). Если значение меньше, то оно содержит длину полезной нагрузки.
После поля типа может идти множество различных тэгов кадра Ethernet. Эти тэги могут использоваться для описания типа Virtual LAN (VLAN) или Quality of Service (QoS) кадра. Тэги кадра Ethernet исключены из нашей реализации, поэтому в объявлении протокола они не встречаются.
Поле payload
содержит указатель на полезную нагрузку кадра Ethernet. В нашем случае она будет содержать пакет ARP или IPv4. Если длина полезной нагрузки меньше минимально необходимых 48 байтов (без тэгов), то для соответствия требованиям в конец полезной нагрузки добавляются байты-заполнители.
Также мы включили заголовок Linux if_ether.h
, чтобы обеспечить сопоставление между ethertype
и их шестнадцатеричными значениями.
Кроме того, в конце формата кадра Ethernet также есть поле Frame Check Sequence, которое наряду с Cyclic Redundancy Check (CRC) используется для проверки целостности кадра. В нашей реализации мы опустим обработку этого поля.
Парсинг кадра Ethernet
Атрибут, упакованный в объявление struct — это подробность реализации, используемая, чтобы сообщить компилятору GNU C о том, что не нужно оптимизировать структуру памяти struct для выравнивания данных при помощи байтов-заполнителей4. Мы используем этот атрибут только из-за способа «парсинга» буфера протокола; по сути, это преобразование типа буфера данных в настоящую struct протокола:
struct eth_hdr *hdr = (struct eth_hdr *) buf;
Можно применить портируемый, но чуть более трудоёмкий подход — ручную сериализацию данных протокола. При этом компилятору можно позволить добавлять байты-заполнители, чтобы лучше отвечать требованиям к выравниванию данных разных процессоров.
Общий сценарий парсинга и обработки входящих кадров Ethernet прост:
if (tun_read(buf, BUFLEN) < 0) {
print_error("ERR: Read from tun_fd: %s\n", strerror(errno));
}
struct eth_hdr *hdr = init_eth_hdr(buf);
handle_frame(&netdev, hdr);
Функция handle_frame
просто ищет поле ethertype
заголовка Ethernet и в зависимости от его значения выбирает своё следующее действие.
Address Resolution Protocol
Address Resolution Protocol (ARP) используется для динамического преобразования 48-битного Ethernet-адреса (MAC-адреса) в адрес протокола (например, в адрес IPv4). Самое важное здесь то, что с ARP можно использовать множество разных протоколов L3: не только IPv4, но и другие протоколы наподобие CHAOS, объявляющего 16-битные адреса протокола.
Обычный сценарий выглядит так: вы знаете IP-адрес какого-то сервиса в вашей LAN, но для установки соединения нужно знать и аппаратный адрес (MAC). Поэтому ARP используется для вещания и выполнения запросов в сети с просьбой к владельцу IP-адреса сообщить его аппаратный адрес.
Формат пакетов ARP относительно прост:
struct arp_hdr
{
uint16_t hwtype;
uint16_t protype;
unsigned char hwsize;
unsigned char prosize;
uint16_t opcode;
unsigned char data[];
} __attribute__((packed));
Заголовок ARP (arp_hdr
) содержит двухоктетный hwtype
, определяющий тип используемого канального уровня. В нашем случае это Ethernet, а значение равно 0×0001.
Двухоктетное поле protype
хранит тип протокола. В нашем случае это IPv4, что передаётся как значение 0×0800.
Поля hwsize
и prosize
однооктетны; они могут содержать, соответственно, размеры полей оборудования и протокола. В нашем случае это будут 6 байтов для MAC-адресов и 4 байта для IP-адресов.
Двухоктетное поле opcode
объявляет тип сообщения ARP. Это может быть запрос ARP (1), ответ ARP (2), запрос RARP (3) или ответ RARP (4).
Поле data
содержит саму полезную нагрузку сообщения ARP; в нашем случае оно содержит относящуюся к IPv4 информацию:
struct arp_ipv4
{
unsigned char smac[6];
uint32_t sip;
unsigned char dmac[6];
uint32_t dip;
} __attribute__((packed));
Смысл полей понятен из их названий. smac
и dmac
содержат, соответственно, 6-байтные MAC-адреса отправителя и получателя. sip
и dip
содержат, соответственно, IP-адреса отправителя и получателя.
Алгоритм ресолвинга адресов
В исходной спецификации представлен следующий простой алгоритм для ресолвинга адресов:
?Есть ли у меня в ar$hrd тип оборудования?
Да: (почти всегда)
[опционально проверяем аппаратную длину ar$hln]
?Говорю ли я с протоколом в ar$pro?
Да:
[опционально проверяем длину протокола ar$pln]
Merge_flag := false
Если пара <тип протокола, адрес протокола отправителя> уже
находится в моей таблице трансляции, помещаем в поле аппаратного
адреса записи новую информацию в пакете и присваиваем
Merge_flag значение true.
?Являюсь ли я целевым адресом протокола?
Да:
Если Merge_flag равен false, добавляем триплет <тип протокола,
адрес протокола отправителя, аппаратный адрес отправителя>
в таблицу трансляции.
?Равен ли opcode ares_op$REQUEST? (ТЕПЕРЬ смотрим opcode!!)
Да:
Меняем местами значения полей оборудования и протокола, помещая локальные
аппаратные адреса и адреса протокола в поля отправителя.
Присваиваем полю ar$op значение ares_op$REPLY
Отправляем пакет на целевой аппаратный адрес (новый)
на том же оборудовании, где был получен запрос.
Для хранения результатов ARP используется таблица трансляции, поэтому хосты просто могут проверять, есть ли уже запись в их кэше. Это позволяет избежать спама сети избыточными запросами ARP.
Алгоритм реализован в arp.c.
Главный тест реализации ARP заключается в проверке того, корректно ли она отвечает на запросы ARP:
[saminiir@localhost lvl-ip]$ arping -I tap0 10.0.0.4
ARPING 10.0.0.4 from 192.168.1.32 tap0
Unicast reply from 10.0.0.4 [00:0C:29:6D:50:25] 3.170ms
Unicast reply from 10.0.0.4 [00:0C:29:6D:50:25] 13.309ms
[saminiir@localhost lvl-ip]$ arp
Address HWtype HWaddress Flags Mask Iface
10.0.0.4 ether 00:0c:29:6d:50:25 C tap0
Сетевой стек ядра распознал ответ ARP от нашего собственного сетевого стека, а потому записал в свой кэш ARP запись нашего виртуального сетевого устройства. Успех!
Заключение
Минимальная реализация обработки кадров Ethernet и ARP относительно проста и может быть воссоздана в нескольких строках кода. А отдача же от этого, напротив, велика — мы можем записать в кэш ARP хоста Linux собственное придуманное Ethernet-устройство!
Источники
https://tools.ietf.org/html/rfc7414
http://ethernethistory.typepad.com/papers/EthernetSpec.pdf
https://en.wikipedia.org/wiki/IEEE_802.3
https://gcc.gnu.org/onlinedocs/gcc/Common-Type-Attributes.html#Common-Type-Attributes
https://github.com/chobits/tapip
Часть 2: IPv4 и ICMPv4
В этой части мы реализуем минимальный работоспособный слой IP и протестируем его эхо-запросами ICMP (пингами).
Мы рассмотрим форматы IPv4 и ICMPv4 и узнаем, как проверять их на целостность. Часть фич, например, IP-фрагментация, оставлена как упражнение для читателя.
В нашем сетевом стеке мы выбрали IPv4, а не IPv6, потому что он по-прежнему остаётся стандартным сетевым протоколом Интернета. Однако ситуация быстро меняется1, поэтому вероятно, что в будущем наш стек дополнится и IPv6.
Internet Protocol version 4
Следующий слой (L3)2 в нашей реализации (после кадров Ethernet) обрабатывает доставку данных в конечную точку. В качестве фундамента для транспортных протоколов наподобие TCP и UDP был придуман Internet Protocol (IP). Он не использует соединения, то есть, в отличие от TCP, все его датаграммы обрабатываются в сетевом стеке независимо друг от друга. Также это означает, что датаграммы могут поступать не в последовательном порядке3.
Более того: IP не гарантирует успешной доставки. Проектировщики протокола осознанно выбрали такое решение, поскольку IP задумывался и как фундамент для протоколов, тоже не гарантирующих доставку. Один из таких протоколов — UDP.
Если между обменивающимися данными сторонами требуется надёжность доставки, поверх IP используется протокол наподобие TCP. В таком случае протокол более высокого уровня отвечает за выявление недостающих данных и полную их доставку.
Формат заголовков
Заголовок IPv4 обычно имеет длину 20 октетов. Заголовок может содержать опции, но в нашей реализации они опущены. Смысл полей относительно прост и может быть описан в виде struct языка C:
struct iphdr {
uint8_t version : 4;
uint8_t ihl : 4;
uint8_t tos;
uint16_t len;
uint16_t id;
uint16_t flags : 3;
uint16_t frag_offset : 13;
uint8_t ttl;
uint8_t proto;
uint16_t csum;
uint32_t saddr;
uint32_t daddr;
} __attribute__((packed));
4-битное поле version
обозначает формат Интернет-заголовка. В нашем случае значение будет равно 4, что соответствует IPv4.
Поле длины Интернет-заголовка ihl
тоже имеет длину 4 бита; оно означает количество 32-битных слов в IP-заголовке. Так как поле имеет размер 4 бита, оно может содержать значение не больше 15. Таким образом, максимальная длина IP-заголовка составляет 60 октетов (15 умножить на 32 и поделить на 8).
Поле типа сервиса tos
появилось в первой спецификации IP4. В последующих спецификациях оно было разделено на более мелкие поля, но для простоты мы будем работать с этим полем так, как оно определено в исходной спецификации. В этом поле указывается качество сервиса для IP-датаграммы.
В поле общей длины len
хранится длина всей IP-датаграммы. Так как это поле 16-битное, максимальная длина составляет 65535 байтов. Большие IP-датаграммы подвергаются фрагментации, то есть разбиваются на меньшие датаграммы, чтобы соответствовать Maximum Transmission Unit (MTU) интерфейсов связи.
Поле id
используется для индексации датаграммы и для повторной сборки фрагментированных IP-датаграмм. Значение поля — это просто счётчик, инкремент которого выполняется отправляющей стороной. В свою очередь, получающая сторона знает, как упорядочивать поступающие фрагменты.
Поле flags
задаёт различные управляющие флаги датаграммы. В частности, отправитель может указать, допускается ли фрагментирование датаграммы, последний ли это фрагмент или поступят другие.
Поле смещения фрагмента frag_offset
указывает на позицию фрагмента в датаграмме. Естественно, для первой датаграммы этот индекс равен 0.
ttl
(time to live) — это стандартный атрибут времени жизни для ведения обратного отсчёта срока существования датаграммы. Обычно изначальный отправитель присваивает ему значение 64 и каждый получатель уменьшает этот счётчик на единицу. Когда он достигает нуля, датаграмма должна быть отклонена; иногда возможна отправка обратно сообщения ICMP об ошибке.
Поле proto
предоставляет датаграмме её способность нести в своей полезной нагрузке другие протоколы. Это поле обычно содержит такие значения, как 16 (UDP) или 6 (TCP) и просто используется, чтобы сообщать получателю тип данных.
Поле контрольной суммы заголовка csum
используется для проверки целостности IP-заголовка. Алгоритм проверки относительно прост, мы подробнее рассмотрим его в этом туториале.
Поля saddr
и daddr
обозначают адреса источника и конечной точки датаграммы. Хотя эти поля имеют длину 32 бита, обеспечивая таким образом пул в 4,5 миллиарда адресов, диапазон адресов в ближайшем будущем будет исчерпан5. Протокол IPv6 расширяет эту длину до 128 битов, поэтому обеспечивает солидный запас диапазона адресов Internet Protocol на будущее, а возможно, и навсегда.
Internet Checksum
Поле Internet Checksum используется для проверки целостности IP-датаграммы. Контрольная сумма вычисляется достаточно просто, алгоритм определён в исходной спецификации4:
Поле контрольной суммы — это обратное 16-битное значение обратного значения суммы всех 16-битных слов в заголовке. При вычислении контрольной суммы значение поля контрольной суммы равно нулю.
Код алгоритма6 имеет следующий вид:
uint16_t checksum(void *addr, int count)
{
/* Вычисляем Internet Checksum для "count" байтов,
* начиная с места "addr".
* Взято из https://tools.ietf.org/html/rfc1071
*/
register uint32_t sum = 0;
uint16_t * ptr = addr;
while( count > 1 ) {
/* Это внутренний цикл */
sum += * ptr++;
count -= 2;
}
/* Прибавляем оставшийся байт, если он есть */
if( count > 0 )
sum += * (uint8_t *) ptr;
/* Сворачиваем 32-битную сумму в 16 битов */
while (sum>>16)
sum = (sum & 0xffff) + (sum >> 16);
return ~sum;
}
Возьмём для примера IP-заголовок 45 00 00 54 41 e0 40 00 40 01 00 00 0a 00 00 04 0a 00 00 05
:
Сложение двух полей даст нам сумму в дополнительном коде 01 1b 3e.
Чтобы преобразовать её в обратное значение, биты переноса прибавляются к первым 16 битам: 1b 3e + 01 = 1b 3f.
Далее берётся обратное значение суммы, и мы получаем значение контрольной суммы e4c0.
IP-заголовок принимает вид 45 00 00 54 41 e0 40 00 40 01 e4 c0 0a 00 00 04 0a 00 00 05.
Контрольную сумму можно проверить, повторно применив алгоритм: если результат равен 0, то данные с большой долей вероятности верные.
Internet Control Message Protocol версии 4
Так как у Internet Protocol нет механизмов обеспечения надёжности, нам требуется какой-то способ сообщать обменивающимся данными сторонам о возможных ошибках. Для диагностики в сети применяется Internet Control Message Protocol (ICMP)7. Для примера возьмём случай, когда невозможно получить доступ к шлюзу — обнаруживший это сетевой стек отправляет источнику ICMP-сообщение «шлюз недоступен».
Формат заголовков
Заголовок ICMP находится в полезной нагрузке соответствующего IP-пакета. Сам заголовок ICMPv4 имеет следующую структуру:
struct icmp_v4 {
uint8_t type;
uint8_t code;
uint16_t csum;
uint8_t data[];
} __attribute__((packed));
Здесь поле type
обозначает цель сообщения. Для него зарезервировано 42 значения8, но чаще всего используются только 8. В нашей реализации применяются типы 0 (Echo Reply), 3 (Destination Unreachable) и 8 (Echo request).
Поле code
подробнее раскрывает содержание сообщения. Например, когда тип равен 3 (Destination Unreachable), то в поле code
подразумевается указание причины. Часто возникает ошибка невозможности маршрутизации пакета в сети: в этом случае хост-источник с большой вероятностью получил сообщение ICMP с типом 3 и кодом 0 (Net Unreachable).
Поле csum
— это то же поле контрольной суммы, что и в заголовке IPv4, и для его вычисления можно использовать тот же алгоритм. Однако в ICMPv4 используется сквозная контрольная сумма, то есть при её вычислении включается и полезная нагрузка.
Сообщения и их обработка
Сама полезная нагрузка ICMP состоит из запросов/информационных сообщений и сообщений об ошибках. Сначала мы смотрим на сообщения Echo Request/Reply, в сетевой терминологии обычно называемые «пингованием»:
struct icmp_v4_echo {
uint16_t id;
uint16_t seq;
uint8_t data[];
} __attribute__((packed));
Формат сообщений компактен. Поле id
задаётся хостом-отправителем, чтобы обозначить, для какого процесса предназначается эхо-ответ. Например, этому полю можно присвоить значение id
процесса.
Поле seq
— это последовательный номер эхо; это просто число, начиная с нуля с увеличением на единицу при каждом формировании нового эхо-запроса. Он используется для обнаружения исчезновения эхо-сообщений или изменения их порядка при передаче.
Поле data
опционально, но часто оно содержит такую информацию, как метка времени эхо. Его можно использовать для определения полного времени передачи туда-обратно между хостами.
Самое, наверно, распространённое сообщение об ошибке ICMPv4 — это Destination Unreachable. Оно имеет следующий формат:
struct icmp_v4_dst_unreachable {
uint8_t unused;
uint8_t len;
uint16_t var;
uint8_t data[];
} __attribute__((packed));
Первый октет не используется (unused
). Поле len
обозначает длину исходной датаграммы, в 4-октетных единицах для IPv4. Значение 2-октетного поля var
зависит от кода ICMP.
В поле data
помещается максимально возможный объём исходного IP-пакета, вызвавшего состояние Destination Unreachable.
Тестируем реализацию
В оболочке мы можем проверить, отвечает ли наш сетевой стек пользовательского пространства на эхо-запросы ICMP:
[saminiir@localhost ~]$ ping -c3 10.0.0.4
PING 10.0.0.4 (10.0.0.4) 56(84) bytes of data.
64 bytes from 10.0.0.4: icmp_seq=1 ttl=64 time=0.191 ms
64 bytes from 10.0.0.4: icmp_seq=2 ttl=64 time=0.200 ms
64 bytes from 10.0.0.4: icmp_seq=3 ttl=64 time=0.150 ms
--- 10.0.0.4 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 1999ms
rtt min/avg/max/mdev = 0.150/0.180/0.200/0.024 ms
Заключение
Минимально работоспособный сетевой стек, обрабатывающий кадры Ethernet, ARP и IP, можно создать относительно легко. Однако первоначальные спецификации были дополнены множеством новых. В этом посте мы опустили такие особенности IP, как опции, фрагментацию и поля DCN и DS заголовков.
Кроме того, для развития Интернета критичным становится использование IPv6. Он ещё не используется повсеместно, но его определённо стоит реализовать в нашем сетевом стеке.
Исходный код для этого поста можно найти на GitHub.
В следующем посте мы перейдём к транспортному слою (L4) и приступим к реализации Transmission Control Protocol (TCP). TCP — это ориентированный на соединения протокол, обеспечивающий надёжность между обоими сторонами передачи. Эти аспекты определённо привнесут дополнительные сложности, а поскольку протокол TCP стар, у него есть свои тёмные уголки.
Источники
https://en.wikipedia.org/wiki/IPv6_deployment
https://en.wikipedia.org/wiki/OSI_model
https://en.wikipedia.org/wiki/TCP/IP_Illustrated#Volume_1:_The_Protocols
http://tools.ietf.org/html/rfc791
https://en.wikipedia.org/wiki/IPv4_address_exhaustion
https://tools.ietf.org/html/rfc1071
https://www.ietf.org/rfc/rfc792.txt
http://www.iana.org/assignments/icmp-parameters/icmp-parameters.xhtml