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

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