Last active
December 11, 2024 14:44
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 + | |
'}'; | |
} | |
} | |
} |
Excellent article (in pt_BR) about using JSch written by Luis Gustavo:
https://luissouza-portfolio.web.app/assets/pdfs/EASY-JAVA_039-PAGE-38-TO-43.pdf#page=38
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
We need to add the below dependencies into the
pom.xml
: