Всем привет! Меня зовут Игорь Горбунов, я разрабатываю платформу базовой станции в 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. Напишите в комментариях, если у вас получилось сделать что-то подобное.