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(); + list.add("-Djavax.net.ssl.trustStore=" + getPath().toAbsolutePath()); + if (password != null) { + list.add("-Djavax.net.ssl.trustStorePassword=" + new String(password)); + } + return list.toArray(new String[0]); + } + + public void configureWebClient(WebClient wc) { + wc.getOptions().setSSLTrustStore(getURL(), getPassword(), getType()); + } + + private static class MergedTrustManager implements X509TrustManager { + private final X509TrustManager defaultTrustManager; + private final List trustManagers; + + public MergedTrustManager(X509TrustManager defaultTrustManager, X509TrustManager... trustManagers) { + this.defaultTrustManager = defaultTrustManager; + this.trustManagers = List.of(trustManagers); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return trustManagers.stream() + .map(X509TrustManager::getAcceptedIssuers) + .flatMap(Arrays::stream) + .toArray(X509Certificate[]::new); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + CertificateException exceptionResult = null; + for (var trustManager : trustManagers) { + try { + trustManager.checkServerTrusted(chain, authType); + return; + } catch (CertificateException e) { + if (exceptionResult == null) { + exceptionResult = e; + } else { + exceptionResult.addSuppressed(e); + } + } + } + if (exceptionResult != null) { + throw exceptionResult; + } + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + defaultTrustManager.checkClientTrusted(trustManagers.stream() + .map(X509TrustManager::getAcceptedIssuers) + .flatMap(Arrays::stream) + .toArray(X509Certificate[]::new), authType); + } + } +} diff --git a/src/main/java/org/jvnet/hudson/test/InboundAgentRule.java b/src/main/java/org/jvnet/hudson/test/InboundAgentRule.java index 2fc7c1bd2..f51c21aca 100644 --- a/src/main/java/org/jvnet/hudson/test/InboundAgentRule.java +++ b/src/main/java/org/jvnet/hudson/test/InboundAgentRule.java @@ -109,6 +109,8 @@ public static final class Options implements Serializable { private final LinkedHashMap loggers = new LinkedHashMap<>(); private String label; private final PrefixedOutputStream.Builder prefixedOutputStreamBuilder = PrefixedOutputStream.builder(); + private String trustStorePath; + private String trustStorePassword; public String getName() { return name; @@ -138,6 +140,21 @@ public String getLabel() { return label; } + /** + * Compute java options requied to connect to the given RealJenkinsRule instance. + * @param r The instance to compute Java options for + */ + private void computeJavaOptions(RealJenkinsRule r) { + if (trustStorePath != null && trustStorePassword != null) { + javaOptions.addAll(List.of( + "-Djavax.net.ssl.trustStore=" + trustStorePath, + "-Djavax.net.ssl.trustStorePassword=" + trustStorePassword + )); + } else { + javaOptions.addAll(List.of(r.getTruststoreJavaOptions())); + } + } + /** * A builder of {@link Options}. * @@ -219,6 +236,18 @@ public Builder javaOptions(String... opts) { return this; } + /** + * Provide a custom truststore for the agent JVM. Can be useful when using a setup with a reverse proxy. + * @param path the path to the truststore + * @param password the password for the truststore + * @return this builder + */ + public Builder trustStore(String path, String password) { + options.trustStorePath = path; + options.trustStorePassword = password; + return this; + } + /** * Skip starting the agent. * @@ -333,6 +362,7 @@ public void start(@NonNull RealJenkinsRule r, Options options) throws Throwable stop(r, name); var args = r.runRemotely(InboundAgentRule::getAgentArguments, name); jars.add(args.agentJar); + options.computeJavaOptions(r); start(args, options); r.runRemotely(InboundAgentRule::waitForAgentOnline, name, options.loggers); } diff --git a/src/main/java/org/jvnet/hudson/test/RealJenkinsRule.java b/src/main/java/org/jvnet/hudson/test/RealJenkinsRule.java index d69e7f3a0..dca15ead9 100644 --- a/src/main/java/org/jvnet/hudson/test/RealJenkinsRule.java +++ b/src/main/java/org/jvnet/hudson/test/RealJenkinsRule.java @@ -25,6 +25,8 @@ package org.jvnet.hudson.test; import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.ExtensionList; import hudson.model.UnprotectedRootAction; @@ -54,13 +56,22 @@ import java.net.URI; import java.net.URL; import java.net.URLClassLoader; +import java.net.URLConnection; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.attribute.FileAttribute; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.Collection; import java.util.Enumeration; import java.util.HashMap; import java.util.HashSet; @@ -88,6 +99,10 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManagerFactory; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; @@ -96,8 +111,11 @@ import io.jenkins.test.fips.FIPSTestBundleProvider; import jenkins.model.Jenkins; import jenkins.model.JenkinsLocationConfiguration; +import jenkins.test.https.KeyStoreManager; import jenkins.util.Timer; import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.htmlunit.WebClient; import org.junit.AssumptionViolatedException; import org.junit.rules.DisableOnDebug; import org.junit.rules.TemporaryFolder; @@ -211,6 +229,10 @@ public final class RealJenkinsRule implements TestRule { private static final Pattern SNAPSHOT_INDEX_JELLY = Pattern.compile("(file:/.+/target)/classes/index.jelly"); private final PrefixedOutputStream.Builder prefixedOutputStreamBuilder = PrefixedOutputStream.builder(); + private boolean https; + private KeyStoreManager keyStoreManager; + private SSLSocketFactory sslSocketFactory; + private X509Certificate rootCA; public RealJenkinsRule() { home = new AtomicReference<>(); @@ -307,8 +329,13 @@ public RealJenkinsRule withTimeout(int timeout) { * might accidentally be shared across otherwise distinct services. *

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 env = new TreeMap<>(); env.put("JENKINS_HOME", getHome().getAbsolutePath()); @@ -860,7 +987,7 @@ public void startJenkins() throws Throwable { if (port != 0) { try { URL status = endpoint("status"); - HttpURLConnection conn = (HttpURLConnection) status.openConnection(); + HttpURLConnection conn = decorateConnection(status.openConnection()); String checkResult = checkResult(conn); if (checkResult == null) { @@ -891,6 +1018,15 @@ public void startJenkins() throws Throwable { addTimeout(); } + private Collection getPortOptions() { + // Initially port=0. On subsequent runs, this is set to the port allocated randomly on the first run. + if (https) { + return List.of("--httpPort=-1", "--httpsPort=" + port); + } else { + return List.of("--httpPort=" + port); + } + } + @CheckForNull public static String checkResult(HttpURLConnection conn) throws IOException { int code = conn.getResponseCode(); @@ -919,7 +1055,7 @@ private void addTimeout() { if (proc != null) { LOGGER.warning("Test timeout expired, stopping steps…"); try { - endpoint("timeout").openStream().close(); + decorateConnection(endpoint("timeout").openConnection()).getInputStream().close(); } catch (IOException x) { x.printStackTrace(); } @@ -965,7 +1101,7 @@ public void stopJenkins() throws Throwable { proc = null; if (_proc.isAlive()) { try { - endpoint("exit").openStream().close(); + decorateConnection(endpoint("exit").openConnection()).getInputStream().close(); } catch (SocketException e) { System.err.println("Unable to connect to the Jenkins process to stop it: " + e); } @@ -995,7 +1131,7 @@ public void runRemotely(Step... steps) throws Throwable { @SuppressWarnings("unchecked") public T runRemotely(Step2 s) throws Throwable { - HttpURLConnection conn = (HttpURLConnection) endpoint("step").openConnection(); + HttpURLConnection conn = decorateConnection(endpoint("step").openConnection()); conn.setRequestProperty("Content-Type", "application/octet-stream"); conn.setDoOutput(true); @@ -1021,6 +1157,13 @@ public T runRemotely(Step2 s) throws Throwable { } } + private HttpURLConnection decorateConnection(@NonNull URLConnection urlConnection) { + if (sslSocketFactory != null) { + ((HttpsURLConnection) urlConnection).setSSLSocketFactory(sslSocketFactory); + } + return (HttpURLConnection) urlConnection; + } + @FunctionalInterface public interface StepWithOneArg extends Serializable { void run(JenkinsRule r, A1 arg1) throws Throwable; diff --git a/src/main/keystore/generate_self_signed_cert.sh b/src/main/keystore/generate_self_signed_cert.sh new file mode 100755 index 000000000..01f0f9dc2 --- /dev/null +++ b/src/main/keystore/generate_self_signed_cert.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/../.." + +tmpDir=$(mktemp -d) +trap 'rm -rf $tmpDir' EXIT + +cat > "$tmpDir/req.cnf" <