From 566e49d7d39b12c785be24b8b61b4960a4b7ea17 Mon Sep 17 00:00:00 2001 From: Thomas Wolf Date: Sun, 26 Jul 2020 20:37:57 +0200 Subject: 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 --- .../META-INF/MANIFEST.MF | 2 + .../eclipse/jgit/transport/sshd/ApacheSshTest.java | 398 ++++++++++++++++++++- 2 files changed, 399 insertions(+), 1 deletion(-) (limited to 'org.eclipse.jgit.ssh.apache.test') diff --git a/org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF b/org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF index 47f00695de..60f7d41a62 100644 --- a/org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF +++ b/org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF @@ -11,11 +11,13 @@ Import-Package: org.apache.sshd.client.config.hosts;version="[2.4.0,2.5.0)", org.apache.sshd.common;version="[2.4.0,2.5.0)", org.apache.sshd.common.auth;version="[2.4.0,2.5.0)", org.apache.sshd.common.config.keys;version="[2.4.0,2.5.0)", + org.apache.sshd.common.helpers;version="[2.4.0,2.5.0)", org.apache.sshd.common.keyprovider;version="[2.4.0,2.5.0)", org.apache.sshd.common.session;version="[2.4.0,2.5.0)", org.apache.sshd.common.util.net;version="[2.4.0,2.5.0)", org.apache.sshd.common.util.security;version="[2.4.0,2.5.0)", org.apache.sshd.server;version="[2.4.0,2.5.0)", + org.apache.sshd.server.forward;version="[2.4.0,2.5.0)", org.eclipse.jgit.api;version="[5.10.0,5.11.0)", org.eclipse.jgit.api.errors;version="[5.10.0,5.11.0)", org.eclipse.jgit.internal.transport.sshd.proxy;version="[5.10.0,5.11.0)", diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java index 651ae7dec1..3427da667d 100644 --- a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java +++ b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, Thomas Wolf and others + * Copyright (C) 2018, 2020 Thomas Wolf and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at @@ -11,19 +11,39 @@ 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(); + } + } + } } -- cgit v1.2.3