View Javadoc
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.transport.sshd;
11  
12  import static java.text.MessageFormat.format;
13  import static org.apache.sshd.common.SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE;
14  import static org.apache.sshd.sftp.SftpModuleProperties.SFTP_CHANNEL_OPEN_TIMEOUT;
15  
16  import java.io.Closeable;
17  import java.io.IOException;
18  import java.io.InputStream;
19  import java.io.OutputStream;
20  import java.net.URISyntaxException;
21  import java.time.Duration;
22  import java.util.ArrayList;
23  import java.util.Collection;
24  import java.util.Collections;
25  import java.util.EnumSet;
26  import java.util.LinkedList;
27  import java.util.List;
28  import java.util.Map;
29  import java.util.concurrent.CopyOnWriteArrayList;
30  import java.util.concurrent.TimeUnit;
31  import java.util.function.Supplier;
32  import java.util.regex.Pattern;
33  
34  import org.apache.sshd.client.SshClient;
35  import org.apache.sshd.client.channel.ChannelExec;
36  import org.apache.sshd.client.channel.ClientChannelEvent;
37  import org.apache.sshd.client.config.hosts.HostConfigEntry;
38  import org.apache.sshd.client.future.ConnectFuture;
39  import org.apache.sshd.client.session.ClientSession;
40  import org.apache.sshd.client.session.forward.PortForwardingTracker;
41  import org.apache.sshd.common.AttributeRepository;
42  import org.apache.sshd.common.SshException;
43  import org.apache.sshd.common.future.CloseFuture;
44  import org.apache.sshd.common.future.SshFutureListener;
45  import org.apache.sshd.common.util.io.IoUtils;
46  import org.apache.sshd.common.util.io.functors.IOFunction;
47  import org.apache.sshd.common.util.net.SshdSocketAddress;
48  import org.apache.sshd.sftp.client.SftpClient;
49  import org.apache.sshd.sftp.client.SftpClient.CopyMode;
50  import org.apache.sshd.sftp.client.SftpClientFactory;
51  import org.apache.sshd.sftp.common.SftpException;
52  import org.eclipse.jgit.annotations.NonNull;
53  import org.eclipse.jgit.errors.TransportException;
54  import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile;
55  import org.eclipse.jgit.internal.transport.sshd.AuthenticationCanceledException;
56  import org.eclipse.jgit.internal.transport.sshd.AuthenticationLogger;
57  import org.eclipse.jgit.internal.transport.sshd.JGitSshClient;
58  import org.eclipse.jgit.internal.transport.sshd.SshdText;
59  import org.eclipse.jgit.transport.FtpChannel;
60  import org.eclipse.jgit.transport.RemoteSession2;
61  import org.eclipse.jgit.transport.SshConstants;
62  import org.eclipse.jgit.transport.URIish;
63  import org.eclipse.jgit.util.StringUtils;
64  import org.slf4j.Logger;
65  import org.slf4j.LoggerFactory;
66  
67  /**
68   * An implementation of {@link org.eclipse.jgit.transport.RemoteSession
69   * RemoteSession} based on Apache MINA sshd.
70   *
71   * @since 5.2
72   */
73  public class SshdSession implements RemoteSession2 {
74  
75  	private static final Logger LOG = LoggerFactory
76  			.getLogger(SshdSession.class);
77  
78  	private static final Pattern SHORT_SSH_FORMAT = Pattern
79  			.compile("[-\\w.]+(?:@[-\\w.]+)?(?::\\d+)?"); //$NON-NLS-1$
80  
81  	private static final int MAX_DEPTH = 10;
82  
83  	private final CopyOnWriteArrayList<SessionCloseListener> listeners = new CopyOnWriteArrayList<>();
84  
85  	private final URIish uri;
86  
87  	private SshClient client;
88  
89  	private ClientSession session;
90  
91  	SshdSession(URIish uri, Supplier<SshClient> clientFactory) {
92  		this.uri = uri;
93  		this.client = clientFactory.get();
94  	}
95  
96  	void connect(Duration timeout) throws IOException {
97  		if (!client.isStarted()) {
98  			client.start();
99  		}
100 		try {
101 			session = connect(uri, Collections.emptyList(),
102 					future -> notifyCloseListeners(), timeout, MAX_DEPTH);
103 		} catch (IOException e) {
104 			disconnect(e);
105 			throw e;
106 		}
107 	}
108 
109 	private ClientSession connect(URIish target, List<URIish> jumps,
110 			SshFutureListener<CloseFuture> listener, Duration timeout,
111 			int depth) throws IOException {
112 		if (--depth < 0) {
113 			throw new IOException(
114 					format(SshdText.get().proxyJumpAbort, target));
115 		}
116 		HostConfigEntry hostConfig = getHostConfig(target.getUser(),
117 				target.getHost(), target.getPort());
118 		String host = hostConfig.getHostName();
119 		int port = hostConfig.getPort();
120 		List<URIish> hops = determineHops(jumps, hostConfig, target.getHost());
121 		ClientSession resultSession = null;
122 		ClientSession proxySession = null;
123 		PortForwardingTracker portForward = null;
124 		AuthenticationLogger authLog = null;
125 		try {
126 			if (!hops.isEmpty()) {
127 				URIish hop = hops.remove(0);
128 				if (LOG.isDebugEnabled()) {
129 					LOG.debug("Connecting to jump host {}", hop); //$NON-NLS-1$
130 				}
131 				proxySession = connect(hop, hops, null, timeout, depth);
132 			}
133 			AttributeRepository context = null;
134 			if (proxySession != null) {
135 				SshdSocketAddress remoteAddress = new SshdSocketAddress(host,
136 						port);
137 				portForward = proxySession.createLocalPortForwardingTracker(
138 						SshdSocketAddress.LOCALHOST_ADDRESS, remoteAddress);
139 				// We must connect to the locally bound address, not the one
140 				// from the host config.
141 				context = AttributeRepository.ofKeyValuePair(
142 						JGitSshClient.LOCAL_FORWARD_ADDRESS,
143 						portForward.getBoundAddress());
144 			}
145 			int timeoutInSec = OpenSshConfigFile.timeSpec(
146 					hostConfig.getProperty(SshConstants.CONNECT_TIMEOUT));
147 			resultSession = connect(hostConfig, context,
148 					timeoutInSec > 0 ? Duration.ofSeconds(timeoutInSec)
149 							: timeout);
150 			if (proxySession != null) {
151 				final PortForwardingTracker tracker = portForward;
152 				final ClientSession pSession = proxySession;
153 				resultSession.addCloseFutureListener(future -> {
154 					IoUtils.closeQuietly(tracker);
155 					String sessionName = pSession.toString();
156 					try {
157 						pSession.close();
158 					} catch (IOException e) {
159 						LOG.error(format(
160 								SshdText.get().sshProxySessionCloseFailed,
161 								sessionName), e);
162 					}
163 				});
164 				portForward = null;
165 				proxySession = null;
166 			}
167 			if (listener != null) {
168 				resultSession.addCloseFutureListener(listener);
169 			}
170 			// Authentication timeout is by default 2 minutes.
171 			authLog = new AuthenticationLogger(resultSession);
172 			resultSession.auth().verify(resultSession.getAuthTimeout());
173 			return resultSession;
174 		} catch (IOException e) {
175 			close(portForward, e);
176 			close(proxySession, e);
177 			close(resultSession, e);
178 			if (e instanceof SshException && ((SshException) e)
179 					.getDisconnectCode() == SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) {
180 				String message = format(SshdText.get().loginDenied, host,
181 						Integer.toString(port));
182 				throw new TransportException(target,
183 						withAuthLog(message, authLog), e);
184 			} else if (e instanceof SshException && e
185 					.getCause() instanceof AuthenticationCanceledException) {
186 				String message = e.getCause().getMessage();
187 				throw new TransportException(target,
188 						withAuthLog(message, authLog), e.getCause());
189 			}
190 			throw e;
191 		} finally {
192 			if (authLog != null) {
193 				authLog.clear();
194 			}
195 		}
196 	}
197 
198 	private String withAuthLog(String message, AuthenticationLogger authLog) {
199 		if (authLog != null) {
200 			String log = String.join(System.lineSeparator(), authLog.getLog());
201 			if (!log.isEmpty()) {
202 				return message + System.lineSeparator() + log;
203 			}
204 		}
205 		return message;
206 	}
207 
208 	private ClientSession connect(HostConfigEntry config,
209 			AttributeRepository context, Duration timeout)
210 			throws IOException {
211 		ConnectFuture connected = client.connect(config, context, null);
212 		long timeoutMillis = timeout.toMillis();
213 		if (timeoutMillis <= 0) {
214 			connected = connected.verify();
215 		} else {
216 			connected = connected.verify(timeoutMillis);
217 		}
218 		return connected.getSession();
219 	}
220 
221 	private void close(Closeable toClose, Throwable error) {
222 		if (toClose != null) {
223 			try {
224 				toClose.close();
225 			} catch (IOException e) {
226 				error.addSuppressed(e);
227 			}
228 		}
229 	}
230 
231 	private HostConfigEntry getHostConfig(String username, String host,
232 			int port) throws IOException {
233 		HostConfigEntry entry = client.getHostConfigEntryResolver()
234 				.resolveEffectiveHost(host, port, null, username, null, null);
235 		if (entry == null) {
236 			if (SshdSocketAddress.isIPv6Address(host)) {
237 				return new HostConfigEntry("", host, port, username); //$NON-NLS-1$
238 			}
239 			return new HostConfigEntry(host, host, port, username);
240 		}
241 		return entry;
242 	}
243 
244 	private List<URIish> determineHops(List<URIish> currentHops,
245 			HostConfigEntry hostConfig, String host) throws IOException {
246 		if (currentHops.isEmpty()) {
247 			String jumpHosts = hostConfig.getProperty(SshConstants.PROXY_JUMP);
248 			if (!StringUtils.isEmptyOrNull(jumpHosts)
249 					&& !SshConstants.NONE.equals(jumpHosts)) {
250 				try {
251 					return parseProxyJump(jumpHosts);
252 				} catch (URISyntaxException e) {
253 					throw new IOException(
254 							format(SshdText.get().configInvalidProxyJump, host,
255 									jumpHosts),
256 							e);
257 				}
258 			}
259 		}
260 		return currentHops;
261 	}
262 
263 	private List<URIish> parseProxyJump(String proxyJump)
264 			throws URISyntaxException {
265 		String[] hops = proxyJump.split(","); //$NON-NLS-1$
266 		List<URIish> result = new LinkedList<>();
267 		for (String hop : hops) {
268 			// There shouldn't be any whitespace, but let's be lenient
269 			hop = hop.trim();
270 			if (SHORT_SSH_FORMAT.matcher(hop).matches()) {
271 				// URIish doesn't understand the short SSH format
272 				// user@host:port, only user@host:path
273 				hop = SshConstants.SSH_SCHEME + "://" + hop; //$NON-NLS-1$
274 			}
275 			URIish to = new URIish(hop);
276 			if (!SshConstants.SSH_SCHEME.equalsIgnoreCase(to.getScheme())) {
277 				throw new URISyntaxException(hop,
278 						SshdText.get().configProxyJumpNotSsh);
279 			} else if (!StringUtils.isEmptyOrNull(to.getPath())) {
280 				throw new URISyntaxException(hop,
281 						SshdText.get().configProxyJumpWithPath);
282 			}
283 			result.add(to);
284 		}
285 		return result;
286 	}
287 
288 	/**
289 	 * Adds a {@link SessionCloseListener} to this session. Has no effect if the
290 	 * given {@code listener} is already registered with this session.
291 	 *
292 	 * @param listener
293 	 *            to add
294 	 */
295 	public void addCloseListener(@NonNull SessionCloseListener listener) {
296 		listeners.addIfAbsent(listener);
297 	}
298 
299 	/**
300 	 * Removes the given {@code listener}; has no effect if the listener is not
301 	 * currently registered with this session.
302 	 *
303 	 * @param listener
304 	 *            to remove
305 	 */
306 	public void removeCloseListener(@NonNull SessionCloseListener listener) {
307 		listeners.remove(listener);
308 	}
309 
310 	private void notifyCloseListeners() {
311 		for (SessionCloseListener l : listeners) {
312 			try {
313 				l.sessionClosed(this);
314 			} catch (RuntimeException e) {
315 				LOG.warn(SshdText.get().closeListenerFailed, e);
316 			}
317 		}
318 	}
319 
320 	@Override
321 	public Process exec(String commandName, int timeout) throws IOException {
322 		return exec(commandName, Collections.emptyMap(), timeout);
323 	}
324 
325 	@Override
326 	public Process exec(String commandName, Map<String, String> environment,
327 			int timeout) throws IOException {
328 		@SuppressWarnings("resource")
329 		ChannelExec exec = session.createExecChannel(commandName, null,
330 				environment);
331 		if (timeout <= 0) {
332 			try {
333 				exec.open().verify();
334 			} catch (IOException | RuntimeException e) {
335 				exec.close(true);
336 				throw e;
337 			}
338 		} else {
339 			try {
340 				exec.open().verify(TimeUnit.SECONDS.toMillis(timeout));
341 			} catch (IOException | RuntimeException e) {
342 				exec.close(true);
343 				throw new IOException(format(SshdText.get().sshCommandTimeout,
344 						commandName, Integer.valueOf(timeout)), e);
345 			}
346 		}
347 		return new SshdExecProcess(exec, commandName);
348 	}
349 
350 	/**
351 	 * Obtain an {@link FtpChannel} to perform SFTP operations in this
352 	 * {@link SshdSession}.
353 	 */
354 	@Override
355 	@NonNull
356 	public FtpChannel getFtpChannel() {
357 		return new SshdFtpChannel();
358 	}
359 
360 	@Override
361 	public void disconnect() {
362 		disconnect(null);
363 	}
364 
365 	private void disconnect(Throwable reason) {
366 		try {
367 			if (session != null) {
368 				session.close();
369 				session = null;
370 			}
371 		} catch (IOException e) {
372 			if (reason != null) {
373 				reason.addSuppressed(e);
374 			} else {
375 				LOG.error(SshdText.get().sessionCloseFailed, e);
376 			}
377 		} finally {
378 			client.stop();
379 			client = null;
380 		}
381 	}
382 
383 	private static class SshdExecProcess extends Process {
384 
385 		private final ChannelExec channel;
386 
387 		private final String commandName;
388 
389 		public SshdExecProcess(ChannelExec channel, String commandName) {
390 			this.channel = channel;
391 			this.commandName = commandName;
392 		}
393 
394 		@Override
395 		public OutputStream getOutputStream() {
396 			return channel.getInvertedIn();
397 		}
398 
399 		@Override
400 		public InputStream getInputStream() {
401 			return channel.getInvertedOut();
402 		}
403 
404 		@Override
405 		public InputStream getErrorStream() {
406 			return channel.getInvertedErr();
407 		}
408 
409 		@Override
410 		public int waitFor() throws InterruptedException {
411 			if (waitFor(-1L, TimeUnit.MILLISECONDS)) {
412 				return exitValue();
413 			}
414 			return -1;
415 		}
416 
417 		@Override
418 		public boolean waitFor(long timeout, TimeUnit unit)
419 				throws InterruptedException {
420 			long millis = timeout >= 0 ? unit.toMillis(timeout) : -1L;
421 			return channel
422 					.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), millis)
423 					.contains(ClientChannelEvent.CLOSED);
424 		}
425 
426 		@Override
427 		public int exitValue() {
428 			Integer exitCode = channel.getExitStatus();
429 			if (exitCode == null) {
430 				throw new IllegalThreadStateException(
431 						format(SshdText.get().sshProcessStillRunning,
432 								commandName));
433 			}
434 			return exitCode.intValue();
435 		}
436 
437 		@Override
438 		public void destroy() {
439 			if (channel.isOpen()) {
440 				channel.close(false);
441 			}
442 		}
443 	}
444 
445 	private class SshdFtpChannel implements FtpChannel {
446 
447 		private SftpClient ftp;
448 
449 		/** Current working directory. */
450 		private String cwd = ""; //$NON-NLS-1$
451 
452 		@Override
453 		public void connect(int timeout, TimeUnit unit) throws IOException {
454 			if (timeout <= 0) {
455 				// This timeout must not be null!
456 				SFTP_CHANNEL_OPEN_TIMEOUT.set(session,
457 						Duration.ofMillis(Long.MAX_VALUE));
458 			} else {
459 				SFTP_CHANNEL_OPEN_TIMEOUT.set(session,
460 						Duration.ofMillis(unit.toMillis(timeout)));
461 			}
462 			ftp = SftpClientFactory.instance().createSftpClient(session);
463 			try {
464 				cd(cwd);
465 			} catch (IOException e) {
466 				ftp.close();
467 			}
468 		}
469 
470 		@Override
471 		public void disconnect() {
472 			try {
473 				ftp.close();
474 			} catch (IOException e) {
475 				LOG.error(SshdText.get().ftpCloseFailed, e);
476 			}
477 		}
478 
479 		@Override
480 		public boolean isConnected() {
481 			return session.isAuthenticated() && ftp.isOpen();
482 		}
483 
484 		private String absolute(String path) {
485 			if (path.isEmpty()) {
486 				return cwd;
487 			}
488 			// Note: there is no path injection vulnerability here. If
489 			// path has too many ".." components, we rely on the server
490 			// catching it and returning an error.
491 			if (path.charAt(0) != '/') {
492 				if (cwd.charAt(cwd.length() - 1) == '/') {
493 					return cwd + path;
494 				}
495 				return cwd + '/' + path;
496 			}
497 			return path;
498 		}
499 
500 		private <T> T map(IOFunction<Void, T> op) throws IOException {
501 			try {
502 				return op.apply(null);
503 			} catch (IOException e) {
504 				if (e instanceof SftpException) {
505 					throw new FtpChannel.FtpException(e.getLocalizedMessage(),
506 							((SftpException) e).getStatus(), e);
507 				}
508 				throw e;
509 			}
510 		}
511 
512 		@Override
513 		public void cd(String path) throws IOException {
514 			cwd = map(x -> ftp.canonicalPath(absolute(path)));
515 			if (cwd.isEmpty()) {
516 				cwd += '/';
517 			}
518 		}
519 
520 		@Override
521 		public String pwd() throws IOException {
522 			return cwd;
523 		}
524 
525 		@Override
526 		public Collection<DirEntry> ls(String path) throws IOException {
527 			return map(x -> {
528 				List<DirEntry> result = new ArrayList<>();
529 				for (SftpClient.DirEntry remote : ftp.readDir(absolute(path))) {
530 					result.add(new DirEntry() {
531 
532 						@Override
533 						public String getFilename() {
534 							return remote.getFilename();
535 						}
536 
537 						@Override
538 						public long getModifiedTime() {
539 							return remote.getAttributes().getModifyTime()
540 									.toMillis();
541 						}
542 
543 						@Override
544 						public boolean isDirectory() {
545 							return remote.getAttributes().isDirectory();
546 						}
547 
548 					});
549 				}
550 				return result;
551 			});
552 		}
553 
554 		@Override
555 		public void rmdir(String path) throws IOException {
556 			map(x -> {
557 				ftp.rmdir(absolute(path));
558 				return null;
559 			});
560 
561 		}
562 
563 		@Override
564 		public void mkdir(String path) throws IOException {
565 			map(x -> {
566 				ftp.mkdir(absolute(path));
567 				return null;
568 			});
569 		}
570 
571 		@Override
572 		public InputStream get(String path) throws IOException {
573 			return map(x -> ftp.read(absolute(path)));
574 		}
575 
576 		@Override
577 		public OutputStream put(String path) throws IOException {
578 			return map(x -> ftp.write(absolute(path)));
579 		}
580 
581 		@Override
582 		public void rm(String path) throws IOException {
583 			map(x -> {
584 				ftp.remove(absolute(path));
585 				return null;
586 			});
587 		}
588 
589 		@Override
590 		public void rename(String from, String to) throws IOException {
591 			map(x -> {
592 				String src = absolute(from);
593 				String dest = absolute(to);
594 				try {
595 					ftp.rename(src, dest, CopyMode.Atomic, CopyMode.Overwrite);
596 				} catch (UnsupportedOperationException e) {
597 					// Older server cannot do POSIX rename...
598 					if (!src.equals(dest)) {
599 						delete(dest);
600 						ftp.rename(src, dest);
601 					}
602 				}
603 				return null;
604 			});
605 		}
606 	}
607 }