TCP PORT FORWARD 总结

程序整体框架

简介

程序整体是一个单线程的非阻塞的程序，分为 client 和 server 两个部分，可以将内网运行在特定端口（app port 比如 80）
的基于 TCP 协议的服务（app）映射到外网服务器上的动态端口（mapping port 比如 44567）。为了实现 client 和 server
的通信，server 需要监听一个端口（c-s port, 比如 7080）来和 client 通信。由于 NAT 存在的原因，最开始需要 client 向
server 监听的端口（7080）发送一个 connect 来建立连接，并将这个连接作为 masterfd, 来管理 server 对 client 的连接发起请求。
收到 client 的请求后，server 将监听一个动态端口（比如 44567），作为内网服务映射的端口，并通过 masterfd 将端口信息发给
客户端。之后 server 每接受一个请求，就会通过 masterfd 向 client 请求一个连接，之后客户端会建立一个 app port （比如 80）到
c-s port（比如 7080）的数据流转发 tunnel, server 就会建立一个 c-s port 到 mapping port 的数据流转发 tunnel, 两者向连接
就可以完成 port forword 功能，并通过 epoll 管理多个这样的 tunnel 的行为。

数据结构

client 和 server 内部其实都在做着相同的事情，那就是一对 sock fd, 总是一端接受数据，然后再转发到另一端。因此
可以抽象出一个对象，来完成这些操作。

Copy
// 对fd的包装
// buf:该fd用来接收数据的缓存区
// iptr, optr记录数据读写位置的指针
// fd_fin, closed,connected:记录fd状态的变量
class fd_buf{
public:
int fd;
char buf[MAXLINE];
int iptr, optr;
bool fd_fin,closed,connected;
// uint32_t mod;
fd_buf() {
iptr = optr = 0;
fd_fin = false;
connected = false;
closed = false;
// mod = EPOLLIN;
}
fd_buf(int fd){
iptr = optr = 0;
fd_fin = false;
connected = false;
closed = false;
// mod = EPOLLIN;
this->fd = fd;
}

bool is_buf_empty() {
return iptr == optr;
}

bool is_buf_full() {
return iptr == MAXLINE;
}

};

// 用来完成数据转发的对象
// 从用户的角度看，一段为上行，一段为下载
class tunnel{
public:
fd_buf *fd_up, *fd_down;
double sum_up, sum_down;
tunnel() {
sum_up = 0;
sum_down = 0;
}
tunnel(int fd_user, int fd_app) {
sum_up = 0;
sum_down = 0;
fd_up = new fd_buf(fd_user);
fd_down = new fd_buf(fd_app);
}
int do_read(fd_buf* fd_buf_in, fd_buf* pair_fd_buf);

int do_write(fd_buf* fd_buf_out, fd_buf* pair_fd_buf);

void fd_user_read(){
int n = do_read(fd_up, fd_down);
sum_up += n;
}

void fd_app_read(){
do_read(fd_down, fd_up);
}

void fd_user_write(){
int n = do_write(fd_up, fd_down);
sum_down += n;
}

void fd_app_write(){
do_write(fd_down, fd_up);
}

bool is_user_fd(int fd){
return fd == fd_up->fd;
}
~tunnel() {
delete fd_down;
delete fd_up;
}

};

非阻塞

为什么会阻塞？因为对于单个连接而言，如果该阶段的工作没完成，下面的任务就不能正确完成，所以必须阻塞。为什么
要设置非阻塞，因为我们要管理多个连接。在单线程的程序中，如果一个连接阻塞，其它的连接就会连带阻塞住。解决的方法
一个是多线程，一个连接一个线程，就不会相互影响了。另一个就是 I/O 多路复用，通过 epoll/select 监听 sockfd, 并返回
就绪状态的 fd, 这样就不会因为单个连接影响其它连接了。在用于 TCP 通信的 socket 编程中，connect, accept,read,write
都可能会阻塞，不过我们可以通过将其设置为非阻塞的方式来提高性能和避免单个 sock fd 阻塞住其它的 sock fd.

setnonblocking 函数

Copy
// 一个多系统兼容的setnonblocking
int setnonblocking(int fd) {
if (fd < 0) return -1;
int flags;
/* If they have O_NONBLOCK, use the Posix way to do it */
#if defined(O_NONBLOCK)
/* Fixme: O_NONBLOCK is defined but broken on SunOS 4.1.x and AIX 3.2.5. */
if (-1 == (flags = fcntl(fd, F_GETFL, 0)))
flags = 0;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
#else
/* Otherwise, use the old way of doing it */
flags = 1;
return ioctl(fd, FIOBIO, &flags);
#endif
}

非阻塞 connect

阻塞的原因

connect 函数会激发 TCP 的三次握手，向 server 发送一个 SYN ，如果对方在一定的时间内没有回复，client 会进行重试，
最长会持续几十秒。

如何设置非阻塞

在调用 connect 函数之前，调用 setnonblocking (fd) 即可

非阻塞 connect 的处理

在 connect 前设置 fd 为非阻塞后，如果 tcp 连接没有完成，将会先返回 EINPROGRESS 错误。在连接发生错误情况下 fd 将
即可读又可写；在连接完成的情况下，fd 将变得可写。一般通过 getsockopt () 函数检
查有没有错误返回来判断连接是否成功建立。

Copy
int error;
socklen_t len = sizeof(error);

if(getsockopt(fd,SOL_SOCKET, SO_ERROR, &error, &len) == 0 && error == 0){
printf("connect success\n");
}
else {
printf("connect failed\n");
}

非阻塞 accept

阻塞的原因

正常的情况下，使用 epoll/select 后，不需要考虑 accept 的阻塞，因为总是监听到连接事件后才调用 accept 的。但是有一种特殊的情况
就是：当客户端发起一个连接，并且这个事件被 epoll/select 监测到，已经放到了待处理的事件列表中，但还没有马上处理，这个时候客
户端中断了这个连接，服务器已经将这个连接移除，当进入 accept 的时候，整个程序就会被阻塞住，直到来了新的连接。

处理方法

将对于的 listenfd 设置为非阻塞；

在 accept 后忽略 EWOULDBLOCK,ECONNABORTED,EPROTO,EINTR 错误

非阻塞 read 和 write

可读可写的判定条件

可读

有数据可读

收到对端的 FIN 包

有新的连接过来

待处理错误

可写

有用于写的空间

待处理错误

非阻塞处理

调用 setnonblocking (fd) 函数即可，然后用 epoll 监听对应的可读可写事件。监听到对应事件
后需要在错误中忽略 EAGAIN 和 EWOULDBLOCK 错误

EPOLL 事件驱动

以 tunnel read 为例

Copy
// 前提 fd&EPOLLIN, 对端fd_peer状态不清楚
n = read
if ( n < 0) {
//由非阻塞引入的错误不需要处理
if(errno != EAGAIN && errno != EWOULDBLOCK) {
//先处理在当前条件下可以马上处理的
//再根据对端状态及应用缓存区状态分类处理

// 关闭fd后，epoll会自动移除对该fd的监听。
close(fd)

//在当前缓存区不为空，对端没有关闭的情况，需要监听对端的可写
if(!fd_pair.closed && fd.buf not empty ){
enable fd_peer EPOLLOUT
//因为fd已经关闭，所以要关闭对端可读
disable fd EPOLLIN
}
}
}
else if (n == 0) {
if(fd_pair.buf not empty ) {
enable fd EPOLLOUT
}

if(!fd_pair.closed && fd.buf is empty) {
enable fd_pair EPOLLIN
}

}
else {
if(fd.buf is empty) {
if(fd_pair not full && !fd_pair.fd_fin) {
fd_pair EPOLLOUT|EPOLLIN
}
else {
enable fd_pair EPOLLOUT
}
}

if(fd.buf is full) {
if(fd_pair.buf not empty) {
enable fd EPOLLOUT
}
else{
disenable fd EPOLLOUT
}
}
}

关于 tcp 流式套接字特性的处理

tcp 协议可以保证数据有序的，无误的传送给对端（如果出错就重试或者报错），但是并不是一次发送多少字节的消息，对端就一次接受多少个字节。在 TCP 基础上设计应用层协议的时候，要注意这个特性。

一般的解决办法有两种，一种是定长消息，即规定一定长度的字节为一个消息，当接受的时候，先将接受字节缓存下来，如果没有达到该长度，就继续接受对端的数据。这种方法的灵活性比较差，不合适传递复杂的应用层数据

另一种是设计一个数据包头部，一部分内容用来处理用户的相关协议，并且在发送的时候加一个长度字段。这样对端再收到数据后，先解析头部数据，得到数据的长度，再在收到该长度的消息后，再解析另一个头部即可。