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