You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

SshdSession.java 16KB

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