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