Skip to content

Instantly share code, notes, and snippets.

@rponte
Last active December 11, 2024 14:44
Show Gist options
  • Save rponte/c61bf871bdbe3345780d0e16fbabe183 to your computer and use it in GitHub Desktop.
Save rponte/c61bf871bdbe3345780d0e16fbabe183 to your computer and use it in GitHub Desktop.
Example of Integration Tests with Java, jUnit5 and Apache Mina to execute remote commands via SSH
package br.com.rponte.sample.ssh;
import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Path;
import java.util.Properties;
public class SshService {
public static final int SSH_PORT = 2222;
private final String host;
private final String user;
private final Path privateKeyPath;
public SshService(String host, String user, Path privateKeyPath) {
this.host = host;
this.user = user;
this.privateKeyPath = privateKeyPath;
}
public String executeCommand(String command) {
Session session = null;
ChannelExec channel = null;
try {
JSch jsch = new JSch();
jsch.addIdentity(privateKeyPath.toAbsolutePath().toString()); // Adds the private key
session = jsch.getSession(user, host, SSH_PORT);
session.setPassword("secret");
// Configuration to prevent authentication errors
Properties config = new Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
session.connect();
channel = (ChannelExec) session.openChannel("exec");
channel.setCommand(command);
InputStream in = channel.getInputStream();
channel.connect();
StringBuilder buffer = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
String line;
while ((line = reader.readLine()) != null) {
buffer.append(line).append("\n");
System.out.println(line); // prints the output
}
}
return buffer.toString();
} catch (Exception e) {
e.printStackTrace();
throw new IllegalStateException(e);
} finally {
if (channel != null) {
channel.disconnect();
}
if (session != null) {
session.disconnect();
}
}
}
}
package br.com.rponte.sample.ssh;
import base.SshKeyGenerator;
import com.jcraft.jsch.JSchException;
import org.apache.sshd.server.SshServer;
import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
import org.apache.sshd.server.shell.ProcessShellCommandFactory;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class SshServiceTest {
private static final Path TEMP_DIR = Paths.get(System.getProperty("java.io.tmpdir"));
private static final int SSH_PORT = 2222; // it must be a higher port
private static final String VALID_USER = "rponte";
private static SshServer SSHD;
private static SshKeyGenerator.TempKeyPair SSH_KEYS;
@BeforeAll
public static void startSshServer() throws IOException {
SSHD = SshServer.setUpDefaultServer();
SSHD.setPort(SSH_PORT);
SSHD.setKeyPairProvider(new SimpleGeneratorHostKeyProvider(TEMP_DIR.resolve("hostkey.ser")));
SSHD.setPasswordAuthenticator((username, password, session) -> VALID_USER.equals(username)); // auth via password
SSHD.setPublickeyAuthenticator((username, key, session) -> VALID_USER.equals(username)); // auth via private key
SSHD.setCommandFactory(new ProcessShellCommandFactory());
SSHD.start();
// creates a temporary pair of public and private keys
SSH_KEYS = new SshKeyGenerator().createTempKeys();
}
@AfterAll
public static void stopSshServer() throws IOException {
if (SSHD != null) {
SSHD.stop();
}
}
@Test
@DisplayName("Should execute a remote command via SSH")
void t1() {
// scenario
Path privatePath = SSH_KEYS.getPrivateKey();
SshService service = new SshService("localhost", "rponte", privatePath);
// action
String result = service.executeCommand("echo 'Hello World!'");
// validation
assertThat(result)
.contains("Hello World!");
}
@Test
@DisplayName("Should not execute a remote command via SSH when credentials are invalid")
void t2() {
// scenario
Path privatePath = SSH_KEYS.getPrivateKey();
SshService service = new SshService("localhost", "invalid-user", privatePath);
// action & validation
assertThatThrownBy(() -> {
service.executeCommand("echo 'This should not work.'");
})
.hasRootCauseInstanceOf(JSchException.class)
.hasMessageContaining("Auth fail");
}
@Test
@DisplayName("Should not execute a remote command via SSH when private key is not found")
void t3() {
// scenario
Path nonExistingPrivateKey = TEMP_DIR.resolve("non-existing-private_key.pem");
SshService service = new SshService("localhost", "rponte", nonExistingPrivateKey);
// action & validation
assertThatThrownBy(() -> {
service.executeCommand("echo 'This should not work.'");
})
.hasRootCauseInstanceOf(FileNotFoundException.class)
.hasMessageContaining("non-existing-private_key.pem");
}
@Test
@DisplayName("Should not execute a remote command via SSH when private key is invalid")
void t4() throws IOException {
// scenario
Path invalidPrivateKey = Files.createTempFile("private_key", ".pem");
SshService service = new SshService("localhost", "rponte", invalidPrivateKey);
// action & validation
assertThatThrownBy(() -> {
service.executeCommand("echo 'This should not work.'");
})
.hasRootCauseInstanceOf(JSchException.class)
.hasMessageContaining("invalid privatekey");
}
}
package base;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.*;
/**
* Class responsible for creating temporary pair of public & private keys. The idea is using it to prevent some guardrails
* in the CI related to store SSH keys in the repository.
*/
public class SshKeyGenerator {
private static final Logger LOGGER = LoggerFactory.getLogger(SshKeyGenerator.class);
/**
* Creates a temporary key pair of public and private keys.
*/
public TempKeyPair createTempKeys() {
try {
// Adds the Bouncy Castle provider
Security.addProvider(new BouncyCastleProvider());
// Generates the key pair
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
KeyPair pair = keyGen.generateKeyPair();
// Writes the public key to a temp file
Path publicKey = writeToTempFile(pair.getPublic(), "public_key");
LOGGER.info("Temporary Public Key created: {}", publicKey);
// Writes the private key to a temp file
Path privateKey = writeToTempFile(pair.getPrivate(), "private_key");
LOGGER.info("Temporary Private Key created: {}", privateKey);
return new TempKeyPair(publicKey, privateKey);
} catch (NoSuchAlgorithmException | IOException e) {
e.printStackTrace();
throw new IllegalStateException(e);
}
}
/**
* Writes a key to a temporary file in PEM format.
*/
private Path writeToTempFile(Key key, String filename) throws IOException {
Path tempKey = Files.createTempFile(
filename, ".pem"
);
try (JcaPEMWriter pemWriter = new JcaPEMWriter(new FileWriter(tempKey.toFile()))) {
pemWriter.writeObject(key);
}
return tempKey;
}
public static class TempKeyPair {
private final Path publicKey;
private final Path privateKey;
public TempKeyPair(Path publicKey, Path privateKey) {
this.publicKey = publicKey;
this.privateKey = privateKey;
}
public Path getPublicKey() {
return publicKey;
}
public Path getPrivateKey() {
return privateKey;
}
@Override
public String toString() {
return "KeyPair{" +
"publicKey=" + publicKey +
", privateKey=" + privateKey +
'}';
}
}
}
@rponte
Copy link
Author

rponte commented Nov 19, 2024

We need to add the below dependencies into the pom.xml:

        <!-- ************* -->
        <!-- Dependencies needed to run a local SSH server and generate pair of public & private keys  -->
        <!-- ************* -->
        <dependency>
            <groupId>org.apache.sshd</groupId>
            <artifactId>sshd-core</artifactId>
            <version>2.7.0</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.bouncycastle</groupId>
            <artifactId>bcpkix-jdk15on</artifactId>
            <version>1.57</version>
            <scope>test</scope>
        </dependency>

@rponte
Copy link
Author

rponte commented Nov 20, 2024

@kalilmvp
Copy link

Awesome gist, i'll run it locally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment