Browse Source

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 <thomas.wolf@paranor.ch>
tags/v5.10.0.202011041322-m2
Thomas Wolf 3 years ago
parent
commit
566e49d7d3

+ 3
- 1
org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestHarness.java View 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.

+ 2
- 0
org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF View 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)",

+ 397
- 1
org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java View 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();
}
}
}
}

+ 1
- 0
org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF View 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)",

+ 6
- 0
org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties View 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}

+ 36
- 13
org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java View 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;
}

+ 6
- 0
org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java View 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;

}

+ 180
- 21
org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java View 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
@@ -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<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.

+ 3
- 0
org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java View 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);
}
}

+ 29
- 1
org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConstants.java View 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";


Loading…
Cancel
Save