Привет Ethernet

Post Thumbnail

Перевод статьи "Network Protocol Breakdown: Ethernet and Go".

Если вы читаете эту статью, то есть очень большая вероятность, что прямо сейчас вы пользуетесь Ethernet (IEEE 802.3) соединением где-то между вашими устройствами и хостингом, на котором размещён этот блог. Семейство технологий Ethernet - это строительные блоки для современных компьютерных сетей.

Было бы неплохо разобраться, как именно Ethernet работает на физическом уровне, но в этой статье я сфокусируюсь на фреймах Ethernet канального уровня ("Ethernet frames"). Этот уровень описывает, каким образом два компьютера взаимодействуют посредством Ethernet-соединения.

В этой статье мы подробно рассмотрим структуру фреймов Ethernet вплоть до значения каждого поля. А также разберёмся, как можно манипулировать Ethernet-фреймами в простой Go-программе, используя пакет github.com/mdlayher/ethernet.

Структура Ethernet-фрейма

Фундаментальная часть второго уровня Ethernet - это фреймы. Структура фрейма достаточно простая и является основой для построения более сложных протоколов поверх Ethernet.

В первых двух полях указывается MAC-адрес получателя и MAC-адрес отправителя. MAC-адрес - это уникальный идентификатор сетевого интерфейса для работы на канальном уровне. Размер MAC-адреса - 48 бит (6 байт).

В поле адреса получателя указывается MAC-адрес сетевого интерфейса, для которого предназначен этот фрейм. В некоторых случаях это может быть специальный широковещательный адрес: FF:FF:FF:FF:FF:FF. Некоторые протоколы, такие как ARP, всегда используют широковещательный адрес для отправки сообщения всем машинам в сегменте сети. Когда коммутатор получает фрейм с таким адресом, он дублирует его на все подключённые порты.

В адресе отправителя указывается MAC-адрес сетевого интерфейса, с которого был отправлен фрейм. Это позволяет другим машинам в сети идентифицировать машину отправителя и отправить сообщение в ответ.

Следующее поле - это 16-битное целочисленное поле, которое называется "Тип Ethernet" (EtherType). Оно определяет протокол более высокого уровня, который должен использоваться для работы с данными (полезной нагрузкой), инкапсулированными во фрейме. В качестве примера это могут быть протоколы ARP, IPv4 и IPv6.

Полезная нагрузка - это набор данных размером от 46 до 1500 (в некоторых случаях и больше) байтов. Размер этого поля зависит от настроек канального уровня. В качестве данных может передаваться что угодно, в том числе заголовки протоколов более высоких уровней.

Последний элемент Ethernet-фрейма - специальное поле - проверочная последовательность ("FCS"). По сути, это CRC32, контрольная сумма, использующая многочлен IEEE. С её помощью можно определить, повреждены ли данные фрейма. Как только фрейм полностью сформирован, контрольная сумма рассчитывается и записывается в последние 4 байта фрейма. Как правило, это делается автоматически операционной системой или сетевым интерфейсом. Но иногда бывает необходимо посчитать FCS в самой программе.

Создание Ethernet-фрейма с помощью Go

С помощью пакета ethernet можно создавать сами Ethernet-фреймы, отправлять и получать их через сеть.

В этом примере мы сделаем фрейм, у которого в качестве полезной нагрузки будет простая фраза "hello world". Также у этого фрейма будет кастомный EtherType. Фрейм будет рассылаться на все машины того же сегмента на канальном уровне; для этого будем использовать адрес: FF:FF:FF:FF:FF:FF.

// Фрейм будет рассылаться по сети.
f := &ethernet.Frame{
    // Рассылаем фрейм на все машины в сегменте.
    Destination: ethernet.Broadcast,
    // Указываем нашу машину как отправителя.
    Source: net.HardwareAddr{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad},
    // Указываем неиспользуемое значение EtherType.
    EtherType: 0xcccc,
    // Отправляем простое сообщение.
    Payload: []byte("hello world"),
}
// Кодируем структуру в бинарный формат Ethernet-фрейма.
b, err := f.MarshalBinary()
if err != nil {
    log.Fatalf("failed to marshal frame: %v", err)
}

// Отправляем данные по сети.
sendEthernetFrame(b)

Как я уже писал, операционная система или сетевой интерфейс сами выполнят расчёт FCS. В некоторых случаях можно воспользоваться методом ethernet.Frame.MarshalFCS, выполнить расчёт FCS "вручную" и добавить эти данные к фрейму.

Введение в VLAN-теги

Если вы работали с компьютерными сетями в прошлом, то вы, вероятно, знакомы с концепцией VLAN: виртуальные LAN-сегменты. VLAN (IEEE 802.1Q) позволяет разбивать один сегмент сети на множество различных сегментов. Для этого хитро используется поле EtherType во фрейме.

Когда добавляется VLAN-тег, первые 16 бит поля EtherType становятся идентификатором протокола (Tag Protocol Identifier). Чтобы указать, что VLAN-тег присутствует, используется зарезервированное значение для поля EtherType, например 0x8100.

Следующие за идентификатором протокола 16 бит в поле EtherType используются для задания специальных параметров:

  • Приоритет (3 бита) - один из классов IEEE P802.1p, используется на сервисном уровне.
  • DEI (Drop Eligible Indicator) (1 бит) - определяет, можно ли отбросить пакет, если в сети есть проблемы.
  • VLAN ID (VID) (12 бит) - указывает на VLAN, к которому относится этот фрейм. Каждый VID создаёт новый сетевой сегмент.

После VLAN-тега указывается EtherType, который уже идентифицирует данные в полезной нагрузке пакета.

В некоторых случаях может быть использовано несколько VLAN-тегов (IEEE 802.1ad, также известный как "Q-in-Q"). К примеру, это может быть полезно, когда провайдер инкапсулирует трафик пользователя в одном VLAN, в то время как пользователь также может инкапсулировать свой трафик в множестве различных VLAN.

Добавление тегов во фрейм с помощью Go

Как правило, сетевой интерфейс сам заботится о добавлении VLAN-тегов в Ethernet-фрейм. Но иногда бывает необходимо добавить VLAN-тег из самого приложения. Давайте посмотрим, как это можно сделать на примере нашего приложения.

// Фрейм будет отправляться по сети.
f := &ethernet.Frame{
    // Рассылаем фрейм на все машины в сегменте сети.
    Destination: ethernet.Broadcast,
    // Идентифицируем нашу машину как отправителя.
    Source: net.HardwareAddr{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad},
    // Добавляем тег VLAN 10. При необходимости указываем
    // несколько тегов для Q-in-Q.
    VLAN: []*ethernet.VLAN{{
        ID: 10,
    }},
    // Указываем неиспользуемый EtherType.
    EtherType: 0xcccc,
    // Отправляем простое сообщение.
    Payload: []byte("hello world"),
}

В моём примере не используются поля Priority и DEI в VLAN-тегах. Если в этих полях нет необходимости, можно просто оставить их пустыми.

Отправка и получение Ethernet-фреймов по сети

Большинство сетевых приложений работают поверх TCP или UDP. Но Ethernet-фреймы используются на более низком уровне, и для работы с ними нужно соответствующее API и права.

Под API, как правило, имеются в виду "сырые сокеты" ("raw sockets" или "packet sockets"). Эти низкоуровневые сокеты позволяют отправлять и получать Ethernet-фреймы напрямую, но требуют повышенных привилегий.

На Linux и BSD-системах можно использовать пакет github.com/mdlayher/raw для отправки фреймов через сетевой интерфейс. Ниже я приведу пример, как можно рассылать наши самодельные фреймы с сообщением "hello world":

// Выбираем интерфейс eth0.
ifi, err := net.InterfaceByName("eth0")
if err != nil {
    log.Fatalf("failed to open interface: %v", err)
}
// Открываем новый сокет. Используем тот же EtherType,
// что и в самом фрейме.
c, err := raw.ListenPacket(ifi, 0xcccc)
if err != nil {
    log.Fatalf("failed to listen: %v", err)
}
defer c.Close()
// Кодируем фрейм в бинарный формат.
f := newEthernetFrame("hello world")
b, err := f.MarshalBinary()
if err != nil {
    log.Fatalf("failed to marshal frame: %v", err)
}
// Рассылаем фрейм на все устройства в сегменте сети.
addr := &raw.Addr{HardwareAddr: ethernet.Broadcast}
if _, err := c.WriteTo(b, addr); err != nil {
    log.Fatalf("failed to write frame: %v", err)
}

На другой машине мы можем использовать похожую программу для приёма Ethernet-фреймов с нашим кастомным EtherType.

// Выбираем интерфейс eth0, с которым будем работать.
ifi, err := net.InterfaceByName("eth0")
if err != nil {
    log.Fatalf("failed to open interface: %v", err)
}
// Открываем сокет с указанием EtherType, как во фрейме.
c, err := raw.ListenPacket(ifi, 0xcccc)
if err != nil {
    log.Fatalf("failed to listen: %v", err)
}
defer c.Close()
// Принимаем сокеты на интерфейсе.
b := make([]byte, ifi.MTU)
var f ethernet.Frame
// Считываем фреймы.
for {
    n, addr, err := c.ReadFrom(b)
    if err != nil {
        log.Fatalf("failed to receive message: %v", err)
    }
    // Парсим Ethernet в Go-структуру.
    if err := (&f).UnmarshalBinary(b[:n]); err != nil {
        log.Fatalf("failed to unmarshal ethernet frame: %v", err)
    }
    // Отображаем полученное сообщение.
    log.Printf("[%s] %s", addr.String(), string(f.Payload))
}

И это, собственно, всё. Если у вас есть несколько машин с Linux, вы можете попробовать запустить все примеры у себя. Исходный код можно найти на GitHub.

Заключение

Низкоуровневые сетевые примитивы, такие как сокеты и Ethernet-фреймы, - очень мощные инструменты. Используя их, можно полностью контролировать весь трафик, который отправляет и получает ваше приложение.

Если вы находите подобные программы захватывающими, так же как и я, то вам пригодятся мои пакеты ethernet и raw. В своих будущих постах я покажу, как можно реализовать различные протоколы поверх Ethernet-фреймов.

Ссылки: