golang で ping コマンドを作る

golang と raw socket の勉強のために ping コマンドを作りました。 自分がつまずいた ICMP の受信処理について書いておきます。 勉強用なので golang.org/x/net/icmp などは使っていません。

リポジトリ: https://github.com/hiroygo/goping
環境: Ubuntu 20.04.2 LTS

golang での ICMP の送受信

Do では ICMP エコー要求の送信と ICMP エコー応答の受信を 1 回します。 ICMP を使うために net.ListenIP("ip4:icmp", nil)*IPConn を生成しています。

func Do(ip4Remote *net.IPAddr, timeout time.Duration, identifier, sequenceNumber, dataSize uint16) (rtt time.Duration, rerr error) {
    conn, err := net.ListenIP("ip4:icmp", nil)
    if err != nil {
        return 0, fmt.Errorf("ListenIP error: %w", err)
    }
    defer func() {
        if err := conn.Close(); err != nil {
            rerr = err
        }
    }()

    // 送信する
    // ペイロードはすべて 0 で作成する
    request := NewEchoRequest(identifier, sequenceNumber, make([]byte, dataSize))
    sendData, err := MarshalEcho(request)
    if err != nil {
        return 0, fmt.Errorf("MarshalEcho error: %w", err)
    }
    if err := conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
        return 0, fmt.Errorf("SetWriteDeadline error: %w", err)
    }
    start := time.Now()
    if _, err := conn.WriteToIP(sendData, ip4Remote); err != nil {
        return 0, fmt.Errorf("WriteToIP error: %w", err)
    }

    // 受信する
    timeoutCh := time.After(timeout)
    for {
        select {
        case <-timeoutCh:
            return 0, errors.New("返答受信がタイムアウトしました")
        default:
            recvData := make([]byte, ipv4TotalMaxSize)
            if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
                return 0, fmt.Errorf("SetReadDeadline error: %w", err)
            }
            recvSize, recvFrom, err := conn.ReadFromIP(recvData)
            end := time.Now()
            if err != nil {
                return 0, fmt.Errorf("ReadFromIP error: %w", err)
            }

            recvData = recvData[:recvSize]
            t, err := icmpType(recvData)
            if err != nil {
                continue
            }
            // Linux では localhost などに ping した場合
            // 自分が送った EchoRequest(8) を受信する
            // その後、カーネルのプロトコルモジュールから EchoReply を受信したりする
            if t == 8 {
                continue
            }
            if t == 0 && recvFrom.IP.Equal(ip4Remote.IP) {
                reply, err := UnmarshalEcho(recvData)
                if err != nil {
                    continue
                }
                if err := Pair(request, reply); err != nil {
                    continue
                }
                return end.Sub(start), nil
            }
            if t == 3 {
                return 0, fmt.Errorf("From %v Destination Unreachable", recvFrom.IP.String())
            }
            // 本当は他の Type も処理すべき
        }
    }
}

つまずいたところ

はじめ *IPConn の生成は net.DialIP("ip4:icmp", nil, &net.IPAddr{IP: net.IPv4(8, 8, 8, 8)}) のようにしていました。 この場合 8.8.8.8 からのパケットは受信できますが、他のノードからのパケットが受信できませんでした。 そのため宛先のノードが存在しない場合の Destination Unreachable などが受信できませんでした。 ICMP って TCP のように接続するわけではないので net.DialIPnet.ListenIP で動作が変わるの?、と疑問でした。

net.DialIP の実装も追ってみましたが、よく分からず疑問は解決しませんでした。

net.DialIP の説明

$ go doc net.dialip
package net // import "net"

func DialIP(network string, laddr, raddr *IPAddr) (*IPConn, error)
    DialIP acts like Dial for IP networks.

    The network must be an IP network name; see func Dial for details.

    If laddr is nil, a local address is automatically chosen. If the IP field of
    raddr is nil or an unspecified IP address, the local system is assumed.

net.ListenIP の説明

$ go doc net.listenip
package net // import "net"

func ListenIP(network string, laddr *IPAddr) (*IPConn, error)
    ListenIP acts like ListenPacket for IP networks.

    The network must be an IP network name; see func Dial for details.

    If the IP field of laddr is nil or an unspecified IP address, ListenIP
    listens on all available IP addresses of the local system except multicast
    IP addresses.

net.Dial の説明

$ go doc net.dial
package net // import "net"

func Dial(network, address string) (Conn, error)
    Dial connects to the address on the named network.

    Known networks are "tcp", "tcp4" (IPv4-only), "tcp6" (IPv6-only), "udp",
    "udp4" (IPv4-only), "udp6" (IPv6-only), "ip", "ip4" (IPv4-only), "ip6"
    (IPv6-only), "unix", "unixgram" and "unixpacket".

    For TCP and UDP networks, the address has the form "host:port". The host
    must be a literal IP address, or a host name that can be resolved to IP
    addresses. The port must be a literal port number or a service name. If the
    host is a literal IPv6 address it must be enclosed in square brackets, as in
    "[2001:db8::1]:80" or "[fe80::1%zone]:80". The zone specifies the scope of
    the literal IPv6 address as defined in RFC 4007. The functions JoinHostPort
    and SplitHostPort manipulate a pair of host and port in this form. When
    using TCP, and the host resolves to multiple IP addresses, Dial will try
    each IP address in order until one succeeds.

    Examples:

        Dial("tcp", "golang.org:http")
        Dial("tcp", "192.0.2.1:http")
        Dial("tcp", "198.51.100.1:80")
        Dial("udp", "[2001:db8::1]:domain")
        Dial("udp", "[fe80::1%lo0]:53")
        Dial("tcp", ":80")

    For IP networks, the network must be "ip", "ip4" or "ip6" followed by a
    colon and a literal protocol number or a protocol name, and the address has
    the form "host". The host must be a literal IP address or a literal IPv6
    address with zone. It depends on each operating system how the operating
    system behaves with a non-well known protocol number such as "0" or "255".

    Examples:

        Dial("ip4:1", "192.0.2.1")
        Dial("ip6:ipv6-icmp", "2001:db8::1")
        Dial("ip6:58", "fe80::1%lo0")

    For TCP, UDP and IP networks, if the host is empty or a literal unspecified
    IP address, as in ":80", "0.0.0.0:80" or "[::]:80" for TCP and UDP, "",
    "0.0.0.0" or "::" for IP, the local system is assumed.

    For Unix networks, the address must be a file system path.

strace の結果

作った ping コマンドを strace して、net.DialIP を使った場合と net.ListenIP を使った場合で比較してみました。 自マシン (192.168.0.10) から存在しないノード 192.168.0.111 に ping したときのログです。 関連しそうな部分だけを抜き出しています。 net.ListenIP のときは Destination host unreachable を受信できています。

net.DialIP の結果

socket(AF_INET, SOCK_RAW|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_ICMP) = 3
setsockopt(3, SOL_SOCKET, SO_BROADCAST, [1], 4) = 0
connect(3, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("192.168.0.111")}, 16) = 0
getsockname(3, {sa_family=AF_INET, sin_port=htons(1), sin_addr=inet_addr("192.168.0.10")}, [112->16]) = 0
getpeername(3, 0xc00010f8d8, [112])     = -1 ENOTCONN (通信端点が接続されていません)
write(3, "\10\0\267$@\333\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 40) = 40
recvfrom(3, 0xc00010fdb1, 65535, 0, 0xc00010fa98, [112]) = -1 EAGAIN (リソースが一時的に利用できません)

net.ListenIP の結果

socket(AF_INET, SOCK_RAW|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_ICMP) = 3
setsockopt(3, SOL_SOCKET, SO_BROADCAST, [1], 4) = 0
bind(3, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
getsockname(3, {sa_family=AF_INET, sin_port=htons(1), sin_addr=inet_addr("0.0.0.0")}, [112->16]) = 0
getpeername(3, 0xc00018f938, [112])     = -1 ENOTCONN (通信端点が接続されていません)
sendto(3, "\10\0\270\35?\342\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 40, 0, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("192.168.0.111")}, 16) = 40
recvfrom(3, 0xc00018fdb1, 65535, 0, 0xc00018fa98, [112]) = -1 EAGAIN (リソースが一時的に利用できません)
recvfrom(3, "E\300\0X\241\35\0\0@\1Wc\300\250\0\n\300\250\0\n\3\1\374\376\0\0\0\0E\0\0<"..., 65535, 0, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("192.168.0.10")}, [112->16]) = 88

ログを見ると connect(3, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("192.168.0.111")}, 16) = 0 が怪しそうでした。

コネクションレスでの connect の動作

Web で調べると UDP + connect の解説は見つかるのですが、ICMP + connect の解説は見つけることができませんでした。 ただ UNIXネットワークプログラミング の 6.5 の connect システムコール には、次の説明がありました。

コネクションレスプロトコルに対して connect を用いると、将来その sockfd に対してのデータの 書き込みが行われた際、どのアドレスに送ればよいのかを覚えておくために、指定された servaddr が格納される。 またこのソケットから読み出すことのできるのは、格納されたアドレスに宛てたデータグラムだけになる。

多分、connect 先からの返答しか受信しないということでしょうか...?

C++ で実験してみたところ、 connect を実行してから recv すると確かに 192.168.0.111 以外のノードからパケットを受信できませんでした。connect を実行せずに recv すると 192.168.0.111 以外のノードからのパケットが受信できました。

これで疑問を解決できました。socket の動作についても理解を深めることができて、よかったです。

#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/ip_icmp.h>

// 以下のページを参考にさせていただきました。
// https://www.geekpage.jp/programming/linux-network/simple-ping.php
int main(int argc, char *argv[])
{
  const int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
  if (sock == -1)
  {
    perror("socket");
    return EXIT_FAILURE;
  }

  struct sockaddr_in addr;
  addr.sin_family = AF_INET;
  addr.sin_port = htons(0);
  addr.sin_addr.s_addr = inet_addr("192.168.0.111");
  if (connect(sock, (struct sockaddr *)&addr, sizeof(addr)) == -1)
  {
    perror("connect");
    return EXIT_FAILURE;
  }

  char buf[2048];
  while (true)
  {
      memset(buf, 0, sizeof(buf));
      const int n = recv(sock, buf, sizeof(buf), 0);
      if (n == -1)
      {
        perror("recv");
        close(sock);
        return EXIT_FAILURE;
      }

      const struct iphdr* iphdrptr = (struct iphdr *)buf;
      const struct icmphdr* icmphdrptr = (struct icmphdr *)(buf + (iphdrptr->ihl * 4));
      printf("received %d\n", icmphdrptr->type);
  }

  close(sock);
  return 0;
}

その他

テーブル駆動テストなどを使い、テストを頑張って書いています

$ sudo /usr/local/go/bin/go test ./ping/ -covermode=count -count=1
ok      github.com/hiroygo/goping/ping  2.511s  coverage: 83.1% of statements

参考にさせていただいたサイト