We can easily send UDP and TCP packets using the net package in the Go standard library, and develop application layer programs based on them, such as HTTP, RPC and other frameworks and programs, and we can even use the official extension package golang.org/x/net/icmp , which is dedicated to sending and receiving icmp packets. However, sometimes we want to do lower level network communication, this time we need to use some additional libraries or do some additional settings, this article tries to introduce several ways to send and receive IP packets.

As usual, we introduce IPv4 related technologies, and IPv6 will be introduced in a separate chapter.

When it comes to Go network programming, I have a little advice for choosing technologies for common scenarios: if the standard library can provide the relevant functionality, then use the standard library; otherwise, examine whether the official extension library golang.org/x/net can meet the requirements; if it is not suitable, then consider using syscall. Socket and gopackete; if that is not enough, then examine whether any third-party libraries have already implemented the relevant functionality. Of course, sometimes the last two considerations may be interchangeable positions can also be.

Why do we sometimes need to send and receive IP packets? Because we sometimes want to perform detailed settings or checks on the IPv4 header. For example, the following definition of IPv4 header:

ipv4 header

Sometimes we want to set TOS, Identification, TTL, Options, we have to be able to assemble our own IPv4 packet and send it out; the same is true for reading.

Using the standard library

Using net.ListenPacket/net.ListenPacket Explore

The standard library provides a way to read and write IP packets, which can achieve half of the read and write capabilities, it is through the func ListenPacket(network, address string) (PacketConn, error) function to achieve. Where network can be udp, udp4, udp6, unixgram, or ip:1, ip:icmp such ip plus protocol number or protocol name.

The protocols are defined in the http://www.iana.org/assignments/protocol-numbers documentation (you can also read them in /etc/protocols on the Linux host, but they may not be up to date). For example, ICMP protocol number is 1, TCP protocol number is 6, UDP protocol number is 17, protocol numbers 253 and 254 are used for testing, etc.

If network is udp, udp4, udp6, the returned PacketConn bottom is *net.UDPConn, if network is prefixed with ip, the returned PacketConn is *net.IPConn, in which case you can use the explicit func ListenIP(network string, laddr *IPAddr) (*IPConn, error).

The following is an example of a client using net.ListenPacket:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func main() {
    conn, err := net.ListenPacket("ip4:udp", "127.0.0.1") // 本地地址
    if err != nil {
        fmt.Println("DialIP failed:", err)
        return
    }
    data, _ := encodeUDPPacket("127.0.0.1", "192.168.0.1", []byte("hello world")) // 生成一个UDP包
    if _, err := conn.WriteTo(data, &net.IPAddr{IP: net.ParseIP("192.168.0.1")}); err != nil {
        panic(err)
    }
    buf := make([]byte, 1024)
    n, peer, err := conn.ReadFrom(buf)
    if err != nil {
        panic(err)
    }
    fmt.Printf("received response from %s: %s\n", peer.String(), buf[8:n])
}

This example starts by generating a PacketConn, which is actually a *net.IPConn, but it should be noted that the conn here sends packets at the UDP layer and does not contain the IP layer, the following example defines the IP layer and is only used to calculate checksum, which is not actually useful.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func encodeUDPPacket(localIP, dstIP string, payload []byte) ([]byte, error) {
    ip := &layers.IPv4{
        SrcIP:    net.ParseIP(localIP),
        DstIP:    net.ParseIP(dstIP),
        Version:  4,
        Protocol: layers.IPProtocolUDP,
    }
    udp := &layers.UDP{
        SrcPort: layers.UDPPort(0),
        DstPort: layers.UDPPort(8972),
    }
    udp.SetNetworkLayerForChecksum(ip)
    buf := gopacket.NewSerializeBuffer()
    opts := gopacket.SerializeOptions{
        ComputeChecksums: true,
        FixLengths:       true,
    }
    err := gopacket.SerializeLayers(buf, opts, udp, gopacket.Payload(payload))
    return buf.Bytes(), err
}

Similarly, the server side reads the message and returns only the protocol layer data under the IPv4 header, and the IPv4 header data is stripped out.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func main() {
    conn, err := net.ListenPacket("ip4:udp", "192.168.0.1")
    if err != nil {
        panic(err)
    }
    buf := make([]byte, 1024)
    for {
        n, peer, err := conn.ReadFrom(buf)
        if err != nil {
            panic(err)
        }
        fmt.Printf("received request from %s: %s\n", peer.String(), buf[8:n])
        data, _ := encodeUDPPacket("192.168.0.1", "127.0.0.1", []byte("hello world"))
        _, err = conn.WriteTo(data, &net.IPAddr{IP: net.ParseIP("127.0.0.1")})
        if err != nil {
            panic(err)
        }
    }
}

Note that the data read by conn.ReadFrom(buf) contains the UDP header, but not the IP header, the UDP header is 8 bytes, so buf[8:n] is the payload data.

If you look at the source code of the go standard library, you can see that Go will call stripIPv4Header to strip the IPv4 header after it receives the IP packet.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func (c *IPConn) readFrom(b []byte) (int, *IPAddr, error) {
    var addr *IPAddr
    n, sa, err := c.fd.readFrom(b)
    switch sa := sa.(type) {
    case *syscall.SockaddrInet4:
        addr = &IPAddr{IP: sa.Addr[0:]}
        n = stripIPv4Header(n, b)
    case *syscall.SockaddrInet6:
        addr = &IPAddr{IP: sa.Addr[0:], Zone: zoneCache.name(int(sa.ZoneId))}
    }
    return n, addr, err
}
func stripIPv4Header(n int, b []byte) int {
    if len(b) < 20 {
        return n
    }
    l := int(b[0]&0x0f) << 2
    if 20 > l || l > len(b) {
        return n
    }
    if b[0]>>4 != 4 {
        return n
    }
    copy(b, b[l:])
    return n - l
}

Sending and receiving IP packets using ipv4

The easiest way to convert net.PacketConn to *ipv4.RawConn is to use ipv4.NewRawConn(conn), as shown in the client code below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
    conn, err := net.ListenPacket("ip4:udp", "127.0.0.1")
    if err != nil {
        fmt.Println("DialIP failed:", err)
        return
    }
    rc, err := ipv4.NewRawConn(conn)
    if err != nil {
        panic(err)
    }
    data, _ := encodeUDPPacket("127.0.0.1", "192.168.0.1", []byte("hello world"))
    if _, err := rc.WriteToIP(data, &net.IPAddr{IP: net.ParseIP("192.168.0.1")}); err != nil {
        panic(err)
    }
    rbuf := make([]byte, 1024)
    _, payload, _, err := rc.ReadFrom(rbuf)
    if err != nil {
        panic(err)
    }
    fmt.Printf("received response: %s\n", payload[8:])
}

Note that the encodeUDPPacket implementation here is different from the one in the example above, it contains the ip header data.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func encodeUDPPacket(localIP, dstIP string, payload []byte) ([]byte, error) {
    ip := &layers.IPv4{
        ...
    }
    udp := &layers.UDP{
        ...
    }
    udp.SetNetworkLayerForChecksum(ip)
    buf := gopacket.NewSerializeBuffer()
    opts := gopacket.SerializeOptions{
        ComputeChecksums: true,
        FixLengths:       true,
    }
    err := gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(payload)) // 注意这里包含ip
    return buf.Bytes(), err
}

When reading data, ReadFrom will read ip header, ip payload (UDP packet), control message (UDP has no control message), so we can also read and analyze the returned IP header.

Using SyscallConn to read IP header

Use the standard library (*net.IPConn).SyscallConn() to send UDP (or other ip protocol) packet data when writing data, but read out the IPv4 header when reading data.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
func main() {
    conn, err := net.ListenPacket("ip4:udp", "127.0.0.1")
    if err != nil {
        fmt.Println("DialIP failed:", err)
        return
    }
    sc, err := conn.(*net.IPConn).SyscallConn()
    if err != nil {
        panic(err)
    }
    var addr syscall.SockaddrInet4
    copy(addr.Addr[:], net.ParseIP("192.168.0.1").To4())
    addr.Port = 8972
    data, _ := encodeUDPPacket("127.0.0.1", "192.168.0.1", []byte("hello world"))
    err = sc.Write(func(fd uintptr) bool {
        // 将 UDP 数据包写入 Socket
        err := syscall.Sendto(int(fd), data, 0, &addr)
        if err != nil {
            panic(err)
        }
        return err == nil
    })
    if err != nil {
        panic(err)
    }
    var n int
    buf := make([]byte, 1024)
    err = sc.Read(func(fd uintptr) bool {
        var err error
        n, err = syscall.Read(int(fd), buf)
        if err != nil {
            return false
        }
        return true
    })
    if err != nil {
        panic(err)
    }
    iph, err := ipv4.ParseHeader(buf[:n])
    if err != nil {
        panic(err)
    }
    fmt.Printf("received response from %s: %s\n", iph.Src.String(), buf[ipv4.HeaderLen+8:])
}

Why there is no way to set IPv4 header when sending, but IPv4 header can be read when reading? Note that our standard library uses syscall.AF_INET and syscall.SOCK_RAW, protocol to create sockets for IPConn, and by default we need to fill in the ip payload data (protocol data). The IP header is automatically generated by the kernel stack, but the IP header is read back when it is read, so the Go behavior is the same as the socket, and the IPv4 header is stripped from the data read out by the standard library for read/write consistency.

So why does ipv4.RawConn send IPv4 header data? This is because it sets up the socket.

1
2
3
4
5
6
func NewRawConn(c net.PacketConn) (*RawConn, error) {
    ...
    so, ok := sockOpts[ssoHeaderPrepend]
    ...
    return r, nil
}

The ssoHeaderPrepend option is what sets the IP_HDRINCL.

1
ssoHeaderPrepend:      {Option: socket.Option{Level: iana.ProtocolIP, Name: unix.IP_HDRINCL, Len: 4}},

So even if you don’t use ipv4.RawConn, you can set the standard library’s *net.IPConn to support manually writable IPv4 header.

1
err = syscall.SetsockoptInt(fd, syscall.IPPROTO_IP, syscall.IP_HDRINCL, 1)

Of course, in order to support both reading and writing IPv4 header, it is most convenient to convert to *ipv4.RawConn.

Using syscall.Socket to send and receive IP packets

The simplest, similar to other languages like C to access system calls, can be used to send and receive IPv4 packets; when developing network programs, you don’t have to worry about the technical barriers, the big deal is to use the most primitive system calls to implement network communication.

The following example creates a socket, where we don’t use UDP for the protocol, but you can actually adapt it to UDP code.

Note that we need to set IP_HDRINCL to 1, we set the IPv4 header manually, instead of letting the kernel stack set it for us.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
func main() {
    fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, syscall.IPPROTO_RAW)
    if err != nil {
        fmt.Println("socket failed:", err)
        return
    }
    defer syscall.Close(fd)
    err = syscall.SetsockoptInt(fd, syscall.IPPROTO_IP, syscall.IP_HDRINCL, 1)
    if err != nil {
        panic(err)
    }
    // Local Address
    addr := syscall.SockaddrInet4{Addr: [4]byte{127, 0, 0, 1}}
    // Sending custom protocol packets
    ip4 := &layers.IPv4{
        SrcIP:    net.ParseIP("127.0.0.1"),
        DstIP:    net.ParseIP("192.168.0.1"),
        Version:  4,
        TTL:      64,
        Protocol: syscall.IPPROTO_RAW,
    }
    pbuf := gopacket.NewSerializeBuffer()
    opts := gopacket.SerializeOptions{
        ComputeChecksums: true,
        FixLengths:       true,
    }
    payload := []byte("hello world")
    err = gopacket.SerializeLayers(pbuf, opts, ip4, gopacket.Payload(payload))
    if err != nil {
        fmt.Println("serialize failed:", err)
        return
    }
    if err := syscall.Sendto(fd, pbuf.Bytes(), 0, &addr); err != nil {
        fmt.Println("sendto failed:", err)
        return
    }
    buf := make([]byte, 1024)
    for {
        n, peer, err := syscall.Recvfrom(fd, buf, 0)
        if err != nil {
            fmt.Println("recvfrom failed:", err)
            return
        }
        raddr := net.IP(peer.(*syscall.SockaddrInet4).Addr[:]).String()
        if raddr != "192.168.0.1" {
            continue
        }
        iph, err := ipv4.ParseHeader(buf[:n])
        if err != nil {
            fmt.Println("parse ipv4 header failed:", err)
            return
        }
        fmt.Printf("received response from %s: %s\n", raddr, string(buf[iph.Len:n]))
        break
    }
}
func htons(i uint16) uint16 {
    return (i<<8)&0xff00 | i>>8
}

And the server-side code is as follows, note that here we have used bpf to do filtering in order to focus only on our own packets of the program, which will improve performance.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func main() {
    fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, syscall.IPPROTO_RAW)
    if err != nil {
        fmt.Println("socket failed:", err)
        return
    }
    defer syscall.Close(fd)
    err = syscall.SetsockoptInt(fd, syscall.IPPROTO_IP, syscall.IP_HDRINCL, 1)
    if err != nil {
        panic(err)
    }
    filter.applyTo(fd)
    // Receive custom protocol packets
    buf := make([]byte, 1024)
    for {
        n, peer, err := syscall.Recvfrom(fd, buf, 0)
        if err != nil {
            fmt.Println("recvfrom failed:", err)
            return
        }
        iph, err := ipv4.ParseHeader(buf[:n])
        if err != nil {
            fmt.Println("parse header failed:", err)
            return
        }
        if string(buf[iph.Len:n]) != "hello world" {
            continue
        }
        fmt.Printf("received request from %s: %s\n", iph.Src.String(), string(buf[iph.Len:n]))
        iph.Src, iph.Dst = iph.Dst, iph.Src
        replayIpHeader, _ := iph.Marshal()
        copy(buf[:iph.Len], replayIpHeader)
        if err := syscall.Sendto(fd, buf[:n], 0, peer); err != nil {
            fmt.Println("sendto failed:", err)
            return
        }
    }
}

Of course, you can also use third-party libraries such as gopacket to send and receive IPv4 packets, but *ipv4.RawConn is enough for us to use, so there is no need to use third-party libraries, so we won’t introduce them here.

Ref

  • https://colobu.com/2023/05/13/send-IP-packets-in-Go/