gresockj commented on a change in pull request #4991: URL: https://github.com/apache/nifi/pull/4991#discussion_r613155455
########## File path: nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-ssl-autoloading-utils/src/main/java/org/apache/nifi/autoload/SSLContextFactoryAutoLoaderTask.java ########## @@ -0,0 +1,270 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.nifi.autoload; + +import org.apache.nifi.security.util.KeyStoreUtils; +import org.apache.nifi.security.util.TlsException; +import org.apache.nifi.util.NiFiProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.nio.file.Path; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableEntryException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * The runnable task that polls the WatchService for updates to the keystore and truststore. + * + */ +public class SSLContextFactoryAutoLoaderTask implements Runnable { + + private static final Logger LOGGER = LoggerFactory.getLogger(SSLContextFactoryAutoLoaderTask.class); + + private static final int MIN_FILE_AGE = 5000; + + private final Path keystorePath; + private final Path truststorePath; + private final WatchService keystoreWatchService; + private final WatchService truststoreWatchService; + private final long pollIntervalMillis; + private final SSLContextFactoryAutoLoader autoLoader; + private final NiFiProperties nifiProperties; + private final List<File> candidateStores; + + private final List<PrivateKeyInfo> existingKeystoreState; + + private volatile boolean stopped = false; + + private SSLContextFactoryAutoLoaderTask(final Builder builder) throws NoSuchAlgorithmException, UnrecoverableEntryException, + KeyStoreException, TlsException { + this.keystorePath = builder.keystorePath; + this.truststorePath = builder.truststorePath; + this.keystoreWatchService = builder.keystoreWatchService; + this.truststoreWatchService = builder.truststoreWatchService; + this.pollIntervalMillis = builder.pollIntervalMillis; + this.autoLoader = builder.autoLoader; + this.nifiProperties = builder.niFiProperties; + this.existingKeystoreState = this.getKeystoreState(); + this.candidateStores = new ArrayList<>(); + } + + private boolean poll(WatchService watchService, Collection<Path> storePaths) { + if (storePaths == null || storePaths.isEmpty()) { + throw new RuntimeException("A polling directory must be specified."); + } + WatchKey key; + try { + key = watchService.poll(pollIntervalMillis, TimeUnit.MILLISECONDS); + } catch (InterruptedException x) { + LOGGER.info("WatchService interrupted, returning..."); + return false; + } + + boolean storeChanged = false; + + // Key comes back as null when there are no new create events, but we still want to continue processing + // so we can consider files added to the candidateNars list in previous iterations + + if (key != null) { + for (WatchEvent<?> event : key.pollEvents()) { + final WatchEvent.Kind<?> kind = event.kind(); + if (kind == StandardWatchEventKinds.OVERFLOW) { + continue; + } + + final WatchEvent<Path> ev = (WatchEvent<Path>) event; + final Path filename = ev.context(); + + for(Path storePath : storePaths) { + + final Path autoLoadFile = storePath.getParent().resolve(filename); + final String autoLoadFilename = autoLoadFile.toFile().getName(); + + if (!storePath.getFileName().toString().equals(autoLoadFilename)) { + continue; + } + + LOGGER.info("Found update to {}", new Object[]{autoLoadFilename}); + storeChanged = true; + } + } + + final boolean valid = key.reset(); + if (!valid) { + LOGGER.error("{} auto-refresh directory is no longer valid", new Object[] {storePaths.iterator().next()}); + stop(); + } + return storeChanged; + } + return false; + } + + @Override + public void run() { + Set<Path> bothPaths = new HashSet<>(Arrays.asList(keystorePath, truststorePath)); + while (!stopped) { + try { + boolean storeChanged = false; + // Can we poll the same directory for updates? + if (keystoreWatchService == truststoreWatchService) { + LOGGER.debug("Polling for keystore updates at {} and truststore updates at {}", new Object[]{keystorePath, truststorePath}); + storeChanged = this.poll(keystoreWatchService, bothPaths); + } else { + // Otherwise, poll separate directories + LOGGER.debug("Polling for keystore updates at {}", new Object[]{keystorePath}); + storeChanged = this.poll(keystoreWatchService, Arrays.asList(keystorePath)); + + LOGGER.debug("Polling for truststore updates at {}", new Object[]{truststorePath}); + storeChanged |= this.poll(truststoreWatchService, Arrays.asList(truststorePath)); + } + + if (storeChanged) { + if (this.isReloadAllowed()) { + autoLoader.getSslContextFactoryReloadable().reloadSSLContextFactory(); + } else { + LOGGER.warn("For security reasons, the SSL Context Factory could not be reloaded because the " + + "keystore {} changed in a way that is disallowed.", new Object[] {keystorePath}); + } + } + + } catch (final Throwable t) { + LOGGER.error("Error reloading SSL context factory due to: " + t.getMessage(), t); + } + } + } + + /** + * Returns a list representing the state of the current keystore at the given path. This method uses the + * keystore properties from nifi.properties. The only state retrieved will be the alias, subject DN, + * issuer DN of each PrivateKeyEntry, and issuer certificate serial number if applicable, and the results + * will be a sorted list. + * @return A sorted list of information about each private key in the keystore + * @throws TlsException If the keystore could not be loaded + * @throws KeyStoreException If the keystore password was incorrect + * @throws UnrecoverableEntryException If a private key entry could not be recovered + * @throws NoSuchAlgorithmException If the default password algorithm is not supported + */ + private List<PrivateKeyInfo> getKeystoreState() throws TlsException, KeyStoreException, + UnrecoverableEntryException, NoSuchAlgorithmException { + List<PrivateKeyInfo> state = new ArrayList<>(); + + KeyStore keyStore = KeyStoreUtils.loadKeyStore(keystorePath.toString(), + nifiProperties.getProperty(NiFiProperties.SECURITY_KEYSTORE_PASSWD).toCharArray(), + nifiProperties.getProperty(NiFiProperties.SECURITY_KEYSTORE_TYPE)); + + Enumeration<String> aliasesEnum = keyStore.aliases(); + while(aliasesEnum.hasMoreElements()) { + String alias = aliasesEnum.nextElement(); + if (keyStore.isKeyEntry(alias)) { + KeyStore.PrivateKeyEntry entry = (KeyStore.PrivateKeyEntry) keyStore.getEntry(alias, new KeyStore.PasswordProtection(nifiProperties + .getProperty(NiFiProperties.SECURITY_KEY_PASSWD).toCharArray())); + X509Certificate cert = (X509Certificate) entry.getCertificateChain()[0]; + String issuerSerialNumber = (entry.getCertificateChain().length > 1) + ? ((X509Certificate) entry.getCertificateChain()[1]).getSerialNumber().toString() + : null; + state.add(new PrivateKeyInfo(cert.getSubjectDN().getName(), cert.getIssuerDN().getName(), alias, issuerSerialNumber)); + } + } + + Collections.sort(state); + return new ArrayList<>(state); + } + + /** + * This returns false if there were any changes to the keystore other than updating a PrivateKeyEntry with + * the same subject DN, issuer DN, alias, and issuer cert serial number if applicable. + * @return True if a reload should be allowed, meaning the keystore has not changed in a meaningful way + */ + boolean isReloadAllowed() throws NoSuchAlgorithmException, UnrecoverableEntryException, KeyStoreException, TlsException { + return existingKeystoreState.equals(this.getKeystoreState()); + } + + public void stop() { + LOGGER.info("Stopping SSL Context Factory Auto-loader"); + stopped = true; + } Review comment: I like it, and this enabled me to write some additional unit tests. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. For queries about this service, please contact Infrastructure at: us...@infra.apache.org