diff options
Diffstat (limited to 'org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestGitServer.java')
-rw-r--r-- | org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestGitServer.java | 579 |
1 files changed, 579 insertions, 0 deletions
diff --git a/org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestGitServer.java b/org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestGitServer.java new file mode 100644 index 0000000000..f5627e00c8 --- /dev/null +++ b/org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestGitServer.java @@ -0,0 +1,579 @@ +/* + * Copyright (C) 2018, 2020 Thomas Wolf <thomas.wolf@paranor.ch> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.junit.ssh; + +import static org.apache.sshd.core.CoreModuleProperties.SERVER_EXTRA_IDENTIFICATION_LINES; +import static org.apache.sshd.core.CoreModuleProperties.SERVER_EXTRA_IDENT_LINES_SEPARATOR; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.PublicKey; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +import org.apache.sshd.common.NamedFactory; +import org.apache.sshd.common.NamedResource; +import org.apache.sshd.common.PropertyResolver; +import org.apache.sshd.common.SshConstants; +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; +import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory; +import org.apache.sshd.common.signature.BuiltinSignatures; +import org.apache.sshd.common.signature.Signature; +import org.apache.sshd.common.util.buffer.Buffer; +import org.apache.sshd.common.util.security.SecurityUtils; +import org.apache.sshd.common.util.threads.CloseableExecutorService; +import org.apache.sshd.common.util.threads.ThreadUtils; +import org.apache.sshd.server.ServerAuthenticationManager; +import org.apache.sshd.server.ServerBuilder; +import org.apache.sshd.server.SshServer; +import org.apache.sshd.server.auth.UserAuth; +import org.apache.sshd.server.auth.UserAuthFactory; +import org.apache.sshd.server.auth.gss.GSSAuthenticator; +import org.apache.sshd.server.auth.gss.UserAuthGSS; +import org.apache.sshd.server.auth.gss.UserAuthGSSFactory; +import org.apache.sshd.server.auth.keyboard.DefaultKeyboardInteractiveAuthenticator; +import org.apache.sshd.server.command.AbstractCommandSupport; +import org.apache.sshd.server.session.ServerSession; +import org.apache.sshd.server.shell.UnknownCommand; +import org.apache.sshd.server.subsystem.SubsystemFactory; +import org.apache.sshd.sftp.server.SftpSubsystemFactory; +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.annotations.Nullable; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.transport.ReceivePack; +import org.eclipse.jgit.transport.RemoteConfig; +import org.eclipse.jgit.transport.UploadPack; + +/** + * A simple ssh/sftp git <em>test</em> server based on Apache MINA sshd. + * <p> + * Supports only a single repository. Authenticates only the given test user + * against his given test public key. Supports fetch and push. + * </p> + * + * @since 5.2 + */ +public class SshTestGitServer { + + /** + * Simple echo test command. Replies with the command string as passed. If + * of the form "echo [int] anything", takes the integer value as a delay in + * seconds before replying, which may be useful to test various + * timeout-related things. + * + * @since 5.9 + */ + public static final String ECHO_COMMAND = "echo"; + + @NonNull + protected final String testUser; + + @NonNull + protected final Repository repository; + + @NonNull + protected final List<KeyPair> hostKeys = new ArrayList<>(); + + protected final SshServer server; + + @NonNull + protected PublicKey testKey; + + private final CloseableExecutorService executorService = ThreadUtils + .newFixedThreadPool("SshTestGitServerPool", 2); + + /** + * Creates a ssh git <em>test</em> server. It serves one single repository, + * and accepts public-key authentication for exactly one test user. + * + * @param testUser + * user name of the test user + * @param testKey + * public key file of the test user + * @param repository + * to serve + * @param hostKey + * the unencrypted private key to use as host key + * @throws IOException + * if an IO error occurred + * @throws GeneralSecurityException + * if something went wrong + */ + public SshTestGitServer(@NonNull String testUser, @NonNull Path testKey, + @NonNull Repository repository, @NonNull byte[] hostKey) + throws IOException, GeneralSecurityException { + this(testUser, readPublicKey(testKey), repository, + readKeyPair(hostKey)); + } + + /** + * Creates a ssh git <em>test</em> server. It serves one single repository, + * and accepts public-key authentication for exactly one test user. + * + * @param testUser + * user name of the test user + * @param testKey + * public key file of the test user + * @param repository + * to serve + * @param hostKey + * the unencrypted private key to use as host key + * @throws IOException + * if an IO error occurred + * @throws GeneralSecurityException + * if something went wrong + * @since 5.9 + */ + public SshTestGitServer(@NonNull String testUser, @NonNull Path testKey, + @NonNull Repository repository, @NonNull KeyPair hostKey) + throws IOException, GeneralSecurityException { + this(testUser, readPublicKey(testKey), repository, hostKey); + } + + /** + * Creates a ssh git <em>test</em> server. It serves one single repository, + * and accepts public-key authentication for exactly one test user. + * + * @param testUser + * user name of the test user + * @param testKey + * the {@link PublicKey} of the test user + * @param repository + * to serve + * @param hostKey + * the {@link KeyPair} to use as host key + * @since 5.9 + */ + public SshTestGitServer(@NonNull String testUser, + @NonNull PublicKey testKey, @NonNull Repository repository, + @NonNull KeyPair hostKey) { + this.testUser = testUser; + setTestUserPublicKey(testKey); + this.repository = repository; + ServerBuilder builder = ServerBuilder.builder() + .signatureFactories(getSignatureFactories()); + server = builder.build(); + hostKeys.add(hostKey); + server.setKeyPairProvider((session) -> hostKeys); + + configureAuthentication(); + + List<SubsystemFactory> subsystems = configureSubsystems(); + if (!subsystems.isEmpty()) { + server.setSubsystemFactories(subsystems); + } + + configureShell(); + + server.setCommandFactory((channel, command) -> { + if (command.startsWith(RemoteConfig.DEFAULT_UPLOAD_PACK)) { + return new GitUploadPackCommand(command, executorService); + } else if (command.startsWith(RemoteConfig.DEFAULT_RECEIVE_PACK)) { + return new GitReceivePackCommand(command, executorService); + } else if (command.startsWith(ECHO_COMMAND)) { + return new EchoCommand(command, executorService); + } + return new UnknownCommand(command); + }); + } + + /** + * Apache MINA sshd 2.6.0 has removed DSA, DSA_CERT and RSA_CERT. We have to + * set it up explicitly to still allow users to connect with DSA keys. + * + * @return a list of supported signature factories + */ + @SuppressWarnings("deprecation") + private static List<NamedFactory<Signature>> getSignatureFactories() { + // @formatter:off + return Arrays.asList( + BuiltinSignatures.nistp256_cert, + BuiltinSignatures.nistp384_cert, + BuiltinSignatures.nistp521_cert, + BuiltinSignatures.ed25519_cert, + BuiltinSignatures.rsaSHA512_cert, + BuiltinSignatures.rsaSHA256_cert, + BuiltinSignatures.rsa_cert, + BuiltinSignatures.nistp256, + BuiltinSignatures.nistp384, + BuiltinSignatures.nistp521, + BuiltinSignatures.ed25519, + BuiltinSignatures.sk_ecdsa_sha2_nistp256, + BuiltinSignatures.sk_ssh_ed25519, + BuiltinSignatures.rsaSHA512, + BuiltinSignatures.rsaSHA256, + BuiltinSignatures.rsa, + BuiltinSignatures.dsa_cert, + BuiltinSignatures.dsa); + // @formatter:on + } + + private static PublicKey readPublicKey(Path key) + throws IOException, GeneralSecurityException { + return AuthorizedKeyEntry.readAuthorizedKeys(key).get(0) + .resolvePublicKey(null, PublicKeyEntryResolver.IGNORING); + } + + private static KeyPair readKeyPair(byte[] keyMaterial) + throws IOException, GeneralSecurityException { + try (ByteArrayInputStream in = new ByteArrayInputStream(keyMaterial)) { + return SecurityUtils.loadKeyPairIdentities(null, null, in, null) + .iterator().next(); + } + } + + private static class FakeUserAuthGSS extends UserAuthGSS { + @Override + protected @Nullable Boolean doAuth(Buffer buffer, boolean initial) + throws Exception { + // We always reply that we did do this, but then we fail at the + // first token message. That way we can test that the client-side + // sends the correct initial request and then is skipped correctly, + // even if it causes a GSSException if Kerberos isn't configured at + // all. + if (initial) { + ServerSession session = getServerSession(); + Buffer b = session.createBuffer( + SshConstants.SSH_MSG_USERAUTH_INFO_REQUEST); + b.putBytes(KRB5_MECH.getDER()); + session.writePacket(b); + return null; + } + return Boolean.FALSE; + } + } + + private List<UserAuthFactory> getAuthFactories() { + List<UserAuthFactory> authentications = new ArrayList<>(); + authentications.add(new UserAuthGSSFactory() { + @Override + public UserAuth createUserAuth(ServerSession session) + throws IOException { + return new FakeUserAuthGSS(); + } + }); + authentications.add( + ServerAuthenticationManager.DEFAULT_USER_AUTH_PUBLIC_KEY_FACTORY); + authentications.add( + ServerAuthenticationManager.DEFAULT_USER_AUTH_KB_INTERACTIVE_FACTORY); + authentications.add( + ServerAuthenticationManager.DEFAULT_USER_AUTH_PASSWORD_FACTORY); + return authentications; + } + + /** + * Configures the authentication mechanisms of this test server. Invoked + * from the constructor. The default sets up public key authentication for + * the test user, and a gssapi-with-mic authenticator that pretends to + * support this mechanism, but that then refuses to authenticate anyone. + */ + protected void configureAuthentication() { + server.setUserAuthFactories(getAuthFactories()); + // Disable some authentications + server.setPasswordAuthenticator(null); + server.setKeyboardInteractiveAuthenticator(null); + server.setHostBasedAuthenticator(null); + // Pretend we did gssapi-with-mic. + server.setGSSAuthenticator(new GSSAuthenticator() { + @Override + public boolean validateInitialUser(ServerSession session, + String user) { + return false; + } + }); + // Accept only the test user/public key + server.setPublickeyAuthenticator((userName, publicKey, session) -> { + return SshTestGitServer.this.testUser.equals(userName) && KeyUtils + .compareKeys(SshTestGitServer.this.testKey, publicKey); + }); + } + + /** + * Configures the test server's subsystems (sftp, scp). Invoked from the + * constructor. The default provides a simple SFTP setup with the root + * directory as the given repository's .git directory's parent. (I.e., at + * the directory containing the .git directory.) + * + * @return A possibly empty collection of subsystems. + */ + @NonNull + protected List<SubsystemFactory> configureSubsystems() { + // SFTP. + server.setFileSystemFactory(new VirtualFileSystemFactory(repository + .getDirectory().getParentFile().getAbsoluteFile().toPath())); + return Collections + .singletonList(new SftpSubsystemFactory.Builder().build()); + } + + /** + * Configures shell access for the test server. The default provides no + * shell at all. + */ + protected void configureShell() { + // No shell + server.setShellFactory(null); + } + + /** + * Adds an additional host key to the server. + * + * @param key + * path to the private key file; should not be encrypted + * @param inFront + * whether to add the new key before other existing keys + * @throws IOException + * if the file denoted by the {@link Path} {@code key} cannot be + * read + * @throws GeneralSecurityException + * if the key contained in the file cannot be read + */ + public void addHostKey(@NonNull Path key, boolean inFront) + throws IOException, GeneralSecurityException { + try (InputStream in = Files.newInputStream(key)) { + KeyPair pair = SecurityUtils + .loadKeyPairIdentities(null, + NamedResource.ofName(key.toString()), in, null) + .iterator().next(); + addHostKey(pair, inFront); + } + } + + /** + * Adds an additional host key to the server. + * + * @param key + * {@link KeyPair} to add + * @param inFront + * whether to add the new key before other existing keys + * @since 5.8 + */ + public void addHostKey(@NonNull KeyPair key, boolean inFront) { + if (inFront) { + hostKeys.add(0, key); + } else { + hostKeys.add(key); + } + } + + /** + * Enable password authentication. The server will accept the test user's + * name, converted to all upper-case, as password. + */ + public void enablePasswordAuthentication() { + server.setPasswordAuthenticator((user, pwd, session) -> { + return testUser.equals(user) + && testUser.toUpperCase(Locale.ROOT).equals(pwd); + }); + } + + /** + * Enable keyboard-interactive authentication. The server will accept the + * test user's name, converted to all upper-case, as password. + */ + public void enableKeyboardInteractiveAuthentication() { + server.setPasswordAuthenticator((user, pwd, session) -> { + return testUser.equals(user) + && testUser.toUpperCase(Locale.ROOT).equals(pwd); + }); + server.setKeyboardInteractiveAuthenticator( + DefaultKeyboardInteractiveAuthenticator.INSTANCE); + } + + /** + * Retrieves the server's {@link PropertyResolver}, giving access to server + * properties. + * + * @return the {@link PropertyResolver} + * @since 5.9 + */ + public PropertyResolver getPropertyResolver() { + return server; + } + + /** + * Starts the test server, listening on a random port. + * + * @return the port the server listens on; test clients should connect to + * that port + * @throws IOException + * if an IO error occurred + */ + public int start() throws IOException { + server.start(); + return server.getPort(); + } + + /** + * Stops the test server. + * + * @throws IOException + * if an IO error occurred + */ + public void stop() throws IOException { + executorService.shutdownNow(); + server.stop(true); + } + + /** + * Sets the test user's public key on the server. + * + * @param key + * to set + * @throws IOException + * if the file cannot be read + * @throws GeneralSecurityException + * if the public key cannot be extracted from the file + */ + public void setTestUserPublicKey(Path key) + throws IOException, GeneralSecurityException { + this.testKey = readPublicKey(key); + } + + /** + * Sets the test user's public key on the server. + * + * @param key + * to set + * + * @since 5.8 + */ + public void setTestUserPublicKey(@NonNull PublicKey key) { + this.testKey = key; + } + + /** + * Sets the lines the server sends before its server identification in the + * initial protocol version exchange. + * + * @param lines + * to send + * @since 5.5 + */ + public void setPreamble(String... lines) { + if (lines != null && lines.length > 0) { + SERVER_EXTRA_IDENTIFICATION_LINES.set(server, String.join( + String.valueOf(SERVER_EXTRA_IDENT_LINES_SEPARATOR), lines)); + } + } + + private class GitUploadPackCommand extends AbstractCommandSupport { + + protected GitUploadPackCommand(String command, + CloseableExecutorService executorService) { + super(command, ThreadUtils.noClose(executorService)); + } + + @Override + public void run() { + try (UploadPack uploadPack = new UploadPack(repository)) { + String gitProtocol = getEnvironment().getEnv() + .get("GIT_PROTOCOL"); + if (gitProtocol != null) { + uploadPack.setExtraParameters( + Collections.singleton(gitProtocol)); + } + uploadPack.upload(getInputStream(), getOutputStream(), + getErrorStream()); + onExit(0); + } catch (IOException e) { + log.warn( + MessageFormat.format("Could not run {0}", getCommand()), + e); + onExit(-1, e.toString()); + } + } + + } + + private class GitReceivePackCommand extends AbstractCommandSupport { + + protected GitReceivePackCommand(String command, + CloseableExecutorService executorService) { + super(command, ThreadUtils.noClose(executorService)); + } + + @Override + public void run() { + try { + new ReceivePack(repository).receive(getInputStream(), + getOutputStream(), getErrorStream()); + onExit(0); + } catch (IOException e) { + log.warn( + MessageFormat.format("Could not run {0}", getCommand()), + e); + onExit(-1, e.toString()); + } + } + + } + + /** + * Simple echo command that echoes back the command string. If the first + * argument is a positive integer, it's taken as a delay (in seconds) before + * replying. Assumes UTF-8 character encoding. + */ + private static class EchoCommand extends AbstractCommandSupport { + + protected EchoCommand(String command, + CloseableExecutorService executorService) { + super(command, ThreadUtils.noClose(executorService)); + } + + @Override + public void run() { + String[] parts = getCommand().split(" "); + int timeout = 0; + if (parts.length >= 2) { + try { + timeout = Integer.parseInt(parts[1]); + } catch (NumberFormatException e) { + // No timeout. + } + if (timeout > 0) { + try { + Thread.sleep(TimeUnit.SECONDS.toMillis(timeout)); + } catch (InterruptedException e) { + // Ignore. + } + } + } + try { + doEcho(getCommand(), getOutputStream()); + onExit(0); + } catch (IOException e) { + log.warn( + MessageFormat.format("Could not run {0}", getCommand()), + e); + onExit(-1, e.toString()); + } + } + + private void doEcho(String text, OutputStream stream) + throws IOException { + stream.write(text.getBytes(StandardCharsets.UTF_8)); + stream.flush(); + } + } +} |