这是我参与「第三届青训营 -后端场」笔记创作活动的的第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])
}