1. 背景需求
由于提供了动态TCP/UDP proxy和proxy_protocol2的设置能力,NJet在非HTTP的私有TCP协议方面得到了较多的应用。常用的场景是NJet作为标准的TCP4层proxy,把请求转发到后端的应用服务,类似如下架构:
当然市场上也有n多的4层代理,社区选择NJet,除了通过PROXY_PROTOCOL2可以灵活的设置属性外,还因为:
- NJet提供了灵活的主动健康检查,可以在后端应用服务故障时自动切换
- NJet CoPilot自带HA能力,无需额外配置可以自动构建HA集群
在实际使用中,有客户报告其端口常常会受到扫描的攻击,比如该端口是一个提供dubbo应用的服务,但有太多的时间收到非正常dubbo client发过来的连接请求。因此客户报告常常是应用服务器收到太多的连接请求导致服务关闭。
在目前阶段,采用的一个临时性的方案是限流(NJet可以配置并发的连接数),限制最大连接数。但这种情况无法区分非法和合法的client,会造成误伤,影响到正常服务。
所以NJet开发了协议识别功能,该功能目的是在连接建立阶段,识别出非法的连接,并立即关闭,避免连接请求发送到后端,从而对后端服务进行了保护。
如同Nginx, NJet可以在client到NJet建立连接,并在预读一段数据、分析校验后,再同后端的服务器建立连接。因此可以通过分析发过来的数据是否符合特定的私有协议,决定是否关闭连接。
2. 实现设计
为了应对企业千差万别的私有协议,NJet必须能够对协议可配置,由现场运维人员编辑应用协议的分析报文。一种直观的思维就是利用脚本来编写协议解析,比如openresty提供的LUA,就提供了类似的能力。但出于性能考虑,NJet决定引入JIT技术,把脚本在NJet的启动阶段编译成标准的c函数,并动态加载进NJet中,从而即实现了灵活性,又保证了优越于LUA等脚本语言的高性能。
此外,为了保证垃圾连接对NJet所在机器系统资源的影响,NJet在管理连接时,引入了快速关闭,绕过了TCP连接关闭的4次挥手机制,直接释放了系统资源。
2.1 指令设计
NJet 为该功能增加了两个配置指令,sniffer控制是否进行协议分析,sniffer_filter_file指明使用的具体分析脚本。
Syntax: | sniffer on/off; 开关ddos分析功能:on 打开;off 关闭 |
---|---|
Default: | off |
Context: | stream server |
Syntax: | sniffer_filter_file [ c 文件路径文件名]; 制定执行过滤文件的路径,以及文件名。 例如: sniffer_filter_file “conf/sniffer.c”; |
---|---|
Default: | — |
Context: | stream server |
2.2 脚本格式
sniffer.c文件是遵循C99标准格式的c语言代码块,必须以return结束,可以返回3个值:
- NJT_ERROR:协议解析失败,是不合法的数据,NJet会关闭client连接
- NJT_OK: NJet会转发该连接到后端
- NJT_AGAIN: 数据长度不够,NJet会等待更多的数据
其骨架代码如下:
//变量定义
//字节流比较,做协议分析
return NJT_OK ; //或NJT_AGAIN,NJT_ERROR
2.2.1 脚本中可使用的预定义变量
以下两个变量定义了目前client发送到的初始报文数据
- bytes_len: 当前收到的数据长度。
- bytes 当前收到的数据buffer 指针
2.2.2 脚本中可使用的函数
出于安全考虑,脚本中可以使用的函数是有限的,具体为
- void sniffer_log(int level, const char *fmt, …) 写日志数据。
level 日志等级:
- NJT_LOG_DEBUG debug 日志。
- NJT_LOG_INFO info 日志。
- NJT_LOG_ERR 错误日志。
- int sniffer_get_hex_cmp(int pos, char* src,int len) 比对数据是否一致。
- pos: 起始位置。
- src : 对比的源数据buffer。
- len :对比的数据长度 。
- 返回值:
- 比对失败:NJT_ERROR
- 比对成功:NJT_OK
- 数据长度不够:NJT_AGAIN 继续等待数据。
- GET_BIT(x, bit) 获取某字节对应 bit位的值,该实现是一个宏定义,
- x: 为char ,或int32, short, long, int64
- bit: 可选值有x的类型决定
- 返回值: 0 或 1
- int sniffer_get_data(int pos,char* buffer,int buffer_len); 从预读的bytes中copy最大buffer_len的内容到buffer中
- pos :从bytes[pos]起copy
- buffer: 需预先分配内存
- buffer_len: 接收的长度。
- 返回值:实际copy的长度
- int sniffer_get_hex_data(int pos,char* buffer,int buffer_len) 从预读的bytes中copy最大buffer_len的内容到buffer中,并把一个字节copy成两个字节,参数同sniffer_get_data
2.2.3样例
下列代码是一个完整的验证client发送的请求是MQTT连接报文的例子。
char buffer3[100] = {0x10,0x1f,0x00,0x04,0x4d,0x51,0x54,0x54,0x05,0x00,0x00,0x3c,0x03,0x21,0x00,0x14,0x00,0x0f,0x6e,0x6f,0x64,0x65,0x2d,0x75,0x30,0x31,0x5f,0x77,0x5f,0x38,0x34,0x31,0x35};
//参考https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901006,可以定义出
//需校验的协议头,buffer3
char name[128] = {0};
int ret = NJT_DECLINED;
char byte;
if (bytes_len < 5) {
sniffer_log(NJT_LOG_DEBUG,"sniffer protocol agin=%d",bytes_len);
return NJT_AGAIN; //数据不够,等待数据。
}
sniffer_get_data(0,(char *)&byte,1);
int Packet_type = GET_BIT(byte,4); //取第1个字节从左数第5位
if (Packet_type != 1) { //判断是否为1,不是1则返回error
return NJT_ERROR;
}
char length_byte = 0;
sniffer_get_data(1,(char *)&byte,1);
int Packet_bit = GET_BIT(byte,7); //取第2个字节从左数第8位
if (Packet_bit == 0) { //判断是否为0,为0根据mqtt协议,packet_length字段长度为1
length_byte = 1;
}
int len = byte; //mqtt协议中,如果packet_length为1,则第二个字节为mqtt包长度
short name_len = 0;
char name_len_buffer[100] = {0};
sniffer_get_data(2,(char *)&name_len,2); //取第3,4个字节
name_len = ntohs(name_len); //获取协议名长度
sniffer_get_data(2+sizeof(name_len),(char *)&name,name_len); //获取协议名
sniffer_get_data(2+sizeof(name_len)+name_len,(char *)&byte,1); //获取版本号
int version=byte;
sniffer_log(NJT_LOG_DEBUG,"sniffer protocol name=%s,version=%d,len=%d,name_len=%d",name,version,len,name_len);
return NJT_OK;
3. 测试
下面是一个完整的验证client发送的请求是必须携带PROXY_PROTOCOL,否则被拒绝的例子。
3.1配置文件
如下例,njet作为4层proxy,监听2223,并转发到后端的2224应用服务,打开sniff开关及sniff文件
helper broker modules/njt_helper_broker_module.so conf/mqtt.conf;
helper ctrl modules/njt_helper_ctrl_module.so conf/ctrl.conf;
user root;
worker_processes 1;
#daemon off;
worker_rlimit_nofile 655350;
cluster_name helper;
node_name node-u01;
error_log logs/error.log debug;
pid logs/njet.pid;
events {
worker_connections 1024;
}
stream {
server {
listen 2223;
sniffer on;
sniffer_filter_file "conf/sniffer.c";
proxy_pass 127.0.0.1:2224;
}
}
具体 sniffer.c 文件:
char v_2[100] = {0x0D,0x0A,0x0D,0x0A,0x00,0x0D,0x0A,0x51,0x55,0x49,0x54,0x0A};
char v_1[100] = {0x50,0x52,0x4f,0x58,0x59};
int ret = NJT_DECLINED;
if (bytes_len < 5) {
sniffer_log(NJT_LOG_DEBUG,"sniffer protocol agin=%d",bytes_len);
return NJT_AGAIN; //数据不够,等待数据。
}
ret = sniffer_get_hex_cmp(0,v_1,5);
if (ret == NJT_OK) {
sniffer_log(NJT_LOG_DEBUG,"sniffer protocol v1");
return ret; // 识别成功 protocol v1
}
ret = sniffer_get_hex_cmp(0,v_2,12);
if (ret == NJT_OK) {
sniffer_log(NJT_LOG_DEBUG,"sniffer protocol v2");
return ret; // 识别成功 protocol v2
} else if (ret == NJT_AGAIN) {
sniffer_log(NJT_LOG_DEBUG,"sniffer protocol agin=%d",bytes_len);
return NJT_AGAIN; //数据不够,等待数据。
}
return NJT_ERROR; //默认拦截。
3.1.1 用例一:protocol v1 版本
请求2221 端口的 protocol v1 版本。
[root@k8s-139 proxy2]# curl 127.0.0.1:2221/test
成功输出:
[root@k8s-139 proxy2]# curl 127.0.0.1:2221/test
src:127.0.0.1:56254,dst:127.0.0.1:2221,tlv01:,tlv02:
日志输出:
2024/05/09 18:40:33 [debug] 32699#0: sniffer protocol v1
3.1.2 用例二:protocol v2 版本
请求2222 端口的 protocol v2 版本。
[root@k8s-139 proxy2]# curl 127.0.0.1:2222/test
成功输出:
[root@k8s-139 tcc-0.9.26]# curl 127.0.0.1:2222/test
src:127.0.0.1:39046,dst:127.0.0.1:2222,tlv01:,tlv02:
日志输出:
2024/05/09 18:42:02 [debug] 32699#0: sniffer protocol v2
3.1.3 用例三:测试快速关闭
需要在另外一台机器上,telnet该端口,模拟非法访问
telnet 192.168.40.139 2223 端口。
[root@CDN156 ~]# telnet 192.168.40.139 2223
Trying 192.168.40.139...
Connected to 192.168.40.139.
Escape character is '^]'.
[root@CDN156 ~]# netstat -an |grep 2223
tcp 0 0 192.168.40.156:58192 192.168.40.139:2223 ESTABLISHED
当在telnet中输入随机字符时,会被NJet拦截 日志输出:
2024/05/09 18:58:39 [debug] 4688#0: sniffer protocol fail=54
查看网络连接:没有ESTABLISHED状态。表明快速关闭,成功。
[root@k8s-139 tcc-0.9.26]# netstat -an |grep 2223
tcp 0 0 0.0.0.0:2223 0.0.0.0:* LISTEN
4. 其它说明
- 只支持tcp的协议。
- 快速端口关闭及资源释放请参考 https://oroboro.com/dealing-with-network-port-abuse-in-sockets-in-c/
- 感谢TCC提供的JIT实现及C语言脚本支持
- Just In Time(JIT):是一种把脚本由解释执行预先编译成机器码,从而加速执行的技术。NJet使用的是Pre-JIT,即把所有的脚本代码都预先编译成类似so,从而被业务当作普通的库函数执行
- NJ动态协议识别需要NJet3.0及以上