1
2
3
4
5
6
7
8
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
69
70
71
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+)?");
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);
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
140
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
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);
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(",");
266 List<URIish> result = new LinkedList<>();
267 for (String hop : hops) {
268
269 hop = hop.trim();
270 if (SHORT_SSH_FORMAT.matcher(hop).matches()) {
271
272
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
290
291
292
293
294
295 public void addCloseListener(@NonNull SessionCloseListener listener) {
296 listeners.addIfAbsent(listener);
297 }
298
299
300
301
302
303
304
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
352
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
450 private String cwd = "";
451
452 @Override
453 public void connect(int timeout, TimeUnit unit) throws IOException {
454 if (timeout <= 0) {
455
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
489
490
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
598 if (!src.equals(dest)) {
599 delete(dest);
600 ftp.rename(src, dest);
601 }
602 }
603 return null;
604 });
605 }
606 }
607 }