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.

SshTestGitServer.java 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  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.junit.ssh;
  11. import java.io.ByteArrayInputStream;
  12. import java.io.IOException;
  13. import java.io.InputStream;
  14. import java.io.OutputStream;
  15. import java.nio.charset.StandardCharsets;
  16. import java.nio.file.Files;
  17. import java.nio.file.Path;
  18. import java.security.GeneralSecurityException;
  19. import java.security.KeyPair;
  20. import java.security.PublicKey;
  21. import java.text.MessageFormat;
  22. import java.util.ArrayList;
  23. import java.util.Collections;
  24. import java.util.List;
  25. import java.util.Locale;
  26. import java.util.concurrent.TimeUnit;
  27. import org.apache.sshd.common.NamedResource;
  28. import org.apache.sshd.common.PropertyResolver;
  29. import org.apache.sshd.common.PropertyResolverUtils;
  30. import org.apache.sshd.common.SshConstants;
  31. import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
  32. import org.apache.sshd.common.config.keys.KeyUtils;
  33. import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
  34. import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory;
  35. import org.apache.sshd.common.session.Session;
  36. import org.apache.sshd.common.util.buffer.Buffer;
  37. import org.apache.sshd.common.util.security.SecurityUtils;
  38. import org.apache.sshd.common.util.threads.CloseableExecutorService;
  39. import org.apache.sshd.common.util.threads.ThreadUtils;
  40. import org.apache.sshd.server.ServerAuthenticationManager;
  41. import org.apache.sshd.server.ServerFactoryManager;
  42. import org.apache.sshd.server.SshServer;
  43. import org.apache.sshd.server.auth.UserAuth;
  44. import org.apache.sshd.server.auth.UserAuthFactory;
  45. import org.apache.sshd.server.auth.gss.GSSAuthenticator;
  46. import org.apache.sshd.server.auth.gss.UserAuthGSS;
  47. import org.apache.sshd.server.auth.gss.UserAuthGSSFactory;
  48. import org.apache.sshd.server.auth.keyboard.DefaultKeyboardInteractiveAuthenticator;
  49. import org.apache.sshd.server.command.AbstractCommandSupport;
  50. import org.apache.sshd.server.session.ServerSession;
  51. import org.apache.sshd.server.shell.UnknownCommand;
  52. import org.apache.sshd.server.subsystem.SubsystemFactory;
  53. import org.apache.sshd.server.subsystem.sftp.SftpSubsystemFactory;
  54. import org.eclipse.jgit.annotations.NonNull;
  55. import org.eclipse.jgit.lib.Repository;
  56. import org.eclipse.jgit.transport.ReceivePack;
  57. import org.eclipse.jgit.transport.RemoteConfig;
  58. import org.eclipse.jgit.transport.UploadPack;
  59. /**
  60. * A simple ssh/sftp git <em>test</em> server based on Apache MINA sshd.
  61. * <p>
  62. * Supports only a single repository. Authenticates only the given test user
  63. * against his given test public key. Supports fetch and push.
  64. * </p>
  65. *
  66. * @since 5.2
  67. */
  68. public class SshTestGitServer {
  69. /**
  70. * Simple echo test command. Replies with the command string as passed. If
  71. * of the form "echo [int] anything", takes the integer value as a delay in
  72. * seconds before replying, which may be useful to test various
  73. * timeout-related things.
  74. *
  75. * @since 5.9
  76. */
  77. public static final String ECHO_COMMAND = "echo";
  78. @NonNull
  79. protected final String testUser;
  80. @NonNull
  81. protected final Repository repository;
  82. @NonNull
  83. protected final List<KeyPair> hostKeys = new ArrayList<>();
  84. protected final SshServer server;
  85. @NonNull
  86. protected PublicKey testKey;
  87. private final CloseableExecutorService executorService = ThreadUtils
  88. .newFixedThreadPool("SshTestGitServerPool", 2);
  89. /**
  90. * Creates a ssh git <em>test</em> server. It serves one single repository,
  91. * and accepts public-key authentication for exactly one test user.
  92. *
  93. * @param testUser
  94. * user name of the test user
  95. * @param testKey
  96. * public key file of the test user
  97. * @param repository
  98. * to serve
  99. * @param hostKey
  100. * the unencrypted private key to use as host key
  101. * @throws IOException
  102. * @throws GeneralSecurityException
  103. */
  104. public SshTestGitServer(@NonNull String testUser, @NonNull Path testKey,
  105. @NonNull Repository repository, @NonNull byte[] hostKey)
  106. throws IOException, GeneralSecurityException {
  107. this(testUser, readPublicKey(testKey), repository,
  108. readKeyPair(hostKey));
  109. }
  110. /**
  111. * Creates a ssh git <em>test</em> server. It serves one single repository,
  112. * and accepts public-key authentication for exactly one test user.
  113. *
  114. * @param testUser
  115. * user name of the test user
  116. * @param testKey
  117. * public key file of the test user
  118. * @param repository
  119. * to serve
  120. * @param hostKey
  121. * the unencrypted private key to use as host key
  122. * @throws IOException
  123. * @throws GeneralSecurityException
  124. * @since 5.9
  125. */
  126. public SshTestGitServer(@NonNull String testUser, @NonNull Path testKey,
  127. @NonNull Repository repository, @NonNull KeyPair hostKey)
  128. throws IOException, GeneralSecurityException {
  129. this(testUser, readPublicKey(testKey), repository, hostKey);
  130. }
  131. /**
  132. * Creates a ssh git <em>test</em> server. It serves one single repository,
  133. * and accepts public-key authentication for exactly one test user.
  134. *
  135. * @param testUser
  136. * user name of the test user
  137. * @param testKey
  138. * the {@link PublicKey} of the test user
  139. * @param repository
  140. * to serve
  141. * @param hostKey
  142. * the {@link KeyPair} to use as host key
  143. * @since 5.9
  144. */
  145. public SshTestGitServer(@NonNull String testUser,
  146. @NonNull PublicKey testKey, @NonNull Repository repository,
  147. @NonNull KeyPair hostKey) {
  148. this.testUser = testUser;
  149. setTestUserPublicKey(testKey);
  150. this.repository = repository;
  151. server = SshServer.setUpDefaultServer();
  152. hostKeys.add(hostKey);
  153. server.setKeyPairProvider((session) -> hostKeys);
  154. configureAuthentication();
  155. List<SubsystemFactory> subsystems = configureSubsystems();
  156. if (!subsystems.isEmpty()) {
  157. server.setSubsystemFactories(subsystems);
  158. }
  159. configureShell();
  160. server.setCommandFactory((channel, command) -> {
  161. if (command.startsWith(RemoteConfig.DEFAULT_UPLOAD_PACK)) {
  162. return new GitUploadPackCommand(command, executorService);
  163. } else if (command.startsWith(RemoteConfig.DEFAULT_RECEIVE_PACK)) {
  164. return new GitReceivePackCommand(command, executorService);
  165. } else if (command.startsWith(ECHO_COMMAND)) {
  166. return new EchoCommand(command, executorService);
  167. }
  168. return new UnknownCommand(command);
  169. });
  170. }
  171. private static PublicKey readPublicKey(Path key)
  172. throws IOException, GeneralSecurityException {
  173. return AuthorizedKeyEntry.readAuthorizedKeys(key).get(0)
  174. .resolvePublicKey(null, PublicKeyEntryResolver.IGNORING);
  175. }
  176. private static KeyPair readKeyPair(byte[] keyMaterial)
  177. throws IOException, GeneralSecurityException {
  178. try (ByteArrayInputStream in = new ByteArrayInputStream(keyMaterial)) {
  179. return SecurityUtils.loadKeyPairIdentities(null, null, in, null)
  180. .iterator().next();
  181. }
  182. }
  183. private static class FakeUserAuthGSS extends UserAuthGSS {
  184. @Override
  185. protected Boolean doAuth(Buffer buffer, boolean initial)
  186. throws Exception {
  187. // We always reply that we did do this, but then we fail at the
  188. // first token message. That way we can test that the client-side
  189. // sends the correct initial request and then is skipped correctly,
  190. // even if it causes a GSSException if Kerberos isn't configured at
  191. // all.
  192. if (initial) {
  193. ServerSession session = getServerSession();
  194. Buffer b = session.createBuffer(
  195. SshConstants.SSH_MSG_USERAUTH_INFO_REQUEST);
  196. b.putBytes(KRB5_MECH.getDER());
  197. session.writePacket(b);
  198. return null;
  199. }
  200. return Boolean.FALSE;
  201. }
  202. }
  203. private List<UserAuthFactory> getAuthFactories() {
  204. List<UserAuthFactory> authentications = new ArrayList<>();
  205. authentications.add(new UserAuthGSSFactory() {
  206. @Override
  207. public UserAuth createUserAuth(ServerSession session)
  208. throws IOException {
  209. return new FakeUserAuthGSS();
  210. }
  211. });
  212. authentications.add(
  213. ServerAuthenticationManager.DEFAULT_USER_AUTH_PUBLIC_KEY_FACTORY);
  214. authentications.add(
  215. ServerAuthenticationManager.DEFAULT_USER_AUTH_KB_INTERACTIVE_FACTORY);
  216. authentications.add(
  217. ServerAuthenticationManager.DEFAULT_USER_AUTH_PASSWORD_FACTORY);
  218. return authentications;
  219. }
  220. /**
  221. * Configures the authentication mechanisms of this test server. Invoked
  222. * from the constructor. The default sets up public key authentication for
  223. * the test user, and a gssapi-with-mic authenticator that pretends to
  224. * support this mechanism, but that then refuses to authenticate anyone.
  225. */
  226. protected void configureAuthentication() {
  227. server.setUserAuthFactories(getAuthFactories());
  228. // Disable some authentications
  229. server.setPasswordAuthenticator(null);
  230. server.setKeyboardInteractiveAuthenticator(null);
  231. server.setHostBasedAuthenticator(null);
  232. // Pretend we did gssapi-with-mic.
  233. server.setGSSAuthenticator(new GSSAuthenticator() {
  234. @Override
  235. public boolean validateInitialUser(ServerSession session,
  236. String user) {
  237. return false;
  238. }
  239. });
  240. // Accept only the test user/public key
  241. server.setPublickeyAuthenticator((userName, publicKey, session) -> {
  242. return SshTestGitServer.this.testUser.equals(userName) && KeyUtils
  243. .compareKeys(SshTestGitServer.this.testKey, publicKey);
  244. });
  245. }
  246. /**
  247. * Configures the test server's subsystems (sftp, scp). Invoked from the
  248. * constructor. The default provides a simple SFTP setup with the root
  249. * directory as the given repository's .git directory's parent. (I.e., at
  250. * the directory containing the .git directory.)
  251. *
  252. * @return A possibly empty collection of subsystems.
  253. */
  254. @NonNull
  255. protected List<SubsystemFactory> configureSubsystems() {
  256. // SFTP.
  257. server.setFileSystemFactory(new VirtualFileSystemFactory() {
  258. @Override
  259. protected Path computeRootDir(Session session) throws IOException {
  260. return SshTestGitServer.this.repository.getDirectory()
  261. .getParentFile().getAbsoluteFile().toPath();
  262. }
  263. });
  264. return Collections
  265. .singletonList((new SftpSubsystemFactory.Builder()).build());
  266. }
  267. /**
  268. * Configures shell access for the test server. The default provides no
  269. * shell at all.
  270. */
  271. protected void configureShell() {
  272. // No shell
  273. server.setShellFactory(null);
  274. }
  275. /**
  276. * Adds an additional host key to the server.
  277. *
  278. * @param key
  279. * path to the private key file; should not be encrypted
  280. * @param inFront
  281. * whether to add the new key before other existing keys
  282. * @throws IOException
  283. * if the file denoted by the {@link Path} {@code key} cannot be
  284. * read
  285. * @throws GeneralSecurityException
  286. * if the key contained in the file cannot be read
  287. */
  288. public void addHostKey(@NonNull Path key, boolean inFront)
  289. throws IOException, GeneralSecurityException {
  290. try (InputStream in = Files.newInputStream(key)) {
  291. KeyPair pair = SecurityUtils
  292. .loadKeyPairIdentities(null,
  293. NamedResource.ofName(key.toString()), in, null)
  294. .iterator().next();
  295. addHostKey(pair, inFront);
  296. }
  297. }
  298. /**
  299. * Adds an additional host key to the server.
  300. *
  301. * @param key
  302. * {@link KeyPair} to add
  303. * @param inFront
  304. * whether to add the new key before other existing keys
  305. * @since 5.8
  306. */
  307. public void addHostKey(@NonNull KeyPair key, boolean inFront) {
  308. if (inFront) {
  309. hostKeys.add(0, key);
  310. } else {
  311. hostKeys.add(key);
  312. }
  313. }
  314. /**
  315. * Enable password authentication. The server will accept the test user's
  316. * name, converted to all upper-case, as password.
  317. */
  318. public void enablePasswordAuthentication() {
  319. server.setPasswordAuthenticator((user, pwd, session) -> {
  320. return testUser.equals(user)
  321. && testUser.toUpperCase(Locale.ROOT).equals(pwd);
  322. });
  323. }
  324. /**
  325. * Enable keyboard-interactive authentication. The server will accept the
  326. * test user's name, converted to all upper-case, as password.
  327. */
  328. public void enableKeyboardInteractiveAuthentication() {
  329. server.setPasswordAuthenticator((user, pwd, session) -> {
  330. return testUser.equals(user)
  331. && testUser.toUpperCase(Locale.ROOT).equals(pwd);
  332. });
  333. server.setKeyboardInteractiveAuthenticator(
  334. DefaultKeyboardInteractiveAuthenticator.INSTANCE);
  335. }
  336. /**
  337. * Retrieves the server's {@link PropertyResolver}, giving access to server
  338. * properties.
  339. *
  340. * @return the {@link PropertyResolver}
  341. * @since 5.9
  342. */
  343. public PropertyResolver getPropertyResolver() {
  344. return server;
  345. }
  346. /**
  347. * Starts the test server, listening on a random port.
  348. *
  349. * @return the port the server listens on; test clients should connect to
  350. * that port
  351. * @throws IOException
  352. */
  353. public int start() throws IOException {
  354. server.start();
  355. return server.getPort();
  356. }
  357. /**
  358. * Stops the test server.
  359. *
  360. * @throws IOException
  361. */
  362. public void stop() throws IOException {
  363. executorService.shutdownNow();
  364. server.stop(true);
  365. }
  366. /**
  367. * Sets the test user's public key on the server.
  368. *
  369. * @param key
  370. * to set
  371. * @throws IOException
  372. * if the file cannot be read
  373. * @throws GeneralSecurityException
  374. * if the public key cannot be extracted from the file
  375. */
  376. public void setTestUserPublicKey(Path key)
  377. throws IOException, GeneralSecurityException {
  378. this.testKey = readPublicKey(key);
  379. }
  380. /**
  381. * Sets the test user's public key on the server.
  382. *
  383. * @param key
  384. * to set
  385. *
  386. * @since 5.8
  387. */
  388. public void setTestUserPublicKey(@NonNull PublicKey key) {
  389. this.testKey = key;
  390. }
  391. /**
  392. * Sets the lines the server sends before its server identification in the
  393. * initial protocol version exchange.
  394. *
  395. * @param lines
  396. * to send
  397. * @since 5.5
  398. */
  399. public void setPreamble(String... lines) {
  400. if (lines != null && lines.length > 0) {
  401. PropertyResolverUtils.updateProperty(this.server,
  402. ServerFactoryManager.SERVER_EXTRA_IDENTIFICATION_LINES,
  403. String.join("|", lines));
  404. }
  405. }
  406. private class GitUploadPackCommand extends AbstractCommandSupport {
  407. protected GitUploadPackCommand(String command,
  408. CloseableExecutorService executorService) {
  409. super(command, ThreadUtils.noClose(executorService));
  410. }
  411. @Override
  412. public void run() {
  413. UploadPack uploadPack = new UploadPack(repository);
  414. String gitProtocol = getEnvironment().getEnv().get("GIT_PROTOCOL");
  415. if (gitProtocol != null) {
  416. uploadPack
  417. .setExtraParameters(Collections.singleton(gitProtocol));
  418. }
  419. try {
  420. uploadPack.upload(getInputStream(), getOutputStream(),
  421. getErrorStream());
  422. onExit(0);
  423. } catch (IOException e) {
  424. log.warn(
  425. MessageFormat.format("Could not run {0}", getCommand()),
  426. e);
  427. onExit(-1, e.toString());
  428. }
  429. }
  430. }
  431. private class GitReceivePackCommand extends AbstractCommandSupport {
  432. protected GitReceivePackCommand(String command,
  433. CloseableExecutorService executorService) {
  434. super(command, ThreadUtils.noClose(executorService));
  435. }
  436. @Override
  437. public void run() {
  438. try {
  439. new ReceivePack(repository).receive(getInputStream(),
  440. getOutputStream(), getErrorStream());
  441. onExit(0);
  442. } catch (IOException e) {
  443. log.warn(
  444. MessageFormat.format("Could not run {0}", getCommand()),
  445. e);
  446. onExit(-1, e.toString());
  447. }
  448. }
  449. }
  450. /**
  451. * Simple echo command that echoes back the command string. If the first
  452. * argument is a positive integer, it's taken as a delay (in seconds) before
  453. * replying. Assumes UTF-8 character encoding.
  454. */
  455. private static class EchoCommand extends AbstractCommandSupport {
  456. protected EchoCommand(String command,
  457. CloseableExecutorService executorService) {
  458. super(command, ThreadUtils.noClose(executorService));
  459. }
  460. @Override
  461. public void run() {
  462. String[] parts = getCommand().split(" ");
  463. int timeout = 0;
  464. if (parts.length >= 2) {
  465. try {
  466. timeout = Integer.parseInt(parts[1]);
  467. } catch (NumberFormatException e) {
  468. // No timeout.
  469. }
  470. if (timeout > 0) {
  471. try {
  472. Thread.sleep(TimeUnit.SECONDS.toMillis(timeout));
  473. } catch (InterruptedException e) {
  474. // Ignore.
  475. }
  476. }
  477. }
  478. try {
  479. doEcho(getCommand(), getOutputStream());
  480. onExit(0);
  481. } catch (IOException e) {
  482. log.warn(
  483. MessageFormat.format("Could not run {0}", getCommand()),
  484. e);
  485. onExit(-1, e.toString());
  486. }
  487. }
  488. private void doEcho(String text, OutputStream stream)
  489. throws IOException {
  490. stream.write(text.getBytes(StandardCharsets.UTF_8));
  491. stream.flush();
  492. }
  493. }
  494. }