package net.i2p.router.web; import java.io.File; import java.io.IOException; import java.lang.reflect.Method; import java.net.URL; import java.net.URLClassLoader; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.StringTokenizer; import java.util.concurrent.ConcurrentHashMap; import net.i2p.CoreVersion; import net.i2p.I2PAppContext; import net.i2p.app.ClientApp; import net.i2p.app.ClientAppState; import net.i2p.data.DataHelper; import net.i2p.data.Base64; import net.i2p.router.RouterContext; import net.i2p.router.RouterVersion; import net.i2p.router.startup.ClientAppConfig; import net.i2p.router.startup.LoadClientAppsJob; import net.i2p.router.update.ConsoleUpdateManager; import static net.i2p.update.UpdateType.*; import net.i2p.util.ConcurrentHashSet; import net.i2p.util.FileUtil; import net.i2p.util.I2PAppThread; import net.i2p.util.Log; import net.i2p.util.PortMapper; import net.i2p.util.SimpleTimer2; import net.i2p.util.Translate; import net.i2p.util.VersionComparator; import org.eclipse.jetty.server.handler.ContextHandlerCollection; /** * Start/stop/delete plugins that are already installed * Get properties of installed plugins * Get or change settings in plugins.config * * @since 0.7.12 * @author zzz */ public class PluginStarter implements Runnable { protected RouterContext _context; private static final String CONFIG_FILE = "plugins.config"; public static final String PREFIX = "plugin."; // false, true, or deleted public static final String ENABLED = ".startOnLoad"; public static final String DELETED = "deleted"; public static final String PLUGIN_DIR = "plugins"; private static final String[] STANDARD_WEBAPPS = { "i2psnark", "i2ptunnel", "susidns", "susimail", "addressbook", "routerconsole" }; private static final String[] STANDARD_THEMES = { "images", "light", "dark", "classic", "midnight" }; private static Map<String, ThreadGroup> pluginThreadGroups = new ConcurrentHashMap<String, ThreadGroup>(); // one thread group per plugin (map key=plugin name) private static Map<String, Collection<SimpleTimer2.TimedEvent>> _pendingPluginClients = new ConcurrentHashMap<String, Collection<SimpleTimer2.TimedEvent>>(); private static Map<String, ClassLoader> _clCache = new ConcurrentHashMap<String, ClassLoader>(); private static Map<String, Collection<String>> pluginWars = new ConcurrentHashMap<String, Collection<String>>(); /** * Plugin name to plugin version of plugins that do not work * with Jetty 9, but do not have a max-jetty-version=8.9999 set. * Unmodifiable. * * @since 0.9.30 */ public static final Map<String, String> jetty9Blacklist; static { Map<String, String> map = new HashMap<String, String>(4); map.put("i2pbote", "0.4.5"); map.put("BwSchedule", "0.0.36"); jetty9Blacklist = Collections.unmodifiableMap(map); } public PluginStarter(RouterContext ctx) { _context = ctx; } static boolean pluginsEnabled(I2PAppContext ctx) { return ctx.getBooleanPropertyDefaultTrue("router.enablePlugins"); } public void run() { deferredDeletePlugins(_context); if (_context.getBooleanPropertyDefaultTrue("plugins.autoUpdate") && !NewsHelper.isUpdateInProgress()) { String prev = _context.getProperty("router.previousVersion"); if (prev != null && VersionComparator.comp(RouterVersion.VERSION, prev) > 0) { updateAll(_context, true); } } startPlugins(_context); } /** * threaded * @since 0.8.13 */ static void updateAll(RouterContext ctx) { Thread t = new I2PAppThread(new PluginUpdater(ctx), "PluginUpdater", true); t.start(); } /** * thread * @since 0.8.13 */ private static class PluginUpdater implements Runnable { private final RouterContext _ctx; public PluginUpdater(RouterContext ctx) { _ctx = ctx; } public void run() { updateAll(_ctx, false); } } /** * inline * @since 0.8.13 */ private static void updateAll(RouterContext ctx, boolean delay) { List<String> plugins = getPlugins(); Map<String, String> toUpdate = new HashMap<String, String>(); for (String appName : plugins) { Properties props = pluginProperties(ctx, appName); String url = props.getProperty("updateURL"); if (url != null) toUpdate.put(appName, url); } if (toUpdate.isEmpty()) return; ConsoleUpdateManager mgr = UpdateHandler.updateManager(ctx); if (mgr == null) return; if (mgr.isUpdateInProgress()) return; if (delay) { // wait for proxy mgr.update(TYPE_DUMMY, 3*60*1000); mgr.notifyProgress(null, Messages.getString("Checking for plugin updates", ctx)); int loop = 0; do { try { Thread.sleep(5*1000); } catch (InterruptedException ie) {} if (loop++ > 40) break; } while (mgr.isUpdateInProgress(TYPE_DUMMY)); } String proxyHost = ctx.getProperty(ConfigUpdateHandler.PROP_PROXY_HOST, ConfigUpdateHandler.DEFAULT_PROXY_HOST); int proxyPort = ConfigUpdateHandler.proxyPort(ctx); if (proxyPort == ConfigUpdateHandler.DEFAULT_PROXY_PORT_INT && proxyHost.equals(ConfigUpdateHandler.DEFAULT_PROXY_HOST) && ctx.portMapper().getPort(PortMapper.SVC_HTTP_PROXY) < 0) { mgr.notifyComplete(null, Messages.getString("Plugin update check failed", ctx) + " - " + Messages.getString("HTTP client proxy tunnel must be running", ctx)); return; } if (ctx.commSystem().isDummy()) { mgr.notifyComplete(null, Messages.getString("Plugin update check failed", ctx) + " - " + "VM Comm System"); return; } Log log = ctx.logManager().getLog(PluginStarter.class); int updated = 0; for (Map.Entry<String, String> entry : toUpdate.entrySet()) { String appName = entry.getKey(); if (log.shouldLog(Log.WARN)) log.warn("Checking for update plugin: " + appName); // blocking if (mgr.checkAvailable(PLUGIN, appName, 60*1000) == null) { if (log.shouldLog(Log.WARN)) log.warn("No update available for plugin: " + appName); continue; } if (log.shouldLog(Log.WARN)) log.warn("Updating plugin: " + appName); // non-blocking mgr.update(PLUGIN, appName, 30*60*1000); int loop = 0; do { // only wait for 4 minutes, then we will // keep going try { Thread.sleep(5*1000); } catch (InterruptedException ie) {} if (loop++ > 48) break; } while (mgr.isUpdateInProgress(PLUGIN, appName)); if (mgr.getUpdateAvailable(PLUGIN, appName) != null) updated++; } if (updated > 0) mgr.notifyComplete(null, ngettext("1 plugin updated", "{0} plugins updated", updated, ctx)); else mgr.notifyComplete(null, Messages.getString("Plugin update check complete", ctx)); } /** this shouldn't throw anything */ static void startPlugins(RouterContext ctx) { Log log = ctx.logManager().getLog(PluginStarter.class); Properties props = pluginProperties(); for (Map.Entry<Object, Object> e : props.entrySet()) { String name = (String)e.getKey(); if (name.startsWith(PREFIX) && name.endsWith(ENABLED)) { if (Boolean.parseBoolean((String) e.getValue())) { String app = name.substring(PREFIX.length(), name.lastIndexOf(ENABLED)); // plugins could have been started after update if (isPluginRunning(app, ctx)) continue; try { if (!startPlugin(ctx, app)) log.error("Failed to start plugin: " + app); } catch (Throwable t) { log.error("Failed to start plugin: " + app, t); } } } } } /** * Deferred deletion of plugins that we failed to delete before. * * @since 0.9.13 */ private static void deferredDeletePlugins(RouterContext ctx) { Log log = ctx.logManager().getLog(PluginStarter.class); boolean changed = false; Properties props = pluginProperties(); for (Iterator<Map.Entry<Object, Object>> iter = props.entrySet().iterator(); iter.hasNext(); ) { Map.Entry<Object, Object> e = iter.next(); String name = (String)e.getKey(); if (name.startsWith(PREFIX) && name.endsWith(ENABLED)) { // deferred deletion of a plugin if (e.getValue().equals(DELETED)) { String app = name.substring(PREFIX.length(), name.lastIndexOf(ENABLED)); // shouldn't happen, this is run early if (isPluginRunning(app, ctx)) continue; File pluginDir = new File(ctx.getConfigDir(), PLUGIN_DIR + '/' + app); boolean deleted = FileUtil.rmdir(pluginDir, false); if (deleted) { log.logAlways(Log.WARN, "Deferred deletion of " + pluginDir + " successful"); iter.remove(); changed = true; } else { if (log.shouldLog(Log.WARN)) log.warn("Deferred deletion of " + pluginDir + " failed"); } } } } if (changed) storePluginProperties(props); } /** * @return true on success * @throws Exception just about anything, caller would be wise to catch Throwable */ @SuppressWarnings("deprecation") public static boolean startPlugin(RouterContext ctx, String appName) throws Exception { Log log = ctx.logManager().getLog(PluginStarter.class); File pluginDir = new File(ctx.getConfigDir(), PLUGIN_DIR + '/' + appName); String iconfile = null; if ((!pluginDir.exists()) || (!pluginDir.isDirectory())) { log.error("Cannot start nonexistent plugin: " + appName); disablePlugin(appName); return false; } // Do we need to extract an update? File pluginUpdate = new File(ctx.getConfigDir(), PLUGIN_DIR + '/' + appName + "/app.xpi2p.zip" ); if(pluginUpdate.exists()) { // Compare the start time of the router with the plugin. if(ctx.router().getWhenStarted() > pluginUpdate.lastModified()) { if (!FileUtil.extractZip(pluginUpdate, pluginDir)) { pluginUpdate.delete(); String foo = "Plugin '" + appName + "' failed to update! File '" + pluginUpdate +"' deleted. You may need to remove and install the plugin again."; log.error(foo); disablePlugin(appName); throw new Exception(foo); } else { pluginUpdate.delete(); // Need to always log this, and log.logAlways() did not work for me. System.err.println("INFO: Plugin updated: " + appName); } } // silently fail to update, because we have not restarted. } Properties props = pluginProperties(ctx, appName); // For the following, we use the exact same translated strings as in PluginUpdateRunner // to avoid duplication String minVersion = ConfigClientsHelper.stripHTML(props, "min-i2p-version"); if (minVersion != null && VersionComparator.comp(CoreVersion.VERSION, minVersion) < 0) { String foo = "Plugin " + appName + " requires I2P version " + minVersion + " or higher"; log.error(foo); disablePlugin(appName); foo = gettext("This plugin requires I2P version {0} or higher", minVersion, ctx); throw new Exception(foo); } minVersion = ConfigClientsHelper.stripHTML(props, "min-java-version"); if (minVersion != null && VersionComparator.comp(System.getProperty("java.version"), minVersion) < 0) { String foo = "Plugin " + appName + " requires Java version " + minVersion + " or higher"; log.error(foo); disablePlugin(appName); foo = gettext("This plugin requires Java version {0} or higher", minVersion, ctx); throw new Exception(foo); } String jVersion = LogsHelper.jettyVersion(); minVersion = ConfigClientsHelper.stripHTML(props, "min-jetty-version"); if (minVersion != null && VersionComparator.comp(minVersion, jVersion) > 0) { String foo = "Plugin " + appName + " requires Jetty version " + minVersion + " or higher"; log.error(foo); disablePlugin(appName); foo = gettext("Plugin requires Jetty version {0} or higher", minVersion, ctx); throw new Exception(foo); } String blacklistVersion = jetty9Blacklist.get(appName); String curVersion = ConfigClientsHelper.stripHTML(props, "version"); if (blacklistVersion != null && VersionComparator.comp(curVersion, blacklistVersion) <= 0) { String foo = "Plugin " + appName + " requires Jetty version 8.9999 or lower"; log.error(foo); disablePlugin(appName); foo = gettext("Plugin requires Jetty version {0} or lower", "8.9999", ctx); throw new Exception(foo); } String maxVersion = ConfigClientsHelper.stripHTML(props, "max-jetty-version"); if (maxVersion != null && VersionComparator.comp(maxVersion, jVersion) < 0) { String foo = "Plugin " + appName + " requires Jetty version " + maxVersion + " or lower"; log.error(foo); disablePlugin(appName); foo = gettext("Plugin requires Jetty version {0} or lower", maxVersion, ctx); throw new Exception(foo); } if (log.shouldLog(Log.INFO)) log.info("Starting plugin: " + appName); // register themes File dir = new File(pluginDir, "console/themes"); File[] tfiles = dir.listFiles(); if (tfiles != null) { for (int i = 0; i < tfiles.length; i++) { String name = tfiles[i].getName(); if (tfiles[i].isDirectory() && (!Arrays.asList(STANDARD_THEMES).contains(tfiles[i]))) { // deprecated ctx.router().setConfigSetting(ConfigUIHelper.PROP_THEME_PFX + name, tfiles[i].getAbsolutePath()); // we don't need to save } } } //handle console icons for plugins without web-resources through prop icon-code String fullprop = props.getProperty("icon-code"); if(fullprop != null && fullprop.length() > 1){ byte[] decoded = Base64.decode(fullprop); if(decoded != null) { NavHelper.setBinary(appName, decoded); iconfile = "/Plugins/pluginicon?plugin=" + appName; } else { iconfile = "/themes/console/images/plugin.png"; } } // load and start things in clients.config File clientConfig = new File(pluginDir, "clients.config"); if (clientConfig.exists()) { Properties cprops = new Properties(); DataHelper.loadProps(cprops, clientConfig); List<ClientAppConfig> clients = ClientAppConfig.getClientApps(clientConfig); runClientApps(ctx, pluginDir, clients, "start"); } // start console webapps in console/webapps ContextHandlerCollection server = WebAppStarter.getConsoleServer(); if (server != null) { File consoleDir = new File(pluginDir, "console"); Properties wprops = RouterConsoleRunner.webAppProperties(consoleDir.getAbsolutePath()); File webappDir = new File(consoleDir, "webapps"); String fileNames[] = webappDir.list(RouterConsoleRunner.WarFilenameFilter.instance()); if (fileNames != null) { if(!pluginWars.containsKey(appName)) pluginWars.put(appName, new ConcurrentHashSet<String>()); for (int i = 0; i < fileNames.length; i++) { try { String warName = fileNames[i].substring(0, fileNames[i].lastIndexOf(".war")); //log.error("Found webapp: " + warName); // check for duplicates in $I2P if (Arrays.asList(STANDARD_WEBAPPS).contains(warName)) { log.error("Skipping duplicate webapp " + warName + " in plugin " + appName); continue; } String enabled = wprops.getProperty(RouterConsoleRunner.PREFIX + warName + ENABLED); if (! "false".equals(enabled)) { if (log.shouldLog(Log.INFO)) log.info("Starting webapp: " + warName); String path = new File(webappDir, fileNames[i]).getCanonicalPath(); WebAppStarter.startWebApp(ctx, server, warName, path); pluginWars.get(appName).add(warName); } } catch (IOException ioe) { log.error("Error resolving '" + fileNames[i] + "' in '" + webappDir, ioe); } } // Check for iconfile in plugin.properties String icfile = ConfigClientsHelper.stripHTML(props, "console-icon"); if (icfile != null && !icfile.contains("..")) { StringBuilder buf = new StringBuilder(32); buf.append('/').append(appName); if (!icfile.startsWith("/")) buf.append('/'); buf.append(icfile); iconfile = buf.toString(); } } } else { log.error("No console web server to start plugins?"); } // add translation jars in console/locale // These will not override existing resource bundles since we are adding them // later in the classpath. File localeDir = new File(pluginDir, "console/locale"); if (localeDir.exists() && localeDir.isDirectory()) { File[] files = localeDir.listFiles(); if (files != null) { boolean added = false; for (int i = 0; i < files.length; i++) { File f = files[i]; if (f.getName().endsWith(".jar")) { try { addPath(f.toURI().toURL()); log.info("INFO: Adding translation plugin to classpath: " + f); added = true; } catch (ClassCastException e) { log.logAlways(Log.WARN, "Java version: " + System.getProperty("java.version") + " does not support adding classpath element: " + f + " for plugin " + appName); } catch (RuntimeException e) { log.error("Plugin " + appName + " bad classpath element: " + f, e); } } } if (added) Translate.clearCache(); } } // add summary bar link String name = ConfigClientsHelper.stripHTML(props, "consoleLinkName_" + Messages.getLanguage(ctx)); if (name == null) name = ConfigClientsHelper.stripHTML(props, "consoleLinkName"); String url = ConfigClientsHelper.stripHTML(props, "consoleLinkURL"); if (name != null && url != null && name.length() > 0 && url.length() > 0) { String tip = ConfigClientsHelper.stripHTML(props, "consoleLinkTooltip_" + Messages.getLanguage(ctx)); if (tip == null) tip = ConfigClientsHelper.stripHTML(props, "consoleLinkTooltip"); NavHelper.registerApp(name, url, tip, iconfile); } return true; } /** * @return true on success * @throws Exception just about anything, caller would be wise to catch Throwable */ public static boolean stopPlugin(RouterContext ctx, String appName) throws Exception { Log log = ctx.logManager().getLog(PluginStarter.class); File pluginDir = new File(ctx.getConfigDir(), PLUGIN_DIR + '/' + appName); if ((!pluginDir.exists()) || (!pluginDir.isDirectory())) { log.error("Cannot stop nonexistent plugin: " + appName); return false; } // stop things in clients.config File clientConfig = new File(pluginDir, "clients.config"); if (clientConfig.exists()) { Properties props = new Properties(); DataHelper.loadProps(props, clientConfig); List<ClientAppConfig> clients = ClientAppConfig.getClientApps(clientConfig); runClientApps(ctx, pluginDir, clients, "stop"); } // stop console webapps in console/webapps //ContextHandlerCollection server = WebAppStarter.getConsoleServer(); //if (server != null) { /* File consoleDir = new File(pluginDir, "console"); Properties props = RouterConsoleRunner.webAppProperties(consoleDir.getAbsolutePath()); File webappDir = new File(consoleDir, "webapps"); String fileNames[] = webappDir.list(RouterConsoleRunner.WarFilenameFilter.instance()); if (fileNames != null) { for (int i = 0; i < fileNames.length; i++) { String warName = fileNames[i].substring(0, fileNames[i].lastIndexOf(".war")); if (Arrays.asList(STANDARD_WEBAPPS).contains(warName)) { continue; } WebAppStarter.stopWebApp(server, warName); } } */ if(pluginWars.containsKey(appName)) { Iterator <String> wars = pluginWars.get(appName).iterator(); while (wars.hasNext()) { String warName = wars.next(); WebAppStarter.stopWebApp(warName); } pluginWars.get(appName).clear(); } //} // remove summary bar link Properties props = pluginProperties(ctx, appName); String name = ConfigClientsHelper.stripHTML(props, "consoleLinkName_" + Messages.getLanguage(ctx)); if (name == null) name = ConfigClientsHelper.stripHTML(props, "consoleLinkName"); if (name != null && name.length() > 0) NavHelper.unregisterApp(name); if (log.shouldLog(Log.WARN)) log.warn("Stopping plugin: " + appName); return true; } /** @return true on success - caller should call stopPlugin() first */ static boolean deletePlugin(RouterContext ctx, String appName) throws Exception { Log log = ctx.logManager().getLog(PluginStarter.class); File pluginDir = new File(ctx.getConfigDir(), PLUGIN_DIR + '/' + appName); if ((!pluginDir.exists()) || (!pluginDir.isDirectory())) { log.error("Cannot delete nonexistent plugin: " + appName); return false; } // uninstall things in clients.config File clientConfig = new File(pluginDir, "clients.config"); if (clientConfig.exists()) { Properties props = new Properties(); DataHelper.loadProps(props, clientConfig); List<ClientAppConfig> clients = ClientAppConfig.getClientApps(clientConfig); runClientApps(ctx, pluginDir, clients, "uninstall"); } // unregister themes, and switch to default if we are unregistering the current theme File dir = new File(pluginDir, "console/themes"); File[] tfiles = dir.listFiles(); if (tfiles != null) { String current = ctx.getProperty(CSSHelper.PROP_THEME_NAME); Map<String, String> changes = new HashMap<String, String>(); List<String> removes = new ArrayList<String>(); for (int i = 0; i < tfiles.length; i++) { String name = tfiles[i].getName(); if (tfiles[i].isDirectory() && (!Arrays.asList(STANDARD_THEMES).contains(tfiles[i]))) { removes.add(ConfigUIHelper.PROP_THEME_PFX + name); if (name.equals(current)) changes.put(CSSHelper.PROP_THEME_NAME, CSSHelper.DEFAULT_THEME); } } ctx.router().saveConfig(changes, removes); } boolean deleted = FileUtil.rmdir(pluginDir, false); Properties props = pluginProperties(); for (Iterator<?> iter = props.keySet().iterator(); iter.hasNext(); ) { String name = (String)iter.next(); if (name.startsWith(PREFIX + appName + '.')) iter.remove(); } if (!deleted) { // This happens on Windows when there are plugin jars in classpath // Mark it as deleted, we will try again after restart log.logAlways(Log.WARN, "Deletion of " + pluginDir + " failed, will try again at restart"); props.setProperty(PREFIX + appName + ENABLED, DELETED); } storePluginProperties(props); return true; } /** plugin.config */ public static Properties pluginProperties(I2PAppContext ctx, String appName) { File cfgFile = new File(ctx.getConfigDir(), PLUGIN_DIR + '/' + appName + '/' + "plugin.config"); Properties rv = new Properties(); try { DataHelper.loadProps(rv, cfgFile); } catch (IOException ioe) {} return rv; } /** * plugins.config * this auto-adds a property for every dir in the plugin directory */ public static Properties pluginProperties() { File dir = I2PAppContext.getGlobalContext().getConfigDir(); Properties rv = new Properties(); File cfgFile = new File(dir, CONFIG_FILE); try { DataHelper.loadProps(rv, cfgFile); } catch (IOException ioe) {} List<String> names = getAllPlugins(); for (String name : names) { String prop = PREFIX + name + ENABLED; if (rv.getProperty(prop) == null) rv.setProperty(prop, "true"); } return rv; } /** * Is the plugin enabled in plugins.config? * Default true * * @since 0.8.13 */ public static boolean isPluginEnabled(String appName) { Properties props = pluginProperties(); String prop = PREFIX + appName + ENABLED; return Boolean.parseBoolean(props.getProperty(prop, "true")); } /** * Disable in plugins.config * * @since 0.8.13 */ public static void disablePlugin(String appName) { Properties props = pluginProperties(); String prop = PREFIX + appName + ENABLED; if (Boolean.parseBoolean(props.getProperty(prop, "true"))) { props.setProperty(prop, "false"); storePluginProperties(props); } } /** * all installed plugins whether enabled or not, * but does NOT include plugins marked as deleted. * @return non-null, sorted, modifiable */ public static List<String> getPlugins() { List<String> rv = getAllPlugins(); Properties props = pluginProperties(); for (Iterator<String> iter = rv.iterator(); iter.hasNext(); ) { String app = iter.next(); if (DELETED.equals(props.getProperty(PREFIX + app + ENABLED))) iter.remove(); } Collections.sort(rv); // ensure the list is in sorted order. return rv; } /** * all installed plugins whether enabled or not, * DOES include plugins marked as deleted. * @return non-null, unsorted, modifiable * @since 0.9.13 */ private static List<String> getAllPlugins() { List<String> rv = new ArrayList<String>(); File pluginDir = new File(I2PAppContext.getGlobalContext().getConfigDir(), PLUGIN_DIR); File[] files = pluginDir.listFiles(); if (files == null) return rv; for (int i = 0; i < files.length; i++) { if (files[i].isDirectory()) rv.add(files[i].getName()); } return rv; } /** * The signing keys from all the plugins * @return Map of key to keyname * Last one wins if a dup (installer should prevent dups) */ public static Map<String, String> getPluginKeys(I2PAppContext ctx) { Map<String, String> rv = new HashMap<String, String>(); List<String> names = getPlugins(); for (String name : names) { Properties props = pluginProperties(ctx, name); String pubkey = props.getProperty("key"); String signer = props.getProperty("signer"); if (pubkey != null && signer != null && pubkey.length() == 172 && signer.length() > 0) rv.put(pubkey, signer); } return rv; } /** * plugins.config */ public static void storePluginProperties(Properties props) { File cfgFile = new File(I2PAppContext.getGlobalContext().getConfigDir(), CONFIG_FILE); try { DataHelper.storeProps(props, cfgFile); } catch (IOException ioe) {} } /** * @param action "start" or "stop" or "uninstall" * @throws Exception just about anything if an app has a delay less than zero, caller would be wise to catch Throwable * If no apps have a delay less than zero, it shouldn't throw anything */ private static void runClientApps(RouterContext ctx, File pluginDir, List<ClientAppConfig> apps, String action) throws Exception { Log log = ctx.logManager().getLog(PluginStarter.class); // initialize pluginThreadGroup and _pendingPluginClients String pluginName = pluginDir.getName(); if (!pluginThreadGroups.containsKey(pluginName)) pluginThreadGroups.put(pluginName, new ThreadGroup(pluginName)); ThreadGroup pluginThreadGroup = pluginThreadGroups.get(pluginName); if (action.equals("start")) _pendingPluginClients.put(pluginName, new ConcurrentHashSet<SimpleTimer2.TimedEvent>()); for(ClientAppConfig app : apps) { // If the client is a running ClientApp that we want to stop, // bypass all the logic below. if (action.equals("stop")) { String[] argVal = LoadClientAppsJob.parseArgs(app.args); // We must do all the substitution just as when started, so the // argument array comparison in getClientApp() works. // Do this after parsing so we don't need to worry about quoting for (int i = 0; i < argVal.length; i++) { if (argVal[i].indexOf('$') >= 0) { argVal[i] = argVal[i].replace("$I2P", ctx.getBaseDir().getAbsolutePath()); argVal[i] = argVal[i].replace("$CONFIG", ctx.getConfigDir().getAbsolutePath()); argVal[i] = argVal[i].replace("$PLUGIN", pluginDir.getAbsolutePath()); } } ClientApp ca = ctx.routerAppManager().getClientApp(app.className, argVal); if (ca != null) { // even if (ca.getState() != ClientAppState.RUNNING), we do this, we don't want to fall thru try { ca.shutdown(LoadClientAppsJob.parseArgs(app.stopargs)); } catch (Throwable t) { throw new Exception(t); } continue; } } if (action.equals("start") && app.disabled) continue; String argVal[]; if (action.equals("start")) { // start argVal = LoadClientAppsJob.parseArgs(app.args); } else { String args; if (action.equals("stop")) args = app.stopargs; else if (action.equals("uninstall")) args = app.uninstallargs; else throw new IllegalArgumentException("bad action"); // args must be present if (args == null || args.length() <= 0) continue; argVal = LoadClientAppsJob.parseArgs(args); } // do this after parsing so we don't need to worry about quoting for (int i = 0; i < argVal.length; i++) { if (argVal[i].indexOf('$') >= 0) { argVal[i] = argVal[i].replace("$I2P", ctx.getBaseDir().getAbsolutePath()); argVal[i] = argVal[i].replace("$CONFIG", ctx.getConfigDir().getAbsolutePath()); argVal[i] = argVal[i].replace("$PLUGIN", pluginDir.getAbsolutePath()); } } ClassLoader cl = null; if (app.classpath != null) { String cp = app.classpath; if (cp.indexOf('$') >= 0) { cp = cp.replace("$I2P", ctx.getBaseDir().getAbsolutePath()); cp = cp.replace("$CONFIG", ctx.getConfigDir().getAbsolutePath()); cp = cp.replace("$PLUGIN", pluginDir.getAbsolutePath()); } // Old way - add for the whole JVM //addToClasspath(cp, app.clientName, log); // New way - add only for this client // We cache the ClassLoader we start the client with, so // we can reuse it for stopping and uninstalling. // If we don't, the client won't be able to find its // static members. String clCacheKey = pluginName + app.className + app.args; if (!action.equals("start")) cl = _clCache.get(clCacheKey); if (cl == null) { URL[] urls = classpathToURLArray(cp, app.clientName, log); if (urls != null) { cl = new URLClassLoader(urls, ClassLoader.getSystemClassLoader()); if (action.equals("start")) _clCache.put(clCacheKey, cl); } } } if (app.delay < 0 && action.equals("start")) { // this will throw exceptions LoadClientAppsJob.runClientInline(app.className, app.clientName, argVal, log, cl); } else if (app.delay == 0 || !action.equals("start")) { // quick check, will throw ClassNotFoundException on error LoadClientAppsJob.testClient(app.className, cl); // run this guy now LoadClientAppsJob.runClient(app.className, app.clientName, argVal, ctx, log, pluginThreadGroup, cl); } else { // If there is some delay, there may be a really good reason for it. // Loading a class would be one of them! // So we do a quick check first, If it bombs out, we delay and try again. // If it bombs after that, then we throw the ClassNotFoundException. try { // quick check LoadClientAppsJob.testClient(app.className, cl); } catch (ClassNotFoundException ex) { // Try again 1 or 2 seconds later. // This should be enough time. Although it is a lousy hack // it should work for most cases. // Perhaps it may be even better to delay a percentage // if > 1, and reduce the delay time. // Under normal circumstances there will be no delay at all. try { if (app.delay > 1) { Thread.sleep(2000); } else { Thread.sleep(1000); } } catch (InterruptedException ie) {} // quick check, will throw ClassNotFoundException on error LoadClientAppsJob.testClient(app.className, cl); } // wait before firing it up SimpleTimer2.TimedEvent evt = new TrackedDelayedClient(pluginName, ctx.simpleTimer2(), ctx, app.className, app.clientName, argVal, pluginThreadGroup, cl); evt.schedule(app.delay); } } } /** * Simple override to track whether a plugin's client is delayed and queued * @since 0.9.6 */ private static class TrackedDelayedClient extends LoadClientAppsJob.DelayedRunClient { private final String _pluginName; public TrackedDelayedClient(String pluginName, SimpleTimer2 pool, RouterContext enclosingContext, String className, String clientName, String args[], ThreadGroup threadGroup, ClassLoader cl) { super(pool, enclosingContext, className, clientName, args, threadGroup, cl); _pluginName = pluginName; _pendingPluginClients.get(pluginName).add(this); } @Override public boolean cancel() { boolean rv = super.cancel(); _pendingPluginClients.get(_pluginName).remove(this); return rv; } @Override public void timeReached() { super.timeReached(); _pendingPluginClients.get(_pluginName).remove(this); } } public static boolean isPluginRunning(String pluginName, RouterContext ctx) { Log log = ctx.logManager().getLog(PluginStarter.class); boolean isJobRunning = false; Collection<SimpleTimer2.TimedEvent> pending = _pendingPluginClients.get(pluginName); if (pending != null && !pending.isEmpty()) { // TODO have a pending indication too isJobRunning = true; } boolean isWarRunning = false; if(pluginWars.containsKey(pluginName)) { Iterator <String> it = pluginWars.get(pluginName).iterator(); while(it.hasNext() && !isWarRunning) { String warName = it.next(); if(WebAppStarter.isWebAppRunning(warName)) { isWarRunning = true; } } } boolean isClientThreadRunning = isClientThreadRunning(pluginName, ctx); if (log.shouldLog(Log.DEBUG)) log.debug("plugin name = <" + pluginName + ">; threads running? " + isClientThreadRunning + "; webapp running? " + isWarRunning + "; jobs running? " + isJobRunning); return isClientThreadRunning || isWarRunning || isJobRunning; // //if (log.shouldLog(Log.DEBUG)) // log.debug("plugin name = <" + pluginName + ">; threads running? " + isClientThreadRunning(pluginName) + "; webapp running? " + WebAppStarter.isWebAppRunning(pluginName) + "; jobs running? " + isJobRunning); //return isClientThreadRunning(pluginName) || WebAppStarter.isWebAppRunning(pluginName) || isJobRunning; // } /** * Returns <code>true</code> if one or more client threads are running in a given plugin. * @param pluginName * @return true if running */ private static boolean isClientThreadRunning(String pluginName, RouterContext ctx) { ThreadGroup group = pluginThreadGroups.get(pluginName); if (group == null) return false; boolean rv = group.activeCount() > 0; // Plugins start before the eepsite, and will create the static Timer thread // in RolloverFileOutputStream, which never stops. Don't count it. // Ditto HSQLDB Timer (jwebcache) if (rv) { Log log = ctx.logManager().getLog(PluginStarter.class); Thread[] activeThreads = new Thread[128]; int count = group.enumerate(activeThreads); boolean notRollover = false; for (int i = 0; i < count; i++) { if (activeThreads[i] != null) { String name = activeThreads[i].getName(); if (!"org.eclipse.jetty.util.RolloverFileOutputStream".equals(name) && !name.startsWith("HSQLDB Timer")) notRollover = true; if (log.shouldLog(Log.DEBUG)) log.debug("Found " + activeThreads[i].getState() + " thread " + name + " for " + pluginName + ": " + name); } } rv = notRollover; } return rv; } /** * Perhaps there's an easy way to use Thread.setContextClassLoader() * but I don't see how to make it magically get used for everything. * So add this to the whole JVM's classpath. */ /****** private static void addToClasspath(String classpath, String clientName, Log log) { StringTokenizer tok = new StringTokenizer(classpath, ","); while (tok.hasMoreTokens()) { String elem = tok.nextToken().trim(); File f = new File(elem); if (!f.isAbsolute()) { log.error("Plugin client " + clientName + " classpath element is not absolute: " + f); continue; } try { addPath(f.toURI().toURL()); if (log.shouldLog(Log.WARN)) log.warn("INFO: Adding plugin to classpath: " + f); } catch (Exception e) { log.error("Plugin client " + clientName + " bad classpath element: " + f, e); } } } *****/ /** * @return null if no valid elements */ private static URL[] classpathToURLArray(String classpath, String clientName, Log log) { StringTokenizer tok = new StringTokenizer(classpath, ","); List<URL> urls = new ArrayList<URL>(); while (tok.hasMoreTokens()) { String elem = tok.nextToken().trim(); File f = new File(elem); if (!f.isAbsolute()) { log.error("Plugin client " + clientName + " classpath element is not absolute: " + f); continue; } try { urls.add(f.toURI().toURL()); if (log.shouldLog(Log.WARN)) log.warn("INFO: Adding plugin to classpath: " + f); } catch (IOException e) { log.error("Plugin client " + clientName + " bad classpath element: " + f, e); } } if (urls.isEmpty()) return null; return urls.toArray(new URL[urls.size()]); } /** * http://jimlife.wordpress.com/2007/12/19/java-adding-new-classpath-at-runtime/ * * @throws ClassCastException in Java 9 */ private static void addPath(URL u) throws Exception { URLClassLoader urlClassLoader = (URLClassLoader) ClassLoader.getSystemClassLoader(); Class<URLClassLoader> urlClass = URLClassLoader.class; Method method = urlClass.getDeclaredMethod("addURL", URL.class); method.setAccessible(true); method.invoke(urlClassLoader, new Object[]{u}); } /** * translate a string * @since 0.9.30 */ private static String gettext(String s, Object o, I2PAppContext ctx) { return Messages.getString(s, o, ctx); } /** translate a string */ private static String ngettext(String s, String p, int n, I2PAppContext ctx) { return Messages.getString(n, s, p, ctx); } }