From dde97938c569218a3323c8c10487dae744aff0f9 Mon Sep 17 00:00:00 2001 From: Jan Esser Date: Sat, 25 Jan 2025 22:35:44 +0100 Subject: [PATCH 01/12] revamped --- .gitignore | 1 + pom.xml | 467 +++++++++--------- .../software/sham/sftp/MockSftpServer.java | 31 +- .../java/software/sham/ssh/MockSshServer.java | 208 ++++---- .../java/software/sham/ssh/MockSshShell.java | 272 +++++----- .../sham/ssh/ResponderDispatcher.java | 3 +- .../java/software/sham/ssh/SshResponder.java | 12 +- .../software/sham/sftp/FunctionalTest.java | 90 ++-- .../software/sham/ssh/FunctionalTest.java | 313 ++++++------ 9 files changed, 711 insertions(+), 686 deletions(-) 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/pom.xml b/pom.xml index aed2f34..86ee7b6 100644 --- a/pom.xml +++ b/pom.xml @@ -1,239 +1,240 @@ - - 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 + 0.3.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 + + + 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 + + + + + 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.sshd + sshd-sftp + 2.14.0 + + + org.apache.sshd + sshd-core + 2.14.0 + + + org.hamcrest + hamcrest-core + 3.0 + + + + org.bouncycastle + bcpg-jdk18on + 1.80 + + + org.bouncycastle + bcpkix-jdk18on + 1.80 + - - - 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-simple + 2.0.16 + + + org.slf4j + slf4j-api + 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 - - - - 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} - - - - - - + + + junit + junit + 4.13.2 + test + + + org.mockito + mockito-core + 1.9.5 + test + + + com.jcraft + jsch + 0.1.55 + test + + + org.awaitility + awaitility + 4.2.2 + test + + + + + + org.apache.sshd + sshd-core + + + org.apache.sshd + sshd-sftp + true + + + org.hamcrest + hamcrest-core + + + org.bouncycastle + bcpg-jdk18on + + + org.bouncycastle + bcpkix-jdk18on + + + org.slf4j + slf4j-api + + + + junit + junit + test + + + org.slf4j + slf4j-simple + test + + + com.jcraft + jsch + 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/sftp/MockSftpServer.java b/src/main/java/software/sham/sftp/MockSftpServer.java index ddd240c..d872cd9 100644 --- a/src/main/java/software/sham/sftp/MockSftpServer.java +++ b/src/main/java/software/sham/sftp/MockSftpServer.java @@ -1,27 +1,16 @@ 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; @@ -39,8 +28,8 @@ private MockSftpServer(int port, boolean enableShell) throws IOException { } private void initSftp() { - sshServer.setCommandFactory(new ScpCommandFactory()); - sshServer.setSubsystemFactories(Arrays.>asList(new SftpSubsystemFactory())); + SftpSubsystemFactory sftpSubsystemFactory = new SftpSubsystemFactory.Builder().build(); + sshServer.setSubsystemFactories(Collections.singletonList(sftpSubsystemFactory)); } public Path getBaseDirectory() { @@ -50,14 +39,14 @@ public Path getBaseDirectory() { @Override public void start() throws IOException { baseDirectory = Files.createTempDirectory("sftproot"); - sshServer.setFileSystemFactory(new VirtualFileSystemFactory(baseDirectory.toAbsolutePath().toString())); + sshServer.setFileSystemFactory(new VirtualFileSystemFactory(baseDirectory.toAbsolutePath())); super.start(); } @Override public void stop() throws IOException { super.stop(); - FileUtils.deleteQuietly(baseDirectory.toFile()); + baseDirectory.toFile().delete(); } public static MockSftpServer createWithShell(int port) throws IOException { diff --git a/src/main/java/software/sham/ssh/MockSshServer.java b/src/main/java/software/sham/ssh/MockSshServer.java index f7fc0c4..8b4e6a1 100644 --- a/src/main/java/software/sham/ssh/MockSshServer.java +++ b/src/main/java/software/sham/ssh/MockSshServer.java @@ -1,118 +1,114 @@ 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 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.session.ServerSession; -import org.hamcrest.Matcher; -import org.hamcrest.Matchers; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; 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(); - } +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.Command; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MockSshServer implements ShellFactory { + public static final String USERNAME = "tester"; + public static final String PASSWORD = "testing"; + + private final Logger log = LoggerFactory.getLogger(this.getClass()); + + protected final SshServer sshServer; + + private Set keys = new HashSet(); + private MockSshShell sshShell; + + public MockSshServer(int port) throws IOException { + this(port, true); + } + + public 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, this.keys)); + return this; + } + + public MockSshServer enableShell() { + log.info("Mock SSH shell is enabled"); + sshShell = new MockSshShell(); + 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(); + } + + 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, this.keys)); + return sshd; + } + + 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)); + } + + private void setDefaults() { + respondTo("exit").withClose(); + } + + @Override + public Command createShell(ChannelSession channel) throws IOException { + return this.sshShell; // mocked singleton + } } diff --git a/src/main/java/software/sham/ssh/MockSshShell.java b/src/main/java/software/sham/ssh/MockSshShell.java index 4b91783..f147e45 100644 --- a/src/main/java/software/sham/ssh/MockSshShell.java +++ b/src/main/java/software/sham/ssh/MockSshShell.java @@ -1,147 +1,155 @@ package software.sham.ssh; -import org.apache.commons.io.IOUtils; -import org.apache.sshd.server.Command; -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.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Writer; 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.Collections; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.Future; + +import org.apache.sshd.server.Environment; +import org.apache.sshd.server.ExitCallback; +import org.apache.sshd.server.channel.ChannelSession; +import org.apache.sshd.server.command.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; 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 { + 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); + + @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(ChannelSession channel, 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); - } - } + executor.submit(eventLoop); + + } + + @Override + public void destroy(ChannelSession channel) throws Exception { + 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(); + } + + if (sb.length() > 0) + return Arrays.asList(sb.toString().split("\\r?\\n")); + else + return Collections.emptyList(); + } + + 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); + } + } } diff --git a/src/main/java/software/sham/ssh/ResponderDispatcher.java b/src/main/java/software/sham/ssh/ResponderDispatcher.java index 1a61a23..af4cd1c 100644 --- a/src/main/java/software/sham/ssh/ResponderDispatcher.java +++ b/src/main/java/software/sham/ssh/ResponderDispatcher.java @@ -8,9 +8,10 @@ import java.util.LinkedList; import java.util.Map; +@SuppressWarnings("rawtypes") public class ResponderDispatcher { private final Logger logger = LoggerFactory.getLogger(getClass()); - private final Map responders = new HashMap<>(); + private final Map responders = new HashMap<>(); private final LinkedList matchers = new LinkedList<>(); public void add(Matcher matcher, SshResponder responder) { diff --git a/src/main/java/software/sham/ssh/SshResponder.java b/src/main/java/software/sham/ssh/SshResponder.java index 84d7bac..9d0069d 100644 --- a/src/main/java/software/sham/ssh/SshResponder.java +++ b/src/main/java/software/sham/ssh/SshResponder.java @@ -1,16 +1,14 @@ 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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import software.sham.ssh.actions.Action; + public class SshResponder { private final Logger logger = LoggerFactory.getLogger(getClass()); diff --git a/src/test/java/software/sham/sftp/FunctionalTest.java b/src/test/java/software/sham/sftp/FunctionalTest.java index 0137919..9a8b6f0 100644 --- a/src/test/java/software/sham/sftp/FunctionalTest.java +++ b/src/test/java/software/sham/sftp/FunctionalTest.java @@ -1,50 +1,60 @@ package software.sham.sftp; -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.util.Collections; +import java.util.Properties; -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; +import com.jcraft.jsch.ChannelSftp; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.JSchException; +import com.jcraft.jsch.Session; +import com.jcraft.jsch.SftpException; 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); - } + 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.write(server.getBaseDirectory().resolve("example.txt"), + Collections.singletonList("example file contents")); + + ChannelSftp channel = (ChannelSftp) sshSession.openChannel("sftp"); + channel.connect(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(channel.get("example.txt"))); + final String downloadedContents = reader.readLine(); + + assertEquals("example file contents", downloadedContents); + } } diff --git a/src/test/java/software/sham/ssh/FunctionalTest.java b/src/test/java/software/sham/ssh/FunctionalTest.java index 2501372..834665c 100644 --- a/src/test/java/software/sham/ssh/FunctionalTest.java +++ b/src/test/java/software/sham/ssh/FunctionalTest.java @@ -1,158 +1,179 @@ package software.sham.ssh; -import static org.junit.Assert.*; -import static org.hamcrest.Matchers.*; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.any; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.io.PrintWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; -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.hamcrest.Matcher; 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; +import com.jcraft.jsch.ChannelShell; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.JSchException; +import com.jcraft.jsch.Session; 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); - } + 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(); + } + + @Test + public void defaultShellCommandsShouldSilentlySucceed() throws Exception { + sendTextToServer("Knock knock\n"); + + waitFor(() -> sshClientOutput.size(), equalTo(0)); + } + + @Test + public void defaultShellShouldDisconnectOnExit() throws Exception { + sendTextToServer("exit\n"); + + waitFor(() -> sshChannel.isConnected(), is(false)); + } + + @Test + public void singleOutput() throws Exception { + server.respondTo(any(String.class)).withOutput("hodor\n"); + + sendTextToServer("Knock knock\n"); + + waitForOutput("hodor\n"); + } + + @Test + public void shouldSupportPublicKeyAuth() throws Exception { + server.allowPublicKey( // + Files.readAllBytes( // + Path.of( // + Thread.currentThread().getContextClassLoader() // + .getResource("keys/id_rsa_tester.der.pub").toURI()))); + + initSshClientWithKey(); + + server.respondTo(any(String.class)).withOutput("hodor\n"); + + sendTextToServer("Knock knock\n"); + + waitForOutput("hodor\n"); + } + + @Test + public void multipleOutput() throws Exception { + server.respondTo(any(String.class)).withOutput("Starting...\n").withOutput("Completed.\n"); + + sendTextToServer("start"); + Thread.sleep(100L); + + waitForOutput("Starting...\nCompleted.\n"); + } + + @Test + public void delayedOutput() throws Exception { + server.respondTo(any(String.class)).withOutput("Starting...\n").withDelay(500L).withOutput("Completed.\n"); + + sendTextToServer("start"); + logger.debug("Checking for first line"); + waitForOutput("Starting...\n"); + logger.debug("Checking for second line"); + waitForOutput("Starting...\nCompleted.\n"); + } + + @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"); + waitForOutput("default\n"); + + sendTextToServer("Knock knock"); + waitForOutput("default\nWho's there?\n"); + } + + private void sendTextToServer(final String text) throws Exception { + inputWriter.write(text); + inputWriter.flush(); + logger.debug("Sent text to SSH server: {}", text); + } + + private T waitFor(final Callable supplier, final Matcher matcher) { + return await() // + .during(200, TimeUnit.MICROSECONDS) // + .atMost(1, TimeUnit.SECONDS) // + .until(supplier, matcher); + } + + private String waitForOutput(String output) { + return this.waitFor(() -> sshClientOutput.toString(), equalTo(output)); + } + } From a6bf8be866fc1bf28e9bb83fe0b9048b8b75a89e Mon Sep 17 00:00:00 2001 From: Jan Esser Date: Sat, 25 Jan 2025 22:37:37 +0100 Subject: [PATCH 02/12] note on README.md --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1ed37c3..08d8832 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ # 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. + +25.01.2025 revamped with latest versions of sshd-core and sshd-sftp. 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. @@ -20,7 +22,7 @@ the API until it reaches 1.0 status. Of course, we'll try to keep breaking chang software.sham sham-ssh - 0.1.0 + 0.3.0 ``` From 49f65bc1af5f2f94e063cb81bdc82aa11cf5e07d Mon Sep 17 00:00:00 2001 From: Jan Esser Date: Sun, 26 Jan 2025 21:40:52 +0100 Subject: [PATCH 03/12] enhanced publickey retrieving --- .../java/software/sham/ssh/MockSshServer.java | 17 +-- .../software/sham/ssh/PublicKeyRetriever.java | 74 ++++++++++ .../sham/ssh/util/SshKeyPairGenerator.java | 133 ++++++++++++++++++ .../software/sham/ssh/FunctionalTest.java | 34 ++++- src/test/resources/keys/id_rsa_tester.pub | 2 +- 5 files changed, 243 insertions(+), 17 deletions(-) create mode 100644 src/main/java/software/sham/ssh/PublicKeyRetriever.java create mode 100644 src/main/java/software/sham/ssh/util/SshKeyPairGenerator.java diff --git a/src/main/java/software/sham/ssh/MockSshServer.java b/src/main/java/software/sham/ssh/MockSshServer.java index 8b4e6a1..215847b 100644 --- a/src/main/java/software/sham/ssh/MockSshServer.java +++ b/src/main/java/software/sham/ssh/MockSshServer.java @@ -4,9 +4,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.security.GeneralSecurityException; -import java.security.KeyFactory; import java.security.PublicKey; -import java.security.spec.KeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.HashSet; import java.util.Set; @@ -28,11 +26,11 @@ public class MockSshServer implements ShellFactory { public static final String USERNAME = "tester"; public static final String PASSWORD = "testing"; - + private final Logger log = LoggerFactory.getLogger(this.getClass()); protected final SshServer sshServer; - + private Set keys = new HashSet(); private MockSshShell sshShell; @@ -49,13 +47,13 @@ public MockSshServer(int port, boolean shouldStartServices) throws IOException { } /** - * @param key Key in DER format + * @param key with explicit {@link X509EncodedKeySpec#getAlgorithm()} notion */ - public MockSshServer allowPublicKey(byte[] key) throws GeneralSecurityException { - final KeySpec spec = new X509EncodedKeySpec(key); - keys.add(KeyFactory.getInstance("RSA").generatePublic(spec)); + public MockSshServer allowPublicKey(X509EncodedKeySpec keySpec) throws GeneralSecurityException { + this.keys.add(new PublicKeyRetriever(keySpec).getPublicKey()); sshServer.setPublickeyAuthenticator(new KeySetPublickeyAuthenticator(this, this.keys)); return this; + } public MockSshServer enableShell() { @@ -68,8 +66,7 @@ public MockSshServer enableShell() { public void start() throws IOException { Path serverKeyPath = Files.createTempFile("sham-mock-sshd-key", null); - AbstractGeneratorHostKeyProvider keyProvider = SecurityUtils - .createGeneratorHostKeyProvider(serverKeyPath); + AbstractGeneratorHostKeyProvider keyProvider = SecurityUtils.createGeneratorHostKeyProvider(serverKeyPath); sshServer.setKeyPairProvider(keyProvider); sshServer.start(); diff --git a/src/main/java/software/sham/ssh/PublicKeyRetriever.java b/src/main/java/software/sham/ssh/PublicKeyRetriever.java new file mode 100644 index 0000000..2326d97 --- /dev/null +++ b/src/main/java/software/sham/ssh/PublicKeyRetriever.java @@ -0,0 +1,74 @@ +package software.sham.ssh; + +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.X509EncodedKeySpec; +import java.util.Base64; + +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.crypto.params.AsymmetricKeyParameter; +import org.bouncycastle.crypto.util.OpenSSHPublicKeyUtil; +import org.bouncycastle.crypto.util.SubjectPublicKeyInfoFactory; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; + +public class PublicKeyRetriever { + private final X509EncodedKeySpec keySpec; + + public PublicKeyRetriever(X509EncodedKeySpec keySpec) { + this.keySpec = keySpec; + } + + public PublicKey getPublicKey() throws GeneralSecurityException { + switch (keySpec.getAlgorithm()) { + case "OPENSSH": + return parseSshEnvelopeBc(keySpec); + case "SSH2": + case "PEM": + return parsePemEnvelope(keySpec); + default: + return KeyFactory.getInstance(keySpec.getAlgorithm()).generatePublic(keySpec); + } + } + + private PublicKey parseSshEnvelopeBc(X509EncodedKeySpec keySpec) throws GeneralSecurityException { + try { + // decompose OPENSSH format to its essence + String base64pubKey = new String(keySpec.getEncoded()) // + .split(" ")[1] // + .replaceAll(System.lineSeparator(), ""); + byte[] pubKey = Base64.getDecoder().decode(base64pubKey); + + // parse & convert + AsymmetricKeyParameter keyParams = OpenSSHPublicKeyUtil.parsePublicKey(pubKey); + SubjectPublicKeyInfo publicKeyInfo = SubjectPublicKeyInfoFactory.createSubjectPublicKeyInfo(keyParams); + return new JcaPEMKeyConverter().getPublicKey(publicKeyInfo); + } catch (IOException ex) { + throw new GeneralSecurityException(ex); + } + + } + + /** + * https://www.baeldung.com/java-read-pem-file-keys + * + * @param keySpec + * @return + * @throws InvalidKeySpecException + * @throws NoSuchAlgorithmException + */ + private PublicKey parsePemEnvelope(X509EncodedKeySpec keySpec) + throws InvalidKeySpecException, NoSuchAlgorithmException { + String publicKeyPem = new String(keySpec.getEncoded()) // + .replace("-----BEGIN PUBLIC KEY-----", "") // + .replaceAll(System.lineSeparator(), "") // + .replace("-----END PUBLIC KEY-----", ""); + + byte[] encoded = Base64.getDecoder().decode(publicKeyPem.getBytes()); + + return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(encoded)); + } +} \ No newline at end of file diff --git a/src/main/java/software/sham/ssh/util/SshKeyPairGenerator.java b/src/main/java/software/sham/ssh/util/SshKeyPairGenerator.java new file mode 100644 index 0000000..88c80d3 --- /dev/null +++ b/src/main/java/software/sham/ssh/util/SshKeyPairGenerator.java @@ -0,0 +1,133 @@ +package software.sham.ssh.util; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.security.AlgorithmParameters; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.InvalidParameterSpecException; +import java.util.Base64; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.EncryptedPrivateKeyInfo; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.PBEParameterSpec; + +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; + +public class SshKeyPairGenerator { + + public static class GeneratorException extends Exception { + + private static final long serialVersionUID = 1L; + + public GeneratorException(Throwable cause) { + super(cause); + } + } + + private final File keyFilePriv; + private final File keyFilePub; + + public SshKeyPairGenerator(File keyFilePriv, File keyFilePub) { + this.keyFilePriv = keyFilePriv; + this.keyFilePub = keyFilePub; + } + + public void generate(String privateKeyPassword) throws GeneratorException { + try { + generateWithJavaSecurityRsa(privateKeyPassword); + } catch (Exception ex) { + throw new GeneratorException(ex); + } + } + + private static final String JAVAX_KP_ALG = "ed25519"; + private static final String JAVAX_OPENSSH_TAG = "ssh-" + JAVAX_KP_ALG; + private static final String JAVAX_PBE_ALG = "PBEWithSHA1AndDESede"; + + /** + * https://stackoverflow.com/questions/41180398/how-to-add-a-password-to-an-existing-private-key-in-java + * + * @param privateKeyPassword + * @throws NoSuchAlgorithmException + * @throws IOException + * @throws InvalidKeySpecException + * @throws NoSuchPaddingException + * @throws InvalidKeyException + * @throws InvalidAlgorithmParameterException + * @throws IllegalBlockSizeException + * @throws BadPaddingException + * @throws InvalidParameterSpecException + */ + private void generateWithJavaSecurityRsa(String privateKeyPassword) throws NoSuchAlgorithmException, IOException, + InvalidKeySpecException, NoSuchPaddingException, InvalidKeyException, InvalidAlgorithmParameterException, + IllegalBlockSizeException, BadPaddingException, InvalidParameterSpecException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(JAVAX_KP_ALG); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + // encrypt privateKey with password + byte[] encodedPrivateKey = keyPair.getPrivate().getEncoded(); + + SecureRandom random = new SecureRandom(); + byte[] salt = new byte[8]; + random.nextBytes(salt); + + PBEParameterSpec pbeParamSpec = new PBEParameterSpec(salt, 20); + PBEKeySpec pbeKeySpec = new PBEKeySpec(privateKeyPassword.toCharArray()); + + SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(JAVAX_PBE_ALG); + SecretKey secretKey = secretKeyFactory.generateSecret(pbeKeySpec); + + Cipher cipher = Cipher.getInstance(JAVAX_PBE_ALG); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, pbeParamSpec); + byte[] encryptePrivateKey = cipher.doFinal(encodedPrivateKey); + + AlgorithmParameters algParams = AlgorithmParameters.getInstance(JAVAX_PBE_ALG); + algParams.init(pbeParamSpec); + EncryptedPrivateKeyInfo encinfo = new EncryptedPrivateKeyInfo(algParams, encryptePrivateKey); + + Files.write(keyFilePriv.toPath(), encinfo.getEncoded()); + + // encode publicKey in OPENSSH format + // https://stackoverflow.com/questions/77460283/encoding-a-ed25519-public-key-to-ssh-format-in-java + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(baos); + byte[] tag = JAVAX_OPENSSH_TAG.getBytes(); + dos.writeInt(tag.length); + dos.write(tag); + + byte[] publicKeyData = retrieveSpkiPublicKeyData(keyPair.getPublic()); + dos.writeInt(publicKeyData.length); + dos.write(publicKeyData); + + StringBuilder publicKeySb = new StringBuilder(); + publicKeySb.append(JAVAX_OPENSSH_TAG); + publicKeySb.append(' '); + publicKeySb.append(Base64.getEncoder().encodeToString(baos.toByteArray())); +// if (comment != null) { +// publicKeySb.append(' '); +// publicKeySb.append(comment); +// } + Files.writeString(keyFilePub.toPath(), publicKeySb.toString()); + + } + + private byte[] retrieveSpkiPublicKeyData(PublicKey pubkey) throws IOException { + return SubjectPublicKeyInfo.getInstance(pubkey.getEncoded()).getPublicKeyData().getOctets(); + } +} diff --git a/src/test/java/software/sham/ssh/FunctionalTest.java b/src/test/java/software/sham/ssh/FunctionalTest.java index 834665c..c84f34a 100644 --- a/src/test/java/software/sham/ssh/FunctionalTest.java +++ b/src/test/java/software/sham/ssh/FunctionalTest.java @@ -13,6 +13,7 @@ import java.io.PrintWriter; import java.nio.file.Files; import java.nio.file.Path; +import java.security.spec.X509EncodedKeySpec; import java.util.Properties; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; @@ -109,13 +110,12 @@ public void singleOutput() throws Exception { waitForOutput("hodor\n"); } - @Test - public void shouldSupportPublicKeyAuth() throws Exception { + private void testPublicKeyAuth(Path pubKey, String alg) throws Exception { server.allowPublicKey( // - Files.readAllBytes( // - Path.of( // - Thread.currentThread().getContextClassLoader() // - .getResource("keys/id_rsa_tester.der.pub").toURI()))); + new X509EncodedKeySpec( // + Files.readAllBytes(pubKey), // + alg) // + ); initSshClientWithKey(); @@ -124,8 +124,30 @@ public void shouldSupportPublicKeyAuth() throws Exception { sendTextToServer("Knock knock\n"); waitForOutput("hodor\n"); + + } + + @Test + public void shouldSupportDERPublicKeyAuth() throws Exception { + testPublicKeyAuth(Path.of(Thread.currentThread().getContextClassLoader() // + .getResource("keys/id_rsa_tester.der.pub").toURI()), // + "RSA"); } + @Test + public void shouldSupportSSHPublicKeyAuth() throws Exception { + testPublicKeyAuth(Path.of(Thread.currentThread().getContextClassLoader() // + .getResource("keys/id_rsa_tester.pub").toURI()), // + "OPENSSH"); + } + + @Test + public void shouldSupportPKCS8PublicKeyAuth() throws Exception { + testPublicKeyAuth(Path.of(Thread.currentThread().getContextClassLoader() // + .getResource("keys/id_rsa_tester.pkcs8.pub").toURI()), // + "PEM"); + } + @Test public void multipleOutput() throws Exception { server.respondTo(any(String.class)).withOutput("Starting...\n").withOutput("Completed.\n"); 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 From 9b0682c3e21e3bc2636bcd7ea63a4c75aaff5b0f Mon Sep 17 00:00:00 2001 From: Jan Esser Date: Mon, 27 Jan 2025 23:46:40 +0100 Subject: [PATCH 04/12] major rewrite leading to 1.0.0-SNAPSHOT --- pom.xml | 73 ++++--- .../software/sham/sftp/MockSftpServer.java | 110 +++++----- .../sham/ssh/CommandParserSupport.java | 43 ++++ .../java/software/sham/ssh/MockSshServer.java | 90 ++++---- .../java/software/sham/ssh/MockSshShell.java | 180 +++++----------- .../software/sham/ssh/PublicKeyRetriever.java | 74 ------- .../sham/ssh/ResponderDispatcher.java | 69 +++--- .../java/software/sham/ssh/SshResponder.java | 36 ++-- .../sham/ssh/SshResponderBuilder.java | 53 +++-- .../software/sham/ssh/actions/Action.java | 24 ++- .../java/software/sham/ssh/actions/Close.java | 13 +- .../java/software/sham/ssh/actions/Delay.java | 8 +- .../java/software/sham/ssh/actions/Echo.java | 34 +++ .../java/software/sham/ssh/actions/Greet.java | 29 +++ .../software/sham/ssh/actions/Output.java | 37 ++-- .../sham/ssh/actions/OutputSupport.java | 18 ++ .../software/sham/ssh/actions/Prompt.java | 33 +++ .../sham/ssh/util/SshKeyPairGenerator.java | 133 ------------ .../software/sham/sftp/FunctionalTest.java | 120 +++++------ .../software/sham/ssh/FunctionalTest.java | 201 ------------------ .../software/sham/ssh/MockSshCommandTest.java | 47 ++++ .../sham/ssh/MockSshServerConnectionTest.java | 120 +++++++++++ .../sham/ssh/MockSshServerTestSupport.java | 73 +++++++ .../software/sham/ssh/MockSshShellTest.java | 103 +++++++++ src/test/resources/log4j2.xml | 21 -- src/test/resources/simplelogger.properties | 1 + 26 files changed, 901 insertions(+), 842 deletions(-) create mode 100644 src/main/java/software/sham/ssh/CommandParserSupport.java delete mode 100644 src/main/java/software/sham/ssh/PublicKeyRetriever.java create mode 100644 src/main/java/software/sham/ssh/actions/Echo.java create mode 100644 src/main/java/software/sham/ssh/actions/Greet.java create mode 100644 src/main/java/software/sham/ssh/actions/OutputSupport.java create mode 100644 src/main/java/software/sham/ssh/actions/Prompt.java delete mode 100644 src/main/java/software/sham/ssh/util/SshKeyPairGenerator.java delete mode 100644 src/test/java/software/sham/ssh/FunctionalTest.java create mode 100644 src/test/java/software/sham/ssh/MockSshCommandTest.java create mode 100644 src/test/java/software/sham/ssh/MockSshServerConnectionTest.java create mode 100644 src/test/java/software/sham/ssh/MockSshServerTestSupport.java create mode 100644 src/test/java/software/sham/ssh/MockSshShellTest.java delete mode 100644 src/test/resources/log4j2.xml create mode 100644 src/test/resources/simplelogger.properties diff --git a/pom.xml b/pom.xml index 86ee7b6..e5d0f95 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ software.sham sham-ssh - 0.3.0 + 1.0.0-SNAPSHOT jar Mock SSH and SFTP Testing API http://sham.software/ssh @@ -99,53 +99,48 @@ - org.bouncycastle - bcpg-jdk18on - 1.80 - - - org.bouncycastle - bcpkix-jdk18on - 1.80 + net.i2p.crypto + eddsa + 0.3.0 org.slf4j - slf4j-simple + slf4j-api 2.0.16 org.slf4j - slf4j-api + slf4j-simple 2.0.16 - - junit - junit - 4.13.2 - test + com.sshtools + maverick-synergy-client + 3.1.2 - org.mockito - mockito-core - 1.9.5 - test + org.bouncycastle + bcpg-jdk18on + 1.80 - com.jcraft - jsch - 0.1.55 - test + org.bouncycastle + bcpkix-jdk18on + 1.80 + + + junit + junit + 4.13.2 org.awaitility awaitility 4.2.2 - test @@ -163,19 +158,32 @@ org.hamcrest hamcrest-core + + net.i2p.crypto + eddsa + true + + + org.slf4j + slf4j-api + + + + + com.sshtools + maverick-synergy-client + test + org.bouncycastle bcpg-jdk18on + test org.bouncycastle bcpkix-jdk18on + test - - org.slf4j - slf4j-api - - junit junit @@ -186,11 +194,6 @@ slf4j-simple test - - com.jcraft - jsch - test - org.awaitility awaitility diff --git a/src/main/java/software/sham/sftp/MockSftpServer.java b/src/main/java/software/sham/sftp/MockSftpServer.java index d872cd9..342fb57 100644 --- a/src/main/java/software/sham/sftp/MockSftpServer.java +++ b/src/main/java/software/sham/sftp/MockSftpServer.java @@ -1,55 +1,55 @@ -package software.sham.sftp; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -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 Path baseDirectory; - - public MockSftpServer(int port) throws IOException { - this(port, false); - } - - private MockSftpServer(int port, boolean enableShell) throws IOException { - super(port, false); - initSftp(); - if (enableShell) { - enableShell(); - } - start(); - } - - private void initSftp() { - SftpSubsystemFactory sftpSubsystemFactory = new SftpSubsystemFactory.Builder().build(); - sshServer.setSubsystemFactories(Collections.singletonList(sftpSubsystemFactory)); - } - - public Path getBaseDirectory() { - return baseDirectory; - } - - @Override - public void start() throws IOException { - baseDirectory = Files.createTempDirectory("sftproot"); - sshServer.setFileSystemFactory(new VirtualFileSystemFactory(baseDirectory.toAbsolutePath())); - super.start(); - } - - @Override - public void stop() throws IOException { - super.stop(); - baseDirectory.toFile().delete(); - } - - public static MockSftpServer createWithShell(int port) throws IOException { - return new MockSftpServer(port, true); - } -} +//package software.sham.sftp; +// +//import java.io.IOException; +//import java.nio.file.Files; +//import java.nio.file.Path; +//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 Path baseDirectory; +// +// public MockSftpServer(int port) throws IOException { +// this(port, false); +// } +// +// private MockSftpServer(int port, boolean enableShell) throws IOException { +// super(port, false); +// initSftp(); +// if (enableShell) { +// enableShell(); +// } +// start(); +// } +// +// private void initSftp() { +// SftpSubsystemFactory sftpSubsystemFactory = new SftpSubsystemFactory.Builder().build(); +// sshServer.setSubsystemFactories(Collections.singletonList(sftpSubsystemFactory)); +// } +// +// public Path getBaseDirectory() { +// return baseDirectory; +// } +// +// @Override +// public void start() throws IOException { +// baseDirectory = Files.createTempDirectory("sftproot"); +// sshServer.setFileSystemFactory(new VirtualFileSystemFactory(baseDirectory.toAbsolutePath())); +// super.start(); +// } +// +// @Override +// public void stop() throws IOException { +// super.stop(); +// baseDirectory.toFile().delete(); +// } +// +// public static MockSftpServer createWithShell(int port) throws IOException { +// return new MockSftpServer(port, true); +// } +//} 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 215847b..bcb1910 100644 --- a/src/main/java/software/sham/ssh/MockSshServer.java +++ b/src/main/java/software/sham/ssh/MockSshServer.java @@ -3,9 +3,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.security.GeneralSecurityException; import java.security.PublicKey; -import java.security.spec.X509EncodedKeySpec; import java.util.HashSet; import java.util.Set; @@ -14,24 +12,26 @@ 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; -public class MockSshServer implements ShellFactory { +public class MockSshServer implements ShellFactory, CommandFactory { public static final String USERNAME = "tester"; public static final String PASSWORD = "testing"; private final Logger log = LoggerFactory.getLogger(this.getClass()); - protected final SshServer sshServer; - - private Set keys = new HashSet(); + 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 { @@ -39,27 +39,40 @@ public MockSshServer(int port) throws IOException { } public MockSshServer(int port, boolean shouldStartServices) throws IOException { - sshServer = initSshServer(port); + 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(); } } - /** - * @param key with explicit {@link X509EncodedKeySpec#getAlgorithm()} notion - */ - public MockSshServer allowPublicKey(X509EncodedKeySpec keySpec) throws GeneralSecurityException { - this.keys.add(new PublicKeyRetriever(keySpec).getPublicKey()); - sshServer.setPublickeyAuthenticator(new KeySetPublickeyAuthenticator(this, this.keys)); + 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(); - setDefaults(); + sshShell = new MockSshShell(this.dispatcher); + sshShell.setDefaults(); sshServer.setShellFactory(this); return this; } @@ -76,36 +89,35 @@ 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, this.keys)); - return sshd; - } - public SshResponderBuilder respondTo(Matcher matcher) { - SshResponderBuilder builder = new SshResponderBuilder(); - sshShell.getDispatcher().add(matcher, builder.getResponder()); - return builder; + 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 respondTo(Matchers.equalTo(input)); - } - - private void setDefaults() { - respondTo("exit").withClose(); + 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); + } + } + }; + } } diff --git a/src/main/java/software/sham/ssh/MockSshShell.java b/src/main/java/software/sham/ssh/MockSshShell.java index f147e45..a128ee8 100644 --- a/src/main/java/software/sham/ssh/MockSshShell.java +++ b/src/main/java/software/sham/ssh/MockSshShell.java @@ -1,155 +1,73 @@ package software.sham.ssh; +import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.Writer; -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.Collections; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.io.InputStreamReader; import org.apache.sshd.server.Environment; -import org.apache.sshd.server.ExitCallback; import org.apache.sshd.server.channel.ChannelSession; -import org.apache.sshd.server.command.Command; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -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); +import org.hamcrest.Matchers; - @Override - public void setInputStream(InputStream in) { - this.in = in; - } +import software.sham.ssh.actions.Action; +import software.sham.ssh.actions.Greet; +import software.sham.ssh.actions.Prompt; - @Override - public void setOutputStream(OutputStream out) { - this.out = out; - } +class MockSshShell extends CommandParserSupport { - @Override - public void setErrorStream(OutputStream err) { - this.err = err; - } + private final ResponderDispatcher dispatcher; - @Override - public void setExitCallback(ExitCallback callback) { - this.callback = callback; - } + private final Action greet = new Greet(); + private final Action prompt = new Prompt(); - @Override - public void start(ChannelSession channel, Environment env) throws IOException { - logger.debug("Starting mock SSH shell"); - executor.submit(eventLoop); - - } + protected MockSshShell(ResponderDispatcher dispatcher) { + super("shell", null); - @Override - public void destroy(ChannelSession channel) throws Exception { - closeSession(); - executor.shutdown(); + this.dispatcher = dispatcher; } - public void closeSession() { - eventLoop.stop(); - } + @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()); + } - 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(); - } - - if (sb.length() > 0) - return Arrays.asList(sb.toString().split("\\r?\\n")); - else - return Collections.emptyList(); - } + prompt.respond(getServerSession(), getOutputStream()); - 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(); + getOutputStream().flush(); + } + } catch (IOException ex) { + log.error("Shell aborting due to Exception.", ex); + } + }); } - 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); - } + @Override + public void destroy(ChannelSession channelSession) throws Exception { + executorService.shutdown(); } - public ResponderDispatcher getDispatcher() { - return this.dispatcher; + @Override + public void run() { + // do nothing here } - 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); - } + /** + * 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/PublicKeyRetriever.java b/src/main/java/software/sham/ssh/PublicKeyRetriever.java deleted file mode 100644 index 2326d97..0000000 --- a/src/main/java/software/sham/ssh/PublicKeyRetriever.java +++ /dev/null @@ -1,74 +0,0 @@ -package software.sham.ssh; - -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.X509EncodedKeySpec; -import java.util.Base64; - -import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; -import org.bouncycastle.crypto.params.AsymmetricKeyParameter; -import org.bouncycastle.crypto.util.OpenSSHPublicKeyUtil; -import org.bouncycastle.crypto.util.SubjectPublicKeyInfoFactory; -import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; - -public class PublicKeyRetriever { - private final X509EncodedKeySpec keySpec; - - public PublicKeyRetriever(X509EncodedKeySpec keySpec) { - this.keySpec = keySpec; - } - - public PublicKey getPublicKey() throws GeneralSecurityException { - switch (keySpec.getAlgorithm()) { - case "OPENSSH": - return parseSshEnvelopeBc(keySpec); - case "SSH2": - case "PEM": - return parsePemEnvelope(keySpec); - default: - return KeyFactory.getInstance(keySpec.getAlgorithm()).generatePublic(keySpec); - } - } - - private PublicKey parseSshEnvelopeBc(X509EncodedKeySpec keySpec) throws GeneralSecurityException { - try { - // decompose OPENSSH format to its essence - String base64pubKey = new String(keySpec.getEncoded()) // - .split(" ")[1] // - .replaceAll(System.lineSeparator(), ""); - byte[] pubKey = Base64.getDecoder().decode(base64pubKey); - - // parse & convert - AsymmetricKeyParameter keyParams = OpenSSHPublicKeyUtil.parsePublicKey(pubKey); - SubjectPublicKeyInfo publicKeyInfo = SubjectPublicKeyInfoFactory.createSubjectPublicKeyInfo(keyParams); - return new JcaPEMKeyConverter().getPublicKey(publicKeyInfo); - } catch (IOException ex) { - throw new GeneralSecurityException(ex); - } - - } - - /** - * https://www.baeldung.com/java-read-pem-file-keys - * - * @param keySpec - * @return - * @throws InvalidKeySpecException - * @throws NoSuchAlgorithmException - */ - private PublicKey parsePemEnvelope(X509EncodedKeySpec keySpec) - throws InvalidKeySpecException, NoSuchAlgorithmException { - String publicKeyPem = new String(keySpec.getEncoded()) // - .replace("-----BEGIN PUBLIC KEY-----", "") // - .replaceAll(System.lineSeparator(), "") // - .replace("-----END PUBLIC KEY-----", ""); - - byte[] encoded = Base64.getDecoder().decode(publicKeyPem.getBytes()); - - return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(encoded)); - } -} \ No newline at end of file diff --git a/src/main/java/software/sham/ssh/ResponderDispatcher.java b/src/main/java/software/sham/ssh/ResponderDispatcher.java index af4cd1c..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,28 +9,48 @@ import java.util.LinkedList; import java.util.Map; -@SuppressWarnings("rawtypes") -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 9d0069d..170b45e 100644 --- a/src/main/java/software/sham/ssh/SshResponder.java +++ b/src/main/java/software/sham/ssh/SshResponder.java @@ -1,32 +1,36 @@ package software.sham.ssh; import java.io.IOException; +import java.io.OutputStream; import java.util.LinkedList; import java.util.List; +import org.apache.sshd.server.session.ServerSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.sham.ssh.actions.Action; -public class SshResponder { - private final Logger logger = LoggerFactory.getLogger(getClass()); +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/main/java/software/sham/ssh/util/SshKeyPairGenerator.java b/src/main/java/software/sham/ssh/util/SshKeyPairGenerator.java deleted file mode 100644 index 88c80d3..0000000 --- a/src/main/java/software/sham/ssh/util/SshKeyPairGenerator.java +++ /dev/null @@ -1,133 +0,0 @@ -package software.sham.ssh.util; - -import java.io.ByteArrayOutputStream; -import java.io.DataOutputStream; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.security.AlgorithmParameters; -import java.security.InvalidAlgorithmParameterException; -import java.security.InvalidKeyException; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.security.PublicKey; -import java.security.SecureRandom; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.InvalidParameterSpecException; -import java.util.Base64; - -import javax.crypto.BadPaddingException; -import javax.crypto.Cipher; -import javax.crypto.EncryptedPrivateKeyInfo; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; -import javax.crypto.SecretKey; -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.PBEKeySpec; -import javax.crypto.spec.PBEParameterSpec; - -import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; - -public class SshKeyPairGenerator { - - public static class GeneratorException extends Exception { - - private static final long serialVersionUID = 1L; - - public GeneratorException(Throwable cause) { - super(cause); - } - } - - private final File keyFilePriv; - private final File keyFilePub; - - public SshKeyPairGenerator(File keyFilePriv, File keyFilePub) { - this.keyFilePriv = keyFilePriv; - this.keyFilePub = keyFilePub; - } - - public void generate(String privateKeyPassword) throws GeneratorException { - try { - generateWithJavaSecurityRsa(privateKeyPassword); - } catch (Exception ex) { - throw new GeneratorException(ex); - } - } - - private static final String JAVAX_KP_ALG = "ed25519"; - private static final String JAVAX_OPENSSH_TAG = "ssh-" + JAVAX_KP_ALG; - private static final String JAVAX_PBE_ALG = "PBEWithSHA1AndDESede"; - - /** - * https://stackoverflow.com/questions/41180398/how-to-add-a-password-to-an-existing-private-key-in-java - * - * @param privateKeyPassword - * @throws NoSuchAlgorithmException - * @throws IOException - * @throws InvalidKeySpecException - * @throws NoSuchPaddingException - * @throws InvalidKeyException - * @throws InvalidAlgorithmParameterException - * @throws IllegalBlockSizeException - * @throws BadPaddingException - * @throws InvalidParameterSpecException - */ - private void generateWithJavaSecurityRsa(String privateKeyPassword) throws NoSuchAlgorithmException, IOException, - InvalidKeySpecException, NoSuchPaddingException, InvalidKeyException, InvalidAlgorithmParameterException, - IllegalBlockSizeException, BadPaddingException, InvalidParameterSpecException { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(JAVAX_KP_ALG); - KeyPair keyPair = keyPairGenerator.generateKeyPair(); - - // encrypt privateKey with password - byte[] encodedPrivateKey = keyPair.getPrivate().getEncoded(); - - SecureRandom random = new SecureRandom(); - byte[] salt = new byte[8]; - random.nextBytes(salt); - - PBEParameterSpec pbeParamSpec = new PBEParameterSpec(salt, 20); - PBEKeySpec pbeKeySpec = new PBEKeySpec(privateKeyPassword.toCharArray()); - - SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(JAVAX_PBE_ALG); - SecretKey secretKey = secretKeyFactory.generateSecret(pbeKeySpec); - - Cipher cipher = Cipher.getInstance(JAVAX_PBE_ALG); - cipher.init(Cipher.ENCRYPT_MODE, secretKey, pbeParamSpec); - byte[] encryptePrivateKey = cipher.doFinal(encodedPrivateKey); - - AlgorithmParameters algParams = AlgorithmParameters.getInstance(JAVAX_PBE_ALG); - algParams.init(pbeParamSpec); - EncryptedPrivateKeyInfo encinfo = new EncryptedPrivateKeyInfo(algParams, encryptePrivateKey); - - Files.write(keyFilePriv.toPath(), encinfo.getEncoded()); - - // encode publicKey in OPENSSH format - // https://stackoverflow.com/questions/77460283/encoding-a-ed25519-public-key-to-ssh-format-in-java - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - DataOutputStream dos = new DataOutputStream(baos); - byte[] tag = JAVAX_OPENSSH_TAG.getBytes(); - dos.writeInt(tag.length); - dos.write(tag); - - byte[] publicKeyData = retrieveSpkiPublicKeyData(keyPair.getPublic()); - dos.writeInt(publicKeyData.length); - dos.write(publicKeyData); - - StringBuilder publicKeySb = new StringBuilder(); - publicKeySb.append(JAVAX_OPENSSH_TAG); - publicKeySb.append(' '); - publicKeySb.append(Base64.getEncoder().encodeToString(baos.toByteArray())); -// if (comment != null) { -// publicKeySb.append(' '); -// publicKeySb.append(comment); -// } - Files.writeString(keyFilePub.toPath(), publicKeySb.toString()); - - } - - private byte[] retrieveSpkiPublicKeyData(PublicKey pubkey) throws IOException { - return SubjectPublicKeyInfo.getInstance(pubkey.getEncoded()).getPublicKeyData().getOctets(); - } -} diff --git a/src/test/java/software/sham/sftp/FunctionalTest.java b/src/test/java/software/sham/sftp/FunctionalTest.java index 9a8b6f0..3d83b01 100644 --- a/src/test/java/software/sham/sftp/FunctionalTest.java +++ b/src/test/java/software/sham/sftp/FunctionalTest.java @@ -1,60 +1,60 @@ -package software.sham.sftp; - -import static org.junit.Assert.assertEquals; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.file.Files; -import java.util.Collections; -import java.util.Properties; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import com.jcraft.jsch.ChannelSftp; -import com.jcraft.jsch.JSch; -import com.jcraft.jsch.JSchException; -import com.jcraft.jsch.Session; -import com.jcraft.jsch.SftpException; - -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.write(server.getBaseDirectory().resolve("example.txt"), - Collections.singletonList("example file contents")); - - ChannelSftp channel = (ChannelSftp) sshSession.openChannel("sftp"); - channel.connect(); - - BufferedReader reader = new BufferedReader(new InputStreamReader(channel.get("example.txt"))); - final String downloadedContents = reader.readLine(); - - assertEquals("example file contents", downloadedContents); - } -} +//package software.sham.sftp; +// +//import static org.junit.Assert.assertEquals; +// +//import java.io.BufferedReader; +//import java.io.IOException; +//import java.io.InputStreamReader; +//import java.nio.file.Files; +//import java.util.Collections; +//import java.util.Properties; +// +//import org.junit.After; +//import org.junit.Before; +//import org.junit.Test; +// +//import com.jcraft.jsch.ChannelSftp; +//import com.jcraft.jsch.JSch; +//import com.jcraft.jsch.JSchException; +//import com.jcraft.jsch.Session; +//import com.jcraft.jsch.SftpException; +// +//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.write(server.getBaseDirectory().resolve("example.txt"), +// Collections.singletonList("example file contents")); +// +// ChannelSftp channel = (ChannelSftp) sshSession.openChannel("sftp"); +// channel.connect(); +// +// BufferedReader reader = new BufferedReader(new InputStreamReader(channel.get("example.txt"))); +// final String downloadedContents = reader.readLine(); +// +// assertEquals("example file contents", downloadedContents); +// } +//} 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 c84f34a..0000000 --- a/src/test/java/software/sham/ssh/FunctionalTest.java +++ /dev/null @@ -1,201 +0,0 @@ -package software.sham.ssh; - -import static org.awaitility.Awaitility.await; -import static org.hamcrest.Matchers.any; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.io.PipedInputStream; -import java.io.PipedOutputStream; -import java.io.PrintWriter; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.spec.X509EncodedKeySpec; -import java.util.Properties; -import java.util.concurrent.Callable; -import java.util.concurrent.TimeUnit; - -import org.hamcrest.Matcher; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.jcraft.jsch.ChannelShell; -import com.jcraft.jsch.JSch; -import com.jcraft.jsch.JSchException; -import com.jcraft.jsch.Session; - -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(); - } - - @Test - public void defaultShellCommandsShouldSilentlySucceed() throws Exception { - sendTextToServer("Knock knock\n"); - - waitFor(() -> sshClientOutput.size(), equalTo(0)); - } - - @Test - public void defaultShellShouldDisconnectOnExit() throws Exception { - sendTextToServer("exit\n"); - - waitFor(() -> sshChannel.isConnected(), is(false)); - } - - @Test - public void singleOutput() throws Exception { - server.respondTo(any(String.class)).withOutput("hodor\n"); - - sendTextToServer("Knock knock\n"); - - waitForOutput("hodor\n"); - } - - private void testPublicKeyAuth(Path pubKey, String alg) throws Exception { - server.allowPublicKey( // - new X509EncodedKeySpec( // - Files.readAllBytes(pubKey), // - alg) // - ); - - initSshClientWithKey(); - - server.respondTo(any(String.class)).withOutput("hodor\n"); - - sendTextToServer("Knock knock\n"); - - waitForOutput("hodor\n"); - - } - - @Test - public void shouldSupportDERPublicKeyAuth() throws Exception { - testPublicKeyAuth(Path.of(Thread.currentThread().getContextClassLoader() // - .getResource("keys/id_rsa_tester.der.pub").toURI()), // - "RSA"); - } - - @Test - public void shouldSupportSSHPublicKeyAuth() throws Exception { - testPublicKeyAuth(Path.of(Thread.currentThread().getContextClassLoader() // - .getResource("keys/id_rsa_tester.pub").toURI()), // - "OPENSSH"); - } - - @Test - public void shouldSupportPKCS8PublicKeyAuth() throws Exception { - testPublicKeyAuth(Path.of(Thread.currentThread().getContextClassLoader() // - .getResource("keys/id_rsa_tester.pkcs8.pub").toURI()), // - "PEM"); - } - - @Test - public void multipleOutput() throws Exception { - server.respondTo(any(String.class)).withOutput("Starting...\n").withOutput("Completed.\n"); - - sendTextToServer("start"); - Thread.sleep(100L); - - waitForOutput("Starting...\nCompleted.\n"); - } - - @Test - public void delayedOutput() throws Exception { - server.respondTo(any(String.class)).withOutput("Starting...\n").withDelay(500L).withOutput("Completed.\n"); - - sendTextToServer("start"); - logger.debug("Checking for first line"); - waitForOutput("Starting...\n"); - logger.debug("Checking for second line"); - waitForOutput("Starting...\nCompleted.\n"); - } - - @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"); - waitForOutput("default\n"); - - sendTextToServer("Knock knock"); - waitForOutput("default\nWho's there?\n"); - } - - private void sendTextToServer(final String text) throws Exception { - inputWriter.write(text); - inputWriter.flush(); - logger.debug("Sent text to SSH server: {}", text); - } - - private T waitFor(final Callable supplier, final Matcher matcher) { - return await() // - .during(200, TimeUnit.MICROSECONDS) // - .atMost(1, TimeUnit.SECONDS) // - .until(supplier, matcher); - } - - private String waitForOutput(String output) { - return this.waitFor(() -> sshClientOutput.toString(), equalTo(output)); - } - -} 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..97c2779 --- /dev/null +++ b/src/test/java/software/sham/ssh/MockSshCommandTest.java @@ -0,0 +1,47 @@ +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; + +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..ce0ab1e --- /dev/null +++ b/src/test/java/software/sham/ssh/MockSshServerConnectionTest.java @@ -0,0 +1,120 @@ +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; + +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/MockSshServerTestSupport.java b/src/test/java/software/sham/ssh/MockSshServerTestSupport.java new file mode 100644 index 0000000..191f8be --- /dev/null +++ b/src/test/java/software/sham/ssh/MockSshServerTestSupport.java @@ -0,0 +1,73 @@ +package software.sham.ssh; + +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; + +abstract class MockSshServerTestSupport { + + static final String SSH_SERVER_HOSTNAME = "localhost"; + static final int SSH_SERVER_PORT = 9022; + + static final String SSH_SERVER_USER = MockSshServer.USERNAME; + static final String SSH_SERVER_PASSWORD = MockSshServer.PASSWORD; + static final String SSH_SERVER_USER_KEY_PASSPHRASE = "testing"; + + static final long SSH_SERVER_CONNECT_TIMOUT = 1000L; + // private static final long SSH_SERVER_SHELL_WAIT = 1000L; + + final Logger logger = LoggerFactory.getLogger(getClass()); + + MockSshServer server; + SshClient sshClient; + + interface MockSshServerConfigurer { + void apply(MockSshServer server); + } + + MockSshServerConfigurer serverConfigurer = server -> { + /* do nothing */ }; + + MockSshServerTestSupport(MockSshServerConfigurer serverConfigurer) { + this.serverConfigurer = serverConfigurer; + } + + MockSshServerTestSupport() { + // no specific serverConfigurer + } + + @Before + public void startSshServer() throws IOException { + this.server = new MockSshServer(MockSshServerTestSupport.SSH_SERVER_PORT, false); + 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/ssh/MockSshShellTest.java b/src/test/java/software/sham/ssh/MockSshShellTest.java new file mode 100644 index 0000000..33783ff --- /dev/null +++ b/src/test/java/software/sham/ssh/MockSshShellTest.java @@ -0,0 +1,103 @@ +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; + +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...\n").withDelay(500L).withOutput("Completed."); + + runInShell(shell -> { + assertEquals("Starting...\nCompleted.", 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/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 From de02b00c5866bd1bdacf8951421977e6c02fde2b Mon Sep 17 00:00:00 2001 From: Jan Esser Date: Sun, 2 Feb 2025 20:10:23 +0100 Subject: [PATCH 05/12] README.md update --- README.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 08d8832..8672a5d 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,17 @@ Mock SSH and SFTP testing library for running an SSH server in process. SFTP uses a local temporary directory. -25.01.2025 revamped with latest versions of sshd-core and sshd-sftp. +## Development Notes +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 -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. +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** @@ -18,11 +25,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.3.0 + 1.0.0-SNAPSHOT ``` From c16e24678bfad0fff120b1d90d83503668a6ee5d Mon Sep 17 00:00:00 2001 From: Jan Esser Date: Sun, 2 Feb 2025 19:12:10 +0000 Subject: [PATCH 06/12] Create maven.yml --- .github/workflows/maven.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/maven.yml 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 From b48d73b1dbe9e367ee06c38ed758ca308d7bc758 Mon Sep 17 00:00:00 2001 From: Jan Esser Date: Mon, 3 Feb 2025 16:57:06 +0100 Subject: [PATCH 07/12] restoring MockSftpServer --- .../software/sham/sftp/MockSftpServer.java | 97 ++++++++----------- .../java/software/sham/ssh/MockSshServer.java | 6 +- .../sham/MockSshServerTestSupport.java | 88 +++++++++++++++++ .../software/sham/sftp/FunctionalTest.java | 60 ------------ .../sham/sftp/MockSftpServerTest.java | 64 ++++++++++++ .../software/sham/ssh/MockSshCommandTest.java | 2 + .../sham/ssh/MockSshServerConnectionTest.java | 2 + .../sham/ssh/MockSshServerTestSupport.java | 73 -------------- .../software/sham/ssh/MockSshShellTest.java | 2 + 9 files changed, 205 insertions(+), 189 deletions(-) create mode 100644 src/test/java/software/sham/MockSshServerTestSupport.java delete mode 100644 src/test/java/software/sham/sftp/FunctionalTest.java create mode 100644 src/test/java/software/sham/sftp/MockSftpServerTest.java delete mode 100644 src/test/java/software/sham/ssh/MockSshServerTestSupport.java diff --git a/src/main/java/software/sham/sftp/MockSftpServer.java b/src/main/java/software/sham/sftp/MockSftpServer.java index 342fb57..3c65847 100644 --- a/src/main/java/software/sham/sftp/MockSftpServer.java +++ b/src/main/java/software/sham/sftp/MockSftpServer.java @@ -1,55 +1,42 @@ -//package software.sham.sftp; -// -//import java.io.IOException; -//import java.nio.file.Files; -//import java.nio.file.Path; -//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 Path baseDirectory; -// -// public MockSftpServer(int port) throws IOException { -// this(port, false); -// } -// -// private MockSftpServer(int port, boolean enableShell) throws IOException { -// super(port, false); -// initSftp(); -// if (enableShell) { -// enableShell(); -// } -// start(); -// } -// -// private void initSftp() { -// SftpSubsystemFactory sftpSubsystemFactory = new SftpSubsystemFactory.Builder().build(); -// sshServer.setSubsystemFactories(Collections.singletonList(sftpSubsystemFactory)); -// } -// -// public Path getBaseDirectory() { -// return baseDirectory; -// } -// -// @Override -// public void start() throws IOException { -// baseDirectory = Files.createTempDirectory("sftproot"); -// sshServer.setFileSystemFactory(new VirtualFileSystemFactory(baseDirectory.toAbsolutePath())); -// super.start(); -// } -// -// @Override -// public void stop() throws IOException { -// super.stop(); -// baseDirectory.toFile().delete(); -// } -// -// public static MockSftpServer createWithShell(int port) throws IOException { -// return new MockSftpServer(port, true); -// } -//} +package software.sham.sftp; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +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 Path baseDirectory; + + public MockSftpServer(int port) throws IOException { + super(port, false); + + SftpSubsystemFactory sftpSubsystemFactory = new SftpSubsystemFactory.Builder().build(); + super.getSshServer().setSubsystemFactories(Collections.singletonList(sftpSubsystemFactory)); + + this.baseDirectory = Files.createTempDirectory("sftp_root"); + log.info("baseDirectory: " + baseDirectory.toAbsolutePath().toString()); + super.getSshServer().setFileSystemFactory(new VirtualFileSystemFactory(baseDirectory.toAbsolutePath())); + } + + @Override + public void start() throws IOException { + super.start(); + } + + @Override + public void stop() throws IOException { + super.stop(); + this.baseDirectory.toFile().delete(); + } + + public Path getBaseDirectory() { + return baseDirectory; + } +} diff --git a/src/main/java/software/sham/ssh/MockSshServer.java b/src/main/java/software/sham/ssh/MockSshServer.java index bcb1910..5c755fd 100644 --- a/src/main/java/software/sham/ssh/MockSshServer.java +++ b/src/main/java/software/sham/ssh/MockSshServer.java @@ -27,7 +27,7 @@ public class MockSshServer implements ShellFactory, CommandFactory { public static final String USERNAME = "tester"; public static final String PASSWORD = "testing"; - private final Logger log = LoggerFactory.getLogger(this.getClass()); + protected final Logger log = LoggerFactory.getLogger(this.getClass()); private final Set keys = new HashSet(); private final ResponderDispatcher dispatcher = new ResponderDispatcher(); @@ -120,4 +120,8 @@ public void run() { } }; } + + protected SshServer getSshServer() { + return sshServer; + } } 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/sftp/FunctionalTest.java b/src/test/java/software/sham/sftp/FunctionalTest.java deleted file mode 100644 index 3d83b01..0000000 --- a/src/test/java/software/sham/sftp/FunctionalTest.java +++ /dev/null @@ -1,60 +0,0 @@ -//package software.sham.sftp; -// -//import static org.junit.Assert.assertEquals; -// -//import java.io.BufferedReader; -//import java.io.IOException; -//import java.io.InputStreamReader; -//import java.nio.file.Files; -//import java.util.Collections; -//import java.util.Properties; -// -//import org.junit.After; -//import org.junit.Before; -//import org.junit.Test; -// -//import com.jcraft.jsch.ChannelSftp; -//import com.jcraft.jsch.JSch; -//import com.jcraft.jsch.JSchException; -//import com.jcraft.jsch.Session; -//import com.jcraft.jsch.SftpException; -// -//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.write(server.getBaseDirectory().resolve("example.txt"), -// Collections.singletonList("example file contents")); -// -// ChannelSftp channel = (ChannelSftp) sshSession.openChannel("sftp"); -// channel.connect(); -// -// BufferedReader reader = new BufferedReader(new InputStreamReader(channel.get("example.txt"))); -// final String downloadedContents = reader.readLine(); -// -// 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/MockSshCommandTest.java b/src/test/java/software/sham/ssh/MockSshCommandTest.java index 97c2779..7f3437b 100644 --- a/src/test/java/software/sham/ssh/MockSshCommandTest.java +++ b/src/test/java/software/sham/ssh/MockSshCommandTest.java @@ -12,6 +12,8 @@ import com.sshtools.common.ssh.SshException; +import software.sham.MockSshServerTestSupport; + public class MockSshCommandTest extends MockSshServerTestSupport { @Before diff --git a/src/test/java/software/sham/ssh/MockSshServerConnectionTest.java b/src/test/java/software/sham/ssh/MockSshServerConnectionTest.java index ce0ab1e..bfad8cd 100644 --- a/src/test/java/software/sham/ssh/MockSshServerConnectionTest.java +++ b/src/test/java/software/sham/ssh/MockSshServerConnectionTest.java @@ -29,6 +29,8 @@ 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) diff --git a/src/test/java/software/sham/ssh/MockSshServerTestSupport.java b/src/test/java/software/sham/ssh/MockSshServerTestSupport.java deleted file mode 100644 index 191f8be..0000000 --- a/src/test/java/software/sham/ssh/MockSshServerTestSupport.java +++ /dev/null @@ -1,73 +0,0 @@ -package software.sham.ssh; - -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; - -abstract class MockSshServerTestSupport { - - static final String SSH_SERVER_HOSTNAME = "localhost"; - static final int SSH_SERVER_PORT = 9022; - - static final String SSH_SERVER_USER = MockSshServer.USERNAME; - static final String SSH_SERVER_PASSWORD = MockSshServer.PASSWORD; - static final String SSH_SERVER_USER_KEY_PASSPHRASE = "testing"; - - static final long SSH_SERVER_CONNECT_TIMOUT = 1000L; - // private static final long SSH_SERVER_SHELL_WAIT = 1000L; - - final Logger logger = LoggerFactory.getLogger(getClass()); - - MockSshServer server; - SshClient sshClient; - - interface MockSshServerConfigurer { - void apply(MockSshServer server); - } - - MockSshServerConfigurer serverConfigurer = server -> { - /* do nothing */ }; - - MockSshServerTestSupport(MockSshServerConfigurer serverConfigurer) { - this.serverConfigurer = serverConfigurer; - } - - MockSshServerTestSupport() { - // no specific serverConfigurer - } - - @Before - public void startSshServer() throws IOException { - this.server = new MockSshServer(MockSshServerTestSupport.SSH_SERVER_PORT, false); - 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/ssh/MockSshShellTest.java b/src/test/java/software/sham/ssh/MockSshShellTest.java index 33783ff..7c14320 100644 --- a/src/test/java/software/sham/ssh/MockSshShellTest.java +++ b/src/test/java/software/sham/ssh/MockSshShellTest.java @@ -19,6 +19,8 @@ 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; From 4923d2fca264c3861071f067e85691cb55c2b8af Mon Sep 17 00:00:00 2001 From: Jan Esser Date: Tue, 4 Feb 2025 10:07:38 +0100 Subject: [PATCH 08/12] MockGitServer --- pom.xml | 17 +++- .../java/software/sham/git/MockGitServer.java | 69 ++++++++++++++ .../software/sham/sftp/MockSftpServer.java | 4 +- .../software/sham/git/MockGitServerTest.java | 93 +++++++++++++++++++ .../software/sham/ssh/MockSshShellTest.java | 3 +- 5 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 src/main/java/software/sham/git/MockGitServer.java create mode 100644 src/test/java/software/sham/git/MockGitServerTest.java diff --git a/pom.xml b/pom.xml index e5d0f95..6848ffe 100644 --- a/pom.xml +++ b/pom.xml @@ -51,6 +51,7 @@ UTF-8 UTF-8 8 + 2.14.0 @@ -81,15 +82,20 @@ + + org.apache.sshd + sshd-core + ${sshd.version} + org.apache.sshd sshd-sftp - 2.14.0 + ${sshd.version} org.apache.sshd - sshd-core - 2.14.0 + sshd-git + ${sshd.version} org.hamcrest @@ -154,6 +160,11 @@ sshd-sftp true + + org.apache.sshd + sshd-git + true + org.hamcrest hamcrest-core 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 3c65847..afb099a 100644 --- a/src/main/java/software/sham/sftp/MockSftpServer.java +++ b/src/main/java/software/sham/sftp/MockSftpServer.java @@ -12,6 +12,8 @@ public class MockSftpServer extends MockSshServer { + private static final String MOCK_SFTP_DIRECTORY = "mock_sftp_root"; + private final Path baseDirectory; public MockSftpServer(int port) throws IOException { @@ -20,7 +22,7 @@ public MockSftpServer(int port) throws IOException { SftpSubsystemFactory sftpSubsystemFactory = new SftpSubsystemFactory.Builder().build(); super.getSshServer().setSubsystemFactories(Collections.singletonList(sftpSubsystemFactory)); - this.baseDirectory = Files.createTempDirectory("sftp_root"); + this.baseDirectory = Files.createTempDirectory(MOCK_SFTP_DIRECTORY); log.info("baseDirectory: " + baseDirectory.toAbsolutePath().toString()); super.getSshServer().setFileSystemFactory(new VirtualFileSystemFactory(baseDirectory.toAbsolutePath())); } 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/ssh/MockSshShellTest.java b/src/test/java/software/sham/ssh/MockSshShellTest.java index 7c14320..236bc89 100644 --- a/src/test/java/software/sham/ssh/MockSshShellTest.java +++ b/src/test/java/software/sham/ssh/MockSshShellTest.java @@ -69,7 +69,8 @@ public void delayedOutput() throws IOException { server.respondTo("delayedResponder").withOutput("Starting...\n").withDelay(500L).withOutput("Completed."); runInShell(shell -> { - assertEquals("Starting...\nCompleted.", shell.executeWithOutput("delayedResponder")); + assertEquals("Starting..." + System.lineSeparator() + "Completed.", + shell.executeWithOutput("delayedResponder")); }); } From 57c604f58696c3f48189b131009651539b9de976 Mon Sep 17 00:00:00 2001 From: Jan Esser Date: Tue, 4 Feb 2025 18:03:55 +0100 Subject: [PATCH 09/12] 1.0.0-RC2 --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 8672a5d..202d5c2 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ Mock SSH and SFTP testing library for running an SSH server in process. SFTP uses a local temporary directory. ## Development Notes +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 From eb2f504aa4b80f19252c3bde4ef278280d0bf086 Mon Sep 17 00:00:00 2001 From: Jan Esser Date: Tue, 4 Feb 2025 18:06:24 +0100 Subject: [PATCH 10/12] build badge --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 202d5c2..d2f3924 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ Mock SSH and SFTP testing library for running an SSH server in process. SFTP uses a local temporary directory. ## 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 From fa7b139b6a0d3d922609d86401713ad52574bead Mon Sep 17 00:00:00 2001 From: Jan Esser Date: Mon, 1 Dec 2025 00:14:12 +0100 Subject: [PATCH 11/12] maven plugin version bump --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 6848ffe..0f48ac8 100644 --- a/pom.xml +++ b/pom.xml @@ -58,7 +58,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.3 + 3.13.0 ${jdk.version} ${jdk.version} @@ -68,7 +68,7 @@ org.apache.maven.plugins maven-source-plugin - 2.4 + 3.4.0 attach-sources From 5a0575fa901b8f8a1a08b25faf8da2d4d95cab61 Mon Sep 17 00:00:00 2001 From: Jan Esser Date: Fri, 2 Jan 2026 15:08:06 +0100 Subject: [PATCH 12/12] fix line-breaks --- src/test/java/software/sham/ssh/MockSshShellTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/test/java/software/sham/ssh/MockSshShellTest.java b/src/test/java/software/sham/ssh/MockSshShellTest.java index 236bc89..e8c3ca4 100644 --- a/src/test/java/software/sham/ssh/MockSshShellTest.java +++ b/src/test/java/software/sham/ssh/MockSshShellTest.java @@ -66,10 +66,11 @@ public void differentOutputForDifferentInput() throws IOException { @Test public void delayedOutput() throws IOException { - server.respondTo("delayedResponder").withOutput("Starting...\n").withDelay(500L).withOutput("Completed."); + server.respondTo("delayedResponder").withOutput("Starting..." + System.lineSeparator()).withDelay(500L) + .withOutput("Completed."); runInShell(shell -> { - assertEquals("Starting..." + System.lineSeparator() + "Completed.", + assertEquals("Starting..." + System.lineSeparator() + System.lineSeparator() + "Completed.", shell.executeWithOutput("delayedResponder")); }); }