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,总是一端接受数据,然后再转发到另一端。因此 可以抽象出一个对象,来完成这些操作。
// 对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 函数
// 一个多系统兼容的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()函数检 查有没有错误返回来判断连接是否成功建立。
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为例
// 前提 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基础上设计应用层协议的时候,要注意这个特性。
一般的解决办法有两种,一种是定长消息,即规定一定长度的字节为一个消息,当接受的时候,先将接受字节缓存下来,如果没有达到该长度,就继续接受对端的数据。这种方法的灵活性比较差,不合适传递复杂的应用层数据
另一种是设计一个数据包头部,一部分内容用来处理用户的相关协议,并且在发送的时候加一个长度字段。这样对端再收到数据后,先解析头部数据,得到数据的长度,再在收到该长度的消息后,再解析另一个头部即可。