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>
This commit is contained in:
parent
020dc586a6
commit
566e49d7d3
|
@ -76,6 +76,8 @@ public abstract class SshTestHarness extends RepositoryTestCase {
|
||||||
|
|
||||||
protected File publicKey1;
|
protected File publicKey1;
|
||||||
|
|
||||||
|
protected File publicKey2;
|
||||||
|
|
||||||
protected SshTestGitServer server;
|
protected SshTestGitServer server;
|
||||||
|
|
||||||
private SshSessionFactory factory;
|
private SshSessionFactory factory;
|
||||||
|
@ -110,7 +112,7 @@ public void setUp() throws Exception {
|
||||||
privateKey1 = new File(sshDir, "first_key");
|
privateKey1 = new File(sshDir, "first_key");
|
||||||
privateKey2 = new File(sshDir, "second_key");
|
privateKey2 = new File(sshDir, "second_key");
|
||||||
publicKey1 = createKeyPair(generator.generateKeyPair(), privateKey1);
|
publicKey1 = createKeyPair(generator.generateKeyPair(), privateKey1);
|
||||||
createKeyPair(generator.generateKeyPair(), privateKey2);
|
publicKey2 = createKeyPair(generator.generateKeyPair(), privateKey2);
|
||||||
// Create a host key
|
// Create a host key
|
||||||
KeyPair hostKey = generator.generateKeyPair();
|
KeyPair hostKey = generator.generateKeyPair();
|
||||||
// Start a server with our test user and the first key.
|
// Start a server with our test user and the first key.
|
||||||
|
|
|
@ -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;version="[2.4.0,2.5.0)",
|
||||||
org.apache.sshd.common.auth;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.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.keyprovider;version="[2.4.0,2.5.0)",
|
||||||
org.apache.sshd.common.session;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.net;version="[2.4.0,2.5.0)",
|
||||||
org.apache.sshd.common.util.security;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;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;version="[5.10.0,5.11.0)",
|
||||||
org.eclipse.jgit.api.errors;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)",
|
org.eclipse.jgit.internal.transport.sshd.proxy;version="[5.10.0,5.11.0)",
|
||||||
|
|
|
@ -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
|
* This program and the accompanying materials are made available under the
|
||||||
* terms of the Eclipse Distribution License v. 1.0 which is available at
|
* 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.assertEquals;
|
||||||
import static org.junit.Assert.assertFalse;
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
import static org.junit.Assert.assertThrows;
|
import static org.junit.Assert.assertThrows;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import java.io.BufferedWriter;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.UncheckedIOException;
|
import java.io.UncheckedIOException;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
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.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.apache.sshd.client.config.hosts.KnownHostEntry;
|
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.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.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.Git;
|
||||||
import org.eclipse.jgit.api.errors.TransportException;
|
import org.eclipse.jgit.api.errors.TransportException;
|
||||||
import org.eclipse.jgit.junit.ssh.SshTestBase;
|
import org.eclipse.jgit.junit.ssh.SshTestBase;
|
||||||
|
@ -211,4 +231,380 @@ public void testCloneAndFetchWithSessionLimit() throws Exception {
|
||||||
git.fetch().call();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.future;version="[2.4.0,2.5.0)",
|
||||||
org.apache.sshd.client.keyverifier;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;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.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;version="[2.4.0,2.5.0)",
|
||||||
org.apache.sshd.common.auth;version="[2.4.0,2.5.0)",
|
org.apache.sshd.common.auth;version="[2.4.0,2.5.0)",
|
||||||
|
|
|
@ -4,8 +4,11 @@ closeListenerFailed=Ssh session close listener failed
|
||||||
configInvalidPath=Invalid path in ssh config key {0}: {1}
|
configInvalidPath=Invalid path in ssh config key {0}: {1}
|
||||||
configInvalidPattern=Invalid pattern 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}''
|
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.
|
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}''
|
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
|
ftpCloseFailed=Closing the SFTP channel failed
|
||||||
gssapiFailure=GSS-API error for mechanism OID {0}
|
gssapiFailure=GSS-API error for mechanism OID {0}
|
||||||
gssapiInitFailure=GSS-API initialization failure for mechanism {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}
|
knownHostsUnknownKeyType=Cannot read server key from known hosts file {0}; line {1}
|
||||||
knownHostsUserAskCreationMsg=File {0} does not exist.
|
knownHostsUserAskCreationMsg=File {0} does not exist.
|
||||||
knownHostsUserAskCreationPrompt=Create file {0} ?
|
knownHostsUserAskCreationPrompt=Create file {0} ?
|
||||||
|
loginDenied=Log-in denied at {0}:{1}
|
||||||
passwordPrompt=Password
|
passwordPrompt=Password
|
||||||
proxyCannotAuthenticate=Cannot authenticate to proxy {0}
|
proxyCannotAuthenticate=Cannot authenticate to proxy {0}
|
||||||
proxyHttpFailure=HTTP Proxy connection to {0} failed with code {1}: {2}
|
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}
|
proxyHttpInvalidUserName=HTTP proxy connection {0} with invalid user name; must not contain colons: {1}
|
||||||
proxyHttpUnexpectedReply=Unexpected HTTP proxy response from {0}: {1}
|
proxyHttpUnexpectedReply=Unexpected HTTP proxy response from {0}: {1}
|
||||||
proxyHttpUnspecifiedFailureReason=unspecified reason
|
proxyHttpUnspecifiedFailureReason=unspecified reason
|
||||||
|
proxyJumpAbort=ProxyJump chain too long at {0}
|
||||||
proxyPasswordPrompt=Proxy password
|
proxyPasswordPrompt=Proxy password
|
||||||
proxySocksAuthenticationFailed=Authentication to SOCKS5 proxy {0} failed
|
proxySocksAuthenticationFailed=Authentication to SOCKS5 proxy {0} failed
|
||||||
proxySocksFailureForbidden=SOCKS5 proxy {0}: connection to {1} not allowed by ruleset
|
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
|
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
|
sshCommandTimeout={0} timed out after {1} seconds while opening the channel
|
||||||
sshProcessStillRunning={0} is not yet completed, cannot get exit code
|
sshProcessStillRunning={0} is not yet completed, cannot get exit code
|
||||||
|
sshProxySessionCloseFailed=Error while closing proxy session {0}
|
||||||
unknownProxyProtocol=Ignoring unknown proxy protocol {0}
|
unknownProxyProtocol=Ignoring unknown proxy protocol {0}
|
|
@ -49,6 +49,7 @@
|
||||||
import org.apache.sshd.common.session.SessionContext;
|
import org.apache.sshd.common.session.SessionContext;
|
||||||
import org.apache.sshd.common.session.helpers.AbstractSession;
|
import org.apache.sshd.common.session.helpers.AbstractSession;
|
||||||
import org.apache.sshd.common.util.ValidateUtils;
|
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.ChainingAttributes;
|
||||||
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession.SessionAttributes;
|
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession.SessionAttributes;
|
||||||
import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector;
|
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<>();
|
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 KeyCache keyCache;
|
||||||
|
|
||||||
private CredentialsProvider credentialsProvider;
|
private CredentialsProvider credentialsProvider;
|
||||||
|
@ -102,25 +113,37 @@ public ConnectFuture connect(HostConfigEntry hostConfig,
|
||||||
throw new IllegalStateException("SshClient not started."); //$NON-NLS-1$
|
throw new IllegalStateException("SshClient not started."); //$NON-NLS-1$
|
||||||
}
|
}
|
||||||
Objects.requireNonNull(hostConfig, "No host configuration"); //$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$
|
hostConfig.getHostName(), "No target host"); //$NON-NLS-1$
|
||||||
int port = hostConfig.getPort();
|
int originalPort = hostConfig.getPort();
|
||||||
ValidateUtils.checkTrue(port > 0, "Invalid port: %d", port); //$NON-NLS-1$
|
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 userName = hostConfig.getUsername();
|
||||||
|
String id = userName + '@' + originalAddress;
|
||||||
AttributeRepository attributes = chain(context, this);
|
AttributeRepository attributes = chain(context, this);
|
||||||
InetSocketAddress address = new InetSocketAddress(host, port);
|
SshdSocketAddress localForward = attributes
|
||||||
ConnectFuture connectFuture = new DefaultConnectFuture(
|
.resolveAttribute(LOCAL_FORWARD_ADDRESS);
|
||||||
userName + '@' + address, null);
|
if (localForward != null) {
|
||||||
SshFutureListener<IoConnectFuture> listener = createConnectCompletionListener(
|
targetAddress = new InetSocketAddress(localForward.getHostName(),
|
||||||
connectFuture, userName, address, hostConfig);
|
localForward.getPort());
|
||||||
attributes = sessionAttributes(attributes, hostConfig, address);
|
id += '/' + targetAddress.toString();
|
||||||
// Proxy support
|
|
||||||
ProxyData proxy = getProxyData(address);
|
|
||||||
if (proxy != null) {
|
|
||||||
address = configureProxy(proxy, address);
|
|
||||||
proxy.clearPassword();
|
|
||||||
}
|
}
|
||||||
connector.connect(address, attributes, localAddress)
|
ConnectFuture connectFuture = new DefaultConnectFuture(id, null);
|
||||||
|
SshFutureListener<IoConnectFuture> 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);
|
.addListener(listener);
|
||||||
return connectFuture;
|
return connectFuture;
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,8 +24,11 @@ public static SshdText get() {
|
||||||
/***/ public String configInvalidPath;
|
/***/ public String configInvalidPath;
|
||||||
/***/ public String configInvalidPattern;
|
/***/ public String configInvalidPattern;
|
||||||
/***/ public String configInvalidPositive;
|
/***/ public String configInvalidPositive;
|
||||||
|
/***/ public String configInvalidProxyJump;
|
||||||
/***/ public String configNoKnownHostKeyAlgorithms;
|
/***/ public String configNoKnownHostKeyAlgorithms;
|
||||||
/***/ public String configNoRemainingHostKeyAlgorithms;
|
/***/ public String configNoRemainingHostKeyAlgorithms;
|
||||||
|
/***/ public String configProxyJumpNotSsh;
|
||||||
|
/***/ public String configProxyJumpWithPath;
|
||||||
/***/ public String ftpCloseFailed;
|
/***/ public String ftpCloseFailed;
|
||||||
/***/ public String gssapiFailure;
|
/***/ public String gssapiFailure;
|
||||||
/***/ public String gssapiInitFailure;
|
/***/ public String gssapiInitFailure;
|
||||||
|
@ -58,12 +61,14 @@ public static SshdText get() {
|
||||||
/***/ public String knownHostsUnknownKeyType;
|
/***/ public String knownHostsUnknownKeyType;
|
||||||
/***/ public String knownHostsUserAskCreationMsg;
|
/***/ public String knownHostsUserAskCreationMsg;
|
||||||
/***/ public String knownHostsUserAskCreationPrompt;
|
/***/ public String knownHostsUserAskCreationPrompt;
|
||||||
|
/***/ public String loginDenied;
|
||||||
/***/ public String passwordPrompt;
|
/***/ public String passwordPrompt;
|
||||||
/***/ public String proxyCannotAuthenticate;
|
/***/ public String proxyCannotAuthenticate;
|
||||||
/***/ public String proxyHttpFailure;
|
/***/ public String proxyHttpFailure;
|
||||||
/***/ public String proxyHttpInvalidUserName;
|
/***/ public String proxyHttpInvalidUserName;
|
||||||
/***/ public String proxyHttpUnexpectedReply;
|
/***/ public String proxyHttpUnexpectedReply;
|
||||||
/***/ public String proxyHttpUnspecifiedFailureReason;
|
/***/ public String proxyHttpUnspecifiedFailureReason;
|
||||||
|
/***/ public String proxyJumpAbort;
|
||||||
/***/ public String proxyPasswordPrompt;
|
/***/ public String proxyPasswordPrompt;
|
||||||
/***/ public String proxySocksAuthenticationFailed;
|
/***/ public String proxySocksAuthenticationFailed;
|
||||||
/***/ public String proxySocksFailureForbidden;
|
/***/ public String proxySocksFailureForbidden;
|
||||||
|
@ -92,6 +97,7 @@ public static SshdText get() {
|
||||||
/***/ public String sshClosingDown;
|
/***/ public String sshClosingDown;
|
||||||
/***/ public String sshCommandTimeout;
|
/***/ public String sshCommandTimeout;
|
||||||
/***/ public String sshProcessStillRunning;
|
/***/ public String sshProcessStillRunning;
|
||||||
|
/***/ public String sshProxySessionCloseFailed;
|
||||||
/***/ public String unknownProxyProtocol;
|
/***/ public String unknownProxyProtocol;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
* This program and the accompanying materials are made available under the
|
||||||
* terms of the Eclipse Distribution License v. 1.0 which is available at
|
* terms of the Eclipse Distribution License v. 1.0 which is available at
|
||||||
|
@ -10,36 +10,53 @@
|
||||||
package org.eclipse.jgit.transport.sshd;
|
package org.eclipse.jgit.transport.sshd;
|
||||||
|
|
||||||
import static java.text.MessageFormat.format;
|
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.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
import org.apache.sshd.client.SshClient;
|
import org.apache.sshd.client.SshClient;
|
||||||
import org.apache.sshd.client.channel.ChannelExec;
|
import org.apache.sshd.client.channel.ChannelExec;
|
||||||
import org.apache.sshd.client.channel.ClientChannelEvent;
|
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.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;
|
||||||
import org.apache.sshd.client.subsystem.sftp.SftpClient.CloseableHandle;
|
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.SftpClient.CopyMode;
|
||||||
import org.apache.sshd.client.subsystem.sftp.SftpClientFactory;
|
import org.apache.sshd.client.subsystem.sftp.SftpClientFactory;
|
||||||
import org.apache.sshd.common.session.Session;
|
import org.apache.sshd.common.AttributeRepository;
|
||||||
import org.apache.sshd.common.session.SessionListener;
|
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.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.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.internal.transport.sshd.SshdText;
|
||||||
import org.eclipse.jgit.transport.FtpChannel;
|
import org.eclipse.jgit.transport.FtpChannel;
|
||||||
import org.eclipse.jgit.transport.RemoteSession;
|
import org.eclipse.jgit.transport.RemoteSession;
|
||||||
|
import org.eclipse.jgit.transport.SshConstants;
|
||||||
import org.eclipse.jgit.transport.URIish;
|
import org.eclipse.jgit.transport.URIish;
|
||||||
|
import org.eclipse.jgit.util.StringUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@ -53,6 +70,11 @@ public class SshdSession implements RemoteSession {
|
||||||
private static final Logger LOG = LoggerFactory
|
private static final Logger LOG = LoggerFactory
|
||||||
.getLogger(SshdSession.class);
|
.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 CopyOnWriteArrayList<SessionCloseListener> listeners = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
private final URIish uri;
|
private final URIish uri;
|
||||||
|
@ -71,32 +93,169 @@ void connect(Duration timeout) throws IOException {
|
||||||
client.start();
|
client.start();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
String username = uri.getUser();
|
session = connect(uri, Collections.emptyList(),
|
||||||
String host = uri.getHost();
|
future -> notifyCloseListeners(), timeout, MAX_DEPTH);
|
||||||
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());
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
disconnect(e);
|
disconnect(e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
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<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
|
* Adds a {@link SessionCloseListener} to this session. Has no effect if the
|
||||||
* given {@code listener} is already registered with this session.
|
* given {@code listener} is already registered with this session.
|
||||||
|
|
|
@ -230,6 +230,9 @@ public SshdSession getSession(URIish uri,
|
||||||
return session;
|
return session;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
unregister(session);
|
unregister(session);
|
||||||
|
if (e instanceof TransportException) {
|
||||||
|
throw (TransportException) e;
|
||||||
|
}
|
||||||
throw new TransportException(uri, e.getMessage(), e);
|
throw new TransportException(uri, e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
* This program and the accompanying materials are made available under the
|
||||||
* terms of the Eclipse Distribution License v. 1.0 which is available at
|
* 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. */
|
/** Key in an ssh config file. */
|
||||||
public static final String PROXY_COMMAND = "ProxyCommand";
|
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. */
|
/** Key in an ssh config file. */
|
||||||
public static final String REMOTE_COMMAND = "RemoteCommand";
|
public static final String REMOTE_COMMAND = "RemoteCommand";
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue