diff --git a/http-server/src/main/java/com/facebook/airlift/http/server/HttpServer.java b/http-server/src/main/java/com/facebook/airlift/http/server/HttpServer.java index db14e329c7..b1af834ed8 100644 --- a/http-server/src/main/java/com/facebook/airlift/http/server/HttpServer.java +++ b/http-server/src/main/java/com/facebook/airlift/http/server/HttpServer.java @@ -52,6 +52,7 @@ import org.eclipse.jetty.server.handler.ContextHandlerCollection; import org.eclipse.jetty.server.handler.StatisticsHandler; import org.eclipse.jetty.server.handler.gzip.GzipHandler; +import org.eclipse.jetty.util.ssl.KeyStoreScanner; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.eclipse.jetty.util.thread.Scheduler; @@ -245,6 +246,9 @@ public HttpServer(HttpServerInfo httpServerInfo, if (config.getKeyManagerPassword() != null) { sslContextFactory.setKeyManagerPassword(config.getKeyManagerPassword()); } + KeyStoreScanner keyStoreScanner = new KeyStoreScanner(sslContextFactory); + keyStoreScanner.setScanInterval(config.getKeystoreScanIntervalSeconds()); + server.addBean(keyStoreScanner); } if (config.getTrustStorePath() != null) { Optional pemTrustStore = tryLoadPemTrustStore(config); @@ -329,6 +333,9 @@ public HttpServer(HttpServerInfo httpServerInfo, -1, sslConnectionFactory, new HttpConnectionFactory(adminConfiguration)); + KeyStoreScanner keyStoreScanner = new KeyStoreScanner(sslContextFactory); + keyStoreScanner.setScanInterval(config.getKeystoreScanIntervalSeconds()); + adminConnector.addBean(keyStoreScanner); } else { HttpConnectionFactory http1 = new HttpConnectionFactory(adminConfiguration); diff --git a/http-server/src/main/java/com/facebook/airlift/http/server/HttpServerConfig.java b/http-server/src/main/java/com/facebook/airlift/http/server/HttpServerConfig.java index 8bdce2788f..f421e7b27c 100644 --- a/http-server/src/main/java/com/facebook/airlift/http/server/HttpServerConfig.java +++ b/http-server/src/main/java/com/facebook/airlift/http/server/HttpServerConfig.java @@ -136,6 +136,8 @@ public enum AuthorizationPolicy private Set defaultAllowedRoles = ImmutableSet.of(); private boolean allowUnsecureRequestsInAuthorizer; + private int keyStoreScanIntervalSeconds; + public boolean isHttpEnabled() { return httpEnabled; @@ -823,4 +825,17 @@ public List getHttpComplianceViolations() { return httpComplianceViolations; } + + @Config("http-server.https.keystore.scan-interval-seconds") + @ConfigDescription("Interval (in seconds) to check the keystore file for changes") + public HttpServerConfig setKeystoreScanIntervalSeconds(int keyStoreScanIntervalSeconds) + { + this.keyStoreScanIntervalSeconds = keyStoreScanIntervalSeconds; + return this; + } + + public int getKeystoreScanIntervalSeconds() + { + return keyStoreScanIntervalSeconds; + } } diff --git a/http-server/src/test/java/com/facebook/airlift/http/server/TestHttpServerConfig.java b/http-server/src/test/java/com/facebook/airlift/http/server/TestHttpServerConfig.java index 2484cf5919..2509e895d4 100644 --- a/http-server/src/test/java/com/facebook/airlift/http/server/TestHttpServerConfig.java +++ b/http-server/src/test/java/com/facebook/airlift/http/server/TestHttpServerConfig.java @@ -90,7 +90,8 @@ public void testDefaults() .setAllowUnsecureRequestsInAuthorizer(false) .setSniHostCheck(true) .setUriComplianceMode(UriCompliance.DEFAULT) - .setHttpComplianceViolations("")); + .setHttpComplianceViolations("") + .setKeystoreScanIntervalSeconds(0)); } @Test @@ -149,6 +150,7 @@ public void testExplicitPropertyMappings() .put("http-server.https.sni-host-check", "false") .put("http-server.uri-compliance.mode", "LEGACY") .put("http-server.http-compliance.violations", "UNSAFE_HOST_HEADER,MISMATCHED_AUTHORITY") + .put("http-server.https.keystore.scan-interval-seconds", "2") .build(); HttpServerConfig expected = new HttpServerConfig() @@ -203,7 +205,8 @@ public void testExplicitPropertyMappings() .setAllowUnsecureRequestsInAuthorizer(true) .setSniHostCheck(false) .setUriComplianceMode(UriCompliance.LEGACY) - .setHttpComplianceViolations("UNSAFE_HOST_HEADER,MISMATCHED_AUTHORITY"); + .setHttpComplianceViolations("UNSAFE_HOST_HEADER,MISMATCHED_AUTHORITY") + .setKeystoreScanIntervalSeconds(2); ConfigAssertions.assertFullMapping(properties, expected); } diff --git a/http-server/src/test/java/com/facebook/airlift/http/server/TestHttpServerProvider.java b/http-server/src/test/java/com/facebook/airlift/http/server/TestHttpServerProvider.java index 1c4bc9b045..106a6e92b3 100644 --- a/http-server/src/test/java/com/facebook/airlift/http/server/TestHttpServerProvider.java +++ b/http-server/src/test/java/com/facebook/airlift/http/server/TestHttpServerProvider.java @@ -35,13 +35,23 @@ import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.BeforeSuite; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + import java.io.File; import java.io.IOException; import java.io.UncheckedIOException; import java.net.URI; +import java.nio.file.Files; import java.nio.file.Path; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; import java.security.cert.X509Certificate; import java.util.Base64; import java.util.concurrent.ExecutionException; @@ -60,6 +70,7 @@ import static com.google.common.io.Resources.getResource; import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.file.Files.createTempDirectory; +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertNotNull; @@ -76,6 +87,34 @@ public class TestHttpServerProvider private HttpServerConfig config; private HttpServerInfo httpServerInfo; + @DataProvider + public static Object[][] serverAdminMode() + { + return new Object[][] {{false}, {true}}; + } + + private static HttpServlet createCertTestServlet() + { + return new HttpServlet() + { + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws IOException + { + X509Certificate[] certs = (X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate"); + if ((certs == null) || (certs.length == 0)) { + throw new RuntimeException("No client certificate"); + } + if (certs.length > 1) { + throw new RuntimeException("Received multiple client certificates"); + } + X509Certificate cert = certs[0]; + response.getWriter().write(cert.getSubjectX500Principal().getName()); + response.setStatus(HttpServletResponse.SC_OK); + } + }; + } + @BeforeSuite public void setupSuite() { @@ -339,6 +378,50 @@ public void testClientCertificateJava() } } + @Test(dataProvider = "serverAdminMode") + public void testKeystoreUpdate(boolean serverAdminMode) + throws Exception + { + tempDir = createTempDirectory("test-keystore").toFile().getCanonicalFile(); + Path tempKeyStore = tempDir.toPath().resolve("server.keystore").toAbsolutePath(); + Path originalKeyStore = Path.of(getResource("clientcert-java/server.keystore").getPath()); + Path updatedKeyStore = Path.of(getResource("clientcert-java/updatedServer.keystore").getPath()); + + // Start the server with the original keystore + Files.copy(originalKeyStore, tempKeyStore, REPLACE_EXISTING); + + int keyStoreScanIntervalSeconds = 1; + config.setHttpEnabled(false) + .setAdminEnabled(serverAdminMode) + .setHttpsEnabled(true) + .setHttpsPort(0) + .setKeystorePath(tempKeyStore.toString()) + .setKeystorePassword("airlift") + .setKeystoreScanIntervalSeconds(keyStoreScanIntervalSeconds); + + createAndStartServer(); + + // Original certificate subject is returned + assertEquals(getServerCertSubject(), "CN=localhost,OU=Server,O=Airlift,L=Palo Alto,ST=CA,C=US"); + + // Replace the keystore with the replacement keystore + Files.copy(updatedKeyStore, tempKeyStore, REPLACE_EXISTING); + + Thread.sleep(keyStoreScanIntervalSeconds * 1000L); // wait for the first scan to complete + // Poll every 100ms up to 2 seconds for the updated keystore to be picked up + for (int i = 0; i < 20; i++) { + try { + // Updated certificate subject is returned + assertEquals(getServerCertSubject(), "CN=localhost,OU=Server,O=updated,L=Palo Alto,ST=CA,C=US"); + return; + } + catch (AssertionError e) { + Thread.sleep(100); + } + } + fail("Updated keystore was not picked up within expected time"); + } + @Test public void testClientCertificatePem() throws Exception @@ -373,28 +456,6 @@ private void assertClientCertificateRequest(HttpClientConfig clientConfig) } } - private static HttpServlet createCertTestServlet() - { - return new HttpServlet() - { - @Override - protected void doGet(HttpServletRequest request, HttpServletResponse response) - throws IOException - { - X509Certificate[] certs = (X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate"); - if ((certs == null) || (certs.length == 0)) { - throw new RuntimeException("No client certificate"); - } - if (certs.length > 1) { - throw new RuntimeException("Received multiple client certificates"); - } - X509Certificate cert = certs[0]; - response.getWriter().write(cert.getSubjectX500Principal().getName()); - response.setStatus(HttpServletResponse.SC_OK); - } - }; - } - @Test public void testShowStackTraceEnabled() throws Exception @@ -529,6 +590,40 @@ private void createAndStartServer() createAndStartServer(new DummyServlet()); } + private String getServerCertSubject() + throws NoSuchAlgorithmException, KeyManagementException, IOException + { + SSLContext trustAllSSLContext = SSLContext.getInstance("TLS"); + trustAllSSLContext.init(null, new TrustManager[] {new X509TrustManager() + { + public void checkClientTrusted(X509Certificate[] certs, String authType) + { + } + + public void checkServerTrusted(X509Certificate[] certs, String authType) + { + } + + public X509Certificate[] getAcceptedIssuers() + { + return null; + } + }}, new SecureRandom()); + + URI uri = httpServerInfo.getHttpsUri(); + HttpsURLConnection conn = (HttpsURLConnection) uri.toURL().openConnection(); + conn.setSSLSocketFactory(trustAllSSLContext.getSocketFactory()); + conn.connect(); + + X509Certificate[] certs = (X509Certificate[]) conn.getServerCertificates(); + assertTrue(certs.length > 0, String.format("No certificates were obtained for URI: %s", uri)); + // Return the leaf certificate subject + String subject = certs[0].getSubjectX500Principal().getName(); + conn.disconnect(); + + return subject; + } + private void createAndStartServer(HttpServlet servlet) throws Exception { diff --git a/http-server/src/test/resources/clientcert-java/clientcert.sh b/http-server/src/test/resources/clientcert-java/clientcert.sh index 43263c8224..88d42b6802 100755 --- a/http-server/src/test/resources/clientcert-java/clientcert.sh +++ b/http-server/src/test/resources/clientcert-java/clientcert.sh @@ -26,3 +26,13 @@ openssl pkcs12 -name client -inkey client.key -in client.crt -export -passout pa # create client truststore keytool -import -noprompt -alias ca -file ca.crt -storetype pkcs12 -storepass airlift -keystore client.truststore + +## We also create a server kesytore for testing the KeyStoreScanner + +# create updated server cert +openssl req -new -key server.key -subj "/C=US/ST=CA/L=Palo Alto/O=updated/OU=Server/CN=localhost" -out updatedServer.csr +openssl x509 -req -days 9999 -in updatedServer.csr -CA ca.crt -CAkey ca.key -set_serial 01 -out updatedServer.crt + +# create updated server keystore +openssl pkcs12 -name server -inkey server.key -in updatedServer.crt -export -passout pass:airlift -out updatedServer.keystore +keytool -import -noprompt -alias ca -file ca.crt -storetype pkcs12 -storepass airlift -keystore updatedServer.keystore \ No newline at end of file diff --git a/http-server/src/test/resources/clientcert-java/updatedServer.keystore b/http-server/src/test/resources/clientcert-java/updatedServer.keystore new file mode 100644 index 0000000000..7faebf0976 Binary files /dev/null and b/http-server/src/test/resources/clientcert-java/updatedServer.keystore differ