View Javadoc
1   /*
2    * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch>
3    * and other copyright owners as documented in the project's IP log.
4    *
5    * This program and the accompanying materials are made available
6    * under the terms of the Eclipse Distribution License v1.0 which
7    * accompanies this distribution, is reproduced below, and is
8    * available at http://www.eclipse.org/org/documents/edl-v10.php
9    *
10   * All rights reserved.
11   *
12   * Redistribution and use in source and binary forms, with or
13   * without modification, are permitted provided that the following
14   * conditions are met:
15   *
16   * - Redistributions of source code must retain the above copyright
17   *   notice, this list of conditions and the following disclaimer.
18   *
19   * - Redistributions in binary form must reproduce the above
20   *   copyright notice, this list of conditions and the following
21   *   disclaimer in the documentation and/or other materials provided
22   *   with the distribution.
23   *
24   * - Neither the name of the Eclipse Foundation, Inc. nor the
25   *   names of its contributors may be used to endorse or promote
26   *   products derived from this software without specific prior
27   *   written permission.
28   *
29   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
30   * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
31   * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
32   * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
33   * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
34   * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
35   * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
36   * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
37   * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
38   * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
39   * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
40   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
41   * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
42   */
43  package org.eclipse.jgit.internal.transport.sshd;
44  
45  import static java.text.MessageFormat.format;
46  import static org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile.positive;
47  
48  import java.io.IOException;
49  import java.net.InetSocketAddress;
50  import java.net.Proxy;
51  import java.net.SocketAddress;
52  import java.nio.file.Files;
53  import java.nio.file.InvalidPathException;
54  import java.nio.file.Path;
55  import java.nio.file.Paths;
56  import java.security.GeneralSecurityException;
57  import java.security.KeyPair;
58  import java.util.Arrays;
59  import java.util.Iterator;
60  import java.util.List;
61  import java.util.NoSuchElementException;
62  import java.util.Objects;
63  import java.util.stream.Collectors;
64  
65  import org.apache.sshd.client.SshClient;
66  import org.apache.sshd.client.config.hosts.HostConfigEntry;
67  import org.apache.sshd.client.future.ConnectFuture;
68  import org.apache.sshd.client.future.DefaultConnectFuture;
69  import org.apache.sshd.client.session.ClientSessionImpl;
70  import org.apache.sshd.client.session.SessionFactory;
71  import org.apache.sshd.common.AttributeRepository;
72  import org.apache.sshd.common.config.keys.FilePasswordProvider;
73  import org.apache.sshd.common.future.SshFutureListener;
74  import org.apache.sshd.common.io.IoConnectFuture;
75  import org.apache.sshd.common.io.IoSession;
76  import org.apache.sshd.common.keyprovider.AbstractResourceKeyPairProvider;
77  import org.apache.sshd.common.keyprovider.KeyIdentityProvider;
78  import org.apache.sshd.common.session.SessionContext;
79  import org.apache.sshd.common.session.helpers.AbstractSession;
80  import org.apache.sshd.common.util.ValidateUtils;
81  import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector;
82  import org.eclipse.jgit.internal.transport.sshd.proxy.Socks5ClientConnector;
83  import org.eclipse.jgit.transport.CredentialsProvider;
84  import org.eclipse.jgit.transport.SshConstants;
85  import org.eclipse.jgit.transport.sshd.KeyCache;
86  import org.eclipse.jgit.transport.sshd.ProxyData;
87  import org.eclipse.jgit.transport.sshd.ProxyDataFactory;
88  
89  /**
90   * Customized {@link SshClient} for JGit. It creates specialized
91   * {@link JGitClientSession}s that know about the {@link HostConfigEntry} they
92   * were created for, and it loads all KeyPair identities lazily.
93   */
94  public class JGitSshClient extends SshClient {
95  
96  	/**
97  	 * We need access to this during the constructor of the ClientSession,
98  	 * before setConnectAddress() can have been called. So we have to remember
99  	 * it in an attribute on the SshClient, from where we can then retrieve it.
100 	 */
101 	static final AttributeKey<HostConfigEntry> HOST_CONFIG_ENTRY = new AttributeKey<>();
102 
103 	static final AttributeKey<InetSocketAddress> ORIGINAL_REMOTE_ADDRESS = new AttributeKey<>();
104 
105 	/**
106 	 * An attribute key for the comma-separated list of default preferred
107 	 * authentication mechanisms.
108 	 */
109 	public static final AttributeKey<String> PREFERRED_AUTHENTICATIONS = new AttributeKey<>();
110 
111 	private KeyCache keyCache;
112 
113 	private CredentialsProvider credentialsProvider;
114 
115 	private ProxyDataFactory proxyDatabase;
116 
117 	@Override
118 	protected SessionFactory createSessionFactory() {
119 		// Override the parent's default
120 		return new JGitSessionFactory(this);
121 	}
122 
123 	@Override
124 	public ConnectFuture connect(HostConfigEntry hostConfig,
125 			AttributeRepository context, SocketAddress localAddress)
126 			throws IOException {
127 		if (connector == null) {
128 			throw new IllegalStateException("SshClient not started."); //$NON-NLS-1$
129 		}
130 		Objects.requireNonNull(hostConfig, "No host configuration"); //$NON-NLS-1$
131 		String host = ValidateUtils.checkNotNullAndNotEmpty(
132 				hostConfig.getHostName(), "No target host"); //$NON-NLS-1$
133 		int port = hostConfig.getPort();
134 		ValidateUtils.checkTrue(port > 0, "Invalid port: %d", port); //$NON-NLS-1$
135 		String userName = hostConfig.getUsername();
136 		InetSocketAddress address = new InetSocketAddress(host, port);
137 		ConnectFuture connectFuture = new DefaultConnectFuture(
138 				userName + '@' + address, null);
139 		SshFutureListener<IoConnectFuture> listener = createConnectCompletionListener(
140 				connectFuture, userName, address, hostConfig);
141 		// sshd needs some entries from the host config already in the
142 		// constructor of the session. Put those as properties on this client,
143 		// where it will find them. We can set the host config only once the
144 		// session object has been created.
145 		copyProperty(
146 				hostConfig.getProperty(SshConstants.PREFERRED_AUTHENTICATIONS,
147 						getAttribute(PREFERRED_AUTHENTICATIONS)),
148 				PREFERRED_AUTHS);
149 		setAttribute(HOST_CONFIG_ENTRY, hostConfig);
150 		setAttribute(ORIGINAL_REMOTE_ADDRESS, address);
151 		// Proxy support
152 		ProxyData proxy = getProxyData(address);
153 		if (proxy != null) {
154 			address = configureProxy(proxy, address);
155 			proxy.clearPassword();
156 		}
157 		connector.connect(address, this, localAddress).addListener(listener);
158 		return connectFuture;
159 	}
160 
161 	private void copyProperty(String value, String key) {
162 		if (value != null && !value.isEmpty()) {
163 			getProperties().put(key, value);
164 		}
165 	}
166 
167 	private ProxyData getProxyData(InetSocketAddress remoteAddress) {
168 		ProxyDataFactory factory = getProxyDatabase();
169 		return factory == null ? null : factory.get(remoteAddress);
170 	}
171 
172 	private InetSocketAddress configureProxy(ProxyData proxyData,
173 			InetSocketAddress remoteAddress) {
174 		Proxy proxy = proxyData.getProxy();
175 		if (proxy.type() == Proxy.Type.DIRECT
176 				|| !(proxy.address() instanceof InetSocketAddress)) {
177 			return remoteAddress;
178 		}
179 		InetSocketAddress address = (InetSocketAddress) proxy.address();
180 		switch (proxy.type()) {
181 		case HTTP:
182 			setClientProxyConnector(
183 					new HttpClientConnector(address, remoteAddress,
184 							proxyData.getUser(), proxyData.getPassword()));
185 			return address;
186 		case SOCKS:
187 			setClientProxyConnector(
188 					new Socks5ClientConnector(address, remoteAddress,
189 							proxyData.getUser(), proxyData.getPassword()));
190 			return address;
191 		default:
192 			log.warn(format(SshdText.get().unknownProxyProtocol,
193 					proxy.type().name()));
194 			return remoteAddress;
195 		}
196 	}
197 
198 	private SshFutureListener<IoConnectFuture> createConnectCompletionListener(
199 			ConnectFuture connectFuture, String username,
200 			InetSocketAddress address, HostConfigEntry hostConfig) {
201 		return new SshFutureListener<IoConnectFuture>() {
202 
203 			@Override
204 			public void operationComplete(IoConnectFuture future) {
205 				if (future.isCanceled()) {
206 					connectFuture.cancel();
207 					return;
208 				}
209 				Throwable t = future.getException();
210 				if (t != null) {
211 					connectFuture.setException(t);
212 					return;
213 				}
214 				IoSession ioSession = future.getSession();
215 				try {
216 					JGitClientSession session = createSession(ioSession,
217 							username, address, hostConfig);
218 					connectFuture.setSession(session);
219 				} catch (RuntimeException e) {
220 					connectFuture.setException(e);
221 					ioSession.close(true);
222 				}
223 			}
224 
225 			@Override
226 			public String toString() {
227 				return "JGitSshClient$ConnectCompletionListener[" + username //$NON-NLS-1$
228 						+ '@' + address + ']';
229 			}
230 		};
231 	}
232 
233 	private JGitClientSession createSession(IoSession ioSession,
234 			String username, InetSocketAddress address,
235 			HostConfigEntry hostConfig) {
236 		AbstractSession rawSession = AbstractSession.getSession(ioSession);
237 		if (!(rawSession instanceof JGitClientSession)) {
238 			throw new IllegalStateException("Wrong session type: " //$NON-NLS-1$
239 					+ rawSession.getClass().getCanonicalName());
240 		}
241 		JGitClientSession session = (JGitClientSession) rawSession;
242 		session.setUsername(username);
243 		session.setConnectAddress(address);
244 		session.setHostConfigEntry(hostConfig);
245 		if (session.getCredentialsProvider() == null) {
246 			session.setCredentialsProvider(getCredentialsProvider());
247 		}
248 		int numberOfPasswordPrompts = getNumberOfPasswordPrompts(hostConfig);
249 		session.getProperties().put(PASSWORD_PROMPTS,
250 				Integer.valueOf(numberOfPasswordPrompts));
251 		FilePasswordProvider passwordProvider = getFilePasswordProvider();
252 		if (passwordProvider instanceof RepeatingFilePasswordProvider) {
253 			((RepeatingFilePasswordProvider) passwordProvider)
254 					.setAttempts(numberOfPasswordPrompts);
255 		}
256 		List<Path> identities = hostConfig.getIdentities().stream()
257 				.map(s -> {
258 					try {
259 						return Paths.get(s);
260 					} catch (InvalidPathException e) {
261 						log.warn(format(SshdText.get().configInvalidPath,
262 								SshConstants.IDENTITY_FILE, s), e);
263 						return null;
264 					}
265 				}).filter(p -> p != null && Files.exists(p))
266 				.collect(Collectors.toList());
267 		CachingKeyPairProvider ourConfiguredKeysProvider = new CachingKeyPairProvider(
268 				identities, keyCache);
269 		ourConfiguredKeysProvider.setPasswordFinder(passwordProvider);
270 		if (hostConfig.isIdentitiesOnly()) {
271 			session.setKeyIdentityProvider(ourConfiguredKeysProvider);
272 		} else {
273 			KeyIdentityProvider defaultKeysProvider = getKeyIdentityProvider();
274 			if (defaultKeysProvider instanceof AbstractResourceKeyPairProvider<?>) {
275 				((AbstractResourceKeyPairProvider<?>) defaultKeysProvider)
276 						.setPasswordFinder(passwordProvider);
277 			}
278 			KeyIdentityProvider combinedProvider = new CombinedKeyIdentityProvider(
279 					ourConfiguredKeysProvider, defaultKeysProvider);
280 			session.setKeyIdentityProvider(combinedProvider);
281 		}
282 		return session;
283 	}
284 
285 	private int getNumberOfPasswordPrompts(HostConfigEntry hostConfig) {
286 		String prompts = hostConfig
287 				.getProperty(SshConstants.NUMBER_OF_PASSWORD_PROMPTS);
288 		if (prompts != null) {
289 			prompts = prompts.trim();
290 			int value = positive(prompts);
291 			if (value > 0) {
292 				return value;
293 			}
294 			log.warn(format(SshdText.get().configInvalidPositive,
295 					SshConstants.NUMBER_OF_PASSWORD_PROMPTS, prompts));
296 		}
297 		// Default for NumberOfPasswordPrompts according to
298 		// https://man.openbsd.org/ssh_config
299 		return 3;
300 	}
301 
302 	/**
303 	 * Set a cache for loaded keys. Newly discovered keys will be added when
304 	 * IdentityFile host entries from the ssh config file are used during
305 	 * session authentication.
306 	 *
307 	 * @param cache
308 	 *            to use
309 	 */
310 	public void setKeyCache(KeyCache cache) {
311 		keyCache = cache;
312 	}
313 
314 	/**
315 	 * Sets a {@link ProxyDataFactory} for connecting through proxies.
316 	 *
317 	 * @param factory
318 	 *            to use, or {@code null} if proxying is not desired or
319 	 *            supported
320 	 */
321 	public void setProxyDatabase(ProxyDataFactory factory) {
322 		proxyDatabase = factory;
323 	}
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 	/**
335 	 * Sets the {@link CredentialsProvider} for this client.
336 	 *
337 	 * @param provider
338 	 *            to set
339 	 */
340 	public void setCredentialsProvider(CredentialsProvider provider) {
341 		credentialsProvider = provider;
342 	}
343 
344 	/**
345 	 * Retrieves the {@link CredentialsProvider} set for this client.
346 	 *
347 	 * @return the provider, or {@code null} if none is set.
348 	 */
349 	public CredentialsProvider getCredentialsProvider() {
350 		return credentialsProvider;
351 	}
352 
353 	/**
354 	 * A {@link SessionFactory} to create our own specialized
355 	 * {@link JGitClientSession}s.
356 	 */
357 	private static class JGitSessionFactory extends SessionFactory {
358 
359 		public JGitSessionFactory(JGitSshClient client) {
360 			super(client);
361 		}
362 
363 		@Override
364 		protected ClientSessionImpl doCreateSession(IoSession ioSession)
365 				throws Exception {
366 			return new JGitClientSession(getClient(), ioSession);
367 		}
368 	}
369 
370 	/**
371 	 * A {@link KeyIdentityProvider} that iterates over the {@link Iterable}s
372 	 * returned by other {@link KeyIdentityProvider}s.
373 	 */
374 	private static class CombinedKeyIdentityProvider
375 			implements KeyIdentityProvider {
376 
377 		private final List<KeyIdentityProvider> providers;
378 
379 		public CombinedKeyIdentityProvider(KeyIdentityProvider... providers) {
380 			this(Arrays.stream(providers).filter(Objects::nonNull)
381 					.collect(Collectors.toList()));
382 		}
383 
384 		public CombinedKeyIdentityProvider(
385 				List<KeyIdentityProvider> providers) {
386 			this.providers = providers;
387 		}
388 
389 		@Override
390 		public Iterable<KeyPair> loadKeys(SessionContext context) {
391 			return () -> new Iterator<KeyPair>() {
392 
393 				private Iterator<KeyIdentityProvider> factories = providers
394 						.iterator();
395 				private Iterator<KeyPair> current;
396 
397 				private Boolean hasElement;
398 
399 				@Override
400 				public boolean hasNext() {
401 					if (hasElement != null) {
402 						return hasElement.booleanValue();
403 					}
404 					while (current == null || !current.hasNext()) {
405 						if (factories.hasNext()) {
406 							try {
407 								current = factories.next().loadKeys(context)
408 										.iterator();
409 							} catch (IOException | GeneralSecurityException e) {
410 								throw new RuntimeException(e);
411 							}
412 						} else {
413 							current = null;
414 							hasElement = Boolean.FALSE;
415 							return false;
416 						}
417 					}
418 					hasElement = Boolean.TRUE;
419 					return true;
420 				}
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 
441 	}
442 }