package org.getmonero.i2p.zero; import net.i2p.I2PAppContext; import net.i2p.app.ClientAppManager; import net.i2p.app.ClientAppManagerImpl; import net.i2p.client.I2PClient; import net.i2p.client.I2PClientFactory; import net.i2p.data.Base64; import net.i2p.data.Destination; import net.i2p.i2ptunnel.I2PTunnel; import net.i2p.sam.SAMBridge; import org.eclipse.jetty.server.*; import org.eclipse.jetty.server.handler.HandlerList; import org.eclipse.jetty.server.handler.ResourceHandler; import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.json.*; import java.io.*; import java.math.BigInteger; import java.net.InetAddress; import java.net.ServerSocket; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; public class TunnelControl implements Runnable { private RouterWrapper routerWrapper; private boolean stopping = false; private ServerSocket controlServerSocket; private File tunnelControlConfigDir; private File tunnelControlTempDir; private TunnelList tunnelList; private static final int TUNNEL_CONTROL_LISTENING_PORT = 8051; public static class TunnelList { private File tunnelControlConfigDir; private File tunnelControlTempDir; private RouterWrapper routerWrapper; private List tunnels = new ArrayList<>(); private List>> changeListeners = new ArrayList<>(); public TunnelList(File tunnelControlConfigDir, File tunnelControlTempDir, RouterWrapper routerWrapper) { this.routerWrapper = routerWrapper; this.tunnelControlConfigDir = tunnelControlConfigDir; this.tunnelControlTempDir = tunnelControlTempDir; } public void addChangeListener(ChangeListener> listener) { changeListeners.add(listener); } public void fireChangeEvent() { for(var listener : changeListeners) listener.onChange(tunnels); } public void addTunnel(Tunnel t) { tunnels.add(t); save(); fireChangeEvent(); } public void removeTunnel(Tunnel t) { tunnels.remove(t); save(); fireChangeEvent(); } public Stream getTunnelsCopyStream() { return new ArrayList<>(tunnels).stream(); } public void load() { try { File tunnelControlConfigFile = new File(tunnelControlConfigDir, "tunnels.json"); if (tunnelControlConfigFile.exists()) { tunnels.clear(); JSONObject root = new JSONObject(Files.readString(tunnelControlConfigFile.toPath())); JSONArray list = root.getJSONArray("tunnels"); for(int i=0; ithis.tunnelControlTempDir.delete())); tunnelList = new TunnelList(tunnelControlConfigDir, tunnelControlTempDir, routerWrapper); } public static abstract class Tunnel { RouterWrapper routerWrapper; public volatile I2PTunnel tunnel; public boolean enabled = true; public abstract String getType(); public abstract String getHost(); public abstract int getPort(); public abstract String getI2P(); public abstract Tunnel start(); protected Tunnel(RouterWrapper routerWrapper) { this.routerWrapper = routerWrapper; } public String getState() { return tunnel==null ? "opening" : "open"; } public boolean getEnabled() { return enabled; } public void destroy(boolean fastDestroy) { new Thread(()->{ // tunnels may sleep for 20 seconds while waiting to open. we may be in a hurry if(!fastDestroy) { while(tunnel==null) { try { Thread.sleep(100); } catch (InterruptedException e) {} } // wait for tunnel to be established before closing it } if(tunnel!=null) { tunnel.runClose(new String[]{"forced", "all"}, tunnel); } }).start(); } } public static class ClientTunnel extends Tunnel { public String dest; public int port; public ClientTunnel(String dest, int port, RouterWrapper routerWrapper) { super(routerWrapper); this.dest = dest; this.port = port; } @Override public Tunnel start() { new Thread(()->{ routerWrapper.waitForRouterRunning(); tunnel = new I2PTunnel(new String[]{"-die", "-nocli", "-e", "config localhost 7654", "-e", "client " + port + " " + dest}); }).start(); return this; } @Override public String getType() { return "client"; } @Override public String getHost() { return "localhost"; } @Override public int getPort() { return port; } @Override public String getI2P() { return dest; } } public static class HttpClientTunnel extends Tunnel { public int port; public HttpClientTunnel(int port, RouterWrapper routerWrapper) { super(routerWrapper); this.port = port; } @Override public Tunnel start() { new Thread(()->{ routerWrapper.waitForRouterRunning(); tunnel = new I2PTunnel(new String[]{"-die", "-nocli", "-e", "config localhost 7654", "-e", "httpclient " + port}); }).start(); return this; } @Override public String getType() { return "http"; } @Override public String getHost() { return "localhost"; } @Override public int getPort() { return port; } @Override public String getI2P() { return "n/a"; } } public static class ServerTunnel extends Tunnel { public String dest; public String host; public int port; public KeyPair keyPair; private File tunnelControlTempDir; public ServerTunnel(String host, int port, KeyPair keyPair, File tunnelControlTempDir, RouterWrapper routerWrapper) { super(routerWrapper); try { this.host = host; this.port = port; this.keyPair = keyPair; this.dest = keyPair.b32Dest; this.tunnelControlTempDir = tunnelControlTempDir; } catch (Exception e) { throw new RuntimeException(e); } } @Override public Tunnel start() { new Thread(() -> { routerWrapper.waitForRouterRunning(); try { String uuid = new BigInteger(128, new Random()).toString(16); String seckeyPath = tunnelControlTempDir.getAbsolutePath() + File.separator + "seckey." + uuid + ".dat"; Files.write(Path.of(seckeyPath), Base64.decode(keyPair.seckey)); new File(seckeyPath).deleteOnExit(); // clean up temporary file that was only required because new I2PTunnel() requires it to be written to disk // listen using the I2P server keypair, and forward incoming connections to a destination and port tunnel = new I2PTunnel(new String[]{"-die", "-nocli", "-e", "server " + host + " " + port + " " + seckeyPath}); } catch(Exception e) { throw new RuntimeException(e); } }).start(); return this; } @Override public String getType() { return "server"; } @Override public String getHost() { return host; } @Override public int getPort() { return port; } @Override public String getI2P() { return dest; } } public static class EepSiteTunnel extends ServerTunnel { public Server server; public String contentDir; public String logsDir; public Boolean allowDirectoryBrowsing; public Boolean enableLogs; public EepSiteTunnel(boolean enabled, KeyPair keyPair, String contentDirStr, String logsDirStr, boolean allowDirectoryBrowsing, boolean enableLogs, int port, File tunnelControlTempDir, RouterWrapper routerWrapper) { super("localhost", port, keyPair, tunnelControlTempDir, routerWrapper); this.enabled = enabled; this.contentDir = contentDirStr; this.logsDir = logsDirStr; this.allowDirectoryBrowsing = allowDirectoryBrowsing; this.enableLogs = enableLogs; this.port = port; this.keyPair = keyPair; } @Override public void destroy(boolean fastDestroy) { try { server.stop(); super.destroy(fastDestroy); } catch (Exception e) { throw new RuntimeException(e); } } public void stopJetty() { if(server==null) return; try { server.stop(); server = null; } catch (Exception e) { throw new RuntimeException(e); } } public void startJetty() { try { File contentDirFile = new File(contentDir); File logsDirFile = new File(logsDir); contentDirFile.mkdirs(); logsDirFile.mkdirs(); int maxThreads = 100; int minThreads = 1; int idleTimeout = 120; QueuedThreadPool threadPool = new QueuedThreadPool(maxThreads, minThreads, idleTimeout); server = new Server(threadPool); server.setStopAtShutdown(true); HttpConfiguration httpConfig = new HttpConfiguration(); httpConfig.setSendServerVersion(false); httpConfig.setSendDateHeader(false); httpConfig.setSendXPoweredBy(false); httpConfig.setSendServerVersion(false); HandlerList handlers = new HandlerList(); ResourceHandler resourceHandler = new ResourceHandler(); resourceHandler.setDirectoriesListed(allowDirectoryBrowsing); resourceHandler.setWelcomeFiles(new String[]{"index.html"}); resourceHandler.setResourceBase(contentDirFile.getAbsolutePath()); handlers.addHandler(resourceHandler); server.setHandler(handlers); if(enableLogs) { NCSARequestLog requestLog = new NCSARequestLog(logsDirFile.getAbsolutePath() + File.separator + "eepsite-yyyy_mm_dd.request.log"); requestLog.setAppend(true); requestLog.setExtended(false); requestLog.setLogTimeZone("UTC"); requestLog.setLogLatency(true); requestLog.setRetainDays(0); server.setRequestLog(requestLog); } ServerConnector http = new ServerConnector(server, new HttpConnectionFactory(httpConfig)); http.setPort(port); http.setIdleTimeout(60000); http.setHost("localhost"); server.addConnector(http); server.start(); server.join(); } catch (Exception e) { throw new RuntimeException(e); } } @Override public Tunnel start() { if(!enabled) return this; new Thread(()->startJetty()).start(); return super.start(); } @Override public String getType() { return "eepsite"; } } public static class SocksTunnel extends Tunnel { public int port; public SocksTunnel(int port, RouterWrapper routerWrapper) { super(routerWrapper); this.port = port; } @Override public Tunnel start() { new Thread(()->{ routerWrapper.waitForRouterRunning(); tunnel = new I2PTunnel(new String[]{"-die", "-nocli", "-e", "sockstunnel " + port}); }).start(); return this; } @Override public String getType() { return "socks"; } @Override public String getHost() { return "localhost"; } @Override public int getPort() { return port; } @Override public String getI2P() { return "n/a"; } } public TunnelList getTunnelList() { return tunnelList; } public static class KeyPair { public String seckey; public String pubkey; public String b32Dest; public KeyPair(String seckey, String pubkey, String b32Dest) { this.seckey = seckey; this.pubkey = pubkey; this.b32Dest = b32Dest; } @Override public String toString() { return seckey + "," + pubkey; } public KeyPair(String base64EncodedCommaDelimitedPair) { try { String[] a = base64EncodedCommaDelimitedPair.split(","); this.seckey = a[0]; this.pubkey = a[1]; Destination d = new Destination(); d.readBytes(new ByteArrayInputStream(Base64.decode(this.seckey))); this.b32Dest = d.toBase32(); } catch (Exception e) { throw new RuntimeException(e); } } public static KeyPair gen() { try { ByteArrayOutputStream seckey = new ByteArrayOutputStream(); ByteArrayOutputStream pubkey = new ByteArrayOutputStream(); I2PClient client = I2PClientFactory.createClient(); Destination d = client.createDestination(seckey); d.writeBytes(pubkey); String b32Dest = d.toBase32(); return new KeyPair(Base64.encode(seckey.toByteArray()), Base64.encode(pubkey.toByteArray()), b32Dest); } catch (Exception e){ throw new RuntimeException(e); } } public static KeyPair read(String path) throws Exception { return new KeyPair(Files.readString(Paths.get(path))); } public void write(String path) throws Exception { Files.writeString(Paths.get(path), toString()); } } public boolean isPortAlreadyAssigned(int port) { return tunnelList.tunnels.stream().anyMatch(t->!(t instanceof ServerTunnel) && t.getPort()==port); } static class KeyPairHolder { public KeyPair keyPair = null; } public static KeyPair generateVanityKeypair(String vanityPrefix) { if(vanityPrefix.length()>3) vanityPrefix = vanityPrefix.substring(0, 3); vanityPrefix = vanityPrefix.toLowerCase(); for(int i=0; i='a' && c<='z') || (c>='0' && c<='9'))) { vanityPrefix = ""; break; } } String prefix = vanityPrefix; List threads = new ArrayList<>(); KeyPairHolder keyPairHolder = new KeyPairHolder(); for(int i=0; i{ KeyPair keyPair; while(keyPairHolder.keyPair==null) { keyPair = KeyPair.gen(); if(keyPair.b32Dest.startsWith(prefix)) { char c = keyPair.b32Dest.charAt(prefix.length()); // require that a digit follows the prefix, so that the prefix is more distinctive if((c>='0' && c<='9')) { keyPairHolder.keyPair = keyPair; } } } }); threads.add(t); t.start(); } while(threads.stream().anyMatch(Thread::isAlive)) { try { Thread.sleep(50); } catch (InterruptedException e) {} } return keyPairHolder.keyPair; } @Override public void run() { tunnelList.load(); for(var t : tunnelList.tunnels) if(t.getEnabled()) t.start(); try { controlServerSocket = new ServerSocket(TUNNEL_CONTROL_LISTENING_PORT, 0, InetAddress.getLoopbackAddress()); while (!stopping) { try (var socket = controlServerSocket.accept()) { var out = new PrintWriter(socket.getOutputStream(), true); var in = new BufferedReader(new InputStreamReader(socket.getInputStream())); var args = in.readLine().split(" "); String vanityPrefix = null; switch(args[0]) { case "server.create.vanity" : { vanityPrefix = args[4]; } case "server.create": { String destHost = args[1]; int destPort = Integer.parseInt(args[2]); File serverTunnelConfigDir = null; if(args.length>=4 && !"none".equals(args[3])) serverTunnelConfigDir = new File(args[3]); File serverKeyFile; KeyPair keyPair; // generate vanity address for specified 3 character prefix keyPair = vanityPrefix==null ? KeyPair.gen() : generateVanityKeypair(vanityPrefix); if(serverTunnelConfigDir!=null) { if (!serverTunnelConfigDir.exists() || serverTunnelConfigDir.listFiles((dir, name) -> name.toLowerCase().endsWith(".keys")).length == 0) { serverTunnelConfigDir.mkdir(); serverKeyFile = new File(serverTunnelConfigDir, keyPair.b32Dest + ".keys"); keyPair.write(serverKeyFile.getPath()); } else { serverKeyFile = serverTunnelConfigDir.listFiles((dir, name) -> name.toLowerCase().endsWith(".keys"))[0]; keyPair = KeyPair.read(serverKeyFile.getPath()); } } var tunnel = new ServerTunnel(destHost, destPort, keyPair, getTunnelControlTempDir(), routerWrapper); tunnel.start(); tunnelList.addTunnel(tunnel); out.println(tunnel.dest); break; } case "server.destroy": { String dest = args[1]; tunnelList.getTunnelsCopyStream().filter(t -> t.getType().equals("server") && ((ServerTunnel) t).dest.equals(dest)).forEach(t -> { t.destroy(false); tunnelList.removeTunnel(t); }); out.println("OK"); break; } case "server.state": { String dest = args[1]; tunnelList.getTunnelsCopyStream().filter(t -> t.getType().equals("server") && ((ServerTunnel) t).dest.equals(dest)).forEach(t -> { out.println((t.getState())); }); break; } case "client.create": { String dest = args[1]; int port = Integer.parseInt(args[2]); if(isPortAlreadyAssigned(port)) { out.println("ERROR - PORT ALREADY ASSIGNED"); } else { var clientTunnel = new ClientTunnel(dest, port, routerWrapper); clientTunnel.start(); tunnelList.addTunnel(clientTunnel); out.println(clientTunnel.port); } break; } case "client.destroy": { int port = Integer.parseInt(args[1]); tunnelList.getTunnelsCopyStream().filter(t->t.getType().equals("client") && ((ClientTunnel) t).port == port).forEach(t->{ t.destroy(false); tunnelList.removeTunnel(t); }); out.println("OK"); break; } case "client.state": { int port = Integer.parseInt(args[1]); tunnelList.getTunnelsCopyStream().filter(t->t.getType().equals("client") && ((ClientTunnel) t).port == port).forEach(t->{ out.println((t.getState())); }); break; } case "socks.create": { int port = Integer.parseInt(args[1]); if(isPortAlreadyAssigned(port)) { out.println("ERROR - PORT ALREADY ASSIGNED"); } else { tunnelList.addTunnel(new SocksTunnel(port, routerWrapper).start()); out.println("OK"); } break; } case "socks.destroy": { int port = Integer.parseInt(args[1]); tunnelList.getTunnelsCopyStream().filter(t -> t.getType().equals("socks") && ((SocksTunnel) t).port == port).forEach(t -> { t.destroy(false); tunnelList.removeTunnel(t); }); out.println("OK"); break; } case "socks.state": { int port = Integer.parseInt(args[1]); tunnelList.getTunnelsCopyStream().filter(t -> t.getType().equals("socks") && ((SocksTunnel) t).port == port).forEach(t -> { out.println((t.getState())); }); break; } case "http.create": { int port = Integer.parseInt(args[1]); if(isPortAlreadyAssigned(port)) { out.println("ERROR - PORT ALREADY ASSIGNED"); } else { tunnelList.addTunnel(new HttpClientTunnel(port, routerWrapper).start()); out.println("OK"); } break; } case "http.destroy": { int port = Integer.parseInt(args[1]); tunnelList.getTunnelsCopyStream().filter(t -> t.getType().equals("http") && ((SocksTunnel) t).port == port).forEach(t -> { t.destroy(false); tunnelList.removeTunnel(t); }); out.println("OK"); break; } case "http.state": { int port = Integer.parseInt(args[1]); tunnelList.getTunnelsCopyStream().filter(t -> t.getType().equals("http") && ((HttpClientTunnel) t).port == port).forEach(t -> { out.println((t.getState())); }); break; } case "all.destroy": { tunnelList.getTunnelsCopyStream().forEach(t -> { t.destroy(false); tunnelList.removeTunnel(t); }); out.println("OK"); break; } case "all.list": { out.println(tunnelList.getJSON(false, true)); break; } case "version": { out.println("i2p-zero " + UpdateCheck.currentVersion); break; } case "router.reachability": { out.println(routerWrapper.getReachability().getMessage()); break; } case "router.isRunning": { out.println(routerWrapper.isRouterRunning()); break; } case "router.externalPort": { out.println(routerWrapper.routerExternalPort); break; } case "router.setBandwidthLimitKBps": { int n = Integer.parseInt(args[1]); routerWrapper.updateBandwidthLimitKBps(n); out.println("OK"); break; } case "router.getBandwidthLimitKBps": { out.println(routerWrapper.getBandwidthLimitKBps()); break; } case "router.getBandwidthStats": { Map stats = new HashMap<>(); stats.put("1sRateInKBps", routerWrapper.get1sRateInKBps()); stats.put("1sRateOutKBps", routerWrapper.get1sRateOutKBps()); stats.put("5mRateInKBps", routerWrapper.get5mRateInKBps()); stats.put("5mRateOutKBps", routerWrapper.get5mRateOutKBps()); stats.put("avgRateInKBps", routerWrapper.getAvgRateInKBps()); stats.put("avgRateOutKBps", routerWrapper.getAvgRateOutKBps()); stats.put("totalInMB", routerWrapper.getTotalInMB()); stats.put("totalOutMB", routerWrapper.getTotalOutMB()); out.println(stats.entrySet().stream().map(e->e.getKey()+"="+e.getValue()).collect(Collectors.joining(","))); break; } case "sam.create": { String[] samArgs = new String[]{"sam.keys", "127.0.0.1", "7656", "i2cp.tcp.host=127.0.0.1", "i2cp.tcp.port=7654"}; I2PAppContext context = routerWrapper.getRouter().getContext(); ClientAppManager mgr = new ClientAppManagerImpl(context); SAMBridge samBridge = new SAMBridge(context, mgr, samArgs); samBridge.startup(); out.println("OK"); break; } } } catch (Exception e) { if(!e.getMessage().contains("Socket closed")) e.printStackTrace(); } } } catch (Exception e) { e.printStackTrace(); } finally { if(controlServerSocket!=null) try { controlServerSocket.close(); } catch (Exception e) { e.printStackTrace(); } } } public void stop(boolean fastStop) { stopping = true; try { getTunnelList().tunnels.forEach(t->t.destroy(fastStop)); controlServerSocket.close(); } catch (Exception e) { e.printStackTrace(); } } public EepSiteTunnel getEepSiteTunnel() { Optional eepSiteTunnelOptional = getTunnelList().getTunnelsCopyStream().filter(t->t.getType().equals("eepsite")).findFirst(); if(eepSiteTunnelOptional.isPresent()) return (EepSiteTunnel) eepSiteTunnelOptional.get(); else { EepSiteTunnel eepSiteTunnel = new EepSiteTunnel(false, KeyPair.gen(), System.getProperty("user.home") + File.separator + ".i2p-zero" + File.separator + "eepsite" + File.separator + "content", System.getProperty("user.home") + File.separator + ".i2p-zero" + File.separator + "eepsite" + File.separator + "logs", true, true, 8080, getTunnelControlTempDir(), routerWrapper); getTunnelList().addTunnel(eepSiteTunnel); return eepSiteTunnel; } } public File getTunnelControlTempDir() { return tunnelControlTempDir; } public static boolean isPortInUse() { try (var socket = new ServerSocket(TUNNEL_CONTROL_LISTENING_PORT, 0, InetAddress.getLoopbackAddress())) { return false; } catch (IOException e) { return true; } } }