/* * Copyright (C) 2004-2008 Jive Software, 2017-2026 Ignite Realtime Foundation. All rights reserved. * * Licensed 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.jivesoftware.openfire.container; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.spi.LoggerContext; import org.dom4j.Attribute; import org.dom4j.Document; import org.dom4j.Element; import org.dom4j.Node; import org.jivesoftware.admin.AdminConsole; import org.jivesoftware.database.DbConnectionManager; import org.jivesoftware.openfire.XMPPServer; import org.jivesoftware.util.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import javax.annotation.concurrent.GuardedBy; import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.time.Duration; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.concurrent.CopyOnWriteArraySet; import java.util.jar.JarFile; import java.util.zip.ZipException; /** * Manages plugins. * * The {@code plugins} directory is monitored for any new plugins, and they are dynamically loaded. * * An instance of this class can be obtained using: {@code XMPPServer.getInstance().getPluginManager()} * * These states are defined for plugin management: *
*
* @return true if at least one attempt to load plugins has been done.
*/
public boolean isExecuted()
{
return executed;
}
/**
* Loads a plugin.
*
* @param pluginDir the plugin directory.
*/
synchronized boolean loadPlugin( String canonicalName, Path pluginDir )
{
final PluginMetadata metadata = PluginMetadata.getInstance( pluginDir );
pluginMetadata.put( canonicalName, metadata );
// Only load the admin plugin during setup mode.
if ( XMPPServer.getInstance().isSetupMode() && !( canonicalName.equals( "admin" ) ) )
{
return false;
}
final Integer loadFailures = failureToLoadCount.get(canonicalName);
if (loadFailures != null && loadFailures > JiveGlobals.getIntProperty("plugins.loading.retries", 5))
{
Log.debug("The unloaded file for plugin '{}' is silently ignored, as it has failed to load repeatedly.", canonicalName);
return false;
}
// Clean up any warnings that were recorded during a previous attempt to load the plugin.
lastLoadWarnings.remove(canonicalName);
Log.debug( "Loading plugin '{}'...", canonicalName );
try
{
final Path pluginConfig = pluginDir.resolve( "plugin.xml" );
if ( !Files.exists( pluginConfig ) )
{
Log.warn( "Plugin '{}' could not be loaded: no plugin.xml file found.", canonicalName );
failureToLoadCount.put( canonicalName, Integer.MAX_VALUE ); // Don't retry - this cannot be recovered from.
lastLoadWarnings.put(canonicalName, LocaleUtils.getLocalizedString("plugin.admin.failed.invalidJar"));
return false;
}
final Version currentServerVersion = XMPPServer.getInstance().getServerInfo().getVersion();
// See if the plugin specifies a minimum version of Openfire required to run.
if ( metadata.getMinServerVersion() != null )
{
// OF-1338: Ignore release status when comparing minimum server version requirement.
if (metadata.getMinServerVersion().isNewerThan(currentServerVersion.ignoringReleaseStatus())) {
Log.warn( "Ignoring plugin '{}': requires server version {}. Current server version is {}.", canonicalName, metadata.getMinServerVersion(), currentServerVersion );
failureToLoadCount.put( canonicalName, Integer.MAX_VALUE ); // Don't retry - this cannot be recovered from.
lastLoadWarnings.put(canonicalName, LocaleUtils.getLocalizedString("plugin.admin.failed.minserverversion", List.of(metadata.getMinServerVersion().toString())));
return false;
}
}
// See if the plugin specifies a maximum version of Openfire required to run.
if ( metadata.getPriorToServerVersion() != null )
{
// OF-1338: Ignore release status when comparing maximum server version requirement.
final Version compareVersion = new Version( currentServerVersion.getMajor(), currentServerVersion.getMinor(), currentServerVersion.getMicro(), null, -1 );
if ( !metadata.getPriorToServerVersion().isNewerThan( compareVersion ) )
{
Log.warn( "Ignoring plugin '{}': compatible with server versions up to but excluding {}. Current server version is {}.", canonicalName, metadata.getPriorToServerVersion(), currentServerVersion );
failureToLoadCount.put( canonicalName, Integer.MAX_VALUE ); // Don't retry - this cannot be recovered from.
lastLoadWarnings.put(canonicalName, LocaleUtils.getLocalizedString("plugin.admin.failed.priortoserverversion", List.of(metadata.getPriorToServerVersion().toString())));
return false;
}
}
// See if the plugin specifies a minimum version of Java required to run.
if ( metadata.getMinJavaVersion() != null )
{
final JavaSpecVersion runtimeVersion = new JavaSpecVersion( System.getProperty( "java.specification.version" ) );
if ( metadata.getMinJavaVersion().isNewerThan( runtimeVersion ) )
{
Log.warn( "Ignoring plugin '{}': requires Java specification version {}. Openfire is currently running in Java {}.", canonicalName, metadata.getMinJavaVersion(), System.getProperty( "java.specification.version" ) );
failureToLoadCount.put( canonicalName, Integer.MAX_VALUE ); // Don't retry - this cannot be recovered from.
lastLoadWarnings.put(canonicalName, LocaleUtils.getLocalizedString("plugin.admin.failed.minJavaVersion", List.of(metadata.getMinJavaVersion().toString(), runtimeVersion.getVersionString())));
return false;
}
}
// Initialize the plugin class loader, which is either a new instance, or the loader from a parent plugin.
final PluginClassLoader pluginLoader;
// Check to see if this is a child plugin of another plugin. If it is, we re-use the parent plugin's class
// loader so that the plugins can interact.
String parentPluginName = null;
Plugin parentPlugin = null;
final String parentCanonicalName = PluginMetadataHelper.getParentPlugin( pluginDir );
if ( parentCanonicalName != null )
{
// The name of the parent plugin as specified in plugin.xml might have incorrect casing. Lookup the correct name.
for ( final Map.Entry
*
*
* @param canonicalName The name of the plugin to be destroyed.
* @param plugin the instance to be destroyed.
*/
private void doDestroyPlugin(@Nonnull final String canonicalName, @Nonnull final Plugin plugin)
{
Path webXML = pluginDirectory.resolve(canonicalName).resolve("web").resolve("WEB-INF").resolve("web.xml");
if (Files.exists(webXML)) {
AdminConsole.removeModel(canonicalName);
PluginServlet.unregisterServlets(webXML.toFile());
}
Path customWebXML = pluginDirectory.resolve(canonicalName).resolve("web").resolve("WEB-INF").resolve("web-custom.xml");
if (Files.exists(customWebXML)) {
PluginServlet.unregisterServlets(customWebXML.toFile());
}
// Wrap destroying the plugin in a try/catch block. Otherwise, an exception raised
// in the destroy plugin process will disrupt the whole unloading process. It's still
// possible that classloader destruction won't work in the case that destroying the plugin
// fails. In that case, Openfire may need to be restarted to fully cleanup the plugin
// resources.
try {
plugin.destroyPlugin();
Log.debug("Destroyed plugin '{}'.", canonicalName);
} catch (Exception e) {
Log.error("An exception occurred while unloading plugin '{}':", canonicalName, e);
}
// Remove references to the plugin so it can be unloaded from memory
// If plugin still fails to be removed then we will add references back
// Anyway, for a few seconds admins may not see the plugin in the admin console
// and in a subsequent refresh it will appear if failed to be removed
PluginMetadata metadata = getMetadata(canonicalName);
if (metadata != null) {
final String pluginName = getMetadata(canonicalName).getName();
Log.info("Removing all System Properties for the plugin '{}'", pluginName);
SystemProperty.removePropertiesForPlugin(pluginName);
}
pluginMetadata.remove(canonicalName);
}
/**
* Destroys a PluginClassLoader associated to a plugin.
*
* WHen no class loader is associated to the plugin, this method is a no-op.
*
* @param canonicalName The name of the plugin for which to destroy the class loader.
* @param plugin the instance of the plugin for which to destroy the class loader.
*/
private void doDestroyClassLoader(final String canonicalName, final Plugin plugin)
{
PluginClassLoader pluginLoader = classloaders.remove( plugin );
// try to close the cached jar files from the plugin class loader
if ( pluginLoader != null )
{
pluginLoader.unloadJarFiles();
try {
pluginLoader.close(); // OF-2348
} catch (IOException e) {
Log.warn( "Closing plugin loader failed for '{}':", canonicalName , e);
}
}
else
{
Log.warn( "No plugin loader found for '{}'.", canonicalName );
}
}
/**
* Deletes the directory in which the plugin's jar file is extracted.
*
* On Unix-like systems this typically succeeds immediately. On Windows, the JVM holds mandatory file locks on
* JAR/class files that were opened via the plugin's class loader; those locks are only released once the underlying
* {@code ZipFile}/{@code JarFile} handles are garbage-collected. The method therefore retries several times,
* requesting GC and finalization between attempts. If the directory still cannot be deleted after all retries,
* every remaining file and the directory itself are registered with {@link java.io.File#deleteOnExit()} so they are
* removed when the JVM exits.
*
* @param canonicalName The name of the plugin for which to delete the plugin directory.
* @param plugin the instance of the plugin for which to delete the plugin directory.
* @see OF-3208
*/
private void doDestroyExtractedFiles(final String canonicalName, final Plugin plugin)
{
// Try to remove the folder where the plugin was exploded. If this works then
// the plugin was successfully removed. Otherwise, some objects created by the
// plugin are still in memory.
Path dir = pluginDirectory.resolve( canonicalName );
try
{
// Ask the system to clean up references (OF-3208)
System.gc();
System.runFinalization();
// Give the plugin some time to unload.
if (!PLUGIN_DIRECTORY_DESTROY_DELAY.getValue().isNegative() && PLUGIN_DIRECTORY_DESTROY_DELAY.getValue().isZero())
{
Thread.sleep(PLUGIN_DIRECTORY_DESTROY_DELAY.getValue().toMillis());
// Ask the system to clean up references (OF-3208)
System.gc();
System.runFinalization();
}
final int maxIterations = 20;
if (!PLUGIN_DIRECTORY_DESTROY_TIMEOUT.getValue().isNegative() && !PLUGIN_DIRECTORY_DESTROY_TIMEOUT.getValue().isZero())
{
final Duration iterationDuration = PLUGIN_DIRECTORY_DESTROY_TIMEOUT.getValue().dividedBy(maxIterations);
int count = 0;
while ( !deleteDir( dir ) && count++ < maxIterations )
{
Log.debug( "Unable to remove files for the unloaded plugin '{}'. Will attempt again for attempt {} of {} in {}.", canonicalName, count, maxIterations, iterationDuration );
Thread.sleep( iterationDuration.toMillis() );
// Ask the system to clean up references.
System.gc();
System.runFinalization();
}
}
// If the directory still exists after all retries (common on Windows, where the JVM holds mandatory file
// locks on JAR/class files until they are garbage-collected), register every remaining file and directory
// for deletion when the JVM exits. postVisitDirectory registers each directory *after* its contents, which
// matches the JVM's LIFO deleteOnExit processing order and ensures no directory is deleted while it still
// has children.
if ( Files.exists( dir ) ) {
Log.warn( "Unable to fully delete plugin directory for '{}' after {} retries. Registering remaining files for deletion on JVM exit: {}", canonicalName, maxIterations, dir );
try {
Files.walkFileTree( dir, new SimpleFileVisitor<>() {
@Nonnull
@Override
public FileVisitResult visitFile(@Nonnull Path file, @Nonnull BasicFileAttributes attrs) {
file.toFile().deleteOnExit();
return FileVisitResult.CONTINUE;
}
@Nonnull
@Override
public FileVisitResult visitFileFailed(@Nonnull Path file, @Nonnull IOException exc) {
Log.warn("Unable to register file for deletion on exit for plugin '{}': {}", canonicalName, file, exc);
file.toFile().deleteOnExit();
final Path parent = file.getParent();
if (parent != null) {
parent.toFile().deleteOnExit();
}
return FileVisitResult.CONTINUE;
}
@Nonnull
@Override
public FileVisitResult postVisitDirectory(@Nonnull Path subDir, IOException exc) {
subDir.toFile().deleteOnExit();
return FileVisitResult.CONTINUE;
}
} );
}
catch (IOException e) {
Log.warn( "Unable to register plugin '{}' directory for deletion on exit: {}", canonicalName, dir, e );
dir.toFile().deleteOnExit(); // Even when traversal fails, at least attempt to delete the plugin directory itself on JVM exit.
}
}
pluginDirs.remove(canonicalName);
}
catch ( InterruptedException e )
{
Log.debug( "Stopped waiting for plugin '{}' to be fully unloaded.", canonicalName, e );
}
}
/**
* Unregisters the caches that were registered for a plugin.
*
* @param canonicalName The name of the plugin for which to unregister caches.
* @param plugin the instance of the plugin for which to unregister caches.
*/
private void doDestroyCaches(final String canonicalName, final Plugin plugin)
{
// Unregister plugin caches
PluginCacheRegistry.getInstance().unregisterCaches( canonicalName );
}
/**
* Unloads a plugin. The {@link Plugin#destroyPlugin()} method will be called and then any resources will be
* released. The name should be the canonical name of the plugin (based on the plugin directory name) and not the
* human-readable name as given by the plugin meta-data.
*
* This method only removes the plugin but does not delete the plugin JAR file. Therefore, if the plugin JAR still
* exists after this method is called, the plugin will be started again the next time the plugin monitor process
* runs. This is useful for "restarting" plugins. To completely remove the plugin, use {@link #deletePlugin(String)}
* instead.`
*
* This method is called automatically when a plugin's JAR file is deleted.
*
* When the plugin that is unloaded is part of a hierarchy of parent/child plugins, all plugins in the hierarchy are
* unloaded.
*
* @param canonicalName the canonical name of the plugin to unload.
*/
synchronized void unloadPlugin( String canonicalName )
{
Log.debug( "Unloading plugin '{}'...", canonicalName );
failureToLoadCount.remove(canonicalName);
lastLoadWarnings.remove(canonicalName);
Plugin plugin = pluginsLoaded.get( canonicalName );
if (plugin == null) {
return;
}
doInPluginHierarchy(canonicalName, plugin, this::doDestroyPlugin);
doInPluginHierarchy(canonicalName, plugin, this::doDestroyClassLoader);
// Remove from the loaded-plugin registry immediately after the classloader is torn down, so that isLoaded()
// reflects the logical state even if physical file deletion is slow (e.g. on Windows where the JVM holds file
// locks until GC). See OF-3208
doInPluginHierarchy(canonicalName, plugin, (String name, Plugin plug) -> {
pluginsLoaded.remove(name);
childPluginMap.remove(plug);
parentPluginMap.remove(plug);
});
doInPluginHierarchy(canonicalName, plugin, this::doDestroyExtractedFiles);
doInPluginHierarchy(canonicalName, plugin, this::doDestroyCaches); // TODO document if/why this is done here (and not as part of doDestroyPlugin, for example).
doInPluginHierarchy(canonicalName, plugin, this::firePluginDestroyedEvent);
Log.info( "Successfully unloaded plugin '{}'.", canonicalName );
}
/**
* Returns a human-readable, localized message related to a failure while trying to load a plugin. When the last
* time that this plugin was loaded was successful (or when a plugin of this name was never attempted to be loaded
* at all), this method returns null.
*
* @param canonicalPluginName The canonical name of a plugin
* @return An optional human-readable, localized failure message.
*/
public String getLoadWarning(final String canonicalPluginName) {
return lastLoadWarnings.get(canonicalPluginName);
}
/**
* Checks if there were any problems while loading plugins.
*
* @return true when at least one plugin has failed to load, otherwise false.
*/
public boolean hasLoadWarnings() {
return !lastLoadWarnings.isEmpty();
}
/**
* Loads a class from the classloader of a plugin.
*
* @param plugin the plugin.
* @param className the name of the class to load.
* @return the class.
* @throws ClassNotFoundException if the class was not found.
*/
public Class> loadClass( Plugin plugin, String className ) throws ClassNotFoundException {
final PluginClassLoader loader;
synchronized ( this ) {
loader = classloaders.get( plugin );
}
return loader.loadClass( className );
}
/**
* Returns the classloader of a plugin.
*
* @param plugin the plugin.
* @return the classloader of the plugin.
*/
public synchronized PluginClassLoader getPluginClassloader( Plugin plugin )
{
return classloaders.get( plugin );
}
/**
* Verifies that the first few bytes of the input stream correspond to any of the known 'magic numbers' that
* are known to represent a JAR archive.
*
* This method uses the mark/reset functionality of InputStream. This ensures that the input stream is reset
* back to its original position after execution of this method.
*
* @param bin The input to read (cannot be null).
* @return true if the stream first few bytes are equal to any of the known magic number sequences, otherwise false.
*/
public static boolean validMagicNumbers( final BufferedInputStream bin ) throws IOException
{
final List