]> source.dussan.org Git - jgit.git/commitdiff
sshd: support the ProxyJump ssh config 68/166868/16
authorThomas Wolf <thomas.wolf@paranor.ch>
Sun, 26 Jul 2020 18:37:57 +0000 (20:37 +0200)
committerThomas Wolf <thomas.wolf@paranor.ch>
Sat, 19 Sep 2020 19:17:00 +0000 (15:17 -0400)
This is useful to access git repositories behind a bastion server
(jump host).

Add a constant for the config; rewrite the whole connection initiation
to parse the value and (recursively) set up the chain of hops. Add
tests for a single hop and two different ways to configure a two-hop
chain.

The connection timeout applies to each hop in the chain individually.

Change-Id: Idd25af95aa2ec5367404587e4e530b0663c03665
Signed-off-by: Thomas Wolf <thomas.wolf@paranor.ch>
org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestHarness.java
org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF
org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java
org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF
org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties
org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java
org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java
org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java
org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java
org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConstants.java

index 797068543649690ffbec5cc603887cf038884f67..90d981b772bfd3083470c3300891c92e47764340 100644 (file)
@@ -76,6 +76,8 @@ public abstract class SshTestHarness extends RepositoryTestCase {
 
        protected File publicKey1;
 
+       protected File publicKey2;
+
        protected SshTestGitServer server;
 
        private SshSessionFactory factory;
@@ -110,7 +112,7 @@ public abstract class SshTestHarness extends RepositoryTestCase {
                privateKey1 = new File(sshDir, "first_key");
                privateKey2 = new File(sshDir, "second_key");
                publicKey1 = createKeyPair(generator.generateKeyPair(), privateKey1);
-               createKeyPair(generator.generateKeyPair(), privateKey2);
+               publicKey2 = createKeyPair(generator.generateKeyPair(), privateKey2);
                // Create a host key
                KeyPair hostKey = generator.generateKeyPair();
                // Start a server with our test user and the first key.
index 47f00695def37e84d6cb4a8f8673d1b31a5b77a1..60f7d41a62837f5fa0d204369906a344b7a195f9 100644 (file)
@@ -11,11 +11,13 @@ Import-Package: org.apache.sshd.client.config.hosts;version="[2.4.0,2.5.0)",
  org.apache.sshd.common;version="[2.4.0,2.5.0)",
  org.apache.sshd.common.auth;version="[2.4.0,2.5.0)",
  org.apache.sshd.common.config.keys;version="[2.4.0,2.5.0)",
+ org.apache.sshd.common.helpers;version="[2.4.0,2.5.0)",
  org.apache.sshd.common.keyprovider;version="[2.4.0,2.5.0)",
  org.apache.sshd.common.session;version="[2.4.0,2.5.0)",
  org.apache.sshd.common.util.net;version="[2.4.0,2.5.0)",
  org.apache.sshd.common.util.security;version="[2.4.0,2.5.0)",
  org.apache.sshd.server;version="[2.4.0,2.5.0)",
+ org.apache.sshd.server.forward;version="[2.4.0,2.5.0)",
  org.eclipse.jgit.api;version="[5.10.0,5.11.0)",
  org.eclipse.jgit.api.errors;version="[5.10.0,5.11.0)",
  org.eclipse.jgit.internal.transport.sshd.proxy;version="[5.10.0,5.11.0)",
index 651ae7dec170c5b1372da39d3fd4d1fa311e0b77..3427da667d4c0d9a3fde156317ca3c4970b1eca8 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others
+ * Copyright (C) 2018, 2020 Thomas Wolf <thomas.wolf@paranor.ch> and others
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Distribution License v. 1.0 which is available at
@@ -11,19 +11,39 @@ package org.eclipse.jgit.transport.sshd;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
+import java.io.BufferedWriter;
 import java.io.File;
 import java.io.IOException;
 import java.io.UncheckedIOException;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.nio.file.StandardOpenOption;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PublicKey;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.stream.Collectors;
+
 import org.apache.sshd.client.config.hosts.KnownHostEntry;
+import org.apache.sshd.client.config.hosts.KnownHostHashValue;
 import org.apache.sshd.common.PropertyResolverUtils;
+import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
+import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
+import org.apache.sshd.server.ServerAuthenticationManager;
 import org.apache.sshd.server.ServerFactoryManager;
+import org.apache.sshd.server.SshServer;
+import org.apache.sshd.server.forward.StaticDecisionForwardingFilter;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.junit.ssh.SshTestBase;
@@ -211,4 +231,380 @@ public class ApacheSshTest extends SshTestBase {
                        git.fetch().call();
                }
        }
+
+       /**
+        * Creates a simple proxy server. Accepts only publickey authentication from
+        * the given user with the given key, allows all forwardings. Adds the
+        * proxy's host key to {@link #knownHosts}.
+        *
+        * @param user
+        *            to accept
+        * @param userKey
+        *            public key of that user at this server
+        * @param report
+        *            single-element array to report back the forwarded address.
+        * @return the started server
+        * @throws Exception
+        */
+       private SshServer createProxy(String user, File userKey,
+                       SshdSocketAddress[] report) throws Exception {
+               SshServer proxy = SshServer.setUpDefaultServer();
+               // Give the server its own host key
+               KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+               generator.initialize(2048);
+               KeyPair proxyHostKey = generator.generateKeyPair();
+               proxy.setKeyPairProvider(
+                               session -> Collections.singletonList(proxyHostKey));
+               // Allow (only) publickey authentication
+               proxy.setUserAuthFactories(Collections.singletonList(
+                               ServerAuthenticationManager.DEFAULT_USER_AUTH_PUBLIC_KEY_FACTORY));
+               // Install the user's public key
+               PublicKey userProxyKey = AuthorizedKeyEntry
+                               .readAuthorizedKeys(userKey.toPath()).get(0)
+                               .resolvePublicKey(null, PublicKeyEntryResolver.IGNORING);
+               proxy.setPublickeyAuthenticator(
+                               (userName, publicKey, session) -> user.equals(userName)
+                                               && KeyUtils.compareKeys(userProxyKey, publicKey));
+               // Allow forwarding
+               proxy.setForwardingFilter(new StaticDecisionForwardingFilter(true) {
+
+                       @Override
+                       protected boolean checkAcceptance(String request, Session session,
+                                       SshdSocketAddress target) {
+                               report[0] = target;
+                               return super.checkAcceptance(request, session, target);
+                       }
+               });
+               proxy.start();
+               // Add the proxy's host key to knownhosts
+               try (BufferedWriter writer = Files.newBufferedWriter(
+                               knownHosts.toPath(), StandardCharsets.US_ASCII,
+                               StandardOpenOption.WRITE, StandardOpenOption.APPEND)) {
+                       writer.append('\n');
+                       KnownHostHashValue.appendHostPattern(writer, "localhost",
+                                       proxy.getPort());
+                       writer.append(',');
+                       KnownHostHashValue.appendHostPattern(writer, "127.0.0.1",
+                                       proxy.getPort());
+                       writer.append(' ');
+                       PublicKeyEntry.appendPublicKeyEntry(writer,
+                                       proxyHostKey.getPublic());
+                       writer.append('\n');
+               }
+               return proxy;
+       }
+
+       @Test
+       public void testJumpHost() throws Exception {
+               SshdSocketAddress[] forwarded = { null };
+               try (SshServer proxy = createProxy(TEST_USER + 'X', publicKey2,
+                               forwarded)) {
+                       try {
+                               // Now try to clone via the proxy
+                               cloneWith("ssh://server/doesntmatter", defaultCloneDir, null, //
+                                               "Host server", //
+                                               "HostName localhost", //
+                                               "Port " + testPort, //
+                                               "User " + TEST_USER, //
+                                               "IdentityFile " + privateKey1.getAbsolutePath(), //
+                                               "ProxyJump " + TEST_USER + "X@proxy:" + proxy.getPort(), //
+                                               "", //
+                                               "Host proxy", //
+                                               "Hostname localhost", //
+                                               "IdentityFile " + privateKey2.getAbsolutePath());
+                               assertNotNull(forwarded[0]);
+                               assertEquals(testPort, forwarded[0].getPort());
+                       } finally {
+                               proxy.stop();
+                       }
+               }
+       }
+
+       @Test
+       public void testJumpHostWrongKeyAtProxy() throws Exception {
+               // Test that we find the proxy server's URI in the exception message
+               SshdSocketAddress[] forwarded = { null };
+               try (SshServer proxy = createProxy(TEST_USER + 'X', publicKey2,
+                               forwarded)) {
+                       try {
+                               // Now try to clone via the proxy
+                               TransportException e = assertThrows(TransportException.class,
+                                               () -> cloneWith("ssh://server/doesntmatter",
+                                                               defaultCloneDir, null, //
+                                                               "Host server", //
+                                                               "HostName localhost", //
+                                                               "Port " + testPort, //
+                                                               "User " + TEST_USER, //
+                                                               "IdentityFile " + privateKey1.getAbsolutePath(),
+                                                               "ProxyJump " + TEST_USER + "X@proxy:"
+                                                                               + proxy.getPort(), //
+                                                               "", //
+                                                               "Host proxy", //
+                                                               "Hostname localhost", //
+                                                               "IdentityFile "
+                                                                               + privateKey1.getAbsolutePath()));
+                               String message = e.getMessage();
+                               assertTrue(message.contains("localhost:" + proxy.getPort()));
+                               assertTrue(message.contains("proxy:" + proxy.getPort()));
+                       } finally {
+                               proxy.stop();
+                       }
+               }
+       }
+
+       @Test
+       public void testJumpHostWrongKeyAtServer() throws Exception {
+               // Test that we find the target server's URI in the exception message
+               SshdSocketAddress[] forwarded = { null };
+               try (SshServer proxy = createProxy(TEST_USER + 'X', publicKey2,
+                               forwarded)) {
+                       try {
+                               // Now try to clone via the proxy
+                               TransportException e = assertThrows(TransportException.class,
+                                               () -> cloneWith("ssh://server/doesntmatter",
+                                                               defaultCloneDir, null, //
+                                                               "Host server", //
+                                                               "HostName localhost", //
+                                                               "Port " + testPort, //
+                                                               "User " + TEST_USER, //
+                                                               "IdentityFile " + privateKey2.getAbsolutePath(),
+                                                               "ProxyJump " + TEST_USER + "X@proxy:"
+                                                                               + proxy.getPort(), //
+                                                               "", //
+                                                               "Host proxy", //
+                                                               "Hostname localhost", //
+                                                               "IdentityFile "
+                                                                               + privateKey2.getAbsolutePath()));
+                               String message = e.getMessage();
+                               assertTrue(message.contains("localhost:" + testPort));
+                               assertTrue(message.contains("ssh://server"));
+                       } finally {
+                               proxy.stop();
+                       }
+               }
+       }
+
+       @Test
+       public void testJumpHostNonSsh() throws Exception {
+               SshdSocketAddress[] forwarded = { null };
+               try (SshServer proxy = createProxy(TEST_USER + 'X', publicKey2,
+                               forwarded)) {
+                       try {
+                               TransportException e = assertThrows(TransportException.class,
+                                               () -> cloneWith("ssh://server/doesntmatter",
+                                                               defaultCloneDir, null, //
+                                                               "Host server", //
+                                                               "HostName localhost", //
+                                                               "Port " + testPort, //
+                                                               "User " + TEST_USER, //
+                                                               "IdentityFile " + privateKey1.getAbsolutePath(), //
+                                                               "ProxyJump http://" + TEST_USER + "X@proxy:"
+                                                                               + proxy.getPort(), //
+                                                               "", //
+                                                               "Host proxy", //
+                                                               "Hostname localhost", //
+                                                               "IdentityFile "
+                                                                               + privateKey2.getAbsolutePath()));
+                               // Find the expected message
+                               Throwable t = e;
+                               while (t != null) {
+                                       if (t instanceof URISyntaxException) {
+                                               break;
+                                       }
+                                       t = t.getCause();
+                               }
+                               assertNotNull(t);
+                               assertTrue(t.getMessage().contains("Non-ssh"));
+                       } finally {
+                               proxy.stop();
+                       }
+               }
+       }
+
+       @Test
+       public void testJumpHostWithPath() throws Exception {
+               SshdSocketAddress[] forwarded = { null };
+               try (SshServer proxy = createProxy(TEST_USER + 'X', publicKey2,
+                               forwarded)) {
+                       try {
+                               TransportException e = assertThrows(TransportException.class,
+                                               () -> cloneWith("ssh://server/doesntmatter",
+                                                               defaultCloneDir, null, //
+                                                               "Host server", //
+                                                               "HostName localhost", //
+                                                               "Port " + testPort, //
+                                                               "User " + TEST_USER, //
+                                                               "IdentityFile " + privateKey1.getAbsolutePath(), //
+                                                               "ProxyJump ssh://" + TEST_USER + "X@proxy:"
+                                                                               + proxy.getPort() + "/wrongPath", //
+                                                               "", //
+                                                               "Host proxy", //
+                                                               "Hostname localhost", //
+                                                               "IdentityFile "
+                                                                               + privateKey2.getAbsolutePath()));
+                               // Find the expected message
+                               Throwable t = e;
+                               while (t != null) {
+                                       if (t instanceof URISyntaxException) {
+                                               break;
+                                       }
+                                       t = t.getCause();
+                               }
+                               assertNotNull(t);
+                               assertTrue(t.getMessage().contains("wrongPath"));
+                       } finally {
+                               proxy.stop();
+                       }
+               }
+       }
+
+       @Test
+       public void testJumpHostWithPathShort() throws Exception {
+               SshdSocketAddress[] forwarded = { null };
+               try (SshServer proxy = createProxy(TEST_USER + 'X', publicKey2,
+                               forwarded)) {
+                       try {
+                               TransportException e = assertThrows(TransportException.class,
+                                               () -> cloneWith("ssh://server/doesntmatter",
+                                                               defaultCloneDir, null, //
+                                                               "Host server", //
+                                                               "HostName localhost", //
+                                                               "Port " + testPort, //
+                                                               "User " + TEST_USER, //
+                                                               "IdentityFile " + privateKey1.getAbsolutePath(), //
+                                                               "ProxyJump " + TEST_USER + "X@proxy:wrongPath", //
+                                                               "", //
+                                                               "Host proxy", //
+                                                               "Hostname localhost", //
+                                                               "Port " + proxy.getPort(), //
+                                                               "IdentityFile "
+                                                                               + privateKey2.getAbsolutePath()));
+                               // Find the expected message
+                               Throwable t = e;
+                               while (t != null) {
+                                       if (t instanceof URISyntaxException) {
+                                               break;
+                                       }
+                                       t = t.getCause();
+                               }
+                               assertNotNull(t);
+                               assertTrue(t.getMessage().contains("wrongPath"));
+                       } finally {
+                               proxy.stop();
+                       }
+               }
+       }
+
+       @Test
+       public void testJumpHostChain() throws Exception {
+               SshdSocketAddress[] forwarded1 = { null };
+               SshdSocketAddress[] forwarded2 = { null };
+               try (SshServer proxy1 = createProxy(TEST_USER + 'X', publicKey2,
+                               forwarded1);
+                               SshServer proxy2 = createProxy("foo", publicKey1, forwarded2)) {
+                       try {
+                               // Clone proxy1 -> proxy2 -> server
+                               cloneWith("ssh://server/doesntmatter", defaultCloneDir, null, //
+                                               "Host server", //
+                                               "HostName localhost", //
+                                               "Port " + testPort, //
+                                               "User " + TEST_USER, //
+                                               "IdentityFile " + privateKey1.getAbsolutePath(), //
+                                               "ProxyJump proxy2," + TEST_USER + "X@proxy:"
+                                                               + proxy1.getPort(), //
+                                               "", //
+                                               "Host proxy", //
+                                               "Hostname localhost", //
+                                               "IdentityFile " + privateKey2.getAbsolutePath(), //
+                                               "", //
+                                               "Host proxy2", //
+                                               "Hostname localhost", //
+                                               "User foo", //
+                                               "Port " + proxy2.getPort(), //
+                                               "IdentityFile " + privateKey1.getAbsolutePath());
+                               assertNotNull(forwarded1[0]);
+                               assertEquals(proxy2.getPort(), forwarded1[0].getPort());
+                               assertNotNull(forwarded2[0]);
+                               assertEquals(testPort, forwarded2[0].getPort());
+                       } finally {
+                               proxy1.stop();
+                               proxy2.stop();
+                       }
+               }
+       }
+
+       @Test
+       public void testJumpHostCascade() throws Exception {
+               SshdSocketAddress[] forwarded1 = { null };
+               SshdSocketAddress[] forwarded2 = { null };
+               try (SshServer proxy1 = createProxy(TEST_USER + 'X', publicKey2,
+                               forwarded1);
+                               SshServer proxy2 = createProxy("foo", publicKey1, forwarded2)) {
+                       try {
+                               // Clone proxy2 -> proxy1 -> server
+                               cloneWith("ssh://server/doesntmatter", defaultCloneDir, null, //
+                                               "Host server", //
+                                               "HostName localhost", //
+                                               "Port " + testPort, //
+                                               "User " + TEST_USER, //
+                                               "IdentityFile " + privateKey1.getAbsolutePath(), //
+                                               "ProxyJump " + TEST_USER + "X@proxy", //
+                                               "", //
+                                               "Host proxy", //
+                                               "Hostname localhost", //
+                                               "Port " + proxy1.getPort(), //
+                                               "ProxyJump ssh://proxy2:" + proxy2.getPort(), //
+                                               "IdentityFile " + privateKey2.getAbsolutePath(), //
+                                               "", //
+                                               "Host proxy2", //
+                                               "Hostname localhost", //
+                                               "User foo", //
+                                               "IdentityFile " + privateKey1.getAbsolutePath());
+                               assertNotNull(forwarded1[0]);
+                               assertEquals(testPort, forwarded1[0].getPort());
+                               assertNotNull(forwarded2[0]);
+                               assertEquals(proxy1.getPort(), forwarded2[0].getPort());
+                       } finally {
+                               proxy1.stop();
+                               proxy2.stop();
+                       }
+               }
+       }
+
+       @Test
+       public void testJumpHostRecursion() throws Exception {
+               SshdSocketAddress[] forwarded1 = { null };
+               SshdSocketAddress[] forwarded2 = { null };
+               try (SshServer proxy1 = createProxy(TEST_USER + 'X', publicKey2,
+                               forwarded1);
+                               SshServer proxy2 = createProxy("foo", publicKey1, forwarded2)) {
+                       try {
+                               TransportException e = assertThrows(TransportException.class,
+                                               () -> cloneWith(
+                                               "ssh://server/doesntmatter", defaultCloneDir, null, //
+                                               "Host server", //
+                                               "HostName localhost", //
+                                               "Port " + testPort, //
+                                               "User " + TEST_USER, //
+                                               "IdentityFile " + privateKey1.getAbsolutePath(), //
+                                               "ProxyJump " + TEST_USER + "X@proxy", //
+                                               "", //
+                                               "Host proxy", //
+                                               "Hostname localhost", //
+                                               "Port " + proxy1.getPort(), //
+                                               "ProxyJump ssh://proxy2:" + proxy2.getPort(), //
+                                               "IdentityFile " + privateKey2.getAbsolutePath(), //
+                                               "", //
+                                               "Host proxy2", //
+                                               "Hostname localhost", //
+                                               "User foo", //
+                                               "ProxyJump " + TEST_USER + "X@proxy", //
+                                               "IdentityFile " + privateKey1.getAbsolutePath()));
+                               assertTrue(e.getMessage().contains("proxy"));
+                       } finally {
+                               proxy1.stop();
+                               proxy2.stop();
+                       }
+               }
+       }
 }
index e6ccbec28497cefd1423f4aa713e60e17f868e63..c5c64fcd9a4e2504ce8684fe4507bb8873fe8f97 100644 (file)
@@ -45,6 +45,7 @@ Import-Package: net.i2p.crypto.eddsa;version="[0.3.0,0.4.0)",
  org.apache.sshd.client.future;version="[2.4.0,2.5.0)",
  org.apache.sshd.client.keyverifier;version="[2.4.0,2.5.0)",
  org.apache.sshd.client.session;version="[2.4.0,2.5.0)",
+ org.apache.sshd.client.session.forward;version="[2.4.0,2.5.0)",
  org.apache.sshd.client.subsystem.sftp;version="[2.4.0,2.5.0)",
  org.apache.sshd.common;version="[2.4.0,2.5.0)",
  org.apache.sshd.common.auth;version="[2.4.0,2.5.0)",
index b89bc606a791da301b86461bc0fd9060e6c8b0a0..504e6001cc8a69992e6a48cda5abd08957fd76a7 100644 (file)
@@ -4,8 +4,11 @@ closeListenerFailed=Ssh session close listener failed
 configInvalidPath=Invalid path in ssh config key {0}: {1}
 configInvalidPattern=Invalid pattern in ssh config key {0}: {1}
 configInvalidPositive=Ssh config entry {0} must be a strictly positive number but is ''{1}''
+configInvalidProxyJump=Ssh config, host ''{0}'': Cannot parse ProxyJump ''{1}''
 configNoKnownHostKeyAlgorithms=No implementations for any of the algorithms ''{0}'' given in HostKeyAlgorithms in the ssh config; using the default.
 configNoRemainingHostKeyAlgorithms=Ssh config removed all host key algorithms: HostKeyAlgorithms ''{0}''
+configProxyJumpNotSsh=Non-ssh URI in ProxyJump ssh config
+configProxyJumpWithPath=ProxyJump ssh config: jump host specification must not have a path
 ftpCloseFailed=Closing the SFTP channel failed
 gssapiFailure=GSS-API error for mechanism OID {0}
 gssapiInitFailure=GSS-API initialization failure for mechanism {0}
@@ -46,12 +49,14 @@ 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} ?
+loginDenied=Log-in denied at {0}:{1}
 passwordPrompt=Password
 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
+proxyJumpAbort=ProxyJump chain too long at {0}
 proxyPasswordPrompt=Proxy password
 proxySocksAuthenticationFailed=Authentication to SOCKS5 proxy {0} failed
 proxySocksFailureForbidden=SOCKS5 proxy {0}: connection to {1} not allowed by ruleset
@@ -80,4 +85,5 @@ sessionWithoutUsername=SSH session created without user name; cannot authenticat
 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
+sshProxySessionCloseFailed=Error while closing proxy session {0}
 unknownProxyProtocol=Ignoring unknown proxy protocol {0}
\ No newline at end of file
index 1825fb37b29a71a65fa415db2aa8a0038df2d4b2..beaaecaac9d13ae2824056d7ba567032cb0630d5 100644 (file)
@@ -49,6 +49,7 @@ import org.apache.sshd.common.keyprovider.KeyIdentityProvider;
 import org.apache.sshd.common.session.SessionContext;
 import org.apache.sshd.common.session.helpers.AbstractSession;
 import org.apache.sshd.common.util.ValidateUtils;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
 import org.eclipse.jgit.internal.transport.sshd.JGitClientSession.ChainingAttributes;
 import org.eclipse.jgit.internal.transport.sshd.JGitClientSession.SessionAttributes;
 import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector;
@@ -82,6 +83,16 @@ public class JGitSshClient extends SshClient {
         */
        public static final AttributeKey<String> PREFERRED_AUTHENTICATIONS = new AttributeKey<>();
 
+       /**
+        * An attribute key for storing an alternate local address to connect to if
+        * a local forward from a ProxyJump ssh config is present. If set,
+        * {@link #connect(HostConfigEntry, AttributeRepository, SocketAddress)}
+        * will not connect to the address obtained from the {@link HostConfigEntry}
+        * but to the address stored in this key (which is assumed to forward the
+        * {@code HostConfigEntry} address).
+        */
+       public static final AttributeKey<SshdSocketAddress> LOCAL_FORWARD_ADDRESS = new AttributeKey<>();
+
        private KeyCache keyCache;
 
        private CredentialsProvider credentialsProvider;
@@ -102,25 +113,37 @@ public class JGitSshClient extends SshClient {
                        throw new IllegalStateException("SshClient not started."); //$NON-NLS-1$
                }
                Objects.requireNonNull(hostConfig, "No host configuration"); //$NON-NLS-1$
-               String host = ValidateUtils.checkNotNullAndNotEmpty(
+               String originalHost = ValidateUtils.checkNotNullAndNotEmpty(
                                hostConfig.getHostName(), "No target host"); //$NON-NLS-1$
-               int port = hostConfig.getPort();
-               ValidateUtils.checkTrue(port > 0, "Invalid port: %d", port); //$NON-NLS-1$
+               int originalPort = hostConfig.getPort();
+               ValidateUtils.checkTrue(originalPort > 0, "Invalid port: %d", //$NON-NLS-1$
+                               originalPort);
+               InetSocketAddress originalAddress = new InetSocketAddress(originalHost,
+                               originalPort);
+               InetSocketAddress targetAddress = originalAddress;
                String userName = hostConfig.getUsername();
+               String id = userName + '@' + originalAddress;
                AttributeRepository attributes = chain(context, this);
-               InetSocketAddress address = new InetSocketAddress(host, port);
-               ConnectFuture connectFuture = new DefaultConnectFuture(
-                               userName + '@' + address, null);
+               SshdSocketAddress localForward = attributes
+                               .resolveAttribute(LOCAL_FORWARD_ADDRESS);
+               if (localForward != null) {
+                       targetAddress = new InetSocketAddress(localForward.getHostName(),
+                                       localForward.getPort());
+                       id += '/' + targetAddress.toString();
+               }
+               ConnectFuture connectFuture = new DefaultConnectFuture(id, null);
                SshFutureListener<IoConnectFuture> listener = createConnectCompletionListener(
-                               connectFuture, userName, address, hostConfig);
-               attributes = sessionAttributes(attributes, hostConfig, address);
+                               connectFuture, userName, originalAddress, hostConfig);
+               attributes = sessionAttributes(attributes, hostConfig, originalAddress);
                // Proxy support
-               ProxyData proxy = getProxyData(address);
-               if (proxy != null) {
-                       address = configureProxy(proxy, address);
-                       proxy.clearPassword();
+               if (localForward == null) {
+                       ProxyData proxy = getProxyData(targetAddress);
+                       if (proxy != null) {
+                               targetAddress = configureProxy(proxy, targetAddress);
+                               proxy.clearPassword();
+                       }
                }
-               connector.connect(address, attributes, localAddress)
+               connector.connect(targetAddress, attributes, localAddress)
                                .addListener(listener);
                return connectFuture;
        }
index 22966f956e1320a4cf48120d212efbb71135f6ab..13bb3ebe7518ead0e22adf7f4d7e4a49f26d1444 100644 (file)
@@ -24,8 +24,11 @@ public final class SshdText extends TranslationBundle {
        /***/ public String configInvalidPath;
        /***/ public String configInvalidPattern;
        /***/ public String configInvalidPositive;
+       /***/ public String configInvalidProxyJump;
        /***/ public String configNoKnownHostKeyAlgorithms;
        /***/ public String configNoRemainingHostKeyAlgorithms;
+       /***/ public String configProxyJumpNotSsh;
+       /***/ public String configProxyJumpWithPath;
        /***/ public String ftpCloseFailed;
        /***/ public String gssapiFailure;
        /***/ public String gssapiInitFailure;
@@ -58,12 +61,14 @@ public final class SshdText extends TranslationBundle {
        /***/ public String knownHostsUnknownKeyType;
        /***/ public String knownHostsUserAskCreationMsg;
        /***/ public String knownHostsUserAskCreationPrompt;
+       /***/ public String loginDenied;
        /***/ public String passwordPrompt;
        /***/ public String proxyCannotAuthenticate;
        /***/ public String proxyHttpFailure;
        /***/ public String proxyHttpInvalidUserName;
        /***/ public String proxyHttpUnexpectedReply;
        /***/ public String proxyHttpUnspecifiedFailureReason;
+       /***/ public String proxyJumpAbort;
        /***/ public String proxyPasswordPrompt;
        /***/ public String proxySocksAuthenticationFailed;
        /***/ public String proxySocksFailureForbidden;
@@ -92,6 +97,7 @@ public final class SshdText extends TranslationBundle {
        /***/ public String sshClosingDown;
        /***/ public String sshCommandTimeout;
        /***/ public String sshProcessStillRunning;
+       /***/ public String sshProxySessionCloseFailed;
        /***/ public String unknownProxyProtocol;
 
 }
index dfd7cca1b42ead7cfdad1202ea894cb19a3acc3e..0fb0610b991243bd5ed84b849d8b0898ecb82680 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others
+ * Copyright (C) 2018, 2020 Thomas Wolf <thomas.wolf@paranor.ch> and others
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Distribution License v. 1.0 which is available at
 package org.eclipse.jgit.transport.sshd;
 
 import static java.text.MessageFormat.format;
+import static org.apache.sshd.common.SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE;
 
+import java.io.Closeable;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.net.URISyntaxException;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.EnumSet;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Supplier;
+import java.util.regex.Pattern;
 
 import org.apache.sshd.client.SshClient;
 import org.apache.sshd.client.channel.ChannelExec;
 import org.apache.sshd.client.channel.ClientChannelEvent;
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
+import org.apache.sshd.client.future.ConnectFuture;
 import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.client.session.forward.PortForwardingTracker;
 import org.apache.sshd.client.subsystem.sftp.SftpClient;
 import org.apache.sshd.client.subsystem.sftp.SftpClient.CloseableHandle;
 import org.apache.sshd.client.subsystem.sftp.SftpClient.CopyMode;
 import org.apache.sshd.client.subsystem.sftp.SftpClientFactory;
-import org.apache.sshd.common.session.Session;
-import org.apache.sshd.common.session.SessionListener;
+import org.apache.sshd.common.AttributeRepository;
+import org.apache.sshd.common.SshException;
+import org.apache.sshd.common.future.CloseFuture;
+import org.apache.sshd.common.future.SshFutureListener;
 import org.apache.sshd.common.subsystem.sftp.SftpException;
+import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
 import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.errors.TransportException;
+import org.eclipse.jgit.internal.transport.sshd.JGitSshClient;
 import org.eclipse.jgit.internal.transport.sshd.SshdText;
 import org.eclipse.jgit.transport.FtpChannel;
 import org.eclipse.jgit.transport.RemoteSession;
+import org.eclipse.jgit.transport.SshConstants;
 import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.util.StringUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -53,6 +70,11 @@ public class SshdSession implements RemoteSession {
        private static final Logger LOG = LoggerFactory
                        .getLogger(SshdSession.class);
 
+       private static final Pattern SHORT_SSH_FORMAT = Pattern
+                       .compile("[-\\w.]+(?:@[-\\w.]+)?(?::\\d+)?"); //$NON-NLS-1$
+
+       private static final int MAX_DEPTH = 10;
+
        private final CopyOnWriteArrayList<SessionCloseListener> listeners = new CopyOnWriteArrayList<>();
 
        private final URIish uri;
@@ -71,32 +93,169 @@ public class SshdSession implements RemoteSession {
                        client.start();
                }
                try {
-                       String username = uri.getUser();
-                       String host = uri.getHost();
-                       int port = uri.getPort();
-                       long t = timeout.toMillis();
-                       if (t <= 0) {
-                               session = client.connect(username, host, port).verify()
-                                               .getSession();
-                       } else {
-                               session = client.connect(username, host, port)
-                                               .verify(timeout.toMillis()).getSession();
-                       }
-                       session.addSessionListener(new SessionListener() {
+                       session = connect(uri, Collections.emptyList(),
+                                       future -> notifyCloseListeners(), timeout, MAX_DEPTH);
+               } catch (IOException e) {
+                       disconnect(e);
+                       throw e;
+               }
+       }
 
-                               @Override
-                               public void sessionClosed(Session s) {
-                                       notifyCloseListeners();
+       private ClientSession connect(URIish target, List<URIish> jumps,
+                       SshFutureListener<CloseFuture> listener, Duration timeout,
+                       int depth) throws IOException {
+               if (--depth < 0) {
+                       throw new IOException(
+                                       format(SshdText.get().proxyJumpAbort, target));
+               }
+               HostConfigEntry hostConfig = getHostConfig(target.getUser(),
+                               target.getHost(), target.getPort());
+               String host = hostConfig.getHostName();
+               int port = hostConfig.getPort();
+               List<URIish> hops = determineHops(jumps, hostConfig, target.getHost());
+               ClientSession resultSession = null;
+               ClientSession proxySession = null;
+               PortForwardingTracker portForward = null;
+               try {
+                       if (!hops.isEmpty()) {
+                               URIish hop = hops.remove(0);
+                               if (LOG.isDebugEnabled()) {
+                                       LOG.debug("Connecting to jump host {}", hop); //$NON-NLS-1$
                                }
-                       });
+                               proxySession = connect(hop, hops, null, timeout, depth);
+                       }
+                       AttributeRepository context = null;
+                       if (proxySession != null) {
+                               SshdSocketAddress remoteAddress = new SshdSocketAddress(host,
+                                               port);
+                               portForward = proxySession.createLocalPortForwardingTracker(
+                                               SshdSocketAddress.LOCALHOST_ADDRESS, remoteAddress);
+                               // We must connect to the locally bound address, not the one
+                               // from the host config.
+                               context = AttributeRepository.ofKeyValuePair(
+                                               JGitSshClient.LOCAL_FORWARD_ADDRESS,
+                                               portForward.getBoundAddress());
+                       }
+                       resultSession = connect(hostConfig, context, timeout);
+                       if (proxySession != null) {
+                               final PortForwardingTracker tracker = portForward;
+                               final ClientSession pSession = proxySession;
+                               resultSession.addCloseFutureListener(future -> {
+                                       IoUtils.closeQuietly(tracker);
+                                       String sessionName = pSession.toString();
+                                       try {
+                                               pSession.close();
+                                       } catch (IOException e) {
+                                               LOG.error(format(
+                                                               SshdText.get().sshProxySessionCloseFailed,
+                                                               sessionName), e);
+                                       }
+                               });
+                               portForward = null;
+                               proxySession = null;
+                       }
+                       if (listener != null) {
+                               resultSession.addCloseFutureListener(listener);
+                       }
                        // Authentication timeout is by default 2 minutes.
-                       session.auth().verify(session.getAuthTimeout());
+                       resultSession.auth().verify(resultSession.getAuthTimeout());
+                       return resultSession;
                } catch (IOException e) {
-                       disconnect(e);
+                       close(portForward, e);
+                       close(proxySession, e);
+                       close(resultSession, e);
+                       if (e instanceof SshException && ((SshException) e)
+                                       .getDisconnectCode() == SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) {
+                               // Ensure the user gets to know on which URI the authentication
+                               // was denied.
+                               throw new TransportException(target,
+                                               format(SshdText.get().loginDenied, host,
+                                                               Integer.toString(port)),
+                                               e);
+                       }
                        throw e;
                }
        }
 
+       private ClientSession connect(HostConfigEntry config,
+                       AttributeRepository context, Duration timeout)
+                       throws IOException {
+               ConnectFuture connected = client.connect(config, context, null);
+               long timeoutMillis = timeout.toMillis();
+               if (timeoutMillis <= 0) {
+                       connected = connected.verify();
+               } else {
+                       connected = connected.verify(timeoutMillis);
+               }
+               return connected.getSession();
+       }
+
+       private void close(Closeable toClose, Throwable error) {
+               if (toClose != null) {
+                       try {
+                               toClose.close();
+                       } catch (IOException e) {
+                               error.addSuppressed(e);
+                       }
+               }
+       }
+
+       private HostConfigEntry getHostConfig(String username, String host,
+                       int port) throws IOException {
+               HostConfigEntry entry = client.getHostConfigEntryResolver()
+                               .resolveEffectiveHost(host, port, null, username, null);
+               if (entry == null) {
+                       if (SshdSocketAddress.isIPv6Address(host)) {
+                               return new HostConfigEntry("", host, port, username); //$NON-NLS-1$
+                       }
+                       return new HostConfigEntry(host, host, port, username);
+               }
+               return entry;
+       }
+
+       private List<URIish> determineHops(List<URIish> currentHops,
+                       HostConfigEntry hostConfig, String host) throws IOException {
+               if (currentHops.isEmpty()) {
+                       String jumpHosts = hostConfig.getProperty(SshConstants.PROXY_JUMP);
+                       if (!StringUtils.isEmptyOrNull(jumpHosts)) {
+                               try {
+                                       return parseProxyJump(jumpHosts);
+                               } catch (URISyntaxException e) {
+                                       throw new IOException(
+                                                       format(SshdText.get().configInvalidProxyJump, host,
+                                                                       jumpHosts),
+                                                       e);
+                               }
+                       }
+               }
+               return currentHops;
+       }
+
+       private List<URIish> parseProxyJump(String proxyJump)
+                       throws URISyntaxException {
+               String[] hops = proxyJump.split(","); //$NON-NLS-1$
+               List<URIish> result = new LinkedList<>();
+               for (String hop : hops) {
+                       // There shouldn't be any whitespace, but let's be lenient
+                       hop = hop.trim();
+                       if (SHORT_SSH_FORMAT.matcher(hop).matches()) {
+                               // URIish doesn't understand the short SSH format
+                               // user@host:port, only user@host:path
+                               hop = SshConstants.SSH_SCHEME + "://" + hop; //$NON-NLS-1$
+                       }
+                       URIish to = new URIish(hop);
+                       if (!SshConstants.SSH_SCHEME.equalsIgnoreCase(to.getScheme())) {
+                               throw new URISyntaxException(hop,
+                                               SshdText.get().configProxyJumpNotSsh);
+                       } else if (!StringUtils.isEmptyOrNull(to.getPath())) {
+                               throw new URISyntaxException(hop,
+                                               SshdText.get().configProxyJumpWithPath);
+                       }
+                       result.add(to);
+               }
+               return result;
+       }
+
        /**
         * Adds a {@link SessionCloseListener} to this session. Has no effect if the
         * given {@code listener} is already registered with this session.
index 0f7ab849f539e418be905578026f2c70423142ca..4ad3c4a4bad6c15723b47d32698592edb2694fe3 100644 (file)
@@ -230,6 +230,9 @@ public class SshdSessionFactory extends SshSessionFactory implements Closeable {
                        return session;
                } catch (Exception e) {
                        unregister(session);
+                       if (e instanceof TransportException) {
+                               throw (TransportException) e;
+                       }
                        throw new TransportException(uri, e.getMessage(), e);
                }
        }
index b1fac2cffb317ba76da2c413f0c4555fb9de9f82..fff2938e5d9940b8eeb4317ad4c27d7a4b33c04d 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2018 Thomas Wolf <thomas.wolf@paranor.ch> and others
+ * Copyright (C) 2018, 2020 Thomas Wolf <thomas.wolf@paranor.ch> and others
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Distribution License v. 1.0 which is available at
@@ -117,6 +117,34 @@ public final class SshConstants {
        /** Key in an ssh config file. */
        public static final String PROXY_COMMAND = "ProxyCommand";
 
+       /**
+        * Comma-separated list of jump hosts, defining a jump host chain <em>in
+        * reverse order</em>. Each jump host is a SSH URI or "[user@]host[:port]".
+        * <p>
+        * Reverse order means: to connect A->B->target, one can do in
+        * {@code ~/.ssh/config} either of:
+        * </p>
+        *
+        * <pre>
+        * Host target
+        *   ProxyJump B,A
+        * </pre>
+        * <p>
+        * <em>or</em>
+        * </p>
+        *
+        * <pre>
+        * Host target
+        *   ProxyJump B
+        *
+        * Host B
+        *   ProxyJump A
+        * </pre>
+        *
+        * @since 5.10
+        */
+       public static final String PROXY_JUMP = "ProxyJump";
+
        /** Key in an ssh config file. */
        public static final String REMOTE_COMMAND = "RemoteCommand";