Skip to content

Commit

Permalink
Merge pull request #1003 from mikecirioli/allow_file_based_private_key
Browse files Browse the repository at this point in the history
[JENKINS-74836] Allow using a file based ssh credential via system property
  • Loading branch information
fcojfernandez authored Nov 11, 2024
2 parents 448ca34 + 5ec3dde commit ff2f5a2
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 26 deletions.
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,25 @@ the main "Manage Jenkins" \> "Configure System" page, and scroll down
near the bottom to the "Cloud" section. There, you click the "Add a new
cloud" button, and select the "Amazon EC2" option. This will display the
UI for configuring the EC2 plugin.  Then enter the Access Key and Secret
Access Key which act like a username/password (see IAM section). Because
of the way EC2 works, you also need to have an RSA private key that the
Access Key which act like a username/password (see IAM section).

Because of the way EC2 works, you also need to have an RSA private key that the
cloud has the other half for, to permit sshing into the instances that
are started. Please use the AWS console or any other tool of your choice
to generate the private key to interactively log in to EC2 instances.
to generate the private key to interactively log in to EC2 instances.

Once you have generated the needed private key you must either store it as
a Jenkins `SSH Private Key` credential (and select that credential in your cloud
config).

If you do not want to create a new Jenkins credential you may alterantively store it
in plain text on disk, indicating its file path via the Jenkins system property
`hudson.plugins.ec2.EC2Cloud.sshPrivateKeyFilePath`. If this system property has a non-empty value then
it will override the ssh credential specified in the cloud configuration page. This
approach works well for `k8s` secrets that are mounted in a jenkins container for example.

Once you have put in your Access Key and Secret Access Key, select a
region for the cloud (not shown in screenshot). You may define only one
Once you have put in your Access Key, Secret Access Key, and configured an ssh private key
select a region for the cloud (not shown in screenshot). You may define only one
cloud for each region, and the regions offered in the UI will show only
the regions that you don't already have clouds defined for them.

Expand Down
101 changes: 81 additions & 20 deletions src/main/java/hudson/plugins/ec2/EC2Cloud.java
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ public abstract class EC2Cloud extends Cloud {

private static final SimpleFormatter sf = new SimpleFormatter();

// if this system property is defined and its value points to a valid ssh private key on disk
// then this will be used instead of any configured ssh credential
public static final String SSH_PRIVATE_KEY_FILEPATH = EC2Cloud.class.getName() + ".sshPrivateKeyFilePath";

private transient ReentrantLock slaveCountingLock = new ReentrantLock();

private final boolean useInstanceProfileForCredentials;
Expand Down Expand Up @@ -195,7 +199,11 @@ protected EC2Cloud(String id, boolean useInstanceProfileForCredentials, String c

@CheckForNull
public EC2PrivateKey resolvePrivateKey(){
if (sshKeysCredentialsId != null) {
if (!System.getProperty(SSH_PRIVATE_KEY_FILEPATH, "").isEmpty()) {
LOGGER.fine(() -> "(resolvePrivateKey) secret key file configured, will load from disk");
return EC2PrivateKey.fetchFromDisk();
} else if (sshKeysCredentialsId != null) {
LOGGER.fine(() -> "(resolvePrivateKey) Using jenkins ssh credential");
SSHUserPrivateKey privateKeyCredential = getSshCredential(sshKeysCredentialsId, Jenkins.get());
if (privateKeyCredential != null) {
return new EC2PrivateKey(privateKeyCredential.getPrivateKey());
Expand Down Expand Up @@ -1122,6 +1130,7 @@ public ListBoxModel doFillSshKeysCredentialsIdItems(@AncestorInPath ItemGroup co
AbstractIdCredentialsListBoxModel result = new StandardListBoxModel();
if (Jenkins.get().hasPermission(Jenkins.ADMINISTER)) {
result = result
.includeEmptyValue()
.includeMatchingAs(Jenkins.getAuthentication(), context, SSHUserPrivateKey.class, Collections.<DomainRequirement>emptyList(), CredentialsMatchers.always())
.includeMatchingAs(ACL.SYSTEM, context, SSHUserPrivateKey.class, Collections.<DomainRequirement>emptyList(), CredentialsMatchers.always())
.includeCurrentValue(sshKeysCredentialsId);
Expand All @@ -1135,18 +1144,35 @@ public FormValidation doCheckSshKeysCredentialsId(@AncestorInPath ItemGroup cont
// Don't do anything if the user is only reading the configuration
return FormValidation.ok();
}
if (value == null || value.isEmpty()){
return FormValidation.error("No ssh credentials selected");
}

SSHUserPrivateKey sshCredential = getSshCredential(value, context);
String privateKey = "";
if (sshCredential != null) {
privateKey = sshCredential.getPrivateKey();
String privateKey;
List<FormValidation> validations = new ArrayList<>();

if (System.getProperty(SSH_PRIVATE_KEY_FILEPATH, "").isEmpty()) {

Check warning on line 1151 in src/main/java/hudson/plugins/ec2/EC2Cloud.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 1151 is only partially covered, one branch is missing
// not using a static ssh key file
if (value == null || value.isEmpty()) {
return FormValidation.error("No ssh credentials selected and no private key file defined");
}

SSHUserPrivateKey sshCredential = getSshCredential(value, context);
if (sshCredential != null) {
privateKey = sshCredential.getPrivateKey();
} else {
return FormValidation.error("Failed to find credential \"" + value + "\" in store.");
}
} else {
return FormValidation.error("Failed to find credential \"" + value + "\" in store.");
EC2PrivateKey k = EC2PrivateKey.fetchFromDisk();
if (k == null) {
validations.add(FormValidation.error("Failed to find private key file " + System.getProperty(SSH_PRIVATE_KEY_FILEPATH)));
if (!StringUtils.isEmpty(value)) {
validations.add(FormValidation.warning("Private key file path defined, selected credential will be ignored"));
}
return FormValidation.aggregate(validations);
}
privateKey = k.getPrivateKey();
}


boolean hasStart = false, hasEnd = false;
BufferedReader br = new BufferedReader(new StringReader(privateKey));
String line;
Expand All @@ -1159,11 +1185,20 @@ public FormValidation doCheckSshKeysCredentialsId(@AncestorInPath ItemGroup cont
hasEnd = true;
}
if (!hasStart)
return FormValidation.error("This doesn't look like a private key at all");
validations.add(FormValidation.error("This doesn't look like a private key at all"));
if (!hasEnd)
return FormValidation
.error("The private key is missing the trailing 'END RSA PRIVATE KEY' marker. Copy&paste error?");
return FormValidation.ok();
validations.add(FormValidation.error("The private key is missing the trailing 'END RSA PRIVATE KEY' marker. Copy&paste error?"));

if (!System.getProperty(SSH_PRIVATE_KEY_FILEPATH, "").isEmpty()) {
if (!StringUtils.isEmpty(value)) {
validations.add(FormValidation.warning("Using private key file instead of selected credential"));
} else {
validations.add(FormValidation.ok("Using private key file"));
}
}

validations.add(FormValidation.ok("SSH key validation successful"));
return FormValidation.aggregate(validations);
}

/**
Expand All @@ -1188,28 +1223,54 @@ protected FormValidation doTestConnection(@AncestorInPath ItemGroup context, URL
return FormValidation.ok();
}
try {
SSHUserPrivateKey sshCredential = getSshCredential(sshKeysCredentialsId, context);
List<FormValidation> validations = new ArrayList<>();

LOGGER.fine(() -> "begin doTestConnection()");
String privateKey = "";
if (sshCredential != null) {
privateKey = sshCredential.getPrivateKey();
if (System.getProperty(SSH_PRIVATE_KEY_FILEPATH, "").isEmpty()) {
LOGGER.fine(() -> "static credential is in use");
SSHUserPrivateKey sshCredential = getSshCredential(sshKeysCredentialsId, context);
if (sshCredential != null) {
privateKey = sshCredential.getPrivateKey();
} else {
return FormValidation.error("Failed to find credential \"" + sshKeysCredentialsId + "\" in store.");
}
} else {
return FormValidation.error("Failed to find credential \"" + sshKeysCredentialsId + "\" in store.");
EC2PrivateKey k = EC2PrivateKey.fetchFromDisk();
if (k == null) {
validations.add(FormValidation.error("Failed to find private key file " + System.getProperty(SSH_PRIVATE_KEY_FILEPATH)));
if (!StringUtils.isEmpty(sshKeysCredentialsId)) {
validations.add(FormValidation.warning("Private key file path defined, selected credential will be ignored"));
}
return FormValidation.aggregate(validations);
}
privateKey = k.getPrivateKey();
}
LOGGER.fine(() -> "private key found ok");

AWSCredentialsProvider credentialsProvider = createCredentialsProvider(useInstanceProfileForCredentials, credentialsId, roleArn, roleSessionName, region);
AmazonEC2 ec2 = AmazonEC2Factory.getInstance().connect(credentialsProvider, ec2endpoint);
ec2.describeInstances();


if (privateKey.trim().length() > 0) {
// check if this key exists
EC2PrivateKey pk = new EC2PrivateKey(privateKey);
if (pk.find(ec2) == null)
return FormValidation
validations.add(FormValidation
.error("The EC2 key pair private key isn't registered to this EC2 region (fingerprint is "
+ pk.getFingerprint() + ")");
+ pk.getFingerprint() + ")"));
}

return FormValidation.ok(Messages.EC2Cloud_Success());
if (!System.getProperty(SSH_PRIVATE_KEY_FILEPATH, "").isEmpty()) {
if (!StringUtils.isEmpty(sshKeysCredentialsId)) {
validations.add(FormValidation.warning("Using private key file instead of selected credential"));
} else {
validations.add(FormValidation.ok("Using private key file"));
}
}
validations.add(FormValidation.ok(Messages.EC2Cloud_Success()));
return FormValidation.aggregate(validations);

Check warning on line 1273 in src/main/java/hudson/plugins/ec2/EC2Cloud.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 1163-1273 are not covered by tests
} catch (AmazonClientException e) {
LOGGER.log(Level.WARNING, "Failed to check EC2 credential", e);
return FormValidation.error(e.getMessage());
Expand Down
29 changes: 29 additions & 0 deletions src/main/java/hudson/plugins/ec2/EC2PrivateKey.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,30 @@
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.UnrecoverableKeyException;
import java.util.Base64;

import com.amazonaws.AmazonClientException;
import com.amazonaws.services.ec2.AmazonEC2;
import com.amazonaws.services.ec2.model.KeyPairInfo;

import edu.umd.cs.findbugs.annotations.CheckForNull;
import hudson.util.Secret;
import jenkins.bouncycastle.api.PEMEncodable;
import javax.crypto.Cipher;
import java.nio.charset.Charset;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.apache.commons.lang.StringUtils;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;

import static hudson.plugins.ec2.EC2Cloud.SSH_PRIVATE_KEY_FILEPATH;

/**
* RSA private key (the one that you generate with ec2-add-keypair.)
*
Expand All @@ -51,6 +59,8 @@
*/
public class EC2PrivateKey {

private static final Logger LOGGER = Logger.getLogger(EC2PrivateKey.class.getName());

private final Secret privateKey;

EC2PrivateKey(String privateKey) {
Expand Down Expand Up @@ -143,6 +153,25 @@ public String decryptWindowsPassword(String encodedPassword) throws AmazonClient
}
}

/* visible for testing */
@CheckForNull
public static EC2PrivateKey fetchFromDisk() {
return fetchFromDisk(System.getProperty(SSH_PRIVATE_KEY_FILEPATH, ""));
}

@CheckForNull
public static EC2PrivateKey fetchFromDisk(String filepath) {
if (StringUtils.isNotEmpty(filepath)) {

Check warning on line 164 in src/main/java/hudson/plugins/ec2/EC2PrivateKey.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 164 is only partially covered, one branch is missing
try {
return new EC2PrivateKey(Files.readString(Paths.get(filepath), StandardCharsets.UTF_8));
} catch (IOException e) {
LOGGER.log(Level.WARNING, "unable to read private key from file " + filepath, e);
return null;
}
}
return null;

Check warning on line 172 in src/main/java/hudson/plugins/ec2/EC2PrivateKey.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 167-172 are not covered by tests
}

@Override
public int hashCode() {
return privateKey.hashCode();
Expand Down
1 change: 1 addition & 0 deletions src/main/java/hudson/plugins/ec2/SlaveTemplate.java
Original file line number Diff line number Diff line change
Expand Up @@ -1656,6 +1656,7 @@ private KeyPair getKeyPair(AmazonEC2 ec2) throws IOException, AmazonClientExcept
if (keyPair == null) {
throw new AmazonClientException("No matching keypair found on EC2. Is the EC2 private key a valid one?");
}
LOGGER.fine("found matching keypair");
return keyPair;
}

Expand Down
1 change: 0 additions & 1 deletion src/test/java/hudson/plugins/ec2/EC2CloudTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@

@RunWith(MockitoJUnitRunner.class)
public class EC2CloudTest {

@Test
public void testSlaveTemplateAddition() throws Exception {
AmazonEC2Cloud cloud = new AmazonEC2Cloud("us-east-1", true,
Expand Down
36 changes: 36 additions & 0 deletions src/test/java/hudson/plugins/ec2/FileBasedSSHKeyTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package hudson.plugins.ec2;

import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.JenkinsRule;
import org.jvnet.hudson.test.RealJenkinsRule;

import java.util.Collections;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;

public class FileBasedSSHKeyTest {
@Rule
public RealJenkinsRule r = new RealJenkinsRule().javaOptions("-D" + EC2Cloud.class.getName() + ".sshPrivateKeyFilePath=" +
getClass().getClassLoader().getResource("hudson/plugins/ec2/test.pem").getPath());

@Test
public void testFileBasedSShKey() throws Throwable {
r.startJenkins();
r.runRemotely(FileBasedSSHKeyTest::verifyKeyFile);
r.runRemotely(FileBasedSSHKeyTest::verifyCorrectKeyIsResolved);
}

private static void verifyKeyFile(JenkinsRule r) throws Throwable {
assertNotNull("file content should not have been empty", EC2PrivateKey.fetchFromDisk());
assertEquals("file content did not match", EC2PrivateKey.fetchFromDisk().getPrivateKey(),"hello, world!");
}

private static void verifyCorrectKeyIsResolved(JenkinsRule r) throws Throwable {
AmazonEC2Cloud cloud = new AmazonEC2Cloud("us-east-1", true, "abc", "us-east-1", null, "ghi", "3", Collections.emptyList(), "roleArn", "roleSessionName");
r.jenkins.clouds.add(cloud);
AmazonEC2Cloud c = r.jenkins.clouds.get(AmazonEC2Cloud.class);
assertEquals("An unexpected key was returned!", c.resolvePrivateKey().getPrivateKey(),"hello, world!");
}
}
1 change: 1 addition & 0 deletions src/test/resources/hudson/plugins/ec2/test.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hello, world!

0 comments on commit ff2f5a2

Please sign in to comment.