/* * Copyright (C) 2018, 2020 Thomas Wolf 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 test server based on Apache MINA sshd. *

* Supports only a single repository. Authenticates only the given test user * against his given test public key. Supports fetch and push. *

* * @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 hostKeys = new ArrayList<>(); protected final SshServer server; @NonNull protected PublicKey testKey; private final CloseableExecutorService executorService = ThreadUtils .newFixedThreadPool("SshTestGitServerPool", 2); /** * Creates a ssh git test 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 test 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 test 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 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> 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 getAuthFactories() { List 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 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(); } } }