From 566e49d7d39b12c785be24b8b61b4960a4b7ea17 Mon Sep 17 00:00:00 2001 From: Thomas Wolf Date: Sun, 26 Jul 2020 20:37:57 +0200 Subject: [PATCH] sshd: support the ProxyJump ssh config 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 --- .../jgit/junit/ssh/SshTestHarness.java | 4 +- .../META-INF/MANIFEST.MF | 2 + .../jgit/transport/sshd/ApacheSshTest.java | 398 +++++++++++++++++- .../META-INF/MANIFEST.MF | 1 + .../transport/sshd/SshdText.properties | 6 + .../transport/sshd/JGitSshClient.java | 53 ++- .../internal/transport/sshd/SshdText.java | 6 + .../jgit/transport/sshd/SshdSession.java | 205 ++++++++- .../transport/sshd/SshdSessionFactory.java | 3 + .../eclipse/jgit/transport/SshConstants.java | 30 +- 10 files changed, 667 insertions(+), 41 deletions(-) diff --git a/org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestHarness.java b/org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestHarness.java index 797068543..90d981b77 100644 --- a/org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestHarness.java +++ b/org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestHarness.java @@ -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 void setUp() throws Exception { 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. 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 47f00695d..60f7d41a6 100644 --- a/org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF +++ b/org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF @@ -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)", 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 651ae7dec..3427da667 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 @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, Thomas Wolf and others + * Copyright (C) 2018, 2020 Thomas Wolf 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 @@ 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 void testCloneAndFetchWithSessionLimit() throws Exception { 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(); + } + } + } } diff --git a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF index e6ccbec28..c5c64fcd9 100644 --- a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF +++ b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF @@ -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)", 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 b89bc606a..504e6001c 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 @@ -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 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 1825fb37b..beaaecaac 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 @@ -49,6 +49,7 @@ 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 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 LOCAL_FORWARD_ADDRESS = new AttributeKey<>(); + private KeyCache keyCache; private CredentialsProvider credentialsProvider; @@ -102,25 +113,37 @@ public ConnectFuture connect(HostConfigEntry hostConfig, 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); - SshFutureListener listener = createConnectCompletionListener( - connectFuture, userName, address, hostConfig); - attributes = sessionAttributes(attributes, hostConfig, address); - // Proxy support - ProxyData proxy = getProxyData(address); - if (proxy != null) { - address = configureProxy(proxy, address); - proxy.clearPassword(); + SshdSocketAddress localForward = attributes + .resolveAttribute(LOCAL_FORWARD_ADDRESS); + if (localForward != null) { + targetAddress = new InetSocketAddress(localForward.getHostName(), + localForward.getPort()); + id += '/' + targetAddress.toString(); } - connector.connect(address, attributes, localAddress) + ConnectFuture connectFuture = new DefaultConnectFuture(id, null); + SshFutureListener listener = createConnectCompletionListener( + connectFuture, userName, originalAddress, hostConfig); + attributes = sessionAttributes(attributes, hostConfig, originalAddress); + // Proxy support + if (localForward == null) { + ProxyData proxy = getProxyData(targetAddress); + if (proxy != null) { + targetAddress = configureProxy(proxy, targetAddress); + proxy.clearPassword(); + } + } + connector.connect(targetAddress, attributes, localAddress) .addListener(listener); return connectFuture; } 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 22966f956..13bb3ebe7 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 @@ -24,8 +24,11 @@ public static SshdText get() { /***/ 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 static SshdText get() { /***/ 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 static SshdText get() { /***/ public String sshClosingDown; /***/ public String sshCommandTimeout; /***/ public String sshProcessStillRunning; + /***/ public String sshProxySessionCloseFailed; /***/ public String unknownProxyProtocol; } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java index dfd7cca1b..0fb0610b9 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, Thomas Wolf and others + * Copyright (C) 2018, 2020 Thomas Wolf 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 @@ -10,36 +10,53 @@ 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 listeners = new CopyOnWriteArrayList<>(); private final URIish uri; @@ -71,32 +93,169 @@ void connect(Duration timeout) throws IOException { 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() { - - @Override - public void sessionClosed(Session s) { - notifyCloseListeners(); - } - }); - // Authentication timeout is by default 2 minutes. - session.auth().verify(session.getAuthTimeout()); + session = connect(uri, Collections.emptyList(), + future -> notifyCloseListeners(), timeout, MAX_DEPTH); } catch (IOException e) { disconnect(e); throw e; } } + private ClientSession connect(URIish target, List jumps, + SshFutureListener 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 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. + resultSession.auth().verify(resultSession.getAuthTimeout()); + return resultSession; + } catch (IOException 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 determineHops(List 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 parseProxyJump(String proxyJump) + throws URISyntaxException { + String[] hops = proxyJump.split(","); //$NON-NLS-1$ + List 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. 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 0f7ab849f..4ad3c4a4b 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 @@ -230,6 +230,9 @@ public SshdSession getSession(URIish uri, return session; } catch (Exception e) { unregister(session); + if (e instanceof TransportException) { + throw (TransportException) e; + } throw new TransportException(uri, e.getMessage(), e); } } diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConstants.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConstants.java index b1fac2cff..fff2938e5 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConstants.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConstants.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018 Thomas Wolf and others + * Copyright (C) 2018, 2020 Thomas Wolf 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 @@ private 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 in + * reverse order. Each jump host is a SSH URI or "[user@]host[:port]". + *

+ * Reverse order means: to connect A->B->target, one can do in + * {@code ~/.ssh/config} either of: + *

+ * + *
+	 * Host target
+	 *   ProxyJump B,A
+	 * 
+ *

+ * or + *

+ * + *
+	 * Host target
+	 *   ProxyJump B
+	 *
+	 * Host B
+	 *   ProxyJump A
+	 * 
+ * + * @since 5.10 + */ + public static final String PROXY_JUMP = "ProxyJump"; + /** Key in an ssh config file. */ public static final String REMOTE_COMMAND = "RemoteCommand";