本文档是对软件套件所做的描述,不能替代实验文档。如果你是《计算机网络及应用》的同学,请确保你已阅读实验文档。
这是一个可供你自己实现TCP协议,并将其在真实网络上应用的软件套件。借助本套件,你可以用高度抽象过的API,自己完成一个TCP协议的实现,并且可以与真实世界中的计算机通信。
本套件分为driver和SDK两部分。你应当使用给出的多种语言SDK的任意一种,实现指定的函数(实现的过程中会需要调用一些给定的API)。然后,将driver先运行起来,再运行SDK即可。
在driver运行期间,它会捕获机器上的全部或部分(取决于运行参数)新建的TCP连接,并与你的SDK进程进行通信,使得这场通信等价于使用了你自定义的TCP协议实现来完成。关于更多的细节,请查看实现原理部分。
如果你是《计算机网络及应用》的同学:请按照实验文档中的要求在虚拟机中完成作业,请无视此部分内容
本套件需要在GNU/Linux操作系统上运行,并确保安装以下依赖:
- iproute2。大多数近年来的发行版均已预装。
- nftables。你应该使用系统的包管理器进行安装,各大主流发行版的包管理器均有此包,甚至已经预装了。
- 例如,Ubuntu 22.04已预装
- rust开发环境。建议使用rustup进行安装。
- 如果你不需要自行编译server,也可以不安装
如果你是《计算机网络及应用》的同学:你不应该进行driver-src的构建步骤,而是直接使用套件中自带的./driver可执行文件。除非你存在例如,使用ARM架构的机器且无法安装amd64的虚拟机,或使用较低版本的操作系统(如Ubuntu20.04)、系统中的lib版本过低等情况,你才需要自己编译driver。
- driver-src: driver部分的源代码。
- 构建方法:
cd driver-src && sh build-driver.sh
- 构建方法:
- driver:使用
driver-src
中的build-driver.sh
脚本编译得到的可执行文件。- 该文件的目标平台:
amd64
- 如果你不能或不想使用该预编译的文件,请自行编译。
- 该文件的目标平台:
- sdk:包含所有语言的SDK。子目录名和对应支持语言如下表:(打勾表示已完成支持,未打钩表示计划但尚未完成支持)
- cpp: C++
- python: Python
- rust: Rust
- java: Java/Kotlin
- nodejs: 基于Node.js运行时的JavaScript/Typescript语言
- go: Go
SDK是你编写代码完成TCP协议实现的地方。各个语言的SDK在功能上等价,都是以相同的通信协议与driver通信来完成工作。
具体而言,每个sdk的src目录下都包含如下的文件:(由于各语言文件后缀不同,这里使用的是后缀名)
- main: 是程序的入口点,同时也包含了SDK所有功能的实现(包括与driver通信等功能)
- 如无特殊需求,此文件通常不用改动。
- 解释型语言,请直接执行这个文件。
- 编译型语言,这个文件中会含有程序入口点。请按正常方法编译后,执行编译出的可执行文件即可。
- api: 包含了你完成功能所必需的函数,你将不可避免地在outgoing中用到这些函数。
- 如无特殊需求,此文件通常不用改动。
- outgoing: 为了实现外发(outgoing, 即向外发出SYN,即本机作为客户端)TCP连接所必须实现的函数。
- 你将必须修改此文件,实现文件中所有以TODO标明的方法。
如果你是《计算机网络及应用》的同学:此部分描述的是软件套件的具体实现原理,包含很多在Linux操作系统中的具体机制,如果你发现自己看不懂的话,这很正常,且并不影响你完成实验。 当然,看懂它能够加深你对实验的理解,可能有助于你完成实验。关于此部分的内容,也是可以向助教提问的。
- 本程序依赖于Linux的iproute2和nftables,借助于tproxy(透明代理),拦截并捕获特定的TCP新发连接。
- 即,这些连接发起人并不是在和真正应该通信的目标进程通信,而是和我们driver在进行通信,且他自己并不知道这一点。
- 这些连接仍然是在使用操作系统内核的TCP/IP协议栈。我们的driver拿到的只是应用层数据。
- 当driver启动时,根据不同的运行参数,会自动配置好iproute2规则和nftables规则,以实现连接捕获;当driver crtl+C退出时,会自动清理这些规则
- 具体的规则和源码见nftables.rs和nftables.conf
- 使用
./driver print
可以打印出当前已被设置的所有规则。
- 同时,依赖于Raw Socket,监听系统上所有的IP报文。
- driver与SDK进行基于Unix Domain Socket的进程间通信(数据报形式),事实上起到的是在拦截到的TCP Socket、Raw Socket和SDK之间转发数据的作用(或者,也可以认为是driver和SDK之间相互RPC)。
- 一个典型的例子:
- driver捕获到了一个试图建立的外发连接。它冒充该连接的目标,与发起者建立连接(称为连接1)(三次握手)。
- 同时,它随机选定一个新端口(在内核正常情况下不会使用的高端口区域61000以上),将选定的新端口、和连接原本的目标发送给SDK(调用SDK的app_connect)
- SDK根据这些信息,构造一个TCP SYN报文,并将报文的内容发送给driver(通过调用tcp_tx函数)
- driver将这个TCP报文通过Raw Socket发送出去。
- 不久后,driver从Raw Socket中收到了连接真正目标的SYN-ACK报文。它把这个报文的内容发给SDK(调用SDK的tcp_rx)。
- SDK构造ACK报文,调用tcp_rx函数发出去,同时调用app_connected函数通知driver,连接已建立(称为连接2)。
- driver开始从连接1中不断读取数据,一旦读到数据,就说明这数据是连接发起者想要发给目标的。于是,driver将这些数据发送给SDK(调用SDK的app_send),SDK将数据组装为TCP报文后调用tcp_tx发出去。
- 同时,driver将后续监听到的连接2上的原始TCP报文原样转给SDK(通过调用SDK的tcp_rx),SDK解析报文内容、回ACK等,同时如果解析出payload数据,就将数据的内容通过调用app_recv函数传给driver。driver将这些数据写入连接1的socket。
- 这样,原始的连接发起者就借助了driver的转发,使用SDK中实现的TCP协议,完成了与真正目标的TCP通信。
- 一个典型的例子:
- 注意:
- 原始的连接发起人看到的端口,和真正连接目标看到的端口,是不同的。
- 原始的连接发起人使用的是操作系统网络协议栈,其端口通常是由操作系统选定的,值小于61000。
- 真正连接目标所使用的端口是由driver选定的,其值大于等于61000。
- 其中的TCP报文细节当然也不同:连接1是使用的系统TCP/IP网络协议栈,而连接2使用的是系统的IP及以上层协议栈,但TCP层协议是自己实现的
- 只是,两者通过TCP协议所传递的数据流的内容完全相同。
- 关于outgoing中的
tick
函数:- 事实上,driver会每隔100ms向sdk发送一个保活报文,这个函数就是在sdk收到保活报文时会调用的。
- 该函数的目的是,确保控制权能够定期的回到outgoing层。否则的话,如果应用层没有发送数据、传输层也没有收到TCP报文,那控制权就会一直阻塞在main文件的循环读取Unix Raw Socket的过程中。于是,就无法在不开启多线程的情况下实现定时任务(如定时重传)了。
- 借助本函数,你将可以在不开启多线程的情况下实现定时任务,具体而言:定时任务被新建时记录时间戳,然后每次tick调用,都检查所有的定时任务是否已到时间,对已到时间的任务予以执行。
- 当然,不使用本函数、而是使用新线程做定时任务肯定也是没有任何问题的,api里的函数都是线程安全的。
- 注意:本函数不保证调用的间隔是严格的100ms(因为它是由保活报文驱动的,保活报文与一般的数据报文共享同一个Unix Domain Socket)
- Node.js SDK无此函数,因为Node的编程模型本就是基于事件循环的非阻塞IO,对Unix Raw Socket的读取不会阻塞进程,因此也就不需要tick函数。直接使用语言内置的
setTimeout
就好。
- 通过WireShark之类的网络监控工具进行抓包时,你应该会发现两个TCP连接:它们的目的IP和端口一定相同,源端口一定不同,源IP对外发来说很可能(但不一定)相同,对内收连接来说很可能(但不一定)不同。
- 其中,端口小于61000的是连接1(你通常不需要看,除非你是在试图给driver做debug),端口大于等于61000的是连接2
- 在你实现TCP协议的过程中,为了debug,你很可能需要对连接2进行抓包,观察你的TCP协议实现发出的东西到底长什么样
- 源IP不一定相同的原因是,由于连接2使用的是操作系统的IP协议,IP源地址会由操作系统根据目标地址自动选择(即IP路由表中的src项)。不能保证总是与连接1相同的,但对于发到本机之外的外发连接来说,通常是相同的(因为两个连接的IP目的地址一致、且由同一台机器选择,选择的结果应该是一致的)。
- 其中,端口小于61000的是连接1(你通常不需要看,除非你是在试图给driver做debug),端口大于等于61000的是连接2
- WireShark的使用提示
- WireShark默认是不开启TCP检验和校验的,这会导致你无法发现自己算错的检验和。开启方法:编辑——首选项——Protocols——TCP——Validate the TCP checksum if possible
- 开启之后,你会发现很多包的TCP checksum都是错的,不用惊慌,这是由于TCP checksum offload的存在。你只需关注连接2的、由你亲手发出的TCP包的检验和是否正确就可以了,其他的TCP检验和错误都完全不需要在意。
- 原始的连接发起人看到的端口,和真正连接目标看到的端口,是不同的。