使用netty实现socks5+trojan混合协议trojan客户端

Published on 2021-05-06 18:32 in 分类: 博客 with 狂盗一枝梅
分类: 博客

使用netty实现trojan协议客户端

一直想使用java实现trojan客户端,接触到netty之后感觉使用netty这个高性能的网络框架来实现非常合适。下面说一说实现过程中遇到的问题和解决方式。

现在主要的trojan客户端应该是v2ray,它是用C++写的,除此之外,使用java编写的客户端还没见到过。。所以一开始遇到的问题还是蛮大的,比如trojan协议看不懂的问题。

一、trojan协议

trojan的github地址:https://github.com/trojan-gfw/trojan

trojan官网:https://trojan-gfw.github.io/trojan/

trojan协议说明地址:https://trojan-gfw.github.io/trojan/protocol

根据trojan协议文档上的说明,实现trojan协议必须满足以下要求:

  1. 服务端必须是https,与客户端的通信必须使用tls加密,否则拒绝连接
  2. tls握手成功后,客户端应当发送以下格式的数据到服务端
    +-----------------------+---------+----------------+---------+----------+
    | hex(SHA224(password)) |  CRLF   | Trojan Request |  CRLF   | Payload  |
    +-----------------------+---------+----------------+---------+----------+
    |          56           | X'0D0A' |    Variable    | X'0D0A' | Variable |
    +-----------------------+---------+----------------+---------+----------+
    
    where Trojan Request is a SOCKS5-like request:
    
    +-----+------+----------+----------+
    | CMD | ATYP | DST.ADDR | DST.PORT |
    +-----+------+----------+----------+
    |  1  |  1   | Variable |    2     |
    +-----+------+----------+----------+
    
    where:
    
        o  CMD
            o  CONNECT X'01'
            o  UDP ASSOCIATE X'03'
        o  ATYP address type of following address
            o  IP V4 address: X'01'
            o  DOMAINNAME: X'03'
            o  IP V6 address: X'04'
        o  DST.ADDR desired destination address
        o  DST.PORT desired destination port in network octet order
    
  3. 它是一个类socks5请求的格式,socks5详情可以参考 https://tools.ietf.org/html/rfc1928

说实话,看完文档我是懵逼的,总觉得哪里不对又说不出来。最终我有了以下几个疑问

  1. tls连接之后要发送以下结构的内容
hex(SHA224(password)) CRLF Trojan Request CRLF Payload
56 X'0D0A' Variable X'0D0A' Variable

我认为这是trojan协议握手的请求,这个时候Payload字段有何作用? 难道说同一个连接中的每次数据请求都要将请求的数据放到Payload字段吗。。。 文档中表示发送完该内容后trojan服务端会打开一个连接目标服务器的通道,然后转发该连接中的所有请求到目标服务器,关键是这第一个请求没有响应客户端吗,socks5的connet请求是要响应客户端的吧,这里trojan协议握手是不是也有个响应才对,响应格式是啥

  1. Trojan Request 字段
CMD ATYP DST.ADDR DST.PORT
1 1 Variable 2

协议中 DST.ADDR是可变长字段,这个字段如果是Sockts5-like,是不是应该如果是ipv4,则固定4个字节长度;如果是域名类型,则第一个字节是域名长度,后面是域名内容?

我将这两个疑问在github上提了个issue,链接如下:https://github.com/trojan-gfw/trojan/issues/585 。根据相关开发描述,Payload字段是第一次请求的时候带上的真正的请求信息,响应内容就是目标服务器所响应的内容(言外之意就是无socks5那样的connection响应);至于DST.ADDR的规则则和socks5协议一样。

二、源码印证

为了验证对trojan协议的猜想,不得不翻看了些trojan服务端的源代码,项目地址:https://github.com/trojan-gfw/trojan

1.trojan客户端发起请求服务端基本校验

这段代码逻辑在 https://github.com/trojan-gfw/trojan/blob/3e7bb9aecdc694f9bcae8d646fae395f773d60f8/src/session/serversession.cpp#L135

image-20210506173539380

这段代码上来就对请求体进行了有效性验证,如果请求有效才对密码进行校验;如果请求体无效,则服务端会打印日志not trojan request, connecting to...

image-20210506173718519

所以说,校验逻辑是啥?校验的代码在这里:https://github.com/trojan-gfw/trojan/blob/master/src/proto/trojanrequest.cpp#L23

image-20210506174032277

这段代码做了如下事情:

  1. 客户端请求必须带有\r\n字符,否则判定无效
  2. 密码字段从第一个字符到\r\n所在的位置
  3. 跳过\r\n字符,剩下的内容中,取出来第一个字符作为CMD字段,紧接着对ATYP+DST.ADDR 地址字段进行了校验
  4. 如果地址无效或者剩余内容中无\r\n,则请求无效
  5. payload字段本来是trojan request+\r\n+payload,更改为真正的payload。

2.地址校验逻辑

校验的代码逻辑在这里:https://github.com/trojan-gfw/trojan/blob/master/src/proto/socks5address.cpp#L25

bool SOCKS5Address::parse(const string &data, size_t &address_len) {
    if (data.length() == 0 || (data[0] != IPv4 && data[0] != DOMAINNAME && data[0] != IPv6)) {
        return false;
    }
    address_type = static_cast<AddressType>(data[0]);
    switch (address_type) {
        case IPv4: {
            if (data.length() > 4 + 2) {
                address = to_string(uint8_t(data[1])) + '.' +
                    to_string(uint8_t(data[2])) + '.' +
                    to_string(uint8_t(data[3])) + '.' +
                    to_string(uint8_t(data[4]));
                port = (uint8_t(data[5]) << 8) | uint8_t(data[6]);
                address_len = 1 + 4 + 2;
                return true;
            }
            break;
        }
        case DOMAINNAME: {
            uint8_t domain_len = data[1];
            if (domain_len == 0) {
                // invalid domain len
                break;
            }
            if (data.length() > (unsigned int)(1 + domain_len + 2)) {
                address = data.substr(2, domain_len);
                port = (uint8_t(data[domain_len + 2]) << 8) | uint8_t(data[domain_len + 3]);
                address_len =  1 + 1 + domain_len + 2;
                return true;
            }
            break;
        }
        case IPv6: {
            if (data.length() > 16 + 2) {
                char t[40];
                sprintf(t, "%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x",
                        uint8_t(data[1]), uint8_t(data[2]), uint8_t(data[3]), uint8_t(data[4]),
                        uint8_t(data[5]), uint8_t(data[6]), uint8_t(data[7]), uint8_t(data[8]),
                        uint8_t(data[9]), uint8_t(data[10]), uint8_t(data[11]), uint8_t(data[12]),
                        uint8_t(data[13]), uint8_t(data[14]), uint8_t(data[15]), uint8_t(data[16]));
                address = t;
                port = (uint8_t(data[17]) << 8) | uint8_t(data[18]);
                address_len = 1 + 16 + 2;
                return true;
            }
            break;
        }
    }
    return false;
}

从这段代码可以看出,如果是ipv4地址类型,直接取四个字节作为ip地址;如果是域名类型,则取第一个字节作为域名长度,接下来的域名长度那么长的字节数作为域名内容;如果是IPv6类型,则取16个字节作为地址长度。无论是哪种地址类型,接下来都取两个字节作为端口号。

3.对payload的处理

https://github.com/trojan-gfw/trojan/blob/master/src/session/serversession.cpp#L169

if (valid) {
    out_write_buf = req.payload;
    if (req.command == TrojanRequest::UDP_ASSOCIATE) {
        Log::log_with_endpoint(in_endpoint, "requested UDP associate to " + req.address.address + ':' + to_string(req.address.port), Log::INFO);
        status = UDP_FORWARD;
        udp_data_buf = out_write_buf;
        udp_sent();
        return;
    } else {
        Log::log_with_endpoint(in_endpoint, "requested connection to " + req.address.address + ':' + to_string(req.address.port), Log::INFO);
    }
} else {
    Log::log_with_endpoint(in_endpoint, "not trojan request, connecting to " + query_addr + ':' + query_port, Log::WARN);
    out_write_buf = data;
}
sent_len += out_write_buf.length();
...省略部分代码...
    out_socket.async_connect(*iterator, [this, self, query_addr, query_port](const boost::system::error_code error) {
        if (error) {
            Log::log_with_endpoint(in_endpoint, "cannot establish connection to remote server " + query_addr + ':' + query_port + ": " + error.message(), Log::ERROR);
            destroy();
            return;
        }
        Log::log_with_endpoint(in_endpoint, "tunnel established");
        status = FORWARD;
        out_async_read();
        if (!out_write_buf.empty()) {
            out_async_write(out_write_buf);
        } else {
            in_async_read();
        }
    });

上述代码中,在请求有效的情况下,首先将payload赋值给了out_write_buf,然后连接目标服务器,将out_write_buf转发给目标服务器。当然这部分代码是在status == HANDSHAKE的情况下,做完这些事情之后,会更改status的状态为FORWARD,再次客户端再次发过来的数据,会被全部转发到目标服务器,不再走“握手”的过程。

image-20210506180724491

三、netty实现trojan客户端

实现trojan客户端要基于原来的socks5服务端实现,socks5接收客户端请求,然后使用trojan客户端转发到trojan服务端,由trojan服务端转发请求到真正的目标服务器。

原来的socks5服务端代码:https://github.com/kdyzm/trojan-client-netty/releases/tag/v3.0

设计上,还是要在socks5协议的第三阶段实现trojan客户端,只需要将真正的目标服务器替换成trojan服务端地址,然后按照协议请求格式请求即可。但是和原来的稍稍有所不同,因为socks5协议的connnet请求是要先响应客户端,客户端拿到响应之后才真正的发送请求数据;而trojan协议则是第一次请求直接将认证信息和请求的数据放到一起发送到trojan服务端,服务端验证没问题之后直接发送请求到目标服务器,不再单独为connet请求单独响应客户端,所以客户端要为每个请求单独维持一个状态。

处理器 作用
com.kdyzm.trojan.client.netty.inbound.TrojanDest2ClientInboundHandler 转发trojan服务端的响应数据到客户端,其逻辑和Dest2ClientInboundHandler逻辑相同
com.kdyzm.trojan.client.netty.inbound.TrojanClient2DestInboundHandler 转发客户端的请求到trojan服务端,因为第一次的请求不一样,所以维护了一个状态
com.kdyzm.trojan.client.netty.encoder.TrojanRequestEncoder 基于TrojanClient2DestInboundHandler,将对象转化为trojan协议所需的格式

四、源代码和参考文档

源代码:https://github.com/kdyzm/trojan-client-netty

参考文档:

https://trojan-gfw.github.io/trojan/protocol

https://github.com/trojan-gfw/trojan/issues/585

五、遇到的问题

换行符\r\n对应着协议中的X'0D0A',不能少,要分开写0X0D和0X0A;该程序要和Proxifier配合使用,如果出现了连接速度缓慢,有些网页打不开的情况,是因为Proxifier没设置好,一定要注意使用代理的dns设置,菜单:Profile->Name Resolution 取消Detect DNS settings automatically选项,勾选Resolve hostnames through proxy,之后就好了。


#netty #trojan
目录