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.AbstractResourceKeyPairProvider;
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 passwordProvider = getFilePasswordProvider();
247 		if (passwordProvider instanceof RepeatingFilePasswordProvider) {
248 			((RepeatingFilePasswordProvider) passwordProvider)
249 					.setAttempts(numberOfPasswordPrompts);
250 		}
251 		List<Path> identities = hostConfig.getIdentities().stream()
252 				.map(s -> {
253 					try {
254 						return Paths.get(s);
255 					} catch (InvalidPathException e) {
256 						log.warn(format(SshdText.get().configInvalidPath,
257 								SshConstants.IDENTITY_FILE, s), e);
258 						return null;
259 					}
260 				}).filter(p -> p != null && Files.exists(p))
261 				.collect(Collectors.toList());
262 		CachingKeyPairProvider ourConfiguredKeysProvider = new CachingKeyPairProvider(
263 				identities, keyCache);
264 		ourConfiguredKeysProvider.setPasswordFinder(passwordProvider);
265 		if (hostConfig.isIdentitiesOnly()) {
266 			session.setKeyPairProvider(ourConfiguredKeysProvider);
267 		} else {
268 			KeyPairProvider defaultKeysProvider = getKeyPairProvider();
269 			if (defaultKeysProvider instanceof AbstractResourceKeyPairProvider<?>) {
270 				((AbstractResourceKeyPairProvider<?>) defaultKeysProvider)
271 						.setPasswordFinder(passwordProvider);
272 			}
273 			KeyPairProvider combinedProvider = new CombinedKeyPairProvider(
274 					ourConfiguredKeysProvider, defaultKeysProvider);
275 			session.setKeyPairProvider(combinedProvider);
276 		}
277 		return session;
278 	}
279 
280 	private int getNumberOfPasswordPrompts(HostConfigEntry hostConfig) {
281 		String prompts = hostConfig
282 				.getProperty(SshConstants.NUMBER_OF_PASSWORD_PROMPTS);
283 		if (prompts != null) {
284 			prompts = prompts.trim();
285 			int value = positive(prompts);
286 			if (value > 0) {
287 				return value;
288 			}
289 			log.warn(format(SshdText.get().configInvalidPositive,
290 					SshConstants.NUMBER_OF_PASSWORD_PROMPTS, prompts));
291 		}
292 		// Default for NumberOfPasswordPrompts according to
293 		// https://man.openbsd.org/ssh_config
294 		return 3;
295 	}
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 	/**
310 	 * Sets a {@link ProxyDataFactory} for connecting through proxies.
311 	 *
312 	 * @param factory
313 	 *            to use, or {@code null} if proxying is not desired or
314 	 *            supported
315 	 */
316 	public void setProxyDatabase(ProxyDataFactory factory) {
317 		proxyDatabase = factory;
318 	}
319 
320 	/**
321 	 * Retrieves the {@link ProxyDataFactory}.
322 	 *
323 	 * @return the factory, or {@code null} if none is set
324 	 */
325 	protected ProxyDataFactory getProxyDatabase() {
326 		return proxyDatabase;
327 	}
328 
329 	/**
330 	 * Sets the {@link CredentialsProvider} for this client.
331 	 *
332 	 * @param provider
333 	 *            to set
334 	 */
335 	public void setCredentialsProvider(CredentialsProvider provider) {
336 		credentialsProvider = provider;
337 	}
338 
339 	/**
340 	 * Retrieves the {@link CredentialsProvider} set for this client.
341 	 *
342 	 * @return the provider, or {@code null} if none is set.
343 	 */
344 	public CredentialsProvider getCredentialsProvider() {
345 		return credentialsProvider;
346 	}
347 
348 	/**
349 	 * A {@link SessionFactory} to create our own specialized
350 	 * {@link JGitClientSession}s.
351 	 */
352 	private static class JGitSessionFactory extends SessionFactory {
353 
354 		public JGitSessionFactory(JGitSshClient client) {
355 			super(client);
356 		}
357 
358 		@Override
359 		protected ClientSessionImpl doCreateSession(IoSession ioSession)
360 				throws Exception {
361 			return new JGitClientSession(getClient(), ioSession);
362 		}
363 	}
364 
365 	/**
366 	 * A {@link KeyPairProvider} that iterates over the {@link Iterable}s
367 	 * returned by other {@link KeyPairProvider}s.
368 	 */
369 	private static class CombinedKeyPairProvider implements KeyPairProvider {
370 
371 		private final List<KeyPairProvider> providers;
372 
373 		public CombinedKeyPairProvider(KeyPairProvider... providers) {
374 			this(Arrays.stream(providers).filter(Objects::nonNull)
375 					.collect(Collectors.toList()));
376 		}
377 
378 		public CombinedKeyPairProvider(List<KeyPairProvider> providers) {
379 			this.providers = providers;
380 		}
381 
382 		@Override
383 		public Iterable<String> getKeyTypes() {
384 			throw new UnsupportedOperationException(
385 					"Should not have been called in a ssh client"); //$NON-NLS-1$
386 		}
387 
388 		@Override
389 		public KeyPair loadKey(String type) {
390 			throw new UnsupportedOperationException(
391 					"Should not have been called in a ssh client"); //$NON-NLS-1$
392 		}
393 
394 		@Override
395 		public Iterable<KeyPair> loadKeys() {
396 			return () -> new Iterator<KeyPair>() {
397 
398 				private Iterator<KeyPairProvider> factories = providers.iterator();
399 				private Iterator<KeyPair> current;
400 
401 				private Boolean hasElement;
402 
403 				@Override
404 				public boolean hasNext() {
405 					if (hasElement != null) {
406 						return hasElement.booleanValue();
407 					}
408 					while (current == null || !current.hasNext()) {
409 						if (factories.hasNext()) {
410 							current = factories.next().loadKeys().iterator();
411 						} else {
412 							current = null;
413 							hasElement = Boolean.FALSE;
414 							return false;
415 						}
416 					}
417 					hasElement = Boolean.TRUE;
418 					return true;
419 				}
420 
421 				@Override
422 				public KeyPair next() {
423 					if (hasElement == null && !hasNext()
424 							|| !hasElement.booleanValue()) {
425 						throw new NoSuchElementException();
426 					}
427 					hasElement = null;
428 					KeyPair result;
429 					try {
430 						result = current.next();
431 					} catch (NoSuchElementException e) {
432 						result = null;
433 					}
434 					return result;
435 				}
436 
437 			};
438 		}
439 
440 	}
441 }