JGitSshClient.java

  1. /*
  2.  * Copyright (C) 2018, 2022 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.function.Supplier;
  34. import java.util.stream.Collectors;

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

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

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

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

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

  81.     /**
  82.      * An attribute key for the home directory.
  83.      */
  84.     public static final AttributeKey<Path> HOME_DIRECTORY = new AttributeKey<>();

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

  94.     private KeyCache keyCache;

  95.     private CredentialsProvider credentialsProvider;

  96.     private ProxyDataFactory proxyDatabase;

  97.     private Supplier<SshAgentFactory> agentFactorySupplier = () -> null;

  98.     @Override
  99.     protected SessionFactory createSessionFactory() {
  100.         // Override the parent's default
  101.         return new JGitSessionFactory(this);
  102.     }

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

  145.     private AttributeRepository chain(AttributeRepository self,
  146.             AttributeRepository parent) {
  147.         if (self == null) {
  148.             return Objects.requireNonNull(parent);
  149.         }
  150.         if (parent == null || parent == self) {
  151.             return self;
  152.         }
  153.         return new ChainingAttributes(self, parent);
  154.     }

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

  179.     private ProxyData getProxyData(InetSocketAddress remoteAddress) {
  180.         ProxyDataFactory factory = getProxyDatabase();
  181.         return factory == null ? null : factory.get(remoteAddress);
  182.     }

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

  212.     private SshFutureListener<IoConnectFuture> createConnectCompletionListener(
  213.             ConnectFuture connectFuture, String username,
  214.             InetSocketAddress address, HostConfigEntry hostConfig) {
  215.         return new SshFutureListener<>() {

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

  237.             @Override
  238.             public String toString() {
  239.                 return "JGitSshClient$ConnectCompletionListener[" + username //$NON-NLS-1$
  240.                         + '@' + address + ']';
  241.             }
  242.         };
  243.     }

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

  290.     private int getNumberOfPasswordPrompts(HostConfigEntry hostConfig) {
  291.         String prompts = hostConfig
  292.                 .getProperty(SshConstants.NUMBER_OF_PASSWORD_PROMPTS);
  293.         if (prompts != null) {
  294.             prompts = prompts.trim();
  295.             int value = positive(prompts);
  296.             if (value > 0) {
  297.                 return value;
  298.             }
  299.             log.warn(format(SshdText.get().configInvalidPositive,
  300.                     SshConstants.NUMBER_OF_PASSWORD_PROMPTS, prompts));
  301.         }
  302.         return PASSWORD_PROMPTS.getRequiredDefault().intValue();
  303.     }

  304.     /**
  305.      * Set a cache for loaded keys. Newly discovered keys will be added when
  306.      * IdentityFile host entries from the ssh config file are used during
  307.      * session authentication.
  308.      *
  309.      * @param cache
  310.      *            to use
  311.      */
  312.     public void setKeyCache(KeyCache cache) {
  313.         keyCache = cache;
  314.     }

  315.     /**
  316.      * Sets a {@link ProxyDataFactory} for connecting through proxies.
  317.      *
  318.      * @param factory
  319.      *            to use, or {@code null} if proxying is not desired or
  320.      *            supported
  321.      */
  322.     public void setProxyDatabase(ProxyDataFactory factory) {
  323.         proxyDatabase = factory;
  324.     }

  325.     /**
  326.      * Retrieves the {@link ProxyDataFactory}.
  327.      *
  328.      * @return the factory, or {@code null} if none is set
  329.      */
  330.     protected ProxyDataFactory getProxyDatabase() {
  331.         return proxyDatabase;
  332.     }

  333.     /**
  334.      * Sets the {@link CredentialsProvider} for this client.
  335.      *
  336.      * @param provider
  337.      *            to set
  338.      */
  339.     public void setCredentialsProvider(CredentialsProvider provider) {
  340.         credentialsProvider = provider;
  341.     }

  342.     /**
  343.      * Retrieves the {@link CredentialsProvider} set for this client.
  344.      *
  345.      * @return the provider, or {@code null} if none is set.
  346.      */
  347.     public CredentialsProvider getCredentialsProvider() {
  348.         return credentialsProvider;
  349.     }

  350.     @Override
  351.     public SshAgentFactory getAgentFactory() {
  352.         return agentFactorySupplier.get();
  353.     }

  354.     @Override
  355.     protected void checkConfig() {
  356.         // The super class requires channel factories for agent forwarding if a
  357.         // factory for an SSH agent is set. We haven't implemented this yet, and
  358.         // we don't do SSH agent forwarding for now. Unfortunately, there is no
  359.         // way to bypass this check in the super class except making
  360.         // getAgentFactory() return null until after the check.
  361.         super.checkConfig();
  362.         agentFactorySupplier = super::getAgentFactory;
  363.     }

  364.     /**
  365.      * A {@link SessionFactory} to create our own specialized
  366.      * {@link JGitClientSession}s.
  367.      */
  368.     private static class JGitSessionFactory extends SessionFactory {

  369.         public JGitSessionFactory(JGitSshClient client) {
  370.             super(client);
  371.         }

  372.         @Override
  373.         protected ClientSessionImpl doCreateSession(IoSession ioSession)
  374.                 throws Exception {
  375.             return new JGitClientSession(getClient(), ioSession);
  376.         }
  377.     }

  378.     /**
  379.      * A {@link KeyIdentityProvider} that iterates over the {@link Iterable}s
  380.      * returned by other {@link KeyIdentityProvider}s.
  381.      */
  382.     private static class CombinedKeyIdentityProvider
  383.             implements KeyIdentityProvider {

  384.         private final List<KeyIdentityProvider> providers;

  385.         public CombinedKeyIdentityProvider(KeyIdentityProvider... providers) {
  386.             this(Arrays.stream(providers).filter(Objects::nonNull)
  387.                     .collect(Collectors.toList()));
  388.         }

  389.         public CombinedKeyIdentityProvider(
  390.                 List<KeyIdentityProvider> providers) {
  391.             this.providers = providers;
  392.         }

  393.         @Override
  394.         public Iterable<KeyPair> loadKeys(SessionContext context) {
  395.             return () -> new Iterator<>() {

  396.                 private Iterator<KeyIdentityProvider> factories = providers
  397.                         .iterator();
  398.                 private Iterator<KeyPair> current;

  399.                 private Boolean hasElement;

  400.                 @Override
  401.                 public boolean hasNext() {
  402.                     if (hasElement != null) {
  403.                         return hasElement.booleanValue();
  404.                     }
  405.                     while (current == null || !current.hasNext()) {
  406.                         if (factories.hasNext()) {
  407.                             try {
  408.                                 current = factories.next().loadKeys(context)
  409.                                         .iterator();
  410.                             } catch (IOException | GeneralSecurityException e) {
  411.                                 throw new RuntimeException(e);
  412.                             }
  413.                         } else {
  414.                             current = null;
  415.                             hasElement = Boolean.FALSE;
  416.                             return false;
  417.                         }
  418.                     }
  419.                     hasElement = Boolean.TRUE;
  420.                     return true;
  421.                 }

  422.                 @Override
  423.                 public KeyPair next() {
  424.                     if ((hasElement == null && !hasNext())
  425.                             || !hasElement.booleanValue()) {
  426.                         throw new NoSuchElementException();
  427.                     }
  428.                     hasElement = null;
  429.                     KeyPair result;
  430.                     try {
  431.                         result = current.next();
  432.                     } catch (NoSuchElementException e) {
  433.                         result = null;
  434.                     }
  435.                     return result;
  436.                 }

  437.             };
  438.         }
  439.     }
  440. }