《零入门kubernetes网络实战》视频专栏地址
https://www.ixigua.com/7193641905282875942
本篇文章视频地址(稍后上传)
本篇文章主要是想做一个测试:
实现的目的是
1、原理图 |
实现原理图,如下图所示:
该图主要分为两下两部分:
我们要实现的目的是:
分在两条线:
在宿主机-1里,又分了用户空间和内核空间两部分。
tun-driver就是咱们要编写的程序;
到此为止,我们介绍了我们要实现的目的是什么,介绍了原理图,数据包的走向,以及我们实现的程序都做了哪些事情;
接下来,看一下我们的代码:
2、方案说明 |
本次测试,我打算采用两种试验方式;
想把我学习tun设备的过程,给大家复盘一下,
代码不是一开始就写正确的,总得有个调试的过程。
刚好方案一,遇到了一些问题。给大家分享一下。
当然,你可以直接跳过方案一,直接看方案二。
3、方案一: |
3.1、创建tun设备的代码 |
package main
import (
"github.com/vishvananda/netlink"
)
const tunName = "tun19"
func main() {
la := netlink.LinkAttrs{
Name: tunName,
Index: 8,
MTU: 1500,
}
tun := netlink.Tuntap{
LinkAttrs: la,
Mode: netlink.TUNTAP_MODE_TUN,
}
l, err := netlink.LinkByName(tunName)
if err == nil {
// 先将tun虚拟网络设备Down掉
netlink.LinkSetDown(l)
// 将tun虚拟网络设备删掉
netlink.LinkDel(l)
}
// 每次创建新的tun设备
err = netlink.LinkAdd(&tun)
if err != nil {
panic(err)
}
l, err = netlink.LinkByName(tunName)
ip, err := netlink.ParseIPNet("10.244.2.2/24")
addr := &netlink.Addr{IPNet: ip, Label: ""}
if err = netlink.AddrAdd(l, addr); err != nil {
panic(err)
}
err = netlink.LinkSetUp(l)
if err != nil {
panic(err)
}
}
这段代码,就是前文介绍的。
在本地编译,上传到测试服务器上:
build:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build main.go
scp:
scp main [email protected]:/root
all:
make build && make scp
执行
make all
登录到远程服务器上
ip a s | grep eth0
ip link sh tun19
./main
ip link sh tun19
ip a sh tun19
3.2、tun-driver 代码原理介绍 |
3.2.1、golang代码 |
package main
import (
"bytes"
"encoding/binary"
"flag"
"fmt"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"net"
"os"
"syscall"
"time"
"unsafe"
)
const (
tunDevice = "/dev/net/tun"
ifnameSize = 16
)
type ifreqFlags struct {
IfrnName [ifnameSize]byte
IfruFlags uint16
}
func ioctl(fd int, request, argp uintptr) error {
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), request, argp)
if errno != 0 {
fmt.Errorf("ioctl failed with '%s'\n", errno)
return fmt.Errorf("ioctl failed with '%s'", errno)
}
return nil
}
func fromZeroTerm(s []byte) string {
return string(bytes.TrimRight(s, "\000"))
}
func OpenTun(name string) (*os.File, string, error) {
tun, err := os.OpenFile(tunDevice, os.O_RDWR, 0)
if err != nil {
fmt.Printf("OpenTun Failed! err:%v", err.Error())
return nil, "", err
}
var ifr ifreqFlags
copy(ifr.IfrnName[:len(ifr.IfrnName)-1], []byte(name+"\000"))
ifr.IfruFlags = syscall.IFF_TUN | syscall.IFF_NO_PI
err = ioctl(int(tun.Fd()), syscall.TUNSETIFF, uintptr(unsafe.Pointer(&ifr)))
if err != nil {
fmt.Printf("OpenTun Failed! err:%v\n", err.Error())
return nil, "", err
}
ifName := fromZeroTerm(ifr.IfrnName[:ifnameSize])
return tun, ifName, nil
}
var tunName string
func checkSum(data []byte) uint16 {
var (
sum uint32
length int = len(data)
index int
)
for length > 1 {
sum += uint32(data[index])<<8 + uint32(data[index+1])
index += 2
length -= 2
}
if length > 0 {
sum += uint32(data[index])
}
sum += sum >> 16
return uint16(^sum)
}
func main() {
flag.StringVar(&tunName, "tunName", "tun19", "Use -tunName xxx")
flag.Parse()
var err error
tunFile, _, err := OpenTun(tunName)
if err != nil {
fmt.Printf("ICMP Listen Packet Failed! err:%v\n", err.Error())
return
}
defer tunFile.Close()
icmpconn, _ := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
defer icmpconn.Close()
var srcCh = make(chan string, 1)
go tunToicmp(icmpconn, tunFile, srcCh)
go icmpToTun(icmpconn, tunFile, srcCh)
time.Sleep(time.Hour)
}
func tunToicmp(icmpconn *icmp.PacketConn, tunFile *os.File, srcCh chan string) {
var srcIP string
packet := make([]byte, 1024*64)
size := 0
var err error
for {
if size, err = tunFile.Read(packet); err != nil {
return
}
fmt.Printf("Msg Length: %d\n", binary.BigEndian.Uint16(packet[2:4]))
fmt.Printf("Msg Protocol: %d (1=ICMP, 6=TCP, 17=UDP)\tsize:%d\n", packet[9], size)
b := packet[:size]
srcIP = GetSrcIP(b)
srcCh <- srcIP
dstIP := GetDstIP(b)
fmt.Printf("Msg srcIP: %s\tdstIP:%v\n", srcIP, dstIP)
var raddr = net.IPAddr{IP: net.ParseIP(dstIP)}
b = b[20:size]
if size, err = icmpconn.WriteTo(b, &raddr); err != nil {
fmt.Println(err.Error())
return
}
fmt.Printf("Send ICMP Packet Success OK! size:%d\n", size)
}
}
func icmpToTun(icmpconn *icmp.PacketConn, tunFile *os.File, srcCh chan string) {
var sb = make([]byte, 1024*64)
var addr net.Addr
var size int
var err error
for {
if size, addr, err = icmpconn.ReadFrom(sb); err != nil {
continue
}
srcIP := <-srcCh
ipHeader := createIPv4Header(net.ParseIP(addr.String()), net.ParseIP(srcIP), os.Getpid())
iphb, err := ipHeader.Marshal()
if err != nil {
continue
}
fmt.Printf("Reply MSG Length: %d\n", binary.BigEndian.Uint16(iphb[2:4]))
fmt.Printf("Reply MSG Protocol: %d (1=ICMP, 6=TCP, 17=UDP)\n", iphb[9])
dstIP := GetDstIP(iphb)
fmt.Printf("Reply src IP: %s\tdstIP:%v\n", addr, dstIP)
var tunb = make([]byte, 84)
tunb = append(iphb, sb[:size]...)
size, err = tunFile.Write(tunb)
if err != nil {
continue
}
fmt.Printf("Reply MSG To Tun OK! size:%d\n", size)
}
}
func createIPv4Header(src, dst net.IP, id int) *ipv4.Header {
iph := &ipv4.Header{
Version: ipv4.Version,
Len: ipv4.HeaderLen,
TOS: 0x00,
TotalLen: ipv4.HeaderLen + 64,
ID: id,
Flags: ipv4.DontFragment,
FragOff: 0,
TTL: 64,
Protocol: 1,
Checksum: 0,
Src: src,
Dst: dst,
}
h, _ := iph.Marshal()
iph.Checksum = int(checkSum(h))
return iph
}
func IsIPv4(packet []byte) bool {
flag := packet[0] >> 4
return flag == 4
}
func GetIPv4Src(packet []byte) net.IP {
return net.IPv4(packet[12], packet[13], packet[14], packet[15])
}
func GetIPv4Dst(packet []byte) net.IP {
return net.IPv4(packet[16], packet[17], packet[18], packet[19])
}
func GetSrcIP(packet []byte) string {
key := ""
if IsIPv4(packet) && len(packet) >= 20 {
key = GetIPv4Src(packet).To4().String()
}
return key
}
func GetDstIP(packet []byte) string {
key := ""
if IsIPv4(packet) && len(packet) >= 20 {
key = GetIPv4Dst(packet).To4().String()
}
return key
}
接下来,对代码进行分析
3.2.2、 main流程分析 |
3.2.3、tunToicmp流程分析 |
3.2.3.1、主要流程说明 |
第111-133行:内部死循环的执行任务。
那么,任务的主要过程是:
3.2.3.2、IP报文头结构说明 |
看一下112行,作用是从/dev/net/tun文件描述符里读取数据到packet切片里
而packet属于字节切片
前面文章已经介绍过了,从/dev/net/tun文件里读取的数据,tun设备默认会给原数据包添加一个IP报文头;
那么, 接下来的问题就是,如何解析IP报文头?
从IP报文头里获取我们想要的信息,如ICMP数据包的目的地址是哪里?
IP报文头的结构形式,如下所示:
从IP报文头结构中,我们可以获取到目的地址,即packet[16], packet[17], packet[18], packet[19]
代码中的115行,116行,118行非必须代码,是为给大家演示一下,才写的。
3.2.3.3、通过icmp连接将数据发送到目的地址 |
到目前为止,我们已经将数据从tun19设备里发送到了另一台宿主机的对外网卡eth0上了。
3.2.4、icmpTotun流程分析 |
3.2.4.1、主流程分析 |
整体看一下这块代码,就是一个for循环,在死循环的执行逻辑;
主要逻辑,如下图所示:
3.2.4.2、从ICMP链接里读取消息 |
3.2.4.3、自定义构建IP报文头 |
什么情况下构建IP报文头和什么情况下不需要构建呢?
从两个方面说,一个是虚拟网卡tun19,一个是/dev/net/tun文件描述符;而且跟数据包的走向有关系;如下:
详细的看代码:
3.2.4.4、重新组合数据包 |
将143行的切片跟150行的自定义IP报文头进行组合,将报文头放到切片的首部;
组成成新的回复数据包。
3.2.4.5、将数据包写入到/dev/net/tun里 |
将最新组合而成的切片写入到/dev/net/tun文件描述符里。
即tun19虚拟网卡就收到另一个宿主机eth0回馈的数据包了。
3.3、本地编译,上传到服务器 |
接下来,在本地环境Mac上编译下,上传到测试服务器上
Makefile内容
build:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build main.go
scp:
scp main [email protected]:/root
all:
make build && make scp
执行
make all
3.4、服务器上测试 |
分别打开两个shell终端,如下图所示:
看一下运行结果:
3.5、问题分析 |
通过测试我们发现执行此命令ping 10.211.55.123 -I tun19的时候
没有反馈结果,
那么,分析的思路是:
好,接下来,先抓包分一下
3.5.1、抓包 |
通过抓包,来判断一下,网卡是否收到数据;如果没有收到数据肯定有问题。
3.5.1.1、分析tun19网卡是否有问题 |
针对tun19网卡有两种方式
3.5.1.1.1、方式一:可以查看tun19网卡的统计信息来判断 |
通过如下命令:
ifconfig -v tun19
查看TX,RX是否有变化
3.5.1.1.2、方式二:可以针对tun19网卡进行抓包来分析 |
tcpdump -nn icmp -i tun19
其实,在测试的初期是只有请求数据包,并没有回复数据包,只是不知道如何回复到当时的场景了。
3.5.1.2、分析10.211.55.122节点上的对外网卡eth0是否收到数据 |
tcpdump -nn icmp -i eth0
3.5.1.3、分析10.211.55.123节点上的对外网卡eth0是否收到数据 |
其实,通过上面的抓包,已经说明了,
123节点上的eth0是可以接收到ping命令的请求的,并且此网卡也对ping命令进行回复。
我们可以抓一下,并且通过wireshark简单看一下:
tcpdump -nn icmp -i eth0 -w icmp.pcap
icmp.pcap用WireShark打开
整个测试试验中,涉及到了三个网卡:
122节点上的虚拟网卡tun19,eth0,以及123节点上的eth0网卡
通过抓包我们可以看出来,这三个网卡都可以正常接收到数据包,应该是没有问题的
但是,执行ping 10.211.55.123 -I tun19的时候,确实没有返回值
其实,查看tun19网卡的时候,已经说明了,tun19网卡可以正常接收到123节点的反馈的数据包
也就是说,反馈的数据包从tun19网卡出来后,ping没有收到?
是这一段线路出了问题。
数据包从网卡到应用程序之间的路径是由主机防火墙来规定的。
有可能是防火墙规则给限制住了。
因此,接下来,只能查看防火墙了。
3.5.2、iptables防火墙规则分析 |
通过对tun19虚拟网卡进行抓包,发现:
tun19网卡已经接收到反馈的数据包了,
接下来,看一下:数据包流向图:
涉及到两个链:
涉及到表
接下来,可以依次查看响应的链。
为了节省篇幅,不再一一展示了。
直接添加日志。
添加日志方式
iptables -nvL -t raw
iptables -t raw -A PREROUTING -p icmp -j TRACE
iptables -t raw -A OUTPUT -p icmp -j TRACE
iptables -nvL -t raw
按照数据包的走向,依次根据表查看规则链
tail -f /var/log/messages | grep raw | grep PREROUTING | grep DST=10.244.2.2
注意:打印日志可能不会马上显示,可能需要等待10秒左右
tail -f /var/log/messages | grep mangle | grep PREROUTING | grep DST=10.244.2.2
tail -f /var/log/messages | grep nat | grep PREROUTING | grep DST=10.244.2.2
tail -f /var/log/messages | grep mangle | grep INPUT | grep DST=10.244.2.2
tail -f /var/log/messages | grep nat | grep INPUT | grep DST=10.244.2.2
tail -f /var/log/messages | grep filter | grep INPUT | grep DST=10.244.2.2
其实,通过上面的命令去查询的话,也不是很靠谱的。
可能是这样的,一个数据包要不要经过某个表的某个链
是根据这个数据包自带的包状态来判断的。
比方说,这个数据包没有涉及到nat功能,可能不会经过nat表,因此,查询nat表时,就不会有响应的日志。
可能raw表,filter会必须经过的。
有点遗憾,感觉没有通过iptables日志完全查出来是什么原因,但至少也进一步定位了问题所在。
数据包从tun19网卡出来后,经过了raw表。
在后续的表中,出了问题。
3.5.3、反向路由校验 |
在即将放弃的时候,突然想起了一个内核参数net.ipv4.conf.all.rp_filter
尝试的修改了几次,发现OK了!
3.5.3.1、什么是反向路由校验 |
所谓反向路由校验,
就是在一个网卡收到数据包后,
把源地址和目标地址对调后查找路由出口,
从而得到反身后路由出口。
然后根据反向路由出口进行过滤。
当rp_filter的值为1时,
当rp_filter的值为2时,
3.5.3.2、rp_filter (Reverse Path Filtering)参数介绍 |
默认值应该是1,严格进行反向路由校验
具体可以参考下面的网址
Linux内核参数 rp_filter
3.5.3.3、ping不通的原因可能是 |
仅仅是猜测:
数据包经过了两个网卡,一个是虚拟网卡tun19, 一个是eth0
当对数据包进行反向路由校验时,从tun19网卡到eth0网卡失败了,它校验的时候,可能没有添加IP报文头。
3.6、设置反向路由校验,重新测试 |
3.6.1、设置反向路由校验参数 |
既然问题原因已经找到了,设置参数
sysctl -w net.ipv4.conf.all.rp_filter=2
sysctl net.ipv4.conf.all.rp_filter
3.6.2、重新测试 |
ping 10.211.55.123 -I tun19
再次,查看iptables日志
tail -f /var/log/messages | grep DST=10.244.2.2
从上面的日志中可以看出来
即使在ping通的情况下,数据包也只经过了raw表,filter表
RPEROUTING链,以及INPUT链。
可能没有涉及到nat、mangle功能,因此没有nat、mangle相关日志。
3.6.3、为什么会存在大量的DUP!呢? |
如果你去查百度的话,会查到一堆。
最后,在测试时无意中发现是代码的问题。
重新测试
好,到这里我们的试验目标已经完全成功了。
其实,测试用了好长时间。结局还是圆满的。
4、方案二 |
4.1、golang代码 |
package main
import (
"bytes"
"encoding/binary"
"fmt"
"github.com/vishvananda/netlink"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"net"
"os"
"syscall"
"time"
"unsafe"
)
const (
tunName = "tun19"
tunDevice = "/dev/net/tun"
ifnameSize = 16
eth0 = "10.211.55.122"
tunIP = "10.244.1.3"
)
type ifreqFlags struct {
IfrnName [ifnameSize]byte
IfruFlags uint16
}
func ioctl(fd int, request, argp uintptr) error {
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), request, argp)
if errno != 0 {
fmt.Errorf("ioctl failed with '%s'\n", errno)
return fmt.Errorf("ioctl failed with '%s'", errno)
}
return nil
}
func fromZeroTerm(s []byte) string {
return string(bytes.TrimRight(s, "\000"))
}
func OpenTun(name string) (*os.File, string, error) {
tun, err := os.OpenFile(tunDevice, os.O_RDWR|syscall.O_NONBLOCK, 0)
if err != nil {
fmt.Printf("OpenTun Failed! err:%v", err.Error())
return nil, "", err
}
var ifr ifreqFlags
copy(ifr.IfrnName[:len(ifr.IfrnName)-1], []byte(name+"\000"))
ifr.IfruFlags = syscall.IFF_TUN | syscall.IFF_NO_PI
err = ioctl(int(tun.Fd()), syscall.TUNSETIFF, uintptr(unsafe.Pointer(&ifr)))
if err != nil {
fmt.Printf("OpenTun Failed! err:%v\n", err.Error())
return nil, "", err
}
ifName := fromZeroTerm(ifr.IfrnName[:ifnameSize])
return tun, ifName, nil
}
func checkSum(data []byte) uint16 {
var (
sum uint32
length int = len(data)
index int
)
for length > 1 {
sum += uint32(data[index])<<8 + uint32(data[index+1])
index += 2
length -= 2
}
if length > 0 {
sum += uint32(data[index])
}
sum += sum >> 16
return uint16(^sum)
}
func main() {
fmt.Printf("======>Now----Test----Tun<===1232===\n")
tunFile, err := createTun()
if err != nil {
fmt.Printf("ICMP Listen Packet Failed! err:%v\n", err.Error())
return
}
defer tunFile.Close()
icmpConn, _ := icmp.ListenPacket("ip4:icmp", eth0)
defer icmpConn.Close()
go tunToIcmp(icmpConn, tunFile)
go icmpToTun(icmpConn, tunFile)
time.Sleep(time.Hour)
}
func tunToIcmp(icmpconn *icmp.PacketConn, tunFile *os.File) {
var srcIP string
packet := make([]byte, 1024*64)
size := 0
var err error
for {
if size, err = tunFile.Read(packet); err != nil {
return
}
fmt.Printf("Msg Length: %d\n", binary.BigEndian.Uint16(packet[2:4]))
fmt.Printf("Msg Protocol: %d (1=ICMP, 6=TCP, 17=UDP)\tsize:%d\n", packet[9], size)
b := packet[:size]
srcIP = GetSrcIP(b)
dstIP := GetDstIP(b)
fmt.Printf("Msg srcIP: %s\tdstIP:%v\n", srcIP, dstIP)
var raddr = net.IPAddr{IP: net.ParseIP(dstIP)}
b = b[20:size]
if size, err = icmpconn.WriteTo(b, &raddr); err != nil {
fmt.Println(err.Error())
return
}
fmt.Printf("Write Msg To Icmp Conn OK! size:%d\n", size)
}
}
func icmpToTun(icmpconn *icmp.PacketConn, tunFile *os.File) {
var sb = make([]byte, 1024*64)
var addr net.Addr
var size int
var err error
for {
if size, addr, err = icmpconn.ReadFrom(sb); err != nil {
continue
}
ipHeader := createIPv4Header(net.ParseIP(addr.String()), net.ParseIP(tunIP), os.Getpid())
iphb, err := ipHeader.Marshal()
if err != nil {
continue
}
fmt.Printf("Reply MSG Length: %d\n", binary.BigEndian.Uint16(iphb[2:4]))
fmt.Printf("Reply MSG Protocol: %d (1=ICMP, 6=TCP, 17=UDP)\n", iphb[9])
dstIP := GetDstIP(iphb)
fmt.Printf("Reply src IP: %s\tdstIP:%v\n", addr, dstIP)
var rep = make([]byte, 84)
rep = append(iphb, sb[:size]...)
size, err = tunFile.Write(rep)
if err != nil {
continue
}
fmt.Printf("Write Msg To /dev/net/tun OK! size:%d\ttime:%v\n", size, time.Now())
}
}
func createIPv4Header(src, dst net.IP, id int) *ipv4.Header {
iph := &ipv4.Header{
Version: ipv4.Version,
Len: ipv4.HeaderLen,
TOS: 0x00,
TotalLen: ipv4.HeaderLen + 64,
ID: id,
Flags: ipv4.DontFragment,
FragOff: 0,
TTL: 64,
Protocol: 1,
Checksum: 0,
Src: src,
Dst: dst,
}
h, _ := iph.Marshal()
iph.Checksum = int(checkSum(h))
return iph
}
func IsIPv4(packet []byte) bool {
flag := packet[0] >> 4
return flag == 4
}
func GetIPv4Src(packet []byte) net.IP {
return net.IPv4(packet[12], packet[13], packet[14], packet[15])
}
func GetIPv4Dst(packet []byte) net.IP {
return net.IPv4(packet[16], packet[17], packet[18], packet[19])
}
func GetSrcIP(packet []byte) string {
key := ""
if IsIPv4(packet) && len(packet) >= 20 {
key = GetIPv4Src(packet).To4().String()
}
return key
}
func GetDstIP(packet []byte) string {
key := ""
if IsIPv4(packet) && len(packet) >= 20 {
key = GetIPv4Dst(packet).To4().String()
}
return key
}
func createTun() (*os.File, error) {
err := addTun()
if err != nil {
return nil, err
}
err = configTun()
if err != nil {
return nil, err
}
tunFile, _, err := OpenTun(tunName)
if err != nil {
return nil, err
}
return tunFile, nil
}
func addTun() error {
la := netlink.LinkAttrs{
Name: tunName,
Index: 8,
MTU: 1500,
}
tun := netlink.Tuntap{
LinkAttrs: la,
Mode: netlink.TUNTAP_MODE_TUN,
}
l, err := netlink.LinkByName(tunName)
if err == nil {
netlink.LinkSetDown(l)
netlink.LinkDel(l)
}
err = netlink.LinkAdd(&tun)
if err != nil {
return err
}
return nil
}
func configTun() error {
l, err := netlink.LinkByName(tunName)
if err != nil {
return err
}
ip, err := netlink.ParseIPNet(fmt.Sprintf("%s/%d", tunIP, 24))
if err != nil {
return err
}
addr := &netlink.Addr{IPNet: ip, Label: ""}
if err = netlink.AddrAdd(l, addr); err != nil {
return err
}
err = netlink.LinkSetUp(l)
if err != nil {
return err
}
return nil
}
本代码里,已经集成了虚拟网卡tun19的创建,配置了。
直接使用即可。
4.2、本地编译,上传到服务器上 |
build:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o tun-driver main.go
scp:
scp tun-driver [email protected]:/root
all:
make build && make scp
执行
make all
即可
4.3、反向路由规则校验 |
sysctl -w net.ipv4.conf.all.rp_filter=2
sysctl net.ipv4.conf.all.rp_filter
4.4、测试 |
登录到远程服务器10.211.55.122上
root目录下
进行测试
./tun-driver
ping 10.211.55.123 -I tun19 -c 1
5、总结 |
本次试验,完成了在用户空间发送请求,经过tun类型的设备,实现请求的跨主机通信。
当然,在上面的测试用例中,你可以将ICMP改成udp来做,只不过在10.211.55.123节点上也需要开启一个udp服务来接收122节点上发送过来的数据包,然后,在将数据包转发给123节点上的eth0
6、参考 |
https://www.itdaan.com/blog/2017/03/09/e9d4766e2982.html
84字节,是如何计算处理的?(ctrl+f全局搜索一下84, 即可发现)
https://blog.csdn.net/Rong_Toa/article/details/86665176
https://www.freesion.com/search
云原生虚拟化:一文读懂网络虚拟化之 tun/tap 网络设备
Linux虚拟网络设备——tun/tap
TUN/TAP 学习总结(二) —— Linux TUN demo
c语言版本的tun读
https://blog.haohtml.com/archives/31687
如何读取二层,三层,四层数据包
图解:Ping 命令的工作原理
点击 下面 返回 专栏目录 |
<<零入门kubernetes网络实战>>技术专栏之文章目录