protected File publicKey1;
+ protected File publicKey2;
+
protected SshTestGitServer server;
private SshSessionFactory factory;
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.
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)",
/*
- * 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
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;
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();
+ }
+ }
+ }
}
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)",
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}
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
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
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;
*/
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;
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;
}
/***/ 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;
/***/ 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;
/***/ public String sshClosingDown;
/***/ public String sshCommandTimeout;
/***/ public String sshProcessStillRunning;
+ /***/ public String sshProxySessionCloseFailed;
/***/ public String unknownProxyProtocol;
}
/*
- * 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;
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;
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.
return session;
} catch (Exception e) {
unregister(session);
+ if (e instanceof TransportException) {
+ throw (TransportException) e;
+ }
throw new TransportException(uri, e.getMessage(), e);
}
}
/*
- * 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
/** 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";