自己動手開發Socks5代理伺服器
一、Socks5協議簡介
socks5是基於傳輸層的協議,客戶端和伺服器經過兩次握手協商之後服務端為客戶端建立一條到目標伺服器的通道,在傳輸層轉發TCP/UDP流量。
關於socks5協議規範,到處都可以找到,我再重複一遍也沒啥意思,因此不再贅述,可以參見rfc1928(英文),或者查閱維基百科SOCKS5 - 維基百科(中文)。
二、程式碼實現
基於socks5進行了一個代理伺服器的簡單實現,認證方式沒有做,客戶端和伺服器只是簡單的進行兩次握手即開始轉發資料。
package cc11001100.proxyServerDev.socks5; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.InetAddress; import java.net.ServerSocket; import java.net.Socket; import java.net.UnknownHostException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; /** * socks5代理伺服器簡單實現 * * <a>https://www.ietf.org/rfc/rfc1928.txt</a> * <p> * <p> * 使用socks5代理的坑,域名在本地解析還是在代理伺服器端解析,有些比如google.com就必須在代理伺服器端解析 * <a>https://blog.emacsos.com/use-socks5-proxy-in-curl.html</a> * * @author CC11001100 */ public class Socks5ProxyServer { // 服務監聽在哪個埠上 private static final Integer SERVICE_LISTENER_PORT = 10086; // 能夠允許的最大客戶端數量 private static final Integer MAX_CLIENT_NUM = 100; // 用於統計客戶端的數量 private static AtomicInteger clientNumCount = new AtomicInteger(); // socks協議的版本,固定為5 private static final byte VERSION = 0X05; // RSV,必須為0 private static final byte RSV = 0X00; private static String SERVER_IP_ADDRESS; static { try { SERVER_IP_ADDRESS = InetAddress.getLocalHost().getHostAddress(); } catch (UnknownHostException e) { e.printStackTrace(); } } public static class ClientHandler implements Runnable { private Socket clientSocket; private String clientIp; private int clientPort; public ClientHandler(Socket clientSocket) { this.clientSocket = clientSocket; this.clientIp = clientSocket.getInetAddress().getHostAddress(); this.clientPort = clientSocket.getPort(); } @Override public void run() { try { // 協商認證方法 negotiationCertificationMethod(); // 開始處理客戶端的命令 handleClientCommand(); } catch (Exception e) { handleLog("exception, " + e.getMessage()); } finally { close(clientSocket); handleLog("client dead, current client count=%s", clientNumCount.decrementAndGet()); } } // 協商與客戶端的認證方法 private void negotiationCertificationMethod() throws IOException { InputStream is = clientSocket.getInputStream(); OutputStream os = clientSocket.getOutputStream(); byte[] buff = new byte[255]; // 接收客戶端的支援的方法 is.read(buff, 0, 2); int version = buff[0]; int methodNum = buff[1]; if (version != VERSION) { throw new RuntimeException("version must 0X05"); } else if (methodNum < 1) { throw new RuntimeException("method num must gt 0"); } is.read(buff, 0, methodNum); List<METHOD> clientSupportMethodList = METHOD.convertToMethod(Arrays.copyOfRange(buff, 0, methodNum)); handleLog("version=%s, methodNum=%s, clientSupportMethodList=%s", version, methodNum, clientSupportMethodList); // 向客戶端傳送迴應,這裡不進行認證 buff[0] = VERSION; buff[1] = METHOD.NO_AUTHENTICATION_REQUIRED.rangeStart; os.write(buff, 0, 2); os.flush(); } // 認證通過,開始處理客戶端傳送過來的指令 private void handleClientCommand() throws IOException { InputStream is = clientSocket.getInputStream(); OutputStream os = clientSocket.getOutputStream(); byte[] buff = new byte[255]; // 接收客戶端命令 is.read(buff, 0, 4); int version = buff[0]; COMMAND command = COMMAND.convertToCmd(buff[1]); int rsv = buff[2]; ADDRESS_TYPE addressType = ADDRESS_TYPE.convertToAddressType(buff[3]); if (rsv != RSV) { throw new RuntimeException("RSV must 0X05"); } else if (version != VERSION) { throw new RuntimeException("VERSION must 0X05"); } else if (command == null) { // 不支援的命令 sendCommandResponse(COMMAND_STATUS.COMMAND_NOT_SUPPORTED); handleLog("not supported command"); return; } else if (addressType == null) { // 不支援的地址型別 sendCommandResponse(COMMAND_STATUS.ADDRESS_TYPE_NOT_SUPPORTED); handleLog("address type not supported"); return; } String targetAddress = ""; switch (addressType) { case DOMAIN: // 如果是域名的話第一個位元組表示域名的長度為n,緊接著n個位元組表示域名 is.read(buff, 0, 1); int domainLength = buff[0]; is.read(buff, 0, domainLength); targetAddress = new String(Arrays.copyOfRange(buff, 0, domainLength)); break; case IPV4: // 如果是ipv4的話使用固定的4個位元組表示地址 is.read(buff, 0, 4); targetAddress = ipAddressBytesToString(buff); break; case IPV6: throw new RuntimeException("not support ipv6."); } is.read(buff, 0, 2); int targetPort = ((buff[0] & 0XFF) << 8) | (buff[1] & 0XFF); StringBuilder msg = new StringBuilder(); msg.append("version=").append(version).append(", cmd=").append(command.name()) .append(", addressType=").append(addressType.name()) .append(", domain=").append(targetAddress).append(", port=").append(targetPort); handleLog(msg.toString()); // 響應客戶端傳送的命令,暫時只實現CONNECT命令 switch (command) { case CONNECT: handleConnectCommand(targetAddress, targetPort); case BIND: throw new RuntimeException("not support command BIND"); case UDP_ASSOCIATE: throw new RuntimeException("not support command UDP_ASSOCIATE"); } } // convert ip address from 4 byte to string private String ipAddressBytesToString(byte[] ipAddressBytes) { // first convert to int avoid negative return (ipAddressBytes[0] & 0XFF) + "." + (ipAddressBytes[1] & 0XFF) + "." + (ipAddressBytes[2] & 0XFF) + "." + (ipAddressBytes[3] & 0XFF); } // 處理CONNECT命令 private void handleConnectCommand(String targetAddress, int targetPort) throws IOException { Socket targetSocket = null; try { targetSocket = new Socket(targetAddress, targetPort); } catch (IOException e) { sendCommandResponse(COMMAND_STATUS.GENERAL_SOCKS_SERVER_FAILURE); return; } sendCommandResponse(COMMAND_STATUS.SUCCEEDED); new SocketForwarding(clientSocket, targetSocket).start(); } private void sendCommandResponse(COMMAND_STATUS commandStatus) throws IOException { OutputStream os = clientSocket.getOutputStream(); os.write(buildCommandResponse(commandStatus.rangeStart)); os.flush(); } private byte[] buildCommandResponse(byte commandStatusCode) { ByteBuffer payload = ByteBuffer.allocate(100); payload.put(VERSION); payload.put(commandStatusCode); payload.put(RSV); // payload.put(ADDRESS_TYPE.IPV4.value); // payload.put(SERVER_IP_ADDRESS.getBytes()); payload.put(ADDRESS_TYPE.DOMAIN.value); byte[] addressBytes = SERVER_IP_ADDRESS.getBytes(); payload.put((byte) addressBytes.length); payload.put(addressBytes); payload.put((byte) (((SERVICE_LISTENER_PORT & 0XFF00) >> 8))); payload.put((byte) (SERVICE_LISTENER_PORT & 0XFF)); byte[] payloadBytes = new byte[payload.position()]; payload.flip(); payload.get(payloadBytes); return payloadBytes; } private void handleLog(String format, Object... args) { log("handle, clientIp=" + clientIp + ", port=" + clientPort + ", " + format, args); } } // 用來連線客戶端和目標伺服器轉發流量 public static class SocketForwarding { // 客戶端socket private Socket clientSocket; private String clientIp; // 目標地址socket private Socket targetSocket; private String targetAddress; private int targetPort; public SocketForwarding(Socket clientSocket, Socket targetSocket) { this.clientSocket = clientSocket; this.clientIp = clientSocket.getInetAddress().getHostAddress(); this.targetSocket = targetSocket; this.targetAddress = targetSocket.getInetAddress().getHostAddress(); this.targetPort = targetSocket.getPort(); } public void start() { OutputStream clientOs = null; InputStream clientIs = null; InputStream targetIs = null; OutputStream targetOs = null; long start = System.currentTimeMillis(); try { clientOs = clientSocket.getOutputStream(); clientIs = clientSocket.getInputStream(); targetOs = targetSocket.getOutputStream(); targetIs = targetSocket.getInputStream(); // 512K,因為會有很多個執行緒同時申請buff空間,所以不要太大以以防OOM byte[] buff = new byte[1024 * 512]; while (true) { boolean needSleep = true; while (clientIs.available() != 0) { int n = clientIs.read(buff); targetOs.write(buff, 0, n); transientLog("client to remote, bytes=%d", n); needSleep = false; } while (targetIs.available() != 0) { int n = targetIs.read(buff); clientOs.write(buff, 0, n); transientLog("remote to client, bytes=%d", n); needSleep = false; } if (clientSocket.isClosed()) { transientLog("client closed"); break; } // 會話最多30秒超時,防止有人佔著執行緒老不釋放 if (System.currentTimeMillis() - start > 30_000) { transientLog("time out"); break; } // 如果本次迴圈沒有資料傳輸,說明管道現在不繁忙,應該休息一下把資源讓給別的執行緒 if (needSleep) { try { TimeUnit.MILLISECONDS.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } } } } catch (IOException e) { transientLog("conn exception" + e.getMessage()); } finally { close(clientIs); close(clientOs); close(targetIs); close(targetOs); close(clientSocket); close(targetSocket); } transientLog("done."); } private void transientLog(String format, Object... args) { log("forwarding, clientIp=" + clientIp + ", targetAddress=" + targetAddress + ", port=" + targetPort + ", " + format, args); } } // 客戶端認證方法 public static enum METHOD { NO_AUTHENTICATION_REQUIRED((byte) 0X00, (byte) 0X00, "NO AUTHENTICATION REQUIRED"), GSSAPI((byte) 0X01, (byte) 0X01, "GSSAPI"), USERNAME_PASSWORD((byte) 0X02, (byte) 0X02, " USERNAME/PASSWORD"), IANA_ASSIGNED((byte) 0X03, (byte) 0X07, "IANA ASSIGNED"), RESERVED_FOR_PRIVATE_METHODS((byte) 0X80, (byte) 0XFE, "RESERVED FOR PRIVATE METHODS"), NO_ACCEPTABLE_METHODS((byte) 0XFF, (byte) 0XFF, "NO ACCEPTABLE METHODS"); private byte rangeStart; private byte rangeEnd; private String description; METHOD(byte rangeStart, byte rangeEnd, String description) { this.rangeStart = rangeStart; this.rangeEnd = rangeEnd; this.description = description; } public boolean isMe(byte value) { return value >= rangeStart && value <= rangeEnd; } public static List<METHOD> convertToMethod(byte[] methodValues) { List<METHOD> methodList = new ArrayList<>(); for (byte b : methodValues) { for (METHOD method : METHOD.values()) { if (method.isMe(b)) { methodList.add(method); break; } } } return methodList; } } // 客戶端命令 public static enum COMMAND { CONNECT((byte) 0X01, "CONNECT"), BIND((byte) 0X02, "BIND"), UDP_ASSOCIATE((byte) 0X03, "UDP ASSOCIATE"); byte value; String description; COMMAND(byte value, String description) { this.value = value; this.description = description; } public static COMMAND convertToCmd(byte value) { for (COMMAND cmd : COMMAND.values()) { if (cmd.value == value) { return cmd; } } return null; } } // 要請求的地址型別 public static enum ADDRESS_TYPE { IPV4((byte) 0X01, "the address is a version-4 IP address, with a length of 4 octets"), DOMAIN((byte) 0X03, "the address field contains a fully-qualified domain name. The first\n" + " octet of the address field contains the number of octets of name that\n" + " follow, there is no terminating NUL octet."), IPV6((byte) 0X04, "the address is a version-6 IP address, with a length of 16 octets."); byte value; String description; ADDRESS_TYPE(byte value, String description) { this.value = value; this.description = description; } public static ADDRESS_TYPE convertToAddressType(byte value) { for (ADDRESS_TYPE addressType : ADDRESS_TYPE.values()) { if (addressType.value == value) { return addressType; } } return null; } } // 對於命令的處理結果 public static enum COMMAND_STATUS { SUCCEEDED((byte) 0X00, (byte) 0X00, "succeeded"), GENERAL_SOCKS_SERVER_FAILURE((byte) 0X01, (byte) 0X01, "general SOCKS server failure"), CONNECTION_NOT_ALLOWED_BY_RULESET((byte) 0X02, (byte) 0X02, "connection not allowed by ruleset"), NETWORK_UNREACHABLE((byte) 0X03, (byte) 0X03, "Network unreachable"), HOST_UNREACHABLE((byte) 0X04, (byte) 0X04, "Host unreachable"), CONNECTION_REFUSED((byte) 0X05, (byte) 0X05, "Connection refused"), TTL_EXPIRED((byte) 0X06, (byte) 0X06, "TTL expired"), COMMAND_NOT_SUPPORTED((byte) 0X07, (byte) 0X07, "Command not supported"), ADDRESS_TYPE_NOT_SUPPORTED((byte) 0X08, (byte) 0X08, "Address type not supported"), UNASSIGNED((byte) 0X09, (byte) 0XFF, "unassigned"); private byte rangeStart; private byte rangeEnd; private String description; COMMAND_STATUS(byte rangeStart, byte rangeEnd, String description) { this.rangeStart = rangeStart; this.rangeEnd = rangeEnd; this.description = description; } } private synchronized static void log(String format, Object... args) { System.out.println(String.format(format, args)); } private static void close(Closeable closeable) { if (closeable != null) { try { closeable.close(); } catch (IOException e) { e.printStackTrace(); } } } public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(SERVICE_LISTENER_PORT); while (true) { Socket socket = serverSocket.accept(); if (clientNumCount.get() >= MAX_CLIENT_NUM) { log("client num run out."); continue; } log("new client, ip=%s:%d, current client count=%s", socket.getInetAddress(), socket.getPort(), clientNumCount.get()); clientNumCount.incrementAndGet(); new Thread(new ClientHandler(socket), "client-handler-" + UUID.randomUUID().toString()).start(); } } }
驗證一下開發的這個代理伺服器究竟能不能用呢?
這裡使用一臺國外撥號主機做實驗,將自己寫的代理伺服器部署上去,新建一個Java檔案將上面的程式碼貼上進去刪除第一行的package資訊然後編譯執行:
javac Socks5ProxyServer.java java Socks5ProxyServer
在Chrome瀏覽器的SwitchyOmega中新增一個情景模式,配置剛剛啟動的socks5代理:
然後使用這個情景模式開啟百度,檢視自己當前的ip:
檢視啟動的伺服器控制檯列印資訊:
三、使用socks5需要注意的坑
在客戶端訪問域名的時候,涉及到一個問題,這個域名是應該是客戶端解析完告訴代理伺服器ip還是應該把域名交給代理伺服器去解析?
一般客戶端的預設行為是域名在客戶端解析,然後再將解析出來的ip拿給代理伺服器去處理,但是對於一些網站來說通過ip訪問是不成功的,比如google的dns解析,國內機器解析到的ip可能已經被汙染,筆者實驗發現對於在阿里雲伺服器訪問google解析到的ip 74.86.151.162:443,通過ip訪問是不能成功的,而在微軟雲機器上解析到的ip 172.217.161.164:443,通過ip可以訪問成功,將域名交給代理伺服器,代理伺服器在美國,解析到的ip是能夠訪問成功的,所以在使用socks5的時候最好能夠指明域名是在本地解析還是在代理伺服器解析:
預設是在本地解析完將ip傳給代理伺服器:
curl --socks5 "23.225.xxx.xxx:10086" https://www.google.com
通過socks5-hostname指定域名交給代理伺服器解析
curl --socks5-hostname "23.225.xxx.xxx:10086" "https://www.google.com"
curl中關於這部分的說明:
--socks5-hostname <host[:port]> Use the specified SOCKS5 proxy (and let the proxy resolve the host name). If the port number is not specified, it is assumed at port 1080. (Added in 7.18.0) This option overrides any previous use of -x, --proxy, as they are mutually exclusive. Since 7.21.7, this option is superfluous since you can specify a socks5 hostname proxy with -x, --proxy using a socks5h:// protocol prefix. If this option is used several times, the last one will be used. (This option was previously wrongly documented and used as --socks without the number appended.) --socks5 <host[:port]> Use the specified SOCKS5 proxy - but resolve the host name locally. If the port number is not specified, it is assumed at port 1080. This option overrides any previous use of -x, --proxy, as they are mutually exclusive. Since 7.21.7, this option is superfluous since you can specify a socks5 proxy with -x, --proxy using a socks5:// protocol prefix. If this option is used several times, the last one will be used. (This option was previously wrongly documented and used as --socks without the number appended.) This option (as well as --socks4) does not work with IPV6, FTPS or LDAP.
Chrome瀏覽器的SwitchyOmega的socks5代理是會將域名傳給代理伺服器解析。
相關資料:
2. rfc1928
.