diff --git a/src/main/java/jenkins/test/https/KeyStoreManager.java b/src/main/java/jenkins/test/https/KeyStoreManager.java new file mode 100644 index 000000000..a729946da --- /dev/null +++ b/src/main/java/jenkins/test/https/KeyStoreManager.java @@ -0,0 +1,286 @@ +/* + * The MIT License + * + * Copyright 2024 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 jenkins.test.https; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import org.htmlunit.WebClient; + +/** + * Manages a Java keystore file. + */ +public final class KeyStoreManager { + @NonNull + private final Path path; + @NonNull + private final URL url; + @CheckForNull + private final char[] password; + @NonNull + private final KeyStore keyStore; + @NonNull + private final String type; + + /** + * Creates a new instance using the default keystore type. + * @param path path of the keystore file. If it exists, it will be loaded automatically. + */ + public KeyStoreManager(@NonNull Path path) + throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException { + this(path, null, KeyStore.getDefaultType()); + } + + /** + * Creates a new instance using the default keystore type. + * @param path path of the keystore file. If it exists, it will be loaded automatically. + * @param password password for the keystore file. + */ + public KeyStoreManager(@NonNull Path path, @CheckForNull String password) + throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException { + this(path, password, KeyStore.getDefaultType()); + } + + /** + * Creates a new instance using the specified keystore type. + * @param path path of the keystore file. If it exists, it will be loaded automatically. + * @param password password for the keystore file. + * @param type type of the keystore file. + */ + public KeyStoreManager(@NonNull Path path, @CheckForNull String password, @NonNull String type) + throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException { + this.path = path; + this.url = path.toUri().toURL(); + this.password = password == null ? null : password.toCharArray(); + this.type = type; + var tmpKeyStore = KeyStore.getInstance(type); + if (Files.exists(path)) { + try (var is = Files.newInputStream(path)) { + tmpKeyStore.load(is, this.password); + } + } else { + tmpKeyStore.load(null); + } + this.keyStore = tmpKeyStore; + } + + @NonNull + private static X509TrustManager getDefaultX509CertificateTrustManager(TrustManagerFactory trustManagerFactory) { + return Arrays.stream(trustManagerFactory.getTrustManagers()) + .filter(X509TrustManager.class::isInstance) + .map(X509TrustManager.class::cast) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Could not load default trust manager")); + } + + /** + * @return the password for the managed keystore + */ + public String getPassword() { + return password == null ? null : new String(password); + } + + /** + * @return the path where the managed keystore is persisted to. + * Make sure {@link #save()} has been called before using the path. + */ + public Path getPath() { + return path; + } + + /** + * @return the type of the managed keystore. + */ + public String getType() { + return type; + } + + /** + * @return returns the URL representation of the keystore file. + *
+ * Make sure {@link #save()} has been called before using the path.
+ */
+ public URL getURL() {
+ return url;
+ }
+
+ /**
+ * Persists the current keystore to disk.
+ */
+ public void save() throws IOException, KeyStoreException, NoSuchAlgorithmException, CertificateException {
+ try (var os = Files.newOutputStream(path)) {
+ keyStore.store(os, password);
+ }
+ }
+
+ /**
+ * Build a custom SSL context that trusts the default certificates as well as those in the current keystore.
+ */
+ @NonNull
+ public SSLContext buildClientSSLContext()
+ throws NoSuchAlgorithmException, KeyStoreException, IOException, CertificateException,
+ KeyManagementException {
+ X509TrustManager result;
+ try (var myKeysInputStream = Files.newInputStream(path)) {
+ var myTrustStore = KeyStore.getInstance(type);
+ myTrustStore.load(myKeysInputStream, password);
+ var trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ trustManagerFactory.init(myTrustStore);
+ result = getDefaultX509CertificateTrustManager(trustManagerFactory);
+ }
+ var trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ trustManagerFactory.init((KeyStore) null);
+ var wrapper = new MergedTrustManager(getDefaultX509CertificateTrustManager(trustManagerFactory), result);
+ var context = SSLContext.getInstance("TLS");
+ context.init(null, new TrustManager[] {wrapper}, null);
+ return context;
+ }
+
+ /**
+ * Build server context for server usage.
+ * @return a SSLContext instance configured with the key store.
+ */
+ public SSLContext buildServerSSLContext() {
+ final KeyManager[] keyManagers;
+ try {
+ var keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ keyManagerFactory.init(keyStore, password);
+ keyManagers = keyManagerFactory.getKeyManagers();
+ } catch (NoSuchAlgorithmException | UnrecoverableKeyException | KeyStoreException e) {
+ throw new RuntimeException("Unable to initialise KeyManager[]", e);
+ }
+
+ final TrustManager[] trustManagers;
+ try {
+ var trustManagerFactory = TrustManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ trustManagerFactory.init(keyStore);
+ trustManagers = trustManagerFactory.getTrustManagers();
+ } catch (NoSuchAlgorithmException | KeyStoreException e) {
+ throw new RuntimeException("Unable to initialise TrustManager[]", e);
+ }
+
+ try {
+ var sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(keyManagers, trustManagers, null);
+ return sslContext;
+ } catch (NoSuchAlgorithmException | KeyManagementException e) {
+ throw new RuntimeException("Unable to create and initialise the SSLContext", e);
+ }
+ }
+
+ /**
+ * @see KeyStore#setCertificateEntry(String, Certificate)
+ */
+ public void setCertificateEntry(String alias, X509Certificate certificate) throws KeyStoreException {
+ keyStore.setCertificateEntry(alias, certificate);
+ }
+
+ /**
+ * @see KeyStore#setKeyEntry(String, byte[], Certificate[])
+ */
+ public void setKeyEntry(String host, PrivateKey privateKey, Certificate[] certificates) throws KeyStoreException {
+ keyStore.setKeyEntry(host, privateKey, password, certificates);
+ }
+
+ public String[] getTruststoreJavaOptions() {
+ var list = new ArrayList Calling this method does not change the fact that Jenkins will be configured to listen only on localhost for security reasons
* (so others in the same network cannot access your system under test, especially if it lacks authentication).
+ *
+ * When using HTTPS, use {@link #https(String,KeyStoreManager, X509Certificate)} instead.
*/
public RealJenkinsRule withHost(String host) {
+ if (https) {
+ throw new IllegalStateException("Don't call this method when using HTTPS");
+ }
this.host = host;
return this;
}
@@ -676,6 +703,10 @@ public boolean isAlive() {
return proc != null && proc.isAlive();
}
+ public String[] getTruststoreJavaOptions() {
+ return keyStoreManager != null ? keyStoreManager.getTruststoreJavaOptions() : new String[0];
+ }
+
/**
* One step to run.
* Since this thunk will be sent to a different JVM, it must be serializable.
@@ -728,7 +759,86 @@ public URL getUrl() throws MalformedURLException {
if (port == 0) {
throw new IllegalStateException("This method must be called after calling #startJenkins.");
}
- return new URL("http", host, port, "/jenkins/");
+ return new URL(https ? "https" : "http", host, port, "/jenkins/");
+ }
+
+ /**
+ * Sets up HTTPS for the current instance, and disables plain HTTP.
+ * This generates a self-signed certificate for localhost. The corresponding root CA that needs to be trusted by HTTP client can be obtained using {@link #getRootCA()}.
+ *
+ * @return the current instance
+ * @see #createWebClient()
+ */
+ public RealJenkinsRule https() {
+ try {
+ var keyStorePath = tmp.allocate().toPath().resolve("test-keystore.p12");
+ IOUtils.copy(getClass().getResource("/https/test-keystore.p12"), keyStorePath.toFile());
+ var keyStoreManager = new KeyStoreManager(keyStorePath, "changeit");
+ try (var is = getClass().getResourceAsStream("/https/test-cert.pem")) {
+ var cert = (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(is);
+ https("localhost", keyStoreManager, cert);
+ }
+ } catch (CertificateException | KeyStoreException | NoSuchAlgorithmException | IOException e) {
+ throw new RuntimeException(e);
+ }
+ return this;
+ }
+
+ /**
+ * Sets up HTTPS for the current instance, and disables plain HTTP.
+ *
+ * You don't need to call {@link #withHost(String)} when calling this method.
+ *
+ * @param host the host name to use in the certificate
+ * @param keyStoreManager a key store manager containing the key and certificate to use for HTTPS. It needs to be valid for the given host
+ * @param rootCA the certificate that needs to be trusted by callers.
+ * @return the current instance
+ * @see #createWebClient()
+ * @see #withHost(String)
+ */
+ public RealJenkinsRule https(@NonNull String host, @NonNull KeyStoreManager keyStoreManager, @NonNull X509Certificate rootCA) {
+ this.host = host;
+ this.https = true;
+ this.keyStoreManager = keyStoreManager;
+ try {
+ this.sslSocketFactory = keyStoreManager.buildClientSSLContext().getSocketFactory();
+ } catch (NoSuchAlgorithmException | KeyManagementException | CertificateException | KeyStoreException |
+ IOException e) {
+ throw new RuntimeException(e);
+ }
+ this.rootCA = rootCA;
+ return this;
+ }
+
+ /**
+ * @return the current autogenerated root CA or null if {@link #https()} has not been called.
+ */
+ @Nullable
+ public X509Certificate getRootCA() {
+ return rootCA;
+ }
+
+ /**
+ * Builds a {@link SSLContext} trusting the current instance.
+ */
+ @NonNull
+ public SSLContext buildSSLContext() throws NoSuchAlgorithmException {
+ if (rootCA != null) {
+ try {
+ var myTrustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ myTrustStore.load(null, null);
+ myTrustStore.setCertificateEntry(getName() != null ? getName() : UUID.randomUUID().toString(), rootCA);
+ var trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ trustManagerFactory.init(myTrustStore);
+ var context = SSLContext.getInstance("TLS");
+ context.init(null, trustManagerFactory.getTrustManagers(), null);
+ return context;
+ } catch (CertificateException | KeyManagementException | IOException | KeyStoreException e) {
+ throw new RuntimeException(e);
+ }
+ } else {
+ return SSLContext.getDefault();
+ }
}
private URL endpoint(String method) throws MalformedURLException {
@@ -771,6 +881,17 @@ private static File findJenkinsWar() throws Exception {
return WarExploder.findJenkinsWar();
}
+ /**
+ * Create a client configured to trust any self-signed certificate used by this instance.
+ */
+ public WebClient createWebClient() {
+ var wc = new WebClient();
+ if (keyStoreManager != null) {
+ keyStoreManager.configureWebClient(wc);
+ }
+ return wc;
+ }
+
@SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "irrelevant")
public void startJenkins() throws Throwable {
if (proc != null) {
@@ -821,9 +942,15 @@ public void startJenkins() throws Throwable {
argv.addAll(List.of(
"-jar", war.getAbsolutePath(),
"--enable-future-java",
- "--httpPort=" + port, // initially port=0. On subsequent runs, the port is set to the port used allocated randomly on the first run.
"--httpListenAddress=" + httpListenAddress,
"--prefix=/jenkins"));
+ argv.addAll(getPortOptions());
+ if (https) {
+ argv.add("--httpsKeyStore=" + keyStoreManager.getPath().toAbsolutePath());
+ if (keyStoreManager.getPassword() != null) {
+ argv.add("--httpsKeyStorePassword=" + keyStoreManager.getPassword());
+ }
+ }
argv.addAll(jenkinsOptions);
Map