blob: 1138be8a96c523cad3cc73571993c34e568f371a [file] [log] [blame]
/*
* 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();
}
}
}