From 6c14d273faa89ab1657e818315b68f3bd672ff87 Mon Sep 17 00:00:00 2001 From: Thomas Wolf Date: Sun, 21 Oct 2018 19:44:34 +0200 Subject: [PATCH] Apache MINA sshd client: proxy support This is not about the ssh config ProxyCommand but about programmatic support for HTTP and SOCKS5 proxies. Eclipse allows the user to specify such proxies, and JSch at least contains code to connect through proxies. So our Apache MINA sshd client also should be able to do this. Add interfaces and provide two implementations for HTTP and SOCKS5 proxies. Adapt the core code to be able to deal with proxy connections at all. The built-in client-side support for this in sshd 2.0.0 is woefully inadequate. Tested manually by running proxies and then fetching various real- world repositories via these proxies from different servers. Proxies tested: ssh -D (SOCKS, anonymous), tinyproxy (HTTP, anonymous), and 3proxy (SOCKS & HTTP, username-password authentication). The GSS-API authentication is untested since I have no Kerberos setup. Bug: 520927 Change-Id: I1a5c34687d439b3ef8373c5d58e24004f93e63ae Signed-off-by: Thomas Wolf --- .../src/org/eclipse/jgit/pgm/TextBuiltin.java | 3 +- .../META-INF/MANIFEST.MF | 3 +- .../transport/sshd/proxy/HttpParserTest.java | 146 ++++ .../jgit/transport/sshd/ApacheSshTest.java | 3 +- .../META-INF/MANIFEST.MF | 12 +- .../transport/sshd/SshdText.properties | 29 +- .../transport/sshd/GssApiMechanisms.java | 7 +- .../sshd/GssApiWithMicAuthentication.java | 7 + .../transport/sshd/JGitClientSession.java | 101 ++- .../transport/sshd/JGitSshClient.java | 72 +- .../transport/sshd/JGitUserInteraction.java | 2 +- .../internal/transport/sshd/SshdText.java | 27 + .../auth/AbstractAuthenticationHandler.java | 89 +++ .../sshd/auth/AuthenticationHandler.java | 121 ++++ .../sshd/auth/BasicAuthentication.java | 167 +++++ .../sshd/auth/GssApiAuthentication.java | 147 ++++ .../proxy/AbstractClientProxyConnector.java | 209 ++++++ .../sshd/proxy/AuthenticationChallenge.java | 123 ++++ .../sshd/proxy/HttpClientConnector.java | 403 +++++++++++ .../transport/sshd/proxy/HttpParser.java | 346 ++++++++++ .../sshd/proxy/Socks5ClientConnector.java | 642 ++++++++++++++++++ .../sshd/proxy/StatefulProxyConnector.java | 89 +++ .../transport/sshd/proxy/StatusLine.java | 99 +++ .../sshd/DefaultProxyDataFactory.java | 103 +++ .../jgit/transport/sshd/ProxyData.java | 136 ++++ .../jgit/transport/sshd/ProxyDataFactory.java | 70 ++ .../transport/sshd/SessionCloseListener.java | 2 + .../transport/sshd/SshdSessionFactory.java | 23 +- 28 files changed, 3157 insertions(+), 24 deletions(-) create mode 100644 org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/transport/sshd/proxy/HttpParserTest.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AbstractAuthenticationHandler.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AuthenticationHandler.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/BasicAuthentication.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/GssApiAuthentication.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AbstractClientProxyConnector.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AuthenticationChallenge.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpClientConnector.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpParser.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/Socks5ClientConnector.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatefulProxyConnector.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatusLine.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/DefaultProxyDataFactory.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyData.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyDataFactory.java diff --git a/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/TextBuiltin.java b/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/TextBuiltin.java index 1ca35a24e..c4b4018b8 100644 --- a/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/TextBuiltin.java +++ b/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/TextBuiltin.java @@ -70,6 +70,7 @@ import org.eclipse.jgit.pgm.opt.CmdLineParser; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.transport.SshSessionFactory; +import org.eclipse.jgit.transport.sshd.DefaultProxyDataFactory; import org.eclipse.jgit.transport.sshd.JGitKeyCache; import org.eclipse.jgit.transport.sshd.SshdSessionFactory; import org.eclipse.jgit.util.io.ThrowingPrintWriter; @@ -249,7 +250,7 @@ public final void execute(String[] args) throws Exception { switch (sshDriver) { case APACHE: { SshdSessionFactory factory = new SshdSessionFactory( - new JGitKeyCache()); + new JGitKeyCache(), new DefaultProxyDataFactory()); Runtime.getRuntime() .addShutdownHook(new Thread(() -> factory.close())); SshSessionFactory.setInstance(factory); diff --git a/org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF b/org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF index c8f53c4c1..38dc19067 100644 --- a/org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF +++ b/org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF @@ -6,7 +6,8 @@ Bundle-SymbolicName: org.eclipse.jgit.ssh.apache.test Bundle-Version: 5.2.0.qualifier Bundle-Vendor: %Provider-Name Bundle-RequiredExecutionEnvironment: JavaSE-1.8 -Import-Package: org.eclipse.jgit.junit;version="[5.2.0,5.3.0)", +Import-Package: org.eclipse.jgit.internal.transport.sshd.proxy;version="[5.2.0,5.3.0)", + org.eclipse.jgit.junit;version="[5.2.0,5.3.0)", org.eclipse.jgit.lib;version="[5.2.0,5.3.0)", org.eclipse.jgit.transport;version="[5.2.0,5.3.0)", org.eclipse.jgit.transport.ssh;version="[5.2.0,5.3.0)", diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/transport/sshd/proxy/HttpParserTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/transport/sshd/proxy/HttpParserTest.java new file mode 100644 index 000000000..b8e85493a --- /dev/null +++ b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/transport/sshd/proxy/HttpParserTest.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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 org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +public class HttpParserTest { + + private static final String STATUS_LINE = "HTTP/1.1. 407 Authentication required"; + + @Test + public void testEmpty() throws Exception { + String[] lines = { STATUS_LINE }; + List challenges = HttpParser + .getAuthenticationHeaders(Arrays.asList(lines), + "WWW-Authenticate:"); + assertTrue("No challenges expected", challenges.isEmpty()); + } + + @Test + public void testRFC7235Example() throws Exception { + // The example from RFC 7235, sec. 4.1, slightly modified ("kind" + // argument with whitespace around '=') + String[] lines = { STATUS_LINE, + "WWW-Authenticate: Newauth realm=\"apps\", type=1 , kind = \t2 ", + " \t title=\"Login to \\\"apps\\\"\", Basic realm=\"simple\"" }; + List challenges = HttpParser + .getAuthenticationHeaders(Arrays.asList(lines), + "WWW-Authenticate:"); + assertEquals("Unexpected number of challenges", 2, challenges.size()); + assertNull("No token expected", challenges.get(0).getToken()); + assertNull("No token expected", challenges.get(1).getToken()); + assertEquals("Unexpected mechanism", "Newauth", + challenges.get(0).getMechanism()); + assertEquals("Unexpected mechanism", "Basic", + challenges.get(1).getMechanism()); + Map expectedArguments = new LinkedHashMap<>(); + expectedArguments.put("realm", "apps"); + expectedArguments.put("type", "1"); + expectedArguments.put("kind", "2"); + expectedArguments.put("title", "Login to \"apps\""); + assertEquals("Unexpected arguments", expectedArguments, + challenges.get(0).getArguments()); + expectedArguments.clear(); + expectedArguments.put("realm", "simple"); + assertEquals("Unexpected arguments", expectedArguments, + challenges.get(1).getArguments()); + } + + @Test + public void testMultipleHeaders() { + String[] lines = { STATUS_LINE, + "Server: Apache", + "WWW-Authenticate: Newauth realm=\"apps\", type=1 , kind = \t2 ", + " \t title=\"Login to \\\"apps\\\"\", Basic realm=\"simple\"", + "Content-Type: text/plain", + "WWW-Authenticate: Other 0123456789=== , YetAnother, ", + "WWW-Authenticate: Negotiate ", + "WWW-Authenticate: Negotiate a87421000492aa874209af8bc028" }; + List challenges = HttpParser + .getAuthenticationHeaders(Arrays.asList(lines), + "WWW-Authenticate:"); + assertEquals("Unexpected number of challenges", 6, challenges.size()); + assertEquals("Mismatched challenge", "Other", + challenges.get(2).getMechanism()); + assertEquals("Token expected", "0123456789===", + challenges.get(2).getToken()); + assertEquals("Mismatched challenge", "YetAnother", + challenges.get(3).getMechanism()); + assertNull("No token expected", challenges.get(3).getToken()); + assertTrue("No arguments expected", + challenges.get(3).getArguments().isEmpty()); + assertEquals("Mismatched challenge", "Negotiate", + challenges.get(4).getMechanism()); + assertNull("No token expected", challenges.get(4).getToken()); + assertEquals("Mismatched challenge", "Negotiate", + challenges.get(5).getMechanism()); + assertEquals("Token expected", "a87421000492aa874209af8bc028", + challenges.get(5).getToken()); + } + + @Test + public void testStopOnEmptyLine() { + String[] lines = { STATUS_LINE, "Server: Apache", + "WWW-Authenticate: Newauth realm=\"apps\", type=1 , kind = \t2 ", + " \t title=\"Login to \\\"apps\\\"\", Basic realm=\"simple\"", + "Content-Type: text/plain", + "WWW-Authenticate: Other 0123456789===", "", + // Not headers anymore; this would be the body + "WWW-Authenticate: Negotiate ", + "WWW-Authenticate: Negotiate a87421000492aa874209af8bc028" }; + List challenges = HttpParser + .getAuthenticationHeaders(Arrays.asList(lines), + "WWW-Authenticate:"); + assertEquals("Unexpected number of challenges", 3, challenges.size()); + } +} diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java index cbbc6386f..69a9165aa 100644 --- a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java +++ b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java @@ -61,7 +61,8 @@ public class ApacheSshTest extends SshTestBase { @Override protected SshSessionFactory createSessionFactory() { - SshdSessionFactory result = new SshdSessionFactory(new JGitKeyCache()); + SshdSessionFactory result = new SshdSessionFactory(new JGitKeyCache(), + null); // The home directory is mocked at this point! result.setHomeDirectory(FS.DETECTED.userHome()); result.setSshDirectory(sshDir); diff --git a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF index caeff5363..e5d66536f 100644 --- a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF +++ b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF @@ -22,14 +22,15 @@ Export-Package: org.eclipse.jgit.internal.transport.sshd;version="5.2.0";x-inter org.apache.sshd.common.signature, org.apache.sshd.common.util.buffer, org.eclipse.jgit.transport", + org.eclipse.jgit.internal.transport.sshd.auth;version="5.2.0";x-internal:=true, + org.eclipse.jgit.internal.transport.sshd.proxy;version="5.2.0";x-friends:="org.eclipse.jgit.ssh.apache.test", org.eclipse.jgit.transport.sshd;version="5.2.0"; - uses:="org.apache.sshd.client, + uses:="org.eclipse.jgit.transport, org.apache.sshd.client.config.hosts, org.apache.sshd.common.keyprovider, - org.apache.sshd.client.keyverifier, - org.eclipse.jgit.internal.transport.sshd, - org.eclipse.jgit.transport, - org.eclipse.jgit.util" + org.eclipse.jgit.util, + org.apache.sshd.client.session, + org.apache.sshd.client.keyverifier" Import-Package: org.apache.sshd.agent;version="[2.0.0,2.1.0)", org.apache.sshd.client;version="[2.0.0,2.1.0)", org.apache.sshd.client.auth;version="[2.0.0,2.1.0)", @@ -64,6 +65,7 @@ Import-Package: org.apache.sshd.agent;version="[2.0.0,2.1.0)", org.apache.sshd.common.subsystem.sftp;version="[2.0.0,2.1.0)", org.apache.sshd.common.util;version="[2.0.0,2.1.0)", org.apache.sshd.common.util.buffer;version="[2.0.0,2.1.0)", + org.apache.sshd.common.util.closeable;version="[2.0.0,2.1.0)", org.apache.sshd.common.util.io;version="[2.0.0,2.1.0)", org.apache.sshd.common.util.logging;version="[2.0.0,2.1.0)", org.apache.sshd.common.util.net;version="[2.0.0,2.1.0)", diff --git a/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties b/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties index 12afedd8b..f9ff02b40 100644 --- a/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties +++ b/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties @@ -14,6 +14,7 @@ identityFileCannotDecrypt=Given passphrase cannot decrypt identity {0} identityFileNoKey=No keys found in identity {0} identityFileMultipleKeys=Multiple key pairs found in identity {0} identityFileUnsupportedFormat=Unsupported format in identity {0} +kexServerKeyInvalid=Server key did not validate keyEncryptedMsg=Key ''{0}'' is encrypted. Enter the passphrase to decrypt it. keyEncryptedPrompt=Passphrase keyEncryptedRetry=Encrypted key ''{0}'' could not be decrypted. Enter the passphrase again. @@ -43,7 +44,33 @@ knownHostsUnknownKeyPrompt=Accept and store this key, and continue connecting? knownHostsUnknownKeyType=Cannot read server key from known hosts file {0}; line {1} knownHostsUserAskCreationMsg=File {0} does not exist. knownHostsUserAskCreationPrompt=Create file {0} ? +proxyCannotAuthenticate=Cannot authenticate to proxy {0} +proxyHttpFailure=HTTP Proxy connection to {0} failed with code {1}: {2} +proxyHttpInvalidUserName=HTTP proxy connection {0} with invalid user name; must not contain colons: {1} +proxyHttpUnexpectedReply=Unexpected HTTP proxy response from {0}: {1} +proxyHttpUnspecifiedFailureReason=unspecified reason +proxyPasswordPrompt=Proxy password +proxySocksAuthenticationFailed=Authentication to SOCKS5 proxy {0} failed +proxySocksFailureForbidden=SOCKS5 proxy {0}: connection to {1} not allowed by ruleset +proxySocksFailureGeneral=SOCKS5 proxy {0}: general failure +proxySocksFailureHostUnreachable=SOCKS5 proxy {0}: host unreachable {1} +proxySocksFailureNetworkUnreachable=SOCKS5 proxy {0}: network unreachable {1} +proxySocksFailureRefused=SOCKS5 proxy {0}: connection refused {1} +proxySocksFailureTTL=TTL expired in SOCKS5 proxy connection {0} +proxySocksFailureUnspecified=Unspecified failure in SOCKS5 proxy connection {0} +proxySocksFailureUnsupportedAddress=SOCKS5 proxy {0} does not support address type +proxySocksFailureUnsupportedCommand=SOCKS5 proxy {0} does not support CONNECT command +proxySocksGssApiFailure=Cannot authenticate with GSS-API to SOCKS5 proxy {0} +proxySocksGssApiMessageTooShort=SOCKS5 proxy {0} sent too short message +proxySocksGssApiUnknownMessage=SOCKS5 proxy {0} sent unexpected GSS-API message type, expected 1, got {1} +proxySocksGssApiVersionMismatch=SOCKS5 proxy {0} sent wrong GSS-API version number, expected 1, got {1} +proxySocksNoRemoteHostName=Could not send remote address {0} +proxySocksPasswordTooLong=Password for proxy {0} must be at most 255 bytes long, is {1} bytes +proxySocksUnexpectedMessage=Unexpected message received from SOCKS5 proxy {0}; client state {1}: {2} +proxySocksUnexpectedVersion=Expected SOCKS version 5, got {0} +proxySocksUsernameTooLong=User name for proxy {0} must be at most 255 bytes long, is {1} bytes: {2} sessionCloseFailed=Closing the session failed sshClosingDown=Apache MINA sshd session factory is closing down; cannot create new ssh sessions on this factory sshCommandTimeout={0} timed out after {1} seconds while opening the channel -sshProcessStillRunning={0} is not yet completed, cannot get exit code \ No newline at end of file +sshProcessStillRunning={0} is not yet completed, cannot get exit code +unknownProxyProtocol=Ignoring unknown proxy protocol {0} \ No newline at end of file diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiMechanisms.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiMechanisms.java index 834a50309..cf68eac5a 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiMechanisms.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiMechanisms.java @@ -74,7 +74,7 @@ private GssApiMechanisms() { public static final Oid KERBEROS_5 = createOid("1.2.840.113554.1.2.2"); //$NON-NLS-1$ /** SGNEGO is not to be used with ssh. */ - private static final Oid SPNEGO = createOid("1.3.6.1.5.5.2"); //$NON-NLS-1$ + public static final Oid SPNEGO = createOid("1.3.6.1.5.5.2"); //$NON-NLS-1$ /** Protects {@link #supportedMechanisms}. */ private static final Object LOCK = new Object(); @@ -99,10 +99,7 @@ public static Collection getSupportedMechanisms() { Map mechanisms = new LinkedHashMap<>(); if (mechs != null) { for (Oid oid : mechs) { - // RFC 4462 states that SPNEGO must not be used with ssh - if (!SPNEGO.equals(oid)) { - mechanisms.put(oid, Boolean.FALSE); - } + mechanisms.put(oid, Boolean.FALSE); } } supportedMechanisms = mechanisms; diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java index fe6671489..aef263d7f 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java @@ -109,6 +109,13 @@ protected boolean sendAuthDataRequest(ClientSession session, String service) } state = ProtocolState.STARTED; currentMechanism = nextMechanism.next(); + // RFC 4462 states that SPNEGO must not be used with ssh + while (GssApiMechanisms.SPNEGO.equals(currentMechanism)) { + if (!nextMechanism.hasNext()) { + return false; + } + currentMechanism = nextMechanism.next(); + } try { String hostName = getHostName(session); context = GssApiMechanisms.createContext(currentMechanism, diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitClientSession.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitClientSession.java index 3e2a1aa6d..9b4694c45 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitClientSession.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitClientSession.java @@ -44,6 +44,8 @@ import static java.text.MessageFormat.format; +import java.io.IOException; +import java.net.SocketAddress; import java.security.PublicKey; import java.util.ArrayList; import java.util.Iterator; @@ -57,10 +59,14 @@ import org.apache.sshd.client.keyverifier.ServerKeyVerifier; import org.apache.sshd.client.session.ClientSessionImpl; import org.apache.sshd.common.FactoryManager; +import org.apache.sshd.common.SshException; import org.apache.sshd.common.config.keys.KeyUtils; import org.apache.sshd.common.io.IoSession; +import org.apache.sshd.common.io.IoWriteFuture; +import org.apache.sshd.common.util.Readable; import org.eclipse.jgit.errors.InvalidPatternException; import org.eclipse.jgit.fnmatch.FileNameMatcher; +import org.eclipse.jgit.internal.transport.sshd.proxy.StatefulProxyConnector; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.SshConstants; @@ -79,6 +85,8 @@ public class JGitClientSession extends ClientSessionImpl { private CredentialsProvider credentialsProvider; + private StatefulProxyConnector proxyHandler; + /** * @param manager * @param session @@ -127,6 +135,95 @@ public CredentialsProvider getCredentialsProvider() { return credentialsProvider; } + /** + * Sets a {@link StatefulProxyConnector} to handle proxy connection + * protocols. + * + * @param handler + * to set + */ + public void setProxyHandler(StatefulProxyConnector handler) { + proxyHandler = handler; + } + + @Override + protected IoWriteFuture sendIdentification(String ident) + throws IOException { + // Nothing; we do this below together with the KEX init in + // sendStartSsh(). Called only from the ClientSessionImpl constructor, + // where the return value is ignored. + return null; + } + + @Override + protected byte[] sendKexInit() throws IOException { + StatefulProxyConnector proxy = proxyHandler; + if (proxy != null) { + try { + // We must not block here; the framework starts reading messages + // from the peer only once sendKexInit() has returned! + proxy.runWhenDone(() -> { + sendStartSsh(); + return null; + }); + // sendKexInit() is called only from the ClientSessionImpl + // constructor, where the return value is ignored. + return null; + } catch (IOException e) { + throw e; + } catch (Exception other) { + throw new IOException(other.getLocalizedMessage(), other); + } + } else { + return sendStartSsh(); + } + } + + /** + * Sends the initial messages starting the ssh setup: the client + * identification and the KEX init message. + * + * @return the client's KEX seed + * @throws IOException + * if something goes wrong + */ + private byte[] sendStartSsh() throws IOException { + super.sendIdentification(clientVersion); + return super.sendKexInit(); + } + + /** + * {@inheritDoc} + * + * As long as we're still setting up the proxy connection, diverts messages + * to the {@link StatefulProxyConnector}. + */ + @Override + public void messageReceived(Readable buffer) throws Exception { + StatefulProxyConnector proxy = proxyHandler; + if (proxy != null) { + proxy.messageReceived(getIoSession(), buffer); + } else { + super.messageReceived(buffer); + } + } + + @Override + protected void checkKeys() throws SshException { + ServerKeyVerifier serverKeyVerifier = getServerKeyVerifier(); + // The super implementation always uses + // getIoSession().getRemoteAddress(). In case of a proxy connection, + // that would be the address of the proxy! + SocketAddress remoteAddress = getConnectAddress(); + PublicKey serverKey = getKex().getServerKey(); + if (!serverKeyVerifier.verifyServerKey(this, remoteAddress, + serverKey)) { + throw new SshException( + org.apache.sshd.common.SshConstants.SSH2_DISCONNECT_HOST_KEY_NOT_VERIFIABLE, + SshdText.get().kexServerKeyInvalid); + } + } + @Override protected String resolveAvailableSignaturesProposal( FactoryManager manager) { @@ -175,8 +272,10 @@ protected String resolveAvailableSignaturesProposal( // keys first. ServerKeyVerifier verifier = getServerKeyVerifier(); if (verifier instanceof ServerKeyLookup) { + SocketAddress remoteAddress = resolvePeerAddress( + resolveAttribute(JGitSshClient.ORIGINAL_REMOTE_ADDRESS)); List allKnownKeys = ((ServerKeyLookup) verifier) - .lookup(this, this.getIoSession().getRemoteAddress()); + .lookup(this, remoteAddress); Set reordered = new LinkedHashSet<>(); for (HostEntryPair h : allKnownKeys) { PublicKey key = h.getServerKey(); diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java index 915b696b9..9e9340482 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java @@ -47,6 +47,7 @@ import java.io.IOException; import java.net.InetSocketAddress; +import java.net.Proxy; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -73,9 +74,15 @@ import org.apache.sshd.common.keyprovider.KeyPairProvider; import org.apache.sshd.common.session.helpers.AbstractSession; import org.apache.sshd.common.util.ValidateUtils; +import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector; +import org.eclipse.jgit.internal.transport.sshd.proxy.Socks5ClientConnector; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.SshConstants; import org.eclipse.jgit.transport.sshd.KeyCache; +import org.eclipse.jgit.transport.sshd.ProxyData; +import org.eclipse.jgit.transport.sshd.ProxyDataFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Customized {@link SshClient} for JGit. It creates specialized @@ -83,7 +90,7 @@ * were created for, and it loads all KeyPair identities lazily. */ public class JGitSshClient extends SshClient { - + private static Logger LOG = LoggerFactory.getLogger(JGitSshClient.class); /** * We need access to this during the constructor of the ClientSession, * before setConnectAddress() can have been called. So we have to remember @@ -91,6 +98,8 @@ public class JGitSshClient extends SshClient { */ static final AttributeKey HOST_CONFIG_ENTRY = new AttributeKey<>(); + static final AttributeKey ORIGINAL_REMOTE_ADDRESS = new AttributeKey<>(); + /** * An attribute key for the comma-separated list of default preferred * authentication mechanisms. @@ -101,6 +110,8 @@ public class JGitSshClient extends SshClient { private CredentialsProvider credentialsProvider; + private ProxyDataFactory proxyDatabase; + @Override protected SessionFactory createSessionFactory() { // Override the parent's default @@ -133,6 +144,13 @@ public ConnectFuture connect(HostConfigEntry hostConfig) getAttribute(PREFERRED_AUTHENTICATIONS)), PREFERRED_AUTHS); setAttribute(HOST_CONFIG_ENTRY, hostConfig); + setAttribute(ORIGINAL_REMOTE_ADDRESS, address); + // Proxy support + ProxyData proxy = getProxyData(hostConfig, address); + if (proxy != null) { + address = configureProxy(proxy, address); + proxy.clearPassword(); + } connector.connect(address).addListener(listener); return connectFuture; } @@ -143,6 +161,38 @@ private void copyProperty(String value, String key) { } } + private ProxyData getProxyData(HostConfigEntry hostConfig, + InetSocketAddress remoteAddress) { + ProxyDataFactory factory = getProxyDatabase(); + return factory == null ? null : factory.get(hostConfig, remoteAddress); + } + + private InetSocketAddress configureProxy(ProxyData proxyData, + InetSocketAddress remoteAddress) { + Proxy proxy = proxyData.getProxy(); + if (proxy.type() == Proxy.Type.DIRECT + || !(proxy.address() instanceof InetSocketAddress)) { + return remoteAddress; + } + InetSocketAddress address = (InetSocketAddress) proxy.address(); + switch (proxy.type()) { + case HTTP: + setClientProxyConnector( + new HttpClientConnector(address, remoteAddress, + proxyData.getUser(), proxyData.getPassword())); + return address; + case SOCKS: + setClientProxyConnector( + new Socks5ClientConnector(address, remoteAddress, + proxyData.getUser(), proxyData.getPassword())); + return address; + default: + LOG.warn(format(SshdText.get().unknownProxyProtocol, + proxy.type().name())); + return remoteAddress; + } + } + private SshFutureListener createConnectCompletionListener( ConnectFuture connectFuture, String username, InetSocketAddress address, HostConfigEntry hostConfig) { @@ -260,6 +310,26 @@ public void setKeyCache(KeyCache cache) { keyCache = cache; } + /** + * Sets a {@link ProxyDataFactory} for connecting through proxies. + * + * @param factory + * to use, or {@code null} if proxying is not desired or + * supported + */ + public void setProxyDatabase(ProxyDataFactory factory) { + proxyDatabase = factory; + } + + /** + * Retrieves the {@link ProxyDataFactory}. + * + * @return the factory, or {@code null} if none is set + */ + protected ProxyDataFactory getProxyDatabase() { + return proxyDatabase; + } + /** * Sets the {@link CredentialsProvider} for this client. * diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java index a96a6962c..27380db33 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java @@ -119,7 +119,7 @@ public String[] interactive(ClientSession session, String name, return prompt; // Is known to have length zero here } URIish uri = toURI(session.getUsername(), - (InetSocketAddress) session.getIoSession().getRemoteAddress()); + (InetSocketAddress) session.getConnectAddress()); if (provider.get(uri, items)) { return items.stream().map(i -> { if (i instanceof CredentialItem.Password) { diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java index bd9b2a254..d4b6593ef 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java @@ -34,6 +34,7 @@ public static SshdText get() { /***/ public String identityFileNoKey; /***/ public String identityFileMultipleKeys; /***/ public String identityFileUnsupportedFormat; + /***/ public String kexServerKeyInvalid; /***/ public String keyEncryptedMsg; /***/ public String keyEncryptedPrompt; /***/ public String keyEncryptedRetry; @@ -55,9 +56,35 @@ public static SshdText get() { /***/ public String knownHostsUnknownKeyType; /***/ public String knownHostsUserAskCreationMsg; /***/ public String knownHostsUserAskCreationPrompt; + /***/ public String proxyCannotAuthenticate; + /***/ public String proxyHttpFailure; + /***/ public String proxyHttpInvalidUserName; + /***/ public String proxyHttpUnexpectedReply; + /***/ public String proxyHttpUnspecifiedFailureReason; + /***/ public String proxyPasswordPrompt; + /***/ public String proxySocksAuthenticationFailed; + /***/ public String proxySocksFailureForbidden; + /***/ public String proxySocksFailureGeneral; + /***/ public String proxySocksFailureHostUnreachable; + /***/ public String proxySocksFailureNetworkUnreachable; + /***/ public String proxySocksFailureRefused; + /***/ public String proxySocksFailureTTL; + /***/ public String proxySocksFailureUnspecified; + /***/ public String proxySocksFailureUnsupportedAddress; + /***/ public String proxySocksFailureUnsupportedCommand; + /***/ public String proxySocksGssApiFailure; + /***/ public String proxySocksGssApiMessageTooShort; + /***/ public String proxySocksGssApiUnknownMessage; + /***/ public String proxySocksGssApiVersionMismatch; + /***/ public String proxySocksNoRemoteHostName; + /***/ public String proxySocksPasswordTooLong; + /***/ public String proxySocksUnexpectedMessage; + /***/ public String proxySocksUnexpectedVersion; + /***/ public String proxySocksUsernameTooLong; /***/ public String sessionCloseFailed; /***/ public String sshClosingDown; /***/ public String sshCommandTimeout; /***/ public String sshProcessStillRunning; + /***/ public String unknownProxyProtocol; } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AbstractAuthenticationHandler.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AbstractAuthenticationHandler.java new file mode 100644 index 000000000..6caa1b6aa --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AbstractAuthenticationHandler.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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.auth; + +import java.net.InetSocketAddress; + +/** + * Abstract base class for {@link AuthenticationHandler}s encapsulating basic + * common things. + * + * @param + * defining the parameter type for the authentication + * @param + * defining the token type for the authentication + */ +public abstract class AbstractAuthenticationHandler + implements AuthenticationHandler { + + /** The {@link InetSocketAddress} or the proxy to connect to. */ + protected InetSocketAddress proxy; + + /** The last set parameters. */ + protected ParameterType params; + + /** A flag telling whether this authentication is done. */ + protected boolean done; + + /** + * Creates a new {@link AbstractAuthenticationHandler} to authenticate with + * the given {@code proxy}. + * + * @param proxy + * the {@link InetSocketAddress} of the proxy to connect to + */ + public AbstractAuthenticationHandler(InetSocketAddress proxy) { + this.proxy = proxy; + } + + @Override + public final void setParams(ParameterType input) { + params = input; + } + + @Override + public final boolean isDone() { + return done; + } + +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AuthenticationHandler.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AuthenticationHandler.java new file mode 100644 index 000000000..34724687a --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AuthenticationHandler.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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.auth; + +import java.io.Closeable; + +/** + * An {@code AuthenticationHandler} encapsulates a possibly multi-step + * authentication protocol. Intended usage: + * + *
+ * setParams(something);
+ * start();
+ * sendToken(getToken());
+ * while (!isDone()) {
+ * 	setParams(receiveMessageAndExtractParams());
+ * 	process();
+ * 	Object t = getToken();
+ * 	if (t != null) {
+ * 		sendToken(t);
+ * 	}
+ * }
+ * 
+ * + * An {@code AuthenticationHandler} may be stateful and therefore is a + * {@link Closeable}. + * + * @param + * defining the parameter type for {@link #setParams(Object)} + * @param + * defining the token type for {@link #getToken()} + */ +public interface AuthenticationHandler + extends Closeable { + + /** + * Produces the initial authentication token that can be then retrieved via + * {@link #getToken()}. + * + * @throws Exception + * if an error occurs + */ + void start() throws Exception; + + /** + * Produces the next authentication token, if any. + * + * @throws Exception + * if an error occurs + */ + void process() throws Exception; + + /** + * Sets the parameters for the next token generation via {@link #start()} or + * {@link #process()}. + * + * @param input + * to set, may be {@code null} + */ + void setParams(ParameterType input); + + /** + * Retrieves the last token generated. + * + * @return the token, or {@code null} if there is none + * @throws Exception + * if an error occurs + */ + TokenType getToken() throws Exception; + + /** + * Tells whether is authentication mechanism is done (successfully or + * unsuccessfully). + * + * @return whether this authentication is done + */ + boolean isDone(); + + @Override + public void close(); +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/BasicAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/BasicAuthentication.java new file mode 100644 index 000000000..efb1f5586 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/BasicAuthentication.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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.auth; + +import java.net.Authenticator; +import java.net.Authenticator.RequestorType; +import java.net.InetSocketAddress; +import java.net.PasswordAuthentication; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.StandardCharsets; +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.Arrays; +import java.util.concurrent.CancellationException; + +import org.eclipse.jgit.internal.transport.sshd.SshdText; +import org.eclipse.jgit.transport.SshConstants; + +/** + * An abstract implementation of a username-password authentication. It can be + * given an initial known username-password pair; if so, this will be tried + * first. Subsequent rounds will then try to obtain a user name and password via + * the global {@link Authenticator}. + * + * @param + * defining the parameter type for the authentication + * @param + * defining the token type for the authentication + */ +public abstract class BasicAuthentication + extends AbstractAuthenticationHandler { + + /** The current user name. */ + protected String user; + + /** The current password. */ + protected byte[] password; + + /** + * Creates a new {@link BasicAuthentication} to authenticate with the given + * {@code proxy}. + * + * @param proxy + * {@link InetSocketAddress} of the proxy to connect to + * @param initialUser + * initial user name to try; may be {@code null} + * @param initialPassword + * initial password to try, may be {@code null} + */ + public BasicAuthentication(InetSocketAddress proxy, String initialUser, + char[] initialPassword) { + super(proxy); + this.user = initialUser; + this.password = convert(initialPassword); + } + + private byte[] convert(char[] pass) { + if (pass == null) { + return new byte[0]; + } + ByteBuffer bytes = StandardCharsets.UTF_8.encode(CharBuffer.wrap(pass)); + byte[] pwd = new byte[bytes.remaining()]; + bytes.get(pwd); + if (bytes.hasArray()) { + Arrays.fill(bytes.array(), (byte) 0); + } + Arrays.fill(pass, '\000'); + return pwd; + } + + /** + * Clears the {@link #password}. + */ + protected void clearPassword() { + if (password != null) { + Arrays.fill(password, (byte) 0); + } + password = new byte[0]; + } + + @Override + public final void close() { + clearPassword(); + done = true; + } + + @Override + public final void start() throws Exception { + if (user != null && !user.isEmpty() + || password != null && password.length > 0) { + return; + } + askCredentials(); + } + + @Override + public void process() throws Exception { + askCredentials(); + } + + /** + * Asks for credentials via the global {@link Authenticator}. + */ + protected void askCredentials() { + clearPassword(); + PasswordAuthentication auth = AccessController + .doPrivileged(new PrivilegedAction() { + + @Override + public PasswordAuthentication run() { + return Authenticator.requestPasswordAuthentication( + proxy.getHostString(), proxy.getAddress(), + proxy.getPort(), SshConstants.SSH_SCHEME, + SshdText.get().proxyPasswordPrompt, "Basic", //$NON-NLS-1$ + null, RequestorType.PROXY); + } + }); + if (auth == null) { + user = ""; //$NON-NLS-1$ + throw new CancellationException( + SshdText.get().authenticationCanceled); + } + user = auth.getUserName(); + password = convert(auth.getPassword()); + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/GssApiAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/GssApiAuthentication.java new file mode 100644 index 000000000..63cc95447 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/GssApiAuthentication.java @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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.auth; + +import static java.text.MessageFormat.format; + +import java.io.IOException; +import java.net.InetSocketAddress; + +import org.eclipse.jgit.internal.transport.sshd.GssApiMechanisms; +import org.eclipse.jgit.internal.transport.sshd.SshdText; +import org.ietf.jgss.GSSContext; + +/** + * An abstract implementation of a GSS-API multi-round authentication. + * + * @param + * defining the parameter type for the authentication + * @param + * defining the token type for the authentication + */ +public abstract class GssApiAuthentication + extends AbstractAuthenticationHandler { + + private GSSContext context; + + /** The last token generated. */ + protected byte[] token; + + /** + * Creates a new {@link GssApiAuthentication} to authenticate with the given + * {@code proxy}. + * + * @param proxy + * the {@link InetSocketAddress} of the proxy to connect to + */ + public GssApiAuthentication(InetSocketAddress proxy) { + super(proxy); + } + + @Override + public void close() { + GssApiMechanisms.closeContextSilently(context); + context = null; + done = true; + } + + @Override + public final void start() throws Exception { + try { + context = createContext(); + context.requestMutualAuth(true); + context.requestConf(false); + context.requestInteg(false); + byte[] empty = new byte[0]; + token = context.initSecContext(empty, 0, 0); + } catch (Exception e) { + close(); + throw e; + } + } + + @Override + public final void process() throws Exception { + if (context == null) { + throw new IOException( + format(SshdText.get().proxyCannotAuthenticate, proxy)); + } + try { + byte[] received = extractToken(params); + token = context.initSecContext(received, 0, received.length); + checkDone(); + } catch (Exception e) { + close(); + throw e; + } + } + + private void checkDone() throws Exception { + done = context.isEstablished(); + if (done) { + context.dispose(); + context = null; + } + } + + /** + * Creates the {@link GSSContext} to use. + * + * @return a fresh {@link GSSContext} to use + * @throws Exception + * if the context cannot be created + */ + protected abstract GSSContext createContext() throws Exception; + + /** + * Extracts the token from the last set parameters. + * + * @param input + * to extract the token from + * @return the extracted token, or {@code null} if none + * @throws Exception + * if an error occurs + */ + protected abstract byte[] extractToken(ParameterType input) + throws Exception; +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AbstractClientProxyConnector.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AbstractClientProxyConnector.java new file mode 100644 index 000000000..444fbb62e --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AbstractClientProxyConnector.java @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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 java.net.InetSocketAddress; +import java.util.Arrays; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.sshd.client.session.ClientSession; +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.internal.transport.sshd.JGitClientSession; + +/** + * Basic common functionality for a {@link StatefulProxyConnector}. + */ +public abstract class AbstractClientProxyConnector + implements StatefulProxyConnector { + + private static final long DEFAULT_PROXY_TIMEOUT_MILLIS = TimeUnit.SECONDS + .toMillis(30L); + + /** Guards {@link #done} and {@link #startSsh}. */ + private Object lock = new Object(); + + private boolean done; + + private Callable startSsh; + + private AtomicReference unregister = new AtomicReference<>(); + + private long remainingProxyProtocolTime = DEFAULT_PROXY_TIMEOUT_MILLIS; + + private long lastProxyOperationTime = 0L; + + /** The ultimate remote address to connect to. */ + protected final InetSocketAddress remoteAddress; + + /** The proxy address. */ + protected final InetSocketAddress proxyAddress; + + /** The user to authenticate at the proxy with. */ + protected String proxyUser; + + /** The password to use for authentication at the proxy. */ + protected char[] proxyPassword; + + /** + * Creates a new {@link AbstractClientProxyConnector}. + * + * @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; may be {@code null} + * @param proxyPassword + * to authenticate at the proxy with; may be {@code null} + */ + public AbstractClientProxyConnector(@NonNull InetSocketAddress proxyAddress, + @NonNull InetSocketAddress remoteAddress, String proxyUser, + char[] proxyPassword) { + this.proxyAddress = proxyAddress; + this.remoteAddress = remoteAddress; + this.proxyUser = proxyUser; + this.proxyPassword = proxyPassword == null ? new char[0] + : proxyPassword; + } + + /** + * Initializes this instance. Installs itself as proxy handler on the + * session. + * + * @param session + * to initialize for + */ + protected void init(ClientSession session) { + remainingProxyProtocolTime = session.getLongProperty( + StatefulProxyConnector.TIMEOUT_PROPERTY, + DEFAULT_PROXY_TIMEOUT_MILLIS); + if (remainingProxyProtocolTime <= 0L) { + remainingProxyProtocolTime = DEFAULT_PROXY_TIMEOUT_MILLIS; + } + if (session instanceof JGitClientSession) { + JGitClientSession s = (JGitClientSession) session; + unregister.set(() -> s.setProxyHandler(null)); + s.setProxyHandler(this); + } else { + // Internal error, no translation + throw new IllegalStateException( + "Not a JGit session: " + session.getClass().getName()); //$NON-NLS-1$ + } + } + + /** + * Obtains the timeout for the whole rest of the proxy connection protocol. + * + * @return the timeout in milliseconds, always > 0L + */ + protected long getTimeout() { + long last = lastProxyOperationTime; + long now = System.nanoTime(); + lastProxyOperationTime = now; + long remaining = remainingProxyProtocolTime; + if (last != 0L) { + long elapsed = now - last; + remaining -= elapsed; + if (remaining < 0L) { + remaining = 10L; // Give it grace period. + } + } + remainingProxyProtocolTime = remaining; + return remaining; + } + + /** + * Adjusts the timeout calculation to not account of elapsed time since the + * last time the timeout was gotten. Can be used for instance to ignore time + * spent in user dialogs be counted against the overall proxy connection + * protocol timeout. + */ + protected void adjustTimeout() { + lastProxyOperationTime = System.nanoTime(); + } + + /** + * Sets the "done" flag. + * + * @param success + * whether the connector terminated successfully. + * @throws Exception + * if starting ssh fails + */ + protected void setDone(boolean success) throws Exception { + Callable starter; + Runnable unset = unregister.getAndSet(null); + if (unset != null) { + unset.run(); + } + synchronized (lock) { + done = true; + starter = startSsh; + startSsh = null; + } + if (success && starter != null) { + starter.call(); + } + } + + @Override + public void runWhenDone(Callable starter) throws Exception { + synchronized (lock) { + if (!done) { + this.startSsh = starter; + return; + } + } + starter.call(); + } + + /** + * Clears the proxy password. + */ + protected void clearPassword() { + Arrays.fill(proxyPassword, '\000'); + proxyPassword = new char[0]; + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AuthenticationChallenge.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AuthenticationChallenge.java new file mode 100644 index 000000000..4a6572d45 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AuthenticationChallenge.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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 java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.eclipse.jgit.annotations.NonNull; + +/** + * A simple representation of an authentication challenge as sent in a + * "WWW-Authenticate" or "Proxy-Authenticate" header. Such challenges start with + * a mechanism name, followed either by one single token, or by a list of + * key=value pairs. + * + * @see RFC 7235, sec. + * 2.1 + */ +public class AuthenticationChallenge { + + private final String mechanism; + + private String token; + + private Map arguments; + + /** + * Create a new {@link AuthenticationChallenge} with the given mechanism. + * + * @param mechanism + * for the challenge + */ + public AuthenticationChallenge(String mechanism) { + this.mechanism = mechanism; + } + + /** + * Retrieves the authentication mechanism specified by this challenge, for + * instance "Basic". + * + * @return the mechanism name + */ + public String getMechanism() { + return mechanism; + } + + /** + * Retrieves the token of the challenge, if any. + * + * @return the token, or {@code null} if there is none. + */ + public String getToken() { + return token; + } + + /** + * Retrieves the arguments of the challenge. + * + * @return a possibly empty map of the key=value arguments of the challenge + */ + @NonNull + public Map getArguments() { + return arguments == null ? Collections.emptyMap() : arguments; + } + + void addArgument(String key, String value) { + if (arguments == null) { + arguments = new LinkedHashMap<>(); + } + arguments.put(key, value); + } + + void setToken(String token) { + this.token = token; + } + + @Override + public String toString() { + return "AuthenticationChallenge[" + mechanism + ',' + token + ',' //$NON-NLS-1$ + + (arguments == null ? "" : arguments.toString()) + ']'; //$NON-NLS-1$ + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpClientConnector.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpClientConnector.java new file mode 100644 index 000000000..46cdd52f5 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpClientConnector.java @@ -0,0 +1,403 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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.text.MessageFormat.format; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; + +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.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.util.Base64; +import org.ietf.jgss.GSSContext; + +/** + * Simple HTTP proxy connector using Basic Authentication. + */ +public class HttpClientConnector extends AbstractClientProxyConnector { + + private static final String HTTP_HEADER_PROXY_AUTHENTICATION = "Proxy-Authentication:"; //$NON-NLS-1$ + + private static final String HTTP_HEADER_PROXY_AUTHORIZATION = "Proxy-Authorization:"; //$NON-NLS-1$ + + private HttpAuthenticationHandler basic; + + private HttpAuthenticationHandler negotiate; + + private List availableAuthentications; + + private Iterator clientAuthentications; + + private HttpAuthenticationHandler authenticator; + + private boolean ongoing; + + /** + * Creates a new {@link HttpClientConnector}. The connector supports + * anonymous proxy connections as well as Basic and Negotiate + * authentication. + * + * @param proxyAddress + * of the proxy server we're connecting to + * @param remoteAddress + * of the target server to connect to + */ + public HttpClientConnector(@NonNull InetSocketAddress proxyAddress, + @NonNull InetSocketAddress remoteAddress) { + this(proxyAddress, remoteAddress, null, null); + } + + /** + * Creates a new {@link HttpClientConnector}. The connector supports + * anonymous proxy connections as well as Basic and Negotiate + * authentication. If a user name and password are given, the connector + * tries pre-emptive Basic 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 HttpClientConnector(@NonNull InetSocketAddress proxyAddress, + @NonNull InetSocketAddress remoteAddress, String proxyUser, + char[] proxyPassword) { + super(proxyAddress, remoteAddress, proxyUser, proxyPassword); + basic = new HttpBasicAuthentication(); + negotiate = new NegotiateAuthentication(); + availableAuthentications = new ArrayList<>(2); + availableAuthentications.add(negotiate); + availableAuthentications.add(basic); + clientAuthentications = availableAuthentications.iterator(); + } + + private void close() { + HttpAuthenticationHandler current = authenticator; + authenticator = null; + if (current != null) { + current.close(); + } + } + + @Override + public void sendClientProxyMetadata(ClientSession sshSession) + throws Exception { + init(sshSession); + IoSession session = sshSession.getIoSession(); + session.addCloseFutureListener(f -> close()); + StringBuilder msg = connect(); + if (proxyUser != null && !proxyUser.isEmpty() + || proxyPassword != null && proxyPassword.length > 0) { + authenticator = basic; + basic.setParams(null); + basic.start(); + msg = authenticate(msg, basic.getToken()); + clearPassword(); + proxyUser = null; + } + ongoing = true; + try { + send(msg, session); + } catch (Exception e) { + ongoing = false; + throw e; + } + } + + private void send(StringBuilder msg, IoSession session) throws Exception { + byte[] data = eol(msg).toString().getBytes(StandardCharsets.US_ASCII); + Buffer buffer = new ByteArrayBuffer(data.length, false); + buffer.putRawBytes(data); + session.writePacket(buffer).verify(getTimeout()); + } + + private StringBuilder connect() { + StringBuilder msg = new StringBuilder(); + // Persistent connections are the default in HTTP 1.1 (see RFC 2616), + // but let's be explicit. + return msg.append(format( + "CONNECT {0}:{1} HTTP/1.1\r\nProxy-Connection: keep-alive\r\nConnection: keep-alive\r\nHost: {0}:{1}\r\n", //$NON-NLS-1$ + remoteAddress.getHostString(), + Integer.toString(remoteAddress.getPort()))); + } + + private StringBuilder authenticate(StringBuilder msg, String token) { + msg.append(HTTP_HEADER_PROXY_AUTHORIZATION).append(' ').append(token); + return eol(msg); + } + + private StringBuilder eol(StringBuilder msg) { + return msg.append('\r').append('\n'); + } + + @Override + public void messageReceived(IoSession session, Readable buffer) + throws Exception { + try { + int length = buffer.available(); + byte[] data = new byte[length]; + buffer.getRawBytes(data, 0, length); + String[] reply = new String(data, StandardCharsets.US_ASCII) + .split("\r\n"); //$NON-NLS-1$ + handleMessage(session, Arrays.asList(reply)); + } catch (Exception e) { + if (authenticator != null) { + authenticator.close(); + authenticator = null; + } + ongoing = false; + try { + setDone(false); + } catch (Exception inner) { + e.addSuppressed(inner); + } + throw e; + } + } + + private void handleMessage(IoSession session, List reply) + throws Exception { + if (reply.isEmpty() || reply.get(0).isEmpty()) { + throw new IOException( + format(SshdText.get().proxyHttpUnexpectedReply, + proxyAddress, "")); //$NON-NLS-1$ + } + try { + StatusLine status = HttpParser.parseStatusLine(reply.get(0)); + if (!ongoing) { + throw new IOException(format( + SshdText.get().proxyHttpUnexpectedReply, proxyAddress, + Integer.toString(status.getResultCode()), + status.getReason())); + } + switch (status.getResultCode()) { + case HttpURLConnection.HTTP_OK: + if (authenticator != null) { + authenticator.close(); + } + authenticator = null; + ongoing = false; + setDone(true); + break; + case HttpURLConnection.HTTP_PROXY_AUTH: + List challenges = HttpParser + .getAuthenticationHeaders(reply, + HTTP_HEADER_PROXY_AUTHENTICATION); + authenticator = selectProtocol(challenges, authenticator); + if (authenticator == null) { + throw new IOException( + format(SshdText.get().proxyCannotAuthenticate, + proxyAddress)); + } + String token = authenticator.getToken(); + if (token == null) { + throw new IOException( + format(SshdText.get().proxyCannotAuthenticate, + proxyAddress)); + } + send(authenticate(connect(), token), session); + break; + default: + throw new IOException(format(SshdText.get().proxyHttpFailure, + proxyAddress, Integer.toString(status.getResultCode()), + status.getReason())); + } + } catch (HttpParser.ParseException e) { + throw new IOException( + format(SshdText.get().proxyHttpUnexpectedReply, + proxyAddress, reply.get(0))); + } + } + + private HttpAuthenticationHandler selectProtocol( + List challenges, + HttpAuthenticationHandler current) throws Exception { + if (current != null && !current.isDone()) { + AuthenticationChallenge challenge = getByName(challenges, + current.getName()); + if (challenge != null) { + current.setParams(challenge); + current.process(); + return current; + } + } + if (current != null) { + current.close(); + } + while (clientAuthentications.hasNext()) { + HttpAuthenticationHandler next = clientAuthentications.next(); + if (!next.isDone()) { + AuthenticationChallenge challenge = getByName(challenges, + next.getName()); + if (challenge != null) { + next.setParams(challenge); + next.start(); + return next; + } + } + } + return null; + } + + private AuthenticationChallenge getByName( + List challenges, + String name) { + return challenges.stream() + .filter(c -> c.getMechanism().equalsIgnoreCase(name)) + .findFirst().orElse(null); + } + + private interface HttpAuthenticationHandler + extends AuthenticationHandler { + + public String getName(); + } + + /** + * @see RFC 7617 + */ + private class HttpBasicAuthentication + extends BasicAuthentication + implements HttpAuthenticationHandler { + + private boolean asked; + + public HttpBasicAuthentication() { + super(proxyAddress, proxyUser, proxyPassword); + } + + @Override + public String getName() { + return "Basic"; //$NON-NLS-1$ + } + + @Override + protected void askCredentials() { + // We ask only once. + if (asked) { + throw new IllegalStateException( + "Basic auth: already asked user for password"); //$NON-NLS-1$ + } + asked = true; + super.askCredentials(); + done = true; + } + + @Override + public String getToken() throws Exception { + if (user.indexOf(':') >= 0) { + throw new IOException(format( + SshdText.get().proxyHttpInvalidUserName, proxy, user)); + } + byte[] rawUser = user.getBytes(StandardCharsets.UTF_8); + byte[] toEncode = new byte[rawUser.length + 1 + password.length]; + System.arraycopy(rawUser, 0, toEncode, 0, rawUser.length); + toEncode[rawUser.length] = ':'; + System.arraycopy(password, 0, toEncode, rawUser.length + 1, + password.length); + Arrays.fill(password, (byte) 0); + String result = Base64.encodeBytes(toEncode); + Arrays.fill(toEncode, (byte) 0); + return getName() + ' ' + result; + } + + } + + /** + * @see RFC 4559 + */ + private class NegotiateAuthentication + extends GssApiAuthentication + implements HttpAuthenticationHandler { + + public NegotiateAuthentication() { + super(proxyAddress); + } + + @Override + public String getName() { + return "Negotiate"; //$NON-NLS-1$ + } + + @Override + public String getToken() throws Exception { + return getName() + ' ' + Base64.encodeBytes(token); + } + + @Override + protected GSSContext createContext() throws Exception { + return GssApiMechanisms.createContext(GssApiMechanisms.SPNEGO, + GssApiMechanisms.getCanonicalName(proxyAddress)); + } + + @Override + protected byte[] extractToken(AuthenticationChallenge input) + throws Exception { + String received = input.getToken(); + if (received == null) { + return new byte[0]; + } + return Base64.decode(received); + } + + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpParser.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpParser.java new file mode 100644 index 000000000..b9b32b130 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpParser.java @@ -0,0 +1,346 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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 java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * A basic parser for HTTP response headers. Handles status lines and + * authentication headers (WWW-Authenticate, Proxy-Authenticate). + * + * @see RFC 7230 + * @see RFC 7235 + */ +public final class HttpParser { + + /** + * An exception indicating some problem parsing HTPP headers. + */ + public static class ParseException extends Exception { + + private static final long serialVersionUID = -1634090143702048640L; + + } + + private HttpParser() { + // No instantiation + } + + /** + * Parse a HTTP response status line. + * + * @param line + * to parse + * @return the {@link StatusLine} + * @throws ParseException + * if the line cannot be parsed or has the wrong HTTP version + */ + public static StatusLine parseStatusLine(String line) + throws ParseException { + // Format is HTTP/ Code Reason + int firstBlank = line.indexOf(' '); + if (firstBlank < 0) { + throw new ParseException(); + } + int secondBlank = line.indexOf(' ', firstBlank + 1); + if (secondBlank < 0) { + // Accept the line even if the (according to RFC 2616 mandatory) + // reason is missing. + secondBlank = line.length(); + } + int resultCode; + try { + resultCode = Integer.parseUnsignedInt( + line.substring(firstBlank + 1, secondBlank)); + } catch (NumberFormatException e) { + throw new ParseException(); + } + // Again, accept even if the reason is missing + String reason = ""; //$NON-NLS-1$ + if (secondBlank < line.length()) { + reason = line.substring(secondBlank + 1); + } + return new StatusLine(line.substring(0, firstBlank), resultCode, + reason); + } + + /** + * Extract the authentication headers from the header lines. It is assumed + * that the first element in {@code reply} is the raw status line as + * received from the server. It is skipped. Line processing stops on the + * first empty line thereafter. + * + * @param reply + * The complete (header) lines of the HTTP response + * @param authenticationHeader + * to look for (including the terminating ':'!) + * @return a list of {@link AuthenticationChallenge}s found. + */ + public static List getAuthenticationHeaders( + List reply, String authenticationHeader) { + List challenges = new ArrayList<>(); + Iterator lines = reply.iterator(); + // We know we have at least one line. Skip the response line. + lines.next(); + StringBuilder value = null; + while (lines.hasNext()) { + String line = lines.next(); + if (line.isEmpty()) { + break; + } + if (Character.isWhitespace(line.charAt(0))) { + // Continuation line. + if (value == null) { + // Skip if we have no current value + continue; + } + // Skip leading whitespace + int i = skipWhiteSpace(line, 1); + value.append(' ').append(line, i, line.length()); + continue; + } + if (value != null) { + parseChallenges(challenges, value.toString()); + value = null; + } + int firstColon = line.indexOf(':'); + if (firstColon > 0 && authenticationHeader + .equalsIgnoreCase(line.substring(0, firstColon + 1))) { + value = new StringBuilder(line.substring(firstColon + 1)); + } + } + if (value != null) { + parseChallenges(challenges, value.toString()); + } + return challenges; + } + + private static void parseChallenges( + List challenges, + String header) { + // Comma-separated list of challenges, each itself a scheme name + // followed optionally by either: a comma-separated list of key=value + // pairs, where the value may be a quoted string with backslash escapes, + // or a single token value, which itself may end in zero or more '=' + // characters. Ugh. + int length = header.length(); + for (int i = 0; i < length;) { + int start = skipWhiteSpace(header, i); + int end = scanToken(header, start); + if (end <= start) { + break; + } + AuthenticationChallenge challenge = new AuthenticationChallenge( + header.substring(start, end)); + challenges.add(challenge); + i = parseChallenge(challenge, header, end); + } + } + + private static int parseChallenge(AuthenticationChallenge challenge, + String header, int from) { + int length = header.length(); + boolean first = true; + for (int start = from; start <= length; first = false) { + // Now we have either a single token, which may end in zero or more + // equal signs, or a comma-separated list of key=value pairs (with + // optional legacy whitespace around the equals sign), where the + // value can be either a token or a quoted string. + start = skipWhiteSpace(header, start); + int end = scanToken(header, start); + if (end == start) { + // Nothing found. Either at end or on a comma. + if (start < header.length() && header.charAt(start) == ',') { + return start + 1; + } + return start; + } + int next = skipWhiteSpace(header, end); + // Comma, or equals sign, or end of string + if (next >= length || header.charAt(next) != '=') { + if (first) { + // It must be a token + challenge.setToken(header.substring(start, end)); + if (next < length && header.charAt(next) == ',') { + next++; + } + return next; + } else { + // This token must be the name of the next authentication + // scheme. + return start; + } + } + int nextStart = skipWhiteSpace(header, next + 1); + if (nextStart >= length) { + if (next == end) { + // '=' immediately after the key, no value: key must be the + // token, and the equals sign is part of the token + challenge.setToken(header.substring(start, end + 1)); + } else { + // Key without value... + challenge.addArgument(header.substring(start, end), null); + } + return nextStart; + } + if (nextStart == end + 1 && header.charAt(nextStart) == '=') { + // More than one equals sign: must be the single token. + end = nextStart + 1; + while (end < length && header.charAt(end) == '=') { + end++; + } + challenge.setToken(header.substring(start, end)); + end = skipWhiteSpace(header, end); + if (end < length && header.charAt(end) == ',') { + end++; + } + return end; + } + if (header.charAt(nextStart) == ',') { + if (next == end) { + // '=' immediately after the key, no value: key must be the + // token, and the equals sign is part of the token + challenge.setToken(header.substring(start, end + 1)); + return nextStart + 1; + } else { + // Key without value... + challenge.addArgument(header.substring(start, end), null); + start = nextStart + 1; + } + } else { + if (header.charAt(nextStart) == '"') { + int nextEnd[] = { nextStart + 1 }; + String value = scanQuotedString(header, nextStart + 1, + nextEnd); + challenge.addArgument(header.substring(start, end), value); + start = nextEnd[0]; + } else { + int nextEnd = scanToken(header, nextStart); + challenge.addArgument(header.substring(start, end), + header.substring(nextStart, nextEnd)); + start = nextEnd; + } + start = skipWhiteSpace(header, start); + if (start < length && header.charAt(start) == ',') { + start++; + } + } + } + return length; + } + + private static int skipWhiteSpace(String header, int i) { + int length = header.length(); + while (i < length && Character.isWhitespace(header.charAt(i))) { + i++; + } + return i; + } + + private static int scanToken(String header, int from) { + int length = header.length(); + int i = from; + while (i < length) { + char c = header.charAt(i); + switch (c) { + case '!': + case '#': + case '$': + case '%': + case '&': + case '\'': + case '*': + case '+': + case '-': + case '.': + case '^': + case '_': + case '`': + case '|': + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + i++; + break; + default: + if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') { + i++; + break; + } + return i; + } + } + return i; + } + + private static String scanQuotedString(String header, int from, int[] to) { + StringBuilder result = new StringBuilder(); + int length = header.length(); + boolean quoted = false; + int i = from; + while (i < length) { + char c = header.charAt(i++); + if (quoted) { + result.append(c); + quoted = false; + } else if (c == '\\') { + quoted = true; + } else if (c == '"') { + break; + } else { + result.append(c); + } + } + to[0] = i; + return result.toString(); + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/Socks5ClientConnector.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/Socks5ClientConnector.java new file mode 100644 index 000000000..1844fdc79 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/Socks5ClientConnector.java @@ -0,0 +1,642 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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.text.MessageFormat.format; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; + +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 RFC 1928 + */ +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 SOCKS + * Methods, IANA.org + */ + 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 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(StandardCharsets.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 RFC 1929 + */ + private class SocksBasicAuthentication + extends BasicAuthentication { + + 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(StandardCharsets.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 RFC 1961 + */ + private class SocksGssApiAuthentication + extends GssApiAuthentication { + + 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)); + } + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatefulProxyConnector.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatefulProxyConnector.java new file mode 100644 index 000000000..0d8e0f93e --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatefulProxyConnector.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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 java.util.concurrent.Callable; + +import org.apache.sshd.client.session.ClientProxyConnector; +import org.apache.sshd.common.io.IoSession; +import org.apache.sshd.common.util.Readable; + +/** + * Some proxy connections are stateful and require the exchange of multiple + * request-reply messages. The default {@link ClientProxyConnector} has only + * support for sending a message; replies get routed through the Ssh session, + * and don't get back to this proxy connector. Augment the interface so that the + * session can know when to route messages received to the proxy connector, and + * when to start handling them itself. + */ +public interface StatefulProxyConnector extends ClientProxyConnector { + + /** + * A property key for a session property defining the timeout for setting up + * the proxy connection. + */ + static final String TIMEOUT_PROPERTY = StatefulProxyConnector.class + .getName() + "-timeout"; //$NON-NLS-1$ + + /** + * Handle a received message. + * + * @param session + * to use for writing data + * @param buffer + * received data + * @throws Exception + * if data cannot be read, or the connection attempt fails + */ + void messageReceived(IoSession session, Readable buffer) throws Exception; + + /** + * Runs {@code startSsh} once the proxy connection is established. + * + * @param startSsh + * operation to run + * @throws Exception + * if the operation is run synchronously and throws an exception + */ + void runWhenDone(Callable startSsh) throws Exception; +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatusLine.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatusLine.java new file mode 100644 index 000000000..7ff0183b2 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatusLine.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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; + +/** + * A very simple representation of a HTTP status line. + */ +public class StatusLine { + + private final String version; + + private final int resultCode; + + private final String reason; + + /** + * Create a new {@link StatusLine} with the given response code and reason + * string. + * + * @param version + * the version string (normally "HTTP/1.1" or "HTTP/1.0") + * @param resultCode + * the HTTP response code (200, 401, etc.) + * @param reason + * the reason phrase for the code + */ + public StatusLine(String version, int resultCode, String reason) { + this.version = version; + this.resultCode = resultCode; + this.reason = reason; + } + + /** + * Retrieves the version string. + * + * @return the version string + */ + public String getVersion() { + return version; + } + + /** + * Retrieves the HTTP response code. + * + * @return the code + */ + public int getResultCode() { + return resultCode; + } + + /** + * Retrieves the HTTP reason phrase. + * + * @return the reason + */ + public String getReason() { + return reason; + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/DefaultProxyDataFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/DefaultProxyDataFactory.java new file mode 100644 index 000000000..d83e31fa2 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/DefaultProxyDataFactory.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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.transport.sshd; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; + +import org.apache.sshd.client.config.hosts.HostConfigEntry; + +/** + * A default implementation of a {@link ProxyDataFactory} based on the standard + * {@link java.net.ProxySelector}. + * + * @since 5.2 + */ +public class DefaultProxyDataFactory implements ProxyDataFactory { + + @Override + public ProxyData get(HostConfigEntry hostConfig, + InetSocketAddress remoteAddress) { + try { + List proxies = ProxySelector.getDefault() + .select(new URI(Proxy.Type.SOCKS.name(), + "//" + remoteAddress.getHostString(), null)); //$NON-NLS-1$ + ProxyData data = getData(proxies, Proxy.Type.SOCKS); + if (data == null) { + proxies = ProxySelector.getDefault() + .select(new URI(Proxy.Type.HTTP.name(), + "//" + remoteAddress.getHostString(), //$NON-NLS-1$ + null)); + data = getData(proxies, Proxy.Type.HTTP); + } + return data; + } catch (URISyntaxException e) { + return null; + } + } + + private ProxyData getData(List proxies, Proxy.Type type) { + Proxy proxy = proxies.stream().filter(p -> type == p.type()).findFirst() + .orElse(null); + if (proxy == null) { + return null; + } + SocketAddress address = proxy.address(); + if (!(address instanceof InetSocketAddress)) { + return null; + } + switch (type) { + case HTTP: + return new ProxyData(proxy); + case SOCKS: + return new ProxyData(proxy); + default: + return null; + } + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyData.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyData.java new file mode 100644 index 000000000..39b1e02ae --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyData.java @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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.transport.sshd; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.util.Arrays; + +import org.eclipse.jgit.annotations.NonNull; + +/** + * A DTO encapsulating the data needed to connect through a proxy server. + * + * @since 5.2 + */ +public class ProxyData { + + private final @NonNull Proxy proxy; + + private final String proxyUser; + + private final char[] proxyPassword; + + /** + * Creates a new {@link ProxyData} instance without user name or password. + * + * @param proxy + * to connect to; must not be {@link java.net.Proxy.Type#DIRECT} + * and must have an {@link InetSocketAddress}. + */ + public ProxyData(@NonNull Proxy proxy) { + this(proxy, null, null); + } + + /** + * Creates a new {@link ProxyData} instance. + * + * @param proxy + * to connect to; must not be {@link java.net.Proxy.Type#DIRECT} + * and must have an {@link InetSocketAddress}. + * @param proxyUser + * to use for log-in to the proxy, may be {@code null} + * @param proxyPassword + * to use for log-in to the proxy, may be {@code null} + */ + public ProxyData(@NonNull Proxy proxy, String proxyUser, + char[] proxyPassword) { + this.proxy = proxy; + if (!(proxy.address() instanceof InetSocketAddress)) { + // Internal error not translated + throw new IllegalArgumentException( + "Proxy does not have an InetSocketAddress"); //$NON-NLS-1$ + } + this.proxyUser = proxyUser; + this.proxyPassword = proxyPassword == null ? null + : proxyPassword.clone(); + } + + /** + * Obtains the remote {@link InetSocketAddress} of the proxy to connect to. + * + * @return the remote address of the proxy + */ + @NonNull + public Proxy getProxy() { + return proxy; + } + + /** + * Obtains the user to log in at the proxy with. + * + * @return the user name, or {@code null} if none + */ + public String getUser() { + return proxyUser; + } + + /** + * Obtains a copy of the internally stored password. + * + * @return the password or {@code null} if none + */ + public char[] getPassword() { + return proxyPassword == null ? null : proxyPassword.clone(); + } + + /** + * Clears the stored password, if any. + */ + public void clearPassword() { + if (proxyPassword != null) { + Arrays.fill(proxyPassword, '\000'); + } + } + +} \ No newline at end of file diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyDataFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyDataFactory.java new file mode 100644 index 000000000..1446d6ece --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyDataFactory.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2018, Thomas Wolf + * 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.transport.sshd; + +import java.net.InetSocketAddress; + +import org.apache.sshd.client.config.hosts.HostConfigEntry; + +/** + * Interface for obtaining {@link ProxyData} to connect through some proxy. + * + * @since 5.2 + */ +public interface ProxyDataFactory { + + /** + * Get the {@link ProxyData} to connect to a proxy. It should return a + * new {@link ProxyData} instance every time; if the returned + * {@link ProxyData} contains a password, the {@link SshdSession} will clear + * it once it is no longer needed. + * + * @param hostConfig + * from the ssh config that we're going to connect for + * @param remoteAddress + * to connect to + * @return the {@link ProxyData} or {@code null} if a direct connection is + * to be made + */ + ProxyData get(HostConfigEntry hostConfig, InetSocketAddress remoteAddress); +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SessionCloseListener.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SessionCloseListener.java index 1707c7079..31fc61f82 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SessionCloseListener.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SessionCloseListener.java @@ -45,6 +45,8 @@ /** * A {@code SessionCloseListener} is invoked when a {@link SshdSession} is * closed. + * + * @since 5.2 */ @FunctionalInterface public interface SessionCloseListener { diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java index 302ba09cc..f5d46d3d8 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java @@ -107,22 +107,25 @@ public class SshdSessionFactory extends SshSessionFactory implements Closeable { private final KeyCache keyCache; + private final ProxyDataFactory proxies; + private File sshDirectory; private File homeDirectory; /** - * Creates a new {@link SshdSessionFactory} without {@link KeyCache}. + * Creates a new {@link SshdSessionFactory} without key cache and a + * {@link DefaultProxyDataFactory}. */ public SshdSessionFactory() { - this(null); + this(null, new DefaultProxyDataFactory()); } /** - * Creates a new {@link SshdSessionFactory} using the given - * {@link KeyCache}. The {@code keyCache} is used for all sessions created - * through this session factory; cached keys are destroyed when the session - * factory is {@link #close() closed}. + * Creates a new {@link SshdSessionFactory} using the given {@link KeyCache} + * and {@link ProxyDataFactory}. The {@code keyCache} is used for all sessions + * created through this session factory; cached keys are destroyed when the + * session factory is {@link #close() closed}. *

* Caching ssh keys in memory for an extended period of time is generally * considered bad practice, but there may be circumstances where using a @@ -143,10 +146,15 @@ public SshdSessionFactory() { * @param keyCache * {@link KeyCache} to use for caching ssh keys, or {@code null} * to not use a key cache + * @param proxies + * {@link ProxyDataFactory} to use, or {@code null} to not use a + * proxy database (in which case connections through proxies will + * not be possible) */ - public SshdSessionFactory(KeyCache keyCache) { + public SshdSessionFactory(KeyCache keyCache, ProxyDataFactory proxies) { super(); this.keyCache = keyCache; + this.proxies = proxies; } /** A simple general map key. */ @@ -222,6 +230,7 @@ public SshdSession getSession(URIish uri, JGitSshClient jgitClient = (JGitSshClient) client; jgitClient.setKeyCache(getKeyCache()); jgitClient.setCredentialsProvider(credentialsProvider); + jgitClient.setProxyDatabase(proxies); String defaultAuths = getDefaultPreferredAuthentications(); if (defaultAuths != null) { jgitClient.setAttribute(