JGitSshClient.java

  1. /*
  2.  * Copyright (C) 2018, 2020 Thomas Wolf <thomas.wolf@paranor.ch> and others
  3.  *
  4.  * This program and the accompanying materials are made available under the
  5.  * terms of the Eclipse Distribution License v. 1.0 which is available at
  6.  * https://www.eclipse.org/org/documents/edl-v10.php.
  7.  *
  8.  * SPDX-License-Identifier: BSD-3-Clause
  9.  */
  10. package org.eclipse.jgit.internal.transport.sshd;

  11. import static java.text.MessageFormat.format;
  12. import static org.apache.sshd.core.CoreModuleProperties.PASSWORD_PROMPTS;
  13. import static org.apache.sshd.core.CoreModuleProperties.PREFERRED_AUTHS;
  14. import static org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile.positive;

  15. import java.io.IOException;
  16. import java.net.InetSocketAddress;
  17. import java.net.Proxy;
  18. import java.net.SocketAddress;
  19. import java.nio.file.Files;
  20. import java.nio.file.InvalidPathException;
  21. import java.nio.file.Path;
  22. import java.nio.file.Paths;
  23. import java.security.GeneralSecurityException;
  24. import java.security.KeyPair;
  25. import java.util.Arrays;
  26. import java.util.Collections;
  27. import java.util.HashMap;
  28. import java.util.Iterator;
  29. import java.util.List;
  30. import java.util.Map;
  31. import java.util.NoSuchElementException;
  32. import java.util.Objects;
  33. import java.util.stream.Collectors;

  34. import org.apache.sshd.client.SshClient;
  35. import org.apache.sshd.client.config.hosts.HostConfigEntry;
  36. import org.apache.sshd.client.future.ConnectFuture;
  37. import org.apache.sshd.client.future.DefaultConnectFuture;
  38. import org.apache.sshd.client.session.ClientSessionImpl;
  39. import org.apache.sshd.client.session.SessionFactory;
  40. import org.apache.sshd.common.AttributeRepository;
  41. import org.apache.sshd.common.config.keys.FilePasswordProvider;
  42. import org.apache.sshd.common.future.SshFutureListener;
  43. import org.apache.sshd.common.io.IoConnectFuture;
  44. import org.apache.sshd.common.io.IoSession;
  45. import org.apache.sshd.common.keyprovider.AbstractResourceKeyPairProvider;
  46. import org.apache.sshd.common.keyprovider.KeyIdentityProvider;
  47. import org.apache.sshd.common.session.SessionContext;
  48. import org.apache.sshd.common.session.helpers.AbstractSession;
  49. import org.apache.sshd.common.util.ValidateUtils;
  50. import org.apache.sshd.common.util.net.SshdSocketAddress;
  51. import org.eclipse.jgit.internal.transport.sshd.JGitClientSession.ChainingAttributes;
  52. import org.eclipse.jgit.internal.transport.sshd.JGitClientSession.SessionAttributes;
  53. import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector;
  54. import org.eclipse.jgit.internal.transport.sshd.proxy.Socks5ClientConnector;
  55. import org.eclipse.jgit.transport.CredentialsProvider;
  56. import org.eclipse.jgit.transport.SshConstants;
  57. import org.eclipse.jgit.transport.sshd.KeyCache;
  58. import org.eclipse.jgit.transport.sshd.ProxyData;
  59. import org.eclipse.jgit.transport.sshd.ProxyDataFactory;
  60. import org.eclipse.jgit.util.StringUtils;

  61. /**
  62.  * Customized {@link SshClient} for JGit. It creates specialized
  63.  * {@link JGitClientSession}s that know about the {@link HostConfigEntry} they
  64.  * were created for, and it loads all KeyPair identities lazily.
  65.  */
  66. public class JGitSshClient extends SshClient {

  67.     /**
  68.      * We need access to this during the constructor of the ClientSession,
  69.      * before setConnectAddress() can have been called. So we have to remember
  70.      * it in an attribute on the SshClient, from where we can then retrieve it.
  71.      */
  72.     static final AttributeKey<HostConfigEntry> HOST_CONFIG_ENTRY = new AttributeKey<>();

  73.     static final AttributeKey<InetSocketAddress> ORIGINAL_REMOTE_ADDRESS = new AttributeKey<>();

  74.     /**
  75.      * An attribute key for the comma-separated list of default preferred
  76.      * authentication mechanisms.
  77.      */
  78.     public static final AttributeKey<String> PREFERRED_AUTHENTICATIONS = new AttributeKey<>();

  79.     /**
  80.      * An attribute key for storing an alternate local address to connect to if
  81.      * a local forward from a ProxyJump ssh config is present. If set,
  82.      * {@link #connect(HostConfigEntry, AttributeRepository, SocketAddress)}
  83.      * will not connect to the address obtained from the {@link HostConfigEntry}
  84.      * but to the address stored in this key (which is assumed to forward the
  85.      * {@code HostConfigEntry} address).
  86.      */
  87.     public static final AttributeKey<SshdSocketAddress> LOCAL_FORWARD_ADDRESS = new AttributeKey<>();

  88.     private KeyCache keyCache;

  89.     private CredentialsProvider credentialsProvider;

  90.     private ProxyDataFactory proxyDatabase;

  91.     @Override
  92.     protected SessionFactory createSessionFactory() {
  93.         // Override the parent's default
  94.         return new JGitSessionFactory(this);
  95.     }

  96.     @Override
  97.     public ConnectFuture connect(HostConfigEntry hostConfig,
  98.             AttributeRepository context, SocketAddress localAddress)
  99.             throws IOException {
  100.         if (connector == null) {
  101.             throw new IllegalStateException("SshClient not started."); //$NON-NLS-1$
  102.         }
  103.         Objects.requireNonNull(hostConfig, "No host configuration"); //$NON-NLS-1$
  104.         String originalHost = ValidateUtils.checkNotNullAndNotEmpty(
  105.                 hostConfig.getHostName(), "No target host"); //$NON-NLS-1$
  106.         int originalPort = hostConfig.getPort();
  107.         ValidateUtils.checkTrue(originalPort > 0, "Invalid port: %d", //$NON-NLS-1$
  108.                 originalPort);
  109.         InetSocketAddress originalAddress = new InetSocketAddress(originalHost,
  110.                 originalPort);
  111.         InetSocketAddress targetAddress = originalAddress;
  112.         String userName = hostConfig.getUsername();
  113.         String id = userName + '@' + originalAddress;
  114.         AttributeRepository attributes = chain(context, this);
  115.         SshdSocketAddress localForward = attributes
  116.                 .resolveAttribute(LOCAL_FORWARD_ADDRESS);
  117.         if (localForward != null) {
  118.             targetAddress = new InetSocketAddress(localForward.getHostName(),
  119.                     localForward.getPort());
  120.             id += '/' + targetAddress.toString();
  121.         }
  122.         ConnectFuture connectFuture = new DefaultConnectFuture(id, null);
  123.         SshFutureListener<IoConnectFuture> listener = createConnectCompletionListener(
  124.                 connectFuture, userName, originalAddress, hostConfig);
  125.         attributes = sessionAttributes(attributes, hostConfig, originalAddress);
  126.         // Proxy support
  127.         if (localForward == null) {
  128.             ProxyData proxy = getProxyData(targetAddress);
  129.             if (proxy != null) {
  130.                 targetAddress = configureProxy(proxy, targetAddress);
  131.                 proxy.clearPassword();
  132.             }
  133.         }
  134.         connector.connect(targetAddress, attributes, localAddress)
  135.                 .addListener(listener);
  136.         return connectFuture;
  137.     }

  138.     private AttributeRepository chain(AttributeRepository self,
  139.             AttributeRepository parent) {
  140.         if (self == null) {
  141.             return Objects.requireNonNull(parent);
  142.         }
  143.         if (parent == null || parent == self) {
  144.             return self;
  145.         }
  146.         return new ChainingAttributes(self, parent);
  147.     }

  148.     private AttributeRepository sessionAttributes(AttributeRepository parent,
  149.             HostConfigEntry hostConfig, InetSocketAddress originalAddress) {
  150.         // sshd needs some entries from the host config already in the
  151.         // constructor of the session. Put those into a dedicated
  152.         // AttributeRepository for the new session where it will find them.
  153.         // We can set the host config only once the session object has been
  154.         // created.
  155.         Map<AttributeKey<?>, Object> data = new HashMap<>();
  156.         data.put(HOST_CONFIG_ENTRY, hostConfig);
  157.         data.put(ORIGINAL_REMOTE_ADDRESS, originalAddress);
  158.         data.put(TARGET_SERVER, new SshdSocketAddress(originalAddress));
  159.         String preferredAuths = hostConfig.getProperty(
  160.                 SshConstants.PREFERRED_AUTHENTICATIONS,
  161.                 resolveAttribute(PREFERRED_AUTHENTICATIONS));
  162.         if (!StringUtils.isEmptyOrNull(preferredAuths)) {
  163.             data.put(SessionAttributes.PROPERTIES,
  164.                     Collections.singletonMap(
  165.                             PREFERRED_AUTHS.getName(),
  166.                             preferredAuths));
  167.         }
  168.         return new SessionAttributes(
  169.                 AttributeRepository.ofAttributesMap(data),
  170.                 parent, this);
  171.     }

  172.     private ProxyData getProxyData(InetSocketAddress remoteAddress) {
  173.         ProxyDataFactory factory = getProxyDatabase();
  174.         return factory == null ? null : factory.get(remoteAddress);
  175.     }

  176.     private InetSocketAddress configureProxy(ProxyData proxyData,
  177.             InetSocketAddress remoteAddress) {
  178.         Proxy proxy = proxyData.getProxy();
  179.         if (proxy.type() == Proxy.Type.DIRECT
  180.                 || !(proxy.address() instanceof InetSocketAddress)) {
  181.             return remoteAddress;
  182.         }
  183.         InetSocketAddress address = (InetSocketAddress) proxy.address();
  184.         if (address.isUnresolved()) {
  185.             address = new InetSocketAddress(address.getHostName(),
  186.                     address.getPort());
  187.         }
  188.         switch (proxy.type()) {
  189.         case HTTP:
  190.             setClientProxyConnector(
  191.                     new HttpClientConnector(address, remoteAddress,
  192.                             proxyData.getUser(), proxyData.getPassword()));
  193.             return address;
  194.         case SOCKS:
  195.             setClientProxyConnector(
  196.                     new Socks5ClientConnector(address, remoteAddress,
  197.                             proxyData.getUser(), proxyData.getPassword()));
  198.             return address;
  199.         default:
  200.             log.warn(format(SshdText.get().unknownProxyProtocol,
  201.                     proxy.type().name()));
  202.             return remoteAddress;
  203.         }
  204.     }

  205.     private SshFutureListener<IoConnectFuture> createConnectCompletionListener(
  206.             ConnectFuture connectFuture, String username,
  207.             InetSocketAddress address, HostConfigEntry hostConfig) {
  208.         return new SshFutureListener<IoConnectFuture>() {

  209.             @Override
  210.             public void operationComplete(IoConnectFuture future) {
  211.                 if (future.isCanceled()) {
  212.                     connectFuture.cancel();
  213.                     return;
  214.                 }
  215.                 Throwable t = future.getException();
  216.                 if (t != null) {
  217.                     connectFuture.setException(t);
  218.                     return;
  219.                 }
  220.                 IoSession ioSession = future.getSession();
  221.                 try {
  222.                     JGitClientSession session = createSession(ioSession,
  223.                             username, address, hostConfig);
  224.                     connectFuture.setSession(session);
  225.                 } catch (RuntimeException e) {
  226.                     connectFuture.setException(e);
  227.                     ioSession.close(true);
  228.                 }
  229.             }

  230.             @Override
  231.             public String toString() {
  232.                 return "JGitSshClient$ConnectCompletionListener[" + username //$NON-NLS-1$
  233.                         + '@' + address + ']';
  234.             }
  235.         };
  236.     }

  237.     private JGitClientSession createSession(IoSession ioSession,
  238.             String username, InetSocketAddress address,
  239.             HostConfigEntry hostConfig) {
  240.         AbstractSession rawSession = AbstractSession.getSession(ioSession);
  241.         if (!(rawSession instanceof JGitClientSession)) {
  242.             throw new IllegalStateException("Wrong session type: " //$NON-NLS-1$
  243.                     + rawSession.getClass().getCanonicalName());
  244.         }
  245.         JGitClientSession session = (JGitClientSession) rawSession;
  246.         session.setUsername(username);
  247.         session.setConnectAddress(address);
  248.         session.setHostConfigEntry(hostConfig);
  249.         if (session.getCredentialsProvider() == null) {
  250.             session.setCredentialsProvider(getCredentialsProvider());
  251.         }
  252.         int numberOfPasswordPrompts = getNumberOfPasswordPrompts(hostConfig);
  253.         PASSWORD_PROMPTS.set(session, Integer.valueOf(numberOfPasswordPrompts));
  254.         List<Path> identities = hostConfig.getIdentities().stream()
  255.                 .map(s -> {
  256.                     try {
  257.                         return Paths.get(s);
  258.                     } catch (InvalidPathException e) {
  259.                         log.warn(format(SshdText.get().configInvalidPath,
  260.                                 SshConstants.IDENTITY_FILE, s), e);
  261.                         return null;
  262.                     }
  263.                 }).filter(p -> p != null && Files.exists(p))
  264.                 .collect(Collectors.toList());
  265.         CachingKeyPairProvider ourConfiguredKeysProvider = new CachingKeyPairProvider(
  266.                 identities, keyCache);
  267.         FilePasswordProvider passwordProvider = getFilePasswordProvider();
  268.         ourConfiguredKeysProvider.setPasswordFinder(passwordProvider);
  269.         if (hostConfig.isIdentitiesOnly()) {
  270.             session.setKeyIdentityProvider(ourConfiguredKeysProvider);
  271.         } else {
  272.             KeyIdentityProvider defaultKeysProvider = getKeyIdentityProvider();
  273.             if (defaultKeysProvider instanceof AbstractResourceKeyPairProvider<?>) {
  274.                 ((AbstractResourceKeyPairProvider<?>) defaultKeysProvider)
  275.                         .setPasswordFinder(passwordProvider);
  276.             }
  277.             KeyIdentityProvider combinedProvider = new CombinedKeyIdentityProvider(
  278.                     ourConfiguredKeysProvider, defaultKeysProvider);
  279.             session.setKeyIdentityProvider(combinedProvider);
  280.         }
  281.         return session;
  282.     }

  283.     private int getNumberOfPasswordPrompts(HostConfigEntry hostConfig) {
  284.         String prompts = hostConfig
  285.                 .getProperty(SshConstants.NUMBER_OF_PASSWORD_PROMPTS);
  286.         if (prompts != null) {
  287.             prompts = prompts.trim();
  288.             int value = positive(prompts);
  289.             if (value > 0) {
  290.                 return value;
  291.             }
  292.             log.warn(format(SshdText.get().configInvalidPositive,
  293.                     SshConstants.NUMBER_OF_PASSWORD_PROMPTS, prompts));
  294.         }
  295.         return PASSWORD_PROMPTS.getRequiredDefault().intValue();
  296.     }

  297.     /**
  298.      * Set a cache for loaded keys. Newly discovered keys will be added when
  299.      * IdentityFile host entries from the ssh config file are used during
  300.      * session authentication.
  301.      *
  302.      * @param cache
  303.      *            to use
  304.      */
  305.     public void setKeyCache(KeyCache cache) {
  306.         keyCache = cache;
  307.     }

  308.     /**
  309.      * Sets a {@link ProxyDataFactory} for connecting through proxies.
  310.      *
  311.      * @param factory
  312.      *            to use, or {@code null} if proxying is not desired or
  313.      *            supported
  314.      */
  315.     public void setProxyDatabase(ProxyDataFactory factory) {
  316.         proxyDatabase = factory;
  317.     }

  318.     /**
  319.      * Retrieves the {@link ProxyDataFactory}.
  320.      *
  321.      * @return the factory, or {@code null} if none is set
  322.      */
  323.     protected ProxyDataFactory getProxyDatabase() {
  324.         return proxyDatabase;
  325.     }

  326.     /**
  327.      * Sets the {@link CredentialsProvider} for this client.
  328.      *
  329.      * @param provider
  330.      *            to set
  331.      */
  332.     public void setCredentialsProvider(CredentialsProvider provider) {
  333.         credentialsProvider = provider;
  334.     }

  335.     /**
  336.      * Retrieves the {@link CredentialsProvider} set for this client.
  337.      *
  338.      * @return the provider, or {@code null} if none is set.
  339.      */
  340.     public CredentialsProvider getCredentialsProvider() {
  341.         return credentialsProvider;
  342.     }

  343.     /**
  344.      * A {@link SessionFactory} to create our own specialized
  345.      * {@link JGitClientSession}s.
  346.      */
  347.     private static class JGitSessionFactory extends SessionFactory {

  348.         public JGitSessionFactory(JGitSshClient client) {
  349.             super(client);
  350.         }

  351.         @Override
  352.         protected ClientSessionImpl doCreateSession(IoSession ioSession)
  353.                 throws Exception {
  354.             return new JGitClientSession(getClient(), ioSession);
  355.         }
  356.     }

  357.     /**
  358.      * A {@link KeyIdentityProvider} that iterates over the {@link Iterable}s
  359.      * returned by other {@link KeyIdentityProvider}s.
  360.      */
  361.     private static class CombinedKeyIdentityProvider
  362.             implements KeyIdentityProvider {

  363.         private final List<KeyIdentityProvider> providers;

  364.         public CombinedKeyIdentityProvider(KeyIdentityProvider... providers) {
  365.             this(Arrays.stream(providers).filter(Objects::nonNull)
  366.                     .collect(Collectors.toList()));
  367.         }

  368.         public CombinedKeyIdentityProvider(
  369.                 List<KeyIdentityProvider> providers) {
  370.             this.providers = providers;
  371.         }

  372.         @Override
  373.         public Iterable<KeyPair> loadKeys(SessionContext context) {
  374.             return () -> new Iterator<KeyPair>() {

  375.                 private Iterator<KeyIdentityProvider> factories = providers
  376.                         .iterator();
  377.                 private Iterator<KeyPair> current;

  378.                 private Boolean hasElement;

  379.                 @Override
  380.                 public boolean hasNext() {
  381.                     if (hasElement != null) {
  382.                         return hasElement.booleanValue();
  383.                     }
  384.                     while (current == null || !current.hasNext()) {
  385.                         if (factories.hasNext()) {
  386.                             try {
  387.                                 current = factories.next().loadKeys(context)
  388.                                         .iterator();
  389.                             } catch (IOException | GeneralSecurityException e) {
  390.                                 throw new RuntimeException(e);
  391.                             }
  392.                         } else {
  393.                             current = null;
  394.                             hasElement = Boolean.FALSE;
  395.                             return false;
  396.                         }
  397.                     }
  398.                     hasElement = Boolean.TRUE;
  399.                     return true;
  400.                 }

  401.                 @Override
  402.                 public KeyPair next() {
  403.                     if (hasElement == null && !hasNext()
  404.                             || !hasElement.booleanValue()) {
  405.                         throw new NoSuchElementException();
  406.                     }
  407.                     hasElement = null;
  408.                     KeyPair result;
  409.                     try {
  410.                         result = current.next();
  411.                     } catch (NoSuchElementException e) {
  412.                         result = null;
  413.                     }
  414.                     return result;
  415.                 }

  416.             };
  417.         }
  418.     }
  419. }