diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 0000000..c6bb036 --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,35 @@ +# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Java CI with Maven + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + - name: Build with Maven + run: mvn -B package --file pom.xml + + # Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive + - name: Update dependency graph + uses: advanced-security/maven-dependency-submission-action@571e99aab1055c2e71a1e2309b9691de18d6b7d6 diff --git a/.gitignore b/.gitignore index e24253b..21c246c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ target *.iml .classpath .project +.settings diff --git a/README.md b/README.md index 1ed37c3..d2f3924 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,25 @@ # sham.software Mock SSH API -Mock SSH and SFTP testing library for running an SSH server in process. SFTP uses a local temporary directory +Mock SSH and SFTP testing library for running an SSH server in process. SFTP uses a local temporary directory. -This library is still under development. Feel free to use, contribute but there could be changes to -the API until it reaches 1.0 status. Of course, we'll try to keep breaking changes to a minimum. +## Development Notes +![Maven Build Status](https://github.com/janesser/sham-ssh/actions/workflows/maven.yml/badge.svg) + +04.02.2025 1.0.0-RC2 is ready +* restored sftp +* introduced git-pack + +02.02.2025 1.0.0-RC1 is ready +* swapping maverick-synergy-client for JSch (for testing) +* completing the mocking framework to survive basic checks from ssh-client +(greeting, prompt and echo-ing) +* support command or shell mode exclusively + +25.01.2025 0.4.0 is ready +* revamped with latest versions of sshd-core and sshd-sftp. + +Hope to have soon artifacts on MC. **Table of Contents** @@ -16,11 +31,17 @@ the API until it reaches 1.0 status. Of course, we'll try to keep breaking chang ### Maven +```shell +git checkout https://github.com/janesser/sham-ssh +cd sham-ssh +mvn install +``` + ```xml software.sham sham-ssh - 0.1.0 + 1.0.0-SNAPSHOT ``` diff --git a/pom.xml b/pom.xml index aed2f34..0f48ac8 100644 --- a/pom.xml +++ b/pom.xml @@ -1,239 +1,254 @@ - - 4.0.0 + + 4.0.0 - software.sham - sham-ssh - 0.1.0 - jar - Mock SSH and SFTP Testing API - http://sham.software/ssh - Testing library for mocking interactions to SSH and SFTP servers - - scm:git:git@github.com:shamsoftware/sham-ssh.git - scm:git:git@github.com:shamsoftware/sham-ssh.git - scm:git:git@github.com:shamsoftware/sham-ssh.git - - - - The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - repo - - - - - rhoegg - Ryan Hoegg - ryan.hoegg@gmail.com - - - - - sonatype-nexus-snapshots - Sonatype Nexus Snapshots - https://oss.sonatype.org/content/repositories/snapshots/ - - - sonatype-nexus-staging - Nexus Release Repository - https://oss.sonatype.org/service/local/staging/deploy/maven2/ - - - - UTF-8 - UTF-8 - 1.7 - 2.1 - 1.7.7 - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.3 - - ${jdk.version} - ${jdk.version} - ISO-8859-1 - - - - org.apache.maven.plugins - maven-source-plugin - 2.4 - - - attach-sources - - jar - - - - - - - - - - - org.apache.logging.log4j - log4j-api - ${log4j.version} - true - - - org.apache.logging.log4j - log4j-core - ${log4j.version} - true - - - org.apache.logging.log4j - log4j-slf4j-impl - ${log4j.version} - true - - - org.slf4j - jcl-over-slf4j - ${sl4fj.version} - true - - - org.slf4j - slf4j-api - ${sl4fj.version} - + software.sham + sham-ssh + 1.0.0-SNAPSHOT + jar + Mock SSH and SFTP Testing API + http://sham.software/ssh + Testing library for mocking interactions to SSH and SFTP + servers + + scm:git:git@github.com:shamsoftware/sham-ssh.git + scm:git:git@github.com:shamsoftware/sham-ssh.git + scm:git:git@github.com:shamsoftware/sham-ssh.git + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + rhoegg + Ryan Hoegg + ryan.hoegg@gmail.com + + + janesser + Jan Esser + jesser@gmx.de + + + + + sonatype-nexus-snapshots + Sonatype Nexus Snapshots + https://oss.sonatype.org/content/repositories/snapshots/ + + + sonatype-nexus-staging + Nexus Release Repository + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + UTF-8 + UTF-8 + 8 + 2.14.0 + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${jdk.version} + ${jdk.version} + ISO-8859-1 + + + + org.apache.maven.plugins + maven-source-plugin + 3.4.0 + + + attach-sources + + jar + + + + + + + + + + org.apache.sshd + sshd-core + ${sshd.version} + + + org.apache.sshd + sshd-sftp + ${sshd.version} + + + org.apache.sshd + sshd-git + ${sshd.version} + + + org.hamcrest + hamcrest-core + 3.0 + + + + net.i2p.crypto + eddsa + 0.3.0 + - - - junit - junit - 4.12 - - compile - true - - - org.mockito - mockito-core - 1.9.5 - test - - - - - - - org.hamcrest - hamcrest-core - 1.3 - - - org.hamcrest - hamcrest-library - 1.3 - - - org.apache.sshd - sshd-core - 1.0.0 - - - com.jcraft - jsch - 0.1.53 - - - - org.bouncycastle - bcpkix-jdk15on - 1.49 - - - commons-io - commons-io - 2.4 - + + + org.slf4j + slf4j-api + 2.0.16 + + + org.slf4j + slf4j-simple + 2.0.16 + - - - org.apache.logging.log4j - log4j-api - - - org.apache.logging.log4j - log4j-core - - - org.apache.logging.log4j - log4j-slf4j-impl - - - org.slf4j - jcl-over-slf4j - - - org.slf4j - slf4j-api - - - com.lmax - disruptor - 3.3.2 - true - + + + com.sshtools + maverick-synergy-client + 3.1.2 + + + org.bouncycastle + bcpg-jdk18on + 1.80 + + + org.bouncycastle + bcpkix-jdk18on + 1.80 + + + junit + junit + 4.13.2 + + + org.awaitility + awaitility + 4.2.2 + + + + + + org.apache.sshd + sshd-core + + + org.apache.sshd + sshd-sftp + true + + + org.apache.sshd + sshd-git + true + + + org.hamcrest + hamcrest-core + + + net.i2p.crypto + eddsa + true + + + org.slf4j + slf4j-api + - - - junit - junit - - - org.mockito - mockito-core - - - - - - sonatype - - - sonatype-public - Sonatype OSS Maven Repo (snapshots) - https://oss.sonatype.org/content/groups/public - - true - - - true - - - - - - - org.apache.maven.plugins - maven-gpg-plugin - 1.4 - - - sign-artifacts - install - - sign - - - - - ${sham.keyname} - - - - - - + + + com.sshtools + maverick-synergy-client + test + + + org.bouncycastle + bcpg-jdk18on + test + + + org.bouncycastle + bcpkix-jdk18on + test + + + junit + junit + test + + + org.slf4j + slf4j-simple + test + + + org.awaitility + awaitility + test + + + + + + sonatype + + + sonatype-public + Sonatype OSS Maven Repo (snapshots) + https://oss.sonatype.org/content/groups/public + + true + + + true + + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.4 + + + sign-artifacts + install + + sign + + + + + ${sham.keyname} + + + + + + diff --git a/src/main/java/software/sham/git/MockGitServer.java b/src/main/java/software/sham/git/MockGitServer.java new file mode 100644 index 0000000..d6d25e4 --- /dev/null +++ b/src/main/java/software/sham/git/MockGitServer.java @@ -0,0 +1,69 @@ +package software.sham.git; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.sshd.git.GitLocationResolver; +import org.apache.sshd.git.pack.GitPackCommandFactory; + +import software.sham.ssh.MockSshServer; + +public class MockGitServer extends MockSshServer { + + private static final String MOCK_GIT_DIRECTORY = "mock_git_root"; + private Path gitRoot; + + public MockGitServer(int port) throws IOException { + super(port, false); + + gitRoot = Files.createTempDirectory(MOCK_GIT_DIRECTORY); + gitRoot.toFile().deleteOnExit(); + + GitPackCommandFactory commandFactory = new GitPackCommandFactory() + .withGitLocationResolver(GitLocationResolver.constantPath(gitRoot)); + super.getSshServer().setCommandFactory(commandFactory); + } + + public Path prepareGitProject(String name) throws IOException, InterruptedException { + Path gitProject = Files.createDirectories(gitRoot.resolve(name)); + + runGit(gitProject, new String[] { "/usr/bin/git", "init" }, // + "Couldn't initialiaze project at: " + gitProject); + runGit(gitProject, new String[] { "/usr/bin/git", "config", "user.email", "tester@localhost" }, + "Couldn't config user.email."); + runGit(gitProject, new String[] { "/usr/bin/git", "config", "user.name", "the tester" }, + "Couldn't config user.name."); + + Path readMeFile = gitProject.resolve("README"); + if (!Files.exists(readMeFile)) + Files.createFile(readMeFile); + Files.writeString(readMeFile, "nothing serious"); + runGit(gitProject, new String[] { "/usr/bin/git", "add", gitProject.relativize(readMeFile).toString() }, // + "Couldn't add README."); + + runGit(gitProject, new String[] { "/usr/bin/git", "commit", "-m", "'Initial commit.'" }, // + "Couldn't commit."); + + return gitProject; + } + + private void runGit(Path gitProject, String[] cmd, String errorMsg) throws InterruptedException, IOException { + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.directory(gitProject.toFile()); + pb.redirectErrorStream(true); + Process p = pb.start(); + String processOutput = readProcessOutput(p); + if (p.exitValue() != 0) { + throw new RuntimeException(errorMsg + processOutput); + } + + } + + private String readProcessOutput(Process p) throws IOException, InterruptedException { + BufferedReader reader = p.inputReader(); + String processOutput = reader.lines().reduce("", (a, b) -> a + "\n" + b); + return String.format("\n(returncode: %d) %s", p.waitFor(), processOutput); + } +} diff --git a/src/main/java/software/sham/sftp/MockSftpServer.java b/src/main/java/software/sham/sftp/MockSftpServer.java index ddd240c..afb099a 100644 --- a/src/main/java/software/sham/sftp/MockSftpServer.java +++ b/src/main/java/software/sham/sftp/MockSftpServer.java @@ -1,66 +1,44 @@ package software.sham.sftp; -import org.apache.commons.io.FileUtils; -import org.apache.sshd.common.NamedFactory; -import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory; -import org.apache.sshd.common.keyprovider.AbstractClassLoadableResourceKeyPairProvider; -import org.apache.sshd.common.util.SecurityUtils; -import org.apache.sshd.server.Command; -import org.apache.sshd.server.SshServer; -import org.apache.sshd.server.auth.password.PasswordAuthenticator; -import org.apache.sshd.server.command.ScpCommandFactory; -import org.apache.sshd.server.session.ServerSession; -import org.apache.sshd.server.subsystem.sftp.SftpSubsystemFactory; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.sham.ssh.MockSshServer; - import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Arrays; +import java.util.Collections; + +import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory; +import org.apache.sshd.sftp.server.SftpSubsystemFactory; + +import software.sham.ssh.MockSshServer; public class MockSftpServer extends MockSshServer { - private final Logger logger = LoggerFactory.getLogger(this.getClass()); - private Path baseDirectory; + private static final String MOCK_SFTP_DIRECTORY = "mock_sftp_root"; - public MockSftpServer(int port) throws IOException { - this(port, false); - } + private final Path baseDirectory; - private MockSftpServer(int port, boolean enableShell) throws IOException { - super(port, false); - initSftp(); - if (enableShell) { - enableShell(); - } - start(); - } + public MockSftpServer(int port) throws IOException { + super(port, false); - private void initSftp() { - sshServer.setCommandFactory(new ScpCommandFactory()); - sshServer.setSubsystemFactories(Arrays.>asList(new SftpSubsystemFactory())); - } + SftpSubsystemFactory sftpSubsystemFactory = new SftpSubsystemFactory.Builder().build(); + super.getSshServer().setSubsystemFactories(Collections.singletonList(sftpSubsystemFactory)); - public Path getBaseDirectory() { - return baseDirectory; - } + this.baseDirectory = Files.createTempDirectory(MOCK_SFTP_DIRECTORY); + log.info("baseDirectory: " + baseDirectory.toAbsolutePath().toString()); + super.getSshServer().setFileSystemFactory(new VirtualFileSystemFactory(baseDirectory.toAbsolutePath())); + } - @Override - public void start() throws IOException { - baseDirectory = Files.createTempDirectory("sftproot"); - sshServer.setFileSystemFactory(new VirtualFileSystemFactory(baseDirectory.toAbsolutePath().toString())); - super.start(); - } + @Override + public void start() throws IOException { + super.start(); + } - @Override - public void stop() throws IOException { - super.stop(); - FileUtils.deleteQuietly(baseDirectory.toFile()); - } + @Override + public void stop() throws IOException { + super.stop(); + this.baseDirectory.toFile().delete(); + } - public static MockSftpServer createWithShell(int port) throws IOException { - return new MockSftpServer(port, true); - } + public Path getBaseDirectory() { + return baseDirectory; + } } diff --git a/src/main/java/software/sham/ssh/CommandParserSupport.java b/src/main/java/software/sham/ssh/CommandParserSupport.java new file mode 100644 index 0000000..e7f1863 --- /dev/null +++ b/src/main/java/software/sham/ssh/CommandParserSupport.java @@ -0,0 +1,43 @@ +package software.sham.ssh; + +import java.util.ArrayList; + +import org.apache.sshd.common.util.threads.CloseableExecutorService; +import org.apache.sshd.server.command.AbstractCommandSupport; + +abstract class CommandParserSupport extends AbstractCommandSupport { + + private static final String COMMAND_SEPERATOR = ";"; + private static final String COMMAND_QUOTES = "\""; + + public CommandParserSupport(String command, CloseableExecutorService executorService) { + super(command, executorService); + } + + String[] divideCommands(String line) { + String[] frags = line.split(CommandParserSupport.COMMAND_SEPERATOR); + + ArrayList commands = new ArrayList(); + + int i = 0; + while (i < frags.length) { + String frag = frags[i]; + + while ((frag.length() - frag.replace(CommandParserSupport.COMMAND_QUOTES, "").length()) % 2 == 1) { + if (i + 1 < frags.length) { + frag += CommandParserSupport.COMMAND_SEPERATOR; + frag += frags[++i]; + } else { + log.error("command division met unbalanced quote situation in: " + line); + return new String[] { line }; + } + } + + commands.add(frag); + i++; + } + + return commands.toArray(new String[0]); + } + +} \ No newline at end of file diff --git a/src/main/java/software/sham/ssh/MockSshServer.java b/src/main/java/software/sham/ssh/MockSshServer.java index f7fc0c4..5c755fd 100644 --- a/src/main/java/software/sham/ssh/MockSshServer.java +++ b/src/main/java/software/sham/ssh/MockSshServer.java @@ -1,118 +1,127 @@ package software.sham.ssh; -import org.apache.sshd.common.Factory; -import org.apache.sshd.common.keyprovider.AbstractClassLoadableResourceKeyPairProvider; -import org.apache.sshd.common.util.SecurityUtils; -import org.apache.sshd.server.Command; -import org.apache.sshd.server.CommandFactory; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.PublicKey; +import java.util.HashSet; +import java.util.Set; + +import org.apache.sshd.common.util.security.SecurityUtils; import org.apache.sshd.server.SshServer; import org.apache.sshd.server.auth.password.PasswordAuthenticator; import org.apache.sshd.server.auth.pubkey.KeySetPublickeyAuthenticator; +import org.apache.sshd.server.channel.ChannelSession; +import org.apache.sshd.server.command.AbstractCommandSupport; +import org.apache.sshd.server.command.Command; +import org.apache.sshd.server.command.CommandFactory; +import org.apache.sshd.server.keyprovider.AbstractGeneratorHostKeyProvider; import org.apache.sshd.server.session.ServerSession; +import org.apache.sshd.server.shell.ShellFactory; import org.hamcrest.Matcher; -import org.hamcrest.Matchers; +import org.hamcrest.core.IsInstanceOf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.security.GeneralSecurityException; -import java.security.KeyFactory; -import java.security.NoSuchAlgorithmException; -import java.security.PublicKey; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.KeySpec; -import java.security.spec.X509EncodedKeySpec; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -public class MockSshServer implements Factory, CommandFactory { - public static final String USERNAME = "tester"; - public static final String PASSWORD = "testing"; - protected final SshServer sshServer; - private Set keys = new HashSet(); - - private final Logger logger = LoggerFactory.getLogger(this.getClass()); - private MockSshShell sshShell; - - public MockSshServer(int port) throws IOException { - this(port, true); - } - - protected MockSshServer(int port, boolean shouldStartServices) throws IOException { - sshServer = initSshServer(port); - if (shouldStartServices) { - enableShell(); - start(); - } - } - - /** - * @param key Key in DER format - */ - public MockSshServer allowPublicKey(byte[] key) throws GeneralSecurityException { - final KeySpec spec = new X509EncodedKeySpec(key); - keys.add(KeyFactory.getInstance("RSA").generatePublic(spec)); - sshServer.setPublickeyAuthenticator(new KeySetPublickeyAuthenticator(this.keys)); - return this; - } - - public SshResponderBuilder respondTo(Matcher matcher) { - SshResponderBuilder builder = new SshResponderBuilder(); - sshShell.getDispatcher().add(matcher, builder.getResponder()); - return builder; - } - - public SshResponderBuilder respondTo(String input) { - return respondTo(Matchers.equalTo(input)); - } - - public MockSshServer enableShell() { - logger.info("Mock SSH shell is enabled"); - sshShell = new MockSshShell(); - setDefaults(); - sshServer.setShellFactory(this); - return this; - } - - public void start() throws IOException { - AbstractClassLoadableResourceKeyPairProvider keyPairProvider = SecurityUtils.createClassLoadableResourceKeyPairProvider(); - keyPairProvider.setResources(Arrays.asList("keys/sham-ssh-id-dsa")); - sshServer.setKeyPairProvider(keyPairProvider); - - sshServer.start(); - } - - public void stop() throws IOException { - sshServer.stop(); - } - - protected SshServer initSshServer(int port) { - final SshServer sshd = SshServer.setUpDefaultServer(); - sshd.setPort(port); - sshd.setPasswordAuthenticator(new PasswordAuthenticator() { - @Override - public boolean authenticate(String username, String password, ServerSession session) { - return USERNAME.equals(username) && PASSWORD.equals(password); - } - - }); - sshd.setPublickeyAuthenticator(new KeySetPublickeyAuthenticator(this.keys)); - return sshd; - } - - private void setDefaults() { - respondTo("exit").withClose(); - } - - @Override - public Command create() { - logger.debug("Creating mock SSH shell"); - return sshShell; - } - - @Override - public Command createCommand(String command) { - return create(); - } +public class MockSshServer implements ShellFactory, CommandFactory { + public static final String USERNAME = "tester"; + public static final String PASSWORD = "testing"; + + protected final Logger log = LoggerFactory.getLogger(this.getClass()); + + private final Set keys = new HashSet(); + private final ResponderDispatcher dispatcher = new ResponderDispatcher(); + private final SshServer sshServer; + private MockSshShell sshShell; + + public MockSshServer(int port) throws IOException { + this(port, true); + } + + public MockSshServer(int port, boolean shouldStartServices) throws IOException { + this.sshServer = SshServer.setUpDefaultServer(); + this.sshServer.setPort(port); + + this.sshServer.setPasswordAuthenticator(new PasswordAuthenticator() { + @Override + public boolean authenticate(String username, String password, ServerSession session) { + return USERNAME.equals(username) && PASSWORD.equals(password); + } + + }); + this.sshServer.setPublickeyAuthenticator(new KeySetPublickeyAuthenticator(this, this.keys)); + + this.sshServer.setCommandFactory(this); + + if (shouldStartServices) { + enableShell(); + start(); + } + } + + public MockSshServer allowPublicKey(PublicKey publicKey) { + this.keys.add(publicKey); + return this; + } + + /** + * enables shell which basically reacts to `exit` and `echo`. + * + * @return fluent-api + */ + public MockSshServer enableShell() { + log.info("Mock SSH shell is enabled"); + sshShell = new MockSshShell(this.dispatcher); + sshShell.setDefaults(); + sshServer.setShellFactory(this); + return this; + } + + public void start() throws IOException { + Path serverKeyPath = Files.createTempFile("sham-mock-sshd-key", null); + AbstractGeneratorHostKeyProvider keyProvider = SecurityUtils.createGeneratorHostKeyProvider(serverKeyPath); + sshServer.setKeyPairProvider(keyProvider); + + sshServer.start(); + } + + public void stop() throws IOException { + sshServer.stop(); + } + + public SshResponderBuilder respondTo(Matcher matcher) { + if (IsInstanceOf.class.equals(matcher.getClass())) + log.warn("Be careful with 'any' matcher, it may harm basic functions."); + return this.dispatcher.respondTo(matcher); + } + + public SshResponderBuilder respondTo(String input) { + return this.dispatcher.respondTo(input); + } + + @Override + public Command createShell(ChannelSession channel) throws IOException { + return this.sshShell; // mocked singleton + } + + @Override + public Command createCommand(final ChannelSession channel, String command) throws IOException { + return new AbstractCommandSupport(command, null) { + + @Override + public void run() { + try { + dispatcher.find(command).respond(getServerSession(), command, getOutputStream()); + Thread.sleep(100L); // graceful wait for client read + onExit(0); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } + }; + } + + protected SshServer getSshServer() { + return sshServer; + } } diff --git a/src/main/java/software/sham/ssh/MockSshShell.java b/src/main/java/software/sham/ssh/MockSshShell.java index 4b91783..a128ee8 100644 --- a/src/main/java/software/sham/ssh/MockSshShell.java +++ b/src/main/java/software/sham/ssh/MockSshShell.java @@ -1,147 +1,73 @@ package software.sham.ssh; -import org.apache.commons.io.IOUtils; -import org.apache.sshd.server.Command; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; + import org.apache.sshd.server.Environment; -import org.apache.sshd.server.ExitCallback; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.*; -import java.nio.ByteBuffer; -import java.nio.CharBuffer; -import java.nio.channels.Channels; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.LinkedList; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; - -public class MockSshShell implements Command { - private final Logger logger = LoggerFactory.getLogger(getClass()); - private InputStream in; - private OutputStream out; - private OutputStream err; - private ExitCallback callback; - private final ResponderDispatcher dispatcher = new ResponderDispatcher(); - private final ExecutorService executor = Executors.newSingleThreadExecutor(); - private MockShellEventLoop eventLoop = new MockShellEventLoop(this); - private Future eventLoopFuture; - - @Override - public void setInputStream(InputStream in) { - this.in = in; - } - - @Override - public void setOutputStream(OutputStream out) { - this.out = out; - } - - @Override - public void setErrorStream(OutputStream err) { - this.err = err; - } - - @Override - public void setExitCallback(ExitCallback callback) { - this.callback = callback; - } - - @Override - public void start(Environment env) throws IOException { - logger.debug("Starting mock SSH shell"); - eventLoopFuture = (Future) executor.submit(eventLoop); - } - - @Override - public void destroy() { - closeSession(); - executor.shutdown(); - } - - public void closeSession() { - eventLoop.stop(); - } - - protected List readInput() throws IOException { - StringBuffer sb = new StringBuffer(); - final Charset charset = StandardCharsets.UTF_8; - ByteBuffer buffer = ByteBuffer.allocate(1024); - int len = in.available(); - while (len > 0) { - if (len > 1024) len = 1024; - int lenRead = in.read(buffer.array(), 0, len); - CharBuffer cb = charset.decode(buffer); - sb.append(cb, 0, lenRead); - logger.trace("Read {} characters from {} bytes", cb.length(), lenRead); - len = in.available(); - } - return Arrays.asList(sb.toString().split("\\r?\\n")); - } - - protected void writeError(Exception e) throws IOException { - Writer writer = Channels.newWriter(Channels.newChannel(err), StandardCharsets.UTF_8.name()); - writer.write(e.toString()); - writer.flush(); - writer.close(); - } - - public void sendResponse(String output) { - try { - out.write(output.getBytes()); - logger.trace("Wrote output {}", output); - out.flush(); - } catch (IOException e) { - logger.error("Error sending response to client", e); - } - } - - public ResponderDispatcher getDispatcher() { - return this.dispatcher; - } - - public class MockShellEventLoop implements Runnable { - private boolean stopped = false; - private final MockSshShell shell; - public MockShellEventLoop(MockSshShell shell) { - this.shell = shell; - } - - public void stop() { - if (!this.stopped) { - this.stopped = true; - logger.info("Stopped Mock SSH shell event loop"); - } - } - - @Override - public void run() { - while(! stopped) { - logger.trace("Polling input..."); - try { - List input = shell.readInput(); - logger.trace("Returned from reading input"); - for (String line : input) { - logger.debug("SSH server received input [{}]", line.toString()); - dispatcher.find(line).respond(shell); - } - Thread.sleep(100); - } catch (IOException e) { - try { - shell.writeError(e); - } catch (IOException e2) { - System.err.println(e2.toString()); - } - } catch (InterruptedException e) { - logger.debug("Interrupted event loop thread: " + e.getMessage()); - } - } - logger.debug("Event loop completed"); - callback.onExit(0); - } - } +import org.apache.sshd.server.channel.ChannelSession; +import org.hamcrest.Matchers; + +import software.sham.ssh.actions.Action; +import software.sham.ssh.actions.Greet; +import software.sham.ssh.actions.Prompt; + +class MockSshShell extends CommandParserSupport { + + private final ResponderDispatcher dispatcher; + + private final Action greet = new Greet(); + private final Action prompt = new Prompt(); + + protected MockSshShell(ResponderDispatcher dispatcher) { + super("shell", null); + + this.dispatcher = dispatcher; + } + + @Override + public void start(ChannelSession channelSession, Environment env) throws IOException { + greet.respond(getServerSession(), getOutputStream()); + + executorService.submit(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(getInputStream()))) { + while (super.getServerSession().isOpen()) { + String[] commands = super.divideCommands(reader.readLine()); + for (String command : commands) { + SshResponder responder = dispatcher.find(command.trim()); + responder.respond(getServerSession(), command, getOutputStream()); + } + + prompt.respond(getServerSession(), getOutputStream()); + + getOutputStream().flush(); + } + } catch (IOException ex) { + log.error("Shell aborting due to Exception.", ex); + } + }); + } + + @Override + public void destroy(ChannelSession channelSession) throws Exception { + executorService.shutdown(); + } + + @Override + public void run() { + // do nothing here + } + + /** + * Activate mocked behavior for + *
    + *
  • basic `echo` (including mocked $?)
  • + *
  • `exit` closing connection from server-end
  • + *
+ */ + public void setDefaults() { + this.dispatcher.respondTo(Matchers.startsWith("echo")).withEcho(); + + this.dispatcher.respondTo("exit").withClose(); + } } diff --git a/src/main/java/software/sham/ssh/ResponderDispatcher.java b/src/main/java/software/sham/ssh/ResponderDispatcher.java index 1a61a23..64d5911 100644 --- a/src/main/java/software/sham/ssh/ResponderDispatcher.java +++ b/src/main/java/software/sham/ssh/ResponderDispatcher.java @@ -1,6 +1,7 @@ package software.sham.ssh; import org.hamcrest.Matcher; +import org.hamcrest.Matchers; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -8,27 +9,48 @@ import java.util.LinkedList; import java.util.Map; -public class ResponderDispatcher { - private final Logger logger = LoggerFactory.getLogger(getClass()); - private final Map responders = new HashMap<>(); - private final LinkedList matchers = new LinkedList<>(); - - public void add(Matcher matcher, SshResponder responder) { - matchers.add(0, matcher); // matchers added last take precedence - responders.put(matcher, responder); - } - - public SshResponder find(String input) { - for (Matcher matcher : matchers) { - logger.debug("checking {}", matcher.toString()); - if (matcher.matches(input)) { - logger.debug("Found responder for " + matcher.toString()); - return responders.get(matcher); - } else { - logger.debug("did not match " +matcher.toString()); - } - } - logger.info("No responder found for input " + input); - return SshResponder.NULL; - } +/** + * dispatcher.matchers (reverse-order) -> responder + * + * TODO pass-through input + */ +class ResponderDispatcher { + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final Map, SshResponder> responders = new HashMap<>(); + private final LinkedList> matchers = new LinkedList<>(); + + public SshResponderBuilder respondTo(Matcher matcher) { + SshResponderBuilder builder = SshResponderBuilder.builder(); + this.add(matcher, builder.getResponder()); + return builder; + } + + public SshResponderBuilder respondTo(String input) { + return this.respondTo(Matchers.equalTo(input)); + } + + public SshResponder find(String input) { + for (Matcher matcher : matchers) { + logger.debug("checking {}", matcher.toString()); + if (matcher.matches(input)) { + logger.debug("Found responder for " + matcher.toString()); + return responders.get(matcher); + } else { + logger.debug("did not match " + matcher.toString()); + } + } + logger.info("No responder found for input " + input); + return SshResponder.NULL; + } + + private void add(Matcher matcher, SshResponder responder) { + matchers.add(0, matcher); // matchers added last take precedence + responders.put(matcher, responder); + } + + public void clear() { + matchers.clear(); + responders.clear(); + } } diff --git a/src/main/java/software/sham/ssh/SshResponder.java b/src/main/java/software/sham/ssh/SshResponder.java index 84d7bac..170b45e 100644 --- a/src/main/java/software/sham/ssh/SshResponder.java +++ b/src/main/java/software/sham/ssh/SshResponder.java @@ -1,34 +1,36 @@ package software.sham.ssh; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.sham.ssh.actions.Action; - import java.io.IOException; import java.io.OutputStream; -import java.nio.channels.Channels; -import java.nio.channels.WritableByteChannel; import java.util.LinkedList; import java.util.List; -public class SshResponder { - private final Logger logger = LoggerFactory.getLogger(getClass()); +import org.apache.sshd.server.session.ServerSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import software.sham.ssh.actions.Action; + +class SshResponder { + public static final SshResponder NULL = new SshResponder(); + + private final Logger logger = LoggerFactory.getLogger(getClass()); - private final List actions = new LinkedList<>(); + private final List actions = new LinkedList<>(); - public void respond(MockSshShell shell) { - for (Action action : actions) { - try { - action.respond(shell); - } catch (IOException e) { - logger.warn("Mock SSH error during response {}: {}", action.toString(), e.getMessage()); - } - } - } + public void respond(ServerSession serverSession, String input, OutputStream outputStream) { + for (Action action : actions) { + try { + action.respond(serverSession, input, outputStream); + } catch (IOException e) { + logger.warn("Mock SSH error during response {}: {}", action.toString(), e.getMessage()); + } + } + } - public void add(Action action) { - actions.add(action); - } + public SshResponder add(Action action) { + actions.add(action); + return this; + } - public static SshResponder NULL = new SshResponder(); } diff --git a/src/main/java/software/sham/ssh/SshResponderBuilder.java b/src/main/java/software/sham/ssh/SshResponderBuilder.java index 7817891..71a0dec 100644 --- a/src/main/java/software/sham/ssh/SshResponderBuilder.java +++ b/src/main/java/software/sham/ssh/SshResponderBuilder.java @@ -2,27 +2,40 @@ import software.sham.ssh.actions.Close; import software.sham.ssh.actions.Delay; +import software.sham.ssh.actions.Echo; import software.sham.ssh.actions.Output; +/** + * + */ public class SshResponderBuilder { - private final SshResponder responder = new SshResponder(); - - public SshResponder getResponder() { - return this.responder; - } - - public SshResponderBuilder withOutput(String output) { - responder.add(new Output(output)); - return this; - } - - public SshResponderBuilder withDelay(long milliseconds) { - responder.add(new Delay(milliseconds)); - return this; - } - - public SshResponderBuilder withClose() { - responder.add(new Close()); - return this; - } + private final SshResponder responder = new SshResponder(); + + public static SshResponderBuilder builder() { + return new SshResponderBuilder(); + } + + public SshResponder getResponder() { + return this.responder; + } + + public SshResponderBuilder withOutput(String... multiLineOutput) { + responder.add(new Output(multiLineOutput)); + return this; + } + + public SshResponderBuilder withDelay(long milliseconds) { + responder.add(new Delay(milliseconds)); + return this; + } + + public SshResponderBuilder withClose() { + responder.add(new Close()); + return this; + } + + public SshResponderBuilder withEcho() { + responder.add(new Echo()); + return this; + } } diff --git a/src/main/java/software/sham/ssh/actions/Action.java b/src/main/java/software/sham/ssh/actions/Action.java index 9e8cb40..40730b9 100644 --- a/src/main/java/software/sham/ssh/actions/Action.java +++ b/src/main/java/software/sham/ssh/actions/Action.java @@ -1,9 +1,27 @@ package software.sham.ssh.actions; -import software.sham.ssh.MockSshShell; - import java.io.IOException; +import java.io.OutputStream; + +import org.apache.sshd.server.session.ServerSession; +/** + * callers must use {@link #respond(ServerSession, String, OutputStream)} + */ public interface Action { - void respond(MockSshShell shell) throws IOException; + default void respond() { + throw new RuntimeException("not implemented"); + } + + default void respond(ServerSession serverSession) throws IOException { + respond(); + } + + default void respond(ServerSession serverSession, OutputStream outputStream) throws IOException { + respond(serverSession); + }; + + default void respond(ServerSession serverSession, String input, OutputStream outputStream) throws IOException { + respond(serverSession, outputStream); + }; } diff --git a/src/main/java/software/sham/ssh/actions/Close.java b/src/main/java/software/sham/ssh/actions/Close.java index c3cb02e..7935508 100644 --- a/src/main/java/software/sham/ssh/actions/Close.java +++ b/src/main/java/software/sham/ssh/actions/Close.java @@ -1,10 +1,13 @@ package software.sham.ssh.actions; -import software.sham.ssh.MockSshShell; +import java.io.IOException; + +import org.apache.sshd.common.SshConstants; +import org.apache.sshd.server.session.ServerSession; public class Close implements Action { - @Override - public void respond(MockSshShell shell) { - shell.closeSession(); - } + @Override + public void respond(ServerSession serverSession) throws IOException { + serverSession.disconnect(SshConstants.SSH2_DISCONNECT_BY_APPLICATION, "mock close"); + } } diff --git a/src/main/java/software/sham/ssh/actions/Delay.java b/src/main/java/software/sham/ssh/actions/Delay.java index db9abf7..bee7605 100644 --- a/src/main/java/software/sham/ssh/actions/Delay.java +++ b/src/main/java/software/sham/ssh/actions/Delay.java @@ -2,7 +2,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.sham.ssh.MockSshShell; public class Delay implements Action { private final Logger logger = LoggerFactory.getLogger(getClass()); @@ -13,7 +12,7 @@ public Delay(long milliseconds) { } @Override - public void respond(MockSshShell shell) { + public void respond() { try { logger.debug("Delaying output for {}ms", milliseconds); Thread.sleep(milliseconds); @@ -21,9 +20,4 @@ public void respond(MockSshShell shell) { logger.warn("Interrupted before delay completed: {}", e.getMessage()); } } - - @Override - public String toString() { - return "delay (" + milliseconds + "ms)"; - } } diff --git a/src/main/java/software/sham/ssh/actions/Echo.java b/src/main/java/software/sham/ssh/actions/Echo.java new file mode 100644 index 0000000..b9002f9 --- /dev/null +++ b/src/main/java/software/sham/ssh/actions/Echo.java @@ -0,0 +1,34 @@ +package software.sham.ssh.actions; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.sshd.server.session.ServerSession; + +public class Echo extends OutputSupport implements Action { + + private static final Pattern ECHO_PATTERN = Pattern.compile("[ ]*echo[ ]+\"?([^\"]+)\"?"); + private final int returnCode; + + public Echo() { + this.returnCode = 0; + } + + public Echo(int returnCode) { + this.returnCode = returnCode; + } + + @Override + public void respond(ServerSession serverSession, String input, OutputStream outputStream) throws IOException { + Matcher matcher = ECHO_PATTERN.matcher(input); + + while (matcher.find()) { + String output = matcher.group(1) // + .replace("$?", String.valueOf(returnCode)); + super.write(output, outputStream); + } + } + +} diff --git a/src/main/java/software/sham/ssh/actions/Greet.java b/src/main/java/software/sham/ssh/actions/Greet.java new file mode 100644 index 0000000..be701c1 --- /dev/null +++ b/src/main/java/software/sham/ssh/actions/Greet.java @@ -0,0 +1,29 @@ +package software.sham.ssh.actions; + +import java.io.IOException; +import java.io.OutputStream; + +import org.apache.sshd.server.session.ServerSession; + +public class Greet extends OutputSupport implements Action { + + private final String greeting; + + public Greet() { + // e.g. Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-51-generic x86_64) + // TODO bring maven-artifact-version via properties file into this spot + this.greeting = String.format("Welcome to %s (GNU/Linux %s x86_64)\n", // + this.getClass().getSimpleName(), // + "1.2.3" // + ); + } + + public Greet(String greeting) { + this.greeting = greeting; + } + + @Override + public void respond(ServerSession serverSession, OutputStream outputStream) throws IOException { + super.write(greeting, outputStream); + } +} diff --git a/src/main/java/software/sham/ssh/actions/Output.java b/src/main/java/software/sham/ssh/actions/Output.java index c5b51b6..af02e8b 100644 --- a/src/main/java/software/sham/ssh/actions/Output.java +++ b/src/main/java/software/sham/ssh/actions/Output.java @@ -1,24 +1,25 @@ package software.sham.ssh.actions; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.sham.ssh.MockSshShell; +import java.io.IOException; +import java.io.OutputStream; -public class Output implements Action { - private final Logger logger = LoggerFactory.getLogger(getClass()); - private final String output; - public Output(String output) { - this.output = output; - } +import org.apache.sshd.server.session.ServerSession; + +public class Output extends OutputSupport implements Action { + + private final String[] outputs; + + public Output(String... outputs) { + super(); + + this.outputs = outputs; + } + + @Override + public void respond(ServerSession serverSession, OutputStream outputStream) throws IOException { + for (String output: outputs) + super.write(output, outputStream); + } - @Override - public void respond(MockSshShell shell) { - logger.debug("Sending output: " + output); - shell.sendResponse(output); - } - @Override - public String toString() { - return "output (" + output + ")"; - } } diff --git a/src/main/java/software/sham/ssh/actions/OutputSupport.java b/src/main/java/software/sham/ssh/actions/OutputSupport.java new file mode 100644 index 0000000..f610a04 --- /dev/null +++ b/src/main/java/software/sham/ssh/actions/OutputSupport.java @@ -0,0 +1,18 @@ +package software.sham.ssh.actions; + +import java.io.IOException; +import java.io.OutputStream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +abstract class OutputSupport { + protected static final byte[] EOL = System.lineSeparator().getBytes(); + protected final Logger logger = LoggerFactory.getLogger(getClass()); + + void write(String output, OutputStream outputStream) throws IOException { + logger.debug("Sending output: " + output); + outputStream.write(output.getBytes()); + outputStream.write(EOL); + } +} diff --git a/src/main/java/software/sham/ssh/actions/Prompt.java b/src/main/java/software/sham/ssh/actions/Prompt.java new file mode 100644 index 0000000..03878f2 --- /dev/null +++ b/src/main/java/software/sham/ssh/actions/Prompt.java @@ -0,0 +1,33 @@ +package software.sham.ssh.actions; + +import java.io.IOException; +import java.io.OutputStream; +import java.text.MessageFormat; + +import org.apache.sshd.server.session.ServerSession; + +public class Prompt extends OutputSupport implements Action { + + private final String promptFormat; + + /** + * "{0}" <- {@link ServerSession#getUsername()} + * + * @param prompt + * @see MessageFormat + */ + public Prompt(String prompt) { + this.promptFormat = prompt; + } + + public Prompt() { + this.promptFormat = "{0}@shell-$ "; + } + + @Override + public void respond(ServerSession serverSession, OutputStream outputStream) throws IOException { + String prompt = MessageFormat.format(promptFormat, serverSession.getUsername()); + + super.write(prompt, outputStream); + } +} \ No newline at end of file diff --git a/src/test/java/software/sham/MockSshServerTestSupport.java b/src/test/java/software/sham/MockSshServerTestSupport.java new file mode 100644 index 0000000..b81fa7a --- /dev/null +++ b/src/test/java/software/sham/MockSshServerTestSupport.java @@ -0,0 +1,88 @@ +package software.sham; + +import java.io.IOException; + +import org.junit.After; +import org.junit.Before; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.sshtools.client.SshClient; +import com.sshtools.client.SshClient.SshClientBuilder; +import com.sshtools.common.ssh.SshException; + +import software.sham.ssh.MockSshServer; + +public abstract class MockSshServerTestSupport { + + protected static final String SSH_SERVER_HOSTNAME = "localhost"; + protected static final int SSH_SERVER_PORT = 9022; + protected static final String SSH_SERVER_USER = MockSshServer.USERNAME; + protected static final String SSH_SERVER_PASSWORD = MockSshServer.PASSWORD; + protected static final String SSH_SERVER_USER_KEY_PASSPHRASE = "testing"; + protected static final long SSH_SERVER_CONNECT_TIMOUT = 1000L; + + protected final Logger logger = LoggerFactory.getLogger(getClass()); + + protected MockSshServer server; + protected SshClient sshClient; + + protected interface MockSshServerConfigurer { + void apply(MockSshServer server); + } + + protected interface MockSshServerCreator { + MockSshServer create() throws IOException; + } + + MockSshServerConfigurer serverConfigurer = server -> { + /* do nothing */ }; + private MockSshServerCreator serverCreator = () -> { + return new MockSshServer(MockSshServerTestSupport.SSH_SERVER_PORT, false); + }; + + protected MockSshServerTestSupport(MockSshServerCreator serverCreator, MockSshServerConfigurer serverConfigurer) { + this.serverCreator = serverCreator; + this.serverConfigurer = serverConfigurer; + } + + protected MockSshServerTestSupport(MockSshServerCreator serverCreator) { + this.serverCreator = serverCreator; + } + + protected MockSshServerTestSupport(MockSshServerConfigurer serverConfigurer) { + this.serverConfigurer = serverConfigurer; + } + + protected MockSshServerTestSupport() { + // no specific serverConfigurer + } + + @Before + public void startSshServer() throws IOException { + this.server = this.serverCreator.create(); + this.serverConfigurer.apply(server); + this.server.start(); + } + + @After + public void stopSsh() throws IOException, InterruptedException { + if (sshClient != null) + this.sshClient.close(); + this.server.stop(); + } + + protected void initSshClientWithPassword() throws SshException, IOException { + this.sshClient = SshClientBuilder.create() // + .withTarget(MockSshServerTestSupport.SSH_SERVER_HOSTNAME, MockSshServerTestSupport.SSH_SERVER_PORT) // + .withUsername(MockSshServerTestSupport.SSH_SERVER_USER) // + .withPassword(MockSshServerTestSupport.SSH_SERVER_PASSWORD) // + .build(); + + this.sshClient.openSessionChannel(MockSshServerTestSupport.SSH_SERVER_CONNECT_TIMOUT); + } + + protected String sendTextToServer(final String text) throws IOException { + return this.sshClient.executeCommand(text); + } +} diff --git a/src/test/java/software/sham/git/MockGitServerTest.java b/src/test/java/software/sham/git/MockGitServerTest.java new file mode 100644 index 0000000..1b7f6e6 --- /dev/null +++ b/src/test/java/software/sham/git/MockGitServerTest.java @@ -0,0 +1,93 @@ +package software.sham.git; + +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.security.GeneralSecurityException; +import java.util.Collections; + +import org.apache.sshd.client.SshClient; +import org.apache.sshd.client.auth.password.PasswordIdentityProvider; +import org.apache.sshd.client.future.AuthFuture; +import org.apache.sshd.client.future.ConnectFuture; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.session.SessionContext; +import org.apache.sshd.git.transport.GitSshdSessionFactory; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.api.errors.InvalidRemoteException; +import org.eclipse.jgit.api.errors.TransportException; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.transport.SshSessionFactory; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; +import org.junit.Before; +import org.junit.Test; + +import software.sham.MockSshServerTestSupport; + +public class MockGitServerTest extends MockSshServerTestSupport { + + private static final String GIT_PROJECT = "mocked-repo"; + private static final String GIT_SSH_URI = String.format("ssh://%s@%s:%d/%s", // + SSH_SERVER_USER, SSH_SERVER_HOSTNAME, SSH_SERVER_PORT, GIT_PROJECT); + + private static final UsernamePasswordCredentialsProvider CREDENTIALS_PROVIDER = new UsernamePasswordCredentialsProvider( + SSH_SERVER_USER, SSH_SERVER_PASSWORD); + + private SshClient client; + + public MockGitServerTest() { + super(() -> new MockGitServer(MockSshServerTestSupport.SSH_SERVER_PORT)); + } + + @Before + public void createSshClient() { + client = SshClient.setUpDefaultClient(); + client.setPasswordIdentityProvider(new PasswordIdentityProvider() { + @Override + public Iterable loadPasswords(SessionContext session) throws IOException, GeneralSecurityException { + return Collections.singleton(SSH_SERVER_PASSWORD); + } + }); + client.start(); + + SshSessionFactory gitSessionFactory = new GitSshdSessionFactory(client); + SshSessionFactory.setInstance(gitSessionFactory); + } + + @Test + public void canConnect() throws IOException { + ConnectFuture connectFuture = client.connect(GIT_SSH_URI); + assertTrue(connectFuture.await()); + assertTrue(connectFuture.isConnected()); + + ClientSession session = connectFuture.getSession(); + AuthFuture authFuture = session.auth(); + assertTrue(authFuture.await()); + assertTrue(authFuture.isSuccess()); + } + + @Test + public void connectAndFetch() + throws InvalidRemoteException, TransportException, GitAPIException, IOException, InterruptedException { + File directory = Files.createTempDirectory(getClass().getSimpleName()).toFile(); + directory.deleteOnExit(); + + getServer().prepareGitProject(GIT_PROJECT); + + Git git = Git.cloneRepository().setURI(GIT_SSH_URI).setDirectory(directory) + // surprisingly JGIT wouldn't take sshClient setup + .setCredentialsProvider(CREDENTIALS_PROVIDER).call(); + + git.fetch().setCredentialsProvider(CREDENTIALS_PROVIDER).call(); + + Iterable commits = git.log().call(); + assertTrue(commits.iterator().hasNext()); + } + + public MockGitServer getServer() { + return (MockGitServer) super.server; + } +} diff --git a/src/test/java/software/sham/sftp/FunctionalTest.java b/src/test/java/software/sham/sftp/FunctionalTest.java deleted file mode 100644 index 0137919..0000000 --- a/src/test/java/software/sham/sftp/FunctionalTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package software.sham.sftp; - -import static org.junit.Assert.*; - -import com.jcraft.jsch.*; -import org.apache.commons.io.IOUtils; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import java.io.IOException; -import java.nio.file.Files; -import java.util.Properties; - -public class FunctionalTest { - MockSftpServer server; - Session sshSession; - - @Before - public void initSftp() throws IOException { - server = new MockSftpServer(9022); - } - - @Before - public void initSshClient() throws JSchException { - JSch jsch = new JSch(); - sshSession = jsch.getSession("tester", "localhost", 9022); - Properties config = new Properties(); - config.setProperty("StrictHostKeyChecking", "no"); - sshSession.setConfig(config); - sshSession.setPassword("testing"); - sshSession.connect(); - } - - @After - public void stopSftp() throws IOException { - server.stop(); - } - - @Test - public void connectAndDownloadFile() throws JSchException, IOException, SftpException { - Files.copy(IOUtils.toInputStream("example file contents"), server.getBaseDirectory().resolve("example.txt")); - - ChannelSftp channel = (ChannelSftp) sshSession.openChannel("sftp"); - channel.connect(); - - final String downloadedContents = IOUtils.toString(channel.get("example.txt")); - assertEquals("example file contents", downloadedContents); - } -} diff --git a/src/test/java/software/sham/sftp/MockSftpServerTest.java b/src/test/java/software/sham/sftp/MockSftpServerTest.java new file mode 100644 index 0000000..6b7c968 --- /dev/null +++ b/src/test/java/software/sham/sftp/MockSftpServerTest.java @@ -0,0 +1,64 @@ +package software.sham.sftp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; + +import org.junit.Before; +import org.junit.Test; + +import com.sshtools.client.sftp.SftpClient; +import com.sshtools.client.sftp.SftpClient.SftpClientBuilder; +import com.sshtools.client.sftp.TransferCancelledException; +import com.sshtools.common.permissions.PermissionDeniedException; +import com.sshtools.common.sftp.SftpStatusException; +import com.sshtools.common.ssh.SshException; + +import software.sham.MockSshServerTestSupport; + +public class MockSftpServerTest extends MockSshServerTestSupport { + + static final String TEST_FILE = "example.txt"; + static final String TEST_FILE_CONTENT = "example file contents" + System.lineSeparator(); + + private SftpClient sftpClient; + + public MockSftpServerTest() { + super( // + () -> new MockSftpServer(MockSshServerTestSupport.SSH_SERVER_PORT), // + server -> server.enableShell() // + ); + } + + @Before + public void initSshClient() throws SshException, PermissionDeniedException, IOException { + super.initSshClientWithPassword(); + this.sftpClient = SftpClientBuilder.create().withClient(this.sshClient).build(); + } + + @Test + public void connectAndDownloadFile() + throws IOException, SftpStatusException, SshException, TransferCancelledException { + Files.write( // + getSftpServer().getBaseDirectory().resolve(TEST_FILE), // + TEST_FILE_CONTENT.getBytes(Charset.defaultCharset()) // + ); + + assertTrue(sftpClient.exists(TEST_FILE)); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + sftpClient.get(TEST_FILE, baos); + + assertEquals(TEST_FILE_CONTENT, baos.toString()); + + sftpClient.close(); + } + + protected MockSftpServer getSftpServer() { + return (MockSftpServer) super.server; + } +} diff --git a/src/test/java/software/sham/ssh/FunctionalTest.java b/src/test/java/software/sham/ssh/FunctionalTest.java deleted file mode 100644 index 2501372..0000000 --- a/src/test/java/software/sham/ssh/FunctionalTest.java +++ /dev/null @@ -1,158 +0,0 @@ -package software.sham.ssh; - -import static org.junit.Assert.*; -import static org.hamcrest.Matchers.*; - -import com.jcraft.jsch.ChannelShell; -import com.jcraft.jsch.JSch; -import com.jcraft.jsch.JSchException; -import com.jcraft.jsch.Session; -import org.apache.commons.io.IOUtils; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.*; -import java.util.Properties; - -public class FunctionalTest { - private final Logger logger = LoggerFactory.getLogger(getClass()); - MockSshServer server; - Session sshSession; - ChannelShell sshChannel; - ByteArrayOutputStream sshClientOutput; - PrintWriter inputWriter; - - @Before - public void initSsh() throws IOException { - server = new MockSshServer(9022); - } - - @Before - public void initSshClientWithPassword() throws JSchException, IOException { - initSshClient(); - - sshSession.setPassword("testing"); - - connectWithStreams(); - } - - private void initSshClientWithKey() throws JSchException, IOException { - JSch jsch = initSshClient(); - - jsch.addIdentity("src/test/resources/keys/id_rsa_tester", "testing"); - - connectWithStreams(); - } - - private JSch initSshClient() throws JSchException { - JSch jsch = new JSch(); - sshSession = jsch.getSession("tester", "localhost", 9022); - Properties config = new Properties(); - config.setProperty("StrictHostKeyChecking", "no"); - sshSession.setConfig(config); - return jsch; - } - - private void connectWithStreams() throws JSchException, IOException { - sshSession.connect(); - sshChannel = (ChannelShell) sshSession.openChannel("shell"); - PipedInputStream channelIn = new PipedInputStream(); - sshChannel.setInputStream(channelIn); - OutputStream sshClientInput = new PipedOutputStream(channelIn); - inputWriter = new PrintWriter(sshClientInput); - sshClientOutput = new ByteArrayOutputStream(); - sshChannel.setOutputStream(sshClientOutput); - sshChannel.connect(1000); - } - - @After - public void stopSsh() throws Exception { - sshSession.disconnect(); - server.stop(); - Thread.sleep(200); - } - - @Test - public void defaultShellCommandsShouldSilentlySucceed() throws Exception { - sendTextToServer("Knock knock\n"); - assertThat(sshClientOutput.size(), equalTo(0)); - } - - @Test - public void defaultShellShouldDisconnectOnExit() throws Exception { - sendTextToServer("exit\n"); - Thread.sleep(300); - assertThat(sshChannel.isConnected(), is(false)); - } - - @Test - public void singleOutput() throws Exception { - server.respondTo(any(String.class)) - .withOutput("hodor\n"); - - sendTextToServer("Knock knock\n"); - assertEquals("hodor\n", sshClientOutput.toString()); - } - - @Test - public void shouldSupportPublicKeyAuth() throws Exception { - server.allowPublicKey(IOUtils.toByteArray(Thread.currentThread().getContextClassLoader().getResourceAsStream("keys/id_rsa_tester.der.pub"))); - - initSshClientWithKey(); - - server.respondTo(any(String.class)) - .withOutput("hodor\n"); - - sendTextToServer("Knock knock\n"); - assertEquals("hodor\n", sshClientOutput.toString()); - } - - @Test - public void multipleOutput() throws Exception { - server.respondTo(any(String.class)) - .withOutput("Starting...\n") - .withOutput("Completed.\n"); - - sendTextToServer("start"); - assertEquals("Starting...\nCompleted.\n", sshClientOutput.toString()); - } - - @Test - public void delayedOutput() throws Exception { - server.respondTo(any(String.class)) - .withOutput("Starting...\n") - .withDelay(500) - .withOutput("Completed.\n"); - - sendTextToServer("start"); - logger.debug("Checking for first line"); - assertEquals("Starting...\n", sshClientOutput.toString()); - Thread.sleep(500); - logger.debug("Checking for second line"); - assertEquals("Starting...\nCompleted.\n", sshClientOutput.toString()); - } - - @Test - public void differentOutputForDifferentInput() throws Exception { - server.respondTo(any(String.class)) - .withOutput("default\n"); - server.respondTo("Knock knock") - .withOutput("Who's there?\n"); - - sendTextToServer("Something wicked this way comes"); - String output = sshClientOutput.toString(); - assertEquals("default\n", output); - sendTextToServer("Knock knock"); - assertEquals("default\nWho's there?\n", sshClientOutput.toString()); - } - - private void sendTextToServer(final String text) throws Exception { - inputWriter.write(text); - inputWriter.flush(); - logger.debug("Sent text to SSH server: {}", text); - Thread.sleep(100); - } -} diff --git a/src/test/java/software/sham/ssh/MockSshCommandTest.java b/src/test/java/software/sham/ssh/MockSshCommandTest.java new file mode 100644 index 0000000..7f3437b --- /dev/null +++ b/src/test/java/software/sham/ssh/MockSshCommandTest.java @@ -0,0 +1,49 @@ +package software.sham.ssh; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.any; +import static org.hamcrest.Matchers.emptyString; +import static org.junit.Assert.assertEquals; + +import java.io.IOException; + +import org.junit.Before; +import org.junit.Test; + +import com.sshtools.common.ssh.SshException; + +import software.sham.MockSshServerTestSupport; + +public class MockSshCommandTest extends MockSshServerTestSupport { + + @Before + public void initSshClientWithPassword() throws SshException, IOException { + super.initSshClientWithPassword(); + } + + @Test + public void defaultShellCommandsShouldSilentlySucceed() throws IOException { + assertThat(sendTextToServer("Knock knock"), emptyString()); + } + + @Test + public void singleOutput() throws IOException { + server.respondTo(any(String.class)).withOutput("hodor"); + + assertEquals("hodor\n", sendTextToServer("Knock knock")); + } + + @Test + public void multipleOutput() throws IOException { + server.respondTo(any(String.class)).withOutput("Starting...").withOutput("Completed."); + + assertEquals("Starting...\nCompleted.\n", sendTextToServer("start")); + } + + @Test + public void multilineOutput() throws IOException { + server.respondTo(any(String.class)).withOutput("Starting...", "Completed."); + + assertEquals("Starting...\nCompleted.\n", sendTextToServer("start")); + } +} diff --git a/src/test/java/software/sham/ssh/MockSshServerConnectionTest.java b/src/test/java/software/sham/ssh/MockSshServerConnectionTest.java new file mode 100644 index 0000000..bfad8cd --- /dev/null +++ b/src/test/java/software/sham/ssh/MockSshServerConnectionTest.java @@ -0,0 +1,122 @@ +package software.sham.ssh; + +import static org.junit.Assert.assertTrue; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; + +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.junit.Test; + +import com.sshtools.client.SshClient.SshClientBuilder; +import com.sshtools.common.publickey.InvalidPassphraseException; +import com.sshtools.common.publickey.SshKeyPairGenerator; +import com.sshtools.common.publickey.SshKeyUtils; +import com.sshtools.common.publickey.SshPublicKeyFileFactory; +import com.sshtools.common.ssh.SshException; +import com.sshtools.common.ssh.components.SshKeyPair; +import com.sshtools.common.ssh.components.SshPublicKey; + +import software.sham.MockSshServerTestSupport; + +public class MockSshServerConnectionTest extends MockSshServerTestSupport { + + @Test(expected = IOException.class) + public void shouldDenyUnknownKey() throws IOException, InvalidPassphraseException, SshException { + // unknown to server + SshKeyPair keyPair = SshKeyUtils.getPrivateKey(Thread.currentThread().getContextClassLoader() // + .getResourceAsStream("keys/id_rsa_tester"), MockSshServerTestSupport.SSH_SERVER_USER_KEY_PASSPHRASE); + + initSshClientWithKey(keyPair); // should throw IOException: Authentication failed + } + + @Test + public void shouldSupportDERPublicKeyAuth() throws IOException, InvalidKeySpecException, NoSuchAlgorithmException, + InvalidPassphraseException, SshException { + try (BufferedInputStream bufferedInputStream = new BufferedInputStream( + Thread.currentThread().getContextClassLoader().getResourceAsStream("keys/id_rsa_tester.der.pub"))) { + + PublicKey publicKey = KeyFactory.getInstance("RSA") + .generatePublic(new X509EncodedKeySpec(bufferedInputStream.readAllBytes())); + testPublicKeyAuth(publicKey); + } + } + + @Test + public void shouldSupportSSHPublicKeyAuth() throws IOException, InvalidPassphraseException, SshException { + SshPublicKey publicKey = SshKeyUtils.getPublicKey(Thread.currentThread().getContextClassLoader() // + .getResourceAsStream("keys/id_rsa_tester.pub")); + + testPublicKeyAuth(publicKey.getJCEPublicKey()); + } + + @Test + public void shouldSupportPKCS8PublicKeyAuth() + throws IOException, URISyntaxException, SshException, InvalidPassphraseException { + try (PEMParser parser = new PEMParser(new InputStreamReader( + Thread.currentThread().getContextClassLoader().getResourceAsStream("keys/id_rsa_tester.pkcs8.pub")))) { + SubjectPublicKeyInfo subjectPublicKeyInfo = SubjectPublicKeyInfo.getInstance(parser.readObject()); + PublicKey publicKey = new JcaPEMKeyConverter().getPublicKey(subjectPublicKeyInfo); + testPublicKeyAuth(publicKey); + } + } + + @Test + public void shouldSupportGeneratorPublicKeyAuth() throws IOException, SshException, InvalidPassphraseException { + final String privKeyPassword = "generated_testing"; + + File privKey = File.createTempFile("privKey", null); + Files.setPosixFilePermissions(privKey.toPath(), PosixFilePermissions.fromString("rw-" + "------")); + privKey.deleteOnExit(); + File pubKey = File.createTempFile("pubKey", null); + pubKey.deleteOnExit(); + + SshKeyPair keyPair = SshKeyPairGenerator.generateKeyPair(SshKeyPairGenerator.SSH2_RSA); + + SshKeyUtils.savePrivateKey(keyPair, privKeyPassword, null, privKey); + SshKeyUtils.createPublicKeyFile(keyPair.getPublicKey(), null, pubKey, SshPublicKeyFileFactory.OPENSSH_FORMAT); + + SshKeyPair sshKeyPair = SshKeyUtils.getPrivateKey(privKey, privKeyPassword); + SshPublicKey publicKey = SshKeyUtils.getPublicKey(pubKey); + + testPublicKeyAuth(sshKeyPair, publicKey.getJCEPublicKey()); + } + + private void initSshClientWithKey(SshKeyPair sshKeyPair) throws IOException, SshException { + + this.sshClient = SshClientBuilder.create().withIdentities(sshKeyPair) + .withUsername(MockSshServerTestSupport.SSH_SERVER_USER) + .withTarget(MockSshServerTestSupport.SSH_SERVER_HOSTNAME, MockSshServerTestSupport.SSH_SERVER_PORT) + .build(); + + this.sshClient.openSessionChannel(MockSshServerTestSupport.SSH_SERVER_CONNECT_TIMOUT, true); + } + + private void testPublicKeyAuth(PublicKey publicKey) throws IOException, InvalidPassphraseException, SshException { + SshKeyPair keyPair = SshKeyUtils.getPrivateKey(Thread.currentThread().getContextClassLoader() // + .getResourceAsStream("keys/id_rsa_tester"), MockSshServerTestSupport.SSH_SERVER_USER_KEY_PASSPHRASE); + + testPublicKeyAuth(keyPair, publicKey); + } + + private void testPublicKeyAuth(SshKeyPair keyPair, PublicKey publicKey) throws IOException, SshException { + server.allowPublicKey(publicKey); + + initSshClientWithKey(keyPair); + + assertTrue(this.sshClient.isAuthenticated()); + } + +} diff --git a/src/test/java/software/sham/ssh/MockSshShellTest.java b/src/test/java/software/sham/ssh/MockSshShellTest.java new file mode 100644 index 0000000..e8c3ca4 --- /dev/null +++ b/src/test/java/software/sham/ssh/MockSshShellTest.java @@ -0,0 +1,107 @@ +package software.sham.ssh; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; + +import org.awaitility.Awaitility; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.sshtools.client.shell.ExpectShell; +import com.sshtools.client.tasks.ShellTask; +import com.sshtools.client.tasks.ShellTask.ShellTaskBuilder; +import com.sshtools.client.tasks.Task; +import com.sshtools.common.logger.Log; +import com.sshtools.common.logger.Log.Level; +import com.sshtools.common.ssh.RequestFuture; +import com.sshtools.common.ssh.SshException; + +import software.sham.MockSshServerTestSupport; + +public class MockSshShellTest extends MockSshServerTestSupport { + + private static final long TASK_TIMEOUT = 5000L; + + public MockSshShellTest() { + super(server -> { + server.enableShell(); + }); + } + + @BeforeClass + public static void setLoggers() { + Log.enableConsole(Level.DEBUG); + // see src/test/resources/simplelogger.properties + } + + @Before + public void initSshClientWithPassword() throws SshException, IOException { + super.initSshClientWithPassword(); + } + + @Test + public void shouldBeGreetedAndExit() throws IOException { + runInShell(shell -> { + // force shell readiness with first command + assertEquals("Hi", shell.executeWithOutput("echo Hi")); + }); + + Awaitility.await().until(() -> Boolean.FALSE.equals(sshClient.isConnected())); + } + + @Test + public void differentOutputForDifferentInput() throws IOException { + server.respondTo("Something wicked this way comes").withOutput("default"); + server.respondTo("Knock knock").withOutput("Who's there?"); + + runInShell(shell -> { + assertEquals("default", shell.executeWithOutput("Something wicked this way comes")); + + assertEquals("Who's there?", shell.executeWithOutput("Knock knock")); + }); + } + + @Test + public void delayedOutput() throws IOException { + server.respondTo("delayedResponder").withOutput("Starting..." + System.lineSeparator()).withDelay(500L) + .withOutput("Completed."); + + runInShell(shell -> { + assertEquals("Starting..." + System.lineSeparator() + System.lineSeparator() + "Completed.", + shell.executeWithOutput("delayedResponder")); + }); + } + + /** + * SMI for testing purpose + */ + interface TestInShell { + void test(ExpectShell shell) throws IOException, SshException; + } + + private void runInShell(TestInShell shellTest) throws IOException { + ShellTask task = ShellTaskBuilder.create() // + .withClient(sshClient) // + .onTask((t, session) -> { + ExpectShell shell = new ExpectShell(t, ExpectShell.OS_LINUX); + + shellTest.test(shell); + + // potentially forceful quitting + shell.exit(); + }) // + .build(); + + Task clientTask = sshClient.addTask(task); + + RequestFuture requestFuture = clientTask // .waitForever(); + .waitFor(TASK_TIMEOUT); + + assertTrue(clientTask.getLastError() != null ? clientTask.getLastError().toString() : "No success.", // + requestFuture.isDoneAndSuccess()); + } + +} \ No newline at end of file diff --git a/src/test/resources/keys/id_rsa_tester.pub b/src/test/resources/keys/id_rsa_tester.pub index adbdda2..4df1406 100644 --- a/src/test/resources/keys/id_rsa_tester.pub +++ b/src/test/resources/keys/id_rsa_tester.pub @@ -1 +1 @@ -ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC3k9SgCm39U8P8Dlh2aV2bWACmjMaRabfIbsxMdoH0Fy6qdGqgs8PYewAkiJnnjewoicbjY06NPx0KAsk9JEBKN0rJsm5auOiKVhmFoGQYUffbO7HPsMBhY0wtvYQCEKufqP5wXe3ml9DT+SoGaSdOgOd8nEu7P2DuH8NCF0x2bEM1xK4NsiOl3Zzc6ebgQ8S5NmbiW8Hh78GowKrx3TLRG38NySNgZWBwkKttsN6upzDZPA8WZ2TVB/o13a/PT+mXQUKPqg0liJVLl7qhWiOFqp879Wqjv4LD9mIfaK7KRszEs9qQDtxXRlSKhuXea/RgFQaoaAu5MVKo2hO3ClC9 rhoegg@hoegg-air +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC3k9SgCm39U8P8Dlh2aV2bWACmjMaRabfIbsxMdoH0Fy6qdGqgs8PYewAkiJnnjewoicbjY06NPx0KAsk9JEBKN0rJsm5auOiKVhmFoGQYUffbO7HPsMBhY0wtvYQCEKufqP5wXe3ml9DT+SoGaSdOgOd8nEu7P2DuH8NCF0x2bEM1xK4NsiOl3Zzc6ebgQ8S5NmbiW8Hh78GowKrx3TLRG38NySNgZWBwkKttsN6upzDZPA8WZ2TVB/o13a/PT+mXQUKPqg0liJVLl7qhWiOFqp879Wqjv4LD9mIfaK7KRszEs9qQDtxXRlSKhuXea/RgFQaoaAu5MVKo2hO3ClC9 rhoegg@hoegg-air \ No newline at end of file diff --git a/src/test/resources/log4j2.xml b/src/test/resources/log4j2.xml deleted file mode 100644 index b01602f..0000000 --- a/src/test/resources/log4j2.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/test/resources/simplelogger.properties b/src/test/resources/simplelogger.properties new file mode 100644 index 0000000..5c89a5a --- /dev/null +++ b/src/test/resources/simplelogger.properties @@ -0,0 +1 @@ +org.slf4j.simpleLogger.defaultLogLevel=DEBUG \ No newline at end of file