diff --git a/README.md b/README.md index 3aeaff214..a9492c135 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/main/java/hudson/plugins/ec2/EC2Cloud.java b/src/main/java/hudson/plugins/ec2/EC2Cloud.java index 76f8d112c..138a94179 100644 --- a/src/main/java/hudson/plugins/ec2/EC2Cloud.java +++ b/src/main/java/hudson/plugins/ec2/EC2Cloud.java @@ -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; @@ -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()); @@ -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.emptyList(), CredentialsMatchers.always()) .includeMatchingAs(ACL.SYSTEM, context, SSHUserPrivateKey.class, Collections.emptyList(), CredentialsMatchers.always()) .includeCurrentValue(sshKeysCredentialsId); @@ -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 validations = new ArrayList<>(); + + if (System.getProperty(SSH_PRIVATE_KEY_FILEPATH, "").isEmpty()) { + // 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; @@ -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); } /** @@ -1188,28 +1223,54 @@ protected FormValidation doTestConnection(@AncestorInPath ItemGroup context, URL return FormValidation.ok(); } try { - SSHUserPrivateKey sshCredential = getSshCredential(sshKeysCredentialsId, context); + List 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); } catch (AmazonClientException e) { LOGGER.log(Level.WARNING, "Failed to check EC2 credential", e); return FormValidation.error(e.getMessage()); diff --git a/src/main/java/hudson/plugins/ec2/EC2PrivateKey.java b/src/main/java/hudson/plugins/ec2/EC2PrivateKey.java index a15728cac..bc5bba2ea 100644 --- a/src/main/java/hudson/plugins/ec2/EC2PrivateKey.java +++ b/src/main/java/hudson/plugins/ec2/EC2PrivateKey.java @@ -26,6 +26,9 @@ 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; @@ -33,15 +36,20 @@ 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.) * @@ -51,6 +59,8 @@ */ public class EC2PrivateKey { + private static final Logger LOGGER = Logger.getLogger(EC2PrivateKey.class.getName()); + private final Secret privateKey; EC2PrivateKey(String privateKey) { @@ -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)) { + 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; + } + @Override public int hashCode() { return privateKey.hashCode(); diff --git a/src/main/java/hudson/plugins/ec2/SlaveTemplate.java b/src/main/java/hudson/plugins/ec2/SlaveTemplate.java index c2e2767f6..e0a640c4b 100644 --- a/src/main/java/hudson/plugins/ec2/SlaveTemplate.java +++ b/src/main/java/hudson/plugins/ec2/SlaveTemplate.java @@ -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; } diff --git a/src/test/java/hudson/plugins/ec2/EC2CloudTest.java b/src/test/java/hudson/plugins/ec2/EC2CloudTest.java index eb0461a21..5ec6a49ae 100644 --- a/src/test/java/hudson/plugins/ec2/EC2CloudTest.java +++ b/src/test/java/hudson/plugins/ec2/EC2CloudTest.java @@ -26,7 +26,6 @@ @RunWith(MockitoJUnitRunner.class) public class EC2CloudTest { - @Test public void testSlaveTemplateAddition() throws Exception { AmazonEC2Cloud cloud = new AmazonEC2Cloud("us-east-1", true, diff --git a/src/test/java/hudson/plugins/ec2/FileBasedSSHKeyTest.java b/src/test/java/hudson/plugins/ec2/FileBasedSSHKeyTest.java new file mode 100644 index 000000000..37ac265ae --- /dev/null +++ b/src/test/java/hudson/plugins/ec2/FileBasedSSHKeyTest.java @@ -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!"); + } +} diff --git a/src/test/resources/hudson/plugins/ec2/test.pem b/src/test/resources/hudson/plugins/ec2/test.pem new file mode 100644 index 000000000..30f51a3fb --- /dev/null +++ b/src/test/resources/hudson/plugins/ec2/test.pem @@ -0,0 +1 @@ +hello, world! \ No newline at end of file