Go 使用原始套接字捕获网卡流量

Go 使用原始套接字捕获网卡流量

Go 捕获网卡流量使用最多的库为 github.com/google/gopacket,需要依赖 libpcap 导致必须开启 CGO 才能够进行编译。

为了减少对环境的依赖可以使用原始套接字捕获网卡流量,然后使用 gopacket 的协议解析功能,这样就省去了解析这部分的工作量,正确性也可以得到保证,同时 CGO 也可以关闭。

cilium 里有一个原始套接字打开的测试用例:

go 复制代码
// Both openRawSock and htons are available in
// https://github.com/cilium/ebpf/blob/master/example_sock_elf_test.go.
// MIT license.

func OpenRawSocket(index int) (int, error) {
	sock, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW|syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC, int(htons(syscall.ETH_P_ALL)))
	if err != nil {
		return 0, err
	}

	sll := syscall.SockaddrLinklayer{Ifindex: index, Protocol: htons(syscall.ETH_P_ALL)}
	if err := syscall.Bind(sock, &sll); err != nil {
		syscall.Close(sock)
		return 0, err
	}
	return sock, nil
}

// htons converts the unsigned short integer hostshort from host byte order to network byte order.
func htons(i uint16) uint16 {
	b := make([]byte, 2)
	binary.BigEndian.PutUint16(b, i)
	return *(*uint16)(unsafe.Pointer(&b[0]))
}

但是这个示例有一个问题,只能拿到本机流量。

捕获经过网桥的非本机流量

通过 tcpdump 是可以抓到经过网桥的转发流量的,我们使用 stracetcpdump 进行跟踪分析

shell 复制代码
root@localhost:~# strace -f tcpdump -i b_2_0 arp -nne
...
socket(AF_PACKET, SOCK_RAW, htons(0 /* ETH_P_??? */)) = 4
ioctl(4, SIOCGIFINDEX, {ifr_name="lo", ifr_ifindex=1}) = 0
ioctl(4, SIOCGIFHWADDR, {ifr_name="b_2_0", ifr_hwaddr={sa_family=ARPHRD_ETHER, sa_data=4e:59:d6:32:f6:42}}) = 0
newfstatat(AT_FDCWD, "/sys/class/net/b_2_0/wireless", 0x7ffdf063bc50, 0) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/sys/class/net/b_2_0/dsa/tagging", O_RDONLY) = -1 ENOENT (No such file or directory)
ioctl(4, SIOCGIFINDEX, {ifr_name="b_2_0", ifr_ifindex=6053}) = 0
bind(4, {sa_family=AF_PACKET, sll_protocol=htons(0 /* ETH_P_??? */), sll_ifindex=if_nametoindex("b_2_0"), sll_hatype=ARPHRD_NETROM, sll_pkttype=PACKET_HOST, sll_halen=0}, 20) = 0
getsockopt(4, SOL_SOCKET, SO_ERROR, [0], [4]) = 0
setsockopt(4, SOL_PACKET, PACKET_ADD_MEMBERSHIP, {mr_ifindex=if_nametoindex("b_2_0"), mr_type=PACKET_MR_PROMISC, mr_alen=0, mr_address=4e:59:d6:32:f6:42}, 16) = 0
getsockopt(4, SOL_SOCKET, SO_BPF_EXTENSIONS, [64], [4]) = 0
mmap(NULL, 266240, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fec47cbe000

看到有一个 setsockopt(PACKET_MR_PROMISC) 设置,看起来是开启的混杂模式,查看资料看到这是一个针对套接字级别的混杂模式。

由于之前看过 suricata 的代码,看看它是怎么做的,直接在 suricata 的仓库里面搜索 PACKET_MR_PROMISC 关键字,出现代码

c 复制代码
memset(&sock_params, 0, sizeof(sock_params));
sock_params.mr_type = PACKET_MR_PROMISC;
sock_params.mr_ifindex = bind_address.sll_ifindex;
r = setsockopt(ptv->socket, SOL_PACKET, PACKET_ADD_MEMBERSHIP,(void *)&sock_params, sizeof(sock_params));
if (r < 0) {
    SCLogError("%s: failed to set promisc mode: %s", devname, strerror(errno));
    goto socket_err;
}

套接字设置混杂模式的 Go 实现如下

go 复制代码
// Set socket level PROMISC mode
err = unix.SetsockoptPacketMreq(sock, syscall.SOL_PACKET, syscall.PACKET_ADD_MEMBERSHIP, &unix.PacketMreq{Type: unix.PACKET_MR_PROMISC, Ifindex: int32(index)})
if err != nil {
	syscall.Close(sock)
	return 0, err
}

捕获 VLAN 流量

目前只能拿到普通的以太网流量,如果还需要拿到 VLAN Id 的话,需要设置 PACKET_AUXDATA ,参考 man packet

c 复制代码
PACKET_AUXDATA (since Linux 2.6.21)
    If this binary option is enabled, the packet socket passes
    a metadata structure along with each packet in the
    recvmsg(2) control field.  The structure can be read with
    cmsg(3).  It is defined as

       struct tpacket_auxdata {
           __u32 tp_status;
           __u32 tp_len;      /* packet length */
           __u32 tp_snaplen;  /* captured length */
           __u16 tp_mac;
           __u16 tp_net;
           __u16 tp_vlan_tci;
           __u16 tp_vlan_tpid; /* Since Linux 3.14; earlier, these
                                  were unused padding bytes */
       };

Go 的实现如下

go 复制代码
// Enable PACKET_AUXDATA option for VLAN
if err := syscall.SetsockoptInt(sock, syscall.SOL_PACKET, unix.PACKET_AUXDATA, 1); err != nil {
	syscall.Close(sock)
	return 0, err
}

完整的 OpenRawSocket 实现

完整的实现如下

go 复制代码
func OpenRawSocket(index int) (int, error) {
	sock, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW|syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC, int(htons(syscall.ETH_P_ALL)))
	if err != nil {
		return 0, err
	}
	// Enable PACKET_AUXDATA option for VLAN
	if err := syscall.SetsockoptInt(sock, syscall.SOL_PACKET, unix.PACKET_AUXDATA, 1); err != nil {
		syscall.Close(sock)
		return 0, err
	}
	// Set socket level PROMISC mode
	err = unix.SetsockoptPacketMreq(sock, syscall.SOL_PACKET, syscall.PACKET_ADD_MEMBERSHIP, &unix.PacketMreq{Type: unix.PACKET_MR_PROMISC, Ifindex: int32(index)})
	if err != nil {
		syscall.Close(sock)
		return 0, err
	}

	sll := syscall.SockaddrLinklayer{Ifindex: index, Protocol: htons(syscall.ETH_P_ALL)}
	if err := syscall.Bind(sock, &sll); err != nil {
		syscall.Close(sock)
		return 0, err
	}
	return sock, nil
}

从 fd 中读取数据

这里使用 select(2) 简单地对 fd 进行监听,使用 recvmsg(2) 来读取数据,包括 VLAN tag。

实现如下

go 复制代码
package pcap

import (
	"context"
	"syscall"
)

func FD_SET(fd int, p *syscall.FdSet) {	p.Bits[fd/64] |= 1 << (uint(fd) % 64) }
func FD_CLR(fd int, p *syscall.FdSet) {	p.Bits[fd/64] &^= 1 << (uint(fd) % 64) }
func FD_ISSET(fd int, p *syscall.FdSet) bool {	return p.Bits[fd/64]&(1<<(uint(fd)%64)) != 0 }
func FD_ZERO(p *syscall.FdSet) {
	for i := range p.Bits {
		p.Bits[i] = 0
	}
}

type RecvmsgHandler func(buf []byte, n int, oob []byte, oobn int, err error) error

func RecvmsgLoop(ctx context.Context, sockfd int, fn RecvmsgHandler) error {
	buf := make([]byte, 1024*64)
	oob := make([]byte, syscall.CmsgSpace(1024))
	readfds := syscall.FdSet{}

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}

		FD_ZERO(&readfds)
		FD_SET(sockfd, &readfds)
		tv := syscall.Timeval{Sec: 0, Usec: 100000} // 100ms

		nfds, err := syscall.Select(sockfd+1, &readfds, nil, nil, &tv)
		if err != nil {
			continue
		}

		if nfds > 0 && FD_ISSET(sockfd, &readfds) {
			n, oobn, _, _, err := syscall.Recvmsg(sockfd, buf, oob, 0)
			err = fn(buf, n, oob, oobn, err)
			if err != nil {
				return err
			}
		}
	}
}

VLAN 数据的解析逻辑如下

go 复制代码
func decodeVlanIdByAuxData(oob []byte) (uint16, error) {
	msgs, err := syscall.ParseSocketControlMessage(oob)
	if err != nil {
		return 0, err
	}

	for _, m := range msgs {
		if m.Header.Level == syscall.SOL_PACKET && m.Header.Type == 8 && len(m.Data) >= 20 {
			auxdata := unix.TpacketAuxdata{
				Status:   binary.LittleEndian.Uint32(m.Data[0:4]),
				Vlan_tci: binary.LittleEndian.Uint16(m.Data[16:18]),
			}
			if auxdata.Status&unix.TP_STATUS_VLAN_VALID != 0 {
				return auxdata.Vlan_tci, nil
			}
		}
	}
	return 0, nil
}

总结

以上代码都在实际的场景中使用,只是稍微修改了一点细节以及使用 epoll(2) 来监听,结合 sync.Pool 和精简了解析逻辑,性能尚可能够满足要求。

参考

相关推荐
心月狐的流火号3 小时前
分布式锁技术详解与Go语言实现
分布式·微服务·go
一个热爱生活的普通人5 小时前
使用 Makefile 和 Docker 简化你的 Go 服务部署流程
后端·go
HyggeBest21 小时前
Golang 并发原语 Sync Pool
后端·go
来杯咖啡1 天前
使用 Go 语言别在反向优化 MD5
后端·go
郭京京1 天前
redis基本操作
redis·go
郭京京1 天前
go操作redis
redis·后端·go
你的人类朋友2 天前
说说你对go的认识
后端·云原生·go
用户580559502102 天前
channel原理解析(流程图+源码解读)
go
HiWorld2 天前
Go源码学习(基于1.24.1)-slice扩容机制-实践才是真理
go
程序员爱钓鱼2 天前
Go语言实战案例-Redis连接与字符串操作
后端·google·go