使用Java NIO 简单实现socks5代理服务

使用场景

当A端无法直接访问C端网站,而B端可以访问C端网站,同时A端又可以访问B端网站时,我们可以在B端部署一个socks5代理服务。这样,A端就能够通过B端的代理服务,间接地访问C端网站。这种方法有效地解决了A端无法直接访问C端网站的问题。

socks5协议

sql 复制代码
1.客户端连接请求(客户端发送到服务器):

    客户端发送的第一个数据包,用于协商版本和认证方式。
    数据包格式:
    +----+----------+----------+
    | VER| NMETHODS | METHODS  |
    +----+----------+----------+
    | 1  |    1     | 1 to 255 |
    +----+----------+----------+
    - VER:SOCKS版本,当前为 5。
    - NMETHODS:认证方法数量。
    - METHODS:认证方法列表,每个字节代表一种认证方法,可选值包括 0x00(无认证)和其他认证方法。

2.服务器回应版本协商(服务器发送到客户端):

    服务器回应客户端的版本协商请求,选择一个认证方法。
    数据包格式:

    +----+--------+
    | VER| METHOD |
    +----+--------+
    | 1  |   1    |
    +----+--------+
    - VER:SOCKS版本,当前为 5。
    - METHOD:选择的认证方法,如果为 0xFF 表示无可接受的方法。
    3.客户端向服务器发送认证信息(客户端发送到服务器):

    客户端根据服务器选择的认证方法,向服务器发送认证信息。
    数据包格式:

    +----+------+----------+
    | VER| ULEN |  UNAME   |
    +----+------+----------+
    | 1  |  1   | 1 to 255 |
    +----+------+----------+
    - VER:SOCKS版本,当前为 5。
    - ULEN:用户名的长度。
    - UNAME:用户名,长度由 ULEN 指定。
4.服务器回应认证结果(服务器发送到客户端):

    服务器根据客户端发送的认证信息进行验证,并回应认证结果。
    数据包格式:

    +----+--------+
    | VER| STATUS |
    +----+--------+
    | 1  |   1    |
    +----+--------+
    - VER:SOCKS版本,当前为 5。
    - STATUS:认证结果,0 表示成功,其他值表示失败。
    
5.建立连接请求(客户端发送到服务器):

    客户端发送建立连接请求,请求与目标服务器建立连接。
    数据包格式:

    +----+-----+-------+------+----------+----------+
    | VER| CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
    +----+-----+-------+------+----------+----------+
    | 1  |  1  | X'00' |  1   | Variable |    2     |
    +----+-----+-------+------+----------+----------+
    - VER:SOCKS版本,当前为 5。
    - CMD:命令,1 表示连接,2 表示绑定。
    - RSV:保留字段,必须为 0x00。
    - ATYP:地址类型,1 表示 IPv4 地址,3 表示域名,4 表示 IPv6 地址。
    - DST.ADDR:目标地址,长度和类型由 ATYP 指定。
    - DST.PORT:目标端口,2 个字节表示。
    
6.服务器回应建立连接结果(服务器发送到客户端):

    服务器根据客户端的连接请求,向客户端发送建立连接的结果。
    数据包格式:

    +----+-----+-------+------+----------+----------+
    | VER| REP |  RSV  | ATYP | BND.ADDR | BND.PORT |
    +----+-----+-------+------+----------+----------+
    | 1  |  1  | X'00' |  1   | Variable |    2     |
    +----+-----+-------+------+----------+----------+
    - VER:SOCKS版本,当前为 5。
    - REP:应答,0 表示成功,其他值表示失败。
    - RSV:保留字段,必须为 0x00。
    - ATYP:地址类型,1 表示 IPv4 地址,3 表示域名,4 表示 IPv6 地址。
    - BND.ADDR:绑定地址,长度和类型由 ATYP 指定。
    - BND.PORT:绑定端口,2 个字节表示。

个人理解

socks5协议比较简单,就是客户端提供socks版本和支持认证方式,然后服务端选择socks版本和认证方式,客户端就按照认证方式提供数据发送给服务端,服务端就开始认证数据。客户端收到验证成功,就会发送ip和端口等信息给服务端,最后就是客户端和服务端交换数据。

NIO 和 Selector

Java NIO(New I/O,即新输入/输出)是Java提供的一种基于通道(Channel)和缓冲区(Buffer)的I/O操作方式,与传统的输入/输出流相比,Java NIO 提供了更灵活、更高效的I/O操作方式。

我这里使用java NIO(零拷贝),按照正常逻辑在window系统拿到数据得先复制到java虚拟机上,java应用才能操作,NIO就少了复制到java虚拟机上操作(少了中间商),这样就减少数据在内核态和用户态之间进行频繁的拷贝。在配合使用Selector通过选择器来实现单线程同时处理多个通道的 I/O 操作,正常一个socket有读和写,读是得用一个线程去死循环读数据,如果不使用Selector那不是每个socket都得创建一个读线程(也可以自己使用线程池)。

TCP服务端 ServerSocketChannel类,注册事件:SelectionKey.OP_ACCEPT(接受连接事件) -> SelectionKey.OP_READ(读取数据事件)

TCP客户端 ServerSocketChannel类,注册事件:SelectionKey.OP_CONNECT(连接事件) -> SelectionKey.OP_READ(读取数据事件)

使用Selector得设置Channel.configureBlocking(false)(设置通道非阻塞),selector.select()阻塞获取事件,isAcceptable有客户端连接服务端触发,isConnectable()客户端连接触发,key.isReadable()channel通道有数据触发。

ByteBuffer支持读和写操作,channel.read(byteBuffer) 是将数据写入byteBuffer中,得调用ByteBuffer.flip()来切换读模式,因为数据写入byteBuffer游标跟着移动到后面,你想读取之前写入的数据那不是得把游标初始为0后读取数据。

具体代码

当我们访问一个网站,通过我们的 SOCKS5 代理服务,大概流程:

  1. 首先,客户端与 SOCKS5 代理服务器建立连接。代理服务器监听来自客户端的连接请求,并在收到请求时执行 key.isAcceptable() 中的方法。在这一步,代理服务器会接受客户端的连接,并将客户端的 channel 注册为读事件到 selector,并传递标志 1。
  2. 客户端连接成功后,会向代理服务器发送数据。此时,代理服务器执行 key.isReadable() 中的方法,初始传递的标志为 1。这时执行 negotiatedConnection 方法,用于协商连接。协商完成后,标志被设置为 2。
  3. 接着,客户端发送要连接的目标服务器的 IP 地址和端口数据包给代理服务器。代理服务器再次执行 key.isReadable() 中的方法,此时标志为 2。执行 connectServer 方法,解析目标服务器的 IP 地址和端口,尝试与目标服务器建立连接。
  4. 由于channel是设置为非阻塞的,连接过程需要注册到 selector。这时代理服务器会执行 key.isConnectable() 中的方法,如果没有异常,说明连接成功建立。此时,双方的channel都注册到 selector,并且 attach 参数是对方的channel
  5. 最后,双方通道有数据时,会写入对方的通道中,完成数据交换和传输。

你可以debug的一下断点,看看代码执行流程,不复杂。

scss 复制代码
public static void main(String[] args) throws IOException {

    //创建一个tcp服务端
    int port = 1080;
    Selector selector = Selector.open();
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.bind(new InetSocketAddress(port));
    serverSocketChannel.configureBlocking(false);
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

    System.out.println("tcp服务端启动成功,端口:"+port);

    while (true) {
        selector.select();
        for (SelectionKey key : selector.selectedKeys()) {
            try{
                if (key.isAcceptable()) {
                    //服务端接收客户端请求
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    socketChannel.configureBlocking(false);
                    socketChannel.register(selector, SelectionKey.OP_READ,1);
                } else if(key.isConnectable()){
                    //本地连接
                    SocketChannel socketChannel = (SocketChannel) key.channel();
                    Object[] attachment = (Object[])key.attachment();
                    SocketChannel clientChannel = (SocketChannel)attachment[0];
                    byte[] replyData = (byte[])attachment[1];
                    try {
                        // 完成连接[这里会出现超时异常]
                        socketChannel.finishConnect();
                        //两个channel交换数据
                        socketChannel.register(selector,SelectionKey.OP_READ,clientChannel);
                        //重新注册事件
                        clientChannel.register(selector, SelectionKey.OP_READ,socketChannel);
                        //告诉客户端连接成功
                        clientChannel.write(ByteBuffer.wrap(replyData));

                    } catch (Exception ex) {
                        ex.printStackTrace();
                        socketChannel.close();
                        clientChannel.close();
                    }
                }else if (key.isReadable()) {
                    SocketChannel socketChannel = (SocketChannel) key.channel();
                    Object attachment = key.attachment();
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    try{
                        int bytesRead = socketChannel.read(buffer);
                        if (bytesRead == -1) {
                            socketChannel.close();
                        } else if (bytesRead > 0) {
                            buffer.flip();

                            if(attachment instanceof Integer){
                                //协议和认证确定,然后连接

                                int i = (Integer) attachment;
                                if(i == 1){
                                    //第一步 协商连接
                                    negotiatedConnection(socketChannel,buffer);
                                    key.attach(2);
                                }else if(i == 2){
                                    //第二步 连接客户端发送的ip和端口
                                    connectServer(selector,socketChannel,buffer);
                                    key.attach(3);
                                }

                            }else {
                                //一个channel写入另一个channel
                                SocketChannel clientChannel =  (SocketChannel) attachment;
                                if(clientChannel.isConnected()){
                                    clientChannel.write(buffer);
                                }

                            }

                        }
                    }catch (Exception e){

                        e.printStackTrace();
                        socketChannel.close();

                    }

                }
            }catch (Exception e){
                e.printStackTrace();
            }
        }
        selector.selectedKeys().clear();
    }
}

connectServer 方法,判断socks版本和是否支持不验证。

ini 复制代码
private static void negotiatedConnection(SocketChannel socketChannel,ByteBuffer buffer) throws IOException {
    //SOCKS版本
    byte version = buffer.get();
    //认证方法数量
    byte nmethods = buffer.get();
    //认证方法
    boolean noAuth = false;
    byte[] methods = new byte[nmethods];
    for (byte b = 0; b < nmethods; b++) {
        methods[b] = buffer.get();
        //无需验证
        if(methods[b] == 0){
            noAuth = true;
        }
    }

    //协议是socks5,支持无需要验证
    if(version == 5 && noAuth){
        //回复客户端,版本5,无需验证
        byte[] reply = {5,0};
        socketChannel.write(ByteBuffer.wrap(reply));
    }else{
        //不支持,关闭连接
        socketChannel.close();
    }
}

connectServer 方法,解析出ip和端口并且创建客户端连接,然后把连接事件注册到select

ini 复制代码
private static void connectServer(Selector selector,SocketChannel socketChannel, ByteBuffer buffer) throws IOException {
    //SOCKS版本
    byte version = buffer.get();
    //CMD:命令,1 表示连接,2 表示绑定。
    byte cmd = buffer.get();
    //保留字段,必须为 0x00。
    byte rsv = buffer.get();
    //地址类型,1 表示 IPv4 地址,3 表示域名,4 表示 IPv6 地址
    byte atyp = buffer.get();
    String host = "";
    if(atyp == 1){
        //ipv4
        byte[] bt = new byte[4];
        buffer.get(bt);
        host = InetAddress.getByAddress(bt).getHostAddress();

    }else if(atyp == 3){
        //域名
        byte len = buffer.get();
        byte[] bt = new byte[len];
        buffer.get(bt);
        host = new String(bt);

    }else if(atyp == 4){
        //ipv6
        byte[] bt = new byte[16];
        buffer.get(bt);
        host = InetAddress.getByAddress(bt).getHostAddress();
    }

   int port = Short.toUnsignedInt(buffer.getShort());

    System.out.println("host:"+host+",port:"+port);

    InetSocketAddress address = new InetSocketAddress(host, port);
    SocketChannel clientChannel = SocketChannel.open();
    clientChannel.configureBlocking(false);
    clientChannel.connect(address);

    byte[] replyData = Arrays.copyOf(buffer.array(), buffer.position());
    //应答成功
    replyData[1] = 0;
    Object[] array = new Object[2];
    array[0] = socketChannel;
    array[1] = replyData;
    clientChannel.register(selector,SelectionKey.OP_CONNECT,array);

}

测试

先简单使用curl设置SOCKS5代理,在cmd下执行下面代码:

perl 复制代码
curl -x socks5://127.0.0.1:1080 http://www.baidu.com

看看结果没啥问题。

最后我们用浏览器去测试,直接使用火狐浏览器,他直接可以设置socks代理。其他浏览器得装插件,麻烦点在windows下设置代理脚本。

下面是在火狐浏览器设置socks5代理,打开火狐浏览器到设置页面

我测试了访问b站和百度没啥问题。

总结

你会发现Selector是用一个线程在跑,按理来说读到数据应该用多线程去跑,但是发现读到数据后面操作都不会阻塞线程,如果加多线程又会导致数据乱序写入channel,得用Future等待上一次数据写完才能写,那不如多开几个线程执行Selector给事件注册,或者使用AIO编写代码。netty框架有现成得,主要是自己实现。

代码: Socks5Server-01 · 断续/learn-demo - 码云 - 开源中国 (gitee.com)

相关推荐
DARLING Zero two♡7 分钟前
【优选算法】Sliding-Chakra:滑动窗口的算法流(上)
java·开发语言·数据结构·c++·算法
love静思冥想12 分钟前
Apache Commons ThreadUtils 的使用与优化
java·线程池优化
君败红颜13 分钟前
Apache Commons Pool2—Java对象池的利器
java·开发语言·apache
意疏22 分钟前
JDK动态代理、Cglib动态代理及Spring AOP
java·开发语言·spring
小王努力学编程24 分钟前
【C++篇】AVL树的实现
java·开发语言·c++
找了一圈尾巴35 分钟前
Wend看源码-Java-集合学习(List)
java·学习
逊嘘1 小时前
【Java数据结构】链表相关的算法
java·数据结构·链表
爱编程的小新☆1 小时前
不良人系列-复兴数据结构(二叉树)
java·数据结构·学习·二叉树
m0_748247801 小时前
SpringBoot集成Flowable
java·spring boot·后端
小娄写码1 小时前
线程池原理
java·开发语言·jvm