# WebClientsManager.java

    /*
    ** Module   : WebClientsManager.java
    ** Abstract : WebClientsManager implements business methods to allocate and release web clients.
    **
    ** Copyright (c) 2017-2024, Golden Code Development Corporation.
    **
    ** -#- -I- --Date-- ----------------Description----------------------
    ** 001 SBI 20170628 Created initial version.
    ** 002 SBI 20171020 Changed to allocate new system resources in order to run new web client on
    **                  the target host, changed to map a host and its port to its web client
    **                  identifier.
    ** 003 SBI 20200317 Added the hosts manager.
    ** 004 IAS 20201222 Added daemon thread pool to the HTTP client.
    ** 005 IAS 20210412 Used daemon thread pool for the HTTP client.
    ** 006 SBI 20210926 Added allocateAgentPorts, updateOptionsForJavaDebuggerAndJMXAgents, isAgentRangeConfigured
    **                  to manage agent ports for new web client.
    ** 007 TJD 20220331 Replaced deprecated new SslContextFactory() with SslContextFactory.Client()
    **     SBI 20221121 Set the current session language identifier for the web client.
    **     SBI 20221202 Set the current language by direct invocation of EnvironventOps.
    ** 008 SBI 20230505 Implemented new approach of using preallocated resource queues.
    ** 009 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
    ** 010 CA  20230704 Do not assume that a resource once allocated will be in-use: use a timer to wait for the
    **                  config resource to be allocated, otherwise release it.
    ** 011 GBB 20230825 Freeing the new mappings for web clients: auth blob and FWD account.
    **                  Moving the directory proxy config resolvement to WebDriverHandler to be reused.
    ** 012 GBB 20231113 Moving setAuthBlob to StandardServer.setupWebClient.
    **                  Freeing LIENT_UUID_SESS_DESCR_PAIRS & CLIENT_UUID_RELATED_SESSION_ID_PAIRS.
    ** 013 SBI 20231211 Implemented the case when the port range was not configured but java debugger single port or/and
    **                  jmx agent single port are provided via clientConfig/jvmArgs.
    ** 014 SBI 20240429 Added webClient/proxyPathSegment configuration to build urls exposed to clients for the case when
    **                  the Apache reverse proxy server is used as a frontend server.
    ** 015 GBB 20240515 Freeing the new mappings for web clients and login params.
    ** 016 GBB 20240709 Hard-coded config names replaced by ConfigItem constants. WebClientConfig renamed to
    **                  WebAllocatedResources. Methods parseDebuggerPort and parseJMXPort moved here from
    **                  ClientBuilderOptions.
    */
    /*
    ** This program is free software: you can redistribute it and/or modify
    ** it under the terms of the GNU Affero General Public License as
    ** published by the Free Software Foundation, either version 3 of the
    ** License, or (at your option) any later version.
    **
    ** This program is distributed in the hope that it will be useful,
    ** but WITHOUT ANY WARRANTY; without even the implied warranty of
    ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    ** GNU Affero General Public License for more details.
    **
    ** You may find a copy of the GNU Affero GPL version 3 at the following
    ** location: https://www.gnu.org/licenses/agpl-3.0.en.html
    **
    ** Additional terms under GNU Affero GPL version 3 section 7:
    **
    **   Under Section 7 of the GNU Affero GPL version 3, the following additional
    **   terms apply to the works covered under the License.  These additional terms
    **   are non-permissive additional terms allowed under Section 7 of the GNU
    **   Affero GPL version 3 and may not be removed by you.
    **
    **   0. Attribution Requirement.
    **
    **     You must preserve all legal notices or author attributions in the covered
    **     work or Appropriate Legal Notices displayed by works containing the covered
    **     work.  You may not remove from the covered work any author or developer
    **     credit already included within the covered work.
    **
    **   1. No License To Use Trademarks.
    **
    **     This license does not grant any license or rights to use the trademarks
    **     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
    **     of Golden Code Development Corporation. You are not authorized to use the
    **     name Golden Code, FWD, or the names of any author or contributor, for
    **     publicity purposes without written authorization.
    **
    **   2. No Misrepresentation of Affiliation.
    **
    **     You may not represent yourself as Golden Code Development Corporation or FWD.
    **
    **     You may not represent yourself for publicity purposes as associated with
    **     Golden Code Development Corporation, FWD, or any author or contributor to
    **     the covered work, without written authorization.
    **
    **   3. No Misrepresentation of Source or Origin.
    **
    **     You may not represent the covered work as solely your work.  All modified
    **     versions of the covered work must be marked in a reasonable way to make it
    **     clear that the modified work is not originating from Golden Code Development
    **     Corporation or FWD.  All modified versions must contain the notices of
    **     attribution required in this license.
    */

    package com.goldencode.p2j.main;

    import java.net.*;
    import java.util.*;
    import java.util.concurrent.*;
    import java.util.logging.*;
    import java.util.regex.*;
    import java.util.stream.*;

    import com.goldencode.p2j.net.RemoteObject;
    import com.goldencode.p2j.net.Session;
    import com.goldencode.p2j.net.SessionListener;
    import com.goldencode.p2j.util.*;
    import com.goldencode.p2j.util.logging.CentralLogger;

    /**
     * Manages the system resources allocated for web clients.
     */
    public class WebClientsManager
    implements WebClientAllocator,
               WebClientRegistrar,
               SessionListener
    {
       /** Logger */
       private static final CentralLogger LOG = CentralLogger.get(WebClientsManager.class.getName());

       /** The search pattern for the jmx agent port */
       private final static Pattern JMX_PORT_PATTERN = Pattern.compile("-Dcom\\.sun\\.management\\.jmxremote\\.port=(\\d+)");

       /** The search pattern for the debugger port */
       private final static Pattern DEBUGGER_ADDRESS_PATTERN = Pattern.compile("address=(\\d+)");

       /**
        * The poll timeout in milliseconds to get element from the queue
        */
       private static final int POLL_TIMEOUT_IN_MILLISECONDS = 100;

       /**
        * The time-out in milliseconds that measures a period of time from connecting test starts until
        * it must be completed.
        */
       private static final int CONNECTING_TIMEOUT_IN_MILLISECONDS = 100;

       /**
        * The path segment used by default to represent a top level segment for forwarded urls exposed to www.
        */
       private static final String PROXY_PATH_SEGMENT = "proxy";

       /**
        * one instance per standard server
        */
       private static WebClientsManager instance;

       /**
        * True value indicates that ports range is given
        */
       private final boolean portsRestricted;

       /** ports range started from this port number */
       private final int from;

       /** ports range bounded by this port number */
       private final int to;

       /**
        * Prefix is used to build mapping from ports to strings
        */
       private final String prefix;

       /**
        * Maps the web client's remote address pair to its configuration
        */
       private final Map<Integer, WebAllocatedResources> webClientSessions;

       /**
        * Map of cleaners, for each UUID.  If this is not removed after
        * {@link ClientSpawner#CLIENT_STARTUP_TIMEOUT_SEC}, then the config is {@link #freeClient released}.
        */
       private final Map<String, TimerTask> cleaners = new ConcurrentHashMap<>();

       /** A timer to clean allocated configurations. */
       private final Timer cleanerTimer = new Timer("Cleaner for failed web clients", true);

       /**
        * Maps client's uuid to its configuration, the client uuid are unique within the given server
        */
       private final Map<String, WebAllocatedResources> webClientConfigs;

       /** The hosts manager */
       private final HostsManager hostsManager;

       /** Holds clientConfig/minAgentPort value */
       private final int minAgentPort;

       /** Holds clientConfig/maxAgentPort value */
       private final int maxAgentPort;

       /** True value indicates that remote jmx agent is provided by jvmArgs */
       private final boolean jmxAgentNeeded;

       /** The jmx agent port number if this value is provided by jvmArgs */
       private final int jmxAgentSinglePortNumber;

       /** True value indicates that remote java debugger agent is provided by jvmArgs */
       private final boolean javaDebuggerNeeded;

       /** The java debugger port number if this value is provided by jvmArgs */
       private final int javaDebuggerSinglePortNumber;

       /** The path segment used to represent a top level segment for forwarded urls */
       private final String proxyPathSegment;

       /**
        * The poll timeout in milliseconds to get element from the queue
        */
       private final int pollTimeout;

       /**
        * The time-out in milliseconds that measures a period of time from connecting test starts until
        * it must be completed.
        */
       private final int connectionTestTimeout;
       /**
        * Creates a unique instance per a standard P2J server.
        *
        * @throws    Exception if it is not setup properly.
        */
       private WebClientsManager()
       throws Exception
       {
          hostsManager = HostsManager.getInstance();

          pollTimeout = ConfigItem.POLL_TIMEOUT.read(POLL_TIMEOUT_IN_MILLISECONDS);

          connectionTestTimeout = ConfigItem.CONNECTION_TEST_TIMEOUT.read(CONNECTING_TIMEOUT_IN_MILLISECONDS);

          proxyPathSegment = ConfigItem.PROXY_CLIENT_PATH_SEGMENT.read(PROXY_PATH_SEGMENT);

          from = ConfigItem.CLIENT_PORT_RANGE_FROM.read(0);

          to = ConfigItem.CLIENT_PORT_RANGE_TO.read(0);

          prefix = ConfigItem.PROXY_CLIENT_NAME_PREFIX.read("client");

          portsRestricted =  (to > from) && (from > 0);

          webClientSessions = new ConcurrentHashMap<Integer, WebAllocatedResources>();

          webClientConfigs = new ConcurrentHashMap<String, WebAllocatedResources>();

          minAgentPort = ConfigItem.MIN_AGENT_PORT.read(0);
          maxAgentPort = ConfigItem.MAX_AGENT_PORT.read(0);

          String jvmArgs = ConfigItem.JVM_ARGS.read("");
          // Find -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=?
          int debugOptionStartIndex = jvmArgs.indexOf("-Xrunjdwp:");
          javaDebuggerNeeded = debugOptionStartIndex != -1;
          javaDebuggerSinglePortNumber = parseDebuggerPort(jvmArgs);
          // find JMX agent expression -Dcom.sun.management.jmxremote=true
          int jmxAgentOptionStartIndex = jvmArgs.indexOf("-Dcom.sun.management.jmxremote=true");
          jmxAgentNeeded = jmxAgentOptionStartIndex != -1;
          jmxAgentSinglePortNumber = parseJMXPort(jvmArgs);

          hostsManager.addHost("localhost", createResourceQueue("localhost"));
          RemoteObject.registerNetworkServer(WebClientRegistrar.class, this);
       }

       /**
        * Returns the web clients manager or null if is is not setup properly.
        * <p>
        * At this time the code doesn't throw exceptions, it just logs them in the standard error
        * stream.
        *
        * @return   The web clients manager
        */
       public static synchronized WebClientsManager getInstance()
       {
          if (instance == null)
          {
             try
             {
                instance = new WebClientsManager();
             }
             catch (Exception e)
             {
                LOG.log(Level.SEVERE, "Failed to create " + WebClientsManager.class.getName(), e);
             }
          }

          return instance;
       }

       /**
        * Create the resource queue filled with web clients ports if web client ports ranges are configured,
        * otherwise null.
        *
        * @return   The resource queue or null
        */
       public BlockingQueue<WebAllocatedResources> createResourceQueue(String host)
       {
          BlockingQueue<WebAllocatedResources> resourcesQueue = null;
          boolean agentRangeConfigured = isAgentRangeConfigured();
          if (portsRestricted || agentRangeConfigured)
          {
             resourcesQueue = new LinkedBlockingQueue<>();
             // if agents port should be setup, this range
             LinkedList<Integer> agentPorts = null;

             if (agentRangeConfigured)
             {
                if (minAgentPort > 0 && (minAgentPort <= maxAgentPort))
                {
                   agentPorts = new LinkedList<>(IntStream.rangeClosed(minAgentPort, maxAgentPort)
                                                          .boxed()
                                                          .collect(Collectors.toSet()));
                }
                else
                {
                   agentPorts = new LinkedList<>();

                   if (javaDebuggerSinglePortNumber > 0 && javaDebuggerNeeded)
                   {
                      agentPorts.add(javaDebuggerSinglePortNumber);
                   }
                   if (jmxAgentSinglePortNumber > 0 && jmxAgentNeeded)
                   {
                      agentPorts.add(jmxAgentSinglePortNumber);
                   }
                }
             }
             if (portsRestricted)
             {
                for (int i = from; i <= to; i++)
                {
                   int javaDebuggerPort = -1, jmxAgentPort = -1;
                   if (agentRangeConfigured)
                   {
                      if (agentPorts.isEmpty())
                      {
                         break; // the queue is filled
                      }
                      if (javaDebuggerNeeded)
                      {
                         javaDebuggerPort = agentPorts.pollFirst();
                      }
                      if (jmxAgentNeeded && agentPorts.isEmpty())
                      {
                         break; // the queue is filled
                      }
                      if (jmxAgentNeeded)
                      {
                         jmxAgentPort = agentPorts.pollFirst();
                      }
                   }
                   resourcesQueue.add(new WebAllocatedResources(host,
                                                                i,
                                                                javaDebuggerPort,
                                                                jmxAgentPort,
                                                                null,
                                                                null,
                                                                null,
                                                                null,
                                                                null,
                                                                resourcesQueue));
                }
             }
             else
             {
                while (!agentPorts.isEmpty())
                {
                   int javaDebuggerPort = -1, jmxAgentPort = -1;
                   if (agentRangeConfigured)
                   {
                      if (javaDebuggerNeeded)
                      {
                         javaDebuggerPort = agentPorts.pollFirst();
                      }
                      if (jmxAgentNeeded && agentPorts.isEmpty())
                      {
                         break; // the queue is filled
                      }
                      if (jmxAgentNeeded)
                      {
                         jmxAgentPort = agentPorts.getFirst();
                      }
                   }
                   resourcesQueue.add(new WebAllocatedResources(host,
                                                                0,
                                                                javaDebuggerPort,
                                                                jmxAgentPort,
                                                                null,
                                                                null,
                                                                null,
                                                                null,
                                                                null,
                                                                resourcesQueue));
                }
             }
          }

          return resourcesQueue;
       }

       /**
        * Tests if jmx agent string is provided by jvmArgs.
        *
        * @return   True if jmx agent string is provided by jvmArgs, otherwise false.
        */
       public boolean isJmxAgentNeeded()
       {
          return jmxAgentNeeded;
       }

       /**
        * Tests if java debugger agent string is provided by jvmArgs.
        *
        * @return   True if java debugger agent string is provided by jvmArgs, otherwise false.
        */
       public boolean isJavaDebuggerNeeded()
       {
          return javaDebuggerNeeded;
       }

       /**
        * Allocates the dedicated port number from the restricted ports range for the new spawned
        * web client.
        *
        * @param    proxyConfigs
        *           The first element should hold the forwarded host and the second one the corresponding
        *           forwarded protocol.
        * @param    host
        *           The remote host for the spawned web client
        * @param    uuid
        *           The web client uuid
        * @param    osUser
        *           The OS user name
        *
        * @return   The web client configuration. The allocated resources for the web client that
        *           will be spawned.
        */
       public WebAllocatedResources allocateClient(String[] proxyConfigs, String host, String uuid, String osUser)
       {
          boolean hasProxyConfigs = proxyConfigs != null && proxyConfigs.length >= 2;
          String forwardedHost = hasProxyConfigs ? proxyConfigs[0] : null;
          String forwardedProto = hasProxyConfigs ? proxyConfigs[1] : null;

          boolean viaProxyServer = forwardedHost != null && !forwardedHost.isEmpty();

          if (viaProxyServer && !portsRestricted)
          {
             throw new IllegalStateException(
                "Correct range: 'webClient/portsRange/from' < 'webClient/portsRange/to' must be provided");
          }

          if (!portsRestricted && !isAgentRangeConfigured()) // ports are unrestricted and no agents
          {
             return new WebAllocatedResources(host,
                                              0,
                                              -1,
                                              -1,
                                              null,
                                              forwardedHost,
                                              forwardedProto,
                                              osUser,
                                              uuid,
                                              null);
          }

          BlockingQueue<WebAllocatedResources> hostResources = hostsManager.getHostResource(host);

          if (hostResources == null) // ports are unrestricted and no agents
          {
             throw new IllegalStateException("Host resources should be registered for host: " + host);
          }

          WebAllocatedResources webClientConfig;

          try
          {
             webClientConfig = hostResources.poll(pollTimeout, TimeUnit.MILLISECONDS);
          }
          catch (InterruptedException e)
          {
             LOG.log(Level.WARNING, "All available ports  are allocated ", e);
             webClientConfig = null;
          }

          if (webClientConfig == null)
          {
             return null; // all available ports are in use
          }

          List<WebAllocatedResources> testedWebClientConfigs = new LinkedList<WebAllocatedResources>();

          int testPort = getTestPort(webClientConfig);
          InetAddress hostAddr = hostsManager.getResolvedHost(host);
          try
          {
             while (testPort > 0 && webClientConfig != null)
             {
                try
                {
                   if (!Utils.isEndPointAvailable(hostAddr, testPort, connectionTestTimeout))
                   {
                      break;
                   }
                }
                catch(SocketTimeoutException ex)
                {
                   LOG.log(Level.WARNING, "Time-out happens while connecting to " +  host + ":" + testPort, ex);
                }

                testedWebClientConfigs.add(webClientConfig);

                WebAllocatedResources nextWebClientConfig = null;
                try
                {
                   nextWebClientConfig = hostResources.poll(pollTimeout, TimeUnit.MILLISECONDS);
                }
                catch (InterruptedException e)
                {
                   LOG.log(Level.WARNING, "All available ports  are allocated ", e);
                   return null;
                }

                webClientConfig = nextWebClientConfig;
                testPort = getTestPort(webClientConfig);
             }
          }
          finally
          {
             for (WebAllocatedResources config : testedWebClientConfigs)
             try
             {
                hostResources.put(config);
             }
             catch (InterruptedException e)
             {
                logLeakedPorts(config, host, e);
             }
          }

          if (webClientConfig == null)
          {
             LOG.log(Level.WARNING, "All available ports  are allocated - no config found.");

             return null;
          }

          String webRoot = null;
          if (viaProxyServer)
          {
             StringBuilder webRootBuilder = new StringBuilder();
             webRootBuilder.append("/");
             webRootBuilder.append(proxyPathSegment);
             webRootBuilder.append("/").append(
                      hostsManager.getPortName(prefix, from, host, webClientConfig.getPort()));
             webRoot = webRootBuilder.toString();
          }
          webClientConfig.setNewClient(uuid, webRoot, osUser, forwardedProto, forwardedHost);

          webClientConfigs.put(uuid, webClientConfig);

          // do not wait more than the spawner timeout for the config to be registered - otherwise, free the
          // client, or the resource will remain 'in use'.
          TimerTask cleaner = new TimerTask()
          {
             public void run()
             {
                freeClient(uuid);
             }
          };
          cleaners.put(uuid, cleaner);
          cleanerTimer.schedule(cleaner, ClientSpawner.CLIENT_STARTUP_TIMEOUT_SEC * 1000);

          return webClientConfig;
       }

       /**
        * Registers the P2J session between the server and the web client to release its system
        * resources on the server side in the case if the web client is failed unexpectedly.
        *
        * @param    webClientId
        *           The web client unique identifier
        * @param    peerNode
        *           The remote node of the P2J session between the server and the web client
        */
       @Override
       public void registerWebClientSession(String webClientId, int peerNode)
       {
          if (!(portsRestricted || isAgentRangeConfigured()))
          {
             return;
          }

          TimerTask cleaner = cleaners.remove(webClientId);
          if (cleaner != null)
          {
             // cancel the cleaner, config was registered and in-use
             cleaner.cancel();
          }

          WebAllocatedResources config = webClientConfigs.get(webClientId);
          if (config != null)
          {
             webClientSessions.put(peerNode, config);
          }
       }

       /**
        * This method is not implemented by WebClientsManager.
        *
        * @param    session
        *           The session that is starting.
        */
       @Override
       public void initialize(Session session)
       {
       }

       /**
        * Releases the system resources for the web client given by uuid.
        *
        * @param    uuid
        *           The web client uuid
        */
       @Override
       public void freeClient(String uuid)
       {
          WebDriverHandler.CLIENT_UUID_AUTHBLOB_PAIRS.remove(uuid);
          WebDriverHandler.CLIENT_UUID_FWD_USERS.remove(uuid);
          WebDriverHandler.CLIENT_UUID_RELATED_SESSION_ID_PAIRS.remove(uuid);
          WebDriverHandler.CLIENT_UUID_SESS_DESCR_PAIRS.remove(uuid);
          WebDriverHandler.CLIENT_UUID_LOGIN_PARAMS.remove(uuid);
          WebClientBuilder.UUID_OPTIONS.remove(uuid);

          if (!(portsRestricted || isAgentRangeConfigured()))
          {
             return;
          }
          if (uuid != null)
          {
             WebAllocatedResources config = webClientConfigs.remove(uuid);
             if (config != null)
             {
                try
                {
                   config.release();
                }
                catch (InterruptedException e)
                {
                   logLeakedPorts(config, config.getHost(), e);
                }
             }
          }
       }

       /**
        * Releases the web client resources managed by the server.
        *
        * @param    webClientId
        *           The web client unique identifier
        */
       @Override
       public void freeWebClientResources(String webClientId)
       {
          freeClient(webClientId);
       }

       /**
        * This method is called when the session is ending. Removes this session from the active
        * sessions and to start asynchronous connect test in order to free the unused port.
        *
        * @param    session
        *           The session that is ending.
        */
       @Override
       public void terminate(Session session)
       {
          if (!(portsRestricted || isAgentRangeConfigured()))
          {
             return;
          }

          int peerNode = session.getRemoteAddress();
          WebAllocatedResources config = webClientSessions.remove(peerNode);
          if (config != null)
          {
             freeClient(config.getUuid());
          }
       }

       /**
        * Tests if the agent port range is configured.
        *
        * @return   True if the agent port range is configured, otherwise false.
        */
       private boolean isAgentRangeConfigured()
       {
          return (minAgentPort > 0 && (minAgentPort <= maxAgentPort) && (javaDebuggerNeeded || jmxAgentNeeded)) ||
                   (javaDebuggerSinglePortNumber > 0 && javaDebuggerNeeded) ||
                   (jmxAgentSinglePortNumber > 0 && jmxAgentNeeded);
       }

       /**
        * Log leaked resource not added back to its queue.
        *
        * @param    webClientConf
        *           The web client config not added back to its queue
        * @param    host
        *           The host managed these web resources
        * @param    ex
        *           The root cause exception
        */
       private void logLeakedPorts(WebAllocatedResources webClientConf, String host, InterruptedException ex)
       {
          LOG.log(Level.SEVERE, webClientConf.toString() + " have been leaked out " +  host, ex);
       }

       /**
        * Get the port to test.
        *
        * @param    webClientConfig
        *           The given web client config
        *
        * @return   The port to test
        */
       private int getTestPort(WebAllocatedResources webClientConfig)
       {
          if (webClientConfig == null)
          {
             return -1;
          }

          int testPort = webClientConfig.getPort();

          if (testPort == 0)
          {
             if (webClientConfig.getDebugAgentPort() > 0)
             {
                testPort = webClientConfig.getDebugAgentPort();
             }
             else if (webClientConfig.getJmxAgentPort() > 0)
             {
                testPort = webClientConfig.getJmxAgentPort();
             }
          }

          return testPort;
       }

       /**
        * Get the debugger port from the jvm arguments string, otherwise return -1.
        *
        * @param    jvmArgs
        *           The jvm arguments string
        *
        * @return   The debugger port number or -1.
        */
       private static int parseDebuggerPort(String jvmArgs)
       {
          Matcher m = DEBUGGER_ADDRESS_PATTERN.matcher(jvmArgs);
          if (m.find())
          {
             return Integer.parseInt(m.group(1));
          }

          return -1;
       }

       /**
        * Get the jmx agent port from the jvm arguments string, otherwise return -1.
        *
        * @param    jvmArgs
        *           The jvm arguments string
        *
        * @return   The jmx agent port number or -1.
        */
       private static int parseJMXPort(String jvmArgs)
       {
          Matcher m = JMX_PORT_PATTERN.matcher(jvmArgs);
          if (m.find())
          {
             return Integer.parseInt(m.group(1));
          }

          return -1;
       }
    }