Socks5ClientConnector.java
/*
* Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch>
* and other copyright owners as documented in the project's IP log.
*
* This program and the accompanying materials are made available
* under the terms of the Eclipse Distribution License v1.0 which
* accompanies this distribution, is reproduced below, and is
* available at http://www.eclipse.org/org/documents/edl-v10.php
*
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or
* without modification, are permitted provided that the following
* conditions are met:
*
* - Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* - Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following
* disclaimer in the documentation and/or other materials provided
* with the distribution.
*
* - Neither the name of the Eclipse Foundation, Inc. nor the
* names of its contributors may be used to endorse or promote
* products derived from this software without specific prior
* written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
* ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.eclipse.jgit.internal.transport.sshd.proxy;
import static java.nio.charset.StandardCharsets.US_ASCII;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.text.MessageFormat.format;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import org.apache.sshd.client.session.ClientSession;
import org.apache.sshd.common.io.IoSession;
import org.apache.sshd.common.util.Readable;
import org.apache.sshd.common.util.buffer.Buffer;
import org.apache.sshd.common.util.buffer.BufferUtils;
import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
import org.eclipse.jgit.annotations.NonNull;
import org.eclipse.jgit.internal.transport.sshd.GssApiMechanisms;
import org.eclipse.jgit.internal.transport.sshd.SshdText;
import org.eclipse.jgit.internal.transport.sshd.auth.AuthenticationHandler;
import org.eclipse.jgit.internal.transport.sshd.auth.BasicAuthentication;
import org.eclipse.jgit.internal.transport.sshd.auth.GssApiAuthentication;
import org.eclipse.jgit.transport.SshConstants;
import org.ietf.jgss.GSSContext;
/**
* A {@link AbstractClientProxyConnector} to connect through a SOCKS5 proxy.
*
* @see <a href="https://tools.ietf.org/html/rfc1928">RFC 1928</a>
*/
public class Socks5ClientConnector extends AbstractClientProxyConnector {
// private static final byte SOCKS_VERSION_4 = 4;
private static final byte SOCKS_VERSION_5 = 5;
private static final byte SOCKS_CMD_CONNECT = 1;
// private static final byte SOCKS5_CMD_BIND = 2;
// private static final byte SOCKS5_CMD_UDP_ASSOCIATE = 3;
// Address types
private static final byte SOCKS_ADDRESS_IPv4 = 1;
private static final byte SOCKS_ADDRESS_FQDN = 3;
private static final byte SOCKS_ADDRESS_IPv6 = 4;
// Reply codes
private static final byte SOCKS_REPLY_SUCCESS = 0;
private static final byte SOCKS_REPLY_FAILURE = 1;
private static final byte SOCKS_REPLY_FORBIDDEN = 2;
private static final byte SOCKS_REPLY_NETWORK_UNREACHABLE = 3;
private static final byte SOCKS_REPLY_HOST_UNREACHABLE = 4;
private static final byte SOCKS_REPLY_CONNECTION_REFUSED = 5;
private static final byte SOCKS_REPLY_TTL_EXPIRED = 6;
private static final byte SOCKS_REPLY_COMMAND_UNSUPPORTED = 7;
private static final byte SOCKS_REPLY_ADDRESS_UNSUPPORTED = 8;
/**
* Authentication methods for SOCKS5.
*
* @see <a href=
* "https://www.iana.org/assignments/socks-methods/socks-methods.xhtml">SOCKS
* Methods, IANA.org</a>
*/
private enum SocksAuthenticationMethod {
ANONYMOUS(0),
GSSAPI(1),
PASSWORD(2),
// CHALLENGE_HANDSHAKE(3),
// CHALLENGE_RESPONSE(5),
// SSL(6),
// NDS(7),
// MULTI_AUTH(8),
// JSON(9),
NONE_ACCEPTABLE(0xFF);
private byte value;
SocksAuthenticationMethod(int value) {
this.value = (byte) value;
}
public byte getValue() {
return value;
}
}
private enum ProtocolState {
NONE,
INIT {
@Override
public void handleMessage(Socks5ClientConnector connector,
IoSession session, Buffer data) throws Exception {
connector.versionCheck(data.getByte());
SocksAuthenticationMethod authMethod = connector.getAuthMethod(
data.getByte());
switch (authMethod) {
case ANONYMOUS:
connector.sendConnectInfo(session);
break;
case PASSWORD:
connector.doPasswordAuth(session);
break;
case GSSAPI:
connector.doGssApiAuth(session);
break;
default:
throw new IOException(
format(SshdText.get().proxyCannotAuthenticate,
connector.proxyAddress));
}
}
},
AUTHENTICATING {
@Override
public void handleMessage(Socks5ClientConnector connector,
IoSession session, Buffer data) throws Exception {
connector.authStep(session, data);
}
},
CONNECTING {
@Override
public void handleMessage(Socks5ClientConnector connector,
IoSession session, Buffer data) throws Exception {
// Special case: when GSS-API authentication completes, the
// client moves into CONNECTING as soon as the GSS context is
// established and sends the connect request. This is per RFC
// 1961. But for the server, RFC 1961 says it _should_ send an
// empty token even if none generated when its server side
// context is established. That means we may actually get an
// empty token here. That message is 4 bytes long (and has
// content 0x01, 0x01, 0x00, 0x00). We simply skip this message
// if we get it here. If the server for whatever reason sends
// back a "GSS failed" message (it shouldn't, at this point)
// it will be two bytes 0x01 0xFF, which will fail the version
// check.
if (data.available() != 4) {
connector.versionCheck(data.getByte());
connector.establishConnection(data);
}
}
},
CONNECTED,
FAILED;
public void handleMessage(Socks5ClientConnector connector,
@SuppressWarnings("unused") IoSession session, Buffer data)
throws Exception {
throw new IOException(
format(SshdText.get().proxySocksUnexpectedMessage,
connector.proxyAddress, this,
BufferUtils.toHex(data.array())));
}
}
private ProtocolState state;
private AuthenticationHandler<Buffer, Buffer> authenticator;
private GSSContext context;
private byte[] authenticationProposals;
/**
* Creates a new {@link Socks5ClientConnector}. The connector supports
* anonymous connections as well as username-password or Kerberos5 (GSS-API)
* authentication.
*
* @param proxyAddress
* of the proxy server we're connecting to
* @param remoteAddress
* of the target server to connect to
*/
public Socks5ClientConnector(@NonNull InetSocketAddress proxyAddress,
@NonNull InetSocketAddress remoteAddress) {
this(proxyAddress, remoteAddress, null, null);
}
/**
* Creates a new {@link Socks5ClientConnector}. The connector supports
* anonymous connections as well as username-password or Kerberos5 (GSS-API)
* authentication.
*
* @param proxyAddress
* of the proxy server we're connecting to
* @param remoteAddress
* of the target server to connect to
* @param proxyUser
* to authenticate at the proxy with
* @param proxyPassword
* to authenticate at the proxy with
*/
public Socks5ClientConnector(@NonNull InetSocketAddress proxyAddress,
@NonNull InetSocketAddress remoteAddress,
String proxyUser, char[] proxyPassword) {
super(proxyAddress, remoteAddress, proxyUser, proxyPassword);
this.state = ProtocolState.NONE;
}
@Override
public void sendClientProxyMetadata(ClientSession sshSession)
throws Exception {
init(sshSession);
IoSession session = sshSession.getIoSession();
// Send the initial request
Buffer buffer = new ByteArrayBuffer(5, false);
buffer.putByte(SOCKS_VERSION_5);
context = getGSSContext(remoteAddress);
authenticationProposals = getAuthenticationProposals();
buffer.putByte((byte) authenticationProposals.length);
buffer.putRawBytes(authenticationProposals);
state = ProtocolState.INIT;
session.writePacket(buffer).verify(getTimeout());
}
private byte[] getAuthenticationProposals() {
byte[] proposals = new byte[3];
int i = 0;
proposals[i++] = SocksAuthenticationMethod.ANONYMOUS.getValue();
proposals[i++] = SocksAuthenticationMethod.PASSWORD.getValue();
if (context != null) {
proposals[i++] = SocksAuthenticationMethod.GSSAPI.getValue();
}
if (i == proposals.length) {
return proposals;
} else {
byte[] result = new byte[i];
System.arraycopy(proposals, 0, result, 0, i);
return result;
}
}
private void sendConnectInfo(IoSession session) throws Exception {
GssApiMechanisms.closeContextSilently(context);
byte[] rawAddress = getRawAddress(remoteAddress);
byte[] remoteName = null;
byte type;
int length = 0;
if (rawAddress == null) {
remoteName = remoteAddress.getHostString().getBytes(US_ASCII);
if (remoteName == null || remoteName.length == 0) {
throw new IOException(
format(SshdText.get().proxySocksNoRemoteHostName,
remoteAddress));
} else if (remoteName.length > 255) {
// Should not occur; host names must not be longer than 255
// US_ASCII characters. Internal error, no translation.
throw new IOException(format(
"Proxy host name too long for SOCKS (at most 255 characters): {0}", //$NON-NLS-1$
remoteAddress.getHostString()));
}
type = SOCKS_ADDRESS_FQDN;
length = remoteName.length + 1;
} else {
length = rawAddress.length;
type = length == 4 ? SOCKS_ADDRESS_IPv4 : SOCKS_ADDRESS_IPv6;
}
Buffer buffer = new ByteArrayBuffer(4 + length + 2, false);
buffer.putByte(SOCKS_VERSION_5);
buffer.putByte(SOCKS_CMD_CONNECT);
buffer.putByte((byte) 0); // Reserved
buffer.putByte(type);
if (remoteName != null) {
buffer.putByte((byte) remoteName.length);
buffer.putRawBytes(remoteName);
} else {
buffer.putRawBytes(rawAddress);
}
int port = remoteAddress.getPort();
if (port <= 0) {
port = SshConstants.SSH_DEFAULT_PORT;
}
buffer.putByte((byte) ((port >> 8) & 0xFF));
buffer.putByte((byte) (port & 0xFF));
state = ProtocolState.CONNECTING;
session.writePacket(buffer).verify(getTimeout());
}
private void doPasswordAuth(IoSession session) throws Exception {
GssApiMechanisms.closeContextSilently(context);
authenticator = new SocksBasicAuthentication();
session.addCloseFutureListener(f -> close());
startAuth(session);
}
private void doGssApiAuth(IoSession session) throws Exception {
authenticator = new SocksGssApiAuthentication();
session.addCloseFutureListener(f -> close());
startAuth(session);
}
private void close() {
AuthenticationHandler<?, ?> handler = authenticator;
authenticator = null;
if (handler != null) {
handler.close();
}
}
private void startAuth(IoSession session) throws Exception {
Buffer buffer = null;
try {
authenticator.setParams(null);
authenticator.start();
buffer = authenticator.getToken();
state = ProtocolState.AUTHENTICATING;
if (buffer == null) {
// Internal error; no translation
throw new IOException(
"No data for proxy authentication with " //$NON-NLS-1$
+ proxyAddress);
}
session.writePacket(buffer).verify(getTimeout());
} finally {
if (buffer != null) {
buffer.clear(true);
}
}
}
private void authStep(IoSession session, Buffer input) throws Exception {
Buffer buffer = null;
try {
authenticator.setParams(input);
authenticator.process();
buffer = authenticator.getToken();
if (buffer != null) {
session.writePacket(buffer).verify(getTimeout());
}
} finally {
if (buffer != null) {
buffer.clear(true);
}
}
if (authenticator.isDone()) {
sendConnectInfo(session);
}
}
private void establishConnection(Buffer data) throws Exception {
byte reply = data.getByte();
switch (reply) {
case SOCKS_REPLY_SUCCESS:
state = ProtocolState.CONNECTED;
setDone(true);
return;
case SOCKS_REPLY_FAILURE:
throw new IOException(format(
SshdText.get().proxySocksFailureGeneral, proxyAddress));
case SOCKS_REPLY_FORBIDDEN:
throw new IOException(
format(SshdText.get().proxySocksFailureForbidden,
proxyAddress, remoteAddress));
case SOCKS_REPLY_NETWORK_UNREACHABLE:
throw new IOException(
format(SshdText.get().proxySocksFailureNetworkUnreachable,
proxyAddress, remoteAddress));
case SOCKS_REPLY_HOST_UNREACHABLE:
throw new IOException(
format(SshdText.get().proxySocksFailureHostUnreachable,
proxyAddress, remoteAddress));
case SOCKS_REPLY_CONNECTION_REFUSED:
throw new IOException(
format(SshdText.get().proxySocksFailureRefused,
proxyAddress, remoteAddress));
case SOCKS_REPLY_TTL_EXPIRED:
throw new IOException(
format(SshdText.get().proxySocksFailureTTL, proxyAddress));
case SOCKS_REPLY_COMMAND_UNSUPPORTED:
throw new IOException(
format(SshdText.get().proxySocksFailureUnsupportedCommand,
proxyAddress));
case SOCKS_REPLY_ADDRESS_UNSUPPORTED:
throw new IOException(
format(SshdText.get().proxySocksFailureUnsupportedAddress,
proxyAddress));
default:
throw new IOException(format(
SshdText.get().proxySocksFailureUnspecified, proxyAddress));
}
}
@Override
public void messageReceived(IoSession session, Readable buffer)
throws Exception {
try {
// Dispatch according to protocol state
ByteArrayBuffer data = new ByteArrayBuffer(buffer.available(),
false);
data.putBuffer(buffer);
data.compact();
state.handleMessage(this, session, data);
} catch (Exception e) {
state = ProtocolState.FAILED;
if (authenticator != null) {
authenticator.close();
authenticator = null;
}
try {
setDone(false);
} catch (Exception inner) {
e.addSuppressed(inner);
}
throw e;
}
}
private void versionCheck(byte version) throws Exception {
if (version != SOCKS_VERSION_5) {
throw new IOException(
format(SshdText.get().proxySocksUnexpectedVersion,
Integer.toString(version & 0xFF)));
}
}
private SocksAuthenticationMethod getAuthMethod(byte value) {
if (value != SocksAuthenticationMethod.NONE_ACCEPTABLE.getValue()) {
for (byte proposed : authenticationProposals) {
if (proposed == value) {
for (SocksAuthenticationMethod method : SocksAuthenticationMethod
.values()) {
if (method.getValue() == value) {
return method;
}
}
break;
}
}
}
return SocksAuthenticationMethod.NONE_ACCEPTABLE;
}
private static byte[] getRawAddress(@NonNull InetSocketAddress address) {
InetAddress ipAddress = GssApiMechanisms.resolve(address);
return ipAddress == null ? null : ipAddress.getAddress();
}
private static GSSContext getGSSContext(
@NonNull InetSocketAddress address) {
if (!GssApiMechanisms.getSupportedMechanisms()
.contains(GssApiMechanisms.KERBEROS_5)) {
return null;
}
return GssApiMechanisms.createContext(GssApiMechanisms.KERBEROS_5,
GssApiMechanisms.getCanonicalName(address));
}
/**
* @see <a href="https://tools.ietf.org/html/rfc1929">RFC 1929</a>
*/
private class SocksBasicAuthentication
extends BasicAuthentication<Buffer, Buffer> {
private static final byte SOCKS_BASIC_PROTOCOL_VERSION = 1;
private static final byte SOCKS_BASIC_AUTH_SUCCESS = 0;
public SocksBasicAuthentication() {
super(proxyAddress, proxyUser, proxyPassword);
}
@Override
public void process() throws Exception {
// Retries impossible. RFC 1929 specifies that the server MUST
// close the connection if authentication is unsuccessful.
done = true;
if (params.getByte() != SOCKS_BASIC_PROTOCOL_VERSION
|| params.getByte() != SOCKS_BASIC_AUTH_SUCCESS) {
throw new IOException(format(
SshdText.get().proxySocksAuthenticationFailed, proxy));
}
}
@Override
protected void askCredentials() {
super.askCredentials();
adjustTimeout();
}
@Override
public Buffer getToken() throws IOException {
if (done) {
return null;
}
try {
byte[] rawUser = user.getBytes(UTF_8);
if (rawUser.length > 255) {
throw new IOException(format(
SshdText.get().proxySocksUsernameTooLong, proxy,
Integer.toString(rawUser.length), user));
}
if (password.length > 255) {
throw new IOException(
format(SshdText.get().proxySocksPasswordTooLong,
proxy, Integer.toString(password.length)));
}
ByteArrayBuffer buffer = new ByteArrayBuffer(
3 + rawUser.length + password.length, false);
buffer.putByte(SOCKS_BASIC_PROTOCOL_VERSION);
buffer.putByte((byte) rawUser.length);
buffer.putRawBytes(rawUser);
buffer.putByte((byte) password.length);
buffer.putRawBytes(password);
return buffer;
} finally {
clearPassword();
done = true;
}
}
}
/**
* @see <a href="https://tools.ietf.org/html/rfc1961">RFC 1961</a>
*/
private class SocksGssApiAuthentication
extends GssApiAuthentication<Buffer, Buffer> {
private static final byte SOCKS5_GSSAPI_VERSION = 1;
private static final byte SOCKS5_GSSAPI_TOKEN = 1;
private static final int SOCKS5_GSSAPI_FAILURE = 0xFF;
public SocksGssApiAuthentication() {
super(proxyAddress);
}
@Override
protected GSSContext createContext() throws Exception {
return context;
}
@Override
public Buffer getToken() throws Exception {
if (token == null) {
return null;
}
Buffer buffer = new ByteArrayBuffer(4 + token.length, false);
buffer.putByte(SOCKS5_GSSAPI_VERSION);
buffer.putByte(SOCKS5_GSSAPI_TOKEN);
buffer.putByte((byte) ((token.length >> 8) & 0xFF));
buffer.putByte((byte) (token.length & 0xFF));
buffer.putRawBytes(token);
return buffer;
}
@Override
protected byte[] extractToken(Buffer input) throws Exception {
if (context == null) {
return null;
}
int version = input.getUByte();
if (version != SOCKS5_GSSAPI_VERSION) {
throw new IOException(
format(SshdText.get().proxySocksGssApiVersionMismatch,
remoteAddress, Integer.toString(version)));
}
int msgType = input.getUByte();
if (msgType == SOCKS5_GSSAPI_FAILURE) {
throw new IOException(format(
SshdText.get().proxySocksGssApiFailure, remoteAddress));
} else if (msgType != SOCKS5_GSSAPI_TOKEN) {
throw new IOException(format(
SshdText.get().proxySocksGssApiUnknownMessage,
remoteAddress, Integer.toHexString(msgType & 0xFF)));
}
if (input.available() >= 2) {
int length = (input.getUByte() << 8) + input.getUByte();
if (input.available() >= length) {
byte[] value = new byte[length];
if (length > 0) {
input.getRawBytes(value);
}
return value;
}
}
throw new IOException(
format(SshdText.get().proxySocksGssApiMessageTooShort,
remoteAddress));
}
}
}