diff --git a/documentation/jetty-documentation/src/main/asciidoc/programming-guide/client/http/client-http-proxy.adoc b/documentation/jetty-documentation/src/main/asciidoc/programming-guide/client/http/client-http-proxy.adoc index 998a9dd14a0..45d1e360fa2 100644 --- a/documentation/jetty-documentation/src/main/asciidoc/programming-guide/client/http/client-http-proxy.adoc +++ b/documentation/jetty-documentation/src/main/asciidoc/programming-guide/client/http/client-http-proxy.adoc @@ -16,7 +16,12 @@ Jetty's `HttpClient` can be configured to use proxies to connect to destinations. -Two types of proxies are available out of the box: a HTTP proxy (provided by class `org.eclipse.jetty.client.HttpProxy`) and a SOCKS 4 proxy (provided by class `org.eclipse.jetty.client.Socks4Proxy`). +These types of proxies are available out of the box: + +* HTTP proxy (provided by class `org.eclipse.jetty.client.HttpProxy`) +* SOCKS 4 proxy (provided by class `org.eclipse.jetty.client.Socks4Proxy`) +* xref:pg-client-http-proxy-socks5[SOCKS 5 proxy] (provided by class `org.eclipse.jetty.client.Socks5Proxy`) + Other implementations may be written by subclassing `ProxyConfiguration.Proxy`. The following is a typical configuration: @@ -30,14 +35,26 @@ You specify the proxy host and proxy port, and optionally also the addresses tha Configured in this way, `HttpClient` makes requests to the HTTP proxy (for plain-text HTTP requests) or establishes a tunnel via HTTP `CONNECT` (for encrypted HTTPS requests). -Proxying is supported for HTTP/1.1 and HTTP/2. +Proxying is supported for any version of the HTTP protocol. + +[[pg-client-http-proxy-socks5]] +===== SOCKS5 Proxy Support + +SOCKS 5 (defined in link:https://datatracker.ietf.org/doc/html/rfc1928[RFC 1928]) offers choices for authentication methods and supports IPv6 (things that SOCKS 4 does not support). + +A typical SOCKS 5 proxy configuration with the username/password authentication method is the following: + +[source,java,indent=0] +---- +include::../../{doc_code}/org/eclipse/jetty/docs/programming/client/http/HTTPClientDocs.java[tag=proxySocks5] +---- [[pg-client-http-proxy-authentication]] -===== Proxy Authentication Support +===== HTTP Proxy Authentication Support -Jetty's `HttpClient` supports proxy authentication in the same way it supports xref:pg-client-http-authentication[server authentication]. +Jetty's `HttpClient` supports HTTP proxy authentication in the same way it supports xref:pg-client-http-authentication[server authentication]. -In the example below, the proxy requires `BASIC` authentication, but the server requires `DIGEST` authentication, and therefore: +In the example below, the HTTP proxy requires `BASIC` authentication, but the server requires `DIGEST` authentication, and therefore: [source,java,indent=0] ---- diff --git a/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/client/http/HTTPClientDocs.java b/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/client/http/HTTPClientDocs.java index 65d7717d75e..2954db45189 100644 --- a/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/client/http/HTTPClientDocs.java +++ b/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/client/http/HTTPClientDocs.java @@ -34,6 +34,8 @@ import org.eclipse.jetty.client.HttpDestination; import org.eclipse.jetty.client.HttpProxy; import org.eclipse.jetty.client.ProxyConfiguration; import org.eclipse.jetty.client.RoundRobinConnectionPool; +import org.eclipse.jetty.client.Socks5; +import org.eclipse.jetty.client.Socks5Proxy; import org.eclipse.jetty.client.api.Authentication; import org.eclipse.jetty.client.api.AuthenticationStore; import org.eclipse.jetty.client.api.ContentResponse; @@ -665,6 +667,30 @@ public class HTTPClientDocs // end::proxy[] } + public void proxySocks5() throws Exception + { + HttpClient httpClient = new HttpClient(); + httpClient.start(); + + // tag::proxySocks5[] + Socks5Proxy proxy = new Socks5Proxy("proxyHost", 8888); + String socks5User = "jetty"; + String socks5Pass = "secret"; + var socks5AuthenticationFactory = new Socks5.UsernamePasswordAuthenticationFactory(socks5User, socks5Pass); + // Add the authentication method to the proxy. + proxy.putAuthenticationFactory(socks5AuthenticationFactory); + + // Do not proxy requests for localhost:8080. + proxy.getExcludedAddresses().add("localhost:8080"); + + // Add the new proxy to the list of proxies already registered. + ProxyConfiguration proxyConfig = httpClient.getProxyConfiguration(); + proxyConfig.addProxy(proxy); + + ContentResponse response = httpClient.GET("http://domain.com/path"); + // end::proxySocks5[] + } + public void proxyAuthentication() throws Exception { HttpClient httpClient = new HttpClient(); diff --git a/jetty-client/src/main/java/org/eclipse/jetty/client/Socks5.java b/jetty-client/src/main/java/org/eclipse/jetty/client/Socks5.java index 668735e14e4..1d71f2e098a 100644 --- a/jetty-client/src/main/java/org/eclipse/jetty/client/Socks5.java +++ b/jetty-client/src/main/java/org/eclipse/jetty/client/Socks5.java @@ -13,140 +13,226 @@ package org.eclipse.jetty.client; +import java.io.IOException; import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.Objects; +import org.eclipse.jetty.io.EndPoint; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.Callback; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + *

Helper class for SOCKS5 proxying.

+ * + * @see Socks5Proxy + */ public class Socks5 { + /** + * The SOCKS protocol version: {@value}. + */ + public static final byte VERSION = 0x05; + /** + * The SOCKS5 {@code CONNECT} command used in SOCKS5 connect requests. + */ + public static final byte COMMAND_CONNECT = 0x01; + /** + * The reserved byte value: {@value}. + */ + public static final byte RESERVED = 0x00; + /** + * The address type for IPv4 used in SOCKS5 connect requests and responses. + */ + public static final byte ADDRESS_TYPE_IPV4 = 0x01; + /** + * The address type for domain names used in SOCKS5 connect requests and responses. + */ + public static final byte ADDRESS_TYPE_DOMAIN = 0x03; + /** + * The address type for IPv6 used in SOCKS5 connect requests and responses. + */ + public static final byte ADDRESS_TYPE_IPV6 = 0x04; - public enum RequestStage + private Socks5() { - INIT, - AUTH, - CONNECTING - } - - public enum ResponseStage - { - INIT, - AUTH, - CONNECTING, - CONNECTED_IPV4, - CONNECTED_DOMAIN_NAME, - CONNECTED_IPV6, - READ_REPLY_VARIABLE - } - - public interface SockConst - { - byte VER = 0x05; - byte USER_PASS_VER = 0x01; - byte RSV = 0x00; - byte SUCCEEDED = 0x00; - byte AUTH_FAILED = 0x01; - } - - public interface AuthType - { - byte NO_AUTH = 0x00; - byte USER_PASS = 0x02; - byte NO_ACCEPTABLE = -1; - } - - public interface Command - { - - byte CONNECT = 0x01; - byte BIND = 0x02; - byte UDP = 0x03; - } - - public interface Reply - { - - byte GENERAL = 0x01; - byte RULE_BAN = 0x02; - byte NETWORK_UNREACHABLE = 0x03; - byte HOST_UNREACHABLE = 0x04; - byte CONNECT_REFUSE = 0x05; - byte TTL_TIMEOUT = 0x06; - byte CMD_UNSUPPORTED = 0x07; - byte ATYPE_UNSUPPORTED = 0x08; - } - - public interface AddrType - { - byte IPV4 = 0x01; - byte DOMAIN_NAME = 0x03; - byte IPV6 = 0x04; } + /** + *

A SOCKS5 authentication method.

+ *

Implementations should send and receive the bytes that + * are specific to the particular authentication method.

+ */ public interface Authentication { /** - * get supported authentication type - * @see AuthType - * @return + *

Performs the authentication send and receive bytes + * exchanges specific for this {@link Authentication}.

+ * + * @param endPoint the {@link EndPoint} to send to and receive from the SOCKS5 server + * @param callback the callback to complete when the authentication is complete */ - byte getAuthType(); + void authenticate(EndPoint endPoint, Callback callback); /** - * write authorize command - * @return + * A factory for {@link Authentication}s. */ - ByteBuffer authorize(); + interface Factory + { + /** + * @return the authentication method defined by RFC 1928 + */ + byte getMethod(); + + /** + * @return a new {@link Authentication} + */ + Authentication newAuthentication(); + } } - public static class NoAuthentication implements Authentication + /** + *

The implementation of the {@code NO AUTH} authentication method defined in + * RFC 1928.

+ */ + public static class NoAuthenticationFactory implements Authentication.Factory { + public static final byte METHOD = 0x00; @Override - public byte getAuthType() + public byte getMethod() { - return AuthType.NO_AUTH; + return METHOD; } @Override - public ByteBuffer authorize() + public Authentication newAuthentication() { - throw new UnsupportedOperationException("authorize error"); + return (endPoint, callback) -> callback.succeeded(); } - } - public static class UsernamePasswordAuthentication implements Authentication + /** + *

The implementation of the {@code USERNAME/PASSWORD} authentication method defined in + * RFC 1929.

+ */ + public static class UsernamePasswordAuthenticationFactory implements Authentication.Factory { - private String username; - private String password; + public static final byte METHOD = 0x02; + public static final byte VERSION = 0x01; + private static final Logger LOG = LoggerFactory.getLogger(UsernamePasswordAuthenticationFactory.class); - public UsernamePasswordAuthentication(String username, String password) + private final String userName; + private final String password; + private final Charset charset; + + public UsernamePasswordAuthenticationFactory(String userName, String password) { - this.username = username; - this.password = password; + this(userName, password, StandardCharsets.US_ASCII); + } + + public UsernamePasswordAuthenticationFactory(String userName, String password, Charset charset) + { + this.userName = Objects.requireNonNull(userName); + this.password = Objects.requireNonNull(password); + this.charset = Objects.requireNonNull(charset); } @Override - public byte getAuthType() + public byte getMethod() { - return AuthType.USER_PASS; + return METHOD; } @Override - public ByteBuffer authorize() + public Authentication newAuthentication() { - byte uLen = (byte)username.length(); - byte pLen = (byte)(password == null ? 0 : password.length()); - ByteBuffer userPass = ByteBuffer.allocate(3 + uLen + pLen); - userPass.put(SockConst.USER_PASS_VER) - .put(uLen) - .put(username.getBytes(StandardCharsets.UTF_8)) - .put(pLen); - if (password != null) + return new UsernamePasswordAuthentication(this); + } + + private static class UsernamePasswordAuthentication implements Authentication, Callback + { + private final ByteBuffer byteBuffer = BufferUtil.allocate(2); + private final UsernamePasswordAuthenticationFactory factory; + private EndPoint endPoint; + private Callback callback; + + private UsernamePasswordAuthentication(UsernamePasswordAuthenticationFactory factory) { - userPass.put(password.getBytes(StandardCharsets.UTF_8)); + this.factory = factory; + } + + @Override + public void authenticate(EndPoint endPoint, Callback callback) + { + this.endPoint = endPoint; + this.callback = callback; + + byte[] userNameBytes = factory.userName.getBytes(factory.charset); + byte[] passwordBytes = factory.password.getBytes(factory.charset); + ByteBuffer byteBuffer = ByteBuffer.allocate(3 + userNameBytes.length + passwordBytes.length) + .put(VERSION) + .put((byte)userNameBytes.length) + .put(userNameBytes) + .put((byte)passwordBytes.length) + .put(passwordBytes) + .flip(); + endPoint.write(Callback.from(this::authenticationSent, this::failed), byteBuffer); + } + + private void authenticationSent() + { + if (LOG.isDebugEnabled()) + LOG.debug("Written SOCKS5 username/password authentication request"); + endPoint.fillInterested(this); + } + + @Override + public void succeeded() + { + try + { + int filled = endPoint.fill(byteBuffer); + if (filled < 0) + throw new ClosedChannelException(); + if (byteBuffer.remaining() < 2) + { + endPoint.fillInterested(this); + return; + } + if (LOG.isDebugEnabled()) + LOG.debug("Received SOCKS5 username/password authentication response"); + byte version = byteBuffer.get(); + if (version != VERSION) + throw new IOException("Unsupported username/password authentication version: " + version); + byte status = byteBuffer.get(); + if (status != 0) + throw new IOException("SOCK5 username/password authentication failure"); + if (LOG.isDebugEnabled()) + LOG.debug("SOCKS5 username/password authentication succeeded"); + callback.succeeded(); + } + catch (Throwable x) + { + failed(x); + } + } + + @Override + public void failed(Throwable x) + { + callback.failed(x); + } + + @Override + public InvocationType getInvocationType() + { + return InvocationType.NON_BLOCKING; } - userPass.flip(); - return userPass; } } } diff --git a/jetty-client/src/main/java/org/eclipse/jetty/client/Socks5Proxy.java b/jetty-client/src/main/java/org/eclipse/jetty/client/Socks5Proxy.java index 700d1accb32..d70791e20ee 100644 --- a/jetty-client/src/main/java/org/eclipse/jetty/client/Socks5Proxy.java +++ b/jetty-client/src/main/java/org/eclipse/jetty/client/Socks5Proxy.java @@ -13,26 +13,21 @@ package org.eclipse.jetty.client; +import java.io.IOException; +import java.net.InetAddress; import java.net.InetSocketAddress; -import java.net.SocketException; import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.Executor; +import java.util.concurrent.TimeoutException; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.eclipse.jetty.client.ProxyConfiguration.Proxy; -import org.eclipse.jetty.client.Socks5.AddrType; -import org.eclipse.jetty.client.Socks5.AuthType; -import org.eclipse.jetty.client.Socks5.Authentication; -import org.eclipse.jetty.client.Socks5.Command; -import org.eclipse.jetty.client.Socks5.NoAuthentication; -import org.eclipse.jetty.client.Socks5.Reply; -import org.eclipse.jetty.client.Socks5.RequestStage; -import org.eclipse.jetty.client.Socks5.ResponseStage; -import org.eclipse.jetty.client.Socks5.SockConst; +import org.eclipse.jetty.client.Socks5.NoAuthenticationFactory; import org.eclipse.jetty.client.api.Connection; import org.eclipse.jetty.io.AbstractConnection; import org.eclipse.jetty.io.ClientConnectionFactory; @@ -41,15 +36,26 @@ import org.eclipse.jetty.io.EndPoint; import org.eclipse.jetty.util.BufferUtil; import org.eclipse.jetty.util.Callback; import org.eclipse.jetty.util.Promise; +import org.eclipse.jetty.util.URIUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + *

Client-side proxy configuration for SOCKS5, defined by + * RFC 1928.

+ *

Multiple authentication methods are supported via + * {@link #putAuthenticationFactory(Socks5.Authentication.Factory)}. + * By default only the {@link Socks5.NoAuthenticationFactory NO AUTH} + * authentication method is configured. + * The {@link Socks5.UsernamePasswordAuthenticationFactory USERNAME/PASSWORD} + * is available to applications but must be explicitly configured and + * added.

+ */ public class Socks5Proxy extends Proxy { - private static final int MAX_AUTHRATIONS = 255; private static final Logger LOG = LoggerFactory.getLogger(Socks5Proxy.class); - private LinkedHashMap authorizations = new LinkedHashMap<>(); + private final Map authentications = new LinkedHashMap<>(); public Socks5Proxy(String host, int port) { @@ -59,290 +65,330 @@ public class Socks5Proxy extends Proxy public Socks5Proxy(Origin.Address address, boolean secure) { super(address, secure, null, null); - // default support no_auth - addAuthentication(new NoAuthentication()); - } - - public Socks5Proxy addAuthentication(Authentication authentication) - { - if (authorizations.size() >= MAX_AUTHRATIONS) - { - throw new IllegalArgumentException("too much authentications"); - } - authorizations.put(authentication.getAuthType(), authentication); - return this; + putAuthenticationFactory(new NoAuthenticationFactory()); } /** - * remove authorization by type - * @see AuthType - * @param type authorization type + *

Provides this class with the given SOCKS5 authentication method.

+ * + * @param authenticationFactory the SOCKS5 authentication factory + * @return the previous authentication method of the same type, or {@code null} + * if there was none of that type already present */ - public Socks5Proxy removeAuthentication(byte type) + public Socks5.Authentication.Factory putAuthenticationFactory(Socks5.Authentication.Factory authenticationFactory) { - authorizations.remove(type); - return this; + return authentications.put(authenticationFactory.getMethod(), authenticationFactory); + } + + /** + *

Removes the authentication of the given {@code method}.

+ * + * @param method the authentication method to remove + */ + public Socks5.Authentication.Factory removeAuthenticationFactory(byte method) + { + return authentications.remove(method); } @Override public ClientConnectionFactory newClientConnectionFactory(ClientConnectionFactory connectionFactory) { - return new Socks5ProxyClientConnectionFactory(connectionFactory, authorizations); + return new Socks5ProxyClientConnectionFactory(connectionFactory); } - @Override - public boolean matches(Origin origin) + private class Socks5ProxyClientConnectionFactory implements ClientConnectionFactory { - return true; + private final ClientConnectionFactory connectionFactory; + + private Socks5ProxyClientConnectionFactory(ClientConnectionFactory connectionFactory) + { + this.connectionFactory = connectionFactory; + } + + public org.eclipse.jetty.io.Connection newConnection(EndPoint endPoint, Map context) + { + HttpDestination destination = (HttpDestination)context.get(HttpClientTransport.HTTP_DESTINATION_CONTEXT_KEY); + Executor executor = destination.getHttpClient().getExecutor(); + Socks5ProxyConnection connection = new Socks5ProxyConnection(endPoint, executor, connectionFactory, context, authentications); + return customize(connection, context); + } } - private static class Socks5ProxyConnection extends AbstractConnection implements Callback + private static class Socks5ProxyConnection extends AbstractConnection implements org.eclipse.jetty.io.Connection.UpgradeFrom { private static final Pattern IPv4_PATTERN = Pattern.compile("(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})"); + + // SOCKS5 response max length is 262 bytes. + private final ByteBuffer byteBuffer = BufferUtil.allocate(512); private final ClientConnectionFactory connectionFactory; private final Map context; + private final Map authentications; + private State state = State.HANDSHAKE; - private LinkedHashMap authorizations; - - private Authentication selectedAuthentication; - private RequestStage requestStage = RequestStage.INIT; - private ResponseStage responseStage = null; - private int variableLen; - - public Socks5ProxyConnection(EndPoint endPoint, Executor executor, ClientConnectionFactory connectionFactory, Map context) + private Socks5ProxyConnection(EndPoint endPoint, Executor executor, ClientConnectionFactory connectionFactory, Map context, Map authentications) { super(endPoint, executor); this.connectionFactory = connectionFactory; this.context = context; + this.authentications = Map.copyOf(authentications); } - public void onOpen() + @Override + public ByteBuffer onUpgradeFrom() + { + return BufferUtil.copy(byteBuffer); + } + + @Override + public void onOpen() { super.onOpen(); - this.writeHandshakeCmd(); + sendHandshake(); } - private void writeHandshakeCmd() + private void sendHandshake() { - switch (requestStage) + try { - case INIT: - // write supported authorizations - int authLen = authorizations.size(); - ByteBuffer init = ByteBuffer.allocate(2 + authLen); - init.put(SockConst.VER).put((byte)authLen); - for (byte type : authorizations.keySet()) - { - init.put(type); - } - init.flip(); - setResponseStage(ResponseStage.INIT); - this.getEndPoint().write(this, init); - break; - case AUTH: - ByteBuffer auth = selectedAuthentication.authorize(); - setResponseStage(ResponseStage.AUTH); - this.getEndPoint().write(this, auth); - break; - case CONNECTING: - HttpDestination destination = (HttpDestination)this.context.get(HttpClientTransport.HTTP_DESTINATION_CONTEXT_KEY); - String host = destination.getHost(); - short port = (short)destination.getPort(); - setResponseStage(ResponseStage.CONNECTING); - Matcher matcher = IPv4_PATTERN.matcher(host); - if (matcher.matches()) - { - // ip - ByteBuffer buffer = ByteBuffer.allocate(10); - buffer.put(SockConst.VER) - .put(Command.CONNECT) - .put(SockConst.RSV) - .put(AddrType.IPV4); - for (int i = 1; i <= 4; ++i) - { - buffer.put((byte)Integer.parseInt(matcher.group(i))); - } - buffer.putShort(port); - buffer.flip(); - this.getEndPoint().write(this, buffer); - } - else - { - // domain - byte[] hostBytes = host.getBytes(StandardCharsets.UTF_8); - ByteBuffer buffer = ByteBuffer.allocate(7 + hostBytes.length); - buffer.put(SockConst.VER) - .put(Command.CONNECT) - .put(SockConst.RSV) - .put(AddrType.DOMAIN_NAME); - - buffer.put((byte)hostBytes.length) - .put(hostBytes) - .putShort(port); - buffer.flip(); - this.getEndPoint().write(this, buffer); - } - break; + // +-------------+--------------------+------------------+ + // | version (1) | num of methods (1) | methods (1..255) | + // +-------------+--------------------+------------------+ + int size = authentications.size(); + ByteBuffer byteBuffer = ByteBuffer.allocate(1 + 1 + size) + .put(Socks5.VERSION) + .put((byte)size); + authentications.keySet().forEach(byteBuffer::put); + byteBuffer.flip(); + getEndPoint().write(Callback.from(this::handshakeSent, this::fail), byteBuffer); + } + catch (Throwable x) + { + fail(x); } } - public void succeeded() + private void handshakeSent() { - if (LOG.isDebugEnabled()) - { + if (LOG.isDebugEnabled()) LOG.debug("Written SOCKS5 handshake request"); - } - this.fillInterested(); + state = State.HANDSHAKE; + fillInterested(); } - public void failed(Throwable x) + private void fail(Throwable x) { - this.close(); + if (LOG.isDebugEnabled()) + LOG.debug("SOCKS5 failure", x); + getEndPoint().close(x); @SuppressWarnings("unchecked") Promise promise = (Promise)this.context.get(HttpClientTransport.HTTP_CONNECTION_PROMISE_CONTEXT_KEY); promise.failed(x); } - public void onFillable() + @Override + public boolean onIdleExpired() { - try + fail(new TimeoutException("Idle timeout expired")); + return false; + } + + @Override + public void onFillable() + { + try { - Socks5Parser parser = new Socks5Parser(); - ByteBuffer buffer; - do + switch (state) { - buffer = BufferUtil.allocate(parser.expected()); - int filled = this.getEndPoint().fill(buffer); - if (LOG.isDebugEnabled()) - { - LOG.debug("Read SOCKS5 connect response, {} bytes", (long)filled); - } - - if (filled < 0) - { - throw new SocketException("SOCKS5 tunnel failed, connection closed"); - } - - if (filled == 0) - { - this.fillInterested(); - return; - } - } - while (!parser.parse(buffer)); - } - catch (Exception e) + case HANDSHAKE: + receiveHandshake(); + break; + case CONNECT: + receiveConnect(); + break; + default: + throw new IllegalStateException(); + } + } + catch (Throwable x) { - this.failed(e); + fail(x); } } - private void onSocks5Response(byte[] bs) throws SocketException + private void receiveHandshake() throws IOException { - switch (responseStage) + // +-------------+------------+ + // | version (1) | method (1) | + // +-------------+------------+ + int filled = getEndPoint().fill(byteBuffer); + if (filled < 0) + throw new ClosedChannelException(); + if (byteBuffer.remaining() < 2) { - case INIT: - if (bs[0] != SockConst.VER) + fillInterested(); + return; + } + + if (LOG.isDebugEnabled()) + LOG.debug("Received SOCKS5 handshake response {}", BufferUtil.toDetailString(byteBuffer)); + + byte version = byteBuffer.get(); + if (version != Socks5.VERSION) + throw new IOException("Unsupported SOCKS5 version: " + version); + + byte method = byteBuffer.get(); + if (method == -1) + throw new IOException("Unacceptable SOCKS5 authentication methods"); + + Socks5.Authentication.Factory factory = authentications.get(method); + if (factory == null) + throw new IOException("Unknown SOCKS5 authentication method: " + method); + + factory.newAuthentication().authenticate(getEndPoint(), Callback.from(this::sendConnect, this::fail)); + } + + private void sendConnect() + { + try + { + // +-------------+-------------+--------------+------------------+------------------------+----------+ + // | version (1) | command (1) | reserved (1) | address type (1) | address bytes (4..255) | port (2) | + // +-------------+-------------+--------------+------------------+------------------------+----------+ + HttpDestination destination = (HttpDestination)context.get(HttpClientTransport.HTTP_DESTINATION_CONTEXT_KEY); + Origin.Address address = destination.getOrigin().getAddress(); + String host = address.getHost(); + short port = (short)address.getPort(); + + ByteBuffer byteBuffer; + Matcher matcher = IPv4_PATTERN.matcher(host); + if (matcher.matches()) + { + byteBuffer = ByteBuffer.allocate(10) + .put(Socks5.VERSION) + .put(Socks5.COMMAND_CONNECT) + .put(Socks5.RESERVED) + .put(Socks5.ADDRESS_TYPE_IPV4); + for (int i = 1; i <= 4; ++i) { - throw new SocketException("SOCKS5 tunnel failed with err VER " + bs[0]); + byteBuffer.put(Byte.parseByte(matcher.group(i))); } - if (bs[1] == AuthType.NO_AUTH) - { - requestStage = RequestStage.CONNECTING; - writeHandshakeCmd(); - } - else if (bs[1] == AuthType.NO_ACCEPTABLE) - { - throw new SocketException("SOCKS : No acceptable methods"); - } - else - { - selectedAuthentication = authorizations.get(bs[1]); - if (selectedAuthentication == null) - { - throw new SocketException("SOCKS5 tunnel failed with unknown auth type"); - } - requestStage = RequestStage.AUTH; - writeHandshakeCmd(); - } - break; - case AUTH: - if (bs[0] != SockConst.USER_PASS_VER) - { - throw new SocketException("SOCKS5 tunnel failed with err UserPassVer " + bs[0]); - } - if (bs[1] != SockConst.SUCCEEDED) - { - throw new SocketException("SOCKS : authentication failed"); - } - // authorization successful - requestStage = RequestStage.CONNECTING; - writeHandshakeCmd(); - break; - case CONNECTING: - if (bs[0] != SockConst.VER) - { - throw new SocketException("SOCKS5 tunnel failed with err VER " + bs[0]); - } - switch (bs[1]) - { - case SockConst.SUCCEEDED: - switch (bs[3]) - { - case AddrType.IPV4: - setResponseStage(ResponseStage.CONNECTED_IPV4); - fillInterested(); - break; - case AddrType.DOMAIN_NAME: - setResponseStage(ResponseStage.CONNECTED_DOMAIN_NAME); - fillInterested(); - break; - case AddrType.IPV6: - setResponseStage(ResponseStage.CONNECTED_IPV6); - fillInterested(); - break; - default: - throw new SocketException("SOCKS: unknown addr type " + bs[3]); - } - break; - case Reply.GENERAL: - throw new SocketException("SOCKS server general failure"); - case Reply.RULE_BAN: - throw new SocketException("SOCKS: Connection not allowed by ruleset"); - case Reply.NETWORK_UNREACHABLE: - throw new SocketException("SOCKS: Network unreachable"); - case Reply.HOST_UNREACHABLE: - throw new SocketException("SOCKS: Host unreachable"); - case Reply.CONNECT_REFUSE: - throw new SocketException("SOCKS: Connection refused"); - case Reply.TTL_TIMEOUT: - throw new SocketException("SOCKS: TTL expired"); - case Reply.CMD_UNSUPPORTED: - throw new SocketException("SOCKS: Command not supported"); - case Reply.ATYPE_UNSUPPORTED: - throw new SocketException("SOCKS: address type not supported"); - default: - throw new SocketException("SOCKS: unknown code " + bs[1]); - } - break; - case CONNECTED_DOMAIN_NAME: - case CONNECTED_IPV6: - variableLen = 2 + bs[0]; - setResponseStage(ResponseStage.READ_REPLY_VARIABLE); - fillInterested(); - break; - case CONNECTED_IPV4: - case READ_REPLY_VARIABLE: + byteBuffer.putShort(port) + .flip(); + } + else if (URIUtil.isValidHostRegisteredName(host)) + { + byte[] bytes = host.getBytes(StandardCharsets.US_ASCII); + if (bytes.length > 255) + throw new IOException("Invalid host name: " + host); + byteBuffer = ByteBuffer.allocate(7 + bytes.length) + .put(Socks5.VERSION) + .put(Socks5.COMMAND_CONNECT) + .put(Socks5.RESERVED) + .put(Socks5.ADDRESS_TYPE_DOMAIN) + .put((byte)bytes.length) + .put(bytes) + .putShort(port) + .flip(); + } + else + { + // Assume IPv6. + byte[] bytes = InetAddress.getByName(host).getAddress(); + byteBuffer = ByteBuffer.allocate(22) + .put(Socks5.VERSION) + .put(Socks5.COMMAND_CONNECT) + .put(Socks5.RESERVED) + .put(Socks5.ADDRESS_TYPE_IPV6) + .put(bytes) + .putShort(port) + .flip(); + } + + getEndPoint().write(Callback.from(this::connectSent, this::fail), byteBuffer); + } + catch (Throwable x) + { + fail(x); + } + } + + private void connectSent() + { + if (LOG.isDebugEnabled()) + LOG.debug("Written SOCKS5 connect request"); + state = State.CONNECT; + fillInterested(); + } + + private void receiveConnect() throws IOException + { + // +-------------+-----------+--------------+------------------+------------------------+----------+ + // | version (1) | reply (1) | reserved (1) | address type (1) | address bytes (4..255) | port (2) | + // +-------------+-----------+--------------+------------------+------------------------+----------+ + int filled = getEndPoint().fill(byteBuffer); + if (filled < 0) + throw new ClosedChannelException(); + if (byteBuffer.remaining() < 5) + { + fillInterested(); + return; + } + byte addressType = byteBuffer.get(3); + int length = 6; + if (addressType == Socks5.ADDRESS_TYPE_IPV4) + length += 4; + else if (addressType == Socks5.ADDRESS_TYPE_DOMAIN) + length += 1 + (byteBuffer.get(4) & 0xFF); + else if (addressType == Socks5.ADDRESS_TYPE_IPV6) + length += 16; + else + throw new IOException("Invalid SOCKS5 address type: " + addressType); + if (byteBuffer.remaining() < length) + { + fillInterested(); + return; + } + + if (LOG.isDebugEnabled()) + LOG.debug("Received SOCKS5 connect response {}", BufferUtil.toDetailString(byteBuffer)); + + // We have all the SOCKS5 bytes. + byte version = byteBuffer.get(); + if (version != Socks5.VERSION) + throw new IOException("Unsupported SOCKS5 version: " + version); + + byte status = byteBuffer.get(); + switch (status) + { + case 0: + // Consume the buffer before upgrading to the tunnel. + byteBuffer.position(length); tunnel(); break; + case 1: + throw new IOException("SOCKS5 general failure"); + case 2: + throw new IOException("SOCKS5 connection not allowed"); + case 3: + throw new IOException("SOCKS5 network unreachable"); + case 4: + throw new IOException("SOCKS5 host unreachable"); + case 5: + throw new IOException("SOCKS5 connection refused"); + case 6: + throw new IOException("SOCKS5 timeout expired"); + case 7: + throw new IOException("SOCKS5 unsupported command"); + case 8: + throw new IOException("SOCKS5 unsupported address"); default: - throw new SocketException("BAD SOCKS5 PROTOCOL"); + throw new IOException("SOCKS5 unknown status: " + status); } } - private void tunnel() + private void tunnel() { - try + try { HttpDestination destination = (HttpDestination)context.get(HttpClientTransport.HTTP_DESTINATION_CONTEXT_KEY); // Don't want to do DNS resolution here. @@ -350,110 +396,21 @@ public class Socks5Proxy extends Proxy context.put(ClientConnector.REMOTE_SOCKET_ADDRESS_CONTEXT_KEY, address); ClientConnectionFactory connectionFactory = this.connectionFactory; if (destination.isSecure()) - { connectionFactory = destination.newSslClientConnectionFactory(null, connectionFactory); - } - org.eclipse.jetty.io.Connection newConnection = connectionFactory.newConnection(getEndPoint(), context); + var newConnection = connectionFactory.newConnection(getEndPoint(), context); getEndPoint().upgrade(newConnection); if (LOG.isDebugEnabled()) - { LOG.debug("SOCKS5 tunnel established: {} over {}", this, newConnection); - } - } - catch (Exception e) + } + catch (Throwable x) { - this.failed(e); + fail(x); } } - void setResponseStage(ResponseStage responseStage) + private enum State { - LOG.debug("set responseStage to {}", responseStage); - this.responseStage = responseStage; - } - - private class Socks5Parser - { - private final int expectedLength; - private final byte[] bs; - private int cursor; - - private Socks5Parser() - { - switch (Socks5ProxyConnection.this.responseStage) - { - case INIT: - expectedLength = 2; - break; - case AUTH: - expectedLength = 2; - break; - case CONNECTING: - expectedLength = 4; - break; - case CONNECTED_IPV4: - expectedLength = 6; - break; - case CONNECTED_IPV6: - expectedLength = 1; - break; - case CONNECTED_DOMAIN_NAME: - expectedLength = 1; - break; - case READ_REPLY_VARIABLE: - expectedLength = Socks5ProxyConnection.this.variableLen; - break; - default: - expectedLength = 0; - break; - } - bs = new byte[expectedLength]; - } - - private boolean parse(ByteBuffer buffer) throws SocketException - { - while (buffer.hasRemaining()) - { - byte current = buffer.get(); - bs[cursor] = current; - - ++this.cursor; - if (this.cursor != expectedLength) - { - continue; - } - - onSocks5Response(bs); - return true; - } - return false; - } - - private int expected() - { - return expectedLength - this.cursor; - } - } - } - - public static class Socks5ProxyClientConnectionFactory implements ClientConnectionFactory - { - private final ClientConnectionFactory connectionFactory; - private final LinkedHashMap authorizations; - - public Socks5ProxyClientConnectionFactory(ClientConnectionFactory connectionFactory, LinkedHashMap authorizations) - { - this.connectionFactory = connectionFactory; - this.authorizations = authorizations; - } - - public org.eclipse.jetty.io.Connection newConnection(EndPoint endPoint, Map context) - { - HttpDestination destination = (HttpDestination)context.get(HttpClientTransport.HTTP_DESTINATION_CONTEXT_KEY); - Executor executor = destination.getHttpClient().getExecutor(); - Socks5ProxyConnection connection = new Socks5ProxyConnection(endPoint, executor, this.connectionFactory, context); - connection.authorizations = authorizations; - return this.customize(connection, context); + HANDSHAKE, CONNECT } } } diff --git a/jetty-client/src/test/java/org/eclipse/jetty/client/Socks5ProxyTest.java b/jetty-client/src/test/java/org/eclipse/jetty/client/Socks5ProxyTest.java index 8a27ba60809..ef955e2e05c 100644 --- a/jetty-client/src/test/java/org/eclipse/jetty/client/Socks5ProxyTest.java +++ b/jetty-client/src/test/java/org/eclipse/jetty/client/Socks5ProxyTest.java @@ -13,11 +13,12 @@ package org.eclipse.jetty.client; -import java.io.InputStream; +import java.io.IOException; import java.io.OutputStream; +import java.net.InetAddress; import java.net.InetSocketAddress; -import java.net.SocketException; import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.nio.charset.StandardCharsets; @@ -28,15 +29,12 @@ import java.util.concurrent.TimeoutException; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocket; -import org.eclipse.jetty.client.Socks5.AddrType; -import org.eclipse.jetty.client.Socks5.AuthType; -import org.eclipse.jetty.client.Socks5.Command; -import org.eclipse.jetty.client.Socks5.SockConst; -import org.eclipse.jetty.client.Socks5.UsernamePasswordAuthentication; +import org.eclipse.jetty.client.Socks5.UsernamePasswordAuthenticationFactory; import org.eclipse.jetty.client.api.Request; import org.eclipse.jetty.client.http.HttpClientTransportOverHTTP; import org.eclipse.jetty.client.util.FutureResponseListener; import org.eclipse.jetty.http.HttpScheme; +import org.eclipse.jetty.http.HttpTester; import org.eclipse.jetty.io.ClientConnector; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.thread.QueuedThreadPool; @@ -47,10 +45,11 @@ import org.junit.jupiter.api.Test; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.instanceOf; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -public class Socks5ProxyTest +public class Socks5ProxyTest { private ServerSocketChannel proxy; private HttpClient client; @@ -65,9 +64,7 @@ public class Socks5ProxyTest QueuedThreadPool clientThreads = new QueuedThreadPool(); clientThreads.setName("client"); connector.setExecutor(clientThreads); - connector.setSslContextFactory(new SslContextFactory.Client()); client = new HttpClient(new HttpClientTransportOverHTTP(connector)); - client.setExecutor(clientThreads); client.start(); } @@ -109,45 +106,49 @@ public class Socks5ProxyTest int initLen = 3; ByteBuffer buffer = ByteBuffer.allocate(initLen); int read = channel.read(buffer); + buffer.flip(); assertEquals(initLen, read); - assertEquals(SockConst.VER, buffer.get(0) & 0xFF); - assertEquals(1, buffer.get(1) & 0xFF); - assertEquals(AuthType.NO_AUTH, buffer.get(2) & 0xFF); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(1, buffer.get()); + byte authenticationMethod = Socks5.NoAuthenticationFactory.METHOD; + assertEquals(authenticationMethod, buffer.get()); - // write acceptable methods - channel.write(ByteBuffer.wrap(new byte[]{SockConst.VER, AuthType.NO_AUTH})); + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); - // read addr + // Read server address. int addrLen = 10; buffer = ByteBuffer.allocate(addrLen); read = channel.read(buffer); - assertEquals(addrLen, read); - assertEquals(SockConst.VER, buffer.get(0) & 0xFF); - assertEquals(Command.CONNECT, buffer.get(1) & 0xFF); - assertEquals(SockConst.RSV, buffer.get(2) & 0xFF); - assertEquals(AddrType.IPV4, buffer.get(3) & 0xFF); - assertEquals(ip1, buffer.get(4) & 0xFF); - assertEquals(ip2, buffer.get(5) & 0xFF); - assertEquals(ip3, buffer.get(6) & 0xFF); - assertEquals(ip4, buffer.get(7) & 0xFF); - assertEquals(serverPort, buffer.getShort(8) & 0xFFFF); - - // Socks5 connect response. - channel.write(ByteBuffer.wrap(new byte[]{SockConst.VER, SockConst.SUCCEEDED, SockConst.RSV, AddrType.IPV4, 0, 0, 0, 0, 0, 0})); - - buffer = ByteBuffer.allocate(method.length() + 1 + path.length()); - read = channel.read(buffer); - assertEquals(buffer.capacity(), read); buffer.flip(); - assertEquals(method + " " + path, StandardCharsets.UTF_8.decode(buffer).toString()); + assertEquals(addrLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(Socks5.COMMAND_CONNECT, buffer.get()); + assertEquals(Socks5.RESERVED, buffer.get()); + assertEquals(Socks5.ADDRESS_TYPE_IPV4, buffer.get()); + assertEquals(ip1, buffer.get()); + assertEquals(ip2, buffer.get()); + assertEquals(ip3, buffer.get()); + assertEquals(ip4, buffer.get()); + assertEquals(serverPort, buffer.getShort() & 0xFFFF); - // http response - String response = - "HTTP/1.1 200 OK\r\n" + - "Content-Length: 0\r\n" + - "Connection: close\r\n" + - "\r\n"; - channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.UTF_8))); + // Write connect response. + channel.write(ByteBuffer.wrap(new byte[]{ + Socks5.VERSION, 0, Socks5.RESERVED, Socks5.ADDRESS_TYPE_IPV4, 127, 0, 0, 2, 13, 13 + })); + + // Parse the HTTP request. + HttpTester.Request request = HttpTester.parseRequest(channel); + assertNotNull(request); + assertEquals(method, request.getMethod()); + assertEquals(path, request.getUri()); + + // Write the HTTP response. + String response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n"; + channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.US_ASCII))); assertTrue(latch.await(5, TimeUnit.SECONDS)); } @@ -180,46 +181,51 @@ public class Socks5ProxyTest int initLen = 3; ByteBuffer buffer = ByteBuffer.allocate(initLen); int read = channel.read(buffer); + buffer.flip(); assertEquals(initLen, read); - assertEquals(SockConst.VER, buffer.get(0) & 0xFF); - assertEquals(1, buffer.get(1) & 0xFF); - assertEquals(AuthType.NO_AUTH, buffer.get(2) & 0xFF); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(1, buffer.get()); + byte authenticationMethod = Socks5.NoAuthenticationFactory.METHOD; + assertEquals(authenticationMethod, buffer.get()); - // write acceptable methods - channel.write(ByteBuffer.wrap(new byte[]{SockConst.VER, AuthType.NO_AUTH})); + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); - // read addr + // Read server address. int addrLen = 7 + serverHost.length(); buffer = ByteBuffer.allocate(addrLen); read = channel.read(buffer); + buffer.flip(); assertEquals(addrLen, read); - buffer.flip(); - byte[] bs = buffer.array(); - assertEquals(SockConst.VER, bs[0] & 0xFF); - assertEquals(Command.CONNECT, bs[1] & 0xFF); - assertEquals(SockConst.RSV, bs[2] & 0xFF); - assertEquals(AddrType.DOMAIN_NAME, bs[3] & 0xFF); - int hLen = bs[4] & 0xFF; - assertEquals(serverHost.length(), hLen); - assertEquals(serverHost, new String(bs, 5, hLen, StandardCharsets.UTF_8)); - assertEquals(serverPort, buffer.getShort(5 + hLen) & 0xFFFF); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(Socks5.COMMAND_CONNECT, buffer.get()); + assertEquals(Socks5.RESERVED, buffer.get()); + assertEquals(Socks5.ADDRESS_TYPE_DOMAIN, buffer.get()); + int hostLen = buffer.get() & 0xFF; + assertEquals(serverHost.length(), hostLen); + int limit = buffer.limit(); + buffer.limit(buffer.position() + hostLen); + assertEquals(serverHost, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + assertEquals(serverPort, buffer.getShort() & 0xFFFF); - // Socks5 connect response. - channel.write(ByteBuffer.wrap(new byte[]{SockConst.VER, SockConst.SUCCEEDED, SockConst.RSV, AddrType.IPV4, 0, 0, 0, 0, 0, 0})); + // Write connect response. + channel.write(ByteBuffer.wrap(new byte[]{ + Socks5.VERSION, 0, Socks5.RESERVED, Socks5.ADDRESS_TYPE_IPV4, 127, 0, 0, 3, 11, 11 + })); - buffer = ByteBuffer.allocate(method.length() + 1 + path.length()); - read = channel.read(buffer); - assertEquals(buffer.capacity(), read); - buffer.flip(); - assertEquals(method + " " + path, StandardCharsets.UTF_8.decode(buffer).toString()); + // Parse the HTTP request. + HttpTester.Request request = HttpTester.parseRequest(channel); + assertNotNull(request); + assertEquals(method, request.getMethod()); + assertEquals(path, request.getUri()); - // http response - String response = - "HTTP/1.1 200 OK\r\n" + - "Content-Length: 0\r\n" + - "Connection: close\r\n" + - "\r\n"; - channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.UTF_8))); + // Write the HTTP response. + String response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n"; + channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.US_ASCII))); assertTrue(latch.await(5, TimeUnit.SECONDS)); } @@ -231,8 +237,9 @@ public class Socks5ProxyTest String username = "jetty"; String password = "pass"; int proxyPort = proxy.socket().getLocalPort(); - client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort) - .addAuthentication(new UsernamePasswordAuthentication(username, password))); + Socks5Proxy socks5Proxy = new Socks5Proxy("127.0.0.1", proxyPort); + socks5Proxy.putAuthenticationFactory(new UsernamePasswordAuthenticationFactory(username, password)); + client.getProxyConfiguration().addProxy(socks5Proxy); CountDownLatch latch = new CountDownLatch(1); @@ -259,79 +266,83 @@ public class Socks5ProxyTest int initLen = 2; ByteBuffer buffer = ByteBuffer.allocate(initLen); int read = channel.read(buffer); + buffer.flip(); assertEquals(initLen, read); - assertEquals(SockConst.VER, buffer.get(0) & 0xFF); - int authTypeLen = buffer.get(1) & 0xFF; + assertEquals(Socks5.VERSION, buffer.get()); + int authTypeLen = buffer.get() & 0xFF; assertTrue(authTypeLen > 0); buffer = ByteBuffer.allocate(authTypeLen); read = channel.read(buffer); - - // assert contains username password authorization - assertEquals(authTypeLen, read); buffer.flip(); + assertEquals(authTypeLen, read); byte[] authTypes = new byte[authTypeLen]; buffer.get(authTypes); - assertTrue(containAuthType(authTypes, AuthType.USER_PASS)); + byte authenticationMethod = Socks5.UsernamePasswordAuthenticationFactory.METHOD; + assertTrue(containsAuthType(authTypes, authenticationMethod)); - // write acceptable methods - channel.write(ByteBuffer.wrap(new byte[]{SockConst.VER, AuthType.USER_PASS})); + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); - // read username password + // Read authentication request. buffer = ByteBuffer.allocate(3 + username.length() + password.length()); read = channel.read(buffer); - assertEquals(buffer.capacity(), read); buffer.flip(); - byte[] userPass = buffer.array(); - assertEquals(SockConst.USER_PASS_VER, userPass[0] & 0xFF); - int uLen = userPass[1] & 0xFF; - assertEquals(username.length(), uLen); - assertEquals(username, new String(userPass, 2, uLen, StandardCharsets.UTF_8)); - int pLen = userPass[2 + uLen]; - assertEquals(password.length(), pLen); - assertEquals(password, new String(userPass, 3 + uLen, pLen, StandardCharsets.UTF_8)); + assertEquals(buffer.capacity(), read); + assertEquals(1, buffer.get()); + int usernameLen = buffer.get() & 0xFF; + assertEquals(username.length(), usernameLen); + int limit = buffer.limit(); + buffer.limit(buffer.position() + usernameLen); + assertEquals(username, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + int passwordLen = buffer.get() & 0xFF; + assertEquals(password.length(), passwordLen); + assertEquals(password, StandardCharsets.US_ASCII.decode(buffer).toString()); - // authorization success - channel.write(ByteBuffer.wrap(new byte[]{SockConst.USER_PASS_VER, SockConst.SUCCEEDED})); + // Write authentication response. + channel.write(ByteBuffer.wrap(new byte[]{1, 0})); - // read addr + // Read server address. int addrLen = 10; buffer = ByteBuffer.allocate(addrLen); read = channel.read(buffer); - assertEquals(addrLen, read); - assertEquals(SockConst.VER, buffer.get(0) & 0xFF); - assertEquals(Command.CONNECT, buffer.get(1) & 0xFF); - assertEquals(SockConst.RSV, buffer.get(2) & 0xFF); - assertEquals(AddrType.IPV4, buffer.get(3) & 0xFF); - assertEquals(ip1, buffer.get(4) & 0xFF); - assertEquals(ip2, buffer.get(5) & 0xFF); - assertEquals(ip3, buffer.get(6) & 0xFF); - assertEquals(ip4, buffer.get(7) & 0xFF); - assertEquals(serverPort, buffer.getShort(8) & 0xFFFF); - - // Socks5 connect response. - channel.write(ByteBuffer.wrap(new byte[]{SockConst.VER, SockConst.SUCCEEDED, SockConst.RSV, AddrType.IPV4, 0, 0, 0, 0, 0, 0})); - - buffer = ByteBuffer.allocate(method.length() + 1 + path.length()); - read = channel.read(buffer); - assertEquals(buffer.capacity(), read); buffer.flip(); - assertEquals(method + " " + path, StandardCharsets.UTF_8.decode(buffer).toString()); + assertEquals(addrLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(Socks5.COMMAND_CONNECT, buffer.get()); + assertEquals(Socks5.RESERVED, buffer.get()); + assertEquals(Socks5.ADDRESS_TYPE_IPV4, buffer.get()); + assertEquals(ip1, buffer.get()); + assertEquals(ip2, buffer.get()); + assertEquals(ip3, buffer.get()); + assertEquals(ip4, buffer.get()); + assertEquals(serverPort, buffer.getShort() & 0xFFFF); - // http response - String response = - "HTTP/1.1 200 OK\r\n" + - "Content-Length: 0\r\n" + - "Connection: close\r\n" + - "\r\n"; - channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.UTF_8))); + // Write connect response. + channel.write(ByteBuffer.wrap(new byte[]{ + Socks5.VERSION, 0, Socks5.RESERVED, Socks5.ADDRESS_TYPE_IPV4, 127, 0, 0, 4, 17, 17 + })); + + // Parse the HTTP request. + HttpTester.Request request = HttpTester.parseRequest(channel); + assertNotNull(request); + assertEquals(method, request.getMethod()); + assertEquals(path, request.getUri()); + + // Write the HTTP response. + String response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n"; + channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.US_ASCII))); assertTrue(latch.await(5, TimeUnit.SECONDS)); } } @Test - public void testSocks5ProxyIpv4AuthNoAcceptable() throws Exception + public void testSocks5ProxyAuthNoAcceptable() throws Exception { int proxyPort = proxy.socket().getLocalPort(); client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort)); @@ -356,22 +367,24 @@ public class Socks5ProxyTest int read = channel.read(buffer); assertEquals(initLen, read); - // write acceptable methods - channel.write(ByteBuffer.wrap(new byte[]{SockConst.VER, AuthType.NO_ACCEPTABLE})); + // Deny authentication method. + byte notAcceptable = -1; + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, notAcceptable})); ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(2 * timeout, TimeUnit.MILLISECONDS)); - assertThat(x.getCause(), instanceOf(SocketException.class)); + assertThat(x.getCause(), instanceOf(IOException.class)); } } @Test - public void testSocks5ProxyIpv4UsernamePasswordAuthFailed() throws Exception + public void testSocks5ProxyUsernamePasswordAuthFailed() throws Exception { String username = "jetty"; String password = "pass"; int proxyPort = proxy.socket().getLocalPort(); - client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort) - .addAuthentication(new UsernamePasswordAuthentication(username, password))); + Socks5Proxy socks5Proxy = new Socks5Proxy("127.0.0.1", proxyPort); + socks5Proxy.putAuthenticationFactory(new UsernamePasswordAuthenticationFactory(username, password)); + client.getProxyConfiguration().addProxy(socks5Proxy); long timeout = 1000; String serverHost = "127.0.0.13"; @@ -391,43 +404,46 @@ public class Socks5ProxyTest int initLen = 2; ByteBuffer buffer = ByteBuffer.allocate(initLen); int read = channel.read(buffer); + buffer.flip(); assertEquals(initLen, read); - assertEquals(SockConst.VER, buffer.get(0) & 0xFF); - int authTypeLen = buffer.get(1) & 0xFF; + assertEquals(Socks5.VERSION, buffer.get()); + int authTypeLen = buffer.get() & 0xFF; assertTrue(authTypeLen > 0); buffer = ByteBuffer.allocate(authTypeLen); read = channel.read(buffer); - - // assert contains username password authorization - assertEquals(authTypeLen, read); buffer.flip(); + assertEquals(authTypeLen, read); byte[] authTypes = new byte[authTypeLen]; buffer.get(authTypes); - assertTrue(containAuthType(authTypes, AuthType.USER_PASS)); + byte authenticationMethod = Socks5.UsernamePasswordAuthenticationFactory.METHOD; + assertTrue(containsAuthType(authTypes, authenticationMethod)); - // write acceptable methods - channel.write(ByteBuffer.wrap(new byte[]{SockConst.VER, AuthType.USER_PASS})); + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); - // read username password + // Read authentication request. buffer = ByteBuffer.allocate(3 + username.length() + password.length()); read = channel.read(buffer); - assertEquals(buffer.capacity(), read); buffer.flip(); - byte[] userPass = buffer.array(); - assertEquals(SockConst.USER_PASS_VER, userPass[0] & 0xFF); - int uLen = userPass[1] & 0xFF; - assertEquals(username.length(), uLen); - assertEquals(username, new String(userPass, 2, uLen, StandardCharsets.UTF_8)); - int pLen = userPass[2 + uLen]; - assertEquals(password.length(), pLen); - assertEquals(password, new String(userPass, 3 + uLen, pLen, StandardCharsets.UTF_8)); + assertEquals(buffer.capacity(), read); + assertEquals(1, buffer.get()); + int usernameLen = buffer.get() & 0xFF; + assertEquals(username.length(), usernameLen); + int limit = buffer.limit(); + buffer.limit(buffer.position() + usernameLen); + assertEquals(username, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + int passwordLen = buffer.get() & 0xFF; + assertEquals(password.length(), passwordLen); + assertEquals(password, StandardCharsets.US_ASCII.decode(buffer).toString()); - // authorization failed - channel.write(ByteBuffer.wrap(new byte[]{SockConst.USER_PASS_VER, SockConst.AUTH_FAILED})); + // Fail authentication. + byte authenticationFailed = 1; // Any non-zero. + channel.write(ByteBuffer.wrap(new byte[]{1, authenticationFailed})); ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(2 * timeout, TimeUnit.MILLISECONDS)); - assertThat(x.getCause(), instanceOf(SocketException.class)); + assertThat(x.getCause(), instanceOf(IOException.class)); } } @@ -437,8 +453,9 @@ public class Socks5ProxyTest String username = "jetty"; String password = "pass"; int proxyPort = proxy.socket().getLocalPort(); - client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort) - .addAuthentication(new UsernamePasswordAuthentication(username, password))); + Socks5Proxy socks5Proxy = new Socks5Proxy("127.0.0.1", proxyPort); + socks5Proxy.putAuthenticationFactory(new UsernamePasswordAuthenticationFactory(username, password)); + client.getProxyConfiguration().addProxy(socks5Proxy); CountDownLatch latch = new CountDownLatch(1); @@ -461,73 +478,78 @@ public class Socks5ProxyTest int initLen = 2; ByteBuffer buffer = ByteBuffer.allocate(initLen); int read = channel.read(buffer); + buffer.flip(); assertEquals(initLen, read); - assertEquals(SockConst.VER, buffer.get(0) & 0xFF); - int authTypeLen = buffer.get(1) & 0xFF; + assertEquals(Socks5.VERSION, buffer.get()); + int authTypeLen = buffer.get() & 0xFF; assertTrue(authTypeLen > 0); buffer = ByteBuffer.allocate(authTypeLen); read = channel.read(buffer); - - // assert contains username password authorization - assertEquals(authTypeLen, read); buffer.flip(); + assertEquals(authTypeLen, read); byte[] authTypes = new byte[authTypeLen]; buffer.get(authTypes); - assertTrue(containAuthType(authTypes, AuthType.USER_PASS)); + byte authenticationMethod = Socks5.UsernamePasswordAuthenticationFactory.METHOD; + assertTrue(containsAuthType(authTypes, authenticationMethod)); - // write acceptable methods - channel.write(ByteBuffer.wrap(new byte[]{SockConst.VER, AuthType.USER_PASS})); + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); - // read username password + // Read authentication request. buffer = ByteBuffer.allocate(3 + username.length() + password.length()); read = channel.read(buffer); - assertEquals(buffer.capacity(), read); buffer.flip(); - byte[] userPass = buffer.array(); - assertEquals(SockConst.USER_PASS_VER, userPass[0] & 0xFF); - int uLen = userPass[1] & 0xFF; - assertEquals(username.length(), uLen); - assertEquals(username, new String(userPass, 2, uLen, StandardCharsets.UTF_8)); - int pLen = userPass[2 + uLen]; - assertEquals(password.length(), pLen); - assertEquals(password, new String(userPass, 3 + uLen, pLen, StandardCharsets.UTF_8)); + assertEquals(buffer.capacity(), read); + assertEquals(1, buffer.get()); + int usernameLen = buffer.get() & 0xFF; + assertEquals(username.length(), usernameLen); + int limit = buffer.limit(); + buffer.limit(buffer.position() + usernameLen); + assertEquals(username, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + int passwordLen = buffer.get() & 0xFF; + assertEquals(password.length(), passwordLen); + assertEquals(password, StandardCharsets.US_ASCII.decode(buffer).toString()); - // authorization success - channel.write(ByteBuffer.wrap(new byte[]{SockConst.USER_PASS_VER, SockConst.SUCCEEDED})); + // Write authentication response. + channel.write(ByteBuffer.wrap(new byte[]{1, 0})); - // read addr + // Read server address. int addrLen = 7 + serverHost.length(); buffer = ByteBuffer.allocate(addrLen); read = channel.read(buffer); + buffer.flip(); assertEquals(addrLen, read); - buffer.flip(); - byte[] bs = buffer.array(); - assertEquals(SockConst.VER, bs[0] & 0xFF); - assertEquals(Command.CONNECT, bs[1] & 0xFF); - assertEquals(SockConst.RSV, bs[2] & 0xFF); - assertEquals(AddrType.DOMAIN_NAME, bs[3] & 0xFF); - int hLen = bs[4] & 0xFF; - assertEquals(serverHost.length(), hLen); - assertEquals(serverHost, new String(bs, 5, hLen, StandardCharsets.UTF_8)); - assertEquals(serverPort, buffer.getShort(5 + hLen) & 0xFFFF); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(Socks5.COMMAND_CONNECT, buffer.get()); + assertEquals(Socks5.RESERVED, buffer.get()); + assertEquals(Socks5.ADDRESS_TYPE_DOMAIN, buffer.get()); + int domainLen = buffer.get() & 0xFF; + assertEquals(serverHost.length(), domainLen); + limit = buffer.limit(); + buffer.limit(buffer.position() + domainLen); + assertEquals(serverHost, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + assertEquals(serverPort, buffer.getShort() & 0xFFFF); - // Socks5 connect response. - channel.write(ByteBuffer.wrap(new byte[]{SockConst.VER, SockConst.SUCCEEDED, SockConst.RSV, AddrType.IPV4, 0, 0, 0, 0, 0, 0})); + // Write connect response. + channel.write(ByteBuffer.wrap(new byte[]{ + Socks5.VERSION, 0, Socks5.RESERVED, Socks5.ADDRESS_TYPE_IPV4, 127, 0, 0, 5, 19, 19 + })); - buffer = ByteBuffer.allocate(method.length() + 1 + path.length()); - read = channel.read(buffer); - assertEquals(buffer.capacity(), read); - buffer.flip(); - assertEquals(method + " " + path, StandardCharsets.UTF_8.decode(buffer).toString()); + // Parse the HTTP request. + HttpTester.Request request = HttpTester.parseRequest(channel); + assertNotNull(request); + assertEquals(method, request.getMethod()); + assertEquals(path, request.getUri()); - // http response - String response = - "HTTP/1.1 200 OK\r\n" + - "Content-Length: 0\r\n" + - "Connection: close\r\n" + - "\r\n"; - channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.UTF_8))); + // Write the HTTP response. + String response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n"; + channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.US_ASCII))); assertTrue(latch.await(5, TimeUnit.SECONDS)); } @@ -539,8 +561,9 @@ public class Socks5ProxyTest String username = "jetty"; String password = "pass"; int proxyPort = proxy.socket().getLocalPort(); - client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort) - .addAuthentication(new UsernamePasswordAuthentication(username, password))); + Socks5Proxy socks5Proxy = new Socks5Proxy("127.0.0.1", proxyPort); + socks5Proxy.putAuthenticationFactory(new UsernamePasswordAuthenticationFactory(username, password)); + client.getProxyConfiguration().addProxy(socks5Proxy); CountDownLatch latch = new CountDownLatch(1); @@ -563,78 +586,81 @@ public class Socks5ProxyTest int initLen = 2; ByteBuffer buffer = ByteBuffer.allocate(initLen); int read = channel.read(buffer); + buffer.flip(); assertEquals(initLen, read); - assertEquals(SockConst.VER, buffer.get(0) & 0xFF); - int authTypeLen = buffer.get(1) & 0xFF; + assertEquals(Socks5.VERSION, buffer.get()); + int authTypeLen = buffer.get() & 0xFF; assertTrue(authTypeLen > 0); buffer = ByteBuffer.allocate(authTypeLen); read = channel.read(buffer); - - // assert contains username password authorization - assertEquals(authTypeLen, read); buffer.flip(); + assertEquals(authTypeLen, read); byte[] authTypes = new byte[authTypeLen]; buffer.get(authTypes); - assertTrue(containAuthType(authTypes, AuthType.USER_PASS)); + byte authenticationMethod = Socks5.UsernamePasswordAuthenticationFactory.METHOD; + assertTrue(containsAuthType(authTypes, authenticationMethod)); - // write acceptable methods - channel.write(ByteBuffer.wrap(new byte[]{SockConst.VER, AuthType.USER_PASS})); + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); - // read username password + // Read authentication request. buffer = ByteBuffer.allocate(3 + username.length() + password.length()); read = channel.read(buffer); - assertEquals(buffer.capacity(), read); buffer.flip(); - byte[] userPass = buffer.array(); - assertEquals(SockConst.USER_PASS_VER, userPass[0] & 0xFF); - int uLen = userPass[1] & 0xFF; - assertEquals(username.length(), uLen); - assertEquals(username, new String(userPass, 2, uLen, StandardCharsets.UTF_8)); - int pLen = userPass[2 + uLen]; - assertEquals(password.length(), pLen); - assertEquals(password, new String(userPass, 3 + uLen, pLen, StandardCharsets.UTF_8)); + assertEquals(buffer.capacity(), read); + assertEquals(1, buffer.get()); + int usernameLen = buffer.get() & 0xFF; + assertEquals(username.length(), usernameLen); + int limit = buffer.limit(); + buffer.limit(buffer.position() + usernameLen); + assertEquals(username, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + int passwordLen = buffer.get() & 0xFF; + assertEquals(password.length(), passwordLen); + assertEquals(password, StandardCharsets.US_ASCII.decode(buffer).toString()); - // authorization success - channel.write(ByteBuffer.wrap(new byte[]{SockConst.USER_PASS_VER, SockConst.SUCCEEDED})); + // Write authentication response. + channel.write(ByteBuffer.wrap(new byte[]{1, 0})); - // read addr + // Read server address. int addrLen = 7 + serverHost.length(); buffer = ByteBuffer.allocate(addrLen); read = channel.read(buffer); - assertEquals(addrLen, read); buffer.flip(); - byte[] bs = buffer.array(); - assertEquals(SockConst.VER, bs[0] & 0xFF); - assertEquals(Command.CONNECT, bs[1] & 0xFF); - assertEquals(SockConst.RSV, bs[2] & 0xFF); - assertEquals(AddrType.DOMAIN_NAME, bs[3] & 0xFF); - int hLen = bs[4] & 0xFF; - assertEquals(serverHost.length(), hLen); - assertEquals(serverHost, new String(bs, 5, hLen, StandardCharsets.UTF_8)); - assertEquals(serverPort, buffer.getShort(5 + hLen) & 0xFFFF); + assertEquals(addrLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(Socks5.COMMAND_CONNECT, buffer.get()); + assertEquals(Socks5.RESERVED, buffer.get()); + assertEquals(Socks5.ADDRESS_TYPE_DOMAIN, buffer.get()); + int domainLen = buffer.get() & 0xFF; + assertEquals(serverHost.length(), domainLen); + limit = buffer.limit(); + buffer.limit(buffer.position() + domainLen); + assertEquals(serverHost, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + assertEquals(serverPort, buffer.getShort() & 0xFFFF); - // Socks5 connect response. - byte[] chunk1 = new byte[]{SockConst.VER, SockConst.SUCCEEDED, SockConst.RSV, AddrType.IPV4}; - byte[] chunk2 = new byte[]{0, 0, 0, 0, 0, 0}; + // Write connect response. + byte[] chunk1 = new byte[]{Socks5.VERSION, 0, Socks5.RESERVED, Socks5.ADDRESS_TYPE_IPV4}; channel.write(ByteBuffer.wrap(chunk1)); // Wait before sending the second chunk. Thread.sleep(1000); + byte[] chunk2 = new byte[]{127, 0, 0, 6, 21, 21}; channel.write(ByteBuffer.wrap(chunk2)); - buffer = ByteBuffer.allocate(method.length() + 1 + path.length()); - read = channel.read(buffer); - assertEquals(buffer.capacity(), read); - buffer.flip(); - assertEquals(method + " " + path, StandardCharsets.UTF_8.decode(buffer).toString()); + // Parse the HTTP request. + HttpTester.Request request = HttpTester.parseRequest(channel); + assertNotNull(request); + assertEquals(method, request.getMethod()); + assertEquals(path, request.getUri()); - // http response - String response = - "HTTP/1.1 200 OK\r\n" + - "Content-Length: 0\r\n" + - "Connection: close\r\n" + - "\r\n"; - channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.UTF_8))); + // Write the HTTP response. + String response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n"; + channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.US_ASCII))); assertTrue(latch.await(5, TimeUnit.SECONDS)); } @@ -647,7 +673,7 @@ public class Socks5ProxyTest String serverHost = "127.0.0.13"; // Server host different from proxy host. int serverPort = proxyPort + 1; // Any port will do. - SslContextFactory clientTLS = client.getSslContextFactory(); + SslContextFactory.Client clientTLS = client.getSslContextFactory(); clientTLS.reload(ssl -> { // The client keystore contains the trustedCertEntry for the @@ -682,19 +708,23 @@ public class Socks5ProxyTest int initLen = 3; ByteBuffer buffer = ByteBuffer.allocate(initLen); int read = channel.read(buffer); + buffer.flip(); assertEquals(initLen, read); - // write acceptable methods - channel.write(ByteBuffer.wrap(new byte[]{SockConst.VER, AuthType.NO_AUTH})); + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, Socks5.NoAuthenticationFactory.METHOD})); - // read addr + // Read server address. int addrLen = 10; buffer = ByteBuffer.allocate(addrLen); read = channel.read(buffer); + buffer.flip(); assertEquals(addrLen, read); - // Socks5 connect response. - channel.write(ByteBuffer.wrap(new byte[]{SockConst.VER, SockConst.SUCCEEDED, SockConst.RSV, AddrType.IPV4, 0, 0, 0, 0, 0, 0})); + // Write connect response. + channel.write(ByteBuffer.wrap(new byte[]{ + Socks5.VERSION, 0, Socks5.RESERVED, Socks5.ADDRESS_TYPE_IPV4, 127, 0, 0, 7, 23, 23 + })); // Wrap the socket with TLS. SslContextFactory.Server serverTLS = new SslContextFactory.Server(); @@ -705,30 +735,19 @@ public class Socks5ProxyTest SSLSocket sslSocket = (SSLSocket)sslContext.getSocketFactory().createSocket(channel.socket(), serverHost, serverPort, false); sslSocket.setUseClientMode(false); - // Read the request. - int crlfs = 0; - InputStream input = sslSocket.getInputStream(); - while (true) - { - read = input.read(); - if (read < 0) - break; - if (read == '\r' || read == '\n') - ++crlfs; - else - crlfs = 0; - if (crlfs == 4) - break; - } + // Parse the HTTP request. + HttpTester.Request request = HttpTester.parseRequest(sslSocket.getInputStream()); + assertNotNull(request); + assertEquals(method, request.getMethod()); + assertEquals(path, request.getUri()); - // Send the response. - String response = - "HTTP/1.1 200 OK\r\n" + - "Content-Length: 0\r\n" + - "Connection: close\r\n" + - "\r\n"; + // Write the HTTP response. + String response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n"; OutputStream output = sslSocket.getOutputStream(); - output.write(response.getBytes(StandardCharsets.UTF_8)); + output.write(response.getBytes(StandardCharsets.US_ASCII)); output.flush(); assertTrue(latch.await(5, TimeUnit.SECONDS)); @@ -761,7 +780,7 @@ public class Socks5ProxyTest } @Test - public void testSocksProxyClosesConnectionImmediately() throws Exception + public void testSocksProxyClosesConnectionInHandshake() throws Exception { int proxyPort = proxy.socket().getLocalPort(); client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort)); @@ -779,7 +798,112 @@ public class Socks5ProxyTest channel.close(); ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(5, TimeUnit.SECONDS)); - assertThat(x.getCause(), instanceOf(SocketException.class)); + assertThat(x.getCause(), instanceOf(ClosedChannelException.class)); + } + } + + @Test + public void testSocksProxyClosesConnectionInAuthentication() throws Exception + { + String username = "jetty"; + String password = "pass"; + int proxyPort = proxy.socket().getLocalPort(); + Socks5Proxy socks5Proxy = new Socks5Proxy("127.0.0.1", proxyPort); + socks5Proxy.putAuthenticationFactory(new UsernamePasswordAuthenticationFactory(username, password)); + client.getProxyConfiguration().addProxy(socks5Proxy); + + // Use an address to avoid resolution of "localhost" to multiple addresses. + String serverHost = "127.0.0.13"; + int serverPort = proxyPort + 1; // Any port will do + Request request = client.newRequest(serverHost, serverPort); + FutureResponseListener listener = new FutureResponseListener(request); + request.send(listener); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 2; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + int authTypeLen = buffer.get() & 0xFF; + assertTrue(authTypeLen > 0); + + buffer = ByteBuffer.allocate(authTypeLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(authTypeLen, read); + byte[] authTypes = new byte[authTypeLen]; + buffer.get(authTypes); + byte authenticationMethod = Socks5.UsernamePasswordAuthenticationFactory.METHOD; + assertTrue(containsAuthType(authTypes, authenticationMethod)); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); + + // Read authentication request. + buffer = ByteBuffer.allocate(3 + username.length() + password.length()); + read = channel.read(buffer); + buffer.flip(); + assertEquals(buffer.capacity(), read); + assertEquals(1, buffer.get()); + int usernameLen = buffer.get() & 0xFF; + assertEquals(username.length(), usernameLen); + int limit = buffer.limit(); + buffer.limit(buffer.position() + usernameLen); + assertEquals(username, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + int passwordLen = buffer.get() & 0xFF; + assertEquals(password.length(), passwordLen); + assertEquals(password, StandardCharsets.US_ASCII.decode(buffer).toString()); + + channel.close(); + + ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(5, TimeUnit.SECONDS)); + assertThat(x.getCause(), instanceOf(ClosedChannelException.class)); + } + } + + @Test + public void testSocksProxyClosesConnectionInConnect() throws Exception + { + int proxyPort = proxy.socket().getLocalPort(); + client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort)); + + // Use an address to avoid resolution of "localhost" to multiple addresses. + String serverHost = "127.0.0.13"; + int serverPort = proxyPort + 1; // Any port will do + Request request = client.newRequest(serverHost, serverPort); + FutureResponseListener listener = new FutureResponseListener(request); + request.send(listener); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 3; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(1, buffer.get()); + byte authenticationMethod = Socks5.NoAuthenticationFactory.METHOD; + assertEquals(authenticationMethod, buffer.get()); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); + + // Read server address. + int addrLen = 10; + buffer = ByteBuffer.allocate(addrLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(addrLen, read); + + channel.close(); + + ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(5, TimeUnit.SECONDS)); + assertThat(x.getCause(), instanceOf(ClosedChannelException.class)); } } @@ -801,18 +925,140 @@ public class Socks5ProxyTest channel.write(ByteBuffer.wrap(new byte[]{1, 2, 3, 4, 5})); ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(5, TimeUnit.SECONDS)); - assertThat(x.getCause(), instanceOf(SocketException.class)); + assertThat(x.getCause(), instanceOf(IOException.class)); } } - private boolean containAuthType(byte[] methods, byte method) + @Test + public void testSocks5ProxyConnectFailed() throws Exception + { + int proxyPort = proxy.socket().getLocalPort(); + client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort)); + + String serverHost = "127.0.0.13"; + int serverPort = proxyPort + 1; // Any port will do + Request request = client.newRequest(serverHost, serverPort); + FutureResponseListener listener = new FutureResponseListener(request); + request.send(listener); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 3; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, Socks5.NoAuthenticationFactory.METHOD})); + + // Read server address. + int addrLen = 10; + buffer = ByteBuffer.allocate(addrLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(addrLen, read); + + // Write connect response failure. + channel.write(ByteBuffer.wrap(new byte[]{ + Socks5.VERSION, 1, Socks5.RESERVED, Socks5.ADDRESS_TYPE_IPV4, 127, 0, 0, 8, 29, 29 + })); + + ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(5, TimeUnit.SECONDS)); + assertThat(x.getCause(), instanceOf(IOException.class)); + } + } + + @Test + public void testSocks5ProxyIPv6NoAuth() throws Exception + { + int proxyPort = proxy.socket().getLocalPort(); + client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort)); + + CountDownLatch latch = new CountDownLatch(1); + + String serverHost = "::13"; + int serverPort = proxyPort + 1; // Any port will do + String method = "GET"; + String path = "/path"; + client.newRequest(serverHost, serverPort) + .method(method) + .path(path) + .timeout(5, TimeUnit.SECONDS) + .send(result -> + { + if (result.isSucceeded()) + latch.countDown(); + }); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 3; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, Socks5.NoAuthenticationFactory.METHOD})); + + // Read server address. + int addrLen = 22; + buffer = ByteBuffer.allocate(addrLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(addrLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(Socks5.COMMAND_CONNECT, buffer.get()); + assertEquals(Socks5.RESERVED, buffer.get()); + assertEquals(Socks5.ADDRESS_TYPE_IPV6, buffer.get()); + for (int i = 0; i < 15; ++i) + { + assertEquals(0, buffer.get()); + } + assertEquals(0x13, buffer.get()); + assertEquals(serverPort, buffer.getShort() & 0xFFFF); + + // Write connect response. + ByteBuffer byteBuffer = ByteBuffer.allocate(22) + .put(Socks5.VERSION) + .put((byte)0) + .put(Socks5.RESERVED) + .put(Socks5.ADDRESS_TYPE_IPV6) + .put(InetAddress.getByName(serverHost).getAddress()) + .putShort((short)3131) + .flip(); + // Write slowly 1 byte at a time. + for (int limit = 1; limit <= buffer.capacity(); ++limit) + { + byteBuffer.limit(limit); + channel.write(byteBuffer); + Thread.sleep(100); + } + + // Parse the HTTP request. + HttpTester.Request request = HttpTester.parseRequest(channel); + assertNotNull(request); + assertEquals(method, request.getMethod()); + assertEquals(path, request.getUri()); + + // Write the HTTP response. + String response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n"; + channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.US_ASCII))); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } + } + + private boolean containsAuthType(byte[] methods, byte method) { for (byte m : methods) { if (m == method) - { return true; - } } return false; } diff --git a/jetty-http/src/main/java/org/eclipse/jetty/http/HttpTester.java b/jetty-http/src/main/java/org/eclipse/jetty/http/HttpTester.java index 9cc0c59242b..52af1cdc97b 100644 --- a/jetty-http/src/main/java/org/eclipse/jetty/http/HttpTester.java +++ b/jetty-http/src/main/java/org/eclipse/jetty/http/HttpTester.java @@ -176,6 +176,36 @@ public class HttpTester return r; } + public static Request parseRequest(InputStream inputStream) throws IOException + { + return parseRequest(from(inputStream)); + } + + public static Request parseRequest(ReadableByteChannel channel) throws IOException + { + return parseRequest(from(channel)); + } + + public static Request parseRequest(Input input) throws IOException + { + Request request; + HttpParser parser = input.takeHttpParser(); + if (parser != null) + { + request = (Request)parser.getHandler(); + } + else + { + request = newRequest(); + parser = new HttpParser(request); + } + parse(input, parser); + if (request.isComplete()) + return request; + input.setHttpParser(parser); + return null; + } + public static Response parseResponse(String response) { Response r = new Response(); @@ -230,7 +260,7 @@ public class HttpTester else r = (Response)parser.getHandler(); - parseResponse(in, parser, r); + parse(in, parser); if (r.isComplete()) return r; @@ -246,13 +276,13 @@ public class HttpTester { parser = new HttpParser(response); } - parseResponse(in, parser, response); + parse(in, parser); if (!response.isComplete()) in.setHttpParser(parser); } - private static void parseResponse(Input in, HttpParser parser, Response r) throws IOException + private static void parse(Input in, HttpParser parser) throws IOException { ByteBuffer buffer = in.getBuffer();