This is an automated email from the ASF dual-hosted git repository. gnodet pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/mina-sshd.git
The following commit(s) were added to refs/heads/master by this push: new 204c0bc [SSHD-1047] Support for SSH jumps (#154) 204c0bc is described below commit 204c0bc48de5a32c43a3b201f8c739bb38f161f8 Author: Guillaume Nodet <gno...@gmail.com> AuthorDate: Fri Jul 31 14:55:28 2020 +0200 [SSHD-1047] Support for SSH jumps (#154) --- CHANGES.md | 9 +- docs/proxies.md | 22 ++ .../org/apache/sshd/cli/client/ScpCommandMain.java | 2 +- .../apache/sshd/cli/client/SftpCommandMain.java | 2 +- .../sshd/cli/client/SshClientCliSupport.java | 33 ++- .../org/apache/sshd/cli/client/SshClientMain.java | 2 +- .../apache/sshd/cli/client/ChannelExecMain.java | 2 +- .../config/hosts/ConfigFileHostEntryResolver.java | 22 +- .../hosts/DefaultConfigFileHostEntryResolver.java | 11 +- .../sshd/client/config/hosts/HostConfigEntry.java | 58 +++++- .../config/hosts/HostConfigEntryResolver.java | 6 +- .../hosts/ConfigFileHostEntryResolverTest.java | 8 +- .../java/org/apache/sshd/client/SshClient.java | 226 +++++++++++++------- .../sshd/client/session/AbstractClientSession.java | 4 + .../sshd/client/session/ClientSessionCreator.java | 14 ++ .../simple/AbstractSimpleClientSessionCreator.java | 15 ++ .../sshd/client/simple/SimpleSessionClient.java | 20 ++ .../java/org/apache/sshd/client/ProxyTest.java | 228 ++++++++++++++++++--- .../config/hosts/HostConfigEntryResolverTest.java | 4 +- 19 files changed, 550 insertions(+), 138 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 6477914..fc54a85 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,16 +13,17 @@ ## Major code re-factoring * [SSHD-506](https://issues.apache.org/jira/browse/SSHD-506) Added support for AES-GCM ciphers. +* [SSHD-1004](https://issues.apache.org/jira/browse/SSHD-1004) Deprecate DES, RC4 and Blowfish ciphers from default setup. * [SSHD-1034](https://issues.apache.org/jira/browse/SSHD-1034) Rename `org.apache.sshd.common.ForwardingFilter` to `Forwarder`. * [SSHD-1035](https://issues.apache.org/jira/browse/SSHD-1035) Move property definitions to common locations. -* [SSHD-1038](https://issues.apache.org/jira/browse/SSHD-1035) Refactor packages from a module into a cleaner hierarchy. -* [SSHD-1004](https://issues.apache.org/jira/browse/SSHD-1035) Deprecate DES, RC4 and Blowfish ciphers from default setup. +* [SSHD-1038](https://issues.apache.org/jira/browse/SSHD-1038) Refactor packages from a module into a cleaner hierarchy. +* [SSHD-1047](https://issues.apache.org/jira/browse/SSHD-1047) Support for SSH jumps. ## Minor code helpers -* [SSHD-1040](https://issues.apache.org/jira/browse/SSHD-1040) Make server key available after KEX completed. * [SSHD-1030](https://issues.apache.org/jira/browse/SSHD-1030) Added a NoneFileSystemFactory implementation -* [SSHD-1042](https://issues.apache.org/jira/browse/SSHD-1030) Added more callbacks to SftpEventListener +* [SSHD-1042](https://issues.apache.org/jira/browse/SSHD-1042) Added more callbacks to SftpEventListener +* [SSHD-1040](https://issues.apache.org/jira/browse/SSHD-1040) Make server key available after KEX completed. ## Behavioral changes and enhancements diff --git a/docs/proxies.md b/docs/proxies.md new file mode 100644 index 0000000..4047179 --- /dev/null +++ b/docs/proxies.md @@ -0,0 +1,22 @@ +# Proxies + +## SSH Jumps + +The SSH client can be configured to use SSH jumps. A *jump host* (also known as a *jump server*) is an +intermediary host or an SSH gateway to a remote network, through which a connection can be made to another +host in a dissimilar security zone, for example a demilitarized zone (DMZ). It bridges two dissimilar +security zones and offers controlled access between them. + +Starting from SSHD 2.6.0, the *ProxyJump* host configuration entry is honored when using the `SshClient` +to connect to a host. The `SshClient` built by default reads the `~/.ssh/config` file. The various CLI clients +also honor the `-J` command line option to specify one or more jumps. + +In order to manually configure jumps, you need to build a `HostConfigEntry` with a `proxyJump` and use it +to connect to the server: +``` +ConnectFuture future = client.connect(new HostConfigEntry( + "", host, port, user, + proxyUser + "@" + proxyHost + ":" + proxyPort)); +``` + +The configuration options specified in the configuration file for the jump hosts are also honored. diff --git a/sshd-cli/src/main/java/org/apache/sshd/cli/client/ScpCommandMain.java b/sshd-cli/src/main/java/org/apache/sshd/cli/client/ScpCommandMain.java index a0a842b..a23eff5 100644 --- a/sshd-cli/src/main/java/org/apache/sshd/cli/client/ScpCommandMain.java +++ b/sshd-cli/src/main/java/org/apache/sshd/cli/client/ScpCommandMain.java @@ -184,7 +184,7 @@ public class ScpCommandMain extends SshClientCliSupport { if (session == null) { stderr.println("usage: scp [" + SCP_PORT_OPTION + " port] [-i identity] [-io nio2|mina|netty]" + " [-v[v][v]] [-E logoutput] [-r] [-p] [-q] [-o option=value] [-o creator=class name]" - + " [-c cipherlist] [-m maclist] [-w password] [-C] <source> <target>"); + + " [-c cipherlist] [-m maclist] [-J proxyJump] [-w password] [-C] <source> <target>"); stderr.println(); stderr.println("Where <source> or <target> are either 'user@host:file' or a local file path"); stderr.println("NOTE: exactly ONE of the source or target must be remote and the other one local"); diff --git a/sshd-cli/src/main/java/org/apache/sshd/cli/client/SftpCommandMain.java b/sshd-cli/src/main/java/org/apache/sshd/cli/client/SftpCommandMain.java index a1501b9..399208f 100644 --- a/sshd-cli/src/main/java/org/apache/sshd/cli/client/SftpCommandMain.java +++ b/sshd-cli/src/main/java/org/apache/sshd/cli/client/SftpCommandMain.java @@ -329,7 +329,7 @@ public class SftpCommandMain extends SshClientCliSupport implements Channel { : setupClientSession(SFTP_PORT_OPTION, stdin, level, stdout, stderr, args); if (session == null) { System.err.println("usage: sftp [-v[v][v]] [-E logoutput] [-i identity] [-io nio2|mina|netty]" - + " [-l login] [" + SFTP_PORT_OPTION + " port] [-o option=value]" + + " [-J proxyJump] [-l login] [" + SFTP_PORT_OPTION + " port] [-o option=value]" + " [-w password] [-c cipherlist] [-m maclist] [-C] hostname/user@host"); System.exit(-1); return; diff --git a/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientCliSupport.java b/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientCliSupport.java index 2eff66d..fca0403 100644 --- a/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientCliSupport.java +++ b/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientCliSupport.java @@ -51,6 +51,7 @@ import org.apache.sshd.client.SshClient; import org.apache.sshd.client.auth.keyboard.UserInteraction; import org.apache.sshd.client.config.SshClientConfigFileReader; import org.apache.sshd.client.config.hosts.HostConfigEntry; +import org.apache.sshd.client.config.hosts.HostConfigEntryResolver; import org.apache.sshd.client.config.keys.ClientIdentity; import org.apache.sshd.client.keyverifier.DefaultKnownHostsServerKeyVerifier; import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier; @@ -79,6 +80,7 @@ import org.apache.sshd.common.util.GenericUtils; import org.apache.sshd.common.util.OsUtils; import org.apache.sshd.common.util.ValidateUtils; import org.apache.sshd.common.util.io.NoCloseOutputStream; +import org.apache.sshd.common.util.net.SshdSocketAddress; import org.apache.sshd.common.util.threads.ThreadUtils; import org.apache.sshd.core.CoreModuleProperties; @@ -106,10 +108,12 @@ public abstract class SshClientCliSupport extends CliSupport { || "-w".equals(argName) || "-c".equals(argName) || "-m".equals(argName) - || "-E".equals(argName); + || "-E".equals(argName) + || "-J".equals(argName); } // NOTE: ClientSession#getFactoryManager is the SshClient + // CHECKSTYLE:OFF public static ClientSession setupClientSession( String portOption, BufferedReader stdin, Level level, PrintStream stdout, PrintStream stderr, String... args) @@ -117,6 +121,7 @@ public abstract class SshClientCliSupport extends CliSupport { int port = -1; String host = null; String login = null; + String proxyJump = null; String password = null; boolean error = false; List<Path> identities = new ArrayList<>(); @@ -149,6 +154,12 @@ public abstract class SshClientCliSupport extends CliSupport { error = showError(stderr, "Bad option value for " + argName + ": " + port); break; } + } else if ("-J".equals(argName)) { + if (proxyJump != null) { + error = showError(stderr, argName + " option value re-specified: " + proxyJump); + break; + } + proxyJump = argVal; } else if ("-w".equals(argName)) { if (GenericUtils.length(password) > 0) { error = showError(stderr, argName + " option value re-specified: " + password); @@ -245,8 +256,9 @@ public abstract class SshClientCliSupport extends CliSupport { port = SshConstants.DEFAULT_PORT; } + HostConfigEntry entry = resolveHost(client, login, host, port, proxyJump); // TODO use a configurable wait time - ClientSession session = client.connect(login, host, port) + ClientSession session = client.connect(entry, null, null) .verify() .getSession(); try { @@ -264,6 +276,23 @@ public abstract class SshClientCliSupport extends CliSupport { throw e; } } + // CHECKSTYLE:ON + + public static HostConfigEntry resolveHost(SshClient client, String username, String host, int port, String proxyJump) + throws IOException { + HostConfigEntryResolver resolver = client.getHostConfigEntryResolver(); + HostConfigEntry entry = resolver.resolveEffectiveHost(host, port, null, username, proxyJump, null); + if (entry == null) { + // IPv6 addresses have a format which means they need special treatment, separate from pattern validation + if (SshdSocketAddress.isIPv6Address(host)) { + // Not using a pattern as the host name passed in was a valid IPv6 address + entry = new HostConfigEntry("", host, port, username, null); + } else { + entry = new HostConfigEntry(host, host, port, username, proxyJump); + } + } + return entry; + } public static Path resolveIdentityFile(String id) throws IOException { BuiltinIdentities identity = BuiltinIdentities.fromName(id); diff --git a/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientMain.java b/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientMain.java index 75d28e2..7c1265e 100644 --- a/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientMain.java +++ b/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientMain.java @@ -135,7 +135,7 @@ public class SshClientMain extends SshClientCliSupport { if (error) { System.err.println("usage: ssh [-A|-a] [-v[v][v]] [-E logoutputfile] [-D socksPort]" - + " [-l login] [" + SSH_CLIENT_PORT_OPTION + " port] [-o option=value]" + + " [-J proxyJump] [-l login] [" + SSH_CLIENT_PORT_OPTION + " port] [-o option=value]" + " [-w password] [-c cipherslist] [-m maclist] [-C]" + " hostname/user@host [command]"); System.exit(-1); diff --git a/sshd-cli/src/test/java/org/apache/sshd/cli/client/ChannelExecMain.java b/sshd-cli/src/test/java/org/apache/sshd/cli/client/ChannelExecMain.java index 93da3ba..45424d4 100644 --- a/sshd-cli/src/test/java/org/apache/sshd/cli/client/ChannelExecMain.java +++ b/sshd-cli/src/test/java/org/apache/sshd/cli/client/ChannelExecMain.java @@ -82,7 +82,7 @@ public class ChannelExecMain extends BaseTestSupport { CliSupport.resolveLoggingVerbosity(args), stdout, stderr, args); if (session == null) { System.err.println("usage: channelExec [-i identity] [-l login] [-P port] [-o option=value]" - + " [-w password] [-c cipherlist] [-m maclist] [-C] hostname/user@host"); + + " [-J proxyJump] [-w password] [-c cipherlist] [-m maclist] [-C] hostname/user@host"); System.exit(-1); return; } diff --git a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/ConfigFileHostEntryResolver.java b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/ConfigFileHostEntryResolver.java index 1159e50..0ee867c 100644 --- a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/ConfigFileHostEntryResolver.java +++ b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/ConfigFileHostEntryResolver.java @@ -52,21 +52,21 @@ public class ConfigFileHostEntryResolver extends ModifiableFileWatcher implement @Override public HostConfigEntry resolveEffectiveHost( - String host, int port, SocketAddress localAddress, String username, AttributeRepository context) + String host, int port, SocketAddress localAddress, String username, String proxyJump, AttributeRepository context) throws IOException { try { HostConfigEntryResolver delegate - = Objects.requireNonNull(resolveEffectiveResolver(host, port, username), "No delegate"); - HostConfigEntry entry = delegate.resolveEffectiveHost(host, port, localAddress, username, context); + = Objects.requireNonNull(resolveEffectiveResolver(host, port, username, proxyJump), "No delegate"); + HostConfigEntry entry = delegate.resolveEffectiveHost(host, port, localAddress, username, proxyJump, context); if (log.isDebugEnabled()) { - log.debug("resolveEffectiveHost({}@{}:{}) => {}", username, host, port, entry); + log.debug("resolveEffectiveHost({}@{}:{}/{}) => {}", username, host, port, proxyJump, entry); } return entry; } catch (Throwable e) { if (log.isDebugEnabled()) { - log.debug("resolveEffectiveHost({}@{}:{}) failed ({}) to resolve: {}", - username, host, port, e.getClass().getSimpleName(), e.getMessage()); + log.debug("resolveEffectiveHost({}@{}:{}/{}) failed ({}) to resolve: {}", + username, host, port, proxyJump, e.getClass().getSimpleName(), e.getMessage()); } if (log.isTraceEnabled()) { @@ -80,18 +80,20 @@ public class ConfigFileHostEntryResolver extends ModifiableFileWatcher implement } } - protected HostConfigEntryResolver resolveEffectiveResolver(String host, int port, String username) throws IOException { + protected HostConfigEntryResolver resolveEffectiveResolver(String host, int port, String username, String proxyJump) + throws IOException { if (checkReloadRequired()) { delegateHolder.set(HostConfigEntryResolver.EMPTY); // start fresh Path path = getPath(); if (exists()) { - Collection<HostConfigEntry> entries = reloadHostConfigEntries(path, host, port, username); + Collection<HostConfigEntry> entries = reloadHostConfigEntries(path, host, port, username, proxyJump); if (GenericUtils.size(entries) > 0) { delegateHolder.set(HostConfigEntry.toHostConfigEntryResolver(entries)); } } else { - log.info("resolveEffectiveResolver({}@{}:{}) no configuration file at {}", username, host, port, path); + log.info("resolveEffectiveResolver({}@{}:{}/{}) no configuration file at {}", username, host, port, proxyJump, + path); } } @@ -99,7 +101,7 @@ public class ConfigFileHostEntryResolver extends ModifiableFileWatcher implement } protected List<HostConfigEntry> reloadHostConfigEntries( - Path path, String host, int port, String username) + Path path, String host, int port, String username, String proxyJump) throws IOException { List<HostConfigEntry> entries = HostConfigEntry.readHostConfigEntries(path); log.info("resolveEffectiveResolver({}@{}:{}) loaded {} entries from {}", username, host, port, diff --git a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/DefaultConfigFileHostEntryResolver.java b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/DefaultConfigFileHostEntryResolver.java index 6b44026..010d28a 100644 --- a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/DefaultConfigFileHostEntryResolver.java +++ b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/DefaultConfigFileHostEntryResolver.java @@ -64,22 +64,23 @@ public class DefaultConfigFileHostEntryResolver extends ConfigFileHostEntryResol } @Override - protected List<HostConfigEntry> reloadHostConfigEntries(Path path, String host, int port, String username) + protected List<HostConfigEntry> reloadHostConfigEntries(Path path, String host, int port, String username, String proxyJump) throws IOException { if (isStrict()) { if (log.isDebugEnabled()) { - log.debug("reloadHostConfigEntries({}@{}:{}) check permissions of {}", username, host, port, path); + log.debug("reloadHostConfigEntries({}@{}:{}/{}) check permissions of {}", username, host, port, proxyJump, + path); } Map.Entry<String, ?> violation = validateStrictConfigFilePermissions(path); if (violation != null) { - log.warn("reloadHostConfigEntries({}@{}:{}) invalid file={} permissions: {}", - username, host, port, path, violation.getKey()); + log.warn("reloadHostConfigEntries({}@{}:{}/{}) invalid file={} permissions: {}", + username, host, port, proxyJump, path, violation.getKey()); updateReloadAttributes(); return Collections.emptyList(); } } - return super.reloadHostConfigEntries(path, host, port, username); + return super.reloadHostConfigEntries(path, host, port, username, proxyJump); } } diff --git a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntry.java b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntry.java index 6a5bb70..26f839b 100644 --- a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntry.java +++ b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntry.java @@ -77,6 +77,7 @@ public class HostConfigEntry extends HostPatternsHolder implements MutableUserHo public static final String HOST_NAME_CONFIG_PROP = "HostName"; public static final String PORT_CONFIG_PROP = ConfigFileReaderSupport.PORT_CONFIG_PROP; public static final String USER_CONFIG_PROP = "User"; + public static final String PROXY_JUMP_CONFIG_PROP = "ProxyJump"; public static final String IDENTITY_FILE_CONFIG_PROP = "IdentityFile"; /** * Use only the identities specified in the host entry (if any) @@ -116,6 +117,7 @@ public class HostConfigEntry extends HostPatternsHolder implements MutableUserHo private String hostName; private int port; private String username; + private String proxyJump; private Boolean exclusiveIdentites; private Collection<String> identities = Collections.emptyList(); private Map<String, String> properties = Collections.emptyMap(); @@ -125,10 +127,15 @@ public class HostConfigEntry extends HostPatternsHolder implements MutableUserHo } public HostConfigEntry(String pattern, String host, int port, String username) { + this(pattern, host, port, username, null); + } + + public HostConfigEntry(String pattern, String host, int port, String username, String proxyJump) { setHost(pattern); setHostName(host); setPort(port); setUsername(username); + setProxyJump(proxyJump); } /** @@ -211,6 +218,29 @@ public class HostConfigEntry extends HostPatternsHolder implements MutableUserHo } /** + * @return the host to use as a proxy + */ + public String getProxyJump() { + return proxyJump; + } + + public void setProxyJump(String proxyJump) { + this.proxyJump = proxyJump; + } + + /** + * Resolves the effective proxyJump + * + * @param originalProxyJump The original requested proxyJump + * @return If the configured host entry proxyJump is not {@code null}/empty then it is used, + * otherwise the original one. + * @see #resolveUsername(String) + */ + public String resolveProxyJump(String originalProxyJump) { + return resolveProxyJump(originalProxyJump, getProxyJump()); + } + + /** * @return The current identities file paths - may be {@code null}/empty */ public Collection<String> getIdentities() { @@ -472,6 +502,11 @@ public class HostConfigEntry extends HostPatternsHolder implements MutableUserHo setIdentitiesOnly( ConfigFileReaderSupport.parseBooleanValue( ValidateUtils.checkNotNullAndNotEmpty(joinedValue, "No identities option value"))); + } else if (PROXY_JUMP_CONFIG_PROP.equalsIgnoreCase(key)) { + String curValue = getProxyJump(); + ValidateUtils.checkTrue(GenericUtils.isEmpty(curValue) || ignoreAlreadyInitialized, "Already initialized %s: %s", + key, curValue); + setProxyJump(joinedValue); } } @@ -659,7 +694,7 @@ public class HostConfigEntry extends HostPatternsHolder implements MutableUserHo if (GenericUtils.isEmpty(entries)) { return HostConfigEntryResolver.EMPTY; } else { - return (host1, port1, lclAddress, username1, ctx) -> { + return (host1, port1, lclAddress, username1, proxyJump1, ctx) -> { List<HostConfigEntry> matches = findMatchingEntries(host1, entries); int numMatches = GenericUtils.size(matches); if (numMatches <= 0) { @@ -672,7 +707,7 @@ public class HostConfigEntry extends HostPatternsHolder implements MutableUserHo host1, port1, numMatches); } - return normalizeEntry(match, host1, port1, username1); + return normalizeEntry(match, host1, port1, username1, proxyJump1); }; } } @@ -691,7 +726,7 @@ public class HostConfigEntry extends HostPatternsHolder implements MutableUserHo * @see #resolveIdentityFilePath(String, String, int, String) */ public static HostConfigEntry normalizeEntry( - HostConfigEntry entry, String host, int port, String username) + HostConfigEntry entry, String host, int port, String username, String proxyJump) throws IOException { if (entry == null) { return null; @@ -702,6 +737,7 @@ public class HostConfigEntry extends HostPatternsHolder implements MutableUserHo normal.setHostName(entry.resolveHostName(host)); normal.setPort(entry.resolvePort(port)); normal.setUsername(entry.resolveUsername(username)); + normal.setProxyJump(entry.resolveProxyJump(proxyJump)); Map<String, String> props = entry.getProperties(); if (GenericUtils.size(props) > 0) { @@ -772,6 +808,22 @@ public class HostConfigEntry extends HostPatternsHolder implements MutableUserHo } } + /** + * Resolves the effective proxyJump + * + * @param originalProxyJump The original requested proxyJump + * @param entryProxyJump The configured host entry proxyJump + * @return If the configured host entry proxyJump is not {@code null}/empty then it is used, + * otherwise the original one. + */ + public static String resolveProxyJump(String originalProxyJump, String entryProxyJump) { + if (GenericUtils.isEmpty(entryProxyJump)) { + return originalProxyJump; + } else { + return entryProxyJump; + } + } + public static List<HostConfigEntry> readHostConfigEntries(Path path, OpenOption... options) throws IOException { try (InputStream input = Files.newInputStream(path, options)) { return readHostConfigEntries(input, true); diff --git a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntryResolver.java b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntryResolver.java index d27c053..cc0766a 100644 --- a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntryResolver.java +++ b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntryResolver.java @@ -36,7 +36,8 @@ public interface HostConfigEntryResolver { HostConfigEntryResolver EMPTY = new HostConfigEntryResolver() { @Override public HostConfigEntry resolveEffectiveHost( - String host, int port, SocketAddress localAddress, String username, AttributeRepository context) + String host, int port, SocketAddress localAddress, String username, String proxyJump, + AttributeRepository context) throws IOException { return null; } @@ -54,6 +55,7 @@ public interface HostConfigEntryResolver { * @param port The requested port * @param localAddress Optional binding endpoint for the local peer * @param username The requested username + * @param proxyJump The requested proxyJump * @param context An optional "context" provided during the connection request (to be attached to * the established session if successfully connected) * @return A {@link HostConfigEntry} for the actual target - {@code null} if use original parameters. @@ -63,6 +65,6 @@ public interface HostConfigEntryResolver { * @throws IOException If failed to resolve the configuration */ HostConfigEntry resolveEffectiveHost( - String host, int port, SocketAddress localAddress, String username, AttributeRepository context) + String host, int port, SocketAddress localAddress, String username, String proxyJump, AttributeRepository context) throws IOException; } diff --git a/sshd-common/src/test/java/org/apache/sshd/client/config/hosts/ConfigFileHostEntryResolverTest.java b/sshd-common/src/test/java/org/apache/sshd/client/config/hosts/ConfigFileHostEntryResolverTest.java index 4a9a070..54085ca 100644 --- a/sshd-common/src/test/java/org/apache/sshd/client/config/hosts/ConfigFileHostEntryResolverTest.java +++ b/sshd-common/src/test/java/org/apache/sshd/client/config/hosts/ConfigFileHostEntryResolverTest.java @@ -53,10 +53,11 @@ public class ConfigFileHostEntryResolverTest extends JUnitTestSupport { ConfigFileHostEntryResolver resolver = new ConfigFileHostEntryResolver( assertHierarchyTargetFolderExists(dir).resolve(getCurrentTestName() + ".config.txt")) { @Override - protected List<HostConfigEntry> reloadHostConfigEntries(Path path, String host, int port, String username) + protected List<HostConfigEntry> reloadHostConfigEntries( + Path path, String host, int port, String username, String proxyJump) throws IOException { reloadCount.incrementAndGet(); - return super.reloadHostConfigEntries(path, host, port, username); + return super.reloadHostConfigEntries(path, host, port, username, proxyJump); } }; Path path = resolver.getPath(); @@ -116,7 +117,8 @@ public class ConfigFileHostEntryResolverTest extends JUnitTestSupport { for (int index = 1; index < Byte.SIZE; index++) { HostConfigEntry actual - = resolver.resolveEffectiveHost(query.getHostName(), query.getPort(), null, query.getUsername(), null); + = resolver.resolveEffectiveHost(query.getHostName(), query.getPort(), null, query.getUsername(), null, + null); if (entries == null) { assertEquals(phase + "[" + index + "]: mismatched reload count", 0, reloadCount.get()); diff --git a/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java b/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java index 8633268..049edca 100644 --- a/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java +++ b/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java @@ -22,6 +22,8 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.SocketTimeoutException; +import java.net.URI; +import java.nio.channels.UnsupportedAddressTypeException; import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; @@ -52,6 +54,7 @@ import org.apache.sshd.client.config.hosts.HostConfigEntryResolver; import org.apache.sshd.client.config.keys.ClientIdentity; import org.apache.sshd.client.config.keys.ClientIdentityLoader; import org.apache.sshd.client.config.keys.DefaultClientIdentitiesWatcher; +import org.apache.sshd.client.future.AuthFuture; import org.apache.sshd.client.future.ConnectFuture; import org.apache.sshd.client.future.DefaultConnectFuture; import org.apache.sshd.client.keyverifier.ServerKeyVerifier; @@ -62,6 +65,7 @@ import org.apache.sshd.client.session.ClientSession; import org.apache.sshd.client.session.ClientSessionCreator; import org.apache.sshd.client.session.ClientUserAuthServiceFactory; import org.apache.sshd.client.session.SessionFactory; +import org.apache.sshd.client.session.forward.ExplicitPortForwardingTracker; import org.apache.sshd.client.simple.AbstractSimpleClientSessionCreator; import org.apache.sshd.client.simple.SimpleClient; import org.apache.sshd.common.AttributeRepository; @@ -466,100 +470,120 @@ public class SshClient extends AbstractFactoryManager implements ClientFactoryMa } @Override - public ConnectFuture connect( - String username, String host, int port, AttributeRepository context, SocketAddress localAddress) - throws IOException { - HostConfigEntryResolver resolver = getHostConfigEntryResolver(); - HostConfigEntry entry = resolver.resolveEffectiveHost(host, port, localAddress, username, context); - if (entry == null) { - // generate a synthetic entry - if (log.isDebugEnabled()) { - log.debug("connect({}@{}:{}) no overrides", username, host, port); - } - - // IPv6 addresses have a format which means they need special treatment, separate from pattern validation - if (SshdSocketAddress.isIPv6Address(host)) { - // Not using a pattern as the host name passed in was a valid IPv6 address - entry = new HostConfigEntry("", host, port, username); - } else { - entry = new HostConfigEntry(host, host, port, username); - } - } else { - if (log.isDebugEnabled()) { - log.debug("connect({}@{}:{}) effective: {}", username, host, port, entry); - } + public ConnectFuture connect(String uriStr) throws IOException { + Objects.requireNonNull(uriStr, "No uri address"); + URI uri = URI.create(uriStr.contains("//") ? uriStr : "ssh://" + uriStr); + if (GenericUtils.isNotEmpty(uri.getScheme()) && !"ssh".equals(uri.getScheme())) { + throw new IllegalArgumentException("Unsupported scheme for uri: " + uri); } - - return connect(entry, context, localAddress); + String host = uri.getHost(); + int port = uri.getPort(); + String userInfo = uri.getUserInfo(); + return connect(userInfo, host, port); } @Override public ConnectFuture connect( - String username, SocketAddress targetAddress, AttributeRepository context, SocketAddress localAddress) + String username, SocketAddress targetAddress, + AttributeRepository context, SocketAddress localAddress) throws IOException { Objects.requireNonNull(targetAddress, "No target address"); - if (targetAddress instanceof InetSocketAddress) { - InetSocketAddress inetAddress = (InetSocketAddress) targetAddress; - String host = ValidateUtils.checkNotNullAndNotEmpty(inetAddress.getHostString(), "No host"); - int port = inetAddress.getPort(); - ValidateUtils.checkTrue(port > 0, "Invalid port: %d", port); - - HostConfigEntryResolver resolver = getHostConfigEntryResolver(); - HostConfigEntry entry = resolver.resolveEffectiveHost(host, port, localAddress, username, context); - if (entry == null) { - if (log.isDebugEnabled()) { - log.debug("connect({}@{}:{}) no overrides", username, host, port); - } - - return doConnect( - username, targetAddress, context, localAddress, KeyIdentityProvider.EMPTY_KEYS_PROVIDER, true); - } else { - if (log.isDebugEnabled()) { - log.debug("connect({}@{}:{}) effective: {}", username, host, port, entry); - } - - return connect(entry, context, localAddress); - } - } else { - if (log.isDebugEnabled()) { - log.debug("connect({}@{}) not an InetSocketAddress: {}", - username, targetAddress, targetAddress.getClass().getName()); - } - return doConnect( - username, targetAddress, context, localAddress, KeyIdentityProvider.EMPTY_KEYS_PROVIDER, true); + if (!(targetAddress instanceof InetSocketAddress)) { + throw new UnsupportedAddressTypeException(); } + InetSocketAddress inetAddress = (InetSocketAddress) targetAddress; + String host = ValidateUtils.checkNotNullAndNotEmpty(inetAddress.getHostString(), "No host"); + int port = inetAddress.getPort(); + ValidateUtils.checkTrue(port > 0, "Invalid port: %d", port); + return connect(username, host, port, context, localAddress); + } + + @Override + public ConnectFuture connect( + String username, String host, int port, + AttributeRepository context, SocketAddress localAddress) + throws IOException { + HostConfigEntry entry = resolveHost(username, host, port, context, localAddress); + return connect(entry, context, localAddress); } @Override public ConnectFuture connect( HostConfigEntry hostConfig, AttributeRepository context, SocketAddress localAddress) throws IOException { + List<HostConfigEntry> jumps = parseProxyJumps(hostConfig.getProxyJump(), context); + return doConnect(hostConfig, jumps, context, localAddress); + } + + protected ConnectFuture doConnect( + HostConfigEntry hostConfig, List<HostConfigEntry> jumps, + AttributeRepository context, SocketAddress localAddress) + throws IOException { Objects.requireNonNull(hostConfig, "No host configuration"); String host = ValidateUtils.checkNotNullAndNotEmpty(hostConfig.getHostName(), "No target host"); int port = hostConfig.getPort(); ValidateUtils.checkTrue(port > 0, "Invalid port: %d", port); - Collection<String> hostIds = hostConfig.getIdentities(); - Collection<PathResource> idFiles = GenericUtils.isEmpty(hostIds) - ? Collections.emptyList() - : hostIds.stream() - .map(Paths::get) - .map(PathResource::new) - .collect(Collectors.toCollection(() -> new ArrayList<>(hostIds.size()))); + Collection<PathResource> idFiles = GenericUtils.stream(hostIds) + .map(Paths::get) + .map(PathResource::new) + .collect(Collectors.toCollection(() -> new ArrayList<>(hostIds.size()))); KeyIdentityProvider keys = preloadClientIdentities(idFiles); - return doConnect(hostConfig.getUsername(), new InetSocketAddress(host, port), - context, localAddress, keys, !hostConfig.isIdentitiesOnly()); - } - - protected KeyIdentityProvider preloadClientIdentities( - Collection<? extends NamedResource> locations) - throws IOException { - return GenericUtils.isEmpty(locations) - ? KeyIdentityProvider.EMPTY_KEYS_PROVIDER - : ClientIdentityLoader.asKeyIdentityProvider( - Objects.requireNonNull(getClientIdentityLoader(), "No ClientIdentityLoader"), - locations, getFilePasswordProvider(), - CoreModuleProperties.IGNORE_INVALID_IDENTITIES.getRequired(this)); + String username = hostConfig.getUsername(); + InetSocketAddress targetAddress = new InetSocketAddress(hostConfig.getHostName(), hostConfig.getPort()); + if (GenericUtils.isNotEmpty(jumps)) { + ConnectFuture connectFuture = new DefaultConnectFuture(username + "@" + targetAddress, null); + HostConfigEntry jump = jumps.remove(0); + ConnectFuture f1 = doConnect(jump, jumps, context, null); + f1.addListener(f2 -> { + if (f2.isConnected()) { + ClientSession proxySession = f2.getClientSession(); + try { + AuthFuture auth = proxySession.auth(); + auth.addListener(f3 -> { + if (f3.isSuccess()) { + try { + SshdSocketAddress address + = new SshdSocketAddress(hostConfig.getHostName(), hostConfig.getPort()); + ExplicitPortForwardingTracker tracker = proxySession + .createLocalPortForwardingTracker(SshdSocketAddress.LOCALHOST_ADDRESS, address); + SshdSocketAddress bound = tracker.getBoundAddress(); + ConnectFuture f4 = doConnect(hostConfig.getUsername(), bound.toInetSocketAddress(), + context, localAddress, keys, !hostConfig.isIdentitiesOnly()); + f4.addListener(f5 -> { + if (f5.isConnected()) { + ClientSession clientSession = f5.getClientSession(); + clientSession.setAttribute(TARGET_SERVER, address); + connectFuture.setSession(clientSession); + proxySession.addCloseFutureListener(f6 -> clientSession.close(true)); + clientSession.addCloseFutureListener(f6 -> proxySession.close(true)); + } else { + proxySession.close(true); + connectFuture.setException(f5.getException()); + } + }); + } catch (IOException e) { + proxySession.close(true); + connectFuture.setException(e); + } + } else { + proxySession.close(true); + connectFuture.setException(f3.getException()); + } + }); + } catch (IOException e) { + proxySession.close(true); + connectFuture.setException(e); + } + } else { + connectFuture.setException(f2.getException()); + } + }); + return connectFuture; + } else { + return doConnect(hostConfig.getUsername(), new InetSocketAddress(host, port), + context, localAddress, keys, !hostConfig.isIdentitiesOnly()); + } } protected ConnectFuture doConnect( @@ -580,6 +604,60 @@ public class SshClient extends AbstractFactoryManager implements ClientFactoryMa return connectFuture; } + protected List<HostConfigEntry> parseProxyJumps(String proxyJump, AttributeRepository context) throws IOException { + List<HostConfigEntry> jumps = new ArrayList<>(); + for (String jump : GenericUtils.split(proxyJump, ',')) { + String j = jump.trim(); + URI uri = URI.create(j.contains("//") ? j : "ssh://" + j); + if (GenericUtils.isNotEmpty(uri.getScheme()) && !"ssh".equals(uri.getScheme())) { + throw new IllegalArgumentException("Unsupported scheme for proxy jump: " + jump); + } + String host = uri.getHost(); + int port = uri.getPort(); + String userInfo = uri.getUserInfo(); + HostConfigEntry entry = resolveHost(userInfo, host, port, context, null); + jumps.add(entry); + } + return jumps; + } + + protected HostConfigEntry resolveHost( + String username, String host, int port, AttributeRepository context, SocketAddress localAddress) + throws IOException { + HostConfigEntryResolver resolver = getHostConfigEntryResolver(); + HostConfigEntry entry = resolver.resolveEffectiveHost(host, port, localAddress, username, null, context); + if (entry == null) { + // generate a synthetic entry + if (log.isDebugEnabled()) { + log.debug("connect({}@{}:{}) no overrides", username, host, port); + } + + // IPv6 addresses have a format which means they need special treatment, separate from pattern validation + if (SshdSocketAddress.isIPv6Address(host)) { + // Not using a pattern as the host name passed in was a valid IPv6 address + entry = new HostConfigEntry("", host, port, username, null); + } else { + entry = new HostConfigEntry(host, host, port, username, null); + } + } else { + if (log.isDebugEnabled()) { + log.debug("connect({}@{}:{}) effective: {}", username, host, port, entry); + } + } + return entry; + } + + protected KeyIdentityProvider preloadClientIdentities( + Collection<? extends NamedResource> locations) + throws IOException { + return GenericUtils.isEmpty(locations) + ? KeyIdentityProvider.EMPTY_KEYS_PROVIDER + : ClientIdentityLoader.asKeyIdentityProvider( + Objects.requireNonNull(getClientIdentityLoader(), "No ClientIdentityLoader"), + locations, getFilePasswordProvider(), + CoreModuleProperties.IGNORE_INVALID_IDENTITIES.getRequired(this)); + } + protected SshFutureListener<IoConnectFuture> createConnectCompletionListener( ConnectFuture connectFuture, String username, SocketAddress address, KeyIdentityProvider identities, boolean useDefaultIdentities) { diff --git a/sshd-core/src/main/java/org/apache/sshd/client/session/AbstractClientSession.java b/sshd-core/src/main/java/org/apache/sshd/client/session/AbstractClientSession.java index 5421ee6..805df8b 100644 --- a/sshd-core/src/main/java/org/apache/sshd/client/session/AbstractClientSession.java +++ b/sshd-core/src/main/java/org/apache/sshd/client/session/AbstractClientSession.java @@ -560,6 +560,10 @@ public abstract class AbstractClientSession extends AbstractSession implements C IoSession networkSession = getIoSession(); SocketAddress remoteAddress = networkSession.getRemoteAddress(); PublicKey serverKey = Objects.requireNonNull(getServerKey(), "No server key to verify"); + SshdSocketAddress targetServerAddress = getAttribute(ClientSessionCreator.TARGET_SERVER); + if (targetServerAddress != null) { + remoteAddress = targetServerAddress.toInetSocketAddress(); + } boolean verified = false; if (serverKey instanceof OpenSshCertificate) { diff --git a/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSessionCreator.java b/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSessionCreator.java index 6f13584..d0e3c7e 100644 --- a/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSessionCreator.java +++ b/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSessionCreator.java @@ -24,11 +24,25 @@ import java.net.SocketAddress; import org.apache.sshd.client.config.hosts.HostConfigEntry; import org.apache.sshd.client.future.ConnectFuture; import org.apache.sshd.common.AttributeRepository; +import org.apache.sshd.common.util.net.SshdSocketAddress; /** * @author <a href="mailto:d...@mina.apache.org">Apache MINA SSHD Project</a> */ public interface ClientSessionCreator { + + AttributeRepository.AttributeKey<SshdSocketAddress> TARGET_SERVER = new AttributeRepository.AttributeKey<>(); + + /** + * Resolves the <U>effective</U> {@link HostConfigEntry} and connects to it + * + * @param uri The server uri to connect to + * @return A {@link ConnectFuture} + * @throws IOException If failed to resolve the effective target or connect to it + * @see #connect(HostConfigEntry) + */ + ConnectFuture connect(String uri) throws IOException; + /** * Resolves the <U>effective</U> {@link HostConfigEntry} and connects to it * diff --git a/sshd-core/src/main/java/org/apache/sshd/client/simple/AbstractSimpleClientSessionCreator.java b/sshd-core/src/main/java/org/apache/sshd/client/simple/AbstractSimpleClientSessionCreator.java index a5045aa..e32bf28 100644 --- a/sshd-core/src/main/java/org/apache/sshd/client/simple/AbstractSimpleClientSessionCreator.java +++ b/sshd-core/src/main/java/org/apache/sshd/client/simple/AbstractSimpleClientSessionCreator.java @@ -82,6 +82,16 @@ public abstract class AbstractSimpleClientSessionCreator extends AbstractSimpleC return loginSession(connect(username, target), identity); } + @Override + public ClientSession sessionLogin(String uri, String password) throws IOException { + return loginSession(connect(uri), password); + } + + @Override + public ClientSession sessionLogin(String uri, KeyPair identity) throws IOException { + return loginSession(connect(uri), identity); + } + protected ClientSession loginSession(ConnectFuture future, String password) throws IOException { return authSession(future.verify(getConnectTimeout()), password); } @@ -143,6 +153,11 @@ public abstract class AbstractSimpleClientSessionCreator extends AbstractSimpleC Objects.requireNonNull(channel, "No channel"); return new AbstractSimpleClientSessionCreator() { @Override + public ConnectFuture connect(String uri) throws IOException { + return creator.connect(uri); + } + + @Override public ConnectFuture connect(String username, String host, int port) throws IOException { return creator.connect(username, host, port); } diff --git a/sshd-core/src/main/java/org/apache/sshd/client/simple/SimpleSessionClient.java b/sshd-core/src/main/java/org/apache/sshd/client/simple/SimpleSessionClient.java index a83fbaa..658abe1 100644 --- a/sshd-core/src/main/java/org/apache/sshd/client/simple/SimpleSessionClient.java +++ b/sshd-core/src/main/java/org/apache/sshd/client/simple/SimpleSessionClient.java @@ -167,4 +167,24 @@ public interface SimpleSessionClient extends SimpleClientConfigurator, Channel { * @throws IOException If failed to login or authenticate */ ClientSession sessionLogin(SocketAddress target, String username, KeyPair identity) throws IOException; + + /** + * Creates a session and logs in using the provided credentials + * + * @param uri The target uri + * @param password Password + * @return Created {@link ClientSession} + * @throws IOException If failed to login or authenticate + */ + ClientSession sessionLogin(String uri, String password) throws IOException; + + /** + * Creates a session and logs in using the provided credentials + * + * @param uri The target uri + * @param identity The {@link KeyPair} identity + * @return Created {@link ClientSession} + * @throws IOException If failed to login or authenticate + */ + ClientSession sessionLogin(String uri, KeyPair identity) throws IOException; } diff --git a/sshd-core/src/test/java/org/apache/sshd/client/ProxyTest.java b/sshd-core/src/test/java/org/apache/sshd/client/ProxyTest.java index 1500729..2a8297b 100644 --- a/sshd-core/src/test/java/org/apache/sshd/client/ProxyTest.java +++ b/sshd-core/src/test/java/org/apache/sshd/client/ProxyTest.java @@ -18,21 +18,38 @@ */ package org.apache.sshd.client; +import java.io.BufferedWriter; import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.rmi.RemoteException; +import java.security.KeyPair; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import org.apache.sshd.client.config.hosts.HostConfigEntry; +import org.apache.sshd.client.config.hosts.KnownHostHashValue; +import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier; +import org.apache.sshd.client.keyverifier.RejectAllServerKeyVerifier; import org.apache.sshd.client.session.ClientSession; -import org.apache.sshd.client.session.forward.ExplicitPortForwardingTracker; -import org.apache.sshd.common.util.io.IoUtils; -import org.apache.sshd.common.util.net.SshdSocketAddress; +import org.apache.sshd.common.config.keys.PublicKeyEntry; +import org.apache.sshd.common.util.GenericUtils; import org.apache.sshd.server.SshServer; import org.apache.sshd.server.forward.AcceptAllForwardingFilter; import org.apache.sshd.util.test.BaseTestSupport; import org.apache.sshd.util.test.CommandExecutionHelper; import org.junit.FixMethodOrder; +import org.junit.Ignore; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.junit.runners.MethodSorters; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * @author <a href="mailto:d...@mina.apache.org">Apache MINA SSHD Project</a> @@ -40,6 +57,11 @@ import org.junit.runners.MethodSorters; @FixMethodOrder(MethodSorters.NAME_ASCENDING) public class ProxyTest extends BaseTestSupport { + @Rule + public TemporaryFolder tmpClientDir = new TemporaryFolder(); + + protected final Logger logger = LoggerFactory.getLogger(getClass()); + private ClientSession proxySession; public ProxyTest() { @@ -69,45 +91,193 @@ public class ProxyTest extends BaseTestSupport { // setup client client.start(); - String command = "ls -la"; - String result; + logger.info("Proxy: " + proxy.getPort() + ", server: " + server.getPort()); + + // Connect through to the proxy. + client.addPasswordIdentity("user2"); try (ClientSession session = createSession( client, "localhost", server.getPort(), "user1", "user1", - "localhost", proxy.getPort(), "user2", "user2")) { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ByteArrayOutputStream errors = new ByteArrayOutputStream(); - session.executeRemoteCommand(command, out, errors, StandardCharsets.UTF_8); - result = out.toString(); + "user2@localhost:" + proxy.getPort())) { + assertTrue(session.isOpen()); + doTestCommand(session, "ls -al"); + } + assertTrue(proxySession == null || proxySession.isClosing() || proxySession.isClosed()); + } + } + + @Test + public void testDirectWithHostKeyVerification() throws Exception { + // This test exists only to show that the knownhosts setup is correct + try (SshServer server = setupTestServer(); + SshServer proxy = setupTestServer(); + SshClient client = setupTestClient()) { + + File knownHosts = prepareHostKeySetup(server, proxy); + // Setup client with a standard ServerKeyVerifier + client.setServerKeyVerifier( + new KnownHostsServerKeyVerifier(RejectAllServerKeyVerifier.INSTANCE, knownHosts.toPath())); + client.start(); + + logger.info("Proxy: " + proxy.getPort() + ", server: " + server.getPort()); + + // Connect to the server directly to verify the knownhosts setup. + try (ClientSession session = createSession( + client, "localhost", server.getPort(), "user1", "user1", null)) { + assertTrue(session.isOpen()); + doTestCommand(session, "ls -al"); + } + assertTrue(proxySession == null || proxySession.isClosing() || proxySession.isClosed()); + + // Connect through to the proxy. + try (ClientSession session = createSession( + client, "localhost", proxy.getPort(), "user2", "user2", null)) { + assertTrue(session.isOpen()); + assertThrows(RemoteException.class, + () -> doTestCommand(session, "ls -al")); + } + assertTrue(proxySession == null || proxySession.isClosing() || proxySession.isClosed()); + } + } + + @Test + public void testProxyWithHostKeyVerification() throws Exception { + try (SshServer server = setupTestServer(); + SshServer proxy = setupTestServer(); + SshClient client = setupTestClient()) { + + File knownHosts = prepareHostKeySetup(server, proxy); + // Setup client with a standard ServerKeyVerifier + client.setServerKeyVerifier( + new KnownHostsServerKeyVerifier(RejectAllServerKeyVerifier.INSTANCE, knownHosts.toPath())); + client.start(); + + logger.info("Proxy: " + proxy.getPort() + ", server: " + server.getPort()); + + // Connect via the proxy + client.addPasswordIdentity("user2"); + try (ClientSession session = createSession( + client, "localhost", server.getPort(), "user1", "user1", + "user2@localhost:" + proxy.getPort())) { + + assertTrue(session.isOpen()); + doTestCommand(session, "ls -la"); } - assertEquals(command, result); // make sure the proxy session is closed / closing assertTrue(proxySession == null || proxySession.isClosing() || proxySession.isClosed()); } } + @Test + public void testProxyWithHostKeyVerificationAndCustomConfig() throws Exception { + try (SshServer server = setupTestServer(); + SshServer proxy = setupTestServer(); + SshClient client = setupTestClient()) { + + File knownHosts = prepareHostKeySetup(server, proxy); + // Setup client with a standard ServerKeyVerifier + client.setServerKeyVerifier( + new KnownHostsServerKeyVerifier(RejectAllServerKeyVerifier.INSTANCE, knownHosts.toPath())); + client.start(); + client.setHostConfigEntryResolver(HostConfigEntry.toHostConfigEntryResolver(Arrays.asList( + new HostConfigEntry("server", "localhost", server.getPort(), "user1", "proxy"), + new HostConfigEntry("proxy", "localhost", proxy.getPort(), "user2")))); + + logger.info("Proxy: " + proxy.getPort() + ", server: " + server.getPort()); + + // Connect via the proxy + client.addPasswordIdentity("user1"); + client.addPasswordIdentity("user2"); + try (ClientSession session = client.connect("server") + .verify(CONNECT_TIMEOUT).getSession()) { + session.auth().verify(AUTH_TIMEOUT); + + assertTrue(session.isOpen()); + doTestCommand(session, "ls -la"); + } + // make sure the proxy session is closed / closing + assertTrue(proxySession == null || proxySession.isClosing() || proxySession.isClosed()); + } + } + + @Test + @Ignore + public void testExternal() throws Exception { + try (SshServer server = setupTestServer(); + SshServer proxy = setupTestServer()) { + + server.setCommandFactory((session, command) -> new CommandExecutionHelper(command) { + @Override + protected boolean handleCommandLine(String command) throws Exception { + OutputStream stdout = getOutputStream(); + stdout.write(command.getBytes(StandardCharsets.US_ASCII)); + stdout.flush(); + return false; + } + }); + server.start(); + // setup proxy with a forwarding filter to allow the local port forwarding + proxy.setForwardingFilter(AcceptAllForwardingFilter.INSTANCE); + proxy.start(); + + logger.info("Proxy: " + proxy.getPort() + ", server: " + server.getPort()); + Thread.sleep(TimeUnit.MINUTES.toMillis(5)); + } + } + + protected void doTestCommand(ClientSession session, String command) throws IOException { + String result; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayOutputStream errors = new ByteArrayOutputStream(); + session.executeRemoteCommand(command, out, errors, StandardCharsets.UTF_8); + result = out.toString(); + assertEquals(command, result); + } + + protected File prepareHostKeySetup(SshServer server, SshServer proxy) throws Exception { + // setup server with an echo command + server.setCommandFactory((session, command) -> new CommandExecutionHelper(command) { + @Override + protected boolean handleCommandLine(String command) throws Exception { + OutputStream stdout = getOutputStream(); + stdout.write(command.getBytes(StandardCharsets.US_ASCII)); + stdout.flush(); + return true; + } + }); + + server.start(); + File knownHosts = tmpClientDir.newFile("knownhosts"); + writeKnownHosts(server, knownHosts); + // setup proxy with a forwarding filter to allow the local port forwarding + proxy.setForwardingFilter(AcceptAllForwardingFilter.INSTANCE); + proxy.start(); + writeKnownHosts(proxy, knownHosts); + return knownHosts; + } + + protected File writeKnownHosts(SshServer server, File knownHosts) throws Exception { + KeyPair serverHostKey = GenericUtils.head(server.getKeyPairProvider().loadKeys(null)); + try (BufferedWriter writer = Files.newBufferedWriter(knownHosts.toPath(), StandardCharsets.US_ASCII, + StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND)) { + KnownHostHashValue.appendHostPattern(writer, "localhost", server.getPort()); + writer.append(' '); + PublicKeyEntry.appendPublicKeyEntry(writer, serverHostKey.getPublic()); + writer.append('\n'); + } + return knownHosts; + } + @SuppressWarnings("checkstyle:ParameterNumber") protected ClientSession createSession( SshClient client, String host, int port, String user, String password, - String proxyHost, int proxyPort, String proxyUser, String proxyPassword) - throws java.io.IOException { - ClientSession session; - if (proxyHost != null) { - proxySession = client.connect(proxyUser, proxyHost, proxyPort) - .verify(CONNECT_TIMEOUT).getSession(); - proxySession.addPasswordIdentity(proxyPassword); - proxySession.auth().verify(AUTH_TIMEOUT); - SshdSocketAddress address = new SshdSocketAddress(host, port); - ExplicitPortForwardingTracker tracker = proxySession.createLocalPortForwardingTracker( - SshdSocketAddress.LOCALHOST_ADDRESS, address); - SshdSocketAddress bound = tracker.getBoundAddress(); - session = client.connect(user, bound.getHostName(), bound.getPort()) - .verify(CONNECT_TIMEOUT).getSession(); - session.addCloseFutureListener(f -> IoUtils.closeQuietly(tracker, proxySession)); - } else { - session = client.connect(user, host, port).verify(CONNECT_TIMEOUT).getSession(); - } + String proxyJump) + throws IOException { + ClientSession session = client.connect(new HostConfigEntry( + "", host, port, user, + proxyJump)) + .verify(CONNECT_TIMEOUT).getSession(); session.addPasswordIdentity(password); session.auth().verify(AUTH_TIMEOUT); return session; diff --git a/sshd-core/src/test/java/org/apache/sshd/client/config/hosts/HostConfigEntryResolverTest.java b/sshd-core/src/test/java/org/apache/sshd/client/config/hosts/HostConfigEntryResolverTest.java index 495ba82..c45f8e0 100644 --- a/sshd-core/src/test/java/org/apache/sshd/client/config/hosts/HostConfigEntryResolverTest.java +++ b/sshd-core/src/test/java/org/apache/sshd/client/config/hosts/HostConfigEntryResolverTest.java @@ -92,7 +92,7 @@ public class HostConfigEntryResolverTest extends BaseTestSupport { @Test public void testEffectiveHostConfigResolution() throws Exception { HostConfigEntry entry = new HostConfigEntry(getCurrentTestName(), TEST_LOCALHOST, port, getCurrentTestName()); - client.setHostConfigEntryResolver((host, portValue, lclAddress, username, context) -> entry); + client.setHostConfigEntryResolver((host, portValue, lclAddress, username, proxy, context) -> entry); client.start(); try (ClientSession session = client.connect( @@ -169,7 +169,7 @@ public class HostConfigEntryResolverTest extends BaseTestSupport { String host = getClass().getSimpleName(); HostConfigEntry entry = new HostConfigEntry(host, TEST_LOCALHOST, port, user); entry.addIdentity(clientIdentity); - client.setHostConfigEntryResolver((host1, portValue, lclAddress, username, context) -> entry); + client.setHostConfigEntryResolver((host1, portValue, lclAddress, username, proxy, context) -> entry); client.start(); try (ClientSession session = client.connect(