Merge branch 'shellservice' into 'master'

Manage Fork-and-Exec Plugins by Monitoring them by PID

See merge request i2p-hackers/i2p.i2p!39
This commit is contained in:
idk
2021-11-17 17:42:50 +00:00
3 changed files with 552 additions and 17 deletions

View File

@ -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.

View File

@ -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;
}
}

View File

@ -0,0 +1,34 @@
-----BEGIN CERTIFICATE-----
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
+rk8M5oGGIQr6dAgBkpRHg==
-----END CERTIFICATE-----