背景
最近在弄Tensorflow,想自己找开源项目自己跑一下模型实现目标检测,但是没有钱去租服务器的,只好自己买个显卡装到家里电脑来训练。在跑模型的时候,需要访问tensorboard看看当前的状态。由于家里是动态IP上网的,在外面无法直接访问到家里的tensorboard服务,于是我用Golang实现了一个TCP代理工具(项目名:bridge),把家里tensorboard服务端口的数据转发到服务器上。
完整项目代码可以在 https://github.com/juxuny/bridge 里找到
自定义通信协议
- 开始标记或结束标记:FlagStart=0xE0
- 转义标记:FlagEsc = 0xF0
- 转义后的开始(结束)标记:FlagEscStart = 0xEF
- 转义后的转义标记:FlagEscEsc = 0xFF
协议定义如下:
[ 开始标记(1) | 指令(2) | 数据长度(4) | 数据(n) | 结束标记(1) ]
Note:其中小括号后面表示字节数
数据默认都采用大端模式
来自百度百科:
下面以unsigned int value = 0x12345678为例,分别看看在两种字节序下其存储情况,我们可以用unsigned char buf[4]来表示value
Big-Endian: 低地址存放高位,如下:
高地址
---------------
buf[3] (0x78) -- 低位
buf[2] (0x56)
buf[1] (0x34)
buf[0] (0x12) -- 高位
---------------
协议指令(CMD)定义如下:
- Auth: 认证连接。客户端连接上之后,首先要把自己的token发送到服务器,服务器通过配置文件获取到这个token所对应的端口,验证通过之后服务端开始监听对应的端口。(整个工作流程会在后面详细说明)
- Data: 数据转发包
- Msg: 文本消息包,收到这个指令就把数据字段转换成string显示到terminal
- Connect: 发送连接请求
- Close: 断开连接
以发送认证包为例子:
- cmd=1: [0x00 0x01]
- token: [0x0A 0x0A 0x0A 0x0A]
- length = 4: [0x00 0x00 0x00 0x04]
所以最后打包发送的字节数组为:
[0xE0 0x00 0x01 0x00 0x00 0x00 0x04 0x0A 0x0A 0x0A 0x0A 0xE0]
接收端是按先后顺序读取的,遇到第一个0xE0之前,都是无效数据,可以直接过滤掉。通过循环把主要部分截取出来:0x00 0x01 0x00 0x00 0x00 0x04 0x0A 0x0A 0x0A 0x0A。根据前面的协议定义,前面两个字节是cmd,后面4个字节是length,剩下的就是数据下文部分,即[0x0A 0x0A 0x0A 0x0A]就是下文。
另外,前面还提到了一个转义字符,主要用来解决起始标记冲突问题。下面举另一个例子:
token 改为 [0x0A 0x0A 0xE0 0x0A]
其它字段都不变,按协议打包之后得到以下字节数组:
[0xE0 0x00 0x01 0x00 0x00 0x00 0x04 0x0A 0x0A 0xE0 0x0A 0xE0]
接收端读取数据的时候,读到第一个 '0xE0' 开始解释数据,第二个 '0xE0' 就结束,则截取到下面这个数组:
[0xE0 0x00 0x01 0x00 0x00 0x00 0x04 0x0A 0x0A 0xE0]
缓冲里还会剩下 [0x0A 0xE0]
按照截取到的数组解释得到token = [0x0A 0x0A],而length明明是4,所以解释就错误了。或者你又可能说直接根据length来读取指定长度的数据就好,但是如果length刚好也是 0xE0呢?数据包就是这样子的:[0xE0 0x00 0x01 0x00 0x00 0x00 0xE0 0x0A ...... 0x0A 0xE0 0x0A 0xE0]。
截取出来的数据是这样子的:[0x00 0x01 0x00 0x00 0x00 ](还是解释错误)
所以这里就需要用转义字符,把特殊符号替换掉。
- 0xE0 => 0xF0 0xEF
- 0xF0 => 0xF0 0xFF
回到上面说的例子,token = [0x0A 0x0A 0xE0 0x0A],打包出来的数据应该是:
[0xE0 0x00 0x01 0x00 0x00 0x00 0x04 0x0A 0x0A 0xF0 0xEF 0x0A 0xE0]
接收端就遇到0xF0字符的时候就按下面规则转换:
- 0xF0 0xEF => 0xE0
- 0xF0 0xFF => 0xF0
这就可以解释出正确的数据包:[0x00 0x01 0x00 0x00 0x00 0x04 0x0A 0x0A 0xE0 0x0A]
整体设计
主要组成部分分别为:
bridge-server:
- slave manager: bridge-client也叫做slave,这里为了区分好bridge-client 和 web server,所以代码里就写成了slaveManager
- connection manager:
- listener manager:监听客户端连接,所有connection都交给connection manager处理
- token manager:根据bridge-client提交的token获取对应的
- main listener:默认监听9090等待bridge-client连接,连接成功之后把connection交给connection manager管理
bridge-client:
- connection manager
- main connection
下面用个图片来展示一下各组件之间如何工作的,其中图2为系统简化模型。
具体实现
上面已经提到了共有5类数据包发送,分别为:CmdAuth, CmdData, CmdMsg, CmdConnect, CmdClose。不同的指令,对应的Data部分也是有不同含意的:
CmdAuth(请求认证):
字段:
- token: 传给bridge-server然后通过配置文件获取要代理的端口
假设token为[0x0A 0x0A 0x0A 0x0A],要发送的数据为应该:
[0xE0 0x00 0x01 0x00 0x00 0x00 0x04 0x0A 0x0A 0x0A 0x0A 0xE0]
CmdData(数据包):
字段:
- fromAddress: 客户端地址(IP+port),8字节
- toAddress: 目标地址(保留),8字节
- data: 二进制数据,根据具体数据长度定义字节数
由于toAddress字段保留,这里可以直接传 [0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00],表示地址 0.0.0.0:0
例如:[0xE0 0x00 0x02 0x00 0x00 0x00 0x14 0x0A 0x00 0x00 0x01 0x00 0x00 0x03 0xE8 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x01 0x02 0x03 0x04 0xE0]
解包得到:
cmd=2, length = 20, fromAddress = 10.0.0.1:1000, toAddress = 0.0.0.0:0, data = [0x01 0x02 0x03 0x04]
CmdMsg(消息提示):
字段:
- msg: 消息文本,(n字节长)
例如:[0xE0 0x00 0x03 0x00 0x00 0x00 0x07 0x73 0x75 0x63 0x63 0x65 0x73 0x73 0xE0]
解包得到:
cmd = 3, length = 7, msg = [0x73 0x75 0x63 0x63 0x65 0x73 0x73],转换为string类型得到文本:"success"
CmdConnect(创建连接):
- fromAddress: 客户端地址(IP+port),8字节
例如:[0xE0 0x00 0x04 0x00 0x00 0x00 0x08 0x0A 0x00 0x00 0x01 0x00 0x00 0x03 0xE8 0xE0]
CmdClose(关闭连接):
- fromAddress: 客户端地址(IP+port),8字节
例如:[0xE0 0x00 0x05 0x00 0x00 0x00 0x08 0x0A 0x00 0x00 0x01 0x00 0x00 0x03 0xE8 0xE0]
指令常量定义如下:
为了更方便地解释数据包,并且一个数据包一个数据读取,特意定义了一个DataReader处理bridge-server 与 bridge-client 之间的TCP通信。
conn 是连接bridge-server的TCP连接
buffer表示网络数据缓存,为空的时候就从connection里读一块数据
每次读取数据包的时候startCount都重新计数,大于等于2的时候说明已经读到了第二个开始标记了,表示已经获取了一个完整的数据包到retBuf,使用Data的Unpack方法就可以解释这个数据包。Data结构定义如下:
由于在代理的时间,浏览器可能会创建多个连接到bridge-server,最后bridge-client也会创建相同数量的连接到指定的Web Server。为了更方便管理各个连接,这里创建了ConnManager:
实质上,ConnManager就是给远程地址和TCP socket做了一个映射,让bridge-server或者bridge-client可以通过远程地址查询对应的socket。当浏览器连接到bridge-sever的时候,假设连接地址是10.0.0.1:50001,bridge-sever可以通过下面代码获取到对应的地址:
ConnManager就负责把addr与conn绑定,所以bridge-server可以通过ConnManager给指定客户返回数据,bridge-client也可以通过ConnManager把数据转发给最终的Web Server。
bridge-server为每个bridge-client创建了一个listener,用于接收Browser连接,每个token对应一个port,listenerManager把port与listener绑定,bridge-client断开连接的时候,可以通过对应的port清理listener,例如调用Remove方法。listenerManager定义如下:
另外还需要一个slaveManager管理所有bridge-client:
其中sendMsg, sendConnect, sendClose, WriteData等方法用于对指定的bridge-client发送不同的数据包。因为每个bridge-client都会有一个特定的token,一个token和bridge-server 监听的端口唯一对应,所以slaveManager可以通过port参数找到对应的bridge-client,然后进行发数据。
最后bridge-server通过一个struct, Server,整合各个manager struct,包括:ConnManager, listenerManager, TokenManager(这个可以参考源码里的定义),slaveManager:
由于篇幅太长,Server struct的源码只能到github上看了,点这里:server.go
安装与配置
- 安装go(略)
- go install github.com/juxuny/bridge/cmd/bridge-server
- 如果$GOPATH/bin已经加到了PATH变量里的话,可以直接执行bridge-server和bridge-client命令了,否则还需要:
假设外网服务器IP为10.0.0.1,bridge-server监听端口是9090,家里外网的动态IP为10.0.0.x,家里运行的bridge-client的设备是192.168.0.1,内部提供Web 服务的设备是192.168.0.2,网页端口是80。现在要将10.0.0.1:10001的数据转发到家里内部网络的192.168.0.2:8888上。
server.json:
token.conf:
client.json:
Note:由于每次传输都执行aes加密解密很影响性能,后来把加密代码去掉了
在10.0.0.1服务器上执行:
在192.168.0.1设备上执行:
性能测试
自定义Web Server,直接获取某个json数据,浏览器访问得到如下结果:
根据上面的安装配置,8888端口表示源服务,10001端口是bridge-server,使用ab压力测试得到如下结果:
(1)测试100次请求,执行命令:
输出结果:
通过代理访问:
输出结果:
由上面的压力测试结果可以得出,不使用代理性能比使用代理快17倍有多( 0.931 / 0.053 \approx 17.566 ),还有很多优化的空间,但终究实现了穿透防火墙功能。