diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 23d7f59e..c38e074a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -44,6 +44,11 @@ jobs:
distribution: temurin
cache: maven
+ - name: Set up Docker
+ uses: docker/setup-docker-action@v4
+ with:
+ set-host: true
+
- name: Build with Maven
shell: bash
run: |
diff --git a/pom.xml b/pom.xml
index e033b92f..434faebc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -685,6 +685,13 @@
0.16
test
+
+ org.testcontainers
+ testcontainers
+ 1.20.4
+ test
+
+
diff --git a/src/main/java/org/xbill/DNS/NioSocks5ProxyConfig.java b/src/main/java/org/xbill/DNS/NioSocks5ProxyConfig.java
new file mode 100644
index 00000000..e1e24c9a
--- /dev/null
+++ b/src/main/java/org/xbill/DNS/NioSocks5ProxyConfig.java
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.xbill.DNS;
+
+import java.net.InetSocketAddress;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class NioSocks5ProxyConfig {
+ private InetSocketAddress proxyAddress;
+ private AuthMethod authMethod;
+ private String socks5User;
+ private String socks5Password;
+
+ public enum AuthMethod {
+ NONE,
+ GSSAPI,
+ USER_PASS
+ }
+
+ public NioSocks5ProxyConfig(InetSocketAddress proxyAddress) {
+ this(proxyAddress, null, null);
+ authMethod = AuthMethod.NONE;
+ }
+
+ public NioSocks5ProxyConfig(
+ InetSocketAddress proxyAddress, String socks5User, String socks5Password) {
+ this.proxyAddress = proxyAddress;
+ this.socks5User = socks5User;
+ this.socks5Password = socks5Password;
+ authMethod = AuthMethod.USER_PASS;
+ }
+
+ // public Socks5ProxyConfig(InetSocketAddress proxyAddress, GSSCredential gssCredential) {
+ // this(proxyAddress, null, null, gssCredential);
+ // }
+}
diff --git a/src/main/java/org/xbill/DNS/NioSocks5ProxyFactory.java b/src/main/java/org/xbill/DNS/NioSocks5ProxyFactory.java
new file mode 100644
index 00000000..d6707633
--- /dev/null
+++ b/src/main/java/org/xbill/DNS/NioSocks5ProxyFactory.java
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.xbill.DNS;
+
+import java.util.Objects;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.xbill.DNS.io.IoClientFactory;
+import org.xbill.DNS.io.TcpIoClient;
+import org.xbill.DNS.io.UdpIoClient;
+
+@Getter
+@Slf4j
+public class NioSocks5ProxyFactory implements IoClientFactory {
+ private final NioSocks5ProxyConfig config;
+ private final TcpIoClient tcpIoClient;
+ private final UdpIoClient udpIoClient;
+
+ public NioSocks5ProxyFactory(NioSocks5ProxyConfig socks5Proxy) {
+ config = Objects.requireNonNull(socks5Proxy, "proxy config must not be null");
+ tcpIoClient = new NioSocksTcpClient(config);
+ udpIoClient = new NioSocksUdpClient(config);
+ }
+
+ @Override
+ public TcpIoClient createOrGetTcpClient() {
+ return tcpIoClient;
+ }
+
+ @Override
+ public UdpIoClient createOrGetUdpClient() {
+ return udpIoClient;
+ }
+}
diff --git a/src/main/java/org/xbill/DNS/NioSocksHandler.java b/src/main/java/org/xbill/DNS/NioSocksHandler.java
new file mode 100644
index 00000000..42f6ec61
--- /dev/null
+++ b/src/main/java/org/xbill/DNS/NioSocksHandler.java
@@ -0,0 +1,460 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.xbill.DNS;
+
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetSocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+@Getter
+@Slf4j
+public class NioSocksHandler {
+ private static final byte SOCKS5_VERSION = 0x05;
+ private static final byte SOCKS5_USER_PWD_AUTH_VERSION = 0x01;
+ private static final byte SOCKS5_AUTH_NONE = 0x00;
+ private static final byte SOCKS5_AUTH_GSSAPI = 0x01;
+ private static final byte SOCKS5_AUTH_USER_PASS = 0x02;
+ private static final byte SOCKS5_AUTH_NO_ACCEPTABLE_METHODS = (byte) 0xFF;
+
+ public static final byte SOCKS5_CMD_CONNECT = 0x01;
+ public static final byte SOCKS5_CMD_BIND = 0x02;
+ public static final byte SOCKS5_CMD_UDP_ASSOCIATE = 0x03;
+
+ private static final byte SOCKS5_ATYP_IPV4 = 0x01;
+ private static final byte SOCKS5_ATYP_DOMAINNAME = 0x03;
+ private static final byte SOCKS5_ATYP_IPV6 = 0x04;
+
+ private static final byte SOCKS5_REP_SUCCEEDED = 0x00;
+ private static final byte SOCKS5_REP_GENERAL_FAILURE = 0x01;
+ private static final byte SOCKS5_REP_CONNECTION_NOT_ALLOWED = 0x02;
+ private static final byte SOCKS5_REP_NETWORK_UNREACHABLE = 0x03;
+ private static final byte SOCKS5_REP_HOST_UNREACHABLE = 0x04;
+ private static final byte SOCKS5_REP_CONNECTION_REFUSED = 0x05;
+ private static final byte SOCKS5_REP_TTL_EXPIRED = 0x06;
+ private static final byte SOCKS5_REP_COMMAND_NOT_SUPPORTED = 0x07;
+ private static final byte SOCKS5_REP_ADDRESS_TYPE_NOT_SUPPORTED = 0x08;
+
+ private static final byte SOCKS5_RESERVED = 0x00;
+
+ private final InetSocketAddress remoteAddress;
+ private final InetSocketAddress localAddress;
+ private final InetSocketAddress proxyAddress;
+ private final String socks5User;
+ private final String socks5Password;
+
+ public NioSocksHandler(
+ InetSocketAddress proxyAddress,
+ InetSocketAddress remoteAddress,
+ InetSocketAddress localAddress,
+ String socks5User,
+ String socks5Password) {
+ this.remoteAddress = Objects.requireNonNull(remoteAddress, "remoteAddress must not be null");
+ this.localAddress = localAddress;
+ this.proxyAddress = Objects.requireNonNull(proxyAddress, "proxyAddress must not be null");
+ this.socks5User = socks5User;
+ this.socks5Password = socks5Password;
+ }
+
+ public NioSocksHandler(
+ InetSocketAddress proxyAddress,
+ InetSocketAddress remoteAddress,
+ InetSocketAddress localAddress) {
+ this(proxyAddress, remoteAddress, localAddress, null, null);
+ }
+
+ private MethodSelectionRequest getMethodSelectionRequest() {
+ return new MethodSelectionRequest(
+ (this.socks5User != null && this.socks5Password != null)
+ ? SOCKS5_AUTH_USER_PASS
+ : SOCKS5_AUTH_NONE);
+ }
+
+ public CompletableFuture doAuthHandshake(
+ NioTcpHandler.ChannelState channel, Message query, long endTime) {
+ CompletableFuture authHandshakeF = new CompletableFuture<>();
+ CompletableFuture methodSelectionF = new CompletableFuture<>();
+ MethodSelectionRequest methodSelectionRequest = getMethodSelectionRequest();
+ NioTcpHandler.Transaction methodSelectionTransaction =
+ new NioTcpHandler.Transaction(
+ query,
+ methodSelectionRequest.toBytes(),
+ endTime,
+ channel.getChannel(),
+ methodSelectionF,
+ true);
+ channel.queueTransaction(methodSelectionTransaction);
+
+ methodSelectionF.thenComposeAsync(
+ methodSelectionBytes -> {
+ if (methodSelectionBytes.length != 2) {
+ authHandshakeF.completeExceptionally(
+ new UnsupportedOperationException("Invalid SOCKS5 method selection response"));
+ return null;
+ }
+ MethodSelectionResponse methodSelectionResponse =
+ new MethodSelectionResponse(methodSelectionBytes);
+ if (methodSelectionResponse.getMethod() == SOCKS5_AUTH_NO_ACCEPTABLE_METHODS) {
+ authHandshakeF.completeExceptionally(
+ new UnsupportedOperationException(
+ "Unsupported SOCKS5 method: " + methodSelectionResponse.getMethod()));
+ return null;
+ }
+ if (methodSelectionResponse.getMethod() == SOCKS5_AUTH_NONE) {
+ authHandshakeF.complete(null);
+ } else if (methodSelectionResponse.getMethod() == SOCKS5_AUTH_USER_PASS) {
+ return handleUserPassAuth(channel, query, endTime, authHandshakeF);
+ } else if (methodSelectionResponse.getMethod() == SOCKS5_AUTH_GSSAPI) {
+ // TODO: Implement GSSAPI
+ authHandshakeF.completeExceptionally(
+ new UnsupportedOperationException(
+ "Unsupported SOCKS5 method: " + methodSelectionResponse.getMethod()));
+ }
+ return null;
+ });
+
+ return authHandshakeF;
+ }
+
+ private CompletableFuture handleUserPassAuth(
+ NioTcpHandler.ChannelState channel,
+ Message query,
+ long endTime,
+ CompletableFuture authHandshakeF) {
+ CompletableFuture userPassAuthF = new CompletableFuture<>();
+ UserPassAuthRequest userPassAuthRequest = new UserPassAuthRequest(socks5User, socks5Password);
+ NioTcpHandler.Transaction userPwdAuthTransaction =
+ new NioTcpHandler.Transaction(
+ query,
+ userPassAuthRequest.toBytes(),
+ endTime,
+ channel.getChannel(),
+ userPassAuthF,
+ true);
+ channel.queueTransaction(userPwdAuthTransaction);
+
+ userPassAuthF.thenComposeAsync(
+ authIn -> {
+ UserPwdAuthResponse userPwdAuthResponse = new UserPwdAuthResponse(authIn);
+ if (userPwdAuthResponse.getStatus() != SOCKS5_REP_SUCCEEDED) {
+ authHandshakeF.completeExceptionally(
+ new UnsupportedOperationException(
+ "SOCKS5 user/pwd authentication failed with status: "
+ + userPwdAuthResponse.getStatus()));
+ } else {
+ authHandshakeF.complete(null);
+ }
+ return null;
+ });
+
+ return authHandshakeF;
+ }
+
+ public CompletableFuture doSocks5Request(
+ NioTcpHandler.ChannelState channel, byte socks5Cmd, Message query, long endTime) {
+ CompletableFuture cmdHandshakeF = new CompletableFuture<>();
+ CompletableFuture commandF = new CompletableFuture<>();
+ // For CONNECT, DST.ADDR and DST.PORT represent the host and port of the remote address.
+ // For UDP ASSOCIATE, DST.ADDR and DST.PORT represent the destination of the returning UDP
+ // packets.
+ // If DST.ADDR and DST.PORT are set to 0.0.0.0:0, the proxy will send the packets back the same
+ // way.
+ // This allows it to work in a NAT-ed network.
+ // After the first packet, the source address and port must not change. If they do, the
+ // proxy will drop the connection and the UDP association.
+ InetSocketAddress address =
+ (socks5Cmd == SOCKS5_CMD_CONNECT) ? remoteAddress : new InetSocketAddress("0.0.0.0", 0);
+ CmdRequest cmdRequest = new CmdRequest(socks5Cmd, address);
+ NioTcpHandler.Transaction commandTransaction =
+ new NioTcpHandler.Transaction(
+ query, cmdRequest.toBytes(), endTime, channel.getChannel(), commandF, true);
+ channel.queueTransaction(commandTransaction);
+
+ commandF.thenComposeAsync(
+ in -> {
+ CmdResponse cmdResponse = new CmdResponse(in);
+ if (cmdResponse.getReply() != SOCKS5_REP_SUCCEEDED) {
+ String errorMessage;
+ switch (cmdResponse.getReply()) {
+ case SOCKS5_REP_GENERAL_FAILURE:
+ errorMessage = "General SOCKS server failure";
+ break;
+ case SOCKS5_REP_CONNECTION_NOT_ALLOWED:
+ errorMessage = "Connection not allowed by ruleset";
+ break;
+ case SOCKS5_REP_NETWORK_UNREACHABLE:
+ errorMessage = "Network unreachable";
+ break;
+ case SOCKS5_REP_HOST_UNREACHABLE:
+ errorMessage = "Host unreachable";
+ break;
+ case SOCKS5_REP_CONNECTION_REFUSED:
+ errorMessage = "Connection refused by destination host";
+ break;
+ case SOCKS5_REP_TTL_EXPIRED:
+ errorMessage = "TTL expired";
+ break;
+ case SOCKS5_REP_COMMAND_NOT_SUPPORTED:
+ errorMessage = "Command not supported";
+ break;
+ case SOCKS5_REP_ADDRESS_TYPE_NOT_SUPPORTED:
+ errorMessage = "Address type not supported";
+ break;
+ default:
+ errorMessage = "Unknown SOCKS5 error with status: " + cmdResponse.getReply();
+ }
+ cmdHandshakeF.completeExceptionally(
+ new UnsupportedOperationException("SOCKS5 command failed: " + errorMessage));
+ } else {
+ cmdHandshakeF.complete(in);
+ }
+ return null;
+ });
+
+ return cmdHandshakeF;
+ }
+
+ public synchronized CompletableFuture doSocks5Handshake(
+ NioTcpHandler.ChannelState channel, byte socks5Cmd, Message query, long endTime) {
+ CompletableFuture socks5HandshakeF = new CompletableFuture<>();
+ channel.setSocks5(true);
+
+ CompletableFuture authHandshakeF = doAuthHandshake(channel, query, endTime);
+ authHandshakeF
+ .thenRunAsync(
+ () -> {
+ CompletableFuture cmdHandshakeF;
+ if (socks5Cmd == SOCKS5_CMD_CONNECT || socks5Cmd == SOCKS5_CMD_UDP_ASSOCIATE) {
+ cmdHandshakeF = doSocks5Request(channel, socks5Cmd, query, endTime);
+ } else {
+ socks5HandshakeF.completeExceptionally(
+ new UnsupportedOperationException("Unsupported SOCKS5 command: " + socks5Cmd));
+ return;
+ }
+ cmdHandshakeF
+ .thenComposeAsync(
+ in -> {
+ socks5HandshakeF.complete(in);
+ return null;
+ })
+ .exceptionally(
+ e -> {
+ socks5HandshakeF.completeExceptionally(e);
+ return null;
+ });
+ })
+ .exceptionally(
+ e -> {
+ socks5HandshakeF.completeExceptionally(e);
+ return null;
+ });
+
+ return socks5HandshakeF;
+ }
+
+ public byte[] addUdpHeader(byte[] data) {
+ ByteBuffer buffer;
+ byte addressType;
+ byte[] addressBytes;
+ if (remoteAddress.getAddress() instanceof Inet4Address) {
+ addressType = SOCKS5_ATYP_IPV4;
+ addressBytes = remoteAddress.getAddress().getAddress();
+ buffer = ByteBuffer.allocate(4 + addressBytes.length + 2 + data.length);
+ } else if (remoteAddress.getAddress() instanceof Inet6Address) {
+ addressType = SOCKS5_ATYP_IPV6;
+ addressBytes = remoteAddress.getAddress().getAddress();
+ buffer = ByteBuffer.allocate(4 + addressBytes.length + 2 + data.length);
+ } else {
+ addressType = SOCKS5_ATYP_DOMAINNAME;
+ addressBytes = remoteAddress.getHostName().getBytes(StandardCharsets.UTF_8);
+ buffer = ByteBuffer.allocate(4 + 1 + addressBytes.length + 2 + data.length);
+ }
+
+ buffer.put((byte) 0x00); // RSV
+ buffer.put((byte) 0x00); // RSV
+ buffer.put((byte) 0x00); // FRAG
+ buffer.put(addressType); // ATYP
+ if (addressType == SOCKS5_ATYP_DOMAINNAME) {
+ buffer.put((byte) addressBytes.length);
+ }
+ buffer.put(addressBytes); // DST.ADDR
+ buffer.putShort((short) remoteAddress.getPort()); // DST.PORT
+ buffer.put(data); // DATA
+
+ return buffer.array();
+ }
+
+ public byte[] removeUdpHeader(byte[] in) throws IllegalArgumentException {
+ if (in.length < 10) {
+ throw new IllegalArgumentException("SOCKS5 UDP response too short");
+ }
+
+ int addressType = in[3] & 0xFF;
+ int headerLength;
+ switch (addressType) {
+ case SOCKS5_ATYP_IPV4:
+ headerLength = 10;
+ break;
+ case SOCKS5_ATYP_DOMAINNAME:
+ headerLength = 7 + (in[4] & 0xFF);
+ break;
+ case SOCKS5_ATYP_IPV6:
+ headerLength = 22;
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported address type: " + addressType);
+ }
+
+ byte[] out = new byte[in.length - headerLength];
+ System.arraycopy(in, headerLength, out, 0, in.length - headerLength);
+ return out;
+ }
+
+ static class MethodSelectionRequest {
+ private final byte version;
+ private final byte method;
+
+ public MethodSelectionRequest(byte method) {
+ this.version = SOCKS5_VERSION;
+ this.method = method;
+ }
+
+ public byte[] toBytes() {
+ ByteBuffer buffer = ByteBuffer.allocate(3);
+ buffer.put(this.version);
+ buffer.put((byte) 0x01);
+ buffer.put(this.method);
+ return buffer.array();
+ }
+ }
+
+ @Getter
+ static class MethodSelectionResponse {
+ private final byte version;
+ private final byte method;
+
+ public MethodSelectionResponse(byte[] methodSelectionBytes) {
+ ByteBuffer buffer = ByteBuffer.wrap(methodSelectionBytes);
+ version = buffer.get();
+ method = buffer.get();
+ }
+ }
+
+ static class UserPassAuthRequest {
+ private final byte version;
+ private final byte usernameLength;
+ private final byte[] username;
+ private final byte passwordLength;
+ private final byte[] password;
+
+ public UserPassAuthRequest(String username, String password) {
+ this.version = SOCKS5_USER_PWD_AUTH_VERSION;
+ this.username = username.getBytes(StandardCharsets.UTF_8);
+ this.usernameLength = (byte) this.username.length;
+ this.password = password.getBytes(StandardCharsets.UTF_8);
+ this.passwordLength = (byte) this.password.length;
+ }
+
+ public byte[] toBytes() {
+ ByteBuffer buffer = ByteBuffer.allocate(3 + username.length + password.length);
+ buffer.put(this.version);
+ buffer.put(this.usernameLength);
+ buffer.put(this.username);
+ buffer.put(this.passwordLength);
+ buffer.put(this.password);
+ return buffer.array();
+ }
+ }
+
+ @Getter
+ static class UserPwdAuthResponse {
+ private final byte version;
+ private final byte status;
+
+ public UserPwdAuthResponse(byte[] userPwdAuthResponseBytes) {
+ ByteBuffer buffer = ByteBuffer.wrap(userPwdAuthResponseBytes);
+ version = buffer.get();
+ status = buffer.get();
+ }
+ }
+
+ static class CmdRequest {
+ private final byte version;
+ private final byte command;
+ private final byte reserved;
+ private final byte addressType;
+ private final byte[] addressBytes;
+ private final short port;
+ private final int bufferSize;
+
+ public CmdRequest(byte command, InetSocketAddress address) {
+ this.version = SOCKS5_VERSION;
+ this.command = command;
+ this.reserved = SOCKS5_RESERVED;
+ if (address.getAddress() instanceof Inet4Address) {
+ this.addressType = SOCKS5_ATYP_IPV4;
+ this.addressBytes = address.getAddress().getAddress();
+ this.bufferSize = 10;
+ } else if (address.getAddress() instanceof Inet6Address) {
+ this.addressType = SOCKS5_ATYP_IPV6;
+ this.addressBytes = address.getAddress().getAddress();
+ this.bufferSize = 22;
+ } else {
+ this.addressType = SOCKS5_ATYP_DOMAINNAME;
+ this.addressBytes = address.getHostName().getBytes(StandardCharsets.UTF_8);
+ this.bufferSize = 6 + 1 + addressBytes.length;
+ }
+ this.port = (short) address.getPort();
+ }
+
+ public byte[] toBytes() {
+ ByteBuffer buffer = ByteBuffer.allocate(bufferSize);
+ buffer.put(this.version);
+ buffer.put(this.command);
+ buffer.put(this.reserved);
+ buffer.put(this.addressType);
+ if (addressType == SOCKS5_ATYP_DOMAINNAME) {
+ buffer.put((byte) addressBytes.length);
+ }
+ buffer.put(this.addressBytes);
+ buffer.putShort(this.port);
+ return buffer.array();
+ }
+ }
+
+ @Getter
+ static class CmdResponse {
+ private final byte version;
+ private final byte reply;
+ private final byte reserved;
+ private final byte addressType;
+ private final byte[] address;
+ private final int port;
+
+ public CmdResponse(byte[] commandResponseBytes) {
+ ByteBuffer buffer = ByteBuffer.wrap(commandResponseBytes);
+ this.version = buffer.get();
+ this.reply = buffer.get();
+ this.reserved = buffer.get();
+ this.addressType = buffer.get();
+
+ if (addressType == SOCKS5_ATYP_IPV4) {
+ this.address = new byte[4];
+ } else if (addressType == SOCKS5_ATYP_IPV6) {
+ this.address = new byte[16];
+ } else if (addressType == SOCKS5_ATYP_DOMAINNAME) {
+ int domainLength = buffer.get() & 0xFF;
+ this.address = new byte[domainLength];
+ } else {
+ throw new IllegalArgumentException("Unsupported address type: " + addressType);
+ }
+
+ buffer.get(this.address);
+ this.port = Short.toUnsignedInt(buffer.getShort());
+ }
+ }
+}
diff --git a/src/main/java/org/xbill/DNS/NioSocksTcpClient.java b/src/main/java/org/xbill/DNS/NioSocksTcpClient.java
new file mode 100644
index 00000000..bde2b62f
--- /dev/null
+++ b/src/main/java/org/xbill/DNS/NioSocksTcpClient.java
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.xbill.DNS;
+
+import java.net.InetSocketAddress;
+import java.time.Duration;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import lombok.extern.slf4j.Slf4j;
+import org.xbill.DNS.io.TcpIoClient;
+
+@Slf4j
+final class NioSocksTcpClient extends NioTcpHandler implements TcpIoClient {
+ private final NioTcpHandler tcpHandler;
+ private final NioSocks5ProxyConfig socksConfig;
+
+ NioSocksTcpClient(NioSocks5ProxyConfig config) {
+ socksConfig = Objects.requireNonNull(config, "proxy config must not be null");
+ tcpHandler = new NioTcpHandler();
+ }
+
+ @Override
+ public CompletableFuture sendAndReceiveTcp(
+ InetSocketAddress local,
+ InetSocketAddress remote,
+ Message query,
+ byte[] data,
+ Duration timeout) {
+ NioSocksHandler proxy =
+ new NioSocksHandler(
+ socksConfig.getProxyAddress(),
+ remote,
+ local,
+ socksConfig.getSocks5User(),
+ socksConfig.getSocks5Password());
+ return tcpHandler.sendAndReceiveTcp(local, remote, proxy, query, data, timeout);
+ }
+}
diff --git a/src/main/java/org/xbill/DNS/NioSocksUdpAssociateChannelPool.java b/src/main/java/org/xbill/DNS/NioSocksUdpAssociateChannelPool.java
new file mode 100644
index 00000000..8800efcf
--- /dev/null
+++ b/src/main/java/org/xbill/DNS/NioSocksUdpAssociateChannelPool.java
@@ -0,0 +1,129 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.xbill.DNS;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.channels.DatagramChannel;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class NioSocksUdpAssociateChannelPool {
+ private final NioTcpHandler tcpHandler;
+ private final NioUdpHandler udpHandler;
+ private static final Map channelMap =
+ new ConcurrentHashMap<>();
+
+ public NioSocksUdpAssociateChannelPool(NioTcpHandler tcpHandler, NioUdpHandler udpHandler) {
+ this.tcpHandler = tcpHandler;
+ this.udpHandler = udpHandler;
+ }
+
+ public SocksUdpAssociateChannelState createOrGetSocketChannelState(
+ InetSocketAddress local, InetSocketAddress remote, CompletableFuture future) {
+ String key = local + " " + remote;
+ SocksUdpAssociateChannelGroup group =
+ channelMap.computeIfAbsent(
+ key, k -> new SocksUdpAssociateChannelGroup(tcpHandler, udpHandler));
+ return group.createOrGetDatagramChannel(local, remote, future);
+ }
+
+ public void removeIdleChannels() {
+ long currentTime = System.currentTimeMillis();
+ channelMap
+ .values()
+ .forEach(
+ group -> {
+ for (SocksUdpAssociateChannelState channel : group.channels) {
+ if (channel.poolChannelIdleTimeout < currentTime) {
+ try {
+ group.removeChannelState(channel);
+ } catch (IOException e) {
+ log.warn("Error closing idle channel", e);
+ }
+ }
+ }
+ });
+ }
+
+ private static class SocksUdpAssociateChannelGroup {
+ private final Queue channels;
+ private final NioTcpHandler tcpHandler;
+ private final NioUdpHandler udpHandler;
+ private final int defaultChannelIdleTimeout = 60000;
+
+ public SocksUdpAssociateChannelGroup(NioTcpHandler tcpHandler, NioUdpHandler udpHandler) {
+ channels = new ConcurrentLinkedQueue<>();
+ this.tcpHandler = tcpHandler;
+ this.udpHandler = udpHandler;
+ }
+
+ public SocksUdpAssociateChannelState createOrGetDatagramChannel(
+ InetSocketAddress local, InetSocketAddress remote, CompletableFuture future) {
+ SocksUdpAssociateChannelState channelState = null;
+ for (Iterator it = channels.iterator(); it.hasNext(); ) {
+ SocksUdpAssociateChannelState c = it.next();
+ synchronized (c) {
+ if (!c.isOccupied && !c.isFailed()) {
+ channelState = c;
+ c.occupy();
+ break;
+ }
+ }
+ }
+
+ if (channelState == null) {
+ try {
+ SocksUdpAssociateChannelState newChannel = new SocksUdpAssociateChannelState();
+ newChannel.tcpChannel = tcpHandler.createChannelState(local, remote, future);
+ newChannel.udpChannel = udpHandler.createChannel(local, future);
+ newChannel.poolChannelIdleTimeout =
+ System.currentTimeMillis() + defaultChannelIdleTimeout;
+ newChannel.isOccupied = true;
+ channels.add(newChannel);
+ channelState = newChannel;
+ } catch (IOException e) {
+ future.completeExceptionally(e);
+ }
+ }
+
+ return channelState;
+ }
+
+ public void removeChannelState(SocksUdpAssociateChannelState channel) throws IOException {
+ if (channel.occupy() || channel.isFailed()) {
+ channels.remove(channel);
+ channel.tcpChannel.close();
+ channel.udpChannel.close();
+ }
+ }
+ }
+
+ @RequiredArgsConstructor
+ @Getter
+ @Setter
+ public static class SocksUdpAssociateChannelState {
+ private NioTcpHandler.ChannelState tcpChannel;
+ private DatagramChannel udpChannel;
+ private boolean isOccupied = false;
+ private boolean isFailed = false;
+ private long poolChannelIdleTimeout;
+
+ public synchronized boolean occupy() {
+ if (!isOccupied) {
+ isOccupied = true;
+ return true;
+ } else {
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/xbill/DNS/NioSocksUdpClient.java b/src/main/java/org/xbill/DNS/NioSocksUdpClient.java
new file mode 100644
index 00000000..69bb2ac4
--- /dev/null
+++ b/src/main/java/org/xbill/DNS/NioSocksUdpClient.java
@@ -0,0 +1,88 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.xbill.DNS;
+
+import java.net.InetSocketAddress;
+import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
+import lombok.extern.slf4j.Slf4j;
+import org.xbill.DNS.io.UdpIoClient;
+
+@Slf4j
+final class NioSocksUdpClient extends NioClient implements UdpIoClient {
+ private final NioUdpHandler udpHandler = new NioUdpHandler();
+ private final NioSocks5ProxyConfig socksConfig;
+
+ NioSocksUdpClient(NioSocks5ProxyConfig config) {
+ socksConfig = config;
+ }
+
+ @Override
+ public CompletableFuture sendAndReceiveUdp(
+ InetSocketAddress local,
+ InetSocketAddress remote,
+ Message query,
+ byte[] data,
+ int max,
+ Duration timeout) {
+ CompletableFuture f = new CompletableFuture<>();
+ long endTime = System.nanoTime() + timeout.toNanos();
+ NioSocksHandler proxy =
+ new NioSocksHandler(
+ socksConfig.getProxyAddress(),
+ remote,
+ local,
+ socksConfig.getSocks5User(),
+ socksConfig.getSocks5Password());
+ NioSocksUdpAssociateChannelPool.SocksUdpAssociateChannelState channel =
+ udpHandler.getUdpPool().createOrGetSocketChannelState(local, proxy.getProxyAddress(), f);
+
+ synchronized (channel) {
+ if (channel.getTcpChannel().socks5HandshakeF == null
+ || channel.getTcpChannel().socks5HandshakeF.isCompletedExceptionally()) {
+ channel.getTcpChannel().setSocks5(true);
+ channel.getTcpChannel().socks5HandshakeF =
+ proxy.doSocks5Handshake(
+ channel.getTcpChannel(), NioSocksHandler.SOCKS5_CMD_UDP_ASSOCIATE, query, endTime);
+ }
+ }
+
+ channel
+ .getTcpChannel()
+ .socks5HandshakeF
+ .thenApplyAsync(
+ cmdBytes -> {
+ byte[] wrappedData = proxy.addUdpHeader(data);
+ NioSocksHandler.CmdResponse cmd = new NioSocksHandler.CmdResponse(cmdBytes);
+ InetSocketAddress newRemote =
+ new InetSocketAddress(socksConfig.getProxyAddress().getAddress(), cmd.getPort());
+ udpHandler
+ .sendAndReceiveUdp(
+ local, newRemote, channel.getUdpChannel(), query, wrappedData, max, timeout)
+ .thenApplyAsync(
+ response -> {
+ channel.setOccupied(false);
+ try {
+ f.complete(proxy.removeUdpHeader(response));
+ } catch (IllegalArgumentException e) {
+ f.completeExceptionally(e);
+ }
+ return null;
+ })
+ .exceptionally(
+ ex -> {
+ channel.setFailed(true);
+ f.completeExceptionally(ex);
+ return null;
+ });
+ return null;
+ })
+ .exceptionally(
+ ex -> {
+ channel.setFailed(true);
+ f.completeExceptionally(ex);
+ return null;
+ });
+
+ return f;
+ }
+}
diff --git a/src/main/java/org/xbill/DNS/NioTcpClient.java b/src/main/java/org/xbill/DNS/NioTcpClient.java
index 566c00eb..90ed4473 100644
--- a/src/main/java/org/xbill/DNS/NioTcpClient.java
+++ b/src/main/java/org/xbill/DNS/NioTcpClient.java
@@ -1,288 +1,18 @@
// SPDX-License-Identifier: BSD-3-Clause
package org.xbill.DNS;
-import java.io.EOFException;
-import java.io.IOException;
import java.net.InetSocketAddress;
-import java.net.SocketTimeoutException;
-import java.nio.ByteBuffer;
-import java.nio.channels.SelectionKey;
-import java.nio.channels.Selector;
-import java.nio.channels.SocketChannel;
import java.time.Duration;
-import java.util.Iterator;
-import java.util.Map;
-import java.util.Queue;
import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentLinkedQueue;
-import lombok.EqualsAndHashCode;
-import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.xbill.DNS.io.TcpIoClient;
@Slf4j
-final class NioTcpClient extends NioClient implements TcpIoClient {
- private final Queue registrationQueue = new ConcurrentLinkedQueue<>();
- private final Map channelMap = new ConcurrentHashMap<>();
+final class NioTcpClient extends NioTcpHandler implements TcpIoClient {
+ NioTcpHandler tcpHandler;
NioTcpClient() {
- setRegistrationsTask(this::processPendingRegistrations, true);
- setTimeoutTask(this::checkTransactionTimeouts, true);
- setCloseTask(this::closeTcp, true);
- }
-
- private void processPendingRegistrations() {
- while (!registrationQueue.isEmpty()) {
- ChannelState state = registrationQueue.poll();
- if (state == null) {
- continue;
- }
-
- try {
- final Selector selector = selector();
- if (!state.channel.isConnected()) {
- state.channel.register(selector, SelectionKey.OP_CONNECT, state);
- } else {
- state.channel.keyFor(selector).interestOps(SelectionKey.OP_WRITE);
- }
- } catch (IOException e) {
- state.handleChannelException(e);
- }
- }
- }
-
- private void checkTransactionTimeouts() {
- for (ChannelState state : channelMap.values()) {
- for (Iterator it = state.pendingTransactions.iterator(); it.hasNext(); ) {
- Transaction t = it.next();
- if (t.endTime - System.nanoTime() < 0) {
- t.f.completeExceptionally(new SocketTimeoutException("Query timed out"));
- it.remove();
- }
- }
- }
- }
-
- private void closeTcp() {
- registrationQueue.clear();
- EOFException closing = new EOFException("Client is closing");
- channelMap.forEach(
- (key, state) -> {
- state.handleTransactionException(closing);
- state.handleChannelException(closing);
- });
- channelMap.clear();
- }
-
- @RequiredArgsConstructor
- private static class Transaction {
- private final Message query;
- private final byte[] queryData;
- private final long endTime;
- private final SocketChannel channel;
- private final CompletableFuture f;
- private ByteBuffer queryDataBuffer;
- long bytesWrittenTotal = 0;
-
- boolean send() throws IOException {
- // send can be invoked multiple times if the entire buffer couldn't be written at once
- if (bytesWrittenTotal == queryData.length + 2) {
- return true;
- }
-
- if (queryDataBuffer == null) {
- // combine length+message to avoid multiple TCP packets
- // https://datatracker.ietf.org/doc/html/rfc7766#section-8
- queryDataBuffer = ByteBuffer.allocate(queryData.length + 2);
- queryDataBuffer.put((byte) (queryData.length >>> 8));
- queryDataBuffer.put((byte) (queryData.length & 0xFF));
- queryDataBuffer.put(queryData);
- queryDataBuffer.flip();
- }
-
- verboseLog(
- "TCP write: transaction id=" + query.getHeader().getID(),
- channel.socket().getLocalSocketAddress(),
- channel.socket().getRemoteSocketAddress(),
- queryDataBuffer);
-
- while (queryDataBuffer.hasRemaining()) {
- long bytesWritten = channel.write(queryDataBuffer);
- bytesWrittenTotal += bytesWritten;
- if (bytesWritten == 0) {
- log.debug(
- "Insufficient room for the data in the underlying output buffer for transaction {}, retrying",
- query.getHeader().getID());
- return false;
- } else if (bytesWrittenTotal < queryData.length) {
- log.debug(
- "Wrote {} of {} bytes data for transaction {}",
- bytesWrittenTotal,
- queryData.length,
- query.getHeader().getID());
- }
- }
-
- log.debug(
- "Send for transaction {} is complete, wrote {} bytes",
- query.getHeader().getID(),
- bytesWrittenTotal);
- return true;
- }
- }
-
- @RequiredArgsConstructor
- private class ChannelState implements KeyProcessor {
- private final SocketChannel channel;
- final Queue pendingTransactions = new ConcurrentLinkedQueue<>();
- ByteBuffer responseLengthData = ByteBuffer.allocate(2);
- ByteBuffer responseData = ByteBuffer.allocate(Message.MAXLENGTH);
- int readState = 0;
-
- @Override
- public void processReadyKey(SelectionKey key) {
- if (key.isValid()) {
- if (key.isConnectable()) {
- processConnect(key);
- } else {
- if (key.isWritable()) {
- processWrite(key);
- }
- if (key.isReadable()) {
- processRead();
- }
- }
- }
- }
-
- void handleTransactionException(IOException e) {
- for (Iterator it = pendingTransactions.iterator(); it.hasNext(); ) {
- Transaction t = it.next();
- t.f.completeExceptionally(e);
- it.remove();
- }
- }
-
- private void handleChannelException(IOException e) {
- handleTransactionException(e);
- for (Map.Entry entry : channelMap.entrySet()) {
- if (entry.getValue() == this) {
- channelMap.remove(entry.getKey());
- try {
- channel.close();
- } catch (IOException ex) {
- log.warn(
- "Failed to close channel l={}/r={}",
- entry.getKey().local,
- entry.getKey().remote,
- ex);
- }
- return;
- }
- }
- }
-
- private void processConnect(SelectionKey key) {
- try {
- channel.finishConnect();
- key.interestOps(SelectionKey.OP_WRITE);
- } catch (IOException e) {
- handleChannelException(e);
- }
- }
-
- private void processRead() {
- try {
- if (readState == 0) {
- int read = channel.read(responseLengthData);
- if (read < 0) {
- handleChannelException(new EOFException());
- return;
- }
-
- if (responseLengthData.position() == 2) {
- int length =
- ((responseLengthData.get(0) & 0xFF) << 8) + (responseLengthData.get(1) & 0xFF);
- responseLengthData.flip();
- responseData.limit(length);
- readState = 1;
- }
- }
-
- int read = channel.read(responseData);
- if (read < 0) {
- handleChannelException(new EOFException());
- return;
- } else if (responseData.hasRemaining()) {
- return;
- }
- } catch (IOException e) {
- handleChannelException(e);
- return;
- }
-
- readState = 0;
- responseData.flip();
- byte[] data = new byte[responseData.limit()];
- System.arraycopy(
- responseData.array(), responseData.arrayOffset(), data, 0, responseData.limit());
-
- // The message was shorter than the minimum length to find the transaction, abort
- if (data.length < 2) {
- verboseLog(
- "TCP read: response too short for a valid reply, discarding",
- channel.socket().getLocalSocketAddress(),
- channel.socket().getRemoteSocketAddress(),
- data);
- return;
- }
-
- int id = ((data[0] & 0xFF) << 8) + (data[1] & 0xFF);
- verboseLog(
- "TCP read: transaction id=" + id,
- channel.socket().getLocalSocketAddress(),
- channel.socket().getRemoteSocketAddress(),
- data);
-
- for (Iterator it = pendingTransactions.iterator(); it.hasNext(); ) {
- Transaction t = it.next();
- int qid = t.query.getHeader().getID();
- if (id == qid) {
- t.f.complete(data);
- it.remove();
- return;
- }
- }
-
- log.warn("Transaction for answer to id {} not found", id);
- }
-
- private void processWrite(SelectionKey key) {
- for (Iterator it = pendingTransactions.iterator(); it.hasNext(); ) {
- Transaction t = it.next();
- try {
- if (!t.send()) {
- // Write was incomplete because the output buffer was full. Wait until the selector
- // tells us that we can write again
- key.interestOps(SelectionKey.OP_WRITE);
- return;
- }
- } catch (IOException e) {
- t.f.completeExceptionally(e);
- it.remove();
- }
- }
-
- key.interestOps(SelectionKey.OP_READ);
- }
- }
-
- @RequiredArgsConstructor
- @EqualsAndHashCode
- private static class ChannelKey {
- final InetSocketAddress local;
- final InetSocketAddress remote;
+ tcpHandler = new NioTcpHandler();
}
@Override
@@ -292,52 +22,6 @@ public CompletableFuture sendAndReceiveTcp(
Message query,
byte[] data,
Duration timeout) {
- CompletableFuture f = new CompletableFuture<>();
- try {
- final Selector selector = selector();
- long endTime = System.nanoTime() + timeout.toNanos();
- ChannelState channel =
- channelMap.computeIfAbsent(
- new ChannelKey(local, remote),
- key -> {
- log.debug("Opening async channel for l={}/r={}", local, remote);
- SocketChannel c = null;
- try {
- c = SocketChannel.open();
- c.configureBlocking(false);
- if (local != null) {
- c.bind(local);
- }
-
- c.connect(remote);
- return new ChannelState(c);
- } catch (IOException e) {
- if (c != null) {
- try {
- c.close();
- } catch (IOException ee) {
- // ignore
- }
- }
- f.completeExceptionally(e);
- return null;
- }
- });
- if (channel != null) {
- log.trace(
- "Creating transaction for id {} ({}/{})",
- query.getHeader().getID(),
- query.getQuestion().getName(),
- Type.string(query.getQuestion().getType()));
- Transaction t = new Transaction(query, data, endTime, channel.channel, f);
- channel.pendingTransactions.add(t);
- registrationQueue.add(channel);
- selector.wakeup();
- }
- } catch (IOException e) {
- f.completeExceptionally(e);
- }
-
- return f;
+ return tcpHandler.sendAndReceiveTcp(local, remote, null, query, data, timeout);
}
}
diff --git a/src/main/java/org/xbill/DNS/NioTcpHandler.java b/src/main/java/org/xbill/DNS/NioTcpHandler.java
new file mode 100644
index 00000000..05598be6
--- /dev/null
+++ b/src/main/java/org/xbill/DNS/NioTcpHandler.java
@@ -0,0 +1,429 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.xbill.DNS;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.SocketTimeoutException;
+import java.nio.ByteBuffer;
+import java.nio.channels.SelectionKey;
+import java.nio.channels.Selector;
+import java.nio.channels.SocketChannel;
+import java.time.Duration;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Getter
+public class NioTcpHandler extends NioClient {
+ // `registrationQueue` and `channelMap` must be static to be shared between instances.
+ // This is necessary because a second instance overwrites the registration, timeout, and close
+ // tasks,
+ // leaving no thread to process the pending registrations for the first instance.
+ private static final Queue registrationQueue = new ConcurrentLinkedQueue<>();
+ private static final Map channelMap = new ConcurrentHashMap<>();
+
+ NioTcpHandler() {
+ setRegistrationsTask(this::processPendingRegistrations, true);
+ setTimeoutTask(this::checkTransactionTimeouts, true);
+ setCloseTask(this::closeTcp, true);
+ }
+
+ private void processPendingRegistrations() {
+ while (!registrationQueue.isEmpty()) {
+ ChannelState state = registrationQueue.poll();
+ if (state == null) {
+ continue;
+ }
+
+ try {
+ final Selector selector = selector();
+ if (!state.channel.isConnected()) {
+ state.channel.register(selector, SelectionKey.OP_CONNECT, state);
+ } else {
+ state.channel.keyFor(selector).interestOps(SelectionKey.OP_WRITE);
+ }
+ } catch (IOException e) {
+ state.handleChannelException(e);
+ }
+ }
+ }
+
+ private void checkTransactionTimeouts() {
+ for (ChannelState state : channelMap.values()) {
+ for (Iterator it = state.pendingTransactions.iterator(); it.hasNext(); ) {
+ Transaction t = it.next();
+ if (t.endTime - System.nanoTime() < 0) {
+ t.f.completeExceptionally(new SocketTimeoutException("Query timed out"));
+ it.remove();
+ }
+ }
+ }
+ }
+
+ private void closeTcp() {
+ registrationQueue.clear();
+ EOFException closing = new EOFException("Client is closing");
+ channelMap.forEach(
+ (key, state) -> {
+ state.handleTransactionException(closing);
+ state.handleChannelException(closing);
+ });
+ channelMap.clear();
+ }
+
+ @RequiredArgsConstructor
+ public static class Transaction {
+ private final Message query;
+ private final byte[] queryData;
+ private final long endTime;
+ private final SocketChannel channel;
+ private final CompletableFuture f;
+ private final boolean isSocks5;
+ private ByteBuffer queryDataBuffer;
+ long bytesWrittenTotal = 0;
+
+ boolean send() throws IOException {
+ // send can be invoked multiple times if the entire buffer couldn't be written at once
+ if (bytesWrittenTotal == queryData.length + 2) {
+ return true;
+ }
+ if (queryDataBuffer == null) {
+ if (isSocks5) {
+ queryDataBuffer = ByteBuffer.wrap(queryData);
+ } else {
+ // combine length+message to avoid multiple TCP packets
+ // https://datatracker.ietf.org/doc/html/rfc7766#section-8
+ queryDataBuffer = ByteBuffer.allocate(queryData.length + 2);
+ queryDataBuffer.put((byte) (queryData.length >>> 8));
+ queryDataBuffer.put((byte) (queryData.length & 0xFF));
+ queryDataBuffer.put(queryData);
+ queryDataBuffer.flip();
+ }
+ }
+
+ verboseLog(
+ "TCP write: transaction id=" + query.getHeader().getID(),
+ channel.socket().getLocalSocketAddress(),
+ channel.socket().getRemoteSocketAddress(),
+ queryDataBuffer);
+ while (queryDataBuffer.hasRemaining()) {
+ long bytesWritten = channel.write(queryDataBuffer);
+ bytesWrittenTotal += bytesWritten;
+ if (bytesWritten == 0) {
+ log.debug(
+ "Insufficient room for the data in the underlying output buffer for transaction {}, retrying",
+ query.getHeader().getID());
+ return false;
+ } else if (bytesWrittenTotal < queryData.length) {
+ log.debug(
+ "Wrote {} of {} bytes data for transaction {}",
+ bytesWrittenTotal,
+ queryData.length,
+ query.getHeader().getID());
+ }
+ }
+
+ log.debug(
+ "Send for transaction {} is complete, wrote {} bytes",
+ query.getHeader().getID(),
+ bytesWrittenTotal);
+ return true;
+ }
+ }
+
+ @RequiredArgsConstructor
+ @Getter
+ @Setter
+ public class ChannelState implements KeyProcessor {
+ private final SocketChannel channel;
+ final Queue pendingTransactions = new ConcurrentLinkedQueue<>();
+ ByteBuffer responseLengthData = ByteBuffer.allocate(2);
+ ByteBuffer responseData = ByteBuffer.allocate(Message.MAXLENGTH);
+ int readState = 0;
+ boolean isSocks5;
+ CompletableFuture socks5HandshakeF;
+
+ @Override
+ public void processReadyKey(SelectionKey key) {
+ if (key.isValid()) {
+ if (key.isConnectable()) {
+ processConnect(key);
+ } else {
+ if (key.isWritable()) {
+ processWrite(key);
+ }
+ if (key.isReadable()) {
+ processRead();
+ }
+ }
+ }
+ }
+
+ void handleTransactionException(IOException e) {
+ for (Iterator it = pendingTransactions.iterator(); it.hasNext(); ) {
+ Transaction t = it.next();
+ t.f.completeExceptionally(e);
+ it.remove();
+ }
+ }
+
+ private void handleChannelException(IOException e) {
+ handleTransactionException(e);
+ close();
+ }
+
+ public void close() {
+ for (Map.Entry entry : channelMap.entrySet()) {
+ if (entry.getValue() == this) {
+ channelMap.remove(entry.getKey());
+ try {
+ channel.close();
+ } catch (IOException ex) {
+ log.warn(
+ "Failed to close channel l={}/r={}",
+ entry.getKey().local,
+ entry.getKey().remote,
+ ex);
+ }
+ return;
+ }
+ }
+ }
+
+ private void processConnect(SelectionKey key) {
+ try {
+ channel.finishConnect();
+ key.interestOps(SelectionKey.OP_WRITE);
+ } catch (IOException e) {
+ handleChannelException(e);
+ }
+ }
+
+ private void processRead() {
+ try {
+ if (isSocks5) {
+ responseData = ByteBuffer.allocate(Message.MAXLENGTH);
+ int read = channel.read(responseData);
+ if (read < 0) {
+ handleChannelException(new EOFException());
+ return;
+ }
+ responseData.flip();
+ byte[] data = new byte[responseData.limit()];
+ System.arraycopy(
+ responseData.array(), responseData.arrayOffset(), data, 0, responseData.limit());
+ // the transactions for the socks5 handshake are synchronized
+ // you can assume that the responses are one after another
+ for (Iterator it = pendingTransactions.iterator(); it.hasNext(); ) {
+ Transaction t = it.next();
+ t.f.complete(data);
+ it.remove();
+ return;
+ }
+ return;
+ }
+
+ if (readState == 0) {
+ int read = channel.read(responseLengthData);
+ if (read < 0) {
+ handleChannelException(new EOFException());
+ return;
+ }
+
+ if (responseLengthData.position() == 2) {
+ int length =
+ ((responseLengthData.get(0) & 0xFF) << 8) + (responseLengthData.get(1) & 0xFF);
+ responseLengthData.flip();
+ responseData.limit(length);
+ readState = 1;
+ }
+ }
+
+ int read = channel.read(responseData);
+ if (read < 0) {
+ handleChannelException(new EOFException());
+ return;
+ } else if (responseData.hasRemaining()) {
+ return;
+ }
+ } catch (IOException e) {
+ handleChannelException(e);
+ return;
+ }
+
+ readState = 0;
+ responseData.flip();
+ byte[] data = new byte[responseData.limit()];
+ System.arraycopy(
+ responseData.array(), responseData.arrayOffset(), data, 0, responseData.limit());
+
+ // The message was shorter than the minimum length to find the transaction, abort
+ if (data.length < 2) {
+ verboseLog(
+ "TCP read: response too short for a valid reply, discarding",
+ channel.socket().getLocalSocketAddress(),
+ channel.socket().getRemoteSocketAddress(),
+ data);
+ return;
+ }
+
+ int id = ((data[0] & 0xFF) << 8) + (data[1] & 0xFF);
+ verboseLog(
+ "TCP read: transaction id=" + id,
+ channel.socket().getLocalSocketAddress(),
+ channel.socket().getRemoteSocketAddress(),
+ data);
+
+ for (Iterator it = pendingTransactions.iterator(); it.hasNext(); ) {
+ Transaction t = it.next();
+ int qid = t.query.getHeader().getID();
+ if (id == qid) {
+ t.f.complete(data);
+ it.remove();
+ return;
+ }
+ }
+
+ log.warn("Transaction for answer to id {} not found", id);
+ }
+
+ private void processWrite(SelectionKey key) {
+ for (Iterator it = pendingTransactions.iterator(); it.hasNext(); ) {
+ Transaction t = it.next();
+ try {
+ if (!t.send()) {
+ // Write was incomplete because the output buffer was full. Wait until the selector
+ // tells us that we can write again
+ key.interestOps(SelectionKey.OP_WRITE);
+ return;
+ }
+ } catch (IOException e) {
+ t.f.completeExceptionally(e);
+ it.remove();
+ }
+ }
+
+ key.interestOps(SelectionKey.OP_READ);
+ }
+
+ public void queueTransaction(Transaction t) {
+ try {
+ final Selector selector = selector();
+ pendingTransactions.add(t);
+ registrationQueue.add(this);
+ selector.wakeup();
+ } catch (IOException e) {
+ t.f.completeExceptionally(e);
+ }
+ }
+ }
+
+ @RequiredArgsConstructor
+ @EqualsAndHashCode
+ static class ChannelKey {
+ final InetSocketAddress local;
+ final InetSocketAddress remote;
+ }
+
+ public void dnsTransaction(
+ ChannelState channel, Message query, byte[] data, long endTime, CompletableFuture f) {
+ channel.setSocks5(false);
+ Transaction t = new Transaction(query, data, endTime, channel.channel, f, false);
+ channel.queueTransaction(t);
+ }
+
+ public ChannelState createChannelState(
+ InetSocketAddress local, InetSocketAddress remote, CompletableFuture f) {
+ log.debug("Opening async channel for l={}/r={}", local, remote);
+ SocketChannel c = null;
+ try {
+ c = SocketChannel.open();
+ c.configureBlocking(false);
+ if (local != null) {
+ c.bind(local);
+ }
+ c.connect(remote);
+ return new ChannelState(c);
+ } catch (IOException e) {
+ if (c != null) {
+ try {
+ c.close();
+ } catch (IOException ee) {
+ // ignore
+ }
+ }
+ f.completeExceptionally(e);
+ return null;
+ }
+ }
+
+ public ChannelState createOrGetChannelState(
+ InetSocketAddress local, InetSocketAddress remote, CompletableFuture f) {
+ return channelMap.computeIfAbsent(
+ new ChannelKey(local, remote), key -> createChannelState(local, remote, f));
+ }
+
+ public CompletableFuture sendAndReceiveTcp(
+ InetSocketAddress local,
+ InetSocketAddress remote,
+ NioSocksHandler proxy,
+ Message query,
+ byte[] data,
+ Duration timeout) {
+ CompletableFuture f = new CompletableFuture<>();
+
+ InetSocketAddress remoteAddr;
+ if (proxy != null) {
+ remoteAddr = proxy.getProxyAddress();
+ } else {
+ remoteAddr = remote;
+ }
+ ChannelState channel = createOrGetChannelState(local, remoteAddr, f);
+ if (channel != null) {
+ log.trace(
+ "Creating transaction for id {} ({}/{})",
+ query.getHeader().getID(),
+ query.getQuestion().getName(),
+ Type.string(query.getQuestion().getType()));
+
+ long endTime = System.nanoTime() + timeout.toNanos();
+ if (proxy != null) {
+ synchronized (channel) {
+ if (channel.socks5HandshakeF == null
+ || channel.socks5HandshakeF.isCompletedExceptionally()) {
+ channel.setSocks5(true);
+ channel.socks5HandshakeF =
+ proxy.doSocks5Handshake(
+ channel, NioSocksHandler.SOCKS5_CMD_CONNECT, query, endTime);
+ }
+ }
+ // Chain the SOCKS5 transactions with the DNS data transaction
+ channel
+ .socks5HandshakeF
+ .thenRunAsync(
+ () -> {
+ dnsTransaction(channel, query, data, endTime, f);
+ })
+ .exceptionally(
+ ex -> {
+ f.completeExceptionally(ex);
+ return null;
+ });
+ } else {
+ dnsTransaction(channel, query, data, endTime, f);
+ }
+ }
+
+ return f;
+ }
+}
diff --git a/src/main/java/org/xbill/DNS/NioUdpClient.java b/src/main/java/org/xbill/DNS/NioUdpClient.java
index ce6607f2..87d28617 100644
--- a/src/main/java/org/xbill/DNS/NioUdpClient.java
+++ b/src/main/java/org/xbill/DNS/NioUdpClient.java
@@ -1,161 +1,18 @@
// SPDX-License-Identifier: BSD-3-Clause
package org.xbill.DNS;
-import java.io.EOFException;
-import java.io.IOException;
import java.net.InetSocketAddress;
-import java.net.SocketException;
-import java.net.SocketTimeoutException;
-import java.nio.ByteBuffer;
-import java.nio.channels.DatagramChannel;
-import java.nio.channels.SelectionKey;
-import java.nio.channels.Selector;
-import java.security.SecureRandom;
import java.time.Duration;
-import java.util.Iterator;
-import java.util.Queue;
import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ConcurrentLinkedQueue;
-import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.xbill.DNS.io.UdpIoClient;
@Slf4j
final class NioUdpClient extends NioClient implements UdpIoClient {
- private final int ephemeralStart;
- private final int ephemeralRange;
-
- private final SecureRandom prng;
- private final Queue registrationQueue = new ConcurrentLinkedQueue<>();
- private final Queue pendingTransactions = new ConcurrentLinkedQueue<>();
+ private final NioUdpHandler udpHandler;
NioUdpClient() {
- // https://datatracker.ietf.org/doc/html/rfc6335#section-6
- int ephemeralStartDefault = 49152;
- int ephemeralEndDefault = 65535;
-
- // Linux usually uses 32768-60999
- if (System.getProperty("os.name").toLowerCase().contains("linux")) {
- ephemeralStartDefault = 32768;
- ephemeralEndDefault = 60999;
- }
-
- ephemeralStart = Integer.getInteger("dnsjava.udp.ephemeral.start", ephemeralStartDefault);
- int ephemeralEnd = Integer.getInteger("dnsjava.udp.ephemeral.end", ephemeralEndDefault);
- ephemeralRange = ephemeralEnd - ephemeralStart;
-
- if (Boolean.getBoolean("dnsjava.udp.ephemeral.use_ephemeral_port")) {
- prng = null;
- } else {
- prng = new SecureRandom();
- }
- setRegistrationsTask(this::processPendingRegistrations, false);
- setTimeoutTask(this::checkTransactionTimeouts, false);
- setCloseTask(this::closeUdp, false);
- }
-
- private void processPendingRegistrations() {
- while (!registrationQueue.isEmpty()) {
- Transaction t = registrationQueue.poll();
- if (t == null) {
- continue;
- }
-
- try {
- log.trace("Registering OP_READ for transaction with id {}", t.id);
- t.channel.register(selector(), SelectionKey.OP_READ, t);
- t.send();
- } catch (IOException e) {
- t.completeExceptionally(e);
- }
- }
- }
-
- private void checkTransactionTimeouts() {
- for (Iterator it = pendingTransactions.iterator(); it.hasNext(); ) {
- Transaction t = it.next();
- if (t.endTime - System.nanoTime() < 0) {
- t.completeExceptionally(new SocketTimeoutException("Query timed out"));
- it.remove();
- }
- }
- }
-
- @RequiredArgsConstructor
- private class Transaction implements KeyProcessor {
- private final int id;
- private final byte[] data;
- private final int max;
- private final long endTime;
- private final DatagramChannel channel;
- private final CompletableFuture f;
-
- void send() throws IOException {
- ByteBuffer buffer = ByteBuffer.wrap(data);
- verboseLog(
- "UDP write: transaction id=" + id,
- channel.socket().getLocalSocketAddress(),
- channel.socket().getRemoteSocketAddress(),
- data);
- int n = channel.send(buffer, channel.socket().getRemoteSocketAddress());
- if (n == 0) {
- throw new EOFException(
- "Insufficient room for the datagram in the underlying output buffer for transaction "
- + id);
- } else if (n < data.length) {
- throw new EOFException("Could not send all data for transaction " + id);
- }
- }
-
- @Override
- public void processReadyKey(SelectionKey key) {
- if (!key.isReadable()) {
- completeExceptionally(new EOFException("Key for transaction " + id + " is not readable"));
- pendingTransactions.remove(this);
- return;
- }
-
- DatagramChannel keyChannel = (DatagramChannel) key.channel();
- ByteBuffer buffer = ByteBuffer.allocate(max);
- int read;
- try {
- read = keyChannel.read(buffer);
- if (read <= 0) {
- throw new EOFException();
- }
- } catch (IOException e) {
- completeExceptionally(e);
- pendingTransactions.remove(this);
- return;
- }
-
- buffer.flip();
- byte[] resultingData = new byte[read];
- System.arraycopy(buffer.array(), 0, resultingData, 0, read);
- verboseLog(
- "UDP read: transaction id=" + id,
- keyChannel.socket().getLocalSocketAddress(),
- keyChannel.socket().getRemoteSocketAddress(),
- resultingData);
- silentDisconnectAndCloseChannel();
- f.complete(resultingData);
- pendingTransactions.remove(this);
- }
-
- private void completeExceptionally(Exception e) {
- silentDisconnectAndCloseChannel();
- f.completeExceptionally(e);
- }
-
- private void silentDisconnectAndCloseChannel() {
- try {
- channel.disconnect();
- } catch (IOException e) {
- // ignore, we either already have everything we need or can't do anything
- } finally {
- NioUdpClient.silentCloseChannel(channel);
- }
- }
+ udpHandler = new NioUdpHandler();
}
@Override
@@ -166,77 +23,6 @@ public CompletableFuture sendAndReceiveUdp(
byte[] data,
int max,
Duration timeout) {
- long endTime = System.nanoTime() + timeout.toNanos();
- CompletableFuture f = new CompletableFuture<>();
- DatagramChannel channel = null;
- try {
- final Selector selector = selector();
- channel = DatagramChannel.open();
- channel.configureBlocking(false);
-
- Transaction t = new Transaction(query.getHeader().getID(), data, max, endTime, channel, f);
- if (local == null || local.getPort() == 0) {
- boolean bound = false;
- for (int i = 0; i < 1024; i++) {
- try {
- InetSocketAddress addr = null;
- if (local == null) {
- if (prng != null) {
- addr = new InetSocketAddress(prng.nextInt(ephemeralRange) + ephemeralStart);
- }
- } else {
- int port = local.getPort();
- if (port == 0 && prng != null) {
- port = prng.nextInt(ephemeralRange) + ephemeralStart;
- }
-
- addr = new InetSocketAddress(local.getAddress(), port);
- }
-
- channel.bind(addr);
- bound = true;
- break;
- } catch (SocketException e) {
- // ignore, we'll try another random port
- }
- }
-
- if (!bound) {
- t.completeExceptionally(new IOException("No available source port found"));
- return f;
- }
- }
-
- channel.connect(remote);
- pendingTransactions.add(t);
- registrationQueue.add(t);
- selector.wakeup();
- } catch (IOException e) {
- silentCloseChannel(channel);
- f.completeExceptionally(e);
- } catch (Throwable e) {
- // Make sure to close the channel, no matter what, but only handle the declared IOException
- silentCloseChannel(channel);
- throw e;
- }
-
- return f;
- }
-
- private static void silentCloseChannel(DatagramChannel channel) {
- if (channel != null) {
- try {
- channel.close();
- } catch (IOException ioe) {
- // ignore
- }
- }
- }
-
- private void closeUdp() {
- registrationQueue.clear();
- EOFException closing = new EOFException("Client is closing");
- pendingTransactions.forEach(t -> t.completeExceptionally(closing));
- pendingTransactions.clear();
+ return udpHandler.sendAndReceiveUdp(local, remote, null, query, data, max, timeout);
}
}
diff --git a/src/main/java/org/xbill/DNS/NioUdpHandler.java b/src/main/java/org/xbill/DNS/NioUdpHandler.java
new file mode 100644
index 00000000..01f2f4fc
--- /dev/null
+++ b/src/main/java/org/xbill/DNS/NioUdpHandler.java
@@ -0,0 +1,271 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.xbill.DNS;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.SocketException;
+import java.net.SocketTimeoutException;
+import java.nio.ByteBuffer;
+import java.nio.channels.DatagramChannel;
+import java.nio.channels.SelectionKey;
+import java.nio.channels.Selector;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.util.Iterator;
+import java.util.Queue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Getter
+final class NioUdpHandler extends NioClient {
+ private final int ephemeralStart;
+ private final int ephemeralRange;
+ private final SecureRandom prng;
+ private static final Queue registrationQueue = new ConcurrentLinkedQueue<>();
+ private static final Queue pendingTransactions = new ConcurrentLinkedQueue<>();
+ private final NioSocksUdpAssociateChannelPool udpPool;
+
+ NioUdpHandler() {
+ int ephemeralStartDefault = 49152;
+ int ephemeralEndDefault = 65535;
+
+ if (System.getProperty("os.name").toLowerCase().contains("linux")) {
+ ephemeralStartDefault = 32768;
+ ephemeralEndDefault = 60999;
+ }
+
+ ephemeralStart = Integer.getInteger("dnsjava.udp.ephemeral.start", ephemeralStartDefault);
+ int ephemeralEnd = Integer.getInteger("dnsjava.udp.ephemeral.end", ephemeralEndDefault);
+ ephemeralRange = ephemeralEnd - ephemeralStart;
+
+ if (Boolean.getBoolean("dnsjava.udp.ephemeral.use_ephemeral_port")) {
+ prng = null;
+ } else {
+ prng = new SecureRandom();
+ }
+
+ // NioTcpHandler for SOCKS5 UDP associate
+ udpPool = new NioSocksUdpAssociateChannelPool(new NioTcpHandler(), this);
+
+ setRegistrationsTask(this::processPendingRegistrations, false);
+ setTimeoutTask(this::checkTransactionTimeouts, false);
+ setCloseTask(this::closeUdp, false);
+ }
+
+ private void processPendingRegistrations() {
+ while (!registrationQueue.isEmpty()) {
+ Transaction t = registrationQueue.poll();
+ if (t == null) {
+ continue;
+ }
+
+ try {
+ log.trace("Registering OP_READ for transaction with id {}", t.id);
+ t.channel.register(selector(), SelectionKey.OP_READ, t);
+ t.send();
+ } catch (IOException e) {
+ t.completeExceptionally(e);
+ }
+ }
+ }
+
+ private void checkTransactionTimeouts() {
+ for (Iterator it = pendingTransactions.iterator(); it.hasNext(); ) {
+ Transaction t = it.next();
+ if (t.endTime - System.nanoTime() < 0) {
+ t.completeExceptionally(new SocketTimeoutException("Query timed out"));
+ it.remove();
+ }
+ }
+
+ udpPool.removeIdleChannels();
+ }
+
+ private static void silentCloseChannel(DatagramChannel channel) {
+ if (channel != null) {
+ try {
+ channel.close();
+ } catch (IOException ioe) {
+ // ignore
+ }
+ }
+ }
+
+ private void closeUdp() {
+ registrationQueue.clear();
+ EOFException closing = new EOFException("Client is closing");
+ pendingTransactions.forEach(t -> t.completeExceptionally(closing));
+ pendingTransactions.clear();
+ }
+
+ @RequiredArgsConstructor
+ private class Transaction implements KeyProcessor {
+ private final int id;
+ private final byte[] data;
+ private final int max;
+ private final long endTime;
+ private final DatagramChannel channel;
+ private final boolean isProxyChannel;
+ private final CompletableFuture f;
+
+ void send() throws IOException {
+ ByteBuffer buffer = ByteBuffer.wrap(data);
+ verboseLog(
+ "UDP write: transaction id=" + id,
+ channel.socket().getLocalSocketAddress(),
+ channel.socket().getRemoteSocketAddress(),
+ data);
+ int n = channel.send(buffer, channel.socket().getRemoteSocketAddress());
+ if (n == 0) {
+ throw new EOFException(
+ "Insufficient room for the datagram in the underlying output buffer for transaction "
+ + id);
+ } else if (n < data.length) {
+ throw new EOFException("Could not send all data for transaction " + id);
+ }
+ }
+
+ @Override
+ public void processReadyKey(SelectionKey key) {
+ if (!key.isReadable()) {
+ completeExceptionally(new EOFException("Key for transaction " + id + " is not readable"));
+ pendingTransactions.remove(this);
+ return;
+ }
+
+ DatagramChannel keyChannel = (DatagramChannel) key.channel();
+ ByteBuffer buffer = ByteBuffer.allocate(max);
+ int read;
+ try {
+ read = keyChannel.read(buffer);
+ if (read <= 0) {
+ throw new EOFException();
+ }
+ } catch (IOException e) {
+ completeExceptionally(e);
+ pendingTransactions.remove(this);
+ return;
+ }
+
+ buffer.flip();
+ byte[] resultingData = new byte[read];
+ System.arraycopy(buffer.array(), 0, resultingData, 0, read);
+ verboseLog(
+ "UDP read: transaction id=" + id,
+ keyChannel.socket().getLocalSocketAddress(),
+ keyChannel.socket().getRemoteSocketAddress(),
+ resultingData);
+ // do not close the channel in case of SOCKS5 UDP associate.
+ // the channel port needs to be claimed for further queries to the same remote host.
+ // you can not use the same UDP associate port with another local port after the first query.
+ if (!isProxyChannel) {
+ silentDisconnectAndCloseChannel();
+ }
+ f.complete(resultingData);
+ pendingTransactions.remove(this);
+ }
+
+ private void completeExceptionally(Exception e) {
+ silentDisconnectAndCloseChannel();
+ f.completeExceptionally(e);
+ }
+
+ private void silentDisconnectAndCloseChannel() {
+ try {
+ channel.disconnect();
+ } catch (IOException e) {
+ // ignore, we either already have everything we need or can't do anything
+ } finally {
+ NioUdpHandler.silentCloseChannel(channel);
+ }
+ }
+ }
+
+ public DatagramChannel createChannel(InetSocketAddress local, CompletableFuture f)
+ throws IOException {
+ DatagramChannel channel = DatagramChannel.open();
+ channel.configureBlocking(false);
+ if (local == null || local.getPort() == 0) {
+ boolean bound = false;
+ for (int i = 0; i < 1024; i++) {
+ try {
+ InetSocketAddress addr = null;
+ if (local == null) {
+ if (prng != null) {
+ addr = new InetSocketAddress(prng.nextInt(ephemeralRange) + ephemeralStart);
+ }
+ } else {
+ int port = local.getPort();
+ if (port == 0 && prng != null) {
+ port = prng.nextInt(ephemeralRange) + ephemeralStart;
+ }
+
+ addr = new InetSocketAddress(local.getAddress(), port);
+ }
+
+ channel.bind(addr);
+ bound = true;
+ break;
+ } catch (SocketException e) {
+ // ignore, we'll try another random port
+ }
+ }
+ if (!bound) {
+ f.completeExceptionally(new IOException("No available source port found"));
+ return null;
+ }
+ } else {
+ channel.bind(local);
+ }
+ return channel;
+ }
+
+ public CompletableFuture sendAndReceiveUdp(
+ InetSocketAddress local,
+ InetSocketAddress remote,
+ DatagramChannel channel,
+ Message query,
+ byte[] data,
+ int max,
+ Duration timeout) {
+ long endTime = System.nanoTime() + timeout.toNanos();
+ CompletableFuture f = new CompletableFuture<>();
+
+ try {
+ boolean isProxyChannel = (channel != null);
+ if (channel == null) {
+ channel = createChannel(local, f);
+ }
+ if (channel != null) {
+ if (!channel.isConnected()) {
+ channel.connect(remote);
+ }
+ } else {
+ f.completeExceptionally(new IOException("Could not create channel"));
+ return f;
+ }
+
+ Transaction t =
+ new Transaction(
+ query.getHeader().getID(), data, max, endTime, channel, isProxyChannel, f);
+
+ final Selector selector = selector();
+ pendingTransactions.add(t);
+ registrationQueue.add(t);
+ selector.wakeup();
+ } catch (IOException e) {
+ silentCloseChannel(channel);
+ f.completeExceptionally(e);
+ } catch (Throwable e) {
+ silentCloseChannel(channel);
+ throw e;
+ }
+
+ return f;
+ }
+}
diff --git a/src/test/java/org/xbill/DNS/io/SimpleSocksTest.java b/src/test/java/org/xbill/DNS/io/SimpleSocksTest.java
new file mode 100644
index 00000000..cf07ea8b
--- /dev/null
+++ b/src/test/java/org/xbill/DNS/io/SimpleSocksTest.java
@@ -0,0 +1,82 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.xbill.DNS.io;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.ComposeContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.xbill.DNS.DClass;
+import org.xbill.DNS.Message;
+import org.xbill.DNS.Name;
+import org.xbill.DNS.NioSocks5ProxyConfig;
+import org.xbill.DNS.NioSocks5ProxyFactory;
+import org.xbill.DNS.Record;
+import org.xbill.DNS.SimpleResolver;
+import org.xbill.DNS.Type;
+
+public class SimpleSocksTest {
+ static final ComposeContainer environment =
+ new ComposeContainer(new File("src/test/resources/compose/compose.yml"))
+ .withBuild(true)
+ .waitingFor("dante-socks5", Wait.forHealthcheck());
+
+ @BeforeAll
+ public static void setUp() throws IOException, InterruptedException {
+ environment.start();
+ // wait to make sure the container is ready
+ Thread.sleep(100);
+ }
+
+ @AfterAll
+ public static void tearDown() {
+ environment.stop();
+ }
+
+ private SimpleResolver createResolver(
+ String address, boolean useTCP, String user, String password) throws IOException {
+ SimpleResolver res = address == null ? new SimpleResolver() : new SimpleResolver(address);
+ InetSocketAddress proxyAddress =
+ new InetSocketAddress(InetAddress.getByName("127.0.0.1"), 1080);
+ NioSocks5ProxyConfig config =
+ user == null
+ ? new NioSocks5ProxyConfig(proxyAddress)
+ : new NioSocks5ProxyConfig(proxyAddress, user, password);
+ res.setIoClientFactory(new NioSocks5ProxyFactory(config));
+ res.setTCP(useTCP);
+ return res;
+ }
+
+ @Test
+ public void testUDP() throws IOException {
+ SimpleResolver res = createResolver("10.5.0.2", false, null, null);
+ Record rec = Record.newRecord(Name.fromString("simple.test", Name.root), Type.A, DClass.IN);
+ Message query = Message.newQuery(rec);
+ Message response = res.send(query);
+ assertNotNull(response);
+ }
+
+ @Test
+ public void testTCP() throws IOException {
+ SimpleResolver res = createResolver("10.5.0.2", true, null, null);
+ Record rec = Record.newRecord(Name.fromString("simple.test", Name.root), Type.A, DClass.IN);
+ Message query = Message.newQuery(rec);
+ Message response = res.send(query);
+ assertNotNull(response);
+ }
+
+ @Test
+ public void testAuth() throws IOException {
+ SimpleResolver res = createResolver("10.5.0.2", false, "me", "42");
+ Record rec = Record.newRecord(Name.fromString("simple.test", Name.root), Type.A, DClass.IN);
+ Message query = Message.newQuery(rec);
+ Message response = res.send(query);
+ assertNotNull(response);
+ }
+}
diff --git a/src/test/resources/compose/.env b/src/test/resources/compose/.env
new file mode 100644
index 00000000..3d970a2c
--- /dev/null
+++ b/src/test/resources/compose/.env
@@ -0,0 +1,4 @@
+COMPOSE_PROJECT_NAME=dnsjava-test
+SOCKD_PORT=1080
+SOCKD_USER_NAME=me
+SOCKD_USER_PASSWORD=42
diff --git a/src/test/resources/compose/Dockerfile b/src/test/resources/compose/Dockerfile
new file mode 100644
index 00000000..f3bff7bb
--- /dev/null
+++ b/src/test/resources/compose/Dockerfile
@@ -0,0 +1,39 @@
+# inspired by https://github.com/adegtyarev/docker-dante
+FROM alpine:3.20
+
+ARG SOCKD_USER_NAME
+ARG SOCKD_USER_PASSWORD
+
+ENV SOCKD_USER_NAME ${SOCKD_USER_NAME}
+ENV SOCKD_USER_PASSWORD ${SOCKD_USER_PASSWORD}
+
+ENV DANTE_VER 1.4.2
+ENV DANTE_URL https://www.inet.no/dante/files/dante-$DANTE_VER.tar.gz
+ENV DANTE_SHA 4c97cff23e5c9b00ca1ec8a95ab22972813921d7fbf60fc453e3e06382fc38a7
+
+RUN apk add --no-cache --virtual .build-deps \
+ build-base \
+ curl \
+ bind-tools \
+ linux-pam-dev && \
+ install -v -d /src && \
+ curl -sSL $DANTE_URL -o /src/dante.tar.gz && \
+ echo "$DANTE_SHA */src/dante.tar.gz" | sha256sum -c && \
+ tar -C /src -vxzf /src/dante.tar.gz && \
+ cd /src/dante-$DANTE_VER && \
+ # https://lists.alpinelinux.org/alpine-devel/3932.html
+ ac_cv_func_sched_setscheduler=no ./configure --build=aarch64-unknown-linux-gnu && \
+ make -j install && \
+ cd / && rm -r /src && \
+ apk del .build-deps && \
+ apk add --no-cache \
+ curl \
+ linux-pam
+
+COPY sockd.conf /etc/
+COPY entrypoint.sh /
+
+EXPOSE 1080
+
+ENTRYPOINT ["/entrypoint.sh"]
+CMD ["sockd"]
diff --git a/src/test/resources/compose/compose.yml b/src/test/resources/compose/compose.yml
new file mode 100644
index 00000000..9c0636d4
--- /dev/null
+++ b/src/test/resources/compose/compose.yml
@@ -0,0 +1,43 @@
+
+services:
+ coredns:
+ image: coredns/coredns:1.12.0
+ networks:
+ default:
+ ipv4_address: 10.5.0.2
+ ipv6_address: 2001:db8::2
+ volumes:
+ - "./coredns:/etc/coredns"
+ command: "-conf /etc/coredns/corefile"
+ ports:
+ - "53/udp"
+
+ dante-socks5:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ env_file:
+ - .env
+ ports:
+ - "1080:1080"
+ - "10000:10000/udp"
+ - "10001:10001/udp"
+ networks:
+ default:
+ ipv4_address: 10.5.0.3
+ ipv6_address: 2001:db8::3
+ healthcheck:
+ test: "curl -x socks5://127.0.0.1:1080 http://10.5.0.2:8080/health"
+ volumes:
+ - ./sockd.conf:/etc/sockd.conf
+
+networks:
+ default:
+ driver: bridge
+ enable_ipv6: true
+ ipam:
+ config:
+ - subnet: 10.5.0.0/16
+ gateway: 10.5.0.1
+ - subnet: 2001:db8::/64
+ gateway: 2001:db8::1
diff --git a/src/test/resources/compose/coredns/corefile b/src/test/resources/compose/coredns/corefile
new file mode 100644
index 00000000..deeb53c1
--- /dev/null
+++ b/src/test/resources/compose/coredns/corefile
@@ -0,0 +1,9 @@
+# RFC 2606: Reserved Top Level DNS Names (https://www.rfc-editor.org/rfc/rfc2606.html)
+test.:53 {
+ debug
+ log
+ errors
+ health
+ file /etc/coredns/test.db
+}
+
diff --git a/src/test/resources/compose/coredns/test.db b/src/test/resources/compose/coredns/test.db
new file mode 100644
index 00000000..11ed72ec
--- /dev/null
+++ b/src/test/resources/compose/coredns/test.db
@@ -0,0 +1,3 @@
+test. IN SOA test. admin.test. 1675303881 7200 3600 1209600 3600
+
+simple.test. IN A 1.2.3.4
diff --git a/src/test/resources/compose/entrypoint.sh b/src/test/resources/compose/entrypoint.sh
new file mode 100755
index 00000000..440154ef
--- /dev/null
+++ b/src/test/resources/compose/entrypoint.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+if [ -n "${SOCKD_USER_NAME}" ]; then
+ echo "${SOCKD_USER_NAME}"
+ if [ -z "${SOCKD_USER_PASSWORD}" ]; then
+ echo "Set SOCKD_USER_PASSWORD in .env"
+ exit 1
+ fi
+ adduser -D "${SOCKD_USER_NAME}"
+ echo "${SOCKD_USER_NAME}:${SOCKD_USER_PASSWORD}" | chpasswd
+ echo "user ${SOCKD_USER_NAME} successfully set"
+fi
+exec "$@"
+
diff --git a/src/test/resources/compose/sockd.conf b/src/test/resources/compose/sockd.conf
new file mode 100644
index 00000000..178c0e3f
--- /dev/null
+++ b/src/test/resources/compose/sockd.conf
@@ -0,0 +1,34 @@
+# logging
+errorlog: /var/log/sockd.errlog
+logoutput: /var/log/sockd.log
+
+# server address specification
+internal: 0.0.0.0 port = 1080
+external: eth0
+
+# auth
+user.notprivileged: nobody
+socksmethod: none username # "none" or "username" user/pwd authentication
+
+# Allow everyone to connect to this server.
+client pass {
+ from: 0.0.0.0/0 to: 0.0.0.0/0
+ log: connect disconnect error # disconnect
+}
+
+# Allow all operations for connected clients on this server.
+socks pass {
+ from: 0.0.0.0/0 to: 0.0.0.0/0
+ command: bind connect udpassociate
+ log: connect disconnect iooperation error
+ socksmethod: none username # "none" or "username" user/pwd authentication
+
+ udp.portrange: 10000-10001
+}
+
+# Allow all inbound packets.
+socks pass {
+ from: 0.0.0.0/0 to: 0.0.0.0/0
+ command: bindreply udpreply
+ log: error connect disconnect iooperation
+}
diff --git a/src/test/resources/simplelogger.properties b/src/test/resources/simplelogger.properties
index 3894e75f..53552b10 100644
--- a/src/test/resources/simplelogger.properties
+++ b/src/test/resources/simplelogger.properties
@@ -3,3 +3,7 @@ org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd HH:mm:ss:SSS Z
org.slf4j.simpleLogger.showDateTime=true
org.slf4j.simpleLogger.log.org.xbill.DNS.Name=info
org.slf4j.simpleLogger.log.org.xbill.DNS.Compression=info
+org.slf4j.simpleLogger.log.org.testcontainers=INFO
+org.slf4j.simpleLogger.log.tc=INFO
+org.slf4j.simpleLogger.log.com.github.dockerjava=WARN
+org.slf4j.simpleLogger.log.com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.wire=OFF