Go中原始套接字(Socket)的深度实践-简单解读| 青训营笔记

1,406 阅读8分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第4篇笔记。

本文的写作目标是记录青训营Socket编程的实践过程,本文主要参考自Go中原始套接字的深度实践

导言

对于青训营课程《打开抖音互联网会发生什么》的课后作业,一开始因为golang标准库中的net库较难学习而放弃处理原始的以太网帧或IP、UDP包。采用取巧的办法,直接用net包的ListenUDP、DialUDP等模拟了服务器和客户端的交互,在应用层实现传输层的功能,在UDP包数据段自定义一层数据协议(比如在UDP包数据段头部再附加一个包头)从而模拟TCP的连接控制功能。

之后看到第二题答案中提示socket处理raw包才明白该使用什么方案,因此查到了标题中的文章。

以下引自原文:原始套接字(raw socket)是一种网络套接字,允许直接发送/接收更底层的数据包而不需要任何传输层协议格式。平常我们使用较多的套接字(socket)都是基于传输层,发送/接收的数据包都是不带TCP/UDP等协议头部的(其实应该是底层socket处理已经把底层对应的包头部分去掉了)。当使用套接字发送数据时,传输层在数据包前填充上面格式的协议头部数据,然后整个发送到网络层,接收时去掉协议头部,把应用数据抛给上层。如果想自己封装头部或定义协议的话,就需要使用原始套接字,直接向网络层发送数据包。为了便于后面理解,这里统一称应用数据为 payload,协议头部为 header,套接字为socket。由于平常使用的socket是建立在传输层之上,并且不可以自定义传输层协议头部的socket,约定称之为应用层socket,它不需要关心TCP/UDP协议头部如何封装。这样区分的目的是为了理解raw socket在不同层所能做的事情。

docker操作

由于本次作业涉及到多台同网段主机,出于成本和便捷性考虑,采用docker运行多个容器实现。docker的安装和基本操作可以参考这本书Docker-从入门到实践:获取镜像。后文中的程序编译和命令行操作都是在容器中完成。

本文采用的基础镜像是Ubuntu - Official Image | Docker Hub中的ubuntu:latest,本文发布时镜像的具体版本是ubuntu:22.04。下面对一些涉及到的命令举例,第一部分在宿主机命令行操作(安装Docker的机器),第二部分在容器命令行操作。

# 拉取最新Ubuntu镜像
docker pull ubuntu:latest
# 建立一个交互式终端操作(-i -t)后台(-d)运行的名字为goCompile的基于ubuntu:latest镜像的容器
docker run -it -d --name goCompile ubuntu:latest
# 运行goCompile容器的/bin/bash文件,即进入该容器的终端
docker exec -it goCompile /bin/bash
# 注意,修改为https镜像源后,如果用梯子的话可能会碰到证书相关的问题,比如清华/阿里源
# 默认镜像源都是http的,所以有梯子的话可以直接更新成功
apt-get update
# 安装ifconfig等工具
apt-get install net-tools
# 安装ping工具
apt-get install iputils-ping
# 安装netcat工具
apt-get install netcat
# 安装golang用来编译程序,不需要编译和运行golang程序的docker不必安装
apt-get install golang
# 上面的安装命令可以合并
apt-get install net-tools iputils-ping net-cat golang 

传输层socket

ICMP示例

package main
import ("fmt" "golang.org/x/net/icmp" "net")

func main() {
   // 这里的ipv4地址是指该程序运行的操作系统的网卡地址
   // 172.17.0.3所在网段是docker的默认网段,默认下面的操作都在docker中进行
   netaddr, _ := net.ResolveIPAddr("ip4", "172.17.0.3")
   // 这里可以指定"网络层协议:子协议"参数,前者可以取ip4/ip6,后者主要有icmp/igmp/tcp/udp/ipv6/icmp
   conn, _ := net.ListenIP("ip4:icmp", netaddr)
   for {
      buf := make([]byte, 1024)
      n, addr, _ := conn.ReadFrom(buf)
      msg, _ := icmp.ParseMessage(1, buf[0:n])
      fmt.Println(n, addr, msg.Type, msg.Code, msg.Checksum)
   }
}

上面的程序放到装有golang编译器的docker中运行,运行前注意使用下列命令获取docker的网卡ip

root@17631723a29a:/# ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        # 这里inet后面的地址就是eth0网卡的局域网地址了
        # 如果与上文不同的话请把上面程序的ip监听地址修改为该ip
        inet 172.17.0.3  netmask 255.255.0.0  broadcast 172.17.255.255
        ether 02:42:ac:11:00:03  txqueuelen 0  (Ethernet)
        RX packets 15631  bytes 22155293 (22.1 MB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 4285  bytes 236543 (236.5 KB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

然后在装有golang编译器且网卡ip为172.17.0.3的docker中运行编写的程序,设程序文件名为main.go

# 运行前的基本操作可以参考golang的基础配置
go mod init main
go mod tidy

root@17631723a29a:/home/tmp# go run main.go
64 172.17.0.2 echo 0 37036
64 172.17.0.2 echo 0 44518
64 172.17.0.2 echo 0 25674
64 172.17.0.2 echo 0 39596

启动上述程序后启动另一个docker使用ping命令

# 这里是在另一台ip为172.17.0.2的docker中运行
root@f786e1727b8a:/# ping 172.17.0.3
PING 172.17.0.3 (172.17.0.3) 56(84) bytes of data.
64 bytes from 172.17.0.3: icmp_seq=1 ttl=64 time=0.118 ms
64 bytes from 172.17.0.3: icmp_seq=2 ttl=64 time=0.271 ms
64 bytes from 172.17.0.3: icmp_seq=3 ttl=64 time=0.101 ms
64 bytes from 172.17.0.3: icmp_seq=4 ttl=64 time=0.147 ms
^C

可以看到上面的程序实现了ICMP协议的监听和拦截,但是比较奇怪的一点是,只能监听和拦截同网段的docker的ping命令,但是不能监听和拦截不同网段的宿主机的ping命令,比如说我在Windows和WSL2中ping 172.17.0.3,程序其实监听不到,不清楚是Windows的ping命令不同寻常还是不能监听不同网段的ping命令。

TCP传输

将上文的程序中的ICMP部分稍作修改,替换成TCP即可监听TCP流量。 main函数参考自引用的博主的Github,TCP首部解析博主也是参考自其他人,博主引用的代码原代码,这里删除不必要的部分。

package main
import ("bytes" "encoding/binary" "fmt" "net")

func main() {
   netaddr, _ := net.ResolveIPAddr("ip4", "172.17.0.3")
   // 注意,这里修改了监听协议
   conn, _ := net.ListenIP("ip4:tcp", netaddr)
   for {
      // 缓冲区大小和TCP包大小匹配
      buf := make([]byte, 1480)
      n, addr, _ := conn.ReadFrom(buf)
      // 这里用别人写好的函数解析了一下TCP首部,默认20字节
      tcpheader := NewTCPHeader(buf[0:n])
      fmt.Println(n, addr, tcpheader)
   }
}

/*
Copyright 2013-2014 Graham King
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License (GPLv3)
*/

type TCPHeader struct {
   Source      uint16
   Destination uint16
   SeqNum      uint32
   AckNum      uint32
   DataOffset  uint8 // 4 bits
   Reserved    uint8 // 3 bits
   ECN         uint8 // 3 bits
   Ctrl        uint8 // 6 bits
   Window      uint16
   Checksum    uint16 // Kernel will set this if it's 0
   Urgent      uint16
}

// Parse packet into TCPHeader structure
func NewTCPHeader(data []byte) *TCPHeader {
   var tcp TCPHeader
   r := bytes.NewReader(data)
   binary.Read(r, binary.BigEndian, &tcp.Source)
   binary.Read(r, binary.BigEndian, &tcp.Destination)
   binary.Read(r, binary.BigEndian, &tcp.SeqNum)
   binary.Read(r, binary.BigEndian, &tcp.AckNum)

   var mix uint16
   binary.Read(r, binary.BigEndian, &mix)
   tcp.DataOffset = byte(mix >> 12)  // top 4 bits
   tcp.Reserved = byte(mix >> 9 & 7) // 3 bits
   tcp.ECN = byte(mix >> 6 & 7)      // 3 bits
   tcp.Ctrl = byte(mix & 0x3f)       // bottom 6 bits

   binary.Read(r, binary.BigEndian, &tcp.Window)
   binary.Read(r, binary.BigEndian, &tcp.Checksum)
   binary.Read(r, binary.BigEndian, &tcp.Urgent)

   return &tcp
}

func (tcp *TCPHeader) String() string {
   if tcp == nil {
      return "<nil>"
   }
   return fmt.Sprintf("Source=%v Destination=%v SeqNum=%v AckNum=%v DataOffset=%v Reserved=%v ECN=%v Ctrl=%v Window=%v Checksum=%v Urgent=%v", tcp.Source, tcp.Destination, tcp.SeqNum, tcp.AckNum, tcp.DataOffset, tcp.Reserved, tcp.ECN, tcp.Ctrl, tcp.Window, tcp.Checksum)
}

下面是监听TCP的容器输出

root@17631723a29a:/home/tmp# go run main.go
40 172.17.0.2 Source=52426 Destination=80 SeqNum=2492695836 AckNum=0 DataOffset=10 Reserved=0 ECN=0 Ctrl=2 Window=64240 Checksum=22614 Urgent=%!v(MISSING)

下面是使用TCP访问监听主机,由于主机只是监听,没有处理握手挥手,所以连接失败。

root@f786e1727b8a:/# curl 172.17.0.3:80
curl: (7) Failed to connect to 172.17.0.3 port 80 after 0 ms: Connection refused

UDP传输

本来感觉"ip:协议"这个参数指定的协议完全没有用,但是后来测试过才知道这个确实是有效的,比如我设为tcp,它是不监听UDP流量的。

将上文的程序中的TCP部分稍作修改,替换成UDP即可监听UDP流量。这个代码与我抄来的不能说完全一致,只能说一模一样。

package main
import ("bytes" "encoding/binary" "fmt" "net")
func main() {
   netaddr, _ := net.ResolveIPAddr("ip4", "172.17.0.3")
   conn, _ := net.ListenIP("ip4:udp", netaddr)
   for {
      buf := make([]byte, 1480)
      n, addr, _ := conn.ReadFrom(buf)
      udpheader := NewUDPHeader(buf[0:n])
      fmt.Println(n, addr, udpheader)
   }
}

/*
2022-2022 Starbamboo
*/
// UDPHeader 8 bytes
type UDPHeader struct {
   Source      uint16 // 2 bytes
   Destination uint16 // 2 bytes
   Length      uint16 // 2 bytes
   Checksum    uint16 // 2 bytes
}

// NewUDPHeader Parse packet
func NewUDPHeader(data []byte) *UDPHeader {
   var udp UDPHeader
   r := bytes.NewReader(data)
   binary.Read(r, binary.BigEndian, &udp.Source)
   binary.Read(r, binary.BigEndian, &udp.Destination)
   binary.Read(r, binary.BigEndian, &udp.Length)
   binary.Read(r, binary.BigEndian, &udp.Checksum)
   return &udp
}
func (tcp *UDPHeader) String() string {
   if tcp == nil {
      return "<nil>"
   }
   return fmt.Sprintf("Source=%v Destination=%v Length=%v Checksum=%v",
      tcp.Source, tcp.Destination, tcp.Length, tcp.Checksum)
}

下面是监听UDP的容器输出

root@17631723a29a:/home/tmp# go run main.go
10 172.17.0.2 Source=51707 Destination=80 Length=10 Checksum=22595

下面是使用UDP访问监听主机,这里使用netcat工具测试UDP连通性,只输入1个'a'作为数据。

root@f786e1727b8a:/# nc -u 172.17.0.3 80
a

UDP模仿ACK

这里放两个只实现模拟TCP前10字节收发序号和控制位的小程序,不具有完全的功能。 server部分。

package main
import ("fmt" "net")
func main() {
   listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 9981})
   if err != nil {
      panic(err)
   }
   fmt.Printf("Local: <%s> \n", listener.LocalAddr().String())

   data := make([]byte, 1024)
   for {
      // Wait for connection
      n, remoteAddr, err := listener.ReadFromUDP(data)
      if err != nil || n != 10 || data[9]&(1<<1) == 0 {
         fmt.Printf("Aborted %s\n", err.Error())
      }

      // Send ACK and SYN, a mimic of TCP
      serverSerial := byte(0)
      clientSerial := data[3]
      start := make([]byte, 10)
      start[3] = serverSerial
      start[7] = clientSerial + 1
      start[9] = 1<<1 + 1<<4
      _, err = listener.WriteToUDP(start, remoteAddr)
      if err != nil {
         fmt.Printf("Aborted %s\n", err.Error())
      }

      // Read ACK
      n, err = listener.Read(data)
      if err != nil || n != 10 || data[9]&(1<<4) == 0 {
         fmt.Printf("Aborted %s\n", err.Error())
      }
      clientSerial = data[3]

      // Read message from client
      n, err = listener.Read(data)
      if err != nil {
         fmt.Printf("Aborted %s\n", err.Error())
      }
      fmt.Printf("<%s> %s\n", remoteAddr, data[:n])

      // Write response to client
      _, err = listener.WriteToUDP([]byte("world"), remoteAddr)
      if err != nil {
         fmt.Printf("Aborted %s\n", err.Error())
      }
   }
}
package main
import ("fmt" "net")
func main() {
   ip := net.ParseIP("127.0.0.1")
   srcAddr := &net.UDPAddr{IP: net.IPv4zero, Port: 0}
   dstAddr := &net.UDPAddr{IP: ip, Port: 9981}
   conn, err := net.DialUDP("udp", srcAddr, dstAddr)
   if err != nil {
      fmt.Printf("Aborted %s\n", err.Error())
   }
   defer conn.Close()

   // 4 byte sequence | 4 byte received | 2 byte sign
   clientSerial := byte(0)
   start := make([]byte, 10)
   start[3] = clientSerial
   start[9] = 1 << 1
   _, err = conn.Write(start)
   if err != nil {
      fmt.Printf("Aborted %s\n", err.Error())
   }

   data := make([]byte, 1024)
   n, err := conn.Read(data)
   if err != nil || n != 10 || data[9]&(1<<4) == 0 {
      fmt.Printf("Aborted %s\n", err.Error())
   }
   serverSerial := data[3]
   clientSerial += 1
   start[3] = clientSerial
   start[7] = serverSerial
   start[9] = 1 << 4
   _, err = conn.Write(start)

   _, err = conn.Write([]byte("hello"))
   n, err = conn.Read(data)
   if err != nil {
      fmt.Printf("Aborted %s\n", err.Error())
   }
   fmt.Printf("<%s> %s\n", conn.RemoteAddr(), data[:n])
}