Всем привет! Меня зовут Игорь Горбунов, я разрабатываю платформу базовой станции в YADRO и изучаю Golang почти год. Уже перевалил рубеж «вывести на экран сумму четных элементов среза» и захотел написать что-то более сложное.
Я интересуюсь сетями, и решил посмотреть, как в Go реализуется работа с протоколами ICMP и ICMPv6. Наиболее простая задача, связанная с ними, — реализация программы ping. Она отправляет указанному узлу сети запросы ICMP типа Echo-Request и ожидает ответы типа Echo-Reply.
На первый взгляд — простейшая задача, поэтому усложним ее: построим приложение, похожее на утилиту ping в UNIX-подобных системах. Под катом расскажу, как я решал задачу и с какими подводными камнями столкнулся.
Требования к приложению
Я сформулировал требования к приложению, на которые буду опираться в процессе разработки:
Возможность запросов Echo-Request по протоколам ICMP и ICMPv6 и поддержка IPv4 и IPv6 со стороны ping.
Возможность указания целевого узла в виде непосредственно адреса либо в виде имени, что требует поддержки разрешения имен.
Возможность менять из командной строки размер отправляемых запросов и их количество.
Подсчет и вывод в консоль статистики отправленных запросов, полученных и неполученных ответов, ошибок, минимального, среднего, максимального времени круговой задержки (rtt), а также стандартного отклонения rtt.
Такой набор требований выглядит уже достаточным для получения начального опыта работы в Go с протоколами IPv4/IPv6 и запросами Echo-Request/Echo-Reply (и не только, как увидим позднее) в ICMP и ICMPv6.
Go-пакеты для работы с ICMP
В интернете можно найти с десяток статей о создании ping-подобных программ на Go. Все они базируются на четырех пакетах Go, предназначенных для работы с сетью:
net — переносимый интерфейс для работы с сетевым вводом и выводом, разрешением имен и сокетами UNIX.
golang.org/x/net/icmp — функции для работы с сообщениями протоколов ICMP и ICMPv6.
golang.org/x/net/ipv4 — опции сетевых сокетов IP-уровня для управления возможностями IPv4.
golang.org/x/net/ipv6 — опции сетевых сокетов IP-уровня для управления возможностями IPv6.
Путеводитель по коду: как я решал задачу
Для простоты я опускаю из листингов проверку некоторых ошибочных ситуаций в исходном коде программы ping, доступном по этому адресу. Для разбора листингов советую открыть в соседнем окне IDE и сверяться с этим кодом.
Выполнение программы начинается с функции init()
, в которой мы определяем, какие аргументы командной строки она будет обрабатывать. Для работы с ними используем пакет flag.
var (
count, szpacket int
tos, ttl int
srcAddr string)
func init() {
flag.IntVar(&count, "c", math.MaxInt, "number of requests to send")
flag.IntVar(&szpacket, "s", 56, "packet size")
flag.IntVar(&tos, "Q", 0, "Quality of Service")
flag.IntVar(&ttl, "t", 64, "IP Time to Live")
flag.StringVar(&srcAddr, "I", "", "interface is either an address or an interface name")
}
С помощью флага -c=<num> будем определять количество отсылаемых пакетов. Если флаг не задан, переменная count примет значение math.MaxInt
. В таком случае мы будем отправлять эхо-запросы до тех пор, пока пользователь не нажмет Ctrl+C.
С помощью флага -s=<num> будем определять размер пакета. По умолчанию примем его равным 56 байтам. Также условимся, что размер пакета должен быть не меньше 16 и не больше 1200 байт. Минимальный размер обусловлен тем, что в полезной нагрузке мы будем сохранять временную метку отправки пакета для подсчета rtt.
В функции main()
разбираем аргументы и получаем указание на целевой узел: вся оставшаяся неразобранная часть строки вызова будет доступна по вызову flag.Arg(0)
.
Целевой узел может быть задан по имени либо с использованием IP-адреса. При этом IPv4-адрес задается в точечной нотации, а IPv6-адрес задается в нотации через двоеточие и может дополняться суффиксом зоны через символ %. В качестве зоны пользователь указывает либо индекс интерфейса, либо его имя в операционной системе (см. RFC 4007 IPv6 Scoped Address Architecture), через которое предполагается обмен при использовании локальных (link-local) адресов. Извлечем и позднее используем эту зону при отправке ICMPv6 эхо-запросов.
var hostname, zone string
hostname = flag.Arg(0)
if idx := strings.Index(hostname, "%"); idx != -1 {
s := strings.Split(hostname, "%")
if len(s) != 2 {
fmt.Fprintln(os.Stderr, "Error: invalid hostname")
os.Exit(2)
}
hostname = s[0]
zone = s[1]
}
В пакете net есть замечательная функция net.ParseIP()
, которая может разобрать и преобразовать во внутреннее представление строку с IPv4- или IPv6-адресом. Попробуем разобрать полученный от пользователя аргумент и при необходимости провести разрешение имени целевого хоста при помощи net.LookupHost()
. Если пользователь задал целевой хост по адресу, то в переменную hostname запишем пустую строку, чтобы не захламлять вывод программы.
var tgtAddr net.IP
if tgtAddr = net.ParseIP(hostname); tgtAddr == nil {
addrs, err := net.LookupHost(hostname)
if len(addrs) == 0 || err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(2)
}
tgtAddr = net.ParseIP(addrs[0])
} else {
hostname = ""
}
Во фрагменте выше используем первый адрес, который вернет net.LookupHost()
. В реальности у узла может быть несколько IP-адресов — как IPv4, так и IPv6. Они все перечислены в срезе, возвращаемом функцией. Выбор конкретного типа адреса (IPv4 против IPv6) важен, когда пользователь хочет отправлять эхо-запросы с использованием конкретной версии протокола ICMP. В таком случае необходимо добавить флаги выбора протокола, как это сделано в стандартной утилите ping: -4 и -6.
Пользователь также может указать локальный интерфейс, с которого должны отправляться пакеты — в виде IP-адреса или имени интерфейса в системе.
В коде программы привязка к источнику осуществляется с помощью IP-адреса в функции net.ListenPacket()
, поэтому для имени интерфейса необходимо найти соответствующий ему адрес. Для этого проведем перебор имеющихся в системе сетевых интерфейсов с помощью net.Interfaces()
и найдем назначенный нашему интерфейсу адрес. Учтем, что такового может и не быть — в этом случае завершим выполнение программы.
if len(srcAddr) > 0 {
ipaddr := net.ParseIP(srcAddr)
if ipaddr == nil {
interfaces, _ := net.Interfaces()
i := slices.IndexFunc(interfaces,
func(iface net.Interface) bool {
return iface.Name == srcAddr
})
addrs, _ := interfaces[i].Addrs()
if len(addrs) == 0 {
os.Exit(2)
} else {
lookForIpv4 := tgtAddr.To4() != nil
for _, v := range addrs {
ipnet, ok := v.(*net.IPNet)
if lookForIpv4 && ipnet.IP.To4() != nil {
srcAddr = ipnet.IP.String()
break
} else if !lookForIpv4 && ipnet.IP.To4() == nil {
zone = srcAddr
srcAddr = ipnet.IP.String()
break
}
}
}
} else if ipaddr.To4() == nil && len(zone) == 0 {
interfaces, _ := net.Interfaces()
for _, i := range interfaces {
a, _ := i.Addrs()
if len(a) > 0 {
for _, v := range a {
ipnet, ok := v.(*net.IPNet)
if ipnet.IP.Equal(ipaddr) {
zone = i.Name
break
}
}
}
}
}
}
Необходимо учесть, что при указании исходного адреса IPv6 пользователь может также добавить зону. Если пользователь указал зону как в исходном, так и в целевом адресе, то первая будет иметь приоритет над второй.
Теперь вызываем одну из функций, которая будет обмениваться с целевым узлом по сетевому протоколу заданной версии. Эти функции определим в собственных файлах с исходным кодом.
var err error
if addr := tgtAddr.To4(); addr != nil {
err = ping4(tgtAddr, srcAddr, count, szpacket, tos, ttl)
} else {
err = ping6(tgtAddr, zone, srcAddr, count, szpacket, tos, ttl)
}
Функции ping4()
и ping6()
возвращают управление в main()
по одной из двух причин:
Отправлено указанное пользователем количество пакетов.
Произошло прерывание выполнения программы по сигналу. Например, пользователь нажал Ctrl+C. По возвращении из
ping4()
/ping6()
можно вывести статистику в консоль.
Статистика накапливается в структуре stats
типа statistics
:
type statistics struct {
rtts []time.Duration
min, max time.Duration
transmitted, received int errors
int
}
var stats statistics
Вывод значений осуществляется в main()
перед завершением программы:
fmt.Printf("transmitted: %v, received: %v packets, errors: %v, packet loss: %v%%\n",
stats.transmitted, stats.received, stats.errors,
percentile(stats.transmitted, stats.transmitted-stats.received))
if stats.received > 0 {
avg, stddev := averagesd(stats.rtts)
fmt.Printf("rtt: min=%v/avg=%v/max=%v/stddev=%v\n",
stats.min, avg, stats.max, stddev)
}
Для краткости изложения не буду приводить здесь реализацию функций averagesd() и percentile() — ищите их в исходном коде программы.
На этом функция main()
заканчивается, и мы можем перейти к описанию непосредственных исполнителей обмена с удаленным узлом.
Начнем с ping6()
, поскольку версия протокола IPv6 представляет больший интерес, как версия, на которую мы рано или поздно перейдем. Сигнатура функции ping6()
выглядит следующим образом:
func ping6(target net.IP, zone, source string, nmpackets, szpacket, tos, ttl int) error {
В функцию передаем адрес целевого узла, зону и исходящий адрес, которые могут быть пустыми, а также количество передаваемых запросов, размер запроса, значения типа сервиса (TOS) и времени жизни (TTL).
Создаем соединение для прослушки входящих пакетов ICMPv6. Если параметр source
не пуст, то используем его для создания соединения. В таком случае в программе будем получать только пакеты, предназначенные для указанного адреса. Иначе привязываемся к обобщенному адресу "::" и будем ловить также ICMP-пакеты, которые могут не иметь отношения к нашей программе.
sourceAddr := "::"
if len(source) > 0 {
sourceAddr = source
}
if len(zone) > 0 {
sourceAddr += "%" + zone
}
netconn, err := net.ListenPacket("ip6:ipv6-icmp", sourceAddr)
defer netconn.Close()
В качестве типа соединения указываем "ip6:ipv6-icmp". Формат строки понятен только из примеров в документации библиотеки, а ее содержимое для ICMPv6 в документации не приведено.
Вот тут я прочувствовал важное преимущество языка Go: открытые исходные тексты компилятора и стандартной библиотеки.
Я не знал названия протокола ICMPv6 для функции net.ListenPacket()
, но смог по исходным текстам дойти до парсера строки с указанием протокола и выяснить, что ожидается строка "ipv6-icmp", а не "icmp6", как предполагал изначально. Не забудем закрыть соединение при выходе из ping6()
.
Для установки параметров TOS и TTL необходимо добавить еще один слой поверх созданного соединения с помощью функции ipv6.NewPacketConn()
. Она вернет объект управления некоторыми полями заголовка IPv6: hop limit и traffic class. Изменения этих полей можно наблюдать через программу для анализа трафика — например, tcpdump.
conn := ipv6.NewPacketConn(netconn)
defer conn.Close()
conn.SetHopLimit(ttl)
conn.SetTrafficClass(tos)
conn.SetControlMessage(ipv6.FlagHopLimit, true)
С помощью вызова conn.SetControlMessage(ipv6.FlagHopLimit, true)
мы указываем пакету ipv6
необходимость вернуть в нашу программу значение TTL, которое было указано в заголовке полученного пакета. Без этого вызова мы не сможем проанализировать значение TTL, которое установил удаленный узел.
Запускаем отправку запросов и прием ответов в отдельных горутинах.
go icmpReceiver6(&wg, conn, target, nmpackets)
go icmpSender6(&wg, zone, conn, target, nmpackets, szpacket)
Функция ping6()
также инициализирует рабочую группу для ожидания завершения горутин приема/передачи и канал обработки прерывания выполнения программы пользователем. Это достаточно стандартные действия, они не относятся к работе с сетью.
В icmpSender6()
осуществляется отправка запросов типа ipv6.ICMPTypeEchoRequest
.
func icmpSender6(wg *sync.WaitGroup, zone string, c *ipv6.PacketConn, target net.IP, nmpackets, szpacket int) {
var seqnumber, id int = 1, os.Getpid() & 0xffff
Получаем идентификатор нашего процесса — его будем записывать в поле ID пакета ICMPv6, чтобы идентифицировать пакеты, имеющие отношение к нашей программе. Этот же идентификатор будет и в ответе на запрос. Если будем запускать одновременно несколько процессов ping, ответы от всех опрашиваемых удаленных узлов будем получать в каждом процессе. При помощи ID в приходящем пакете будем отбрасывать нерелевантные пакеты.
Сообщения ICMP в Go имеют тип icmp.Message
. Для выбора конкретной версии ICMPv4 или ICMPv6 необходимо заполнить поле Type
значением ipv4.ICMPTypeEcho
либо ipv6.ICMPTypeEchoRequest
.
msg := &icmp.Message{
Type: ipv6.ICMPTypeEchoRequest,
Code: 0,
}
Далее в цикле формируем тело запроса и высылаем целевому узлу. В тело запроса помещаем тот самый идентификатор процесса, который мы получили в начале функции, и порядковый номер запроса, увеличивающийся в каждой итерации цикла. Данные запроса содержат также метку текущего времени и почти случайный набор байтов. Метка будет извлечена из ответа (поскольку ответ будет содержать копию тела запроса) и использована для подсчета статистики.
for {
data := make([]byte, 0, szpacket)
tmnow := time.Now()
raw, err := tmnow.MarshalBinary()
if err != nil {
break
}
if szpacket >= len(raw) {
data = append(data, raw...)
for i, j := len(raw), 0; i < cap(data); i++ {
data = append(data, byte(j+0x10))
j++
}
} else {
for i, j := 0, 0; i < cap(data); i++ {
data = append(data, byte(j+0x10))
j++
}
}
msg.Body = &icmp.Echo{
ID: id,
Seq: seqnumber,
Data: data,
}
wb, err := msg.Marshal(nil)
if err != nil {
break
}
Формируем срез байтов из структуры сообщения и отправляем по указанному целевому адресу.
var addr net.Addr = &net.UDPAddr{IP: target, Zone: zone}
if _, err := c.WriteTo(wb, nil, addr); err != nil {
break
}
При указании целевого адреса используем зону, которая может быть пустой. Если она не пуста, то система отправит пакет именно с указанного интерфейса.
После отправки запроса обновляем статистику и порядковый номер запроса. Когда количество отправленных запросов достигло указанных пользователем, прерываем цикл отправки.
stats.transmitted++
if seqnumber == nmpackets {
break
}
seqnumber = (seqnumber + 1) % math.MaxUint16
time.Sleep(1 * time.Second)
}
В icmpReceiver6()
принимаем входящие пакеты ICMPv6. Мы можем принимать ответы на запросы, а также управляющие сообщения о недоступности целевого узла при возникновении ошибок. Для этого необходимо установить фильтр на прием сообщений типа ipv6.ICMPTypeDestinationUnreachable
.
func icmpReceiver6(wg *sync.WaitGroup, conn *ipv6.PacketConn, target net.IP, nmpackets int) {
filter, _ := conn.ICMPFilter()
if filter != nil {
filter.Accept(ipv6.ICMPTypeDestinationUnreachable)
conn.SetICMPFilter(filter)
}
Получаем идентификатор нашего процесса — его мы будем сравнивать с полученным из тела ответа идентификатором.
id := os.Getpid() & 0xffff
Размер поля ID — 2 байта, поэтому маскируем его значением 0xffff
.
Далее в цикле считываем входящие пакеты и обрабатываем только те, которые имеют тип ipv6.ICMPTypeEchoReply
и ipv6.ICMPTypeDestinationUnreachable
. Поскольку вызов conn.ReadFrom()
является блокирующим, перед этим задаем таймаут чтения в одну секунду с помощью conn.SetReadDeadline()
.
for {
conn.SetReadDeadline(time.Now().Add(1 * time.Second))
rb := make([]byte, 1500)
n, cm, _, err := conn.ReadFrom(rb)
if err != nil {
if nope, ok := err.(*net.OpError); ok {
if nope.Timeout() {
continue
}
} else {
break
}
}
При успешном получении пакета функция ReadFrom()
вернет количество прочитанных байтов, заголовок пакета IPv6, а в предоставленный срез запишет содержимое принятого пакета ICMPv6.
Из заголовка IPv6 получим TTL, который выставил удаленный узел при ответе на наш эхо-запрос. Далее разберем пакет ICMPv6 с помощью вызова icmp.ParseMessage()
, первым параметром которого укажем число 58, соответствующее протоколу ICMPv6.
var ttl int = cm.HopLimit
rm, err := icmp.ParseMessage(58, rb[:n])
Дальше можем обработать принятый пакет в зависимости от его типа. Я уже упоминал, что нас интересуют только два типа пакетов: ipv6.ICMPTypeEchoReply
получаем как ответ от целевого узла на наш запрос, а ipv6.ICMPTypeDestinationUnreachable
получаем, если в процессе обмена возникли ошибки. Код ошибки содержится в теле пакета ipv6.ICMPTypeDestinationUnreachable
. В тексте программы они содержатся в таблице dstUnreachCodes6
.
switch rm.Type {
case ipv6.ICMPTypeEchoReply:
r := rm.Body.(*icmp.Echo)
if !(r.ID == id && target.Equal(cm.Src)) {
break
}
Проверяем, что получен ответ именно для нашего процесса, и извлекаем метку времени.
var tmthen time.Time
if err := tmthen.UnmarshalBinary(r.Data[:15]); err != nil {
fmt.Fprintf(os.Stderr, "Couldn't read time, err = %v\n", err)
}
rtt := time.Since(tmthen)
fmt.Printf("%v bytes from %s: seq=%v, ttl=%v, rtt=%v\n",
n, cm.Src, r.Seq, ttl, rtt)
stats.rtts = append(stats.rtts, rtt)
if rtt < stats.min || stats.min == 0 {
stats.min = rtt
}
if rtt > stats.max || stats.max == 0 {
stats.max = rtt
}
stats.received++
finished = r.Seq >= nmpackets
Вычисляем разность времени отправки и получения, обновляем статистику и проверяем условие завершения программы: завершаемся, если в очередном полученном пакете значение поля sequence превысило количество отправляемых пакетов.
Здесь кроется потенциальная проблема: если пакеты будут перепутаны в сети и придут не в том порядке, в каком мы их отправляли, то мы можем завершиться преждевременно, не успев обработать запоздавший пакет. Однако, если учесть, что мы отправляем запросы не чаще одного раза в секунду, вероятность такого события невелика.
Если же при отправке запроса произошла ошибка, мы получим ответ типа ipv6.ICMPTypeDestinationUnreachable
. Он будет содержать код ошибочной ситуации, а также полную копию неудавшегося запроса.
case ipv6.ICMPTypeDestinationUnreachable:
code := rm.Code
r := rm.Body.(*icmp.DstUnreach)
rm, _ := icmp.ParseMessage(58, r.Data[40:])
b := rm.Body.(*icmp.Echo)
if b.ID != id {
break
}
fmt.Printf("%v bytes from %s: seq=%v, %v\n",
n, cm.Src, b.Seq, dstUnreachCodes6[code])
stats.errors++
finished = b.Seq >= nmpackets
}
Этот ответ мы также разберем, обработаем и выведем соответствующее сообщение в консоль.
Реализация ping4()
Ping4()
от рассмотренной ping6()
особо не отличается. Так же, как и в ping6()
, создается прослушиваемое соединение, проводится настройка полей TTL и TOS заголовка пакета IPv4, настройка получения TTL в контрольных сообщениях, запуск горутин отправки и получения пакетов ICMP, а также создается обработчик сигналов операционной системы.
При создании прослушиваемого соединения для связки IPv4+ICMP необходимо указать "ip4:icmp":
netconn, err := net.ListenPacket("ip4:icmp", sourceAddr)
В отличие от ping6()
, здесь мы используем функции из пакета ipv4
. Имена полей заголовка IPv4 отличаются от IPv6.
conn := ipv4.NewPacketConn(netconn)
defer conn.Close()
conn.SetTTL(ttl)
conn.SetTOS(tos)
conn.SetControlMessage(ipv4.FlagTTL, true)
Функции icmpReceiver4()
и icmpSender4()
аналогичны функциям icmpReceiver6()
и icmpSender6()
с поправкой на использование пакета ipv4
.
Рассказ о sudo setcap cap_net_raw+ep ./ping
Отлично, код готов. Компилируем и запускаем.
$ ./ping -c 3 ya.ru
Pinging ya.ru (2a02:6b8::2:242), echo request size 56 bytes
Error: listen ip6:ipv6-icmp ::: socket: operation not permitted
Получаем сообщение об ошибке. Оказывается, в Linux не любая программа может открывать raw сокеты и осуществлять ввод/вывод на сетевом уровне. Если попробуем от имени root (например, через sudo
), то программа заработает так, как должна.
Однако, все время вызывать через sudo
весьма утомительно, да и не всегда возможно. Бит suid считается брешью в системе, поэтому воспользуемся утилитой setcap
для назначения нашей программе полномочий работы только с raw сокетами.
$ sudo setcap cap_net_raw+ep ./ping
[sudo] пароль для igor-gorbunov:
$ ./ping -c 3 ya.ru
Pinging ya.ru (2a02:6b8::2:242), echo request size 56 bytes
64 bytes from 2a02:6b8::2:242: seq=1, ttl=57, rtt=30.2003ms
64 bytes from 2a02:6b8::2:242: seq=2, ttl=57, rtt=41.855547ms
64 bytes from 2a02:6b8::2:242: seq=3, ttl=57, rtt=18.10199ms
--- ya.ru ping statistics ---
transmitted: 3, received: 3 packets, errors: 0, packet loss: 0%
rtt: min=18.10199ms/avg=30.052612ms/max=41.855547ms/stddev=9.697911ms
Вот так просто в Go работают с пакетами протокола ICMP. Благодаря стандартной библиотеке Go, в 600 строк кода уместилась довольно мощная программа. Добавление параметров командной строки типа -4/-6 не сильно увеличит количество строк исходного кода, поскольку вся поддержка сведется к добавлению флагов для разбора пакетом flag
и поиска нужного целевого адреса.
Тот самый подводный камень
У нашего приложения есть, правда, существенный недостаток: его размер составляет 3,6 МБ против 88 КБ у стандартной утилиты (ОС OpenSUSE Leap 15.6).
Использование CGO_ENABLED=0
при билде уменьшает целевой файл на 0,1 МБ, вместе с тем мы избавились от зависимостей от стандартной библиотеки glibc. Использование флагов -ldflags "-s -w"
уменьшает размер исполняемого файла до 2,3 МБ. Итоговый файл при компиляции имеет размер 2,3 МБ и независимость от стандартной библиотеки glibc.
$ CGO_ENABLED=0 go build -ldflags "-s -w" .
Возможно, чтобы уменьшить размер исполняемых файлов за счет разделяемых участков кода, стоит использовать компилятор gcc-go и динамическую линковку с libgo.so. Утилиты из одного и того же дистрибутива могут быть динамически слинкованы с одной и той же библиотекой libgo.so достаточно безопасно.
Использовать gccgo для компиляции ping оказалось непросто — мне не удалось с ходу собрать
x/net
. Напишите в комментариях, если у вас получилось сделать что-то подобное.
Комментарии (3)
mrobespierre
14.01.2025 09:51Стрипать надо не через "
-ldflags "-s -w"
", а просто утилитой strip, так обычно бинарник меньше выходит (и это официально поддерживается).
Boozlachu
Честно говоря не понял, а зачем вообще это нужно? Чем не нравится уже работающие реализации?
Zhurikello
скорее всего типа "могём... умеем"