Skip to content

New Feature: DNS over SOCKS #347

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 53 commits into
base: master
Choose a base branch
from

Conversation

tw-datascientist
Copy link

I added SOCKS5 support to the ioClients.
It does work for TCP and UDP (udp associate).
It also supports user/pwd authentication.

The implementation is based on

I tested it on a Dante SOCKS5 proxy with following config.

# 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.privileged: root
user.notprivileged: nobody
socksmethod: none   # "none" or "username" user/pwd auth

# 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 
}

# 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  # "none" or "username" user/pwd auth

    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 
}

You should be able to install the Dante SOCKS5 proxy via package managers like apt, apk or brew.
I can also provide you a Docker setup.

I tried it like

InetSocketAddress localAddress = new InetSocketAddress(InetAddress.getByName("0.0.0.0"), 0);
InetSocketAddress remoteAddress = new InetSocketAddress(InetAddress.getByName("1.1.1.1"), 53);
InetSocketAddress proxyAddress = new InetSocketAddress(InetAddress.getByName("<socks5-host>"), 1080);
Socks5Proxy proxy = new Socks5Proxy(proxyAddress, remoteAddress, localAddress);
res = new SimpleResolver(proxy);
res.setTCP(true); // optional

or

InetSocketAddress localAddress = new InetSocketAddress(InetAddress.getByName("0.0.0.0"), 0);
InetSocketAddress remoteAddress = new InetSocketAddress(InetAddress.getByName("1.1.1.1"), 53);
InetSocketAddress proxyAddress = new InetSocketAddress(InetAddress.getByName("<socks5-host>"), 1080);
String socks5User = "<user>";
String socks5Password = "<password>";
Socks5Proxy proxy = new Socks5Proxy(proxyAddress, remoteAddress, localAddress, socks5User, socks5Password);
res = new SimpleResolver(proxy);
res.setTCP(true);  // optional

I implemented it with method overloading on sendAndReceiveTcp and sendAndReceiveUDP. In this way it does not affect the tests.

@ibauersachs
Copy link
Member

I haven't forgotten about this, but as mentioned, it'll take some more time until I can review it properly.
Thanks for your work so far!

@tw-datascientist
Copy link
Author

No worries, take your time. Thanks for the update.

@ibauersachs
Copy link
Member

I'll review in more detail later, but as a first change, please undo the new *sendAndReceive methods in the interfaces and their corresponding usage in SimpleResolver (as well as the new proxy constructor there) and DefaultIoClient. Instead, please create a dedicated IoClientFactory, e.g. Socks5ProxyIoClientFactory and Socks5ProxyIoClient, this can then internally handle the proxy.

@ibauersachs ibauersachs self-requested a review November 26, 2024 14:01
@@ -286,7 +300,14 @@ public CompletableFuture<byte[]> sendAndReceiveTcp(
c.bind(local);
}

c.connect(remote);
if (proxy != null) {
c.configureBlocking(true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is too easy :-)
The whole process of connecting to the proxy must be async here too. If this method starts to block, the entire thread pool can get exhausted and all the timeouts won't work anymore.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I probably need to implement it as KeyProcessor. I would like to use the Transaction class from NioTcpClient and make it a separate public class.

if (local != null) {
tcpChannel.bind(local);
}
tcpChannel.connect(proxy.getProxyAddress());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here too, this needs to be fully async. Also, this creates a dedicated TCP connection to the proxy for each request. Shouldn't there be just one (or maybe one per remote) and reused?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need only one open TCP connection to establish the UDP relay.
It might be optimal to have just one or a pool off connections.

@@ -44,6 +44,9 @@ private void processPendingRegistrations() {
if (!state.channel.isConnected()) {
state.channel.register(selector, SelectionKey.OP_CONNECT, state);
} else {
if (state.channel.keyFor(selector) == null) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What did you encounter that this is necessary? Also, would it be possible to merge the condition with the outer if?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I encountered:

Exception in thread "dnsjava NIO selector" java.lang.NullPointerException: Cannot invoke "java.nio.channels.SelectionKey.interestOps(int)" because the return value of "java.nio.channels.SocketChannel.keyFor(java.nio.channels.Selector)" is null
	at org.dnsjava/org.xbill.DNS.NioTcpClient.processPendingRegistrations(NioTcpClient.java:47)
	at org.dnsjava/org.xbill.DNS.NioClient.runTasks(NioClient.java:168)
	at org.dnsjava/org.xbill.DNS.NioClient.runSelector(NioClient.java:133)
	at java.base/java.lang.Thread.run(Thread.java:1583)
Exception in thread "main" java.io.IOException: Timed out while trying to resolve google.com./A, id=25831
	at org.dnsjava/org.xbill.DNS.Resolver.send(Resolver.java:172)
	at org.dnsjava/org.xbill.DNS.tools.dig.main(dig.java:227)
Caused by: java.util.concurrent.TimeoutException
	at java.base/java.util.concurrent.CompletableFuture.timedGet(CompletableFuture.java:1960)
	at java.base/java.util.concurrent.CompletableFuture.get(CompletableFuture.java:2095)
	at org.dnsjava/org.xbill.DNS.Resolver.send(Resolver.java:153)
	... 1 more

The merge with the outer condition didn't work out for me.


private void writeToChannel(SocketChannel c, ByteBuffer buffer) {
try {
c.write(buffer);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return the return value, then handle it everywhere the write might have failed but not thrown.


private void readFromChannel(SocketChannel c, ByteBuffer buffer) {
try {
c.read(buffer);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return the return value and handle it everywhere

return new InetSocketAddress(this.getProxyAddress().getAddress(), port);
}

public byte[] addUdpHeader(byte[] in, InetSocketAddress to) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handle IPv6 in this method

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this IPv6 worked for me

ByteBuffer buffer;
byte addressType;
byte[] addressBytes;
if (remote.getAddress() instanceof Inet4Address) {
  addressType = SOCKS5_ATYP_IPV4;
  addressBytes = remote.getAddress().getAddress();
  buffer = ByteBuffer.allocate(4 + addressBytes.length + 2 + data.length);
} else if (remote.getAddress() instanceof Inet6Address) {
  addressType = SOCKS5_ATYP_IPV6;
  addressBytes = remote.getAddress().getAddress();
  buffer = ByteBuffer.allocate(4 + addressBytes.length + 2 + data.length);
} else {
  addressType = SOCKS5_ATYP_DOMAINNAME;
  addressBytes = remote.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 (IPv4)
if (addressType == SOCKS5_ATYP_DOMAINNAME) {
  buffer.put((byte) addressBytes.length);
}
buffer.put(addressBytes); // DST.ADDR
buffer.putShort((short) remote.getPort()); // DST.PORT
buffer.put(data); // DATA

return buffer.array();
}

public byte[] removeUdpHeader(byte[] in) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handle IPv6

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It keeps track of the headerLength in case of IPv4, IPv6 or domainname and removes it accordingly

buffer.put(SOCKS5_VERSION);
buffer.put(SOCKS5_CMD_CONNECT);
buffer.put(SOCKS5_RESERVED);
buffer.put(SOCKS5_ATYP_IPV4);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handle IPv6; and remote could also be a hostname. What if the proxy should resolve the hostname because the client isn't even able to?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I adjusted it to

if (remote.getAddress() instanceof Inet4Address) {
  addressType = SOCKS5_ATYP_IPV4;
  addressBytes = remote.getAddress().getAddress();
  buffer = ByteBuffer.allocate(10);
} else if (remote.getAddress() instanceof Inet6Address) {
  addressType = SOCKS5_ATYP_IPV6;
  addressBytes = remote.getAddress().getAddress();
  buffer = ByteBuffer.allocate(22);
} else {
  addressType = SOCKS5_ATYP_DOMAINNAME;
  addressBytes = remote.getHostName().getBytes(StandardCharsets.UTF_8);
  buffer = ByteBuffer.allocate(7 + addressBytes.length);
}

buffer.put(SOCKS5_VERSION);
buffer.put(SOCKS5_CMD_CONNECT);
buffer.put(SOCKS5_RESERVED);
buffer.put(addressType);
if (addressType == SOCKS5_ATYP_DOMAINNAME) {
  buffer.put((byte) addressBytes.length);
}
buffer.put(addressBytes);
buffer.putShort((short) remote.getPort());

ByteBuffer buffer = ByteBuffer.allocate(3);
buffer.put(SOCKS5_VERSION);
buffer.put((byte) 1);
buffer.put((this.socks5User != null && this.socks5Password != null) ? SOCKS5_AUTH_USER_PASS : SOCKS5_AUTH_NONE);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Java has built-in GSSAPI support. Would be nice to include the support here too, or at least prepare the code flow to be extendable.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to use org.ietf.jgss, but I'm getting

Field gssCredential: Undefined reference: org.ietf.jgss.GSSCredential
Undefined reference: org.ietf.jgss.GSSCredential

when running mvn clean package

Adding requires java.security.jgss; to the module-info.java didn't help.
Do you have an idea, why this is happening?

import java.util.Objects;

@Getter
public class Socks5Proxy {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if there's a need for timeout handling?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can set timeouts on the Dante config. It should reply with "TTL expired" then. Don't know, how to do this properly. There might be the need to track and handle this. Or you add a retry mechanism.

@tw-datascientist
Copy link
Author

I undid the changes in SimpleResolver, DefaultIoClient and *sendAndReceive.

I also added a Socks5ProxyIoClientFactory. Now, you can set it up like following:

InetSocketAddress proxyAddress = new InetSocketAddress(InetAddress.getByName("<socks5-host>"), 1080);
String socks5User = "<user>";
String socks5Password = "<password>";
Socks5ProxyConfig config = new Socks5ProxyConfig(proxyAddress, socks5User, socks5Password);
res.setIoClientFactory(new Socks5ProxyIoClientFactory(config));

I couldn't figure out how to add the SOCKS5 transactions with the NioClient. I implemented it like an event loop in the Socks5ProxyIoClientFactory and added the read and write operations in the attachment of the channel keys. I tried to implement the timeout handling with a ScheduledExecutorService, but I think it is not ready, yet.

Now it's fully async. IPv6 worked with 2001:4860:4860::8888 for me. It also reuses the connection without repeating the SOCKS5 handshake again.

@ibauersachs ibauersachs linked an issue Dec 15, 2024 that may be closed by this pull request
@tw-datascientist
Copy link
Author

Hi @ibauersachs. I suggested a huge amount of code additions and I also have changed a lot since the requested changes. I think, I should give some more descriptions.

Initially, I did not understand the NioClient back then. So, I attempted to implement it in a way that suited the handling of the SOCKS5 handshake. My concept was to start with an event loop that uses the Selector as thread safe queue and store the read/write logic as Runnables in the attachment of the SelectionKey. A new Runnable is set in the attachment at the end of the previous one to chain the Runnables in the right order. Or the CompletableFuture is completed. It should also work together with a worker pool to execute the Runnables in a multithreaded way.

Then later I realized I could not sync up the close operations for the implementation of timeouts into the event loop through the Selector. I didn't want to use selector.select(timeout) and opted for a solution with the java.util.concurrent.ScheduledExecutorService. I think, there is still a problem with synchronizing the close operation with the connect, read and write operations in a thread safe way. Now I think, I should write a method that handles connect, read and write and wrap it around a synchronized(tcpSelectionKey) block in Socks5ProxyTcpIoClient. Similar for Socks5ProxyUdpIoClient.

To reuse the connections and avoiding the repetition of the SOCKS handshake, I defined private static final Map<String, Map<String, Socks5Proxy>> socks5ConnectionPool = new ConcurrentHashMap<>(); Initially, I used a simple Map<String, Socks5Proxy>, but I realized that I needed to track the state of the TCP connection to prevent the SOCKS handshake and read/write operations from being disrupted by concurrent usage. Therefore, I decided to use a nested Map structure to track the connections per remote address in the outer Map and manage multiple connections for the same remote host in a the inner Map. This structure might require a cleanup strategy to close connections that aren't used for a longer time. I used the SelectionKey registration and deregistration from the Selector to track whether a connection is usable or if a new connection to the host needs to be established. This way I prevent that concurrent use messes up the UDP/TCP transaction of a connection.

What do you think?

@ibauersachs
Copy link
Member

Hi, sorry for not getting back to you yet. I haven't had time to thoroughly review this PR again, and as you probably figured out, the NIO stuff is nasty.

At first glance, there's a lot of duplication between the existing IO client classes and what you wrote now. I wonder if there couldn't be more shared code, especially the selector thread and timeout handling. The IO clients are currently package-private, so there can be changes to the API if needed.
The general idea with the map of connections etc. is probably correct.

It would also be good if there were some unit/integration tests. You could use Testcontainers to run a real proxy to test against.

@tw-datascientist
Copy link
Author

Hi. Thanks for your quick reply.

Yes. I now believe that the logic for the SOCKS proxy should be built on top of the NioClients. I can't use the current NioTcpClient because of this part ChannelState channel = channelMap.computeIfAbsent(new ChannelKey(local, remote), ...). For SOCKS, the remote would always be the proxy.
It would suggest to have the selector thread, timeout handling and connection pooling in the IoClientFactory and passing a connection handler to the IoClients. I will try something like this.

@ibauersachs
Copy link
Member

The current TcpIoClient implementation currently makes a hard assumption that it's talking directly with DNS servers, also with the prefix-length expected in each reply. You can also try introducing an intermediate class if it helps:

         NioClient 
             |
     AbstractTcpClient
     |               |
DnsTcpClient    SocksClient

Going back to the IoClientFactory will provide the challenge of keeping the public interface stable.

@tw-datascientist
Copy link
Author

That might help. I try it out.

@tw-datascientist
Copy link
Author

Hallo @ibauersachs. Thanks for your input. I appreciate it.

I integrated the transactions with the IoClients. Selector and timeout handling is now shared. Also I added 3 basic tests with Testcontainers (ComposeContainer).

The builds for this pull request are currently failing. Probably, Docker is not set up in the build containers.

Error:  Errors: 
Error:  org.xbill.DNS.io.SimpleSocksTest.null
Error:    Run 1: SimpleSocksTest.setUp:27 � IllegalState Could not find a valid Docker environment. Please see logs and check configuration
Error:    Run 2: SimpleSocksTest.setUp:27 � ContainerFetch Can't get Docker image: RemoteDockerImage(imageName=docker:24.0.2, imagePullPolicy=DefaultPullPolicy(), 

It works fine on my server.

[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 38.84 s -- in org.xbill.DNS.io.SimpleSocksTest

It seems that on Mac, there is an issue with routing UDP traffic into the Docker container. I do not believe this is related to the proper functioning of the SOCKS interactions.

[ERROR] Errors: 
[ERROR] org.xbill.DNS.io.SimpleSocksTest.testAuth
[ERROR]   Run 1: SimpleSocksTest.testAuth » PortUnreachable

It works fine when I try it from a test container in the same Docker network.

The introduction of intermediate classes worked well.

                                NioClient 
                            /              \                                
                NioTcpHandler               NioUdpHandler
           /        |          \             /       \          
NioTcpClient NioSocksTcpClient  NioSocksUdpClient    NioUdpClient
                          \        /        
                        NioSocksHandler

For the NioSocksHandler, I now handle I/O using futures and the provided Transaction classes. I use a future for the entire SOCKS handshake and one for each transaction.

For the NioSocksTcpClient, I moved the logic from NioTcpClient to the NioTcpHandler. I introduced boolean attributes to perform operations for the SOCKS interactions when necessary. I added a future as an attribute for the SOCKS handshake on the ChannelState to manage the reuse of the TCP channels without redoing the SOCKS handshake.

For the NioSocksUdpClient, I encountered an issue where the SOCKS proxy only accepts UDP interactions from the same port after the first interaction. To address this, I wrote NioSocksUdpAssociateChannelPool, that keeps track of the established SOCKS UDP associations and handles multiple SOCKS connections per local+remote address.

I plan to add more tests and further improve and simplify the NioSocksUdpAssociateChannelPool to avoid needing a new SOCKS connection for each remote address. And I'm looking for a way to reduce the log output of Testcontainers.

It would be nice to get a short review and your opinion on the pull request.

@ibauersachs
Copy link
Member

Thanks! I'll need a while to dig through all of this.

As for the integration tests, you could add a tag to only run them on the stage that also runs Sonar (i.e. Ubuntu and currently Java 21). Docker might need to be installed beforehand, you can do that as an actions step with apt-get install.

Also, can you please rebase the branch on top of master?

@tw-datascientist
Copy link
Author

I rebased the branch on top of master.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

DNS over SOCKS
3 participants