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