diff --git a/apps/routerconsole/java/src/net/i2p/router/web/PluginStarter.java b/apps/routerconsole/java/src/net/i2p/router/web/PluginStarter.java index 1fee9c6ab52f8a1e402b7f5d78cee72cacf749fd..6b7291f1e01fc6ab0c1ce1e27578a217be634603 100644 --- a/apps/routerconsole/java/src/net/i2p/router/web/PluginStarter.java +++ b/apps/routerconsole/java/src/net/i2p/router/web/PluginStarter.java @@ -562,13 +562,23 @@ public class PluginStarter implements Runnable { if (log.shouldLog(Log.WARN)) log.warn("Stopping plugin: " + appName); - // 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"); + ClientApp client = ctx.clientAppManager().getRegisteredApp(appName); + if (client != null) { + try{ + client.shutdown(null); + }catch(Throwable t){ + if (log.shouldLog(Log.ERROR)) + log.error("Error stopping client app: " + appName, t); + } + } else { + // 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 @@ -681,7 +691,7 @@ public class PluginStarter implements Runnable { File dir = I2PAppContext.getGlobalContext().getConfigDir(); Properties rv = new Properties(); File cfgFile = new File(dir, CONFIG_FILE); - + try { DataHelper.loadProps(rv, cfgFile); } catch (IOException ioe) {} @@ -792,7 +802,7 @@ public class PluginStarter implements Runnable { */ 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)) @@ -800,7 +810,7 @@ public class PluginStarter implements Runnable { 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. @@ -903,7 +913,7 @@ public class PluginStarter implements Runnable { // quick check LoadClientAppsJob.testClient(app.className, cl); } catch (ClassNotFoundException ex) { - // Try again 1 or 2 seconds later. + // 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 @@ -966,7 +976,7 @@ public class PluginStarter implements Runnable { */ protected static boolean isPluginRunning(String pluginName, RouterContext ctx, Server s) { Log log = ctx.logManager().getLog(PluginStarter.class); - + boolean isJobRunning = false; Collection<SimpleTimer2.TimedEvent> pending = _pendingPluginClients.get(pluginName); if (pending != null && !pending.isEmpty()) { @@ -987,17 +997,35 @@ public class PluginStarter implements Runnable { } } + // load and check for ShellServices. + boolean isProcessRunning = false; + ClientApp client = ctx.clientAppManager().getRegisteredApp(pluginName); + if (client != null) { + if (log.shouldLog(Log.DEBUG)) + log.debug("Checking state of client " + pluginName + client.getState()); + if (client.getState() == ClientAppState.RUNNING) { + isProcessRunning = true; + } + } else { + if (log.shouldLog(Log.DEBUG)) + log.debug("No client found for plugin " + pluginName); + } + 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; + log.debug("plugin name = <" + pluginName + + ">; threads running? " + isClientThreadRunning + + "; webapp running? " + isWarRunning + + "; jobs running? " + isJobRunning + + "; process running? " + isProcessRunning); + return isClientThreadRunning || isWarRunning || isJobRunning || isProcessRunning; // //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 @@ -1008,7 +1036,7 @@ public class PluginStarter implements Runnable { if (group == null) return false; boolean rv = group.activeCount() > 0; - + // Plugins start before the I2P Site, and will create the static Timer thread // in RolloverFileOutputStream, which never stops. Don't count it. // Ditto HSQLDB Timer (jwebcache) @@ -1032,7 +1060,7 @@ public class PluginStarter implements Runnable { 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. diff --git a/core/java/src/net/i2p/app/ShellService.java b/core/java/src/net/i2p/app/ShellService.java new file mode 100644 index 0000000000000000000000000000000000000000..1ba2759a2d53df28d8f16190558e7f24c55d09c5 --- /dev/null +++ b/core/java/src/net/i2p/app/ShellService.java @@ -0,0 +1,473 @@ +/* + * I2P - An anonymous, secure, and fully-distributed communication network. + * + * ShellService.java + * 2021 The I2P Project + * http://www.geti2p.net + * This code is public domain. + */ + +package net.i2p.app; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.IOException; +import java.io.UnsupportedEncodingException; + +import java.lang.IllegalArgumentException; +import java.lang.NullPointerException; +import java.lang.IndexOutOfBoundsException; +import java.lang.SecurityException; +import java.lang.ProcessBuilder; +import java.lang.reflect.Field; + +import java.nio.file.Files; +import java.nio.file.Paths; + +import java.util.Arrays; +import java.util.ArrayList; + +import java.util.HashMap; + +import net.i2p.I2PAppContext; +import net.i2p.app.ClientApp; +import net.i2p.app.ClientAppManager; +import net.i2p.app.ClientAppState; +import net.i2p.util.Log; +import net.i2p.util.ShellCommand; +import net.i2p.util.SystemVersion; + +/** + * Alternative to ShellCommand based on ProcessBuilder, which manages + * a process and keeps track of it's state by PID when a plugin cannot be + * managed otherwise. Eliminates the need for a bespoke shell script to manage + * application state for forked plugins. + * + * Keeps track of the PID of the plugin, reports start/stop status correctly + * on configplugins. When running a ShellService from a clients.config file, + * the user MUST pass -shellservice.name in the args field in clients.config + * to override the plugin name. The name passed to -shellservice.name should + * be unique to avoid causing issues. (https://i2pgit.org/i2p-hackers/i2p.i2p/-/merge_requests/39#note_4234) + * -shellservice.displayName is optional and configures the name of the plugin + * which is shown on the console. In most cases, the -shellservice.name must be + * the same as the plugin name in order for the $PLUGIN field in clients.config + * to match the expected value. If this is not the case, i.e. + * (-shellservice.name != plugin.name), you must not use $PLUGIN in your + * clients.config file. + * + * The recommended way to use this tool is to manage a single forked app/process, + * with a single ShellService, in a single plugin. + * + * When you are writing your clients.config file, please take note that $PLUGIN + * will be derived from the `shellservice.name` field in the config file args. + * + * Works on Windows, OSX, and Linux. + * + * @author eyedeekay + * @since 1.6.0/0.9.52 + */ +public class ShellService implements ClientApp { + private static final String NAME_OPTION = "-shellservice.name"; + private static final String DISPLAY_NAME_OPTION = "-shellservice.displayname"; + private static final String PLUGIN_DIR = "plugins"; + + private final Log _log; + private final ProcessBuilder _pb; + private final I2PAppContext _context; + private final ClientAppManager _cmgr; + + private ClientAppState _state = ClientAppState.UNINITIALIZED; + + private volatile String name = "unnamedClient"; + private volatile String displayName = "unnamedClient"; + + private Process _p; + private volatile long _pid; + + public ShellService(I2PAppContext context, ClientAppManager listener, String[] args) { + _context = context; + _cmgr = listener; + _log = context.logManager().getLog(ShellService.class); + + String[] procArgs = trimArgs(args); + + String process = writeScript(procArgs); + + if(_log.shouldLog(Log.DEBUG)){ + _log.debug("Process: " + process); + _log.debug("Name: " + this.getName() + ", DisplayName: " + this.getDisplayName()); + } + + _pb = new ProcessBuilder(process); + + File pluginDir = new File(_context.getConfigDir(), PLUGIN_DIR + '/' + this.getName()); + _pb.directory(pluginDir); + changeState(ClientAppState.INITIALIZED, "ShellService: "+getName()+" set up and initialized"); + } + + private String scriptArgs(String[] procArgs) { + StringBuilder tidiedArgs = new StringBuilder(); + for (int i = 0; i < procArgs.length; i++) { + tidiedArgs.append(" \""+procArgs[i]+"\" "); + } + return tidiedArgs.toString(); + } + + private String batchScript(String[] procArgs) { + String cmd = procArgs[0]; + if(_log.shouldLog(Log.DEBUG)) + _log.debug("cmd: " + cmd); + String Script = "start \""+getName()+"\" "+scriptArgs(procArgs)+System.lineSeparator(); + Script += "tasklist /V /FI \"WindowTitle eq "+getName()+"*\""+System.lineSeparator(); + return Script; + } + + private String shellScript(String[] procArgs) { + String cmd = procArgs[0]; + if(_log.shouldLog(Log.DEBUG)) + _log.debug("cmd: " + cmd); + File file = new File(cmd); + if(file.exists()){ + if (!file.isDirectory() && !file.canExecute()) { + file.setExecutable(true); + } + } + String Script = "nohup "+scriptArgs(procArgs)+" 1>/dev/null 2>/dev/null & echo $!"+System.lineSeparator(); + return Script; + } + + private void deleteScript() { + File dir = _context.getTempDir(); + if (SystemVersion.isWindows()) { + File bat = new File(dir, "shellservice-"+getName()+".bat"); + bat.delete(); + } else { + File sh = new File(dir, "shellservice-"+getName()+".sh"); + sh.delete(); + } + } + + private String writeScript(File dir, String extension, String[] procArgs){ + File script = new File(dir, "shellservice-"+getName()+extension); + script.delete(); + if (_log.shouldLog(Log.DEBUG)) + _log.debug("Writing Batch Script " + script.toString()); + FileWriter scriptWriter = null; + try { + script.createNewFile(); + scriptWriter = new FileWriter(script); + if (extension == ".bat" || extension == "") + scriptWriter.write(batchScript(procArgs)); + if (extension == ".sh") + scriptWriter.write(shellScript(procArgs)); + changeState(ClientAppState.INITIALIZED, "ShellService: "+getName()+" initialized"); + } catch (IOException ioe) { + if (_log.shouldLog(Log.ERROR)) + _log.error("Error writing wrapper script shellservice-" + getName() + extension, ioe); + script.delete(); + changeState(ClientAppState.START_FAILED, "ShellService: "+getName()+" failed to start, error writing script.", ioe); + } finally { + try { + if (scriptWriter != null) + scriptWriter.close(); + } catch (IOException ioe) { + if (_log.shouldLog(Log.ERROR)){ + _log.error("Error writing wrapper script shellservice-" + getName() + extension, ioe); + changeState(ClientAppState.START_FAILED, "ShellService: "+getName()+" failed to start, error closing script writer", ioe); + } + } + } + script.setExecutable(true); + return script.getAbsolutePath(); + } + + private String writeScript(String[] procArgs){ + File dir = _context.getTempDir(); + if (SystemVersion.isWindows()) { + return writeScript(dir, ".bat", procArgs); + } else { + return writeScript(dir, ".sh", procArgs); + } + } + + private String getPID() { + return String.valueOf(_pid); + } + + /** + * Queries {@code tasklist} if the process ID {@code pid} is running. + * + * Contain code from Stack Overflow(https://stackoverflow.com/questions/2533984/java-checking-if-any-process-id-is-currently-running-on-windows/41489635) + * + * @param pid the PID to check + * @return {@code true} if the PID is running, {@code false} otherwise + */ + private boolean isProcessIdRunningOnWindows(String pid){ + try { + String cmds[] = {"cmd", "/c", "tasklist /FI \"PID eq " + pid + "\""}; + ShellCommand _shellCommand = new ShellCommand(); + return _shellCommand.executeSilentAndWaitTimed(cmds, 240); + } catch (Exception ex) { + if (_log.shouldLog(Log.DEBUG)) + _log.debug("Error checking if process is running", ex); + changeState(ClientAppState.CRASHED, "ShellService: "+getName()+" status unknowable", ex); + } + return false; + } + + private boolean isProcessIdRunningOnUnix(String pid) { + try { + String cmds[] = {"ps", "-p", pid}; + ShellCommand _shellCommand = new ShellCommand(); + return _shellCommand.executeSilentAndWaitTimed(cmds, 240); + } catch (Exception ex) { + if (_log.shouldLog(Log.DEBUG)) + _log.debug("Error checking if process is running", ex); + changeState(ClientAppState.CRASHED, "ShellService: "+getName()+" status unknowable", ex); + } + return false; + } + + private boolean isProcessIdRunning(String pid) { + boolean running = false; + if (SystemVersion.isWindows()) { + running = isProcessIdRunningOnWindows(pid); + } else { + running = isProcessIdRunningOnUnix(pid); + } + return running; + } + + private long getPidOfProcess() { + if (_log.shouldLog(Log.DEBUG)) + _log.debug("Finding the PID of: " + getName()); + if (isProcessIdRunning(getPID())) { + if (_log.shouldLog(Log.DEBUG)) + _log.debug("Read PID in from " + getPID().toString()); + return Long.valueOf(getPID()); + } + BufferedInputStream bis = null; + ByteArrayOutputStream buf = null; + try { + if (_log.shouldLog(Log.DEBUG)) + _log.debug("Getting PID from output"); + if (_p == null) { + if (_log.shouldLog(Log.WARN)) { + _log.warn("Process is null, something is wrong"); + changeState(ClientAppState.CRASHED, "ShellService: "+getName()+" should be runnning but the process is null."); + return -1; + } + } + bis = new BufferedInputStream(_p.getInputStream()); + buf = new ByteArrayOutputStream(); + for (int result = bis.read(); result != -1; result = bis.read()) { + buf.write((byte) result); + if (result == 10) + break; + } + String pidString = buf.toString("UTF-8").replaceAll("[\\r\\n\\t ]", ""); + long pid = Long.valueOf(pidString); + if (_log.shouldLog(Log.DEBUG)) + _log.debug("Found " + getName() + "process with PID: " + pid); + return pid; + } catch (IOException ioe) { + if (_log.shouldLog(Log.ERROR)) + _log.error("Error getting PID of application started by shellservice-" + getName() , ioe); + changeState(ClientAppState.CRASHED, "ShellService: "+getName()+" PID could not be discovered", ioe); + } finally { + if (bis != null) { + try { + bis.close(); // close the input stream + } catch (IOException ioe) { + if (_log.shouldLog(Log.ERROR)) + _log.error("Error closing input stream", ioe); + } + } + if (buf != null) { + try { + buf.close(); // close the output stream + } catch (IOException ioe) { + if (_log.shouldLog(Log.ERROR)) + _log.error("Error closing output stream", ioe); + } + } + + } + return -1; + } + + private String[] trimArgs(String[] args) { + ArrayList<String> newargs = new ArrayList<String>(); + for (int i = 0; i < args.length; i++) { + if ( args[i].startsWith(NAME_OPTION) ) { + if (args[i].contains("=")){ + name = args[i].split("=")[1]; + }else{ + name = args[i+1]; + i++; + } + } else if ( args[i].startsWith(DISPLAY_NAME_OPTION) ) { + if (args[i].contains("=")) { + displayName = args[i].split("=")[1]; + } else { + displayName = args[i+1]; + i++; + } + } else { + newargs.add(args[i]); + } + } + if (getName() == null) + throw new IllegalArgumentException("ShellService: ShellService passed with args="+args+" must have a name"); + if (getDisplayName() == null) + displayName = name; + String arr[] = new String[newargs.size()]; + return newargs.toArray(arr); + } + + private synchronized void changeState(ClientAppState newState, String message, Exception ex){ + if (_state != newState) { + _state = newState; + _cmgr.notify(this, newState, message, ex); + } + } + + private synchronized void changeState(ClientAppState newState, String message){ + changeState(newState, message, null); + } + + /** + * Determine if a ShellService corresponding to the wrapped application + * has been started yet. If it hasn't, attempt to start the process and + * notify the router that it has been started. + */ + public synchronized void startup() throws Throwable { + if (getName().equals("unnamedClient")){ + if (_log.shouldLog(Log.WARN)) + _log.warn("ShellService has no name, not starting"); + return; + } + changeState(ClientAppState.STARTING, "ShellService: "+getName()+" starting"); + boolean start = checkIsStopped(); + if (start) { + _p = _pb.start(); + long pid = getPidOfProcess(); + if (pid == -1 && _log.shouldLog(Log.ERROR)) + _log.error("Error getting PID of application from recently instantiated shellservice" + getName()); + if (_log.shouldLog(Log.DEBUG)) + _log.debug("Started " + getName() + "process with PID: " + pid); + this._pid = pid; + deleteScript(); + } + changeState(ClientAppState.RUNNING, "ShellService: "+getName()+" started"); + Boolean reg = _cmgr.register(this); + if (reg){ + if (_log.shouldLog(Log.DEBUG)) + _log.debug("ShellService: "+getName()+" registered with the router"); + } else { + if (_log.shouldLog(Log.WARN)) + _log.warn("ShellService: "+getName()+" failed to register with the router"); + _cmgr.unregister(this); + _cmgr.register(this); + } + return; + } + + /** + * Determine if the PID found in "shellservice"+getName()+".pid" is + * running or not. Result is the answer to the question "Should I attempt + * to start the process" so returns false when PID corresponds to a running + * process and true if it does not. + * + * Usage in PluginStarter.isClientThreadRunning requires the !inverse of + * the result. + * + * @return {@code true} if the PID is NOT running, {@code false} if the PID is running + */ + public boolean checkIsStopped() { + if (_log.shouldLog(Log.DEBUG)) + _log.debug("Checking process status " + getName()); + return !isProcessIdRunning(getPID()); + } + + /** + * Query the stored PID of the previously launched ShellService and attempt + * to send SIGINT on Unix, SIGKILL on Windows in order to stop the wrapped + * application. + * + * @param args generally null but could be stopArgs from clients.config + */ + public synchronized void shutdown(String[] args) throws Throwable { + String pid = getPID(); + if (getName().equals("unnamedClient")){ + if (_log.shouldLog(Log.WARN)) + _log.warn("ShellService has no name, not shutting down"); + return; + } + changeState(ClientAppState.STOPPING, "ShellService: "+getName()+" stopping"); + if (_p != null) { + if (_log.shouldLog(Log.DEBUG)) + _log.debug("Stopping " + getName() + "process started with ShellService, PID: " + pid); + _p.destroy(); + } + ShellCommand _shellCommand = new ShellCommand(); + if (SystemVersion.isWindows()) { + if (_log.shouldLog(Log.DEBUG)) + _log.debug("Stopping " + getName() + "process with PID: " + pid + "on Windows"); + String cmd[] = {"cmd", "/c", "taskkill /F /T /PID " + pid}; + _shellCommand.executeSilentAndWaitTimed(cmd, 240); + } else { + if (_log.shouldLog(Log.DEBUG)) + _log.debug("Stopping " + getName() + "process with PID: " + pid + "on Unix"); + String cmd[] = {"kill", pid}; + _shellCommand.executeSilentAndWaitTimed(cmd, 240); + } + deleteScript(); + changeState(ClientAppState.STOPPED, "ShellService: "+getName()+" stopped"); + _cmgr.unregister(this); + } + + /** + * Query the PID of the wrapped application and determine if it is running + * or not. Convert to corresponding ClientAppState and return the correct + * value. + * + * @return non-null + */ + public ClientAppState getState() { + String pid = getPID(); + if (!isProcessIdRunning(pid)) { + changeState(ClientAppState.STOPPED, "ShellService: "+getName()+" stopped"); + _cmgr.unregister(this); + } + return _state; + } + + /** + * The generic name of the ClientApp, used for registration, + * e.g. "console". Do not translate. Has a special use in the context of + * ShellService, it is used to name the file which contains the PID of the + * process ShellService is wrapping. + * + * @return non-null + */ + public String getName() { + return name; + } + + /** + * The display name of the ClientApp, used in user interfaces. + * The app must translate. + * @return non-null + */ + public String getDisplayName() { + return displayName; + } + +} diff --git a/installer/resources/certificates/plugin/hankhill19580_at_gmail.com.crt b/installer/resources/certificates/plugin/hankhill19580_at_gmail.com.crt new file mode 100644 index 0000000000000000000000000000000000000000..4752e422b68d8ebe44517a66ec2ff1f29483c1f5 --- /dev/null +++ b/installer/resources/certificates/plugin/hankhill19580_at_gmail.com.crt @@ -0,0 +1,34 @@ +-----BEGIN CERTIFICATE----- +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 ++rk8M5oGGIQr6dAgBkpRHg== +-----END CERTIFICATE-----