diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..b994e04 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,2 @@ +_extends: .github +tag-template: ssh-agent-$NEXT_MINOR_VERSION diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..03bcfc8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,153 @@ +# Development Ideology + +Truths which we believe to be self-evident (adapted from [TextSecure's](https://github.com/WhisperSystems/TextSecure/blob/master/contributing.md)) + +1. **The answer is not more options.** If you feel compelled to add a preference that's exposed to the user, it's very possible you've made a wrong turn somewhere. +2. **There are no power users.** The idea that some users "understand" concepts better than others has proven to be, for the most part, false. If anything, "power users" are more dangerous than the rest, and we should avoid exposing dangerous functionality to them. +3. **If it's "like PGP," it's wrong.** PGP is our guide for what not to do. +4. **It's an asynchronous world.** We wary of anything that is anti-asynchronous: ACKs, protocol confirmations, or anly protocol-level "advisory" message. +5. **There is no such thing as time**. Protocol ideas that require synchonized clocks are doomed to failure. + +# Code Style Guidelines + +## Resulting from long experience + +* To the largest extent possible, all fields shall be private. Use an IDE to generate the getters and setters. +* If a class has more than one `volatile` member field, it is probable that there are subtle race conditions. Please consider where appropriate encapsulation of the multiple fields into an immutable value object replace the multiple `volatile` member fields with a single `volatile` reference to the value object (or perhaps better yet an `AtomicReference` to allow for `compareAndSet` - if compare-and-set logic is appropriate). +* If it is `Serializable` it shall have a `serialVersionUID` field. Unless code has shipped to users, the initial value of the `serialVersionUID` field shall be `1L`. + +## Indentation + +1. **Use spaces.** Tabs are banned. +2. **Java blocks are 4 spaces.** JavaScript blocks as for Java. **XML nesting is 2 spaces** + +## Field Naming Conventions + +1. "hungarian"-style notation is banned (i.e. instance variable names preceded by an 'm', etc) +2. If the field is `static final` then it shall be named in `ALL_CAPS_WITH_UNDERSCORES`. +3. Start variable names with a lowercase letter and use camelCase rather than under_scores. +4. Spelling and abreviations: If the word is widely used in the JVM runtime, stick with the spelling/abreviation in the JVM runtime, e.g. `color` over `colour`, `sync` over `synch`, `async` over `asynch`, etc. +5. It is acceptable to use `i`, `j`, `k` for loop indices and iterators. If you need more than three, you are likely doing something wrong and as such you shall either use full descriptive names or refactor. +6. It is acceptable to use `e` for the exception in a `try...catch` block. +7. You shall never use `l` (i.e. lower case `L`) as a variable name. + +## Line Length + +To the greatest extent possible, please wrap lines to ensure that they do not exceed 120 characters. + +## Maven POM file layout + +* The `pom.xml` file shall use the sequencing of elements as defined by the `mvn tidy:pom` command (after any indenting fix-up). +* If you are introducing a property to the `pom.xml` the property must be used in at least two distinct places in the model or a comment justifying the use of a property shall be provided. +* If the `` is in the groupId `org.apache.maven.plugins` you shall omit the ``. +* All `` entries shall have an explicit version defined unless inherited from the parent. + +## Java code style + +### Modifiers + +* For fields, the order is: + - public / protected / private + - static + - final + - transient + - volatile +* For methods, the order is: + - public / protected / private + - abstract + - static + - final + - synchronized + - native + - strictfp +* For classes, the order is: + - public / protected / private + - abstract + - static + - final + - strictfp + +### Imports + +* For code in `src/main`: + - `*` imports are banned. + - `static` imports are strongly discouraged. + - `static` `*` imports are discouraged unless code readability is significantly enhanced and the import is restricted to a single class. +* For code in `src/test`: + - `*` imports of anything other than JUnit classes and Hamcrest matchers are banned. + - `static` imports of anything other than JUnit classes and Hamcrest matchers are strongly discouraged. + - `import static org.hamcrest.Matchers.*`, `import static org.junit.Assert.*` are expressly permitted. Any other `static` `*` imports are discouraged unless code readability is significantly enhanced and the import is restricted to a single class. + +### Annotation placement + +* Annotations on classes, interfaces, annotations, enums, methods, fields and local variables shall be on the lines immediately preceding the line where modifier(s) (e.g. `public` / `protected` / `private` / `final`, etc) would be appropriate. +* Annotations on method arguments shall, to the largest extent possible, be on the same line as the method argument (and, if present, before the `final` modifier) + +### Javadoc + +* Each class shall have a Javadoc comment. +* Each field shall have a Javadoc comment. +* Unless the method is `private`, it shall have a Javadoc comment. +* When a method is overriding a method from a super-class / interface, unless the semantics of the method have changed it is sufficient to document the intent of implementing the super-method's contract with: + + ``` + /** + * {@inheritDoc} + */ + @Override + ``` +* Getters and Setters shall have a Javadoc comment. The following is prefered + ``` + /** + * The count of widgets + */ + private int widgetCount; + + /** + * Returns the count of widgets. + * + * @return the count of widgets. + */ + public int getWidgetCount() { + return widgetCount; + } + + /** + * Sets the count of widgets. + * + * @param widgetCount the count of widgets. + */ + public void setWidgetCount(int widgetCount) { + this.widgetCount = widgetCount; + } + ``` +* When adding a new class / interface / etc, it shall have a `@since` doc comment. The version shall be `FIXME` to indicate that the person merging the change should replace the `FIXME` with the next release version number. The fields and methods within a class/interface (but not nested classes) will be assumed to have the `@since` annotation of their class/interface unless a different `@since` annotation is present. + +### IDE Configuration + +* Eclipse, by and large the IDE defaults are acceptable with the following changes: + - Tab policy to `Spaces only` + - Indent statements within `switch` body + - Maximum line width `120` + - Line wrapping, ensure all to `wrap where necessary` + - Organize imports alphabetically, no grouping +* NetBeans, by and large the IDE defaults are acceptable with the following changes: + - Tabs and Indents + + Change Right Margin to `120` + + Indent case statements in switch + - Wrapping + + Change all the `Never` values to `If Long` + + Select the checkbox for Wrap After Assignement Operators +* IntelliJ, by and large the IDE defaults are acceptable with the following changes: + - Wrapping and Braces + + Change `Do not wrap` to `Wrap if long` + + Change `Do not force` to `Always` + - Javadoc + + Disable generating `

` on empty lines + - Imports + + Class count to use import with '*': `9999` + + Names count to use static import with '*': `99999` + + Import Layout + * import all other imports + * blank line + * import static all other imports diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..5b60d43 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1 @@ +buildPlugin(platforms: ['linux']) diff --git a/README.md b/README.md index 3c6104d..f9c6d24 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,99 @@ -Jenkins SSH Agent Plugin -======================== +# SSH Agent Plugin -Read more: [http://wiki.jenkins-ci.org/display/JENKINS/SSH+Agent+Plugin](http://wiki.jenkins-ci.org/display/JENKINS/SSH+Agent+Plugin) +This plugin allows you to provide SSH credentials to builds via a +ssh-agent in Jenkins. -Development -=========== +# Requirements -Start the local Jenkins instance: +Currently all **Windows** nodes (including the master) on which this +plugin will be used must have the [Apache Tomcat Native +libraries](http://tomcat.apache.org/native-doc/) +installed. As of 1.0 should be unnecessary for Unix nodes. As of 1.14 +unnecessary if `ssh-agent` is installed. - mvn hpi:run +# Configuring +First you need to add some SSH Credentials to your instance: -How to install --------------- +Jenkins \| Manage Jenkins \| Manage Credentials -Run +![](docs/images/Screen_Shot_2012-10-26_at_12.25.04.png) - mvn clean package +Note that only Private Key based credentials can be used. -to create the plugin .hpi file. +Then configure your build to use the credentials: +![](docs/images/Screen_Shot_2012-10-26_at_12.26.13.png) -To install: +And then your build will have those credentials available, e.g. -1. copy the resulting ./target/ssh-agent.hpi file to the $JENKINS_HOME/plugins directory. Don't forget to restart Jenkins afterwards. +![](docs/images/Screen_Shot_2012-10-26_at_11.54.21.png) -2. or use the plugin management console (http://example.com:8080/pluginManager/advanced) to upload the hpi file. You have to restart Jenkins in order to find the pluing in the installed plugins list. +From a Pipeline job, use the `sshagent` step. +# Installation Example: MacOSX (10.7.5) -Plugin releases ---------------- +**Irrelevant in 1.14+ when `ssh-agent` is available in the path.** - mvn release:prepare release:perform -B +Prerequisites: +- JDK7. The tomcat native libraries target the java 7 version. +- APR - this seems to be preinstalled in /usr/lib/apr. -License -------- +Note that tomcat itself is not needed. This works fine with winstone +(just running jenkins jar from command line). - (The MIT License) +Download and extract the tomcat native +library:  - Copyright © 2012, CloudBees, Inc., Stephen Connolly. + tar -zxvf tomcat-native-1.1.XX-src.tar.gz - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: +Build the native library: - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. + cd tomcat-native-1.1.XX/jni/native - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. + ./configure --with-apr=/usr/bin/apr-1-config + + make && sudo make install + +Build the java interface: + + cd .. + export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.7.0_17.jdk/Contents/Home + + ant build + + ant jar + +Copy the output jar somewhere suitable for inclusion in your jenkins +CLASSPATH. + +Set environment variables prior to starting jenkins: + + export DYLD_LIBRARY_PATH=/usr/local/apr/lib + export CLASSPATH=/path/to/tomcat-native-1.1.XX.jar + java -jar jenkins.war + +Additionally, you might have to add bouncycastle to your JCE providers. +If you attempt to use the plugin and get an exception like the +following: + + java.lang.IllegalStateException: BouncyCastle must be registered as a JCE provider + +Then you may need to configure the jce provider. One way is to do this +right in the JRE, so if Jenkins is using the same jdk as above, edit + +/Library/Java/JavaVirtualMachines/jdk1.7.0\_17.jdk/Contents/Home/jre/lib/security/java.security, +and add the following line: + + security.provider.11=org.bouncycastle.jce.provider.BouncyCastleProvider + +Then, restart jenkins. + +From there, configure using the instructions above. + +# Version History + +For new versions, see [GitHub releases](https://github.com/jenkinsci/ssh-agent-plugin/releases). + +For old versions, see the [old changelog](docs/old-changelog.md). diff --git a/docs/images/Screen_Shot_2012-10-26_at_11.54.21.png b/docs/images/Screen_Shot_2012-10-26_at_11.54.21.png new file mode 100644 index 0000000..554eab1 Binary files /dev/null and b/docs/images/Screen_Shot_2012-10-26_at_11.54.21.png differ diff --git a/docs/images/Screen_Shot_2012-10-26_at_12.25.04.png b/docs/images/Screen_Shot_2012-10-26_at_12.25.04.png new file mode 100644 index 0000000..0a45e6f Binary files /dev/null and b/docs/images/Screen_Shot_2012-10-26_at_12.25.04.png differ diff --git a/docs/images/Screen_Shot_2012-10-26_at_12.26.13.png b/docs/images/Screen_Shot_2012-10-26_at_12.26.13.png new file mode 100644 index 0000000..40e275e Binary files /dev/null and b/docs/images/Screen_Shot_2012-10-26_at_12.26.13.png differ diff --git a/docs/old-changelog.md b/docs/old-changelog.md new file mode 100644 index 0000000..ffc846d --- /dev/null +++ b/docs/old-changelog.md @@ -0,0 +1,136 @@ +For new versions, see [GitHub releases](https://github.com/jenkinsci/ssh-agent-plugin/releases). + +### Version 1.17 (2018-10-02) + +- Did not properly interact with `withDockerContainer` when run on a + machine with `DISPLAY=:0` set. + +### Version 1.16 (2018-07-30) + +- [Fix security + issue](https://jenkins.io/security/advisory/2018-07-30/#SECURITY-704) + +### Version 1.15 (2017-04-06) + +- [issue@42093](#) Fixed quoting for askpass in + command-line implementation.  + +### Version 1.14 (2017-02-10) + +- [JENKINS-36997](https://issues.jenkins-ci.org/browse/JENKINS-36997) + New default implementation that uses command-line `ssh-agent`. + Should fix various problems with crypto APIs, + `docker.image(…).inside {sshagent(…) {…}}`, etc. +- [JENKINS-38830](https://issues.jenkins-ci.org/browse/JENKINS-38830) + Track credentials used in the wrapper. +- [JENKINS-35563](https://issues.jenkins-ci.org/browse/JENKINS-35563) + Fixes to credentials dropdown. + +### Version 1.13 (2016-03-03) + +- [JENKINS-32120](https://issues.jenkins-ci.org/browse/JENKINS-32120) + Register Bouncy Castle on the remote agent by using Bouncy Castle + API plugin + +Apparently does not work in some versions of Jenkins; see +[JENKINS-36935](https://issues.jenkins-ci.org/browse/JENKINS-36935). + +### Version 1.12 (2016-03-03) + +- **Wrong release**. Release process broken due a network issue. + +### Version 1.11 (2016-03-03) + +- [JENKINS-35463](https://issues.jenkins-ci.org/browse/JENKINS-35463) + First release using + [bouncycastle-api-plugin](https://wiki.jenkins-ci.org/display/JENKINS/Bouncy+Castle+API+Plugin) + +### Version 1.10 (2016-03-03) + +- [JENKINS-27152](https://issues.jenkins-ci.org/browse/JENKINS-27152) / [JENKINS-32624](https://issues.jenkins-ci.org/browse/JENKINS-32624) + Use a standardized temporary directory compatible with Docker + Pipeline. + +### Version 1.9 (2015-12-07) + +Changelog unrecorded. + +### Version 1.8 (2015-08-07) + +- Compatible with + [Workflow](https://wiki.jenkins-ci.org/display/JENKINS/Workflow+Plugin) (issue + [\#28689](https://issues.jenkins-ci.org/browse/JENKINS-28689)) + +### Version 1.7 (2015-06-02) + +- Fixed a socket and thread leak ([issue + \#27555](https://issues.jenkins-ci.org/browse/JENKINS-27555)) + +### Version 1.6 (2015-04-20) + +- SSH agent socket service thread shouldn't keep JVM alive. + +### Version 1.5 (2014-08-11) + +- Add support for multiple credentials +- Add support for parameterized credentials + +### Version 1.4.2 (2014-08-11) + +- Fix for + [JENKINS-20276](https://issues.jenkins-ci.org/browse/JENKINS-20276) +- **WARNING: Due to classpath conflicts, this plugin will not work if + 1.518 \<= Jenkins Version \< 1.533 (i.e. 1.518 broke it, 1.533 fixed + it)** + +### Version 1.4.1 (2013-11-08) + +- Switch from f:select to c:select so that in-place addition of + credentials is supported when the credentials plugin exposes such + support +- **WARNING: Due to classpath conflicts, this plugin will not work if + 1.518 \<= Jenkins Version \< 1.533 (i.e. 1.518 broke it, 1.533 fixed + it)** + +### Version 1.4 (2013-10-08) + +- Minor improvement in exception handling +- Minor improvement in fault reporting +- Update JNR libraries +- **WARNING: Due to classpath conflicts, this plugin will not work if + 1.518 \<= Jenkins Version \< 1.533 (i.e. 1.518 broke it, 1.533 fixed + it)** + +### Version 1.3 (2013-08-09) + +- Set-up SSH Agent before SCM checkout, this way [GIT can use the ssh + agent](https://issues.jenkins-ci.org/browse/JENKINS-12492). + (Contributed by Patric Boos) +- Upgrade to [SSH Credentials + 1.3](https://wiki.jenkins.io/display/JENKINS/SSH+Credentials+Plugin) + +### Version 1.2 (2013-08-07) + +- Upgrade to [Credentials plugin + 1.6](https://wiki.jenkins.io/display/JENKINS/Credentials+Plugin) and + [SSH Credentials plugin + 1.0](https://wiki.jenkins.io/display/JENKINS/SSH+Credentials+Plugin). + This now allows serving multiple private keys from the users home + directory, e.g. \~/.ssh/id\_rsa, \~/.ssh/id\_dsa and + \~/.ssh/identity + +### Version 1.1 (2013-07-04) + +- If BouncyCastleProvider is not registered, try to register it + ourselves anyway... this should make installation and configuration + even easier. + +### Version 1.0 (2012-11-01) + +- Using jnr-unixsocket have been able to remove the requirement on + Apache Tomcat Native for unix nodes. Likely still require the Apache + Tomcat Native for Windows nodes. + +### Version 0.1 (2012-10-26) + +- Initial release  diff --git a/pom.xml b/pom.xml index 452eda4..d225bc2 100644 --- a/pom.xml +++ b/pom.xml @@ -29,15 +29,17 @@ org.jenkins-ci.plugins plugin - 1.466 + 3.24 + ssh-agent - 1.3 + 1.18 hpi SSH Agent Plugin - http://wiki.jenkins-ci.org/display/JENKINS/SSH+Agent+Plugin + This plugin allows you to provide SSH credentials to builds via a ssh-agent in Jenkins + https://github.com/jenkinsci/ssh-agent-plugin The MIT license @@ -53,33 +55,29 @@ - - 2.2.1 - - scm:git:git://github.com/jenkinsci/ssh-agent-plugin.git scm:git:git@github.com:jenkinsci/ssh-agent-plugin.git http://github.com/jenkinsci/ssh-agent-plugin + ssh-agent-1.18 - UTF-8 - UTF-8 - UTF-8 - true + 2.60.3 + 8 + 2.18 repo.jenkins-ci.org - http://repo.jenkins-ci.org/public/ + https://repo.jenkins-ci.org/public/ repo.jenkins-ci.org - http://repo.jenkins-ci.org/public/ + https://repo.jenkins-ci.org/public/ @@ -88,116 +86,130 @@ org.apache.sshd sshd-core - 0.8.0 + 1.0.0 tomcat tomcat-apr 5.5.23 - - org.bouncycastle - bcpkix-jdk15on - 1.47 - - - org.slf4j - slf4j-api - 1.6.6 - - - org.slf4j - slf4j-jdk14 - 1.6.6 - com.cloudbees.util jnr-unixsocket-nodep - 0.1 + 0.3.1 + + org.jenkins-ci.plugins.workflow + workflow-step-api + 2.16 + + org.jenkins-ci.plugins credentials - 1.6 + 2.1.17 org.jenkins-ci.plugins ssh-credentials - 1.3 + 1.14 + + + org.jenkins-ci.plugins + bouncycastle-api + 2.16.3 - junit - junit - 4.10 + org.jenkins-ci.plugins.workflow + workflow-api + 2.27 + test + + + org.jenkins-ci.plugins.workflow + workflow-job + 2.12.2 + test + + + org.jenkins-ci.plugins.workflow + workflow-basic-steps + 2.8 + test + + + org.jenkins-ci.plugins.workflow + workflow-durable-task-step + 2.19 + test + + + org.jenkins-ci.plugins.workflow + workflow-cps + 2.45 + test + + + org.jenkins-ci.plugins.workflow + workflow-support + ${workflow-support-plugin.version} + test + + + org.jenkins-ci.plugins.workflow + workflow-support + ${workflow-support-plugin.version} + tests + test + + + org.jenkins-ci.plugins + docker-workflow + 1.17 test + + + + org.jenkins-ci.plugins + structs + 1.14 + + + org.jenkins-ci + symbol-annotation + 1.14 + + + org.jenkins-ci.plugins + script-security + 1.44 + test + + + org.jenkins-ci.plugins + scm-api + 2.2.7 + test + + + - - - - maven-enforcer-plugin - 1.0.1 - - - enforce-maven - - enforce - - - - - (,2.1.0),(2.1.0,2.2.0),(2.2.0,) - Maven 2.1.0 and 2.2.0 produce incorrect GPG signatures and checksums respectively. - - - - (,3.0),[3.0.4,) - Maven 3.0 through 3.0.3 inclusive do not pass correct settings.xml to Maven Release - Plugin. - - - - - - - - - - - maven-release-plugin - 2.2.2 - org.jenkins-ci.tools maven-hpi-plugin true true + 1.5 - - org.codehaus.mojo - findbugs-maven-plugin - 2.4.0 - - src/findbugs/excludesFilter.xml - ${maven.findbugs.failure.strict} - - - - verify - - check - - - - diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/RemoteAgent.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/RemoteAgent.java index 5d66e55..4408f01 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/RemoteAgent.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/RemoteAgent.java @@ -24,6 +24,9 @@ package com.cloudbees.jenkins.plugins.sshagent; +import hudson.Launcher; +import hudson.model.TaskListener; + import java.io.IOException; /** @@ -43,12 +46,18 @@ public interface RemoteAgent { * @param privateKey the private key. * @param passphrase the passphrase or {@code null}. * @param comment the comment to give to the key. + * @param launcher the launcher for the remote node. + * @param listener for logging. * @throws java.io.IOException if something went wrong. */ - void addIdentity(String privateKey, String passphrase, String comment) throws IOException; + void addIdentity(String privateKey, String passphrase, String comment, Launcher launcher, + TaskListener listener) throws IOException, InterruptedException; /** * Stops the agent. + * + * @param launcher the launcher for the remote node. + * @param listener for logging. */ - void stop(); + void stop(Launcher launcher, TaskListener listener) throws IOException, InterruptedException; } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/RemoteAgentFactory.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/RemoteAgentFactory.java index f9a280d..dc86616 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/RemoteAgentFactory.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/RemoteAgentFactory.java @@ -25,8 +25,11 @@ package com.cloudbees.jenkins.plugins.sshagent; import hudson.ExtensionPoint; +import hudson.FilePath; import hudson.Launcher; +import hudson.Util; import hudson.model.TaskListener; +import javax.annotation.CheckForNull; /** * Extension point for ssh-agent providers. @@ -48,13 +51,25 @@ public abstract class RemoteAgentFactory implements ExtensionPoint { */ public abstract boolean isSupported(Launcher launcher, TaskListener listener); + @Deprecated + public RemoteAgent start(Launcher launcher, TaskListener listener) throws Throwable { + return start(launcher, listener, null); + } + /** * Start a ssh-agent on the specified launcher. * * @param launcher the launcher on which to start a ssh-agent. * @param listener a listener for any diagnostics. + * @param temp a temporary directory to use; null if unspecified * @return the agent. * @throws Throwable if the agent cannot be started. */ - public abstract RemoteAgent start(Launcher launcher, TaskListener listener) throws Throwable; + public /*abstract*/ RemoteAgent start(Launcher launcher, TaskListener listener, @CheckForNull FilePath temp) throws Throwable { + if (Util.isOverridden(RemoteAgentFactory.class, getClass(), "start", Launcher.class, TaskListener.class)) { + return start(launcher, listener); + } else { + throw new AbstractMethodError("you must implement the start method"); + } + } } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/RemoteHelper.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/RemoteHelper.java new file mode 100644 index 0000000..fa7afa8 --- /dev/null +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/RemoteHelper.java @@ -0,0 +1,61 @@ +/* + * The MIT License + * + * Copyright (c) 2016, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.cloudbees.jenkins.plugins.sshagent; + +import javax.annotation.Nonnull; + +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +import hudson.model.TaskListener; +import hudson.remoting.Channel; +import hudson.remoting.VirtualChannel; +import jenkins.bouncycastle.api.InstallBouncyCastleJCAProvider; + +/** + * Helper class for common remote tasks + */ +@Restricted(NoExternalUse.class) +public class RemoteHelper { + + /** + * Registers Bouncy Castle on a remote node logging the result. + * + * @param channel to communicate with the agent + * @param listener to log the messages + */ + public static void registerBouncyCastle(@Nonnull VirtualChannel channel, @Nonnull final TaskListener listener) { + if (channel instanceof Channel) { + try { + InstallBouncyCastleJCAProvider.on((Channel) channel); + listener.getLogger().println("[ssh-agent] Registered BouncyCastle on the remote agent"); + } catch (Exception e) { + e.printStackTrace(listener.error("[ssh-agent] Could not register BouncyCastle on the remote agent.")); + } + } else { + listener.getLogger().println("[ssh-agent] Skipped registering BouncyCastle, not running on a remote agent"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBuildWrapper.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBuildWrapper.java index fa0f2f0..e7ef8d4 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBuildWrapper.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBuildWrapper.java @@ -23,52 +23,123 @@ */ package com.cloudbees.jenkins.plugins.sshagent; -import com.cloudbees.jenkins.plugins.sshcredentials.SSHUser; -import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserListBoxModel; +import com.cloudbees.jenkins.plugins.sshcredentials.SSHAuthenticator; import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; import com.cloudbees.plugins.credentials.CredentialsProvider; import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; +import com.cloudbees.plugins.credentials.common.StandardUsernameListBoxModel; import com.cloudbees.plugins.credentials.domains.DomainRequirement; import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; import hudson.Extension; +import hudson.FilePath; import hudson.Launcher; import hudson.Util; import hudson.model.AbstractBuild; +import hudson.model.AbstractDescribableImpl; import hudson.model.AbstractProject; import hudson.model.BuildListener; -import hudson.model.Hudson; +import hudson.model.Descriptor; import hudson.model.Item; +import hudson.model.Queue; +import hudson.model.queue.Tasks; import hudson.security.ACL; import hudson.tasks.BuildWrapper; import hudson.tasks.BuildWrapperDescriptor; +import hudson.util.IOException2; import hudson.util.ListBoxModel; import hudson.util.Secret; -import org.kohsuke.stapler.DataBoundConstructor; -import org.kohsuke.stapler.Stapler; - import java.io.IOException; +import java.io.ObjectStreamException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import jenkins.model.Jenkins; +import org.apache.commons.lang.StringUtils; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.Stapler; /** * A build wrapper that provides an SSH agent using supplied credentials */ public class SSHAgentBuildWrapper extends BuildWrapper { /** - * The {@link com.cloudbees.jenkins.plugins.sshcredentials.SSHUser#getId()} of the credentials to use. + * The {@link StandardUsernameCredentials#getId()} of the credentials to use. + */ + private transient String user; + + /** + * The {@link StandardUsernameCredentials#getId()}s of the credentials + * to use. + * + * @since 1.5 + */ + private final List credentialIds; + + /** + * When {@code true} then any missing credentials will be ignored. When {@code false} then the build will be failed + * if any of the required credentials cannot be resolved. + * + * @since 1.5 */ - private final String user; + private final boolean ignoreMissing; /** * Constructs a new instance. * * @param user the {@link SSHUserPrivateKey#getId()} of the credentials to use. + * @deprecated use {@link #SSHAgentBuildWrapper(java.util.List,boolean)} */ - @DataBoundConstructor + @Deprecated @SuppressWarnings("unused") // used via stapler public SSHAgentBuildWrapper(String user) { - this.user = user; + this(Collections.singletonList(user), false); + } + + /** + * Constructs a new instance. + * + * @param credentialHolders the {@link com.cloudbees.jenkins.plugins.sshagent.SSHAgentBuildWrapper.CredentialHolder}s of the credentials to use. + * @param ignoreMissing {@code true} missing credentials will not cause a build failure. + * @since 1.5 + */ + @DataBoundConstructor + @SuppressWarnings("unused") // used via stapler + public SSHAgentBuildWrapper(CredentialHolder[] credentialHolders, boolean ignoreMissing) { + this(CredentialHolder.toIdList(credentialHolders), ignoreMissing); + } + + /** + * Constructs a new instance. + * + * @param credentialIds the {@link com.cloudbees.plugins.credentials.common.StandardUsernameCredentials#getId()}s + * of the credentials to use. + * @param ignoreMissing {@code true} missing credentials will not cause a build failure. + * @since 1.5 + */ + @SuppressWarnings("unused") // used via stapler + public SSHAgentBuildWrapper(List credentialIds, boolean ignoreMissing) { + this.credentialIds = new ArrayList(new LinkedHashSet(credentialIds)); + this.ignoreMissing = ignoreMissing; + } + + /** + * Migrate legacy data format. + * + * @since 1.5 + */ + private Object readResolve() throws ObjectStreamException { + if (user != null) { + return new SSHAgentBuildWrapper(Collections.singletonList(user),false); + } + return this; } /** @@ -77,16 +148,90 @@ public SSHAgentBuildWrapper(String user) { * @return the {@link SSHUserPrivateKey#getId()} of the credentials to use. */ @SuppressWarnings("unused") // used via stapler + @Deprecated public String getUser() { - return user; + return credentialIds.isEmpty() ? null : credentialIds.get(0); + } + + /** + * Gets the {@link com.cloudbees.plugins.credentials.common.StandardUsernameCredentials#getId()}s of the + * credentials to use. + * + * @return the {@link com.cloudbees.plugins.credentials.common.StandardUsernameCredentials#getId()}s of the + * credentials to use. + * @since 1.5 + */ + public List getCredentialIds() { + return Collections.unmodifiableList(credentialIds); + } + + /** + * When {@code true} then any missing credentials will be ignored. When {@code false} then the build will be failed + * if any of the required credentials cannot be resolved. + * @return {@code true} missing credentials will not cause a build failure. + */ + @SuppressWarnings("unused") // used via stapler + public boolean isIgnoreMissing() { + return ignoreMissing; + } + + /** + * Returns the value objects used to hold the credential ids. + * + * @return the value objects used to hold the credential ids. + * @since 1.5 + */ + @SuppressWarnings("unused") // used via stapler + public CredentialHolder[] getCredentialHolders() { + List result = new ArrayList(credentialIds.size()); + for (String id : credentialIds) { + result.add(new CredentialHolder(id)); + } + return result.toArray(new CredentialHolder[result.size()]); } /** * {@inheritDoc} */ @Override - public void preCheckout(AbstractBuild build, Launcher launcher, BuildListener listener) throws IOException, InterruptedException { - build.getEnvironments().add(createSSHAgentEnvironment(build, launcher, listener)); + public void preCheckout(AbstractBuild build, Launcher launcher, BuildListener listener) + throws IOException, InterruptedException { + // first collect all the keys (this is so we can bomb out before starting an agent + List keys = new ArrayList(); + for (String id : new LinkedHashSet(getCredentialIds())) { + final SSHUserPrivateKey c = CredentialsProvider.findCredentialById( + id, + SSHUserPrivateKey.class, + build + ); + CredentialsProvider.track(build, c); + if (c == null && !ignoreMissing) { + IOException ioe = new IOException(Messages.SSHAgentBuildWrapper_CredentialsNotFound()); + ioe.printStackTrace(listener.fatalError("")); + throw ioe; + } + if (c != null && !keys.contains(c)) { + keys.add(c); + } + } + + SSHAgentEnvironment environment = null; + for (hudson.model.Environment env: build.getEnvironments()) { + if (env instanceof SSHAgentEnvironment) { + environment = (SSHAgentEnvironment) env; + // strictly speaking we should break here, but we continue in case there are multiples + // the last one wins, so we want the last one + } + } + if (environment == null) { + // none so let's add one + environment = createSSHAgentEnvironment(build, launcher, listener); + build.getEnvironments().add(environment); + } + for (SSHUserPrivateKey key : keys) { + environment.add(key); + listener.getLogger().println(Messages.SSHAgentBuildWrapper_UsingCredentials(description(key))); + } } /** @@ -97,40 +242,31 @@ public Environment setUp(AbstractBuild build, final Launcher launcher, BuildList throws IOException, InterruptedException { // Jenkins needs this: // null would stop the build, and super implementation throws UnsupportedOperationException - return new Environment() { - }; + return new NoOpEnvironment(); } - private Environment createSSHAgentEnvironment(AbstractBuild build, Launcher launcher, BuildListener listener) { - SSHUserPrivateKey userPrivateKey = null; - for (SSHUserPrivateKey u : CredentialsProvider - .lookupCredentials(SSHUserPrivateKey.class, build.getProject(), ACL.SYSTEM, null)) { - if (user.equals(u.getId())) { - userPrivateKey = u; - break; - } - } - if (userPrivateKey == null) { - listener.fatalError(Messages.SSHAgentBuildWrapper_CredentialsNotFound()); - return null; - } - listener.getLogger().println(Messages.SSHAgentBuildWrapper_UsingCredentials(description(userPrivateKey))); + private SSHAgentEnvironment createSSHAgentEnvironment(AbstractBuild build, Launcher launcher, BuildListener listener) + throws IOException, InterruptedException { try { - return new SSHAgentEnvironment(launcher, listener, userPrivateKey); - } catch (Throwable e) { + return new SSHAgentEnvironment(launcher, listener, build.getWorkspace()); + } catch (IOException e) { + throw new IOException2(Messages.SSHAgentBuildWrapper_CouldNotStartAgent(), e); + } catch (InterruptedException e) { e.printStackTrace(listener.fatalError(Messages.SSHAgentBuildWrapper_CouldNotStartAgent())); - return null; + throw e; + } catch (Throwable e) { + throw new IOException2(Messages.SSHAgentBuildWrapper_CouldNotStartAgent(), e); } } /** - * Helper method that returns a safe description of a {@link SSHUser}. + * Helper method that returns a safe description of a {@link StandardUsernameCredentials}. * * @param c the credentials. * @return the description. */ - @NonNull - private static String description(@NonNull StandardUsernameCredentials c) { + @Nonnull + public static String description(@Nonnull StandardUsernameCredentials c) { String description = Util.fixEmptyAndTrim(c.getDescription()); return c.getUsername() + (description != null ? " (" + description + ")" : ""); } @@ -157,20 +293,6 @@ public String getDisplayName() { return Messages.SSHAgentBuildWrapper_DisplayName(); } - /** - * Populate the list of credentials available to the job. - * - * @return the list box model. - */ - @SuppressWarnings("unused") // used by stapler - public ListBoxModel doFillUserItems() { - Item item = Stapler.getCurrentRequest().findAncestorObject(Item.class); - return new SSHUserListBoxModel().withAll( - CredentialsProvider.lookupCredentials(SSHUserPrivateKey.class, item, ACL.SYSTEM, - Collections.emptyList()) - ); - } - } /** @@ -183,6 +305,10 @@ private class SSHAgentEnvironment extends Environment { */ private final RemoteAgent agent; + private final Launcher launcher; + + private final BuildListener listener; + /** * Construct the environment and initialize on the remote node. * @@ -190,17 +316,57 @@ private class SSHAgentEnvironment extends Environment { * @param listener the listener for reporting progress. * @param sshUserPrivateKey the private key to add to the agent. * @throws Throwable if things go wrong. + * @deprecated use {@link #SSHAgentEnvironment(hudson.Launcher, hudson.model.BuildListener, java.util.List)} */ + @Deprecated public SSHAgentEnvironment(Launcher launcher, final BuildListener listener, final SSHUserPrivateKey sshUserPrivateKey) throws Throwable { + this(launcher, listener, Collections.singletonList(sshUserPrivateKey)); + } + + /** + * Construct the environment and initialize on the remote node. + * + * @param launcher the launcher for the remote node. + * @param listener the listener for reporting progress. + * @param sshUserPrivateKeys the private keys to add to the agent. + * @throws Throwable if things go wrong. + * @since 1.5 + * @deprecated use {@link #SSHAgentEnvironment(Launcher, BuildListener)} and {@link #add(SSHUserPrivateKey)}. + */ + @Deprecated + public SSHAgentEnvironment(Launcher launcher, final BuildListener listener, + final List sshUserPrivateKeys) throws Throwable { + this(launcher, listener); + for (SSHUserPrivateKey sshUserPrivateKey : sshUserPrivateKeys) { + add(sshUserPrivateKey); + } + } + + @Deprecated + public SSHAgentEnvironment(Launcher launcher, final BuildListener listener) throws Throwable { + this(launcher, listener, (FilePath) null); + } + + /** + * Construct the environment and initialize on the remote node. + * + * @param launcher the launcher for the remote node. + * @param listener the listener for reporting progress. + * @throws Throwable if things go wrong. + * @since 1.9 + */ + public SSHAgentEnvironment(Launcher launcher, BuildListener listener, @CheckForNull FilePath workspace) throws Throwable { RemoteAgent agent = null; + this.launcher = launcher; + this.listener = listener; listener.getLogger().println("[ssh-agent] Looking for ssh-agent implementation..."); Map faults = new LinkedHashMap(); - for (RemoteAgentFactory factory : Hudson.getInstance().getExtensionList(RemoteAgentFactory.class)) { + for (RemoteAgentFactory factory : Jenkins.getActiveInstance().getExtensionList(RemoteAgentFactory.class)) { if (factory.isSupported(launcher, listener)) { try { listener.getLogger().println("[ssh-agent] " + factory.getDisplayName()); - agent = factory.start(launcher, listener); + agent = factory.start(launcher, listener, workspace != null ? SSHAgentStepExecution.tempDir(workspace) : null); break; } catch (Throwable t) { faults.put(factory.getDisplayName(), t); @@ -212,17 +378,31 @@ public SSHAgentEnvironment(Launcher launcher, final BuildListener listener, listener.getLogger().println("[ssh-agent] Diagnostic report"); for (Map.Entry fault : faults.entrySet()) { listener.getLogger().println("[ssh-agent] * " + fault.getKey()); - fault.getValue().printStackTrace(listener.getLogger()); + StringWriter sw = new StringWriter(); + fault.getValue().printStackTrace(new PrintWriter(sw)); + for (String line : StringUtils.split(sw.toString(), "\n")) { + listener.getLogger().println("[ssh-agent] " + line); + } } throw new RuntimeException("[ssh-agent] Could not find a suitable ssh-agent provider."); } this.agent = agent; - final Secret passphrase = sshUserPrivateKey.getPassphrase(); + listener.getLogger().println(Messages.SSHAgentBuildWrapper_Started()); + } + + /** + * Adds a key to the agent. + * + * @param key the key. + * @throws IOException if the key cannot be added. + * @since 1.9 + */ + public void add(SSHUserPrivateKey key) throws IOException, InterruptedException { + final Secret passphrase = key.getPassphrase(); final String effectivePassphrase = passphrase == null ? null : passphrase.getPlainText(); - for (String privateKey : sshUserPrivateKey.getPrivateKeys()) { - agent.addIdentity(privateKey, effectivePassphrase, description(sshUserPrivateKey)); + for (String privateKey : key.getPrivateKeys()) { + agent.addIdentity(privateKey, effectivePassphrase, description(key), launcher, listener); } - listener.getLogger().println(Messages.SSHAgentBuildWrapper_Started()); } /** @@ -240,10 +420,96 @@ public void buildEnvVars(Map env) { public boolean tearDown(AbstractBuild build, BuildListener listener) throws IOException, InterruptedException { if (agent != null) { - agent.stop(); + agent.stop(launcher, listener); listener.getLogger().println(Messages.SSHAgentBuildWrapper_Stopped()); } return true; } } + + /** + * A value object to make it possible to pass back multiple credentials via the UI. + * + * @since 1.5 + */ + public static class CredentialHolder extends AbstractDescribableImpl { + + /** + * The id. + */ + private final String id; + + /** + * Stapler's constructor. + * + * @param id the ID. + */ + @DataBoundConstructor + public CredentialHolder(String id) { + this.id = id; + } + + /** + * Gets the id. + * + * @return the id. + */ + public String getId() { + return id; + } + + /** + * Converts an array of value objects into a list of ids. + * + * @param credentialHolders the array of value objects. + * @return the possibly empty but never null list of ids. + */ + @NonNull + public static List toIdList(@Nullable CredentialHolder[] credentialHolders) { + List result = new ArrayList(credentialHolders == null ? 0 : credentialHolders.length); + if (credentialHolders != null) { + for (CredentialHolder h : credentialHolders) { + result.add(h.getId()); + } + } + return result; + } + + /** + * Our descriptor. + */ + @Extension + public static class DescriptorImpl extends Descriptor { + + /** + * {@inheritDoc} + */ + @Override + public String getDisplayName() { + return Messages.SSHAgentBuildWrapper_CredentialHolder_DisplayName(); + } + + /** + * Populate the list of credentials available to the job. + * + * @return the list box model. + */ + @SuppressWarnings("unused") // used by stapler + public ListBoxModel doFillIdItems() { + Item item = Stapler.getCurrentRequest().findAncestorObject(Item.class); + return new StandardUsernameListBoxModel() + .includeMatchingAs( + item instanceof Queue.Task ? Tasks.getAuthenticationOf((Queue.Task) item) : ACL.SYSTEM, + item, + SSHUserPrivateKey.class, + Collections.emptyList(), + SSHAuthenticator.matcher() + ); + } + + } + } + + private class NoOpEnvironment extends Environment { + } } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStep.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStep.java new file mode 100644 index 0000000..aa3fa99 --- /dev/null +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStep.java @@ -0,0 +1,106 @@ +package com.cloudbees.jenkins.plugins.sshagent; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHAuthenticator; +import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.common.StandardUsernameListBoxModel; +import com.cloudbees.plugins.credentials.domains.DomainRequirement; +import hudson.Extension; +import hudson.model.Item; +import hudson.model.Queue; +import hudson.model.queue.Tasks; +import hudson.security.ACL; +import hudson.util.ListBoxModel; +import org.jenkinsci.plugins.workflow.steps.AbstractStepDescriptorImpl; +import org.jenkinsci.plugins.workflow.steps.AbstractStepImpl; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.Stapler; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; + +public class SSHAgentStep extends AbstractStepImpl implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * The {@link com.cloudbees.plugins.credentials.common.StandardUsernameCredentials#getId()}s of the credentials + * to use. + */ + private final List credentials; + + /** + * If a credentials is missed, the SSH Agent is launched anyway. + * By the fault is false. Initialized in the constructor. + */ + private boolean ignoreMissing; + + /** + * Default parameterized constructor. + * + * @param credentials + */ + @DataBoundConstructor + public SSHAgentStep(final List credentials) { + this.credentials = credentials; + this.ignoreMissing = false; + } + + @Extension + public static final class DescriptorImpl extends AbstractStepDescriptorImpl { + + public DescriptorImpl() { + super(SSHAgentStepExecution.class); + } + + @Override + public String getFunctionName() { + return "sshagent"; + } + + @Override + public String getDisplayName() { + return Messages.SSHAgentBuildWrapper_DisplayName(); + } + + @Override + public boolean takesImplicitBlockArgument() { + return true; + } + + /** + * Populate the list of credentials available to the job. + * + * @return the list box model. + */ + @SuppressWarnings("unused") // used by stapler + public ListBoxModel doFillCredentialsItems() { + Item item = Stapler.getCurrentRequest().findAncestorObject(Item.class); + return new StandardUsernameListBoxModel() + .includeMatchingAs( + item instanceof Queue.Task ? Tasks.getAuthenticationOf((Queue.Task)item) : ACL.SYSTEM, + item, + SSHUserPrivateKey.class, + Collections.emptyList(), + SSHAuthenticator.matcher() + ); + } + + } + + @DataBoundSetter + public void setIgnoreMissing(final boolean ignoreMissing) { + this.ignoreMissing = ignoreMissing; + } + + public boolean isIgnoreMissing() { + return ignoreMissing; + } + + public List getCredentials() { + return credentials; + } + +} diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStepExecution.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStepExecution.java new file mode 100644 index 0000000..55c3dea --- /dev/null +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStepExecution.java @@ -0,0 +1,232 @@ +package com.cloudbees.jenkins.plugins.sshagent; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.google.inject.Inject; +import hudson.EnvVars; +import hudson.FilePath; +import hudson.Launcher; +import hudson.model.Run; +import hudson.model.TaskListener; +import hudson.slaves.WorkspaceList; +import hudson.util.Secret; +import jenkins.model.Jenkins; +import org.apache.commons.lang.StringUtils; +import org.jenkinsci.plugins.workflow.steps.*; + +import javax.annotation.CheckReturnValue; +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.*; + +public class SSHAgentStepExecution extends AbstractStepExecutionImpl { + + private static final long serialVersionUID = 1L; + + @StepContextParameter + private transient TaskListener listener; + + @StepContextParameter + private transient Run build; + + @StepContextParameter + private transient Launcher launcher; + + @StepContextParameter + private transient FilePath workspace; + + @Inject(optional = true) + private SSHAgentStep step; + + /** + * Value for SSH_AUTH_SOCK environment variable. + */ + private String socket; + + /** + * Listing of socket files created. Will be used by {@link #purgeSockets()} and {@link #initRemoteAgent()} + */ + private List sockets; + + /** + * The proxy for the real remote agent that is on the other side of the channel (as the agent needs to + * run on a remote machine) + */ + private transient RemoteAgent agent = null; + + @Override + public boolean start() throws Exception { + StepContext context = getContext(); + sockets = new ArrayList(); + initRemoteAgent(); + context.newBodyInvoker(). + withContext(EnvironmentExpander.merge(getContext().get(EnvironmentExpander.class), new ExpanderImpl(this))). + withCallback(new Callback(this)).start(); + return false; + } + + @Override + public void stop(Throwable cause) throws Exception { + if (agent != null) { + agent.stop(getContext().get(Launcher.class), listener); + listener.getLogger().println(Messages.SSHAgentBuildWrapper_Stopped()); + } + purgeSockets(); + } + + @Override + public void onResume() { + super.onResume(); + try { + purgeSockets(); + initRemoteAgent(); + } catch (IOException | InterruptedException x) { + listener.getLogger().println(Messages.SSHAgentBuildWrapper_CouldNotStartAgent()); + x.printStackTrace(listener.getLogger()); + } + } + + static FilePath tempDir(FilePath ws) { + return WorkspaceList.tempDir(ws); + } + + private static class Callback extends BodyExecutionCallback.TailCall { + + private static final long serialVersionUID = 1L; + + private final SSHAgentStepExecution execution; + + Callback (SSHAgentStepExecution execution) { + this.execution = execution; + } + + @Override + protected void finished(StepContext context) throws Exception { + execution.cleanUp(); + } + + } + + private static final class ExpanderImpl extends EnvironmentExpander { + + private static final long serialVersionUID = 1L; + + private final SSHAgentStepExecution execution; + + ExpanderImpl(SSHAgentStepExecution execution) { + this.execution = execution; + } + + @Override + public void expand(EnvVars env) throws IOException, InterruptedException { + env.override("SSH_AUTH_SOCK", execution.getSocket()); + } + } + + /** + * Initializes a SSH Agent. + * + * @throws IOException + */ + private void initRemoteAgent() throws IOException, InterruptedException { + + List userPrivateKeys = new ArrayList(); + for (String id : new LinkedHashSet(step.getCredentials())) { + final SSHUserPrivateKey c = CredentialsProvider.findCredentialById(id, SSHUserPrivateKey.class, build); + CredentialsProvider.track(build, c); + if (c == null && !step.isIgnoreMissing()) { + listener.fatalError(Messages.SSHAgentBuildWrapper_CredentialsNotFound()); + } + if (c != null && !userPrivateKeys.contains(c)) { + userPrivateKeys.add(c); + } + } + for (SSHUserPrivateKey userPrivateKey : userPrivateKeys) { + listener.getLogger().println(Messages.SSHAgentBuildWrapper_UsingCredentials(SSHAgentBuildWrapper.description(userPrivateKey))); + } + + listener.getLogger().println("[ssh-agent] Looking for ssh-agent implementation..."); + Map faults = new LinkedHashMap(); + for (RemoteAgentFactory factory : Jenkins.getActiveInstance().getExtensionList(RemoteAgentFactory.class)) { + if (factory.isSupported(launcher, listener)) { + try { + listener.getLogger().println("[ssh-agent] " + factory.getDisplayName()); + agent = factory.start(launcher, listener, tempDir(workspace)); + break; + } catch (Throwable t) { + faults.put(factory.getDisplayName(), t); + } + } + } + if (agent == null) { + listener.getLogger().println("[ssh-agent] FATAL: Could not find a suitable ssh-agent provider"); + listener.getLogger().println("[ssh-agent] Diagnostic report"); + for (Map.Entry fault : faults.entrySet()) { + listener.getLogger().println("[ssh-agent] * " + fault.getKey()); + StringWriter sw = new StringWriter(); + fault.getValue().printStackTrace(new PrintWriter(sw)); + for (String line : StringUtils.split(sw.toString(), "\n")) { + listener.getLogger().println("[ssh-agent] " + line); + } + } + throw new RuntimeException("[ssh-agent] Could not find a suitable ssh-agent provider."); + } + + for (SSHUserPrivateKey userPrivateKey : userPrivateKeys) { + final Secret passphrase = userPrivateKey.getPassphrase(); + final String effectivePassphrase = passphrase == null ? null : passphrase.getPlainText(); + for (String privateKey : userPrivateKey.getPrivateKeys()) { + agent.addIdentity(privateKey, effectivePassphrase, SSHAgentBuildWrapper.description(userPrivateKey), + launcher, listener); + } + } + + listener.getLogger().println(Messages.SSHAgentBuildWrapper_Started()); + socket = agent.getSocket(); + sockets.add(socket); + } + + /** + * Shuts down the current SSH Agent and purges socket files. + */ + private void cleanUp() throws Exception { + try { + TaskListener listener = getContext().get(TaskListener.class); + if (agent != null) { + agent.stop(getContext().get(Launcher.class), listener); + listener.getLogger().println(Messages.SSHAgentBuildWrapper_Stopped()); + } + } finally { + purgeSockets(); + } + } + + /** + * Purges all socket files created previously. + * Especially useful when Jenkins is restarted during the execution of this step. + */ + private void purgeSockets() { + Iterator it = sockets.iterator(); + while (it.hasNext()) { + File socket = new File(it.next()); + if (socket.exists()) { + if (!socket.delete()) { + listener.getLogger().format("It was a problem removing this socket file %s", socket.getAbsolutePath()); + } + } + it.remove(); + } + } + + /** + * Returns the socket. + * + * @return The value that SSH_AUTH_SOCK should be set to. + */ + @CheckReturnValue private String getSocket() { + return socket; + } + +} diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/exec/ExecRemoteAgent.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/exec/ExecRemoteAgent.java new file mode 100644 index 0000000..51a82e1 --- /dev/null +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/exec/ExecRemoteAgent.java @@ -0,0 +1,168 @@ +/* + * The MIT License + * + * Copyright (c) 2014, Eccam s.r.o., Milan Kriz, CloudBees Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.cloudbees.jenkins.plugins.sshagent.exec; + +import com.cloudbees.jenkins.plugins.sshagent.RemoteAgent; +import hudson.AbortException; +import hudson.FilePath; +import hudson.Launcher; +import hudson.model.TaskListener; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + + +/** + * An implementation that uses native SSH agent installed on a system. + */ +public class ExecRemoteAgent implements RemoteAgent { + private static final String AuthSocketVar = "SSH_AUTH_SOCK"; + private static final String AgentPidVar = "SSH_AGENT_PID"; + + private final FilePath temp; + + /** + * The socket bound by the agent. + */ + private final String socket; + + /** Agent environment used for {@code ssh-add} and {@code ssh-agent -k}. */ + private final Map agentEnv; + + ExecRemoteAgent(Launcher launcher, TaskListener listener, FilePath temp) throws Exception { + this.temp = temp; + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + if (launcher.launch().cmds("ssh-agent").stdout(baos).start().joinWithTimeout(1, TimeUnit.MINUTES, listener) != 0) { + throw new AbortException("Failed to run ssh-agent"); + } + agentEnv = parseAgentEnv(new String(baos.toByteArray(), StandardCharsets.US_ASCII), listener); // TODO could include local filenames, better to look up remote charset + + if (agentEnv.containsKey(AuthSocketVar)) { + socket = agentEnv.get(AuthSocketVar); + } else { + throw new AbortException(AuthSocketVar + " was not included"); + } + } + + /** + * {@inheritDoc} + */ + @Override + public String getSocket() { + return socket; + } + + /** + * {@inheritDoc} + */ + @Override + public void addIdentity(String privateKey, final String passphrase, String comment, Launcher launcher, + TaskListener listener) throws IOException, InterruptedException { + FilePath keyFile = temp.createTextTempFile("private_key_", ".key", privateKey); + try { + keyFile.chmod(0600); + + FilePath askpass = passphrase != null ? createAskpassScript() : null; + try { + + Map env = new HashMap<>(agentEnv); + if (passphrase != null) { + env.put("SSH_PASSPHRASE", passphrase); + env.put("DISPLAY", "bogus"); // just to force using SSH_ASKPASS + env.put("SSH_ASKPASS", askpass.getRemote()); + } + + // as the next command is in quiet mode, we just add a message to the log + listener.getLogger().println("Running ssh-add (command line suppressed)"); + + if (launcher.launch().quiet(true).cmds("ssh-add", keyFile.getRemote()).envs(env).stdout(listener).start().joinWithTimeout(1, TimeUnit.MINUTES, listener) != 0) { + throw new AbortException("Failed to run ssh-add"); + } + } finally { + if (askpass != null && askpass.exists()) { // the ASKPASS script is self-deleting, anyway rather try to delete it in case of some error + askpass.delete(); + } + } + } finally { + keyFile.delete(); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void stop(Launcher launcher, TaskListener listener) throws IOException, InterruptedException { + if (launcher.launch().cmds("ssh-agent", "-k").envs(agentEnv).stdout(listener).start().joinWithTimeout(1, TimeUnit.MINUTES, listener) != 0) { + throw new AbortException("Failed to run ssh-agent -k"); + } + } + + /** + * Parses ssh-agent output. + */ + private Map parseAgentEnv(String agentOutput, TaskListener listener) throws Exception{ + Map env = new HashMap<>(); + + // get SSH_AUTH_SOCK + env.put(AuthSocketVar, getAgentValue(agentOutput, AuthSocketVar)); + listener.getLogger().println(AuthSocketVar + "=" + env.get(AuthSocketVar)); + + // get SSH_AGENT_PID + env.put(AgentPidVar, getAgentValue(agentOutput, AgentPidVar)); + listener.getLogger().println(AgentPidVar + "=" + env.get(AgentPidVar)); + + return env; + } + + /** + * Parses a value from ssh-agent output. + */ + private String getAgentValue(String agentOutput, String envVar) { + int pos = agentOutput.indexOf(envVar) + envVar.length() + 1; // +1 for '=' + int end = agentOutput.indexOf(';', pos); + return agentOutput.substring(pos, end); + } + + /** + * Creates a self-deleting script for SSH_ASKPASS. Self-deleting to be able to detect a wrong passphrase. + */ + private FilePath createAskpassScript() throws IOException, InterruptedException { + // TODO: assuming that ssh-add runs the script in shell even on Windows, not cmd + // for cmd following could work + // suffix = ".bat"; + // script = "@ECHO %SSH_PASSPHRASE%\nDEL \"" + askpass.getAbsolutePath() + "\"\n"; + + FilePath askpass = temp.createTextTempFile("askpass_", ".sh", "#!/bin/sh\necho \"$SSH_PASSPHRASE\"\nrm \"$0\"\n"); + + // executable only for a current user + askpass.chmod(0700); + return askpass; + } +} diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/exec/ExecRemoteAgentFactory.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/exec/ExecRemoteAgentFactory.java new file mode 100644 index 0000000..7c0259f --- /dev/null +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/exec/ExecRemoteAgentFactory.java @@ -0,0 +1,84 @@ +/* + * The MIT License + * + * Copyright (c) 2014, Eccam s.r.o., Milan Kriz + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.cloudbees.jenkins.plugins.sshagent.exec; + +import com.cloudbees.jenkins.plugins.sshagent.RemoteAgent; +import com.cloudbees.jenkins.plugins.sshagent.RemoteAgentFactory; +import hudson.Extension; +import hudson.FilePath; +import hudson.Launcher; +import hudson.model.TaskListener; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + + + +/** + * A factory that uses the native SSH agent installed on a remote system. SSH agent has to be in PATH environment variable. + */ +@Extension +public class ExecRemoteAgentFactory extends RemoteAgentFactory { + + /** + * {@inheritDoc} + */ + @Override + public String getDisplayName() { + return "Exec ssh-agent (binary ssh-agent on a remote machine)"; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isSupported(Launcher launcher, final TaskListener listener) { + try { + int status = launcher.launch().cmds("ssh-agent", "-k").quiet(true).start().joinWithTimeout(1, TimeUnit.MINUTES, listener); + /* + * `ssh-agent -k` returns 0 if terminates running agent or 1 if + * it fails to terminate it. On Linux, + */ + return (status == 0) || (status == 1); + } catch (IOException e) { + e.printStackTrace(); + listener.getLogger().println("Could not find ssh-agent: IOException: " + e.getMessage()); + listener.getLogger().println("Check if ssh-agent is installed and in PATH"); + return false; + } catch (InterruptedException e) { + e.printStackTrace(); + listener.getLogger().println("Could not find ssh-agent: InterruptedException: " + e.getMessage()); + return false; + } + } + + /** + * {@inheritDoc} + */ + @Override + public RemoteAgent start(Launcher launcher, final TaskListener listener, FilePath temp) throws Throwable { + return new ExecRemoteAgent(launcher, listener, temp); + } +} diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/AgentServer.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/AgentServer.java index 3b075c7..7ed652f 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/AgentServer.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/AgentServer.java @@ -18,7 +18,8 @@ */ package com.cloudbees.jenkins.plugins.sshagent.jna; -import jnr.posix.POSIXFactory; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import jnr.enxio.channels.NativeSelectorProvider; import jnr.unixsocket.UnixServerSocket; import jnr.unixsocket.UnixServerSocketChannel; import jnr.unixsocket.UnixSocketAddress; @@ -27,13 +28,25 @@ import org.apache.sshd.agent.SshAgent; import org.apache.sshd.agent.common.AbstractAgentClient; import org.apache.sshd.agent.local.AgentImpl; -import org.apache.sshd.common.util.Buffer; import org.apache.sshd.common.util.OsUtils; import java.io.Closeable; import java.io.File; import java.io.IOException; import java.nio.ByteBuffer; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermission; +import java.util.EnumSet; +import java.util.Iterator; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.CheckForNull; +import org.apache.sshd.common.util.buffer.Buffer; +import org.apache.sshd.common.util.buffer.ByteArrayBuffer; + /** @@ -47,14 +60,17 @@ public class AgentServer { private UnixSocketAddress address; private UnixServerSocketChannel channel; private UnixServerSocket socket; - private volatile boolean stopped = false; + private Selector selector; + private volatile boolean selectable = true; + private final @CheckForNull File temp; - public AgentServer() { - this(new AgentImpl()); + public AgentServer(File temp) { + this(new AgentImpl(), temp); } - public AgentServer(SshAgent agent) { + public AgentServer(SshAgent agent, File temp) { this.agent = agent; + this.temp = temp; } public SshAgent getAgent() { @@ -63,40 +79,83 @@ public SshAgent getAgent() { public String start() throws Exception { authSocket = createLocalSocketAddress(); - address = new UnixSocketAddress(new File(authSocket)); + final File file = new File(authSocket); + address = new UnixSocketAddress(file); channel = UnixServerSocketChannel.open(); - channel.configureBlocking(true); + channel.configureBlocking(false); socket = channel.socket(); socket.bind(address); - stopped = false; - POSIXFactory.getPOSIX().chmod(authSocket, 0600); - thread = new Thread() { - public void run() { - try { - while (!stopped) { - final UnixSocketChannel clientSock = channel.accept(); - clientSock.configureBlocking(true); - new SshAgentSession(clientSock, agent); - } - } catch (Exception e) { - if (!stopped) { - e.printStackTrace(); + selector = NativeSelectorProvider.getInstance().openSelector(); + + channel.register(selector, SelectionKey.OP_ACCEPT, new SshAgentServerSocketHandler()); + + + final EnumSet permissions = EnumSet.noneOf(PosixFilePermission.class); + permissions.add(PosixFilePermission.OWNER_READ); + permissions.add(PosixFilePermission.OWNER_WRITE); + Files.setPosixFilePermissions(file.toPath(), permissions); + if (!file.exists()) { + throw new IllegalStateException("failed to create " + authSocket + " of length " + authSocket.length() + " (check UNIX_PATH_MAX)"); + } + + thread = new Thread(new AgentSocketAcceptor(), "SSH Agent socket acceptor " + authSocket); + thread.setDaemon(true); + thread.start(); + return authSocket; + } + + final class AgentSocketAcceptor implements Runnable { + public void run() { + try { + while (selectable) { + // The select() will be woke up if some new connection + // have occurred, or if the selector has been explicitly + // woke up + if (selector.select() > 0) { + Iterator selectedKeys = selector.selectedKeys().iterator(); + + while(selectedKeys.hasNext()) { + SelectionKey key = selectedKeys.next(); + selectedKeys.remove(); + + if (key.isValid()) { + EventHandler processor = ((EventHandler) key.attachment()); + processor.process(key); + } + } } } + + } catch (IOException ioe) { + LOGGER.log(Level.WARNING, "Error while waiting for events", ioe); + } finally { + if (selectable) { + LOGGER.log(Level.WARNING, "Unexpected death of thread {0}", + Thread.currentThread().getName()); + } else { + LOGGER.log(Level.FINE, "Thread {0} termination initiated by call to AgentServer.close()", + Thread.currentThread().getName()); + } } - }; - thread.start(); - return authSocket; + } } - static String createLocalSocketAddress() throws IOException { + @SuppressFBWarnings(value="RV_RETURN_VALUE_IGNORED_BAD_PRACTICE", justification="createTempFile will fail anyway if there is a problem with mkdirs") + private String createLocalSocketAddress() throws IOException { String name; + if (temp != null) { + temp.mkdirs(); + } if (OsUtils.isUNIX()) { - File socket = File.createTempFile("jenkins", ".jnr"); + File socket = File.createTempFile("ssh", "", temp); + if (socket.getAbsolutePath().length() >= /*UNIX_PATH_MAX*/108) { + LOGGER.log(Level.WARNING, "Cannot use {0} due to UNIX_PATH_MAX; falling back to system temp dir", socket); + socket = File.createTempFile("ssh", ""); + } FileUtils.deleteQuietly(socket); name = socket.getAbsolutePath(); } else { - File socket = File.createTempFile("jenkins", ".jnr"); + File socket = File.createTempFile("ssh", "", temp); FileUtils.deleteQuietly(socket); name = "\\\\.\\pipe\\" + socket.getName(); } @@ -104,54 +163,103 @@ static String createLocalSocketAddress() throws IOException { } public void close() { - stopped = true; - agent.close(); + selectable = false; + selector.wakeup(); + + // forcibly close remaining sockets + for (SelectionKey key : selector.keys()) { + if (key != null) { + safelyClose(key.channel()); + } + } + + safelyClose(selector); + safelyClose(agent); safelyClose(channel); if (authSocket != null) { FileUtils.deleteQuietly(new File(authSocket)); } } - protected class SshAgentSession extends AbstractAgentClient implements Runnable { + interface EventHandler { + void process(SelectionKey key) throws IOException; + } + + final class SshAgentServerSocketHandler implements EventHandler { + public final void process(SelectionKey key) throws IOException { + try { + UnixSocketChannel clientChannel = channel.accept(); + clientChannel.configureBlocking(false); + clientChannel.register(selector, SelectionKey.OP_READ, new SshAgentSessionSocketHandler(clientChannel)); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "failed to accept new connection", ex); + safelyClose(channel); + throw ex; + } + } + } + + final class SshAgentSessionSocketHandler extends AbstractAgentClient implements EventHandler { + + public static final byte SSH_AGENTC_REQUEST_RSA_IDENTITIES=1; + public static final byte SSH_AGENT_RSA_IDENTITIES_ANSWER=2; - private final UnixSocketChannel channel; + private final UnixSocketChannel sessionChannel; - public SshAgentSession(UnixSocketChannel channel, SshAgent agent) { + public SshAgentSessionSocketHandler(UnixSocketChannel sessionChannel) { super(agent); - this.channel = channel; - new Thread(this).start(); + this.sessionChannel = sessionChannel; } - public void run() { + public void process(SelectionKey key) { try { ByteBuffer buf = ByteBuffer.allocate(1024); - while (!stopped) { - buf.rewind(); - int result = channel.read(buf); - if (result > 0) { - buf.flip(); - messageReceived(new Buffer(buf.array(), buf.position(), buf.remaining())); + int result; + while (0 < (result = sessionChannel.read(buf))) { + buf.flip(); + messageReceived(new ByteArrayBuffer(buf.array(), buf.position(), buf.remaining())); + if (result == 1024) { + buf.rewind(); } else { - break; + return; } } - } catch (Exception e) { - if (!stopped) { - e.printStackTrace(); + + if (result == -1) { + // EOF => remote closed the connection, cancel the selection key and close the channel. + key.cancel(); + sessionChannel.close(); } - } finally { - safelyClose(channel); + } catch (IOException e) { + LOGGER.log(Level.INFO, "Could not write response to socket", e); + key.cancel(); + safelyClose(sessionChannel); } } + @Override protected void reply(Buffer buf) throws IOException { ByteBuffer b = ByteBuffer.wrap(buf.array(), buf.rpos(), buf.available()); - int result = channel.write(b); + int result = sessionChannel.write(b); if (result < 0) { throw new IOException("Could not write response to socket"); } } + @Override + protected void process(int cmd, Buffer req, Buffer rep) throws Exception { + switch (cmd) { + case SSH_AGENTC_REQUEST_RSA_IDENTITIES: + // stop causing ssh-add -l to log errors + rep.putByte(SSH_AGENT_RSA_IDENTITIES_ANSWER); + rep.putInt(0); + break; + + default: + super.process(cmd, req, rep); + break; + } + } } private static void safelyClose(Closeable channel) { @@ -159,9 +267,10 @@ private static void safelyClose(Closeable channel) { try { channel.close(); } catch (IOException e) { - // ignore + LOGGER.log(Level.INFO, "Error while closing resource", e); } } } + private static final Logger LOGGER = Logger.getLogger(AgentServer.class.getName()); } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/JNRRemoteAgent.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/JNRRemoteAgent.java index 5e7eaba..520d089 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/JNRRemoteAgent.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/JNRRemoteAgent.java @@ -26,14 +26,15 @@ import com.cloudbees.jenkins.plugins.sshagent.Messages; import com.cloudbees.jenkins.plugins.sshagent.RemoteAgent; +import hudson.Launcher; import hudson.model.TaskListener; -import org.apache.sshd.common.util.SecurityUtils; -import org.bouncycastle.openssl.PEMReader; -import org.bouncycastle.openssl.PasswordFinder; +import jenkins.bouncycastle.api.PEMEncodable; + +import java.io.File; import java.io.IOException; -import java.io.StringReader; import java.security.KeyPair; +import javax.annotation.CheckForNull; /** * An implementation that uses Apache SSH to provide the Agent over JNR's UnixSocket implementation. @@ -47,10 +48,6 @@ public class JNRRemoteAgent implements RemoteAgent { * The socket bound by the agent. */ private final String socket; - /** - * The listener in case we need to report exceptions - */ - private final TaskListener listener; /** * Constructor. @@ -58,9 +55,8 @@ public class JNRRemoteAgent implements RemoteAgent { * @param listener the listener. * @throws Exception if the agent could not start. */ - public JNRRemoteAgent(TaskListener listener) throws Exception { - this.listener = listener; - agent = new AgentServer(); + public JNRRemoteAgent(TaskListener listener, @CheckForNull File temp) throws Exception { + agent = new AgentServer(temp); socket = agent.start(); } @@ -74,28 +70,11 @@ public String getSocket() { /** * {@inheritDoc} */ - public void addIdentity(String privateKey, final String passphrase, String comment) throws IOException { - if (!SecurityUtils.isBouncyCastleRegistered()) { - SecurityUtils.setRegisterBouncyCastle(true); - if (!SecurityUtils.isBouncyCastleRegistered()) { - throw new IllegalStateException("BouncyCastle must be registered as a JCE provider"); - } - } + public void addIdentity(String privateKey, final String passphrase, String comment, Launcher launcher, + TaskListener listener) throws IOException { try { - PEMReader r = new PEMReader(new StringReader(privateKey), - passphrase == null ? null : new PasswordFinder() { - public char[] getPassword() { - return passphrase.toCharArray(); - } - }); - try { - Object o = r.readObject(); - if (o instanceof KeyPair) { - agent.getAgent().addIdentity((KeyPair) o, comment); - } - } finally { - r.close(); - } + KeyPair keyPair = PEMEncodable.decode(privateKey, passphrase == null ? null : passphrase.toCharArray()).toKeyPair(); + agent.getAgent().addIdentity(keyPair, comment); } catch (Exception e) { listener.getLogger().println(Messages.SSHAgentBuildWrapper_UnableToReadKey(e.getMessage())); e.printStackTrace(listener.getLogger()); @@ -105,7 +84,7 @@ public char[] getPassword() { /** * {@inheritDoc} */ - public void stop() { + public void stop(Launcher launcher, TaskListener listener) { agent.close(); } } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/JNRRemoteAgentFactory.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/JNRRemoteAgentFactory.java index f7a381a..562919e 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/JNRRemoteAgentFactory.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/JNRRemoteAgentFactory.java @@ -26,7 +26,10 @@ import com.cloudbees.jenkins.plugins.sshagent.RemoteAgent; import com.cloudbees.jenkins.plugins.sshagent.RemoteAgentFactory; +import com.cloudbees.jenkins.plugins.sshagent.RemoteHelper; + import hudson.Extension; +import hudson.FilePath; import hudson.Launcher; import hudson.model.TaskListener; @@ -57,8 +60,10 @@ public boolean isSupported(Launcher launcher, final TaskListener listener) { * {@inheritDoc} */ @Override - public RemoteAgent start(Launcher launcher, final TaskListener listener) throws Throwable { - return launcher.getChannel().call(new JNRRemoteAgentStarter(listener)); + public RemoteAgent start(Launcher launcher, final TaskListener listener, FilePath temp) throws Throwable { + RemoteHelper.registerBouncyCastle(launcher.getChannel(), listener); + + return launcher.getChannel().call(new JNRRemoteAgentStarter(listener, temp != null ? temp.getRemote() : null)); } } \ No newline at end of file diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/JNRRemoteAgentStarter.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/JNRRemoteAgentStarter.java index 9d4a363..2478cf4 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/JNRRemoteAgentStarter.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/jna/JNRRemoteAgentStarter.java @@ -24,35 +24,50 @@ package com.cloudbees.jenkins.plugins.sshagent.jna; +import jenkins.security.MasterToSlaveCallable; + import com.cloudbees.jenkins.plugins.sshagent.RemoteAgent; + import hudson.model.TaskListener; -import hudson.remoting.Callable; import hudson.remoting.Channel; +import java.io.File; +import javax.annotation.CheckForNull; /** * Callable to start the remote agent. */ -public class JNRRemoteAgentStarter implements Callable { +public class JNRRemoteAgentStarter extends MasterToSlaveCallable { + + /** + * Ensure consistent serialization. Value generated from the 1.7 release. + * @since 1.8 + */ + private static final long serialVersionUID = 5020446864184061252L; + /** * Need to pass this through. */ private final TaskListener listener; + private final @CheckForNull String tempDir; + /** * Constructor. * * @param listener the listener to pass to the agent. */ - public JNRRemoteAgentStarter(TaskListener listener) { + public JNRRemoteAgentStarter(TaskListener listener, String tempDir) { this.listener = listener; + this.tempDir = tempDir; } /** * {@inheritDoc} */ public RemoteAgent call() throws Throwable { - final JNRRemoteAgent instance = new JNRRemoteAgent(listener); + final JNRRemoteAgent instance = new JNRRemoteAgent(listener, tempDir != null ? new File(tempDir) : null); final Channel channel = Channel.current(); return channel == null ? instance : channel.export(RemoteAgent.class, instance); } + } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/mina/MinaRemoteAgent.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/mina/MinaRemoteAgent.java index ea9b308..e0f2d67 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/mina/MinaRemoteAgent.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/mina/MinaRemoteAgent.java @@ -26,14 +26,14 @@ import com.cloudbees.jenkins.plugins.sshagent.Messages; import com.cloudbees.jenkins.plugins.sshagent.RemoteAgent; +import hudson.Launcher; import hudson.model.TaskListener; +import jenkins.bouncycastle.api.PEMEncodable; + +import org.apache.commons.io.IOUtils; import org.apache.sshd.agent.unix.AgentServer; -import org.apache.sshd.common.util.SecurityUtils; -import org.bouncycastle.openssl.PEMReader; -import org.bouncycastle.openssl.PasswordFinder; import java.io.IOException; -import java.io.StringReader; import java.security.KeyPair; /** @@ -48,10 +48,6 @@ public class MinaRemoteAgent implements RemoteAgent { * The socket bound by the agent. */ private final String socket; - /** - * The listener in case we need to report exceptions - */ - private final TaskListener listener; /** * Constructor. @@ -60,7 +56,6 @@ public class MinaRemoteAgent implements RemoteAgent { * @throws Exception if the agent could not start. */ public MinaRemoteAgent(TaskListener listener) throws Exception { - this.listener = listener; agent = new AgentServer(); socket = agent.start(); } @@ -75,28 +70,11 @@ public String getSocket() { /** * {@inheritDoc} */ - public void addIdentity(String privateKey, final String passphrase, String comment) throws IOException { - if (!SecurityUtils.isBouncyCastleRegistered()) { - SecurityUtils.setRegisterBouncyCastle(true); - if (!SecurityUtils.isBouncyCastleRegistered()) { - throw new IllegalStateException("BouncyCastle must be registered as a JCE provider"); - } - } + public void addIdentity(String privateKey, final String passphrase, String comment, Launcher launcher, + TaskListener listener) throws IOException { try { - PEMReader r = new PEMReader(new StringReader(privateKey), - passphrase == null ? null : new PasswordFinder() { - public char[] getPassword() { - return passphrase.toCharArray(); - } - }); - try { - Object o = r.readObject(); - if (o instanceof KeyPair) { - agent.getAgent().addIdentity((KeyPair) o, comment); - } - } finally { - r.close(); - } + KeyPair keyPair = PEMEncodable.decode(privateKey, passphrase == null ? null : passphrase.toCharArray()).toKeyPair(); + agent.getAgent().addIdentity(keyPair, comment); } catch (Exception e) { e.printStackTrace(listener.error(Messages.SSHAgentBuildWrapper_UnableToReadKey(e.getMessage()))); } @@ -105,7 +83,7 @@ public char[] getPassword() { /** * {@inheritDoc} */ - public void stop() { - agent.close(); + public void stop(Launcher launcher, TaskListener listener) { + IOUtils.closeQuietly(agent); } } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/mina/MinaRemoteAgentFactory.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/mina/MinaRemoteAgentFactory.java index a8dfdb8..4345ea9 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/mina/MinaRemoteAgentFactory.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/mina/MinaRemoteAgentFactory.java @@ -26,10 +26,14 @@ import com.cloudbees.jenkins.plugins.sshagent.RemoteAgent; import com.cloudbees.jenkins.plugins.sshagent.RemoteAgentFactory; +import com.cloudbees.jenkins.plugins.sshagent.RemoteHelper; + import hudson.Extension; +import hudson.FilePath; import hudson.Launcher; import hudson.model.TaskListener; -import hudson.remoting.Callable; + +import jenkins.security.MasterToSlaveCallable; import org.apache.tomcat.jni.Library; /** @@ -62,11 +66,21 @@ public boolean isSupported(Launcher launcher, final TaskListener listener) { * {@inheritDoc} */ @Override - public RemoteAgent start(Launcher launcher, final TaskListener listener) throws Throwable { + public RemoteAgent start(Launcher launcher, final TaskListener listener, FilePath temp) throws Throwable { + RemoteHelper.registerBouncyCastle(launcher.getChannel(), listener); + + // TODO temp directory currently ignored return launcher.getChannel().call(new MinaRemoteAgentStarter(listener)); } - private static class TomcatNativeInstalled implements Callable { + private static class TomcatNativeInstalled extends MasterToSlaveCallable { + + /** + * Ensure consistent serialization. Value generated from the 1.7 release. + * @since 1.8 + */ + private static final long serialVersionUID = 3234893369850673438L; + private final TaskListener listener; public TomcatNativeInstalled(TaskListener listener) { diff --git a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/mina/MinaRemoteAgentStarter.java b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/mina/MinaRemoteAgentStarter.java index d222b5a..99e6afe 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/sshagent/mina/MinaRemoteAgentStarter.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/sshagent/mina/MinaRemoteAgentStarter.java @@ -24,15 +24,24 @@ package com.cloudbees.jenkins.plugins.sshagent.mina; +import jenkins.security.MasterToSlaveCallable; + import com.cloudbees.jenkins.plugins.sshagent.RemoteAgent; + import hudson.model.TaskListener; -import hudson.remoting.Callable; import hudson.remoting.Channel; /** * Callable to start the remote agent. */ -public class MinaRemoteAgentStarter implements Callable { +public class MinaRemoteAgentStarter extends MasterToSlaveCallable { + + /** + * Ensure consistent serialization. Value generated from the 1.7 release. + * @since 1.8 + */ + private static final long serialVersionUID = -3757105406876098311L; + /** * Need to pass this through. */ @@ -55,4 +64,5 @@ public RemoteAgent call() throws Throwable { final Channel channel = Channel.current(); return channel == null ? instance : channel.export(RemoteAgent.class, instance); } + } diff --git a/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/Messages.properties b/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/Messages.properties index 569b2e7..6f8aef3 100644 --- a/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/Messages.properties +++ b/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/Messages.properties @@ -27,4 +27,5 @@ SSHAgentBuildWrapper.Started=[ssh-agent] Started. SSHAgentBuildWrapper.Stopped=[ssh-agent] Stopped. SSHAgentBuildWrapper.UnableToReadKey=[ssh-agent] Unable to read key\: {0} SSHAgentBuildWrapper.UsingCredentials=[ssh-agent] Using credentials {0} -SSHAgentBuildWrapper.CouldNotStartAgent=[ssh-agent] Unable to start agent \ No newline at end of file +SSHAgentBuildWrapper.CouldNotStartAgent=[ssh-agent] Unable to start agent +SSHAgentBuildWrapper.CredentialHolder.DisplayName=Credentials \ No newline at end of file diff --git a/src/findbugs/excludesFilter.xml b/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBuildWrapper/CredentialHolder/config.jelly similarity index 74% rename from src/findbugs/excludesFilter.xml rename to src/main/resources/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBuildWrapper/CredentialHolder/config.jelly index 90ad48a..f6569d5 100644 --- a/src/findbugs/excludesFilter.xml +++ b/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBuildWrapper/CredentialHolder/config.jelly @@ -22,8 +22,15 @@ ~ THE SOFTWARE. --> - - - - - + + + + + + +

+ + +
+ + diff --git a/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBuildWrapper/config.jelly b/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBuildWrapper/config.jelly index e09b042..7b840f6 100644 --- a/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBuildWrapper/config.jelly +++ b/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBuildWrapper/config.jelly @@ -23,8 +23,12 @@ --> - - - + + + + + + + diff --git a/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStep/config.jelly b/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStep/config.jelly new file mode 100644 index 0000000..81bb240 --- /dev/null +++ b/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStep/config.jelly @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + diff --git a/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStep/help.html b/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStep/help.html new file mode 100644 index 0000000..c1ad8e9 --- /dev/null +++ b/src/main/resources/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStep/help.html @@ -0,0 +1,8 @@ +

+node {
+  sshagent (credentials: ['deploy-dev']) {
+    sh 'ssh -o StrictHostKeyChecking=no -l cloudbees 192.168.1.106 uname -a'
+  }
+}
+
+

Multiple credentials could be passed in the array but it is not supported using Snippet Generator.

\ No newline at end of file diff --git a/src/test/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBase.java b/src/test/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBase.java new file mode 100644 index 0000000..0de046c --- /dev/null +++ b/src/test/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBase.java @@ -0,0 +1,323 @@ +package com.cloudbees.jenkins.plugins.sshagent; + +import com.trilead.ssh2.crypto.Base64; +import com.trilead.ssh2.packets.TypesWriter; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; +import org.apache.sshd.server.SshServer; +import org.apache.sshd.common.Factory; +import org.apache.sshd.server.Command; +import org.apache.sshd.server.CommandFactory; +import org.apache.sshd.server.Environment; +import org.apache.sshd.server.ExitCallback; +import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator; +import org.apache.sshd.server.command.UnknownCommand; +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider; +import org.apache.sshd.server.session.ServerSession; +import org.apache.sshd.server.session.SessionFactory; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.StringReader; +import java.net.*; +import java.security.PublicKey; +import java.security.interfaces.RSAPublicKey; + +public class SSHAgentBase { + + public static AtomicInteger SSH_SERVER_INITIAL_PORT = new AtomicInteger(4380); + + public static String SSH_SERVER_HOST = "localhost"; + + public static String CREDENTIAL_ID = "84822271-02d5-47b8-b8ff-c40fef175c29"; + + private SshServer sshd = null; + + private int assignedPort = SSH_SERVER_INITIAL_PORT.getAndIncrement(); + + protected void startMockSSHServer() throws Exception { + File hostKey = new File(System.getProperty("java.io.tmpdir") + "/key.ser"); + hostKey.delete(); // do not carry from test to test + sshd = SshServer.setUpDefaultServer(); + sshd.setPort(getValidPort()); + sshd.setHost(SSH_SERVER_HOST); + sshd.getProperties().put(SshServer.WELCOME_BANNER, "Welcome to the Mock SSH Server\n"); + SimpleGeneratorHostKeyProvider hostKeyProvider = new SimpleGeneratorHostKeyProvider(new File(hostKey.getPath())); + hostKeyProvider.setAlgorithm(/* TODO when upgrading sshd: KeyUtils.RSA_ALGORITHM */"RSA"); // http://stackoverflow.com/a/33692432/12916 + sshd.setKeyPairProvider(hostKeyProvider); + sshd.setShellFactory(new Factory() { + @Override + public Command create() { + Logger.getAnonymousLogger().info("Create shell"); + return new Command() { + private InputStream inputStream; + private OutputStream outputStream; + private OutputStream errorStream; + private ExitCallback exitCallback; + + @Override + public void setInputStream(InputStream inputStream) { + this.inputStream = inputStream; + } + + @Override + public void setOutputStream(OutputStream outputStream) { + this.outputStream = outputStream; + } + + @Override + public void setErrorStream(OutputStream outputStream) { + this.errorStream = outputStream; + } + + @Override + public void setExitCallback(ExitCallback exitCallback) { + this.exitCallback = exitCallback; + } + + @Override + public void start(Environment environment) throws IOException { + if (outputStream != null) { + try { + outputStream.write("Connection established. Closing...\n".getBytes("UTF-8")); + outputStream.flush(); + } catch (IOException e) { + // squash + } + } + if (exitCallback != null) { + exitCallback.onExit(0); + } + } + + @Override + public void destroy() { + + } + }; + } + }); + sshd.setCommandFactory(new CommandFactory() { + @Override + public Command createCommand(String s) { + return new UnknownCommand(s); + } + }); + + sshd.setPublickeyAuthenticator(new PublickeyAuthenticator() { + @Override + public boolean authenticate(String username, PublicKey publicKey, ServerSession session) { + return isAuthorizedKey(getPublicKeySignature(publicKey)); + } + + private String getPublicKeySignature(PublicKey pk) { + TypesWriter tw = new TypesWriter(); + if (pk instanceof RSAPublicKey) { + RSAPublicKey rpk = (RSAPublicKey) pk; + tw.writeString("ssh-rsa"); + tw.writeMPInt(rpk.getPublicExponent()); + tw.writeMPInt(rpk.getModulus()); + return new String(Base64.encode(tw.getBytes())); + } + throw new IllegalArgumentException("Unknown key type: " + pk); + } + + + public boolean isAuthorizedKey(String sig) { + try { + final BufferedReader r = new BufferedReader(new StringReader(getAuthorizedPublicKey())); + String s; + while ((s=r.readLine()) != null) { + String[] tokens = s.split("\\s+"); + if (tokens.length>=2 && tokens[1].equals(sig)) + return true; + } + return false; + } catch (IOException e) { + return false; + } + } + + }); + sshd.setSessionFactory(new SessionFactory()); + + sshd.start(); + System.out.println("Mock SSH Server is started using the port " + getAssignedPort()); + } + + protected void stopMockSSHServer() throws InterruptedException, IOException { + if (sshd != null) { + sshd.stop(); + System.out.println("Mock SSH Server is shutdown"); + } + } + + /** + * Returns a string with the Private Key with Passphrase. + * + * @return String with the Private Key + */ + public String getPrivateKey() { + return "-----BEGIN RSA PRIVATE KEY-----\n" + + "Proc-Type: 4,ENCRYPTED\n" + + "DEK-Info: AES-128-CBC,D584FC96A8C0A05CBB0658D8051A626B\n" + + "\n" + + "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\n" + + "-----END RSA PRIVATE KEY-----\n"; + } + + /** + * Same key as getPrivateKey(), but encrypted with a different passphrase + * + * @return + */ + public String getPrivateKey2() { + return "-----BEGIN RSA PRIVATE KEY-----\n" + + "Proc-Type: 4,ENCRYPTED\n" + + "DEK-Info: AES-128-CBC,4F1CC1FC8ABFF63F16459D0FD743235A\n" + + "\n" + + "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\n" + + "-----END RSA PRIVATE KEY-----\n"; + } + + /** + * Returns a string with the authorized Public Key. + * + * @return String with the authorized Public Key + */ + public String getAuthorizedPublicKey() { + return "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDB+TU0nnxVKh+m4zV2DhPm8SM5dBWQW2maU2VQp/sKHdgMuGep422eKbNfm9u82kyh1gImJzQVFQaWX+h+SewxiT9Xm7yD4D2RYXuIXgxp5x5WBpQBIHcWgV9v/a+O0cIUnDJYCh5j3O2RT4CpqnrseRrcVoMFSI+sdSAseYC2CKFAIua1x4cUykEH0kE/vkt4WPDJCb7+mIhNpjJEhHW7etsSCcA+vKxux3Kw0TuMNb/o/jL631R7NrU5jo3LzjjgD2FX6wolkYEp9F7YWaXZY4BvopObAGe52aj20Oay7L6uxiFUq/NTOMrT5trJBY3LNOSJuFr+UWGuUSulwj6qR++Io5pTyHjJLaw+s+dXdOArFAeum5bbxhGcLa18eSFYM761wA4KLdVbwd1nXoIG3+wTSO1EQCbArqc7UIhQYKI/WYDpNROdKOTyIpIYXjHz4SZBYXOn9zXJGvgnPpuHoefkT2RB5ryrfr1GFmoV2Bd/i32KIdtiDVqzCZHp9y4ZLlxz3+beMA19dNGbdYgUuanzQuAqeDNK2AcAd0IcnSrmijrxs3oNPbKr1wX2cYdD01m8jhNEn1+JCRAYghI9VsUVeEfuydA942M9gAjIiMGp7L7j09+YaJ0KH3BH8ZVJl20ojjIEa5GkOLo4IK/DMflkgG/qupG2u94o77LaIQ== cloudbees@localhost"; + } + + /** + * Returns a valid port number to use in Mocked SSH Server. Verifies that the port is not being used. + * + * @return int with a valid port number + */ + private int getValidPort() throws IOException { + boolean validPort = false; + while (!validPort) { + ServerSocket socket = null; + try { + socket = new ServerSocket(assignedPort, 0, InetAddress.getByName(SSH_SERVER_HOST)); + } catch (BindException be) { + assignedPort = SSH_SERVER_INITIAL_PORT.getAndIncrement(); + } finally { + if (socket != null) { + socket.close(); + validPort = true; + } + } + } + return assignedPort; + } + + /** + * Returns the assigned port number to be used in Mock. + * + * @return int with an assigned port number + */ + public int getAssignedPort() { + return assignedPort; + } + +} diff --git a/src/test/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBuildWrapperTest.java b/src/test/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBuildWrapperTest.java new file mode 100644 index 0000000..2e0e01b --- /dev/null +++ b/src/test/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentBuildWrapperTest.java @@ -0,0 +1,254 @@ +package com.cloudbees.jenkins.plugins.sshagent; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; +import com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.SystemCredentialsProvider; +import hudson.model.Fingerprint; +import hudson.model.FreeStyleBuild; +import hudson.model.FreeStyleProject; +import hudson.model.Result; +import hudson.tasks.Shell; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Future; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.BuildWatcher; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsCollectionContaining.hasItem; +import static org.hamcrest.core.IsNull.notNullValue; +import static org.hamcrest.core.IsNull.nullValue; + +public class SSHAgentBuildWrapperTest extends SSHAgentBase { + + @Rule + public JenkinsRule r = new JenkinsRule(); + + @ClassRule + public static BuildWatcher buildWatcher = new BuildWatcher(); + + @Test + public void sshAgentAvailable() throws Exception { + startMockSSHServer(); + + List credentialIds = new ArrayList(); + credentialIds.add(CREDENTIAL_ID); + + SSHUserPrivateKey key = new BasicSSHUserPrivateKey(CredentialsScope.GLOBAL, credentialIds.get(0), "cloudbees", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(getPrivateKey()), "cloudbees", "test"); + SystemCredentialsProvider.getInstance().getCredentials().add(key); + SystemCredentialsProvider.getInstance().save(); + + FreeStyleProject job = r.createFreeStyleProject(); + job.setAssignedNode(r.createSlave()); + + SSHAgentBuildWrapper sshAgent = new SSHAgentBuildWrapper(credentialIds, false); + job.getBuildWrappersList().add(sshAgent); + + Shell shell = new Shell("set | grep SSH_AUTH_SOCK " + + "&& ssh-add -l " + + "&& ssh -o NoHostAuthenticationForLocalhost=yes -o StrictHostKeyChecking=no -p " + getAssignedPort() + + " -v -l cloudbees " + SSH_SERVER_HOST); + job.getBuildersList().add(shell); + + r.assertBuildStatusSuccess(job.scheduleBuild2(0)); + + stopMockSSHServer(); + } + + @Test + public void sshAgentDoesNotDieAfterFirstUse() throws Exception { + startMockSSHServer(); + + List credentialIds = new ArrayList(); + credentialIds.add(CREDENTIAL_ID); + + SSHUserPrivateKey key = new BasicSSHUserPrivateKey(CredentialsScope.GLOBAL, credentialIds.get(0), "cloudbees", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(getPrivateKey()), "cloudbees", "test"); + SystemCredentialsProvider.getInstance().getCredentials().add(key); + SystemCredentialsProvider.getInstance().save(); + + FreeStyleProject job = r.createFreeStyleProject(); + + SSHAgentBuildWrapper sshAgent = new SSHAgentBuildWrapper(credentialIds, false); + job.getBuildWrappersList().add(sshAgent); + + Shell shell = new Shell( + "set | grep SSH_AUTH_SOCK " + + "&& ssh-add -l " + + "&& ssh -o NoHostAuthenticationForLocalhost=yes -o StrictHostKeyChecking=no -p " + + getAssignedPort() + " -v -l cloudbees " + SSH_SERVER_HOST); + job.getBuildersList().add(shell); + + shell = new Shell("set | grep SSH_AUTH_SOCK " + + "&& ssh-add -l " + + "&& ssh -o NoHostAuthenticationForLocalhost=yes -o StrictHostKeyChecking=no -p " + + getAssignedPort() + " -v -l cloudbees " + SSH_SERVER_HOST); + job.getBuildersList().add(shell); + + shell = new Shell("set | grep SSH_AUTH_SOCK " + + "&& ssh-add -l " + + "&& ssh -o NoHostAuthenticationForLocalhost=yes -o StrictHostKeyChecking=no -p " + + getAssignedPort() + " -v -l cloudbees " + SSH_SERVER_HOST); + job.getBuildersList().add(shell); + + r.assertBuildStatusSuccess(job.scheduleBuild2(0)); + + stopMockSSHServer(); + } + + @Test + public void sshAgentUnavailable() throws Exception { + startMockSSHServer(); + + List credentialIds = new ArrayList(); + credentialIds.add(CREDENTIAL_ID); + + SSHUserPrivateKey key = new BasicSSHUserPrivateKey(CredentialsScope.GLOBAL, credentialIds.get(0), "cloudbees", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(getPrivateKey()), "cloudbees", "test"); + SystemCredentialsProvider.getInstance().getCredentials().add(key); + SystemCredentialsProvider.getInstance().save(); + + FreeStyleProject job = r.createFreeStyleProject(); + + Shell shell = new Shell("ssh -o StrictHostKeyChecking=no -p " + getAssignedPort() + " -v -l cloudbees " + SSH_SERVER_HOST); + job.getBuildersList().add(shell); + + r.assertLogContains("Permission denied (publickey).", r.assertBuildStatus(Result.FAILURE, job.scheduleBuild2(0).get())); + + stopMockSSHServer(); + } + + @Test + public void sshAgentWithInvalidCredentials() throws Exception { + startMockSSHServer(); + + List credentialIds = new ArrayList(); + credentialIds.add(CREDENTIAL_ID); + + SSHUserPrivateKey key = new BasicSSHUserPrivateKey(CredentialsScope.GLOBAL, credentialIds.get(0), "cloudbees", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(getPrivateKey()), "BAD-passphrase-cloudbees", "test"); + SystemCredentialsProvider.getInstance().getCredentials().add(key); + SystemCredentialsProvider.getInstance().save(); + + FreeStyleProject job = r.createFreeStyleProject(); + + SSHAgentBuildWrapper sshAgent = new SSHAgentBuildWrapper(credentialIds, false); + job.getBuildWrappersList().add(sshAgent); + + Shell shell = new Shell("ssh -o StrictHostKeyChecking=no -p " + getAssignedPort() + " -v -l cloudbees " + SSH_SERVER_HOST); + job.getBuildersList().add(shell); + + r.assertLogContains("Failed to run ssh-add", r.assertBuildStatus(Result.FAILURE, job.scheduleBuild2(0).get())); + + stopMockSSHServer(); + } + + @Issue("JENKINS-38830") + @Test + public void testTrackingOfCredential() throws Exception { + startMockSSHServer(); + + List credentialIds = new ArrayList(); + credentialIds.add(CREDENTIAL_ID); + + SSHUserPrivateKey key = new BasicSSHUserPrivateKey(CredentialsScope.GLOBAL, credentialIds.get(0), "cloudbees", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(getPrivateKey()), "cloudbees", "test"); + SystemCredentialsProvider.getInstance().getCredentials().add(key); + SystemCredentialsProvider.getInstance().save(); + + Fingerprint fingerprint = CredentialsProvider.getFingerprintOf(key); + assertThat("No fingerprint created until first use", fingerprint, nullValue()); + + FreeStyleProject job = r.createFreeStyleProject(); + + SSHAgentBuildWrapper sshAgent = new SSHAgentBuildWrapper(credentialIds, false); + job.getBuildWrappersList().add(sshAgent); + + Shell shell = new Shell("set | grep SSH_AUTH_SOCK " + + "&& ssh-add -l " + + "&& ssh -o NoHostAuthenticationForLocalhost=yes -o StrictHostKeyChecking=no -p " + getAssignedPort() + + " -v -l cloudbees " + SSH_SERVER_HOST); + job.getBuildersList().add(shell); + + r.assertBuildStatusSuccess(job.scheduleBuild2(0)); + + fingerprint = CredentialsProvider.getFingerprintOf(key); + assertThat(fingerprint, notNullValue()); + assertThat(fingerprint.getJobs(), hasItem(is(job.getFullName()))); + Fingerprint.RangeSet rangeSet = fingerprint.getRangeSet(job); + assertThat(rangeSet, notNullValue()); + assertThat(rangeSet.includes(job.getLastBuild().getNumber()), is(true)); + + stopMockSSHServer(); + } + + @Issue("JENKINS-42093") + @Test + public void sshAgentWithSpacesInWorkspacePath() throws Exception { + startMockSSHServer(); + + List credentialIds = new ArrayList(); + credentialIds.add(CREDENTIAL_ID); + + SSHUserPrivateKey key = new BasicSSHUserPrivateKey(CredentialsScope.GLOBAL, credentialIds.get(0), "cloudbees", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(getPrivateKey()), "cloudbees", "test"); + SystemCredentialsProvider.getInstance().getCredentials().add(key); + SystemCredentialsProvider.getInstance().save(); + + FreeStyleProject job = r.createFreeStyleProject("name with spaces"); + job.setAssignedNode(r.createSlave()); + + SSHAgentBuildWrapper sshAgent = new SSHAgentBuildWrapper(credentialIds, false); + job.getBuildWrappersList().add(sshAgent); + + Shell shell = new Shell("set | grep SSH_AUTH_SOCK " + + "&& ssh-add -l " + + "&& ssh -o NoHostAuthenticationForLocalhost=yes -o StrictHostKeyChecking=no -p " + getAssignedPort() + + " -v -l cloudbees " + SSH_SERVER_HOST); + job.getBuildersList().add(shell); + + Future build = job.scheduleBuild2(0); + r.assertBuildStatusSuccess(build); + r.assertLogNotContains("rm: ", build.get()); + + stopMockSSHServer(); + } + + @Test + public void sshAgentWithTrickyPassphrase() throws Exception { + startMockSSHServer(); + + List credentialIds = new ArrayList(); + credentialIds.add(CREDENTIAL_ID); + + SSHUserPrivateKey key = new BasicSSHUserPrivateKey(CredentialsScope.GLOBAL, credentialIds.get(0), "cloudbees", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(getPrivateKey2()), "* .*", "test"); + SystemCredentialsProvider.getInstance().getCredentials().add(key); + SystemCredentialsProvider.getInstance().save(); + + FreeStyleProject job = r.createFreeStyleProject(); + job.setAssignedNode(r.createSlave()); + + SSHAgentBuildWrapper sshAgent = new SSHAgentBuildWrapper(credentialIds, false); + job.getBuildWrappersList().add(sshAgent); + + Shell shell = new Shell("set | grep SSH_AUTH_SOCK " + + "&& ssh-add -l " + + "&& ssh -o NoHostAuthenticationForLocalhost=yes -o StrictHostKeyChecking=no -p " + getAssignedPort() + + " -v -l cloudbees " + SSH_SERVER_HOST); + job.getBuildersList().add(shell); + + r.assertBuildStatusSuccess(job.scheduleBuild2(0)); + + stopMockSSHServer(); + } + +} diff --git a/src/test/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStepWorkflowTest.java b/src/test/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStepWorkflowTest.java new file mode 100644 index 0000000..e8e7b67 --- /dev/null +++ b/src/test/java/com/cloudbees/jenkins/plugins/sshagent/SSHAgentStepWorkflowTest.java @@ -0,0 +1,274 @@ +package com.cloudbees.jenkins.plugins.sshagent; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; +import com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.SystemCredentialsProvider; +import hudson.Launcher; +import hudson.model.Fingerprint; +import hudson.slaves.DumbSlave; +import hudson.util.StreamTaskListener; +import java.io.IOException; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.cps.CpsFlowExecution; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; +import org.jenkinsci.plugins.workflow.test.steps.SemaphoreStep; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runners.model.Statement; +import org.jvnet.hudson.test.BuildWatcher; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.RestartableJenkinsRule; + +import java.util.ArrayList; +import java.util.List; +import java.util.Scanner; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.core.IsCollectionContaining.hasItem; +import static org.hamcrest.core.IsNull.notNullValue; +import static org.hamcrest.core.IsNull.nullValue; +import org.jenkinsci.plugins.docker.commons.tools.DockerTool; +import org.jenkinsci.plugins.docker.workflow.client.DockerClient; +import static org.junit.Assert.*; +import static org.junit.Assume.*; + +public class SSHAgentStepWorkflowTest extends SSHAgentBase { + + @Rule + public RestartableJenkinsRule story = new RestartableJenkinsRule(); + + @ClassRule + public static BuildWatcher buildWatcher = new BuildWatcher(); + + @Test + public void sshAgentAvailable() throws Exception { + story.addStep(new Statement() { + @Override + public void evaluate() throws Throwable { + startMockSSHServer(); + + List credentialIds = new ArrayList(); + credentialIds.add(CREDENTIAL_ID); + + SSHUserPrivateKey key = new BasicSSHUserPrivateKey(CredentialsScope.GLOBAL, credentialIds.get(0), "cloudbees", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(getPrivateKey()), "cloudbees", "test"); + SystemCredentialsProvider.getInstance().getCredentials().add(key); + SystemCredentialsProvider.getInstance().save(); + + WorkflowJob job = story.j.jenkins.createProject(WorkflowJob.class, "sshAgentAvailable"); + job.setDefinition(new CpsFlowDefinition("" + + "node('" + story.j.createSlave().getNodeName() + "') {\n" + + " sshagent (credentials: ['" + CREDENTIAL_ID + "']) {\n" + + " sh 'ls -l $SSH_AUTH_SOCK && ssh -o StrictHostKeyChecking=no -p " + getAssignedPort() + " -v -l cloudbees " + SSH_SERVER_HOST + "'\n" + + " }\n" + + "}\n", true) + ); + story.j.assertBuildStatusSuccess(job.scheduleBuild2(0)); + + stopMockSSHServer(); + } + }); + } + + /** + * This test verifies: + * + * 1. The Job is executed successfully + * 2. SSH_AUTH_SOCK is available before and after Jenkins was restarted + * 3. SSH_AUTH_SOCK has different values before and after Jenkins was restarted + * + * It verifies that {@link SSHAgentStepExecution#onResume()} method is invoked and a new SSH Agent is launched after Jenkins is restarted. + * + * @throws Exception + */ + @Test + public void sshAgentAvailableAfterRestart() throws Exception { + story.addStep(new Statement() { + @Override + public void evaluate() throws Throwable { + startMockSSHServer(); + + List credentialIds = new ArrayList(); + credentialIds.add(CREDENTIAL_ID); + + SSHUserPrivateKey key = new BasicSSHUserPrivateKey(CredentialsScope.GLOBAL, credentialIds.get(0), "cloudbees", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(getPrivateKey()), "cloudbees", "test"); + SystemCredentialsProvider.getInstance().getCredentials().add(key); + SystemCredentialsProvider.getInstance().save(); + + WorkflowJob p = story.j.jenkins.createProject(WorkflowJob.class, "sshAgentAvailableAfterRestart"); + p.setDefinition(new CpsFlowDefinition("" + + "node {\n" + + " sshagent (credentials: ['" + CREDENTIAL_ID + "']) {\n" + + " sh 'ssh -o StrictHostKeyChecking=no -p " + getAssignedPort() + " -v -l cloudbees " + SSH_SERVER_HOST + "'\n" + + " echo \"SSH Agent before restart ${env.SSH_AUTH_SOCK}\"\n" + + " semaphore 'sshAgentAvailableAfterRestart'\n" + + " sh 'ssh -o StrictHostKeyChecking=no -p " + getAssignedPort() + " -v -l cloudbees " + SSH_SERVER_HOST + "'\n" + + " echo \"SSH Agent after restart ${env.SSH_AUTH_SOCK}\"\n" + + " }\n" + + "}\n", true)); + // get the build going + WorkflowRun b = p.scheduleBuild2(0).getStartCondition().get(); + CpsFlowExecution e = (CpsFlowExecution) b.getExecutionPromise().get(); + + // wait until the executor gets assigned and the execution pauses + SemaphoreStep.waitForStart("sshAgentAvailableAfterRestart/1", b); + assertTrue(JenkinsRule.getLog(b), b.isBuilding()); + } + }); + story.addStep(new Statement() { + @Override + public void evaluate() throws Throwable { + WorkflowJob p = story.j.jenkins.getItemByFullName("sshAgentAvailableAfterRestart", WorkflowJob.class); + WorkflowRun b = p.getBuildByNumber(1); + + SemaphoreStep.success("sshAgentAvailableAfterRestart/1", null); + + story.j.assertBuildStatusSuccess(story.j.waitForCompletion(b)); + + Pattern pattern = Pattern.compile("(?:SSH Agent (?:before|after) restart )/.+/ssh-.+/agent.(\\d)+"); + Scanner sc = new Scanner(b.getLogFile()); + List socketFile = new ArrayList(); + while (sc.hasNextLine()) { + String match = sc.findInLine(pattern); + if (match != null) { + socketFile.add(match); + } else { + sc.nextLine(); + } + } + sc.close(); + + assertEquals(socketFile.toString(), 2, socketFile.size()); + assertNotEquals(socketFile.get(0), socketFile.get(1)); + stopMockSSHServer(); + } + }); + + } + + /** + * This test verifies that sshAgent step handles that the build agent + * disconnects and reconnects during the step execution. + */ + @Issue("JENKINS-59259") + @Test + public void agentConnectionDropTest() throws Exception { + story.then(r -> { + List credentialIds = new ArrayList(); + credentialIds.add(CREDENTIAL_ID); + SSHUserPrivateKey key = new BasicSSHUserPrivateKey(CredentialsScope.GLOBAL, credentialIds.get(0), "cloudbees", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(getPrivateKey()), "cloudbees", "test"); + SystemCredentialsProvider.getInstance().getCredentials().add(key); + SystemCredentialsProvider.getInstance().save(); + + DumbSlave agent = r.createSlave(true); + WorkflowJob job = r.jenkins.createProject(WorkflowJob.class, "sshAgentAvailable"); + job.setDefinition(new CpsFlowDefinition("" + + "node('" + agent.getNodeName() + "') {\n" + + " sshagent (credentials: ['" + CREDENTIAL_ID + "']) {\n" + + " semaphore 'upAndRunning'\n" + + " }\n" + + "}\n", true) + ); + + WorkflowRun run = job.scheduleBuild2(0).getStartCondition().get(); + + SemaphoreStep.waitForStart("upAndRunning/1", run); + + r.disconnectSlave(agent); + r.waitOnline(agent); + + SemaphoreStep.success("upAndRunning/1", null); + + r.waitForCompletion(run); + r.assertBuildStatusSuccess(run); + }); + } + + @Issue("JENKINS-38830") + @Test + public void testTrackingOfCredential() { + + + story.addStep(new Statement() { + @Override + public void evaluate() throws Throwable { + startMockSSHServer(); + + List credentialIds = new ArrayList(); + credentialIds.add(CREDENTIAL_ID); + + SSHUserPrivateKey key = new BasicSSHUserPrivateKey(CredentialsScope.GLOBAL, credentialIds.get(0), "cloudbees", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(getPrivateKey()), "cloudbees", "test"); + SystemCredentialsProvider.getInstance().getCredentials().add(key); + SystemCredentialsProvider.getInstance().save(); + + Fingerprint fingerprint = CredentialsProvider.getFingerprintOf(key); + + WorkflowJob job = story.j.jenkins.createProject(WorkflowJob.class, "sshAgentAvailable"); + job.setDefinition(new CpsFlowDefinition("" + + "node {\n" + + " sshagent (credentials: ['" + CREDENTIAL_ID + "']) {\n" + + " sh 'ls -l $SSH_AUTH_SOCK && ssh -o StrictHostKeyChecking=no -p " + getAssignedPort() + " -v -l cloudbees " + SSH_SERVER_HOST + "'\n" + + " }\n" + + "}\n", true) + ); + + assertThat("No fingerprint created until first use", fingerprint, nullValue()); + + story.j.assertBuildStatusSuccess(job.scheduleBuild2(0)); + + fingerprint = CredentialsProvider.getFingerprintOf(key); + assertThat(fingerprint, notNullValue()); + assertThat(fingerprint.getJobs(), hasItem(is(job.getFullName()))); + + stopMockSSHServer(); + } + }); + } + + @Issue("SECURITY-704") + @Test + public void sshAgentDocker() throws Exception { + story.then(r -> { + // From org.jenkinsci.plugins.docker.workflow.DockerTestUtil: + Launcher.LocalLauncher localLauncher = new Launcher.LocalLauncher(StreamTaskListener.NULL); + try { + assumeThat("Docker working", localLauncher.launch().cmds(DockerTool.getExecutable(null, null, null, null), "ps").start().joinWithTimeout(DockerClient.CLIENT_TIMEOUT, TimeUnit.SECONDS, localLauncher.getListener()), is(0)); + } catch (IOException x) { + assumeNoException("have Docker installed", x); + } + + List credentialIds = new ArrayList(); + credentialIds.add(CREDENTIAL_ID); + + SSHUserPrivateKey key = new BasicSSHUserPrivateKey(CredentialsScope.GLOBAL, credentialIds.get(0), "x", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(getPrivateKey()), "cloudbees", "test"); + SystemCredentialsProvider.getInstance().getCredentials().add(key); + SystemCredentialsProvider.getInstance().save(); + + WorkflowJob job = r.createProject(WorkflowJob.class, "sshAgentDocker"); + job.setDefinition(new CpsFlowDefinition("" + + "node('" + r.createSlave().getNodeName() + "') {\n" + + " withDockerContainer('kroniak/ssh-client') {\n" + + " sh 'ssh-agent -k || :'\n" + + " sshagent(credentials: ['" + CREDENTIAL_ID + "']) {\n" + + " sh 'env'\n" + + " }\n" + + " }\n" + + "}\n", true) + ); + WorkflowRun b = r.buildAndAssertSuccess(job); + r.assertLogNotContains("cloudbees", b); + }); + } + +}