From 3a499606b1d8f18cb129cd47e63dd17f54e80def Mon Sep 17 00:00:00 2001 From: Thomas Wolf Date: Thu, 23 Apr 2020 18:30:19 +0200 Subject: [PATCH] Builder API to configure SshdSessionFactories A builder API provides a more convenient way to define a customized SshdSessionFactory by hiding the subclassing. Also provide a new interface SshConfigStore to abstract away the specifics of reading a ssh config file, and provide a way to customize the concrete ssh config implementation to be used. This facilitates using an alternate ssh config implementation that may or may not be based on files. Change-Id: Ib9038e8ff2a4eb3a9ce7b3554d1450befec8e1e1 Signed-off-by: Thomas Wolf --- .../transport/sshd/NoFilesSshBuilderTest.java | 163 ++++++++ .../jgit/transport/sshd/NoFilesSshTest.java | 1 - .../transport/sshd/JGitSshConfig.java | 58 +-- .../transport/sshd/SshdSessionFactory.java | 55 ++- .../sshd/SshdSessionFactoryBuilder.java | 393 ++++++++++++++++++ .../transport/ssh/OpenSshConfigFile.java | 14 +- .../jgit/transport/SshConfigStore.java | 114 +++++ 7 files changed, 746 insertions(+), 52 deletions(-) create mode 100644 org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/NoFilesSshBuilderTest.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactoryBuilder.java create mode 100644 org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConfigStore.java diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/NoFilesSshBuilderTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/NoFilesSshBuilderTest.java new file mode 100644 index 000000000..04208fef3 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/NoFilesSshBuilderTest.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 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 + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.transport.sshd; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.PublicKey; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +import org.apache.sshd.common.NamedResource; +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.keyprovider.KeyIdentityProvider; +import org.apache.sshd.common.session.SessionContext; +import org.apache.sshd.common.util.net.SshdSocketAddress; +import org.apache.sshd.common.util.security.SecurityUtils; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.SshSessionFactory; +import org.eclipse.jgit.transport.ssh.SshTestHarness; +import org.eclipse.jgit.util.FS; +import org.junit.After; +import org.junit.Test; + +/** + * Test for using the SshdSessionFactory without files in ~/.ssh but with an + * in-memory setup, creating the factory via the builder API. + */ +public class NoFilesSshBuilderTest extends SshTestHarness { + + private PublicKey testServerKey; + + private KeyPair testUserKey; + + @Override + protected SshSessionFactory createSessionFactory() { + return new SshdSessionFactoryBuilder() // + .setConfigStoreFactory((h, f, u) -> null) + .setDefaultKeysProvider(f -> new KeyAuthenticator()) + .setServerKeyDatabase((h, s) -> new ServerKeyDatabase() { + + @Override + public List lookup(String connectAddress, + InetSocketAddress remoteAddress, + Configuration config) { + return Collections.singletonList(testServerKey); + } + + @Override + public boolean accept(String connectAddress, + InetSocketAddress remoteAddress, + PublicKey serverKey, Configuration config, + CredentialsProvider provider) { + return KeyUtils.compareKeys(serverKey, testServerKey); + } + + }) // + .setPreferredAuthentications("publickey") + .setHomeDirectory(FS.DETECTED.userHome()) + .setSshDirectory(sshDir) // + .build(new JGitKeyCache()); + } + + private class KeyAuthenticator + implements KeyIdentityProvider, Iterable { + + @Override + public Iterator iterator() { + // Should not be called. The use of the Iterable interface in + // SshdSessionFactory.getDefaultKeys() made sense in sshd 2.0.0, + // but sshd 2.2.0 added the SessionContext, which although good + // (without it we couldn't check here) breaks the Iterable analogy. + // But we're stuck now with that interface for getDefaultKeys, and + // so this override throwing an exception is unfortunately needed. + throw new UnsupportedOperationException(); + } + + @Override + public Iterable loadKeys(SessionContext session) + throws IOException, GeneralSecurityException { + if (!TEST_USER.equals(session.getUsername())) { + return Collections.emptyList(); + } + SshdSocketAddress remoteAddress = SshdSocketAddress + .toSshdSocketAddress(session.getRemoteAddress()); + switch (remoteAddress.getHostName()) { + case "localhost": + case "127.0.0.1": + return Collections.singletonList(testUserKey); + default: + return Collections.emptyList(); + } + } + } + + @After + public void cleanUp() { + testServerKey = null; + testUserKey = null; + } + + @Override + protected void installConfig(String... config) { + File configFile = new File(sshDir, Constants.CONFIG); + if (config != null) { + try { + Files.write(configFile.toPath(), Arrays.asList(config)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + } + + private KeyPair load(Path path) throws Exception { + try (InputStream in = Files.newInputStream(path)) { + return SecurityUtils + .loadKeyPairIdentities(null, + NamedResource.ofName(path.toString()), in, null) + .iterator().next(); + } + } + + @Test + public void testCloneWithBuiltInKeys() throws Exception { + // This test should fail unless our in-memory setup is taken: no + // known_hosts file, and a config that specifies a non-existing key. + File newHostKey = new File(getTemporaryDirectory(), "newhostkey"); + copyTestResource("id_ed25519", newHostKey); + server.addHostKey(newHostKey.toPath(), true); + testServerKey = load(newHostKey.toPath()).getPublic(); + assertTrue(newHostKey.delete()); + testUserKey = load(privateKey1.getAbsoluteFile().toPath()); + assertNotNull(testServerKey); + assertNotNull(testUserKey); + cloneWith( + "ssh://" + TEST_USER + "@localhost:" + testPort + + "/doesntmatter", + new File(getTemporaryDirectory(), "cloned"), null, // + "Host localhost", // + "IdentityFile " + + new File(sshDir, "does_not_exist").getAbsolutePath()); + } + +} diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/NoFilesSshTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/NoFilesSshTest.java index 608f647bc..fa026a5c0 100644 --- a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/NoFilesSshTest.java +++ b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/NoFilesSshTest.java @@ -47,7 +47,6 @@ import org.junit.Test; */ public class NoFilesSshTest extends SshTestHarness { - private PublicKey testServerKey; private KeyPair testUserKey; diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshConfig.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshConfig.java index e770134fa..97e0fcc7d 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshConfig.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshConfig.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 @@ -12,7 +12,6 @@ package org.eclipse.jgit.internal.transport.sshd; import static org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile.flag; import static org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile.positive; -import java.io.File; import java.io.IOException; import java.net.SocketAddress; import java.util.Map; @@ -22,61 +21,36 @@ import org.apache.sshd.client.config.hosts.HostConfigEntry; import org.apache.sshd.client.config.hosts.HostConfigEntryResolver; import org.apache.sshd.common.AttributeRepository; import org.apache.sshd.common.util.net.SshdSocketAddress; -import org.eclipse.jgit.annotations.NonNull; -import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile; -import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile.HostEntry; +import org.eclipse.jgit.transport.SshConfigStore; import org.eclipse.jgit.transport.SshConstants; +import org.eclipse.jgit.transport.SshSessionFactory; /** - * A {@link HostConfigEntryResolver} adapted specifically for JGit. - *

- * We use our own config file parser and entry resolution since the default - * {@link org.apache.sshd.client.config.hosts.ConfigFileHostEntryResolver - * ConfigFileHostEntryResolver} has a number of problems: - *

- *
    - *
  • It does case-insensitive pattern matching. Matching in OpenSsh is - * case-sensitive! Compare also bug 531118.
  • - *
  • It only merges values from the global items (before the first "Host" - * line) into the host entries. Otherwise it selects the most specific match. - * OpenSsh processes all entries in the order they appear in the file - * and whenever one matches, it updates values as appropriate.
  • - *
  • We have to ensure that ~ replacement uses the same HOME directory as - * JGit. Compare bug bug 526175.
  • - *
- * Therefore, this re-uses the parsing and caching from - * {@link OpenSshConfigFile}. - * + * A bridge between a JGit {@link SshConfigStore} and the Apache MINA sshd + * {@link HostConfigEntryResolver}. */ public class JGitSshConfig implements HostConfigEntryResolver { - private final OpenSshConfigFile configFile; - - private final String localUserName; + private final SshConfigStore configFile; /** - * Creates a new {@link OpenSshConfigFile} that will read the config from - * file {@code config} use the given file {@code home} as "home" directory. + * Creates a new {@link JGitSshConfig} that will read the config from the + * given {@link SshConfigStore}. * - * @param home - * user's home directory for the purpose of ~ replacement - * @param config - * file to load; may be {@code null} if no ssh config file - * handling is desired - * @param localUserName - * user name of the current user on the local host OS + * @param store + * to use */ - public JGitSshConfig(@NonNull File home, File config, - @NonNull String localUserName) { - this.localUserName = localUserName; - configFile = config == null ? null : new OpenSshConfigFile(home, config, localUserName); + public JGitSshConfig(SshConfigStore store) { + configFile = store; } @Override public HostConfigEntry resolveEffectiveHost(String host, int port, SocketAddress localAddress, String username, AttributeRepository attributes) throws IOException { - HostEntry entry = configFile == null ? new HostEntry() : configFile.lookup(host, port, username); + SshConfigStore.HostConfig entry = configFile == null + ? SshConfigStore.EMPTY_CONFIG + : configFile.lookup(host, port, username); JGitHostConfigEntry config = new JGitHostConfigEntry(); // Apache MINA conflates all keys, even multi-valued ones, in one map // and puts multiple values separated by commas in one string. See @@ -102,7 +76,7 @@ public class JGitSshConfig implements HostConfigEntryResolver { String user = username != null && !username.isEmpty() ? username : entry.getValue(SshConstants.USER); if (user == null || user.isEmpty()) { - user = localUserName; + user = SshSessionFactory.getLocalUserName(); } config.setUsername(user); config.setProperty(SshConstants.USER, user); diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java index 4c1b49b67..cb6b7d674 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java @@ -39,6 +39,7 @@ import org.apache.sshd.common.config.keys.loader.openssh.kdf.BCryptKdfOptions; import org.apache.sshd.common.keyprovider.KeyIdentityProvider; import org.eclipse.jgit.annotations.NonNull; import org.eclipse.jgit.errors.TransportException; +import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile; import org.eclipse.jgit.internal.transport.sshd.CachingKeyPairProvider; import org.eclipse.jgit.internal.transport.sshd.GssApiWithMicAuthFactory; import org.eclipse.jgit.internal.transport.sshd.JGitPasswordAuthFactory; @@ -50,6 +51,7 @@ import org.eclipse.jgit.internal.transport.sshd.OpenSshServerKeyDatabase; import org.eclipse.jgit.internal.transport.sshd.PasswordProviderWrapper; import org.eclipse.jgit.internal.transport.sshd.SshdText; import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.SshConfigStore; import org.eclipse.jgit.transport.SshConstants; import org.eclipse.jgit.transport.SshSessionFactory; import org.eclipse.jgit.transport.URIish; @@ -327,8 +329,8 @@ public class SshdSessionFactory extends SshSessionFactory implements Closeable { @NonNull File homeDir, @NonNull File sshDir) { return defaultHostConfigEntryResolver.computeIfAbsent( new Tuple(new Object[] { homeDir, sshDir }), - t -> new JGitSshConfig(homeDir, getSshConfig(sshDir), - getLocalUserName())); + t -> new JGitSshConfig(createSshConfigStore(homeDir, + getSshConfig(sshDir), getLocalUserName()))); } /** @@ -347,7 +349,29 @@ public class SshdSessionFactory extends SshSessionFactory implements Closeable { } /** - * Obtain a {@link ServerKeyDatabase} to verify server host keys. The + * Obtains a {@link SshConfigStore}, or {@code null} if not SSH config is to + * be used. The default implementation returns {@code null} if + * {@code configFile == null} and otherwise an OpenSSH-compatible store + * reading host entries from the given file. + * + * @param homeDir + * may be used for ~-replacements by the returned config store + * @param configFile + * to use, or {@code null} if none + * @param localUserName + * user name of the current user on the local OS + * @return A {@link SshConfigStore}, or {@code null} if none is to be used + * + * @since 5.8 + */ + protected SshConfigStore createSshConfigStore(@NonNull File homeDir, + File configFile, String localUserName) { + return configFile == null ? null + : new OpenSshConfigFile(homeDir, configFile, localUserName); + } + + /** + * Obtains a {@link ServerKeyDatabase} to verify server host keys. The * default implementation returns a {@link ServerKeyDatabase} that * recognizes the two openssh standard files {@code ~/.ssh/known_hosts} and * {@code ~/.ssh/known_hosts2} as well as any files configured via the @@ -365,10 +389,31 @@ public class SshdSessionFactory extends SshSessionFactory implements Closeable { @NonNull File sshDir) { return defaultServerKeyDatabase.computeIfAbsent( new Tuple(new Object[] { homeDir, sshDir }), - t -> new OpenSshServerKeyDatabase(true, - getDefaultKnownHostsFiles(sshDir))); + t -> createServerKeyDatabase(homeDir, sshDir)); + + } + /** + * Creates a {@link ServerKeyDatabase} to verify server host keys. The + * default implementation returns a {@link ServerKeyDatabase} that + * recognizes the two openssh standard files {@code ~/.ssh/known_hosts} and + * {@code ~/.ssh/known_hosts2} as well as any files configured via the + * {@code UserKnownHostsFile} option in the ssh config file. + * + * @param homeDir + * home directory to use for ~ replacement + * @param sshDir + * representing ~/.ssh/ + * @return the {@link ServerKeyDatabase} + * @since 5.8 + */ + @NonNull + protected ServerKeyDatabase createServerKeyDatabase(@NonNull File homeDir, + @NonNull File sshDir) { + return new OpenSshServerKeyDatabase(true, + getDefaultKnownHostsFiles(sshDir)); } + /** * Gets the list of default user known hosts files. The default returns * ~/.ssh/known_hosts and ~/.ssh/known_hosts2. The ssh config diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactoryBuilder.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactoryBuilder.java new file mode 100644 index 000000000..2147c2bd5 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactoryBuilder.java @@ -0,0 +1,393 @@ +/* + * Copyright (C) 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 + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.transport.sshd; + +import java.io.File; +import java.nio.file.Path; +import java.security.KeyPair; +import java.util.Collections; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Function; + +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.SshConfigStore; +import org.eclipse.jgit.util.StringUtils; + +/** + * A builder API to configure {@link SshdSessionFactory SshdSessionFactories}. + * + * @since 5.8 + */ +public final class SshdSessionFactoryBuilder { + + private final State state = new State(); + + /** + * Sets the {@link ProxyDataFactory} to use for {@link SshdSessionFactory + * SshdSessionFactories} created by {@link #build(KeyCache)}. + * + * @param proxyDataFactory + * to use + * @return this {@link SshdSessionFactoryBuilder} + */ + public SshdSessionFactoryBuilder setProxyDataFactory( + ProxyDataFactory proxyDataFactory) { + this.state.proxyDataFactory = proxyDataFactory; + return this; + } + + /** + * Sets the home directory to use for {@link SshdSessionFactory + * SshdSessionFactories} created by {@link #build(KeyCache)}. + * + * @param homeDirectory + * to use; may be {@code null}, in which case the home directory + * as defined by {@link org.eclipse.jgit.util.FS#userHome() + * FS.userHome()} is assumed + * @return this {@link SshdSessionFactoryBuilder} + */ + public SshdSessionFactoryBuilder setHomeDirectory(File homeDirectory) { + this.state.homeDirectory = homeDirectory; + return this; + } + + /** + * Sets the SSH directory to use for {@link SshdSessionFactory + * SshdSessionFactories} created by {@link #build(KeyCache)}. + * + * @param sshDirectory + * to use; may be {@code null}, in which case ".ssh" under the + * {@link #setHomeDirectory(File) home directory} is assumed + * @return this {@link SshdSessionFactoryBuilder} + */ + public SshdSessionFactoryBuilder setSshDirectory(File sshDirectory) { + this.state.sshDirectory = sshDirectory; + return this; + } + + /** + * Sets the default preferred authentication mechanisms to use for + * {@link SshdSessionFactory SshdSessionFactories} created by + * {@link #build(KeyCache)}. + * + * @param authentications + * comma-separated list of authentication mechanism names; if + * {@code null} or empty, the default as specified by + * {@link SshdSessionFactory#getDefaultPreferredAuthentications()} + * will be used + * @return this {@link SshdSessionFactoryBuilder} + */ + public SshdSessionFactoryBuilder setPreferredAuthentications( + String authentications) { + this.state.preferredAuthentications = authentications; + return this; + } + + /** + * Sets a function that returns the SSH config file, given the SSH + * directory. The function may return {@code null}, in which case no SSH + * config file will be used. If a non-null file is returned, it will be used + * when it exists. If no supplier has been set, or the supplier has been set + * explicitly to {@code null}, by default a file named + * {@link org.eclipse.jgit.transport.SshConstants#CONFIG + * SshConstants.CONFIG} in the {@link #setSshDirectory(File) SSH directory} + * is used. + * + * @param supplier + * returning a {@link File} for the SSH config file to use, or + * returning {@code null} if no config file is to be used + * @return this {@link SshdSessionFactoryBuilder} + */ + public SshdSessionFactoryBuilder setConfigFile( + Function supplier) { + this.state.configFileFinder = supplier; + return this; + } + + /** + * A factory interface for creating a @link SshConfigStore}. + */ + @FunctionalInterface + public interface ConfigStoreFactory { + + /** + * Creates a {@link SshConfigStore}. May return {@code null} if none is + * to be used. + * + * @param homeDir + * to use for ~-replacements + * @param configFile + * to use, may be {@code null} if none + * @param localUserName + * name of the current user in the local OS + * @return the {@link SshConfigStore}, or {@code null} if none is to be + * used + */ + SshConfigStore create(@NonNull File homeDir, File configFile, + String localUserName); + } + + /** + * Sets a factory for the {@link SshConfigStore} to use. If not set or + * explicitly set to {@code null}, the default as specified by + * {@link SshdSessionFactory#createSshConfigStore(File, File, String)} is + * used. + * + * @param factory + * to set + * @return this {@link SshdSessionFactoryBuilder} + */ + public SshdSessionFactoryBuilder setConfigStoreFactory( + ConfigStoreFactory factory) { + this.state.configFactory = factory; + return this; + } + + /** + * Sets a function that returns the default known hosts files, given the SSH + * directory. If not set or explicitly set to {@code null}, the defaults as + * specified by {@link SshdSessionFactory#getDefaultKnownHostsFiles(File)} + * are used. + * + * @param supplier + * to get the default known hosts files + * @return this {@link SshdSessionFactoryBuilder} + */ + public SshdSessionFactoryBuilder setDefaultKnownHostsFiles( + Function> supplier) { + this.state.knownHostsFileFinder = supplier; + return this; + } + + /** + * Sets a function that returns the default private key files, given the SSH + * directory. If not set or explicitly set to {@code null}, the defaults as + * specified by {@link SshdSessionFactory#getDefaultIdentities(File)} are + * used. + * + * @param supplier + * to get the default private key files + * @return this {@link SshdSessionFactoryBuilder} + */ + public SshdSessionFactoryBuilder setDefaultIdentities( + Function> supplier) { + this.state.defaultKeyFileFinder = supplier; + return this; + } + + /** + * Sets a function that returns the default private keys, given the SSH + * directory. If not set or explicitly set to {@code null}, the defaults as + * specified by {@link SshdSessionFactory#getDefaultKeys(File)} are used. + * + * @param provider + * to get the default private key files + * @return this {@link SshdSessionFactoryBuilder} + */ + public SshdSessionFactoryBuilder setDefaultKeysProvider( + Function> provider) { + this.state.defaultKeysProvider = provider; + return this; + } + + /** + * Sets a factory function to create a {@link KeyPasswordProvider}. If not + * set or explicitly set to {@code null}, or if the factory returns + * {@code null}, the default as specified by + * {@link SshdSessionFactory#createKeyPasswordProvider(CredentialsProvider)} + * is used. + * + * @param factory + * to create a {@link KeyPasswordProvider} + * @return this {@link SshdSessionFactoryBuilder} + */ + public SshdSessionFactoryBuilder setKeyPasswordProvider( + Function factory) { + this.state.passphraseProviderFactory = factory; + return this; + } + + /** + * Sets a function that creates a new {@link ServerKeyDatabase}, given the + * SSH and home directory. If not set or explicitly set to {@code null}, or + * if the {@code factory} returns {@code null}, the default as specified by + * {@link SshdSessionFactory#createServerKeyDatabase(File, File)} is used. + * + * @param factory + * to create a {@link ServerKeyDatabase} + * @return this {@link SshdSessionFactoryBuilder} + */ + public SshdSessionFactoryBuilder setServerKeyDatabase( + BiFunction factory) { + this.state.serverKeyDatabaseCreator = factory; + return this; + } + + /** + * Builds a {@link SshdSessionFactory} as configured, using the given + * {@link KeyCache} for caching keys. + *

+ * Different {@link SshdSessionFactory SshdSessionFactories} should + * not share the same {@link KeyCache} since the cache is + * invalidated when the factory itself or when the last {@link SshdSession} + * created from the factory is closed. + *

+ * + * @param cache + * to use for caching ssh keys; may be {@code null} if no caching + * is desired. + * @return the {@link SshdSessionFactory} + */ + public SshdSessionFactory build(KeyCache cache) { + // Use a copy to avoid that subsequent calls to setters affect an + // already created SshdSessionFactory. + return state.copy().build(cache); + } + + private static class State { + + ProxyDataFactory proxyDataFactory; + + File homeDirectory; + + File sshDirectory; + + String preferredAuthentications; + + Function configFileFinder; + + ConfigStoreFactory configFactory; + + Function passphraseProviderFactory; + + Function> knownHostsFileFinder; + + Function> defaultKeyFileFinder; + + Function> defaultKeysProvider; + + BiFunction serverKeyDatabaseCreator; + + State copy() { + State c = new State(); + c.proxyDataFactory = proxyDataFactory; + c.homeDirectory = homeDirectory; + c.sshDirectory = sshDirectory; + c.preferredAuthentications = preferredAuthentications; + c.configFileFinder = configFileFinder; + c.configFactory = configFactory; + c.passphraseProviderFactory = passphraseProviderFactory; + c.knownHostsFileFinder = knownHostsFileFinder; + c.defaultKeyFileFinder = defaultKeyFileFinder; + c.defaultKeysProvider = defaultKeysProvider; + c.serverKeyDatabaseCreator = serverKeyDatabaseCreator; + return c; + } + + SshdSessionFactory build(KeyCache cache) { + SshdSessionFactory factory = new SessionFactory(cache, + proxyDataFactory); + factory.setHomeDirectory(homeDirectory); + factory.setSshDirectory(sshDirectory); + return factory; + } + + private class SessionFactory extends SshdSessionFactory { + + public SessionFactory(KeyCache cache, + ProxyDataFactory proxyDataFactory) { + super(cache, proxyDataFactory); + } + + @Override + protected File getSshConfig(File sshDir) { + if (configFileFinder != null) { + return configFileFinder.apply(sshDir); + } + return super.getSshConfig(sshDir); + } + + @Override + protected List getDefaultKnownHostsFiles(File sshDir) { + if (knownHostsFileFinder != null) { + List result = knownHostsFileFinder.apply(sshDir); + return result == null ? Collections.emptyList() : result; + } + return super.getDefaultKnownHostsFiles(sshDir); + } + + @Override + protected List getDefaultIdentities(File sshDir) { + if (defaultKeyFileFinder != null) { + List result = defaultKeyFileFinder.apply(sshDir); + return result == null ? Collections.emptyList() : result; + } + return super.getDefaultIdentities(sshDir); + } + + @Override + protected String getDefaultPreferredAuthentications() { + if (!StringUtils.isEmptyOrNull(preferredAuthentications)) { + return preferredAuthentications; + } + return super.getDefaultPreferredAuthentications(); + } + + @Override + protected Iterable getDefaultKeys(File sshDir) { + if (defaultKeysProvider != null) { + Iterable result = defaultKeysProvider + .apply(sshDir); + return result == null ? Collections.emptyList() : result; + } + return super.getDefaultKeys(sshDir); + } + + @Override + protected KeyPasswordProvider createKeyPasswordProvider( + CredentialsProvider provider) { + if (passphraseProviderFactory != null) { + KeyPasswordProvider result = passphraseProviderFactory + .apply(provider); + if (result != null) { + return result; + } + } + return super.createKeyPasswordProvider(provider); + } + + @Override + protected ServerKeyDatabase createServerKeyDatabase(File homeDir, + File sshDir) { + if (serverKeyDatabaseCreator != null) { + ServerKeyDatabase result = serverKeyDatabaseCreator + .apply(homeDir, sshDir); + if (result != null) { + return result; + } + } + return super.createServerKeyDatabase(homeDir, sshDir); + } + + @Override + protected SshConfigStore createSshConfigStore(File homeDir, + File configFile, String localUserName) { + if (configFactory != null) { + return configFactory.create(homeDir, configFile, + localUserName); + } + return super.createSshConfigStore(homeDir, configFile, + localUserName); + } + } + } +} diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/transport/ssh/OpenSshConfigFile.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/transport/ssh/OpenSshConfigFile.java index 2fbc9122f..98c63cdcd 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/internal/transport/ssh/OpenSshConfigFile.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/transport/ssh/OpenSshConfigFile.java @@ -32,6 +32,7 @@ import java.util.TreeSet; import org.eclipse.jgit.annotations.NonNull; import org.eclipse.jgit.errors.InvalidPatternException; import org.eclipse.jgit.fnmatch.FileNameMatcher; +import org.eclipse.jgit.transport.SshConfigStore; import org.eclipse.jgit.transport.SshConstants; import org.eclipse.jgit.util.FS; import org.eclipse.jgit.util.StringUtils; @@ -80,7 +81,7 @@ import org.eclipse.jgit.util.SystemReader; * @see man * ssh-config */ -public class OpenSshConfigFile { +public class OpenSshConfigFile implements SshConfigStore { /** * "Host" name of the HostEntry for the default options before the first @@ -152,8 +153,9 @@ public class OpenSshConfigFile { * the user supplied; <= 0 if none * @param userName * the user supplied, may be {@code null} or empty if none given - * @return r configuration for the requested name. + * @return the configuration for the requested name. */ + @Override @NonNull public HostEntry lookup(@NonNull String hostName, int port, String userName) { @@ -446,7 +448,7 @@ public class OpenSshConfigFile { * of several matching host entries, %-substitutions, and ~ replacement have * all been done. */ - public static class HostEntry { + public static class HostEntry implements SshConfigStore.HostConfig { /** * Keys that can be specified multiple times, building up a list. (I.e., @@ -489,7 +491,7 @@ public class OpenSshConfigFile { private Map> listOptions; /** - * Retrieves the value of a single-valued key, or the first is the key + * Retrieves the value of a single-valued key, or the first if the key * has multiple values. Keys are case-insensitive, so * {@code getValue("HostName") == getValue("HOSTNAME")}. * @@ -497,6 +499,7 @@ public class OpenSshConfigFile { * to get the value of * @return the value, or {@code null} if none */ + @Override public String getValue(String key) { String result = options != null ? options.get(key) : null; if (result == null) { @@ -524,6 +527,7 @@ public class OpenSshConfigFile { * to get the values of * @return a possibly empty list of values */ + @Override public List getValues(String key) { List values = listOptions != null ? listOptions.get(key) : null; @@ -778,6 +782,7 @@ public class OpenSshConfigFile { * * @return all single-valued options */ + @Override @NonNull public Map getOptions() { if (options == null) { @@ -792,6 +797,7 @@ public class OpenSshConfigFile { * * @return all multi-valued options */ + @Override @NonNull public Map> getMultiValuedOptions() { if (listOptions == null && multiOptions == null) { diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConfigStore.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConfigStore.java new file mode 100644 index 000000000..04a4922bb --- /dev/null +++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConfigStore.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 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 + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.transport; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.eclipse.jgit.annotations.NonNull; + +/** + * An abstraction for a SSH config storage, like the OpenSSH ~/.ssh/config file. + * + * @since 5.8 + */ +public interface SshConfigStore { + + /** + * Locate the configuration for a specific host request. + * + * @param hostName + * to look up + * @param port + * the user supplied; <= 0 if none + * @param userName + * the user supplied, may be {@code null} or empty if none given + * @return the configuration for the requested name. + */ + @NonNull + HostConfig lookup(@NonNull String hostName, int port, String userName); + + /** + * A host entry from the ssh config. Any merging of global values and of + * several matching host entries, %-substitutions, and ~ replacement have + * all been done. + */ + interface HostConfig { + + /** + * Retrieves the value of a single-valued key, or the first if the key + * has multiple values. Keys are case-insensitive, so + * {@code getValue("HostName") == getValue("HOSTNAME")}. + * + * @param key + * to get the value of + * @return the value, or {@code null} if none + */ + String getValue(String key); + + /** + * Retrieves the values of a multi- or list-valued key. Keys are + * case-insensitive, so + * {@code getValue("HostName") == getValue("HOSTNAME")}. + * + * @param key + * to get the values of + * @return a possibly empty list of values + */ + List getValues(String key); + + /** + * Retrieves an unmodifiable map of all single-valued options, with + * case-insensitive lookup by keys. + * + * @return all single-valued options + */ + @NonNull + Map getOptions(); + + /** + * Retrieves an unmodifiable map of all multi- or list-valued options, + * with case-insensitive lookup by keys. + * + * @return all multi-valued options + */ + @NonNull + Map> getMultiValuedOptions(); + + } + + /** + * An empty {@link HostConfig}. + */ + static final HostConfig EMPTY_CONFIG = new HostConfig() { + + @Override + public String getValue(String key) { + return null; + } + + @Override + public List getValues(String key) { + return Collections.emptyList(); + } + + @Override + public Map getOptions() { + return Collections.emptyMap(); + } + + @Override + public Map> getMultiValuedOptions() { + return Collections.emptyMap(); + } + + }; +}