Всем привет! Меня зовут Игорь Горбунов, я разрабатываю платформу базовой станции в YADRO и изучаю Golang почти год. Уже перевалил рубеж «вывести на экран сумму четных элементов среза» и захотел написать что-то более сложное.

Я интересуюсь сетями, и решил посмотреть, как в Go реализуется работа с протоколами ICMP и ICMPv6. Наиболее простая задача, связанная с ними, — реализация программы ping. Она отправляет указанному узлу сети запросы ICMP типа Echo-Request и ожидает ответы типа Echo-Reply.

На первый взгляд — простейшая задача, поэтому усложним ее: построим приложение, похожее на утилиту ping в UNIX-подобных системах. Под катом расскажу, как я решал задачу и с какими подводными камнями столкнулся.

Требования к приложению

Я сформулировал требования к приложению, на которые буду опираться в процессе разработки:

  1. Возможность запросов Echo-Request по протоколам ICMP и ICMPv6 и поддержка IPv4 и IPv6 со стороны ping.

  2. Возможность указания целевого узла в виде непосредственно адреса либо в виде имени, что требует поддержки разрешения имен.

  3. Возможность менять из командной строки размер отправляемых запросов и их количество.

  4. Подсчет и вывод в консоль статистики отправленных запросов, полученных и неполученных ответов, ошибок, минимального, среднего, максимального времени круговой задержки (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)


  1. Boozlachu
    14.01.2025 09:51

    Честно говоря не понял, а зачем вообще это нужно? Чем не нравится уже работающие реализации?


    1. Zhurikello
      14.01.2025 09:51

      скорее всего типа "могём... умеем"


  1. mrobespierre
    14.01.2025 09:51

    Стрипать надо не через "-ldflags "-s -w" ", а просто утилитой strip, так обычно бинарник меньше выходит (и это официально поддерживается).