背景

最近在弄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)定义如下:

  1. Auth: 认证连接。客户端连接上之后,首先要把自己的token发送到服务器,服务器通过配置文件获取到这个token所对应的端口,验证通过之后服务端开始监听对应的端口。(整个工作流程会在后面详细说明)
  2. Data: 数据转发包
  3. Msg: 文本消息包,收到这个指令就把数据字段转换成string显示到terminal
  4. Connect: 发送连接请求
  5. 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为系统简化模型。

图1
图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,使用DataUnpack方法就可以解释这个数据包。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就负责把addrconn绑定,所以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


安装与配置

  1. 安装go(略)
  2. go install github.com/juxuny/bridge/cmd/bridge-server
  3. 如果$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 ),还有很多优化的空间,但终究实现了穿透防火墙功能。

参考