View Javadoc
1   /*
2    * Copyright (C) 2018, 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  
14  import java.io.IOException;
15  import java.io.InputStream;
16  import java.io.InterruptedIOException;
17  import java.io.OutputStream;
18  import java.time.Duration;
19  import java.util.ArrayList;
20  import java.util.Collection;
21  import java.util.EnumSet;
22  import java.util.List;
23  import java.util.concurrent.CopyOnWriteArrayList;
24  import java.util.concurrent.TimeUnit;
25  import java.util.concurrent.atomic.AtomicReference;
26  import java.util.function.Supplier;
27  
28  import org.apache.sshd.client.SshClient;
29  import org.apache.sshd.client.channel.ChannelExec;
30  import org.apache.sshd.client.channel.ClientChannelEvent;
31  import org.apache.sshd.client.session.ClientSession;
32  import org.apache.sshd.client.subsystem.sftp.SftpClient;
33  import org.apache.sshd.client.subsystem.sftp.SftpClient.CloseableHandle;
34  import org.apache.sshd.client.subsystem.sftp.SftpClient.CopyMode;
35  import org.apache.sshd.client.subsystem.sftp.SftpClientFactory;
36  import org.apache.sshd.common.session.Session;
37  import org.apache.sshd.common.session.SessionListener;
38  import org.apache.sshd.common.subsystem.sftp.SftpException;
39  import org.eclipse.jgit.annotations.NonNull;
40  import org.eclipse.jgit.internal.transport.sshd.SshdText;
41  import org.eclipse.jgit.transport.FtpChannel;
42  import org.eclipse.jgit.transport.RemoteSession;
43  import org.eclipse.jgit.transport.URIish;
44  import org.slf4j.Logger;
45  import org.slf4j.LoggerFactory;
46  
47  /**
48   * An implementation of {@link RemoteSession} based on Apache MINA sshd.
49   *
50   * @since 5.2
51   */
52  public class SshdSession implements RemoteSession {
53  
54  	private static final Logger LOG = LoggerFactory
55  			.getLogger(SshdSession.class);
56  
57  	private final CopyOnWriteArrayList<SessionCloseListener> listeners = new CopyOnWriteArrayList<>();
58  
59  	private final URIish uri;
60  
61  	private SshClient client;
62  
63  	private ClientSession session;
64  
65  	SshdSession(URIish uri, Supplier<SshClient> clientFactory) {
66  		this.uri = uri;
67  		this.client = clientFactory.get();
68  	}
69  
70  	void connect(Duration timeout) throws IOException {
71  		if (!client.isStarted()) {
72  			client.start();
73  		}
74  		try {
75  			String username = uri.getUser();
76  			String host = uri.getHost();
77  			int port = uri.getPort();
78  			long t = timeout.toMillis();
79  			if (t <= 0) {
80  				session = client.connect(username, host, port).verify()
81  						.getSession();
82  			} else {
83  				session = client.connect(username, host, port)
84  						.verify(timeout.toMillis()).getSession();
85  			}
86  			session.addSessionListener(new SessionListener() {
87  
88  				@Override
89  				public void sessionClosed(Session s) {
90  					notifyCloseListeners();
91  				}
92  			});
93  			// Authentication timeout is by default 2 minutes.
94  			session.auth().verify(session.getAuthTimeout());
95  		} catch (IOException e) {
96  			disconnect(e);
97  			throw e;
98  		}
99  	}
100 
101 	/**
102 	 * Adds a {@link SessionCloseListener} to this session. Has no effect if the
103 	 * given {@code listener} is already registered with this session.
104 	 *
105 	 * @param listener
106 	 *            to add
107 	 */
108 	public void addCloseListener(@NonNull SessionCloseListener listener) {
109 		listeners.addIfAbsent(listener);
110 	}
111 
112 	/**
113 	 * Removes the given {@code listener}; has no effect if the listener is not
114 	 * currently registered with this session.
115 	 *
116 	 * @param listener
117 	 *            to remove
118 	 */
119 	public void removeCloseListener(@NonNull SessionCloseListener listener) {
120 		listeners.remove(listener);
121 	}
122 
123 	private void notifyCloseListeners() {
124 		for (SessionCloseListener l : listeners) {
125 			try {
126 				l.sessionClosed(this);
127 			} catch (RuntimeException e) {
128 				LOG.warn(SshdText.get().closeListenerFailed, e);
129 			}
130 		}
131 	}
132 
133 	@Override
134 	public Process exec(String commandName, int timeout) throws IOException {
135 		@SuppressWarnings("resource")
136 		ChannelExec exec = session.createExecChannel(commandName);
137 		long timeoutMillis = TimeUnit.SECONDS.toMillis(timeout);
138 		try {
139 			if (timeout <= 0) {
140 				exec.open().verify();
141 			} else {
142 				long start = System.nanoTime();
143 				exec.open().verify(timeoutMillis);
144 				timeoutMillis -= TimeUnit.NANOSECONDS
145 						.toMillis(System.nanoTime() - start);
146 			}
147 		} catch (IOException | RuntimeException e) {
148 			exec.close(true);
149 			throw e;
150 		}
151 		if (timeout > 0 && timeoutMillis <= 0) {
152 			// We have used up the whole timeout for opening the channel
153 			exec.close(true);
154 			throw new InterruptedIOException(
155 					format(SshdText.get().sshCommandTimeout, commandName,
156 							Integer.valueOf(timeout)));
157 		}
158 		return new SshdExecProcess(exec, commandName, timeoutMillis);
159 	}
160 
161 	/**
162 	 * Obtain an {@link FtpChannel} to perform SFTP operations in this
163 	 * {@link SshdSession}.
164 	 */
165 	@Override
166 	@NonNull
167 	public FtpChannel getFtpChannel() {
168 		return new SshdFtpChannel();
169 	}
170 
171 	@Override
172 	public void disconnect() {
173 		disconnect(null);
174 	}
175 
176 	private void disconnect(Throwable reason) {
177 		try {
178 			if (session != null) {
179 				session.close();
180 				session = null;
181 			}
182 		} catch (IOException e) {
183 			if (reason != null) {
184 				reason.addSuppressed(e);
185 			} else {
186 				LOG.error(SshdText.get().sessionCloseFailed, e);
187 			}
188 		} finally {
189 			client.stop();
190 			client = null;
191 		}
192 	}
193 
194 	private static class SshdExecProcess extends Process {
195 
196 		private final ChannelExec channel;
197 
198 		private final long timeoutMillis;
199 
200 		private final String commandName;
201 
202 		public SshdExecProcess(ChannelExec channel, String commandName,
203 				long timeoutMillis) {
204 			this.channel = channel;
205 			this.timeoutMillis = timeoutMillis > 0 ? timeoutMillis : -1L;
206 			this.commandName = commandName;
207 		}
208 
209 		@Override
210 		public OutputStream getOutputStream() {
211 			return channel.getInvertedIn();
212 		}
213 
214 		@Override
215 		public InputStream getInputStream() {
216 			return channel.getInvertedOut();
217 		}
218 
219 		@Override
220 		public InputStream getErrorStream() {
221 			return channel.getInvertedErr();
222 		}
223 
224 		@Override
225 		public int waitFor() throws InterruptedException {
226 			if (waitFor(timeoutMillis, TimeUnit.MILLISECONDS)) {
227 				return exitValue();
228 			}
229 			return -1;
230 		}
231 
232 		@Override
233 		public boolean waitFor(long timeout, TimeUnit unit)
234 				throws InterruptedException {
235 			long millis = timeout >= 0 ? unit.toMillis(timeout) : -1L;
236 			return channel
237 					.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), millis)
238 					.contains(ClientChannelEvent.CLOSED);
239 		}
240 
241 		@Override
242 		public int exitValue() {
243 			Integer exitCode = channel.getExitStatus();
244 			if (exitCode == null) {
245 				throw new IllegalThreadStateException(
246 						format(SshdText.get().sshProcessStillRunning,
247 								commandName));
248 			}
249 			return exitCode.intValue();
250 		}
251 
252 		@Override
253 		public void destroy() {
254 			if (channel.isOpen()) {
255 				channel.close(true);
256 			}
257 		}
258 	}
259 
260 	/**
261 	 * Helper interface like {@link Supplier}, but possibly raising an
262 	 * {@link IOException}.
263 	 *
264 	 * @param <T>
265 	 *            return type
266 	 */
267 	@FunctionalInterface
268 	private interface FtpOperation<T> {
269 
270 		T call() throws IOException;
271 
272 	}
273 
274 	private class SshdFtpChannel implements FtpChannel {
275 
276 		private SftpClient ftp;
277 
278 		/** Current working directory. */
279 		private String cwd = ""; //$NON-NLS-1$
280 
281 		@Override
282 		public void connect(int timeout, TimeUnit unit) throws IOException {
283 			if (timeout <= 0) {
284 				session.getProperties().put(
285 						SftpClient.SFTP_CHANNEL_OPEN_TIMEOUT,
286 						Long.valueOf(Long.MAX_VALUE));
287 			} else {
288 				session.getProperties().put(
289 						SftpClient.SFTP_CHANNEL_OPEN_TIMEOUT,
290 						Long.valueOf(unit.toMillis(timeout)));
291 			}
292 			ftp = SftpClientFactory.instance().createSftpClient(session);
293 			try {
294 				cd(cwd);
295 			} catch (IOException e) {
296 				ftp.close();
297 			}
298 		}
299 
300 		@Override
301 		public void disconnect() {
302 			try {
303 				ftp.close();
304 			} catch (IOException e) {
305 				LOG.error(SshdText.get().ftpCloseFailed, e);
306 			}
307 		}
308 
309 		@Override
310 		public boolean isConnected() {
311 			return session.isAuthenticated() && ftp.isOpen();
312 		}
313 
314 		private String absolute(String path) {
315 			if (path.isEmpty()) {
316 				return cwd;
317 			}
318 			// Note: there is no path injection vulnerability here. If
319 			// path has too many ".." components, we rely on the server
320 			// catching it and returning an error.
321 			if (path.charAt(0) != '/') {
322 				if (cwd.charAt(cwd.length() - 1) == '/') {
323 					return cwd + path;
324 				}
325 				return cwd + '/' + path;
326 			}
327 			return path;
328 		}
329 
330 		private <T> T map(FtpOperation<T> op) throws IOException {
331 			try {
332 				return op.call();
333 			} catch (IOException e) {
334 				if (e instanceof SftpException) {
335 					throw new FtpChannel.FtpException(e.getLocalizedMessage(),
336 							((SftpException) e).getStatus(), e);
337 				}
338 				throw e;
339 			}
340 		}
341 
342 		@Override
343 		public void cd(String path) throws IOException {
344 			cwd = map(() -> ftp.canonicalPath(absolute(path)));
345 			if (cwd.isEmpty()) {
346 				cwd += '/';
347 			}
348 		}
349 
350 		@Override
351 		public String pwd() throws IOException {
352 			return cwd;
353 		}
354 
355 		@Override
356 		public Collection<DirEntry> ls(String path) throws IOException {
357 			return map(() -> {
358 				List<DirEntry> result = new ArrayList<>();
359 				try (CloseableHandle handle = ftp.openDir(absolute(path))) {
360 					AtomicReference<Boolean> atEnd = new AtomicReference<>(
361 							Boolean.FALSE);
362 					while (!atEnd.get().booleanValue()) {
363 						List<SftpClient.DirEntry> chunk = ftp.readDir(handle,
364 								atEnd);
365 						if (chunk == null) {
366 							break;
367 						}
368 						for (SftpClient.DirEntry remote : chunk) {
369 							result.add(new DirEntry() {
370 
371 								@Override
372 								public String getFilename() {
373 									return remote.getFilename();
374 								}
375 
376 								@Override
377 								public long getModifiedTime() {
378 									return remote.getAttributes()
379 											.getModifyTime().toMillis();
380 								}
381 
382 								@Override
383 								public boolean isDirectory() {
384 									return remote.getAttributes().isDirectory();
385 								}
386 
387 							});
388 						}
389 					}
390 				}
391 				return result;
392 			});
393 		}
394 
395 		@Override
396 		public void rmdir(String path) throws IOException {
397 			map(() -> {
398 				ftp.rmdir(absolute(path));
399 				return null;
400 			});
401 
402 		}
403 
404 		@Override
405 		public void mkdir(String path) throws IOException {
406 			map(() -> {
407 				ftp.mkdir(absolute(path));
408 				return null;
409 			});
410 		}
411 
412 		@Override
413 		public InputStream get(String path) throws IOException {
414 			return map(() -> ftp.read(absolute(path)));
415 		}
416 
417 		@Override
418 		public OutputStream put(String path) throws IOException {
419 			return map(() -> ftp.write(absolute(path)));
420 		}
421 
422 		@Override
423 		public void rm(String path) throws IOException {
424 			map(() -> {
425 				ftp.remove(absolute(path));
426 				return null;
427 			});
428 		}
429 
430 		@Override
431 		public void rename(String from, String to) throws IOException {
432 			map(() -> {
433 				String src = absolute(from);
434 				String dest = absolute(to);
435 				try {
436 					ftp.rename(src, dest, CopyMode.Atomic, CopyMode.Overwrite);
437 				} catch (UnsupportedOperationException e) {
438 					// Older server cannot do POSIX rename...
439 					if (!src.equals(dest)) {
440 						delete(dest);
441 						ftp.rename(src, dest);
442 					}
443 				}
444 				return null;
445 			});
446 		}
447 	}
448 }