View Javadoc
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  
12  import static java.text.MessageFormat.format;
13  import static org.apache.sshd.core.CoreModuleProperties.PASSWORD_PROMPTS;
14  import static org.apache.sshd.core.CoreModuleProperties.PREFERRED_AUTHS;
15  import static org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile.positive;
16  
17  import java.io.IOException;
18  import java.net.InetSocketAddress;
19  import java.net.Proxy;
20  import java.net.SocketAddress;
21  import java.nio.file.Files;
22  import java.nio.file.InvalidPathException;
23  import java.nio.file.Path;
24  import java.nio.file.Paths;
25  import java.security.GeneralSecurityException;
26  import java.security.KeyPair;
27  import java.util.Arrays;
28  import java.util.Collections;
29  import java.util.HashMap;
30  import java.util.Iterator;
31  import java.util.List;
32  import java.util.Map;
33  import java.util.NoSuchElementException;
34  import java.util.Objects;
35  import java.util.stream.Collectors;
36  
37  import org.apache.sshd.client.SshClient;
38  import org.apache.sshd.client.config.hosts.HostConfigEntry;
39  import org.apache.sshd.client.future.ConnectFuture;
40  import org.apache.sshd.client.future.DefaultConnectFuture;
41  import org.apache.sshd.client.session.ClientSessionImpl;
42  import org.apache.sshd.client.session.SessionFactory;
43  import org.apache.sshd.common.AttributeRepository;
44  import org.apache.sshd.common.config.keys.FilePasswordProvider;
45  import org.apache.sshd.common.future.SshFutureListener;
46  import org.apache.sshd.common.io.IoConnectFuture;
47  import org.apache.sshd.common.io.IoSession;
48  import org.apache.sshd.common.keyprovider.AbstractResourceKeyPairProvider;
49  import org.apache.sshd.common.keyprovider.KeyIdentityProvider;
50  import org.apache.sshd.common.session.SessionContext;
51  import org.apache.sshd.common.session.helpers.AbstractSession;
52  import org.apache.sshd.common.util.ValidateUtils;
53  import org.apache.sshd.common.util.net.SshdSocketAddress;
54  import org.eclipse.jgit.internal.transport.sshd.JGitClientSession.ChainingAttributes;
55  import org.eclipse.jgit.internal.transport.sshd.JGitClientSession.SessionAttributes;
56  import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector;
57  import org.eclipse.jgit.internal.transport.sshd.proxy.Socks5ClientConnector;
58  import org.eclipse.jgit.transport.CredentialsProvider;
59  import org.eclipse.jgit.transport.SshConstants;
60  import org.eclipse.jgit.transport.sshd.KeyCache;
61  import org.eclipse.jgit.transport.sshd.ProxyData;
62  import org.eclipse.jgit.transport.sshd.ProxyDataFactory;
63  import org.eclipse.jgit.util.StringUtils;
64  
65  /**
66   * Customized {@link SshClient} for JGit. It creates specialized
67   * {@link JGitClientSession}s that know about the {@link HostConfigEntry} they
68   * were created for, and it loads all KeyPair identities lazily.
69   */
70  public class JGitSshClient extends SshClient {
71  
72  	/**
73  	 * We need access to this during the constructor of the ClientSession,
74  	 * before setConnectAddress() can have been called. So we have to remember
75  	 * it in an attribute on the SshClient, from where we can then retrieve it.
76  	 */
77  	static final AttributeKey<HostConfigEntry> HOST_CONFIG_ENTRY = new AttributeKey<>();
78  
79  	static final AttributeKey<InetSocketAddress> ORIGINAL_REMOTE_ADDRESS = new AttributeKey<>();
80  
81  	/**
82  	 * An attribute key for the comma-separated list of default preferred
83  	 * authentication mechanisms.
84  	 */
85  	public static final AttributeKey<String> PREFERRED_AUTHENTICATIONS = new AttributeKey<>();
86  
87  	/**
88  	 * An attribute key for storing an alternate local address to connect to if
89  	 * a local forward from a ProxyJump ssh config is present. If set,
90  	 * {@link #connect(HostConfigEntry, AttributeRepository, SocketAddress)}
91  	 * will not connect to the address obtained from the {@link HostConfigEntry}
92  	 * but to the address stored in this key (which is assumed to forward the
93  	 * {@code HostConfigEntry} address).
94  	 */
95  	public static final AttributeKey<SshdSocketAddress> LOCAL_FORWARD_ADDRESS = new AttributeKey<>();
96  
97  	private KeyCache keyCache;
98  
99  	private CredentialsProvider credentialsProvider;
100 
101 	private ProxyDataFactory proxyDatabase;
102 
103 	@Override
104 	protected SessionFactory createSessionFactory() {
105 		// Override the parent's default
106 		return new JGitSessionFactory(this);
107 	}
108 
109 	@Override
110 	public ConnectFuture connect(HostConfigEntry hostConfig,
111 			AttributeRepository context, SocketAddress localAddress)
112 			throws IOException {
113 		if (connector == null) {
114 			throw new IllegalStateException("SshClient not started."); //$NON-NLS-1$
115 		}
116 		Objects.requireNonNull(hostConfig, "No host configuration"); //$NON-NLS-1$
117 		String originalHost = ValidateUtils.checkNotNullAndNotEmpty(
118 				hostConfig.getHostName(), "No target host"); //$NON-NLS-1$
119 		int originalPort = hostConfig.getPort();
120 		ValidateUtils.checkTrue(originalPort > 0, "Invalid port: %d", //$NON-NLS-1$
121 				originalPort);
122 		InetSocketAddress originalAddress = new InetSocketAddress(originalHost,
123 				originalPort);
124 		InetSocketAddress targetAddress = originalAddress;
125 		String userName = hostConfig.getUsername();
126 		String id = userName + '@' + originalAddress;
127 		AttributeRepository attributes = chain(context, this);
128 		SshdSocketAddress localForward = attributes
129 				.resolveAttribute(LOCAL_FORWARD_ADDRESS);
130 		if (localForward != null) {
131 			targetAddress = new InetSocketAddress(localForward.getHostName(),
132 					localForward.getPort());
133 			id += '/' + targetAddress.toString();
134 		}
135 		ConnectFuture connectFuture = new DefaultConnectFuture(id, null);
136 		SshFutureListener<IoConnectFuture> listener = createConnectCompletionListener(
137 				connectFuture, userName, originalAddress, hostConfig);
138 		attributes = sessionAttributes(attributes, hostConfig, originalAddress);
139 		// Proxy support
140 		if (localForward == null) {
141 			ProxyData proxy = getProxyData(targetAddress);
142 			if (proxy != null) {
143 				targetAddress = configureProxy(proxy, targetAddress);
144 				proxy.clearPassword();
145 			}
146 		}
147 		connector.connect(targetAddress, attributes, localAddress)
148 				.addListener(listener);
149 		return connectFuture;
150 	}
151 
152 	private AttributeRepository chain(AttributeRepository self,
153 			AttributeRepository parent) {
154 		if (self == null) {
155 			return Objects.requireNonNull(parent);
156 		}
157 		if (parent == null || parent == self) {
158 			return self;
159 		}
160 		return new ChainingAttributes(self, parent);
161 	}
162 
163 	private AttributeRepository sessionAttributes(AttributeRepository parent,
164 			HostConfigEntry hostConfig, InetSocketAddress originalAddress) {
165 		// sshd needs some entries from the host config already in the
166 		// constructor of the session. Put those into a dedicated
167 		// AttributeRepository for the new session where it will find them.
168 		// We can set the host config only once the session object has been
169 		// created.
170 		Map<AttributeKey<?>, Object> data = new HashMap<>();
171 		data.put(HOST_CONFIG_ENTRY, hostConfig);
172 		data.put(ORIGINAL_REMOTE_ADDRESS, originalAddress);
173 		data.put(TARGET_SERVER, new SshdSocketAddress(originalAddress));
174 		String preferredAuths = hostConfig.getProperty(
175 				SshConstants.PREFERRED_AUTHENTICATIONS,
176 				resolveAttribute(PREFERRED_AUTHENTICATIONS));
177 		if (!StringUtils.isEmptyOrNull(preferredAuths)) {
178 			data.put(SessionAttributes.PROPERTIES,
179 					Collections.singletonMap(
180 							PREFERRED_AUTHS.getName(),
181 							preferredAuths));
182 		}
183 		return new SessionAttributes(
184 				AttributeRepository.ofAttributesMap(data),
185 				parent, this);
186 	}
187 
188 	private ProxyData getProxyData(InetSocketAddress remoteAddress) {
189 		ProxyDataFactory factory = getProxyDatabase();
190 		return factory == null ? null : factory.get(remoteAddress);
191 	}
192 
193 	private InetSocketAddress configureProxy(ProxyData proxyData,
194 			InetSocketAddress remoteAddress) {
195 		Proxy proxy = proxyData.getProxy();
196 		if (proxy.type() == Proxy.Type.DIRECT
197 				|| !(proxy.address() instanceof InetSocketAddress)) {
198 			return remoteAddress;
199 		}
200 		InetSocketAddress address = (InetSocketAddress) proxy.address();
201 		if (address.isUnresolved()) {
202 			address = new InetSocketAddress(address.getHostName(),
203 					address.getPort());
204 		}
205 		switch (proxy.type()) {
206 		case HTTP:
207 			setClientProxyConnector(
208 					new HttpClientConnector(address, remoteAddress,
209 							proxyData.getUser(), proxyData.getPassword()));
210 			return address;
211 		case SOCKS:
212 			setClientProxyConnector(
213 					new Socks5ClientConnector(address, remoteAddress,
214 							proxyData.getUser(), proxyData.getPassword()));
215 			return address;
216 		default:
217 			log.warn(format(SshdText.get().unknownProxyProtocol,
218 					proxy.type().name()));
219 			return remoteAddress;
220 		}
221 	}
222 
223 	private SshFutureListener<IoConnectFuture> createConnectCompletionListener(
224 			ConnectFuture connectFuture, String username,
225 			InetSocketAddress address, HostConfigEntry hostConfig) {
226 		return new SshFutureListener<IoConnectFuture>() {
227 
228 			@Override
229 			public void operationComplete(IoConnectFuture future) {
230 				if (future.isCanceled()) {
231 					connectFuture.cancel();
232 					return;
233 				}
234 				Throwable t = future.getException();
235 				if (t != null) {
236 					connectFuture.setException(t);
237 					return;
238 				}
239 				IoSession ioSession = future.getSession();
240 				try {
241 					JGitClientSession session = createSession(ioSession,
242 							username, address, hostConfig);
243 					connectFuture.setSession(session);
244 				} catch (RuntimeException e) {
245 					connectFuture.setException(e);
246 					ioSession.close(true);
247 				}
248 			}
249 
250 			@Override
251 			public String toString() {
252 				return "JGitSshClient$ConnectCompletionListener[" + username //$NON-NLS-1$
253 						+ '@' + address + ']';
254 			}
255 		};
256 	}
257 
258 	private JGitClientSession createSession(IoSession ioSession,
259 			String username, InetSocketAddress address,
260 			HostConfigEntry hostConfig) {
261 		AbstractSession rawSession = AbstractSession.getSession(ioSession);
262 		if (!(rawSession instanceof JGitClientSession)) {
263 			throw new IllegalStateException("Wrong session type: " //$NON-NLS-1$
264 					+ rawSession.getClass().getCanonicalName());
265 		}
266 		JGitClientSession session = (JGitClientSession) rawSession;
267 		session.setUsername(username);
268 		session.setConnectAddress(address);
269 		session.setHostConfigEntry(hostConfig);
270 		// Set signature algorithms for public key authentication
271 		String pubkeyAlgos = hostConfig
272 				.getProperty(SshConstants.PUBKEY_ACCEPTED_ALGORITHMS);
273 		if (!StringUtils.isEmptyOrNull(pubkeyAlgos)) {
274 			List<String> signatures = getSignatureFactoriesNames();
275 			signatures = session.modifyAlgorithmList(signatures, pubkeyAlgos,
276 					SshConstants.PUBKEY_ACCEPTED_ALGORITHMS);
277 			if (!signatures.isEmpty()) {
278 				if (log.isDebugEnabled()) {
279 					log.debug(SshConstants.PUBKEY_ACCEPTED_ALGORITHMS + ' '
280 							+ signatures);
281 				}
282 				session.setSignatureFactoriesNames(signatures);
283 			} else {
284 				log.warn(format(SshdText.get().configNoKnownAlgorithms,
285 						SshConstants.PUBKEY_ACCEPTED_ALGORITHMS, pubkeyAlgos));
286 			}
287 		}
288 		if (session.getCredentialsProvider() == null) {
289 			session.setCredentialsProvider(getCredentialsProvider());
290 		}
291 		int numberOfPasswordPrompts = getNumberOfPasswordPrompts(hostConfig);
292 		PASSWORD_PROMPTS.set(session, Integer.valueOf(numberOfPasswordPrompts));
293 		List<Path> identities = hostConfig.getIdentities().stream()
294 				.map(s -> {
295 					try {
296 						return Paths.get(s);
297 					} catch (InvalidPathException e) {
298 						log.warn(format(SshdText.get().configInvalidPath,
299 								SshConstants.IDENTITY_FILE, s), e);
300 						return null;
301 					}
302 				}).filter(p -> p != null && Files.exists(p))
303 				.collect(Collectors.toList());
304 		CachingKeyPairProvider ourConfiguredKeysProvider = new CachingKeyPairProvider(
305 				identities, keyCache);
306 		FilePasswordProvider passwordProvider = getFilePasswordProvider();
307 		ourConfiguredKeysProvider.setPasswordFinder(passwordProvider);
308 		if (hostConfig.isIdentitiesOnly()) {
309 			session.setKeyIdentityProvider(ourConfiguredKeysProvider);
310 		} else {
311 			KeyIdentityProvider defaultKeysProvider = getKeyIdentityProvider();
312 			if (defaultKeysProvider instanceof AbstractResourceKeyPairProvider<?>) {
313 				((AbstractResourceKeyPairProvider<?>) defaultKeysProvider)
314 						.setPasswordFinder(passwordProvider);
315 			}
316 			KeyIdentityProvider combinedProvider = new CombinedKeyIdentityProvider(
317 					ourConfiguredKeysProvider, defaultKeysProvider);
318 			session.setKeyIdentityProvider(combinedProvider);
319 		}
320 		return session;
321 	}
322 
323 	private int getNumberOfPasswordPrompts(HostConfigEntry hostConfig) {
324 		String prompts = hostConfig
325 				.getProperty(SshConstants.NUMBER_OF_PASSWORD_PROMPTS);
326 		if (prompts != null) {
327 			prompts = prompts.trim();
328 			int value = positive(prompts);
329 			if (value > 0) {
330 				return value;
331 			}
332 			log.warn(format(SshdText.get().configInvalidPositive,
333 					SshConstants.NUMBER_OF_PASSWORD_PROMPTS, prompts));
334 		}
335 		return PASSWORD_PROMPTS.getRequiredDefault().intValue();
336 	}
337 
338 	/**
339 	 * Set a cache for loaded keys. Newly discovered keys will be added when
340 	 * IdentityFile host entries from the ssh config file are used during
341 	 * session authentication.
342 	 *
343 	 * @param cache
344 	 *            to use
345 	 */
346 	public void setKeyCache(KeyCache cache) {
347 		keyCache = cache;
348 	}
349 
350 	/**
351 	 * Sets a {@link ProxyDataFactory} for connecting through proxies.
352 	 *
353 	 * @param factory
354 	 *            to use, or {@code null} if proxying is not desired or
355 	 *            supported
356 	 */
357 	public void setProxyDatabase(ProxyDataFactory factory) {
358 		proxyDatabase = factory;
359 	}
360 
361 	/**
362 	 * Retrieves the {@link ProxyDataFactory}.
363 	 *
364 	 * @return the factory, or {@code null} if none is set
365 	 */
366 	protected ProxyDataFactory getProxyDatabase() {
367 		return proxyDatabase;
368 	}
369 
370 	/**
371 	 * Sets the {@link CredentialsProvider} for this client.
372 	 *
373 	 * @param provider
374 	 *            to set
375 	 */
376 	public void setCredentialsProvider(CredentialsProvider provider) {
377 		credentialsProvider = provider;
378 	}
379 
380 	/**
381 	 * Retrieves the {@link CredentialsProvider} set for this client.
382 	 *
383 	 * @return the provider, or {@code null} if none is set.
384 	 */
385 	public CredentialsProvider getCredentialsProvider() {
386 		return credentialsProvider;
387 	}
388 
389 	/**
390 	 * A {@link SessionFactory} to create our own specialized
391 	 * {@link JGitClientSession}s.
392 	 */
393 	private static class JGitSessionFactory extends SessionFactory {
394 
395 		public JGitSessionFactory(JGitSshClient client) {
396 			super(client);
397 		}
398 
399 		@Override
400 		protected ClientSessionImpl doCreateSession(IoSession ioSession)
401 				throws Exception {
402 			return new JGitClientSession(getClient(), ioSession);
403 		}
404 	}
405 
406 	/**
407 	 * A {@link KeyIdentityProvider} that iterates over the {@link Iterable}s
408 	 * returned by other {@link KeyIdentityProvider}s.
409 	 */
410 	private static class CombinedKeyIdentityProvider
411 			implements KeyIdentityProvider {
412 
413 		private final List<KeyIdentityProvider> providers;
414 
415 		public CombinedKeyIdentityProvider(KeyIdentityProvider... providers) {
416 			this(Arrays.stream(providers).filter(Objects::nonNull)
417 					.collect(Collectors.toList()));
418 		}
419 
420 		public CombinedKeyIdentityProvider(
421 				List<KeyIdentityProvider> providers) {
422 			this.providers = providers;
423 		}
424 
425 		@Override
426 		public Iterable<KeyPair> loadKeys(SessionContext context) {
427 			return () -> new Iterator<KeyPair>() {
428 
429 				private Iterator<KeyIdentityProvider> factories = providers
430 						.iterator();
431 				private Iterator<KeyPair> current;
432 
433 				private Boolean hasElement;
434 
435 				@Override
436 				public boolean hasNext() {
437 					if (hasElement != null) {
438 						return hasElement.booleanValue();
439 					}
440 					while (current == null || !current.hasNext()) {
441 						if (factories.hasNext()) {
442 							try {
443 								current = factories.next().loadKeys(context)
444 										.iterator();
445 							} catch (IOException | GeneralSecurityException e) {
446 								throw new RuntimeException(e);
447 							}
448 						} else {
449 							current = null;
450 							hasElement = Boolean.FALSE;
451 							return false;
452 						}
453 					}
454 					hasElement = Boolean.TRUE;
455 					return true;
456 				}
457 
458 				@Override
459 				public KeyPair next() {
460 					if (hasElement == null && !hasNext()
461 							|| !hasElement.booleanValue()) {
462 						throw new NoSuchElementException();
463 					}
464 					hasElement = null;
465 					KeyPair result;
466 					try {
467 						result = current.next();
468 					} catch (NoSuchElementException e) {
469 						result = null;
470 					}
471 					return result;
472 				}
473 
474 			};
475 		}
476 	}
477 }