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