diff --git a/apps/routerconsole/java/src/net/i2p/router/web/RouterConsoleRunner.java b/apps/routerconsole/java/src/net/i2p/router/web/RouterConsoleRunner.java index 68533f7ef..7f415894d 100644 --- a/apps/routerconsole/java/src/net/i2p/router/web/RouterConsoleRunner.java +++ b/apps/routerconsole/java/src/net/i2p/router/web/RouterConsoleRunner.java @@ -10,31 +10,46 @@ import java.util.StringTokenizer; import net.i2p.I2PAppContext; import net.i2p.apps.systray.SysTray; +import net.i2p.data.Base32; import net.i2p.data.DataHelper; import net.i2p.router.RouterContext; import net.i2p.util.FileUtil; import net.i2p.util.I2PAppThread; import net.i2p.util.SecureDirectory; +import net.i2p.util.SecureFileOutputStream; +import net.i2p.util.ShellCommand; import org.mortbay.http.DigestAuthenticator; import org.mortbay.http.HashUserRealm; import org.mortbay.http.SecurityConstraint; +import org.mortbay.http.SslListener; import org.mortbay.http.handler.SecurityHandler; import org.mortbay.jetty.Server; import org.mortbay.jetty.servlet.WebApplicationContext; import org.mortbay.jetty.servlet.WebApplicationHandler; +import org.mortbay.util.InetAddrPort; public class RouterConsoleRunner { private Server _server; - private String _listenPort = "7657"; - private String _listenHost = "127.0.0.1"; - private String _webAppsDir = "./webapps/"; + private String _listenPort; + private String _listenHost; + private String _sslListenPort; + private String _sslListenHost; + private String _webAppsDir; private static final String PROP_WEBAPP_CONFIG_FILENAME = "router.webappsConfigFile"; private static final String DEFAULT_WEBAPP_CONFIG_FILENAME = "webapps.config"; private static final DigestAuthenticator authenticator = new DigestAuthenticator(); public static final String ROUTERCONSOLE = "routerconsole"; public static final String PREFIX = "webapps."; public static final String ENABLED = ".startOnLoad"; + private static final String PROP_KEYSTORE_PASSWORD = "routerconsole.keystorePassword"; + private static final String DEFAULT_KEYSTORE_PASSWORD = "changeit"; + private static final String PROP_KEY_PASSWORD = "routerconsole.keyPassword"; + private static final String DEFAULT_LISTEN_PORT = "7657"; + private static final String DEFAULT_LISTEN_HOST = "127.0.0.1"; + private static final String DEFAULT_WEBAPPS_DIR = "./webapps/"; + private static final String USAGE = "Bad RouterConsoleRunner arguments, check clientApp.0.args in your clients.config file! " + + "Usage: [[port host[,host]] [-s sslPort [host[,host]]] [webAppsDir]]"; static { System.setProperty("org.mortbay.http.Version.paranoid", "true"); @@ -42,6 +57,27 @@ public class RouterConsoleRunner { } /** + *
+     *  non-SSL:
+     *  RouterConsoleRunner
+     *  RouterConsoleRunner 7657
+     *  RouterConsoleRunner 7657 127.0.0.1
+     *  RouterConsoleRunner 7657 127.0.0.1,::1
+     *  RouterConsoleRunner 7657 127.0.0.1,::1 ./webapps/
+     *
+     *  SSL:
+     *  RouterConsoleRunner -s 7657
+     *  RouterConsoleRunner -s 7657 127.0.0.1
+     *  RouterConsoleRunner -s 7657 127.0.0.1,::1
+     *  RouterConsoleRunner -s 7657 127.0.0.1,::1 ./webapps/
+     *
+     *  If using both, non-SSL must be first:
+     *  RouterConsoleRunner 7657 127.0.0.1 -s 7667
+     *  RouterConsoleRunner 7657 127.0.0.1 -s 7667 127.0.0.1
+     *  RouterConsoleRunner 7657 127.0.0.1,::1 -s 7667 127.0.0.1,::1
+     *  RouterConsoleRunner 7657 127.0.0.1,::1 -s 7667 127.0.0.1,::1 ./webapps/
+     *  
+ * * @param args second arg may be a comma-separated list of bind addresses, * for example ::1,127.0.0.1 * On XP, the other order (127.0.0.1,::1) fails the IPV6 bind, @@ -50,10 +86,40 @@ public class RouterConsoleRunner { * So the wise choice is ::1,127.0.0.1 */ public RouterConsoleRunner(String args[]) { - if (args.length == 3) { - _listenPort = args[0].trim(); - _listenHost = args[1].trim(); - _webAppsDir = args[2].trim(); + if (args.length == 0) { + // _listenHost and _webAppsDir are defaulted above + _listenPort = DEFAULT_LISTEN_PORT; + } else { + boolean ssl = false; + for (int i = 0; i < args.length; i++) { + if (args[i].equals("-s")) + ssl = true; + else if ((!ssl) && _listenPort == null) + _listenPort = args[i]; + else if ((!ssl) && _listenHost == null) + _listenHost = args[i]; + else if (ssl && _sslListenPort == null) + _sslListenPort = args[i]; + else if (ssl && _sslListenHost == null) + _sslListenHost = args[i]; + else if (_webAppsDir == null) + _webAppsDir = args[i]; + else { + System.err.println(USAGE); + throw new IllegalArgumentException(USAGE); + } + } + } + if (_listenHost == null) + _listenHost = DEFAULT_LISTEN_HOST; + if (_sslListenHost == null) + _sslListenHost = _listenHost; + if (_webAppsDir == null) + _webAppsDir = DEFAULT_WEBAPPS_DIR; + // _listenPort and _sslListenPort are not defaulted, if one or the other is null, do not enable + if (_listenPort == null && _sslListenPort == null) { + System.err.println(USAGE); + throw new IllegalArgumentException(USAGE); } } @@ -96,22 +162,63 @@ public class RouterConsoleRunner { List notStarted = new ArrayList(); WebApplicationHandler baseHandler = null; try { - StringTokenizer tok = new StringTokenizer(_listenHost, " ,"); int boundAddresses = 0; - while (tok.hasMoreTokens()) { - String host = tok.nextToken().trim(); - try { - if (host.indexOf(":") >= 0) // IPV6 - requires patched Jetty 5 - _server.addListener('[' + host + "]:" + _listenPort); - else - _server.addListener(host + ':' + _listenPort); - boundAddresses++; - } catch (IOException ioe) { // this doesn't seem to work, exceptions don't happen until start() below - System.err.println("Unable to bind routerconsole to " + host + " port " + _listenPort + ' ' + ioe); + + // add standard listeners + if (_listenPort != null) { + StringTokenizer tok = new StringTokenizer(_listenHost, " ,"); + while (tok.hasMoreTokens()) { + String host = tok.nextToken().trim(); + try { + if (host.indexOf(":") >= 0) // IPV6 - requires patched Jetty 5 + _server.addListener('[' + host + "]:" + _listenPort); + else + _server.addListener(host + ':' + _listenPort); + boundAddresses++; + } catch (IOException ioe) { // this doesn't seem to work, exceptions don't happen until start() below + System.err.println("Unable to bind routerconsole to " + host + " port " + _listenPort + ' ' + ioe); + } } } + + // add SSL listeners + int sslPort = 0; + if (_sslListenPort != null) { + try { + sslPort = Integer.parseInt(_sslListenPort); + } catch (NumberFormatException nfe) {} + if (sslPort <= 0) + System.err.println("Bad routerconsole SSL port " + _sslListenPort); + } + if (sslPort > 0) { + I2PAppContext ctx = I2PAppContext.getGlobalContext(); + File keyStore = new File(ctx.getConfigDir(), "keystore/console.ks"); + if (verifyKeyStore(keyStore)) { + StringTokenizer tok = new StringTokenizer(_sslListenHost, " ,"); + while (tok.hasMoreTokens()) { + String host = tok.nextToken().trim(); + // doing it this way means we don't have to escape an IPv6 host with [] + InetAddrPort iap = new InetAddrPort(host, sslPort); + try { + SslListener ssll = new SslListener(iap); + // the keystore path and password + ssll.setKeystore(keyStore.getAbsolutePath()); + ssll.setPassword(ctx.getProperty(PROP_KEYSTORE_PASSWORD, DEFAULT_KEYSTORE_PASSWORD)); + // the X.509 cert password (if not present, verifyKeyStore() returned false) + ssll.setKeyPassword(ctx.getProperty(PROP_KEY_PASSWORD, "thisWontWork")); + _server.addListener(ssll); + boundAddresses++; + } catch (Exception e) { // probably no exceptions at this point + System.err.println("Unable to bind routerconsole to " + host + " port " + _listenPort + " for SSL: " + e); + } + } + } else { + System.err.println("Unable to create or access keystore for SSL: " + keyStore.getAbsolutePath()); + } + } + if (boundAddresses <= 0) { - System.err.println("Unable to bind routerconsole to any address on port " + _listenPort); + System.err.println("Unable to bind routerconsole to any address on port " + _listenPort + (sslPort > 0 ? (" or SSL port " + sslPort) : "")); return; } _server.setRootWebApp(ROUTERCONSOLE); @@ -201,6 +308,90 @@ public class RouterConsoleRunner { } } + /** + * @return success if it exists and we have a password, or it was created successfully. + * @since 0.8.3 + */ + private static boolean verifyKeyStore(File ks) { + if (ks.exists()) { + I2PAppContext ctx = I2PAppContext.getGlobalContext(); + boolean rv = ctx.getProperty(PROP_KEY_PASSWORD) != null; + if (!rv) + System.err.println("Console SSL error, must set " + PROP_KEY_PASSWORD + " in " + (new File(ctx.getConfigDir(), "router.config")).getAbsolutePath()); + return rv; + } + File dir = ks.getParentFile(); + if (!dir.exists()) { + File sdir = new SecureDirectory(dir.getAbsolutePath()); + if (!sdir.mkdir()) + return false; + } + return createKeyStore(ks); + } + + + /** + * Call out to keytool to create a new keystore with a keypair in it. + * Trying to do this programatically is a nightmare, requiring either BouncyCastle + * libs or using proprietary Sun libs, and it's a huge mess. + * + * @return success + * @since 0.8.3 + */ + private static boolean createKeyStore(File ks) { + I2PAppContext ctx = I2PAppContext.getGlobalContext(); + // make a random 48 character password (30 * 8 / 5) + byte[] rand = new byte[30]; + ctx.random().nextBytes(rand); + String keyPassword = Base32.encode(rand); + // and one for the cname + ctx.random().nextBytes(rand); + String cname = Base32.encode(rand) + ".console.i2p.net"; + + String keytool = (new File(System.getProperty("java.home"), "bin/keytool")).getAbsolutePath(); + String[] args = new String[] { + keytool, + "-genkey", // -genkeypair preferred in newer keytools, but this works with more + "-storetype", "JKS", + "-keystore", ks.getAbsolutePath(), + "-storepass", DEFAULT_KEYSTORE_PASSWORD, + "-alias", "console", + "-dname", "CN=" + cname + ",OU=Console,O=I2P Anonymous Network,L=XX,ST=XX,C=XX", + "-validity", "3652", // 10 years + "-keyalg", "DSA", + "-keysize", "1024", + "-keypass", keyPassword}; + boolean success = (new ShellCommand()).executeSilentAndWaitTimed(args, 30); // 30 secs + if (success) { + success = ks.exists(); + if (success) { + SecureFileOutputStream.setPerms(ks); + try { + RouterContext rctx = (RouterContext) ctx; + rctx.router().setConfigSetting(PROP_KEYSTORE_PASSWORD, DEFAULT_KEYSTORE_PASSWORD); + rctx.router().setConfigSetting(PROP_KEY_PASSWORD, keyPassword); + rctx.router().saveConfig(); + } catch (Exception e) {} // class cast exception + } + } + if (success) { + System.err.println("Created self-signed certificate for " + cname + " in keystore: " + ks.getAbsolutePath() + "\n" + + "The certificate name was generated randomly, and is not associated with your " + + "IP address, host name, router identity, or destination keys."); + } else { + System.err.println("Failed to create console SSL keystore using command line:"); + StringBuilder buf = new StringBuilder(256); + for (int i = 0; i < args.length; i++) { + buf.append('"').append(args[i]).append("\" "); + } + System.err.println(buf.toString()); + System.err.println("This is for the Sun/Oracle keytool, others may be incompatible.\n" + + "If you create the keystore manually, you must add " + PROP_KEYSTORE_PASSWORD + " and " + PROP_KEY_PASSWORD + + " to " + (new File(ctx.getConfigDir(), "router.config")).getAbsolutePath()); + } + return success; + } + static void initialize(WebApplicationContext context) { String password = getPassword(); if (password != null) { diff --git a/core/java/src/net/i2p/util/ShellCommand.java b/core/java/src/net/i2p/util/ShellCommand.java index d6fce0021..12b668f67 100644 --- a/core/java/src/net/i2p/util/ShellCommand.java +++ b/core/java/src/net/i2p/util/ShellCommand.java @@ -51,17 +51,18 @@ public class ShellCommand { */ private class CommandThread extends Thread { - final Object caller; - boolean consumeOutput; - String shellCommand; + private final Object caller; + private final boolean consumeOutput; + private final Object shellCommand; - CommandThread(Object caller, String shellCommand, boolean consumeOutput) { + /** + * @param shellCommand either a String or a String[] (since 0.8.3) + */ + CommandThread(Object caller, Object shellCommand, boolean consumeOutput) { super("CommandThread"); this.caller = caller; this.shellCommand = shellCommand; this.consumeOutput = consumeOutput; - _commandSuccessful = false; - _commandCompleted = false; } @Override @@ -200,6 +201,9 @@ public class ShellCommand { * {@link #getErrorStream()}, respectively. Input can be passed to the * STDIN of the shell process via {@link #getInputStream()}. * + * Warning, no good way to quote or escape spaces in arguments with this method. + * @deprecated unused + * * @param shellCommand The command for the shell to execute. */ public void execute(String shellCommand) { @@ -215,6 +219,9 @@ public class ShellCommand { * Input can be passed to the STDIN of the shell process via * {@link #getInputStream()}. * + * Warning, no good way to quote or escape spaces in arguments with this method. + * @deprecated unused + * * @param shellCommand The command for the shell to execute. * @return true if the spawned shell process * returns an exit status of 0 (indicating success), @@ -237,6 +244,9 @@ public class ShellCommand { * {@link #getErrorStream()}, respectively. Input can be passed to the * STDIN of the shell process via {@link #getInputStream()}. * + * Warning, no good way to quote or escape spaces in arguments with this method. + * @deprecated unused + * * @param shellCommand The command for the shell to execute. * @param seconds The method will return true if this * number of seconds elapses without the process @@ -276,6 +286,9 @@ public class ShellCommand { * without waiting for an exit status. Any output produced by the executed * command will not be displayed. * + * Warning, no good way to quote or escape spaces in arguments with this method. + * @deprecated unused + * * @param shellCommand The command for the shell to execute. * @throws IOException */ @@ -288,6 +301,8 @@ public class ShellCommand { * all of the command's resulting shell processes have completed. Any output * produced by the executed command will not be displayed. * + * Warning, no good way to quote or escape spaces in arguments with this method. + * * @param shellCommand The command for the shell to execute. * @return true if the spawned shell process * returns an exit status of 0 (indicating success), @@ -307,7 +322,12 @@ public class ShellCommand { * specified number of seconds has elapsed first. Any output produced by the * executed command will not be displayed. * - * @param shellCommand The command for the shell to execute. + * Warning, no good way to quote or escape spaces in arguments when shellCommand is a String. + * Use a String array for best results, especially on Windows. + * + * @param shellCommand The command for the shell to execute, as a String. + * You can't quote arguments successfully. + * See Runtime.exec(String) for more info. * @param seconds The method will return true if this * number of seconds elapses without the process * returning an exit status. A value of 0 @@ -317,7 +337,33 @@ public class ShellCommand { * else false. */ public synchronized boolean executeSilentAndWaitTimed(String shellCommand, int seconds) { + return executeSAWT(shellCommand, seconds); + } + /** + * Passes a command to the shell for execution. This method blocks until + * all of the command's resulting shell processes have completed unless a + * specified number of seconds has elapsed first. Any output produced by the + * executed command will not be displayed. + * + * @param commandArray The command for the shell to execute, + * as a String[]. + * See Runtime.exec(String[]) for more info. + * @param seconds The method will return true if this + * number of seconds elapses without the process + * returning an exit status. A value of 0 + * here disables waiting. + * @return true if the spawned shell process + * returns an exit status of 0 (indicating success), + * else false. + * @since 0.8.3 + */ + public synchronized boolean executeSilentAndWaitTimed(String[] commandArray, int seconds) { + return executeSAWT(commandArray, seconds); + } + + /** @since 0.8.3 */ + private boolean executeSAWT(Object shellCommand, int seconds) { _commandThread = new CommandThread(this, shellCommand, CONSUME_OUTPUT); _commandThread.start(); try { @@ -364,7 +410,10 @@ public class ShellCommand { return; } - private boolean execute(String shellCommand, boolean consumeOutput, boolean waitForExitStatus) { + /** + * @param shellCommand either a String or a String[] (since 0.8.3) - quick hack + */ + private boolean execute(Object shellCommand, boolean consumeOutput, boolean waitForExitStatus) { StreamConsumer processStderrConsumer; StreamConsumer processStdoutConsumer; @@ -374,7 +423,13 @@ public class ShellCommand { StreamReader processStdoutReader; try { - _process = Runtime.getRuntime().exec(shellCommand, null); + // easy way so we don't have to copy this whole method + if (shellCommand instanceof String) + _process = Runtime.getRuntime().exec((String)shellCommand); + else if (shellCommand instanceof String[]) + _process = Runtime.getRuntime().exec((String[])shellCommand); + else + throw new ClassCastException("shell command must be a String or a String[]"); if (consumeOutput) { processStderrConsumer = new StreamConsumer(_process.getErrorStream()); processStderrConsumer.start(); diff --git a/installer/resources/clients.config b/installer/resources/clients.config index f82aec526..08c6c62ba 100644 --- a/installer/resources/clients.config +++ b/installer/resources/clients.config @@ -6,6 +6,21 @@ # # fire up the web console +## There are several choices, here are some examples: +## non-SSL, bind to local IPv4 only +#clientApp.0.args=7657 127.0.0.1 ./webapps/ +## non-SSL, bind to local IPv6 only +#clientApp.0.args=7657 ::1 ./webapps/ +## non-SSL, bind to all IPv4 addresses +#clientApp.0.args=7657 0.0.0.0 ./webapps/ +## non-SSL, bind to all IPv6 addresses +#clientApp.0.args=7657 :: ./webapps/ +## For SSL only, change clientApp.4.args below to https:// +## SSL only +#clientApp.0.args=-s 7657 ::1,127.0.0.1 ./webapps/ +## non-SSL and SSL +#clientApp.0.args=7657 ::1,127.0.0.1 -s 7667 ::1,127.0.0.1 ./webapps/ +## non-SSL only, both IPv6 and IPv4 local interfaces clientApp.0.args=7657 ::1,127.0.0.1 ./webapps/ clientApp.0.main=net.i2p.router.web.RouterConsoleRunner clientApp.0.name=I2P Router Console