From 6655c328a616751ce8715ac8cea59dfd7b0c2072 Mon Sep 17 00:00:00 2001
From: Stefan Rossbach <srossbach@arcor.de>
Date: Sun, 8 Mar 2020 02:05:08 +0100
Subject: [PATCH] [INTERNAL][CORE] moves Socks5Logic out of XMPPConnection

The whole logic regarding the Socks5Proxy is now handled in its own
class.

In addition only the STUN discovery is performed asynchronous.

Assuming the UPNP router works as expected mapping and unmapping ports
just takes a few milliseconds.
---
 core/src/saros/net/util/NetworkingUtils.java  |  22 --
 .../saros/net/xmpp/Socks5ProxySupport.java    | 326 ++++++++++++++++++
 .../saros/net/xmpp/XMPPConnectionService.java | 226 +-----------
 3 files changed, 344 insertions(+), 230 deletions(-)
 create mode 100644 core/src/saros/net/xmpp/Socks5ProxySupport.java

diff --git a/core/src/saros/net/util/NetworkingUtils.java b/core/src/saros/net/util/NetworkingUtils.java
index 2932218185..f27b151451 100644
--- a/core/src/saros/net/util/NetworkingUtils.java
+++ b/core/src/saros/net/util/NetworkingUtils.java
@@ -4,7 +4,6 @@
 import java.net.InetAddress;
 import java.net.NetworkInterface;
 import java.net.SocketException;
-import java.util.ArrayList;
 import java.util.Enumeration;
 import java.util.LinkedList;
 import java.util.List;
@@ -78,25 +77,4 @@ public static Socks5Proxy getSocks5ProxySafe() {
 
     return proxy;
   }
-
-  /**
-   * Adds a specified IP (String) to the list of addresses of the Socks5Proxy. (the target attempts
-   * the stream host addresses one by one in the order of the list)
-   *
-   * @param ip String of the address of the Socks5Proxy (stream host)
-   * @param inFront boolean flag, if the address is to be inserted in front of the list. If <code>
-   *     false</code>, address is added at the end of the list.
-   */
-  public static void addProxyAddress(String ip, boolean inFront) {
-    Socks5Proxy proxy = getSocks5ProxySafe();
-
-    if (!inFront) {
-      proxy.addLocalAddress(ip);
-      return;
-    }
-    ArrayList<String> list = new ArrayList<String>(proxy.getLocalAddresses());
-    list.remove(ip);
-    list.add(0, ip);
-    proxy.replaceLocalAddresses(list);
-  }
 }
diff --git a/core/src/saros/net/xmpp/Socks5ProxySupport.java b/core/src/saros/net/xmpp/Socks5ProxySupport.java
new file mode 100644
index 0000000000..3073c15c18
--- /dev/null
+++ b/core/src/saros/net/xmpp/Socks5ProxySupport.java
@@ -0,0 +1,326 @@
+package saros.net.xmpp;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.apache.log4j.Logger;
+import org.bitlet.weupnp.GatewayDevice;
+import org.jivesoftware.smack.SmackConfiguration;
+import org.jivesoftware.smackx.bytestreams.socks5.Socks5Proxy;
+import saros.net.stun.IStunService;
+import saros.net.upnp.IUPnPService;
+import saros.net.util.NetworkingUtils;
+import saros.util.ThreadUtils;
+
+/**
+ * Access class for accessing the Smack Socks5 proxy. It supports UPNP and STUN to retrieve possible
+ * Socks5 candidates and allows access to the local Smack Socks5 proxy behind gateways that supports
+ * UPNP.
+ */
+class Socks5ProxySupport {
+
+  private static final int STUN_DISCOVERY_TIMEOUT = 10000;
+
+  /* DO NOT CHANGE THE CONTENT OF THIS STRING, NEVER NEVER NEVER !!!
+   * The UNPN implementation will currently not overwrite present PORT mappings if they share not
+   * the same description. */
+  private static final String UPNP_PORT_MAPPING_DESCRIPTION = "Saros Socks5 TCP";
+
+  private static final Logger log = Logger.getLogger(Socks5ProxySupport.class);
+
+  private static final Object socks5AddressReplacementLock = new Object();
+
+  private final IUPnPService upnpService;
+  private final IStunService stunService;
+
+  /** The current gateway device to use for port mapping or <code>null</code>. */
+  private GatewayDevice device;
+
+  /** The current used Socks5 proxy or <code>null</code>. */
+  private Socks5Proxy socks5Proxy;
+
+  /**
+   * There is so much magic involved in Smack. Performing a disconnect shuts the proxy down so we
+   * need to remember the port.
+   */
+  private int socks5ProxyPort;
+
+  public Socks5ProxySupport(final IUPnPService upnpService, final IStunService stunService) {
+    this.upnpService = upnpService;
+    this.stunService = stunService;
+  }
+
+  /**
+   * Enables the Socks5 proxy. If the port number is negative the logic will try to find an unused
+   * port starting with the positive value of the port number up to 65535.
+   *
+   * @param port the port to use
+   * @param proxyAddresses collection containing addresses that should be published as public
+   *     addresses for connection purpose
+   * @param gatewayDeviceId the ID an UPNP device for port mapping or <code>null</code>
+   * @param useExternalGatewayDeviceAddress if <code>true</code> the public address of the device
+   *     will be published
+   * @param stunServerAddress address of a stun server to retrieve and publish public addresses or
+   *     <code>null</code>
+   * @param stunServerPort port of the stun server, not used if <code>stunServerAddress</code> is
+   *     <code>null</code>
+   * @return <code>true</code> if the proxy was successfully started, <code>false</code> if it could
+   *     not be started or is already running
+   */
+  public synchronized boolean enableProxy(
+      final int port,
+      final Collection<String> proxyAddresses,
+      final String gatewayDeviceId,
+      final boolean useExternalGatewayDeviceAddress,
+      final String stunServerAddress,
+      final int stunServerPort) {
+
+    if (socks5Proxy != null) return false;
+
+    SmackConfiguration.setLocalSocks5ProxyEnabled(true);
+    SmackConfiguration.setLocalSocks5ProxyPort(port);
+
+    socks5Proxy = Socks5Proxy.getSocks5Proxy();
+
+    if (socks5Proxy == null) {
+      log.warn("failed to start Socks5 proxy on port: " + port);
+      SmackConfiguration.setLocalSocks5ProxyEnabled(false);
+      return false;
+    }
+
+    socks5ProxyPort = socks5Proxy.getPort();
+
+    // Unlikely but the connection was already lost, but signal that the proxy as running.
+    if (socks5ProxyPort <= 0) return true;
+
+    // Remove any addresses that Smack already discovered because we use our own logic.
+    socks5Proxy.replaceLocalAddresses(Collections.emptyList());
+
+    log.info(
+        "started Socks5 proxy on port: "
+            + socks5Proxy.getPort()
+            + " [listening on all interfaces]");
+
+    final List<String> proxyAddressesToPublish = new ArrayList<>();
+
+    if (proxyAddresses != null && proxyAddresses.isEmpty())
+      log.warn("Socks5 preconfigured addresses list is empty, using autodetect mode");
+
+    if (proxyAddresses == null || proxyAddresses.isEmpty()) {
+      NetworkingUtils.getAllNonLoopbackLocalIPAddresses(true)
+          .stream()
+          .map(InetAddress::getHostAddress)
+          .forEach(proxyAddressesToPublish::add);
+    } else {
+      proxyAddressesToPublish.addAll(proxyAddresses);
+    }
+
+    addSocks5ProxyAddresses(socks5Proxy, proxyAddressesToPublish, true);
+
+    // as STUN discovery can fail, take ages etc. do not block here
+    if (stunService != null && stunServerAddress != null && !stunServerAddress.isEmpty()) {
+
+      ThreadUtils.runSafeAsync(
+          "saros-stun-discovery",
+          log,
+          () -> {
+            discoverAndPublishStunAddresses(socks5Proxy, stunServerAddress, stunServerPort);
+          });
+    }
+
+    if (upnpService != null && gatewayDeviceId != null && !gatewayDeviceId.isEmpty()) {
+      device = getGatewayDevice(gatewayDeviceId);
+
+      if (device == null) {
+        log.warn(
+            "could not find a gateway device with id: + "
+                + gatewayDeviceId
+                + " in the current network environment");
+      } else {
+        mapPort(socks5Proxy, upnpService, device);
+
+        if (useExternalGatewayDeviceAddress) {
+          final InetAddress externalAddress = upnpService.getExternalAddress(device);
+
+          if (externalAddress != null) {
+            log.debug(
+                "obtained public IP address "
+                    + externalAddress
+                    + " from device: "
+                    + device.getFriendlyName());
+
+            addSocks5ProxyAddresses(
+                socks5Proxy, Collections.singletonList(externalAddress.getHostAddress()), true);
+          }
+        }
+      }
+    }
+
+    return true;
+  }
+
+  /**
+   * Stops the current Socks5 proxy if enabled and disables further usage of the Socks5 proxy. This
+   * method can be safely called to just prevent the global usage of the Socks5 proxy.
+   */
+  public synchronized void disableProxy() {
+
+    if (socks5Proxy == null) {
+      SmackConfiguration.setLocalSocks5ProxyEnabled(false);
+      return;
+    }
+
+    socks5Proxy.stop();
+
+    SmackConfiguration.setLocalSocks5ProxyEnabled(false);
+
+    socks5Proxy = null;
+
+    log.info("stopped Socks5 proxy on port: " + socks5ProxyPort);
+
+    if (socks5ProxyPort > 0 && device != null) {
+      assert upnpService != null;
+      unmapPort(upnpService, device, socks5ProxyPort);
+    }
+
+    socks5ProxyPort = 0;
+    device = null;
+  }
+
+  private GatewayDevice getGatewayDevice(final String gatewayDeviceId) {
+    assert (upnpService != null);
+
+    final List<GatewayDevice> devices = upnpService.getGateways(false);
+
+    if (devices == null) {
+      log.warn("unable to retrieve gateway device(s) due to network failure");
+      return null;
+    }
+
+    for (GatewayDevice currentDevice : devices) {
+      if (gatewayDeviceId.equals(currentDevice.getUSN())) {
+        return currentDevice;
+      }
+    }
+
+    return null;
+  }
+
+  private static void mapPort(
+      final Socks5Proxy proxy, final IUPnPService upnpService, final GatewayDevice device) {
+
+    final int socks5ProxyPort = proxy.getPort();
+
+    if (socks5ProxyPort <= 0) return;
+
+    upnpService.deletePortMapping(device, socks5ProxyPort, IUPnPService.TCP);
+
+    if (!upnpService.createPortMapping(
+        device, socks5ProxyPort, IUPnPService.TCP, UPNP_PORT_MAPPING_DESCRIPTION)) {
+
+      log.warn(
+          "failed to create port mapping on device: "
+              + device.getFriendlyName()
+              + " ["
+              + socks5ProxyPort
+              + "|"
+              + IUPnPService.TCP
+              + "]");
+
+      return;
+    }
+
+    log.info(
+        "added port mapping on device: "
+            + device.getFriendlyName()
+            + " ["
+            + socks5ProxyPort
+            + "|"
+            + IUPnPService.TCP
+            + "]");
+  }
+
+  private static void unmapPort(
+      final IUPnPService upnpService, final GatewayDevice device, final int port) {
+
+    if (!upnpService.isMapped(device, port, IUPnPService.TCP)) return;
+
+    if (!upnpService.deletePortMapping(device, port, IUPnPService.TCP)) {
+      log.warn(
+          "failed to delete port mapping on device: "
+              + device.getFriendlyName()
+              + " ["
+              + port
+              + "|"
+              + IUPnPService.TCP
+              + "]");
+    }
+
+    log.info(
+        "removed port mapping on device: "
+            + device.getFriendlyName()
+            + " ["
+            + port
+            + "|"
+            + IUPnPService.TCP
+            + "]");
+  }
+
+  private void discoverAndPublishStunAddresses(
+      final Socks5Proxy proxy, final String stunServerAddress, final int stunServerPort) {
+
+    assert (stunService != null);
+
+    final Collection<InetSocketAddress> addresses =
+        stunService.discover(stunServerAddress, stunServerPort, STUN_DISCOVERY_TIMEOUT);
+
+    if (addresses.isEmpty()) {
+      log.warn(
+          "could not discover any public address using STUN server "
+              + stunServerAddress
+              + " (port="
+              + stunServerPort
+              + ")");
+
+      return;
+    }
+
+    // stun returns always literal IP addresses
+    final List<String> discoveredAddresses =
+        addresses.stream().map(InetSocketAddress::getHostString).collect(Collectors.toList());
+    log.debug(
+        "STUN discovery result for STUN server "
+            + stunServerAddress
+            + " (port="
+            + stunServerPort
+            + ") : "
+            + discoveredAddresses);
+
+    addSocks5ProxyAddresses(proxy, discoveredAddresses, true);
+  }
+
+  private static void addSocks5ProxyAddresses(
+      final Socks5Proxy proxy, final Collection<String> addresses, boolean inFront) {
+
+    synchronized (socks5AddressReplacementLock) {
+      final List<String> newAddresses = new ArrayList<>();
+
+      if (inFront) newAddresses.addAll(addresses);
+
+      newAddresses.addAll(proxy.getLocalAddresses());
+
+      if (!inFront) newAddresses.addAll(addresses);
+
+      final List<String> distinctAddresses =
+          newAddresses.stream().sequential().distinct().collect(Collectors.toList());
+
+      log.info("Socks5 proxy - public published IP addresses : " + distinctAddresses);
+
+      proxy.replaceLocalAddresses(distinctAddresses);
+    }
+  }
+}
diff --git a/core/src/saros/net/xmpp/XMPPConnectionService.java b/core/src/saros/net/xmpp/XMPPConnectionService.java
index 6dc24657ba..487fac8138 100644
--- a/core/src/saros/net/xmpp/XMPPConnectionService.java
+++ b/core/src/saros/net/xmpp/XMPPConnectionService.java
@@ -1,14 +1,10 @@
 package saros.net.xmpp;
 
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.concurrent.CountDownLatch;
 import org.apache.log4j.Logger;
-import org.bitlet.weupnp.GatewayDevice;
 import org.jivesoftware.smack.Connection;
 import org.jivesoftware.smack.ConnectionConfiguration;
 import org.jivesoftware.smack.ConnectionListener;
@@ -18,16 +14,13 @@
 import org.jivesoftware.smack.XMPPException;
 import org.jivesoftware.smack.packet.Presence;
 import org.jivesoftware.smackx.ServiceDiscoveryManager;
-import org.jivesoftware.smackx.bytestreams.socks5.Socks5Proxy;
 import org.jivesoftware.smackx.entitycaps.EntityCapsManager;
 import org.jivesoftware.smackx.entitycaps.packet.CapsExtension;
 import saros.annotations.Component;
 import saros.net.ConnectionState;
 import saros.net.stun.IStunService;
 import saros.net.upnp.IUPnPService;
-import saros.net.util.NetworkingUtils;
 import saros.repackaged.picocontainer.annotations.Nullable;
-import saros.util.ThreadUtils;
 
 /**
  * This class is responsible for establishing XMPP connections and notifying registered listeners
@@ -38,14 +31,12 @@
  */
 @Component(module = "net")
 public class XMPPConnectionService {
+
   public static final String XMPP_CLIENT_IDENTIFIER = "https://saros-project.org";
   private static final String CAPS_HASH_ALGORITHM = "sha-1";
 
   private static final Logger log = Logger.getLogger(XMPPConnectionService.class);
 
-  // DO NOT CHANGE THE CONTENT OF THIS STRING, NEVER NEVER NEVER !!!
-  private static final String PORT_MAPPING_DESCRIPTION = "Saros Socks5 TCP";
-
   private Connection connection;
 
   private String resource;
@@ -59,18 +50,14 @@ public class XMPPConnectionService {
   /** The current port of the STUN server or <code>null</code>. */
   private int stunPort;
 
-  /** The current gateway device to use for port mapping or <code>null</code>. */
-  private GatewayDevice device;
-
+  /**
+   * Flag indicating if the public IP address of the gateway device should be published as Socks5
+   * candidate.
+   */
   private boolean useExternalGatewayDeviceAddress;
 
-  /** The listening port of the current Socks5Proxy. Is <b>-1</b> if the proxy is not running. */
-  private int socks5ProxyPort;
-
   private List<String> proxyAddresses;
 
-  private final Object portMappingLock = new Object();
-
   private JID localJID;
 
   private ConnectionState connectionState = ConnectionState.NOT_CONNECTED;
@@ -80,9 +67,7 @@ public class XMPPConnectionService {
   private final List<IConnectionListener> listeners =
       new CopyOnWriteArrayList<IConnectionListener>();
 
-  private final IStunService stunService;
-
-  private final IUPnPService upnpService;
+  private final Socks5ProxySupport socks5ProxySupport;
 
   private int packetReplyTimeout;
 
@@ -127,9 +112,7 @@ public void reconnectionSuccessful() {
 
   public XMPPConnectionService(
       @Nullable IUPnPService upnpService, @Nullable IStunService stunService) {
-    this.upnpService = upnpService;
-    this.stunService = stunService;
-
+    socks5ProxySupport = new Socks5ProxySupport(upnpService, stunService);
     packetReplyTimeout = Integer.getInteger("saros.net.smack.PACKET_REPLAY_TIMEOUT", 30000);
   }
 
@@ -180,28 +163,9 @@ public synchronized void configure(
     this.stunServer = stunServer;
     this.stunPort = stunPort;
 
-    this.device = null;
-
     if (this.stunServer != null && this.stunServer.isEmpty()) this.stunServer = null;
 
     if (this.gatewayDeviceID != null && this.gatewayDeviceID.isEmpty()) this.gatewayDeviceID = null;
-
-    if (this.gatewayDeviceID == null) return;
-
-    /*
-     * perform blocking tasks in the background meanwhile to speed up first
-     * connection attempt, currently it is only UPNP discovery
-     */
-
-    ThreadUtils.runSafeAsync(
-        "upnp-resolver",
-        log,
-        new Runnable() {
-          @Override
-          public void run() {
-            if (upnpService != null) upnpService.getGateways(true);
-          }
-        });
   }
 
   /**
@@ -410,175 +374,21 @@ private void initialzeNetworkComponents() {
      */
     SmackConfiguration.setPacketReplyTimeout(packetReplyTimeout);
 
-    SmackConfiguration.setLocalSocks5ProxyEnabled(isProxyEnabled);
-
-    if (!isProxyEnabled) return; // we are done, STUN and UPNP only affect Socks5
-
-    SmackConfiguration.setLocalSocks5ProxyPort(proxyPort);
-
-    final Socks5Proxy proxy = Socks5Proxy.getSocks5Proxy();
-
-    proxy.start();
-    socks5ProxyPort = proxy.getPort();
-
-    log.debug(
-        "started Socks5 proxy on port: " + socks5ProxyPort + " [listening on all interfaces]");
-
-    List<String> interfaceAddresses = new ArrayList<String>();
-
-    if (proxyAddresses != null) {
-      interfaceAddresses.addAll(proxyAddresses);
-
-      if (interfaceAddresses.isEmpty())
-        log.warn("Socks5 preconfigured addresses list is empty, using autodetect mode");
-      else log.debug("using preconfigured addresses: " + interfaceAddresses);
-    }
-
-    if (interfaceAddresses.isEmpty()) {
-      for (InetAddress interfaceAddress : NetworkingUtils.getAllNonLoopbackLocalIPAddresses(true)) {
-        interfaceAddresses.add(interfaceAddress.getHostAddress());
-      }
-      log.debug("using autodetected addresses: " + interfaceAddresses);
+    if (!isProxyEnabled) {
+      socks5ProxySupport.disableProxy();
+      return; // we are done, STUN and UPNP only affect Socks5
     }
 
-    proxy.replaceLocalAddresses(interfaceAddresses);
-
-    /*
-     * The public IP address from the STUN result may be added to late but
-     * this is definitely better then blocking the connection establishment
-     * for several seconds. Also it is very uncommon the a connection
-     * attempt to another is done just several seconds after the connection
-     * to the XMPP server is established
-     */
-    if (stunService != null && stunServer != null) {
-      ThreadUtils.runSafeAsync(
-          "stun-discovery",
-          log,
-          new Runnable() {
-            @Override
-            public void run() {
-              Collection<InetSocketAddress> addresses =
-                  stunService.discover(stunServer, stunPort, 10000);
-
-              for (InetSocketAddress address : addresses)
-                NetworkingUtils.addProxyAddress(address.getAddress().getHostAddress(), true);
-            }
-          });
-    }
-
-    final CountDownLatch mappingStart = new CountDownLatch(1);
-
-    if (gatewayDeviceID != null && upnpService != null) {
-      final String gatewayDeviceIDToFind = gatewayDeviceID;
-      ThreadUtils.runSafeAsync(
-          "upnp-portmapping",
-          log,
-          new Runnable() {
-            @Override
-            public void run() {
-              synchronized (portMappingLock) {
-                mappingStart.countDown();
-
-                List<GatewayDevice> devices = upnpService.getGateways(false);
-
-                if (devices == null) {
-                  log.warn("aborting UPNP port mapping due to network failure");
-                  return;
-                }
-
-                for (GatewayDevice currentDevice : devices) {
-                  if (gatewayDeviceIDToFind.equals(currentDevice.getUSN())) {
-                    device = currentDevice;
-                    break;
-                  }
-                }
-
-                if (device == null) {
-                  log.warn(
-                      "could not find gateway device with id: + "
-                          + gatewayDeviceID
-                          + " in the current network environment");
-                  return;
-                }
-
-                upnpService.deletePortMapping(device, socks5ProxyPort, IUPnPService.TCP);
-
-                log.debug(
-                    "creating port mapping on device: "
-                        + device.getFriendlyName()
-                        + " ["
-                        + socks5ProxyPort
-                        + "|"
-                        + IUPnPService.TCP
-                        + "]");
-
-                if (!upnpService.createPortMapping(
-                    device, socks5ProxyPort, IUPnPService.TCP, PORT_MAPPING_DESCRIPTION)) {
-
-                  log.warn(
-                      "failed to create port mapping on device: "
-                          + device.getFriendlyName()
-                          + " ["
-                          + socks5ProxyPort
-                          + "|"
-                          + IUPnPService.TCP
-                          + "]");
-
-                  device = null;
-                  return;
-                }
-
-                if (!useExternalGatewayDeviceAddress) return;
-
-                InetAddress externalAddress = upnpService.getExternalAddress(device);
-
-                if (externalAddress != null)
-                  NetworkingUtils.addProxyAddress(externalAddress.getHostAddress(), true);
-              }
-            }
-          });
-
-      try {
-        mappingStart.await();
-      } catch (InterruptedException e) {
-        Thread.currentThread().interrupt();
-      }
-    }
+    socks5ProxySupport.enableProxy(
+        proxyPort,
+        proxyAddresses,
+        gatewayDeviceID,
+        useExternalGatewayDeviceAddress,
+        stunServer,
+        stunPort);
   }
 
   private void uninitialzeNetworkComponents() {
-    if (socks5ProxyPort == -1) return;
-
-    Socks5Proxy.getSocks5Proxy().stop();
-
-    deleteMapping:
-    synchronized (portMappingLock) {
-      if (device == null) break deleteMapping;
-
-      if (!upnpService.isMapped(device, socks5ProxyPort, IUPnPService.TCP)) break deleteMapping;
-
-      log.debug(
-          "deleting port mapping on device: "
-              + device.getFriendlyName()
-              + " ["
-              + socks5ProxyPort
-              + "|"
-              + IUPnPService.TCP
-              + "]");
-
-      if (!upnpService.deletePortMapping(device, socks5ProxyPort, IUPnPService.TCP)) {
-        log.warn(
-            "failed to delete port mapping on device: "
-                + device.getFriendlyName()
-                + " ["
-                + socks5ProxyPort
-                + "|"
-                + IUPnPService.TCP
-                + "]");
-      }
-    }
-
-    socks5ProxyPort = -1;
-    device = null;
+    socks5ProxySupport.disableProxy();
   }
 }