0%

orange-debug和日志记录

记录一些bug和修改以及日志


部署问题

云服务器应用防火墙设置

在服务器上部署需要先设置端口才能连接

image-20221028144759472


客户端发布

  • 图标
    • 资源文件那添加资源,选icon,然后导入一个ico的文件即可
  • vs选择release x64,然后生成即可。

内网穿透-p2p文件发送

发文件时,客户端互相connect不上,listen端无所谓,发起connect的那方马上就发现无法连接就返回了。这是因为主机在内网的缘故。

NAT与内网穿透

假设A打算发文件给B,那么服务器只将客户端B连接服务器使用的ip发给了A,对于内网用户来说,B的这个ip是路由器网关的ip,要进行NAT转换才能到内网主机,NAT就是网络地址转换的意思,因此需要进行内网穿透。

对于内网用户来说,服务器接收到一个ip和一个端口,如果是公网用户,则这个ip是主机ip,端口是主机进程使用的端口;如果是内网用户,则这个ip是网关的ip,端口是映射表中的端口,根据这个端口,网关能知道要发送给哪个主机中的哪个进程。NAT转换表的映射如下:

img

内网穿透(Intranet penetration)就是通过一个公网服务器,让内网主机去连接服务器,服务器就能获取两个内网主机的ip和端口(实际上是各自的网关的ip和映射表中的端口),然后两个内网主机就可以通信了。

UDP打洞

这实际上就是内网穿透最通常的实现方式,服务器如何获取内网主机网关的ip和端口呢,总是要通过连接或者发送信息。因为TCP开销比UDP大得多,所以一般来讲都是使用UDP来实现内网穿透,所以也叫UDP打洞(UDP hole punching)。

当然使用TCP也是可以的。


测NAT类型,用miwifi.com测试,每次打开第一次都是端口限制圆锥形,然后之后都是完全圆锥形。这是软件的问题,不管怎么样,是cone类型即可。

image-20221031110618667

image-20221030210807715

客户端中的问题

原来我以为已经实现了内网穿透了…但刚开始没去细想,理解还是片面了,其实并没有实现。

注意这里的端口映射是映射到一个进程的,也就是说内网穿透实际上是进程(线程)与进程(线程)之间的互相穿透。

然而客户端只有主线程和接收线程连上了服务器,发送文件和接收文件的线程并没有!这两个线程才是真正需要p2p对端发送文件的,但由于接收文件线程是临时创建的,所以需要再内网穿透,因此要让接收文件线程也去连接服务器,让发送线程获取ip端口信息才行。回顾一下先前的设计:

  • 1)用户A发送sendfile请求,服务器发sendfilefrom给B
  • 2)用户B发送acceptfile给服务器,并开始listen等待连接
  • 3)服务器给A发一个sendfile accept + ip信息,A准备根据这个 ip 和sendfile port连接B(实际上该ip是B的网关ip,但是并没有端口映射到接收文件线程)
    • A首先根据ip连接到网关
    • 然后网关根据port查映射表
    • 但由于B接收文件线程没有在网关中添加映射,首先网关肯定不能映射到该线程;并且网关需要查询的port也不一定就是sendfile port(因为网关添加映射是它自己添加的,port怎么样在添加前并不清楚)

因此需要B去连接服务器,一方面让网关添加映射,一方面让服务器获取网关自己添加的映射表中的端口。

简单的想法是直接让接收文件线程去TCP连接服务器,然后让其发送acceptfile命令,这里实现UDP打洞。

当用户B发送acceptfile的命令时,还是让命令主线程直接发送,接着让接收线程创建socket后直接向服务器的端口发一个信息。服务器在获取这个信息时,知道这个命令就是接收文件线程发过来的,就直接把ip和port发给发送文件线程。

更具体的实现是:

  • 首先服务器新建一个listen套接字(和服务器一起初始化),这是因为另外两个套接字在连接时都会做一些后续动作(添加映射表和向epoll注册事件),这里并不需要,因为连接是一次性的。
  • 然后当服务器收到acceptfile请求后,不像之前一下拼接ip就发回去了,而是调用recvfrom阻塞(如果客户端发的比较慢)等待或直接获取(如果客户端发得快)客户端sendto发送的信息(因为udp不确保正确,所以随便发就可以了),然后把ip和port拼接发给目标就可以了。
  • 客户端直接创建完socket就sendto,不管服务器有没有收到,然后进行listen等待对端传输文件。

注意这里的问题不能在用户较多并且同时使用acceptfile时区分是哪个用户,这也是UDP打洞的问题所在,除非再添加其他实现。这里不搞那么复杂。


实现

定义端口号为10000,在云服务器防火墙添加UDP规则。

UDP流程如下:

image-20221030200457821

主要介绍两个函数:

1
2
3
4
5
6
7
8
9
10
11
int recvfrom(int sockfd, void * buf, size_t len, int flags, struct sockaddr * src_addr, socklen_t * addrlen);
/*
recvfrom: 用于接收数据
- sockfd:用于接收UDP数据的套接字;
- buf:保存接收数据的缓冲区地址;
- len:可接收的最大字节数(不能超过buf缓冲区的大小);
- flags:可选项参数,若没有可传递0;
- src_addr:存有发送端地址信息的sockaddr结构体变量的地址;
- addrlen:保存参数 src_addr的结构体变量长度的变量地址值。
*/
返回值:成功为发送的字节数,失败为-1,失败原因存于errno
1
2
3
4
5
6
7
8
9
10
11
int sendto(int sockfd, const void * buf, size_t len, int flags, const struct sockaddr * dest_addr, socklen_t addrlen);
/*
sendto:用于发送数据
- sockfd:用于传输UDP数据的套接字;
- buf:保存待传输数据的缓冲区地址;
- len:带传输数据的长度(以字节计);
- flags:可选项参数,若没有可传递0;
- dest_addr:存有目标地址信息的 sockaddr 结构体变量的地址;
- addrlen:传递给参数 dest_addr的地址值结构体变量的长度。
*/
返回值:成功为发送的字节数,失败为-1,失败原因存于errno

recvfrom相当于把accept的事情做了(保存了客户端地址端口),sendto相当于把connect的事情做了(用到了服务器的ip和port)

对于listen来说,该bind还是要bind,才能启动监听,但不用调用listen()函数。后面可以看到,创建socket的方式基本都是相同的,当创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将调用connect连接的客户套接字。listen函数把一个未连接的套接字转换成一个被动套接字,指示内核应该接受指向该套接字的连接请求。

而UDP不是面向连接的,当然不用listen()了,创建出来的端口发送可接收。接收的话就要bind,仅发送就不用bind。bind的作用是,使得这个套接字的接收是从该端口接收的,发送是从该端口发送的(使得报文中的源端口是该端口)。所以一般客户端不用bind某个端口,交给系统从connect后选择,这样同样的代码可以避免bind同一个端口,否则每次都要改端口。而当需要收发端口统一时,请使用bind。

服务器端实现,初始化UDP监听端口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void init_udp_Socket(int& listenfd, const int port)
{
//定义socketfd,它要绑定监听的网卡地址和端口
listenfd = socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);//UDP
if(listenfd < 0)
{
LOG_ERROR("create listen socket error, port-%d",port);
exit(1);
}
//定义sockaddr_in
struct sockaddr_in socketaddr;
socketaddr.sin_family = AF_INET;//ipv4
socketaddr.sin_port = htons(port);//字节序转换
socketaddr.sin_addr.s_addr = htonl(INADDR_ANY);//INADDR_ANY表示监听所有网卡地址,0.0.0.0;

//端口复用,在bind前设置,否则bind时出错就晚了
int optval = 1;
int ret = setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (const void*)&optval, sizeof(int));
if(ret == -1)
{
LOG_ERROR("set socket setsockopt error !");
close(listenfd);
exit(1);
}

//绑定套接字和地址端口信息,sockaddr_in转成sockaddr
if(bind(listenfd,(struct sockaddr *)&socketaddr,sizeof(socketaddr))==-1)
{
LOG_ERROR("bind port-%d error !",port);
close(listenfd);
exit(1);
}
//完事了
}

原来的acceptfile命令的处理基本长这样,需要在sendstr前把ip和端口拿到。

1
2
3
4
5
6
7
8
9
10
11
12
13
void acceptfile(int conn1, string sid)
{
...
if(state == clientState::isWaiting)
{
string myip = usermap.fvalue_conn1_ip(conn1);
sendstr = "@#sendfile accept "+myip;
send(conn2,sendstr.c_str(),sendstr.size(),0);//发给对方

...
}
...
}

添加一个函数,放回ip和port的string。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
string udp_hole_punching(int listenfd)
{
//定义sockaddr_in
struct sockaddr_in gateway;//表示网关
socklen_t addr_len = sizeof(gateway);
memset(&gateway, 0, sizeof(gateway));
char recvbuf[128];//对数据不感兴趣

//len要传地址,因为要保存写入结构体的长度
int res = recvfrom(listenfd, recvbuf, 128, 0, (struct sockaddr *)&gateway, &addr_len);
if(res < 0)
{
LOG_ERROR("udp hole punching receive error!");
return "";
}
else
{
string ip = string(inet_ntoa(gateway.sin_addr));
string port = to_string(ntohs(gateway.sin_port));
LOG_DEBUG("udp hole punching ip: %s, port: %s",ip.c_str(),port.c_str());
return ip+" "+port;
}
}

服务器差不多就完成了,下面是客户端的修改。客户端主要是在recvfile这个函数做修改,在函数开始前发送udp打洞信息即可,实现一个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
bool udp_hole_punching(const char* server_ip, const int port)
{
//定义socketfd,它要绑定监听的网卡地址和端口
sockfd = socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);//UDP
if (sockfd == INVALID_SOCKET)
{
printf("udp socket error!\n");
return false;
}
//定义sockaddr_in
struct sockaddr_in socketaddr;//告知要发送的目标ip及端口
socketaddr.sin_family = AF_INET;//ipv4
socketaddr.sin_port = htons(port);//字节序转换
inet_pton(AF_INET, server_ip, &socketaddr.sin_addr);

//发送数据,最后的len不用传地址,因为是告知,不用修改
char sendbuf[10] = "udp";
int res = sendto(sockfd, sendbuf, strlen(sendbuf), 0, (struct sockaddr*)&socketaddr, sizeof(socketaddr));
if(res < 0)
{
printf("udp sendto error!\n");
return false;
}
return true;

}

linux-windows字符集问题

linux和windows编码不一样,中文乱码

一些奇妙的bug

客户端退出问题

  • 客户端接收线程recv阻塞退出问题

    • 一开始很奇怪,exit后服务器关闭套接字,然后接收线程就出问题了,recv是-1然后退出一直循环。

    • 这个问题是因为使用了size_t recvbytes = recv(connfd, recvbuf, sizeof(recvbuf), 0);其中size_t无法让返回值变成负数,因此判断-1失效,无法获取服务器已关闭的消息。

    • 然后发现了更怪的问题,就是服务器只close主线程的套接字,接收线程的并没有管,为什么接收线程会退出呢?

    • 查看socket的error字段,发现是10053,即主机主动关闭了连接。思考可能是主线程退出后关了些东西使得接收线程也失败了。检查发现主线程只关了自己的套接字,(一开始没有发现)再仔细看发现执行了WSACleanup();使得socket都退出了,这也难怪接收线程会直接退出。

    • 然而这并不是什么不好的事情,因为本来接收线程recv阻塞也不好退出,现在刚好根据主线程退出,二者同时close掉套接字,然后服务器分别响应并close。分别响应的原因是,当客户端自己崩了的话也是二者同时close掉套接字,此时服务器也应该是分别响应的。

  • 注意客户端调用exit后会close自己的套接字,所以服务器可以直接根据close这个信息来exit_,不需要根据exit命令来操作;这样可以把客户端正常退出和异常退出的情况合起来。


cout多线程安全问题

  • cout多线程安全问题,这个在客户端里涉及。因为cout本身是流对象重载了<<函数,所以<<endl和前面的不是同个函数调用(flush同理),因此会被其他线程的cout挤掉,就导致输出混乱(主要是换行endl被挤掉了不好看)。解决方法是:
    • 换行符直接写到字符串里,但cout刷新缓冲不支持,可能不能及时输出,因为\n在cout中不会刷新,刷新时机:
      • 程序正常退出会刷新cout的缓冲区
      • 一些输出操纵符可以帮助我们刷新,比如endl,flush,ends 代码实例: cout<<”hello”<<flush;由于重载函数,每个<<都可以被其他线程挤掉
      • 将输入于输出绑定在一起,则输入会导致刷新输出的缓冲区 代码:cin.tie(&cout)
      • 也可以通过unitbuf操纵符设置流的内部状态,从而清空缓冲区
    • 使用printf,在printf中\n会刷新缓冲区,刷新时机程序正常退出,输出字符带有‘/n’,调用函数fflush(stdout),发生标准输入,但注意printf能打印的格式是有限制的,cout可以打印重载了<<运算符的对象。
    • c++20中出现了std::format,太新了先不用
    • 也可以cout时加个互斥锁。。。
  • 解决方法就是在接收线程那cout尽量改为printf,主线程使用cout就不用改了(也比较多)。为什么说尽量,因为有的cout不用换行。

服务器命令解析问题-gdb调试

在chatting的一方exit后服务器崩溃,出现Segmentation fault (core dumped)。需要用gdb查看core文件,首先ulimit -c unlimited,然后在Makefile编译选项加个-g(就是-o2那里)。

不过我还是没产生core文件,直接gdb server,在gdb内运行程序(start),一直nnext跳转到start那行代码,然后复现bug,最终发现是命令解析出了问题:

image-20221028172207557

唯一的可能是:因为chatting中要退出,所以要使用@,这说明要进一步检查@这一部分。

跟踪客户端的代码,对于@命令的解析是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
vector<string> parse(string cmdstr)
{
//首先看第一个字符是不是@,是的话去掉就好了
if (cmdstr[0] == '@')
cmdstr = cmdstr.substr(1, cmdstr.size() - 1);
//对于一个关键字的命令,无法用空格分割,考虑到最后一定有个\n是没用的,因此把\n改为空格,一举两得
cmdstr[cmdstr.size() - 1] = ' ';
vector<string> res;
size_t pos = 0;
size_t pos1;
while ((pos1 = cmdstr.find(' ', pos)) != string::npos)
{
res.push_back(cmdstr.substr(pos, pos1 - pos));
while (cmdstr[pos1] == ' ')//过滤空格
pos1++;
pos = pos1;
}
return res;//返回值是右值,外部vector会接收右值,调用移动构造
}

可以看到为了复用没有@的解析,把传进来的cmdstr的@去掉了,但是上层并没有去掉,直接发给服务器了,就出问题了。所以要么把发的去掉,要么收的时候去掉。这里使用引用的话会把换行也消了,所以打算修改服务器端的代码。

改完之后这个bug就解决了。


很小的失误都会导致崩溃

  • 用户1accept后服务器没有把用户1的名字发给用户2,只发了@#chat accept,导致用户2访vector越界。
  • 用户1accept后忘记切换状态了。

image-20221028232057720

这个bug是客户端sendfile后,对端因为服务器发过来的filename是空导致取filename时vec越界崩溃。

检查发现是服务器在处理sendfile命令时获取文件名使用的:find_last_of,打成了find_last_not_of,这样总是找到最后一个位置,然后把filename变成空。

1
2
3
4
5
6
7
8
9
10
11
size_t pos = filename.find_last_of("/\\");//把not去掉
if (pos != string::npos)
filename = filename.substr(pos + 1);

//并且加一个保护,以防发了个文件夹过来,比如a/
if(filename == "")
{
sendstr = "filename error, please break and check!";
send(myconn2, sendstr.c_str(), sendstr.size(), 0);
return;
}

相同ip会导致映射表冲刷。这是因为使用了交换机,发给服务器的ip是交换机的ip。

日志

  • 2022-10-12:有个idea,开始设计
  • 接下来两天:设计客户端状态机和初步完成代码,还有一堆烦人的课程作业
  • 接下来五天:根据状态机边设计边实现若干业务处理函数,还有一堆烦人的课程作业
  • 接下来大概三天:完善最后的逻辑补充,如一些特殊情况的思考,以及补充一些边写边想起来的命令(config那些),还有一堆烦人的课程作业
  • 接下来四天:实现服务端的设计,复杂度主要在用户映射和逻辑处理的那块,还有一堆烦人的课程作业
  • 2022-10-26:客户端和服务端都完成,由于代码在markdown中手写,有少量warning和error,迅速改完后已经能成功跑起来了
    接下来两三天:测bug,还是有一些问题,内存越界啊cout多线程安全,都是小事(一查查半天hh),调试后能正确运行,还有一堆烦人的课程作业
  • 2022-10-30:基于tcp的p2p文件传输无响应,猜测是内网无法连接,准备进行内网穿透的实现,从NAT的类型开始了解,进行了简单的udp打洞测试(基于c++)
  • 2022-10-31:udp打洞逐渐深入,实现了逆向连接(NAT和公网客户端),由于网络资源不允许(大多数人使用校园网是对称型,这种无法穿透),还无法进行双NAT下的udp打洞内网穿透;测试了tcp逆向连接的完成(基于conntrack连接跟踪原理,穿透了防火墙),这个过程中发现了TCP同时打开的现象(双主动connect实现tcp连接建立)
  • 2022-11-1:周二课多,没写代码。系统了解了TCP同时打开的原理,整理了一篇博客(技术讲解博客确实写得少)
  • 接下来三天:这几天没怎么干,有一堆烦人的课程作业,然后周五考试,考完下午散了下心
  • 2022-11-5:借助小薛的路由器(NAT是圆锥型,可以穿透)验证了udp打洞的内网穿透;但基于tcp的内网穿透一直无法实现。
  • 接下来两天:打算用quic实现udp可靠文件传输。试着配msquic环境,然而微软这个文档写的真逆天,作者测试也不完全,网上也没有相关的配置博客,折磨了两天放弃了(win10的TLS1.3打开了也test失败)
  • 接下来两天:事情多,课多以及写课程大作业…
  • 2022-11-9:不打算用quic了,看了其他RDT的UDP,有UDT和RUDP,GitHub上看开源项目看了好久,不太热门的东西文档太烂了,而且接口也不写明白;还看了下别人实现的简单的RDT的UDP,写的太烂了。浪费一中午和一下午时间和一个傍晚的时间,急,项目被卡着快两周了。还是要整理下心情
  • 2022-11-10:打算自己写一个RDT的UDP,并且不参考tcp而参考quic,当然只是简单实现。估计要写很久了,接下来事情好多。
  • 2022-11-20:十天过去了,下半学期开始后好忙…
  • 2022-11-27:终于完成了RDT的UDP实现,但还没融入到项目里,之后还要肝大作业,需要等寒假再完成了。
  • 2022-12-18:大作业队友迟迟不干活,把项目整理完,项目开发到此为止。