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.nio.file.Files;
52  import java.nio.file.InvalidPathException;
53  import java.nio.file.Path;
54  import java.nio.file.Paths;
55  import java.security.KeyPair;
56  import java.util.Arrays;
57  import java.util.Iterator;
58  import java.util.List;
59  import java.util.NoSuchElementException;
60  import java.util.Objects;
61  import java.util.stream.Collectors;
62  
63  import org.apache.sshd.client.SshClient;
64  import org.apache.sshd.client.config.hosts.HostConfigEntry;
65  import org.apache.sshd.client.future.ConnectFuture;
66  import org.apache.sshd.client.future.DefaultConnectFuture;
67  import org.apache.sshd.client.session.ClientSessionImpl;
68  import org.apache.sshd.client.session.SessionFactory;
69  import org.apache.sshd.common.config.keys.FilePasswordProvider;
70  import org.apache.sshd.common.future.SshFutureListener;
71  import org.apache.sshd.common.io.IoConnectFuture;
72  import org.apache.sshd.common.io.IoSession;
73  import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
74  import org.apache.sshd.common.keyprovider.KeyPairProvider;
75  import org.apache.sshd.common.session.helpers.AbstractSession;
76  import org.apache.sshd.common.util.ValidateUtils;
77  import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector;
78  import org.eclipse.jgit.internal.transport.sshd.proxy.Socks5ClientConnector;
79  import org.eclipse.jgit.transport.CredentialsProvider;
80  import org.eclipse.jgit.transport.SshConstants;
81  import org.eclipse.jgit.transport.sshd.KeyCache;
82  import org.eclipse.jgit.transport.sshd.ProxyData;
83  import org.eclipse.jgit.transport.sshd.ProxyDataFactory;
84  
85  /**
86   * Customized {@link SshClient} for JGit. It creates specialized
87   * {@link JGitClientSession}s that know about the {@link HostConfigEntry} they
88   * were created for, and it loads all KeyPair identities lazily.
89   */
90  public class JGitSshClient extends SshClient {
91  
92  	/**
93  	 * We need access to this during the constructor of the ClientSession,
94  	 * before setConnectAddress() can have been called. So we have to remember
95  	 * it in an attribute on the SshClient, from where we can then retrieve it.
96  	 */
97  	static final AttributeKey<HostConfigEntry> HOST_CONFIG_ENTRY = new AttributeKey<>();
98  
99  	static final AttributeKey<InetSocketAddress> ORIGINAL_REMOTE_ADDRESS = new AttributeKey<>();
100 
101 	/**
102 	 * An attribute key for the comma-separated list of default preferred
103 	 * authentication mechanisms.
104 	 */
105 	public static final AttributeKey<String> PREFERRED_AUTHENTICATIONS = new AttributeKey<>();
106 
107 	private KeyCache keyCache;
108 
109 	private CredentialsProvider credentialsProvider;
110 
111 	private ProxyDataFactory proxyDatabase;
112 
113 	@Override
114 	protected SessionFactory createSessionFactory() {
115 		// Override the parent's default
116 		return new JGitSessionFactory(this);
117 	}
118 
119 	@Override
120 	public ConnectFuture connect(HostConfigEntry hostConfig)
121 			throws IOException {
122 		if (connector == null) {
123 			throw new IllegalStateException("SshClient not started."); //$NON-NLS-1$
124 		}
125 		Objects.requireNonNull(hostConfig, "No host configuration"); //$NON-NLS-1$
126 		String host = ValidateUtils.checkNotNullAndNotEmpty(
127 				hostConfig.getHostName(), "No target host"); //$NON-NLS-1$
128 		int port = hostConfig.getPort();
129 		ValidateUtils.checkTrue(port > 0, "Invalid port: %d", port); //$NON-NLS-1$
130 		String userName = hostConfig.getUsername();
131 		InetSocketAddress address = new InetSocketAddress(host, port);
132 		ConnectFuture connectFuture = new DefaultConnectFuture(
133 				userName + '@' + address, null);
134 		SshFutureListener<IoConnectFuture> listener = createConnectCompletionListener(
135 				connectFuture, userName, address, hostConfig);
136 		// sshd needs some entries from the host config already in the
137 		// constructor of the session. Put those as properties on this client,
138 		// where it will find them. We can set the host config only once the
139 		// session object has been created.
140 		copyProperty(
141 				hostConfig.getProperty(SshConstants.PREFERRED_AUTHENTICATIONS,
142 						getAttribute(PREFERRED_AUTHENTICATIONS)),
143 				PREFERRED_AUTHS);
144 		setAttribute(HOST_CONFIG_ENTRY, hostConfig);
145 		setAttribute(ORIGINAL_REMOTE_ADDRESS, address);
146 		// Proxy support
147 		ProxyData proxy = getProxyData(address);
148 		if (proxy != null) {
149 			address = configureProxy(proxy, address);
150 			proxy.clearPassword();
151 		}
152 		connector.connect(address).addListener(listener);
153 		return connectFuture;
154 	}
155 
156 	private void copyProperty(String value, String key) {
157 		if (value != null && !value.isEmpty()) {
158 			getProperties().put(key, value);
159 		}
160 	}
161 
162 	private ProxyData getProxyData(InetSocketAddress remoteAddress) {
163 		ProxyDataFactory factory = getProxyDatabase();
164 		return factory == null ? null : factory.get(remoteAddress);
165 	}
166 
167 	private InetSocketAddress configureProxy(ProxyData proxyData,
168 			InetSocketAddress remoteAddress) {
169 		Proxy proxy = proxyData.getProxy();
170 		if (proxy.type() == Proxy.Type.DIRECT
171 				|| !(proxy.address() instanceof InetSocketAddress)) {
172 			return remoteAddress;
173 		}
174 		InetSocketAddress address = (InetSocketAddress) proxy.address();
175 		switch (proxy.type()) {
176 		case HTTP:
177 			setClientProxyConnector(
178 					new HttpClientConnector(address, remoteAddress,
179 							proxyData.getUser(), proxyData.getPassword()));
180 			return address;
181 		case SOCKS:
182 			setClientProxyConnector(
183 					new Socks5ClientConnector(address, remoteAddress,
184 							proxyData.getUser(), proxyData.getPassword()));
185 			return address;
186 		default:
187 			log.warn(format(SshdText.get().unknownProxyProtocol,
188 					proxy.type().name()));
189 			return remoteAddress;
190 		}
191 	}
192 
193 	private SshFutureListener<IoConnectFuture> createConnectCompletionListener(
194 			ConnectFuture connectFuture, String username,
195 			InetSocketAddress address, HostConfigEntry hostConfig) {
196 		return new SshFutureListener<IoConnectFuture>() {
197 
198 			@Override
199 			public void operationComplete(IoConnectFuture future) {
200 				if (future.isCanceled()) {
201 					connectFuture.cancel();
202 					return;
203 				}
204 				Throwable t = future.getException();
205 				if (t != null) {
206 					connectFuture.setException(t);
207 					return;
208 				}
209 				IoSession ioSession = future.getSession();
210 				try {
211 					JGitClientSession session = createSession(ioSession,
212 							username, address, hostConfig);
213 					connectFuture.setSession(session);
214 				} catch (RuntimeException e) {
215 					connectFuture.setException(e);
216 					ioSession.close(true);
217 				}
218 			}
219 
220 			@Override
221 			public String toString() {
222 				return "JGitSshClient$ConnectCompletionListener[" + username //$NON-NLS-1$
223 						+ '@' + address + ']';
224 			}
225 		};
226 	}
227 
228 	private JGitClientSession createSession(IoSession ioSession,
229 			String username, InetSocketAddress address,
230 			HostConfigEntry hostConfig) {
231 		AbstractSession rawSession = AbstractSession.getSession(ioSession);
232 		if (!(rawSession instanceof JGitClientSession)) {
233 			throw new IllegalStateException("Wrong session type: " //$NON-NLS-1$
234 					+ rawSession.getClass().getCanonicalName());
235 		}
236 		JGitClientSession session = (JGitClientSession) rawSession;
237 		session.setUsername(username);
238 		session.setConnectAddress(address);
239 		session.setHostConfigEntry(hostConfig);
240 		if (session.getCredentialsProvider() == null) {
241 			session.setCredentialsProvider(getCredentialsProvider());
242 		}
243 		int numberOfPasswordPrompts = getNumberOfPasswordPrompts(hostConfig);
244 		session.getProperties().put(PASSWORD_PROMPTS,
245 				Integer.valueOf(numberOfPasswordPrompts));
246 		FilePasswordProvider provider = getFilePasswordProvider();
247 		if (provider instanceof RepeatingFilePasswordProvider) {
248 			((RepeatingFilePasswordProvider) provider)
249 					.setAttempts(numberOfPasswordPrompts);
250 		}
251 		FileKeyPairProvider ourConfiguredKeysProvider = null;
252 		List<Path> identities = hostConfig.getIdentities().stream()
253 				.map(s -> {
254 					try {
255 						return Paths.get(s);
256 					} catch (InvalidPathException e) {
257 						log.warn(format(SshdText.get().configInvalidPath,
258 								SshConstants.IDENTITY_FILE, s), e);
259 						return null;
260 					}
261 				}).filter(p -> p != null && Files.exists(p))
262 				.collect(Collectors.toList());
263 		ourConfiguredKeysProvider = new CachingKeyPairProvider(identities,
264 				keyCache);
265 		ourConfiguredKeysProvider.setPasswordFinder(getFilePasswordProvider());
266 		if (hostConfig.isIdentitiesOnly()) {
267 			session.setKeyPairProvider(ourConfiguredKeysProvider);
268 		} else {
269 			KeyPairProvider defaultKeysProvider = getKeyPairProvider();
270 			if (defaultKeysProvider instanceof FileKeyPairProvider) {
271 				((FileKeyPairProvider) defaultKeysProvider)
272 						.setPasswordFinder(getFilePasswordProvider());
273 			}
274 			KeyPairProvider combinedProvider = new CombinedKeyPairProvider(
275 					ourConfiguredKeysProvider, defaultKeysProvider);
276 			session.setKeyPairProvider(combinedProvider);
277 		}
278 		return session;
279 	}
280 
281 	private int getNumberOfPasswordPrompts(HostConfigEntry hostConfig) {
282 		String prompts = hostConfig
283 				.getProperty(SshConstants.NUMBER_OF_PASSWORD_PROMPTS);
284 		if (prompts != null) {
285 			prompts = prompts.trim();
286 			int value = positive(prompts);
287 			if (value > 0) {
288 				return value;
289 			}
290 			log.warn(format(SshdText.get().configInvalidPositive,
291 					SshConstants.NUMBER_OF_PASSWORD_PROMPTS, prompts));
292 		}
293 		// Default for NumberOfPasswordPrompts according to
294 		// https://man.openbsd.org/ssh_config
295 		return 3;
296 	}
297 
298 	/**
299 	 * Set a cache for loaded keys. Newly discovered keys will be added when
300 	 * IdentityFile host entries from the ssh config file are used during
301 	 * session authentication.
302 	 *
303 	 * @param cache
304 	 *            to use
305 	 */
306 	public void setKeyCache(KeyCache cache) {
307 		keyCache = cache;
308 	}
309 
310 	/**
311 	 * Sets a {@link ProxyDataFactory} for connecting through proxies.
312 	 *
313 	 * @param factory
314 	 *            to use, or {@code null} if proxying is not desired or
315 	 *            supported
316 	 */
317 	public void setProxyDatabase(ProxyDataFactory factory) {
318 		proxyDatabase = factory;
319 	}
320 
321 	/**
322 	 * Retrieves the {@link ProxyDataFactory}.
323 	 *
324 	 * @return the factory, or {@code null} if none is set
325 	 */
326 	protected ProxyDataFactory getProxyDatabase() {
327 		return proxyDatabase;
328 	}
329 
330 	/**
331 	 * Sets the {@link CredentialsProvider} for this client.
332 	 *
333 	 * @param provider
334 	 *            to set
335 	 */
336 	public void setCredentialsProvider(CredentialsProvider provider) {
337 		credentialsProvider = provider;
338 	}
339 
340 	/**
341 	 * Retrieves the {@link CredentialsProvider} set for this client.
342 	 *
343 	 * @return the provider, or {@code null} if none is set.
344 	 */
345 	public CredentialsProvider getCredentialsProvider() {
346 		return credentialsProvider;
347 	}
348 
349 	/**
350 	 * A {@link SessionFactory} to create our own specialized
351 	 * {@link JGitClientSession}s.
352 	 */
353 	private static class JGitSessionFactory extends SessionFactory {
354 
355 		public JGitSessionFactory(JGitSshClient client) {
356 			super(client);
357 		}
358 
359 		@Override
360 		protected ClientSessionImpl doCreateSession(IoSession ioSession)
361 				throws Exception {
362 			return new JGitClientSession(getClient(), ioSession);
363 		}
364 	}
365 
366 	/**
367 	 * A {@link KeyPairProvider} that iterates over the {@link Iterable}s
368 	 * returned by other {@link KeyPairProvider}s.
369 	 */
370 	private static class CombinedKeyPairProvider implements KeyPairProvider {
371 
372 		private final List<KeyPairProvider> providers;
373 
374 		public CombinedKeyPairProvider(KeyPairProvider... providers) {
375 			this(Arrays.stream(providers).filter(Objects::nonNull)
376 					.collect(Collectors.toList()));
377 		}
378 
379 		public CombinedKeyPairProvider(List<KeyPairProvider> providers) {
380 			this.providers = providers;
381 		}
382 
383 		@Override
384 		public Iterable<String> getKeyTypes() {
385 			throw new UnsupportedOperationException(
386 					"Should not have been called in a ssh client"); //$NON-NLS-1$
387 		}
388 
389 		@Override
390 		public KeyPair loadKey(String type) {
391 			throw new UnsupportedOperationException(
392 					"Should not have been called in a ssh client"); //$NON-NLS-1$
393 		}
394 
395 		@Override
396 		public Iterable<KeyPair> loadKeys() {
397 			return () -> new Iterator<KeyPair>() {
398 
399 				private Iterator<KeyPairProvider> factories = providers.iterator();
400 				private Iterator<KeyPair> current;
401 
402 				private Boolean hasElement;
403 
404 				@Override
405 				public boolean hasNext() {
406 					if (hasElement != null) {
407 						return hasElement.booleanValue();
408 					}
409 					while (current == null || !current.hasNext()) {
410 						if (factories.hasNext()) {
411 							current = factories.next().loadKeys().iterator();
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 }