WebClientSpawner.java
/*
** Module : WebClientSpawner.java
** Abstract : customizations for spawning a web client
**
** Copyright (c) 2013-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- --------------------------------Description-----------------------------------
** 001 MAG 20131119 First version.
** 002 MAG 20140206 Send URL as referrer address via configuration file.
** 003 CA 20140206 Changes related to refactoring the spawner infrastructure (allow P2J process
** clients to be started, beside web chui clients).
** 004 GES 20150312 Added support for the GUI web client.
** 005 CA 20150820 Wait for the remote web side to notify that it has initialized all P2J modules
** before starting sending requests.
** 006 SBI 20151119 Added the trust server certificate checking.
** 007 GES 20160201 Moved auth token processing here to be better encapsulated/hidden. Modified
** the constructor to handle trusted mode (and to pass the referrer directly
** instead of requiring a setter to be called later).
** SBI 20160301 Fixed the web server to redirect the authorized users to the dedicated web
** application handler.
** 008 CA 20160428 Added command-line configuration options for web-clients.
** 009 SBI 20160628 Added new methods to allocate and to release web clients.
** 010 SBI 20171023 Changed to allocate new system resources in order to run new web client on
** the target host.
** 011 SBI 20210926 Used WebClientsManager to manage agents ports for new web client.
** 012 SBI 20221121 Set the current session language identifier (client:web:lang) for the web client.
** 013 HC 20230323 Added getOrigin and setOrigin methods. There are needed by JS driver to embed
** origin URL in the generated HTML content.
** 014 SBI 20230411 Removed unused code as the client process is spawned after its network resources
** are allocated.
** 015 GBB 20240709 Adding getClientConfig to set the project token for the TemporaryClient connection,
** resolve the options, merge them in the right order and convert them to bootstrap
** override values. Print diagnostics in TemporaryClient.doWork.
** 016 GBB 20240718 Moving base client builder configs to a field in the respective options class.
*/
/*
** 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.function.*;
import java.util.logging.*;
import com.goldencode.p2j.cfg.*;
import com.goldencode.p2j.directory.Base64;
import com.goldencode.p2j.ui.client.*;
import com.goldencode.p2j.ui.client.driver.web.*;
import com.goldencode.p2j.ui.client.chui.driver.web.*;
import com.goldencode.p2j.ui.client.gui.driver.web.*;
import com.goldencode.p2j.ui.client.driver.*;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.logging.*;
import com.goldencode.util.*;
/**
* Class used to spawn a new web client process.
* <p>
* This class is tricky to understand because the main part of it runs on the server while the
* <code>TemporaryClientTask</code> inner class is serialized and sent down to execute on the
* client side. That client side code then calls methods which are implemented as a remote
* object with the {@link Spawner} interface, which the server portions of this class implement.
* <p>
* In the interactive login mode, the spawned process first tries to authenticate the userid and
* password at the OS level. On success a new process (client) is spawned with those credentials.
* <p>
* In trusted mode, the spawner process will connect to the server using a secure socket and
* temporary credentials to establish a secure session. If that works, it will lookup the
* credentials to use for spawning the client based on a unique UUID for this spawning session.
* Using those credentials it will start the client and notify the server about success or
* failure.
* <p>
* In both modes, the spawned process will call a remote object method to get the server's
* KeyStore which is used to start the client's embedded web server. Finally, the client will
* call a remote object method to notify this (server side) instance about the URI on which the
* client's embedded web server is listening. If a notification does not occur within a specific
* amount of time, a failure in spawning is asserted.
*/
public class WebClientSpawner
extends ClientSpawner
{
/** Authorization token query parameter name */
public static final String PARAM_TOKEN = "token";
/** Logger. */
private static final CentralLogger LOG = CentralLogger.get(WebClientSpawner.class);
/** The configuration for this spawner. */
private final WebClientBuilderParameters buildParams;
/** Remote server URI */
private volatile String remoteUri = null;
/** Flag that denotes client type (<code>true</code> for GUI, <code>false</code> for ChUI). */
private boolean gui;
/** The web clients manager */
private final WebClientsManager webClientsManager;
/** Server key store */
private final ServerKeyStore serverKeyStore;
/** The {@link WebClientBuilder} used to spawn the P2J client process. */
private final WebClientBuilder cb;
/**
* Create a new web client spawner.
*
* @param cfg
* The web client builder parameters
* @param webClientsManager
* The web clients manager
*/
public WebClientSpawner(WebClientBuilderParameters cfg, WebClientsManager webClientsManager)
{
this.buildParams = cfg;
this.gui = cfg.isGui();
this.webClientsManager = webClientsManager;
this.cb = new WebClientBuilder(buildParams);
// load server key store
serverKeyStore = ServerKeyStore.getStore();
}
/**
* Get {@link WebClientBuilderParameters} used to spawn P2J clients.
*
* @return See above.
*/
@Override
public WebClientBuilderParameters getBuildParams()
{
return buildParams;
}
/**
* Notify when the client has started.
*
* @param data
* Custom data sent by the P2J client back to the server. For web clients, this is
* the remote URI to which it will redirect the browser.
*/
@Override
public void clientIsReady(String data)
{
remoteUri = data;
super.clientIsReady(data);
}
/**
* Get a {@link WebClientBuilder} used to spawn P2J clients.
*
* @return See above.
*/
@Override
protected WebClientBuilder getBuilder()
{
return cb;
}
/**
* Get the remote server URI to which the browser will be redirected.
*
* @return The remote server URI or <code>null</code>.
* @throws URISyntaxException
*/
public String getRemoteUri() throws URISyntaxException
{
String uri = remoteUri;
if (uri != null)
{
// set authorization token for the redirect
Object store = getServerData();
if (store instanceof ServerKeyStore)
{
String token = ((ServerKeyStore) store).getAuthorizationToken();
if (token != null)
{
uri = uri + "index.html?" + PARAM_TOKEN + "=" + token;
}
}
URI uriObj = new URI(uri);
uri = uriObj.toASCIIString();
}
return uri;
}
/**
* Get a {@link TemporaryClient} worker which will do the work after authenticating on the P2J
* server using the temporary credentials.
*
* @param uuid
* The client identifier uuid.
* @param projectToken
* The project token to be set for the temporary session.
*
* @return See above.
*/
@Override
public TemporaryClient getTemporaryClient(String uuid, String projectToken)
{
return new TemporaryClientTask(uuid, getServerData(), gui, getClientConfig(uuid, projectToken));
}
/**
* Get the server key store.
*
* @return See above.
*/
private ServerKeyStore getServerData()
{
return (serverKeyStore != null && serverKeyStore.isValid()) ? serverKeyStore : null;
}
/**
* On P2J client side, it will do custom work after authenticating using the temporary
* credentials.
* <p>
* This will create a {@link ChuiWebDriver} or {@link GuiWebDriver} and start the
* embedded web server which will serve this client's requests.
*/
private static class TemporaryClientTask
implements TemporaryClient
{
/** Logger. */
private static final CentralLogger LOG = CentralLogger.get(TemporaryClientTask.class);
/** The unique id for the spawned client running the task. */
private final String uuid;
/** Key store. */
private final ServerKeyStore keyStore;
/** Flag that denotes client type. */
private final boolean gui;
/** List of client configs. */
private final List<String> clientConfig;
/**
* Create a new web client spawner.
*
* @param uuid
* The client identifier uuid.
* @param keyStore
* The key store.
* @param gui
* Flag that denotes client type (<code>true</code> for GUI, <code>false</code>
* for ChUI).
* @param clientConfig
* The list of client configs.
*/
TemporaryClientTask(String uuid, ServerKeyStore keyStore, boolean gui, List<String> clientConfig)
{
this.uuid = uuid;
this.keyStore = keyStore;
this.gui = gui;
this.clientConfig = clientConfig;
}
/**
* On P2J client side, it will do custom work after authenticating using the temporary
* credentials.
* <p>
* This will create a {@link ChuiWebDriver} or {@link GuiWebDriver} and start the
* embedded web server which will serve this client's requests.
*
* @param spawner
* The spawner remote access interface.
* @param config
* The configuration to use for setup of the client and for the server connection.
* @param diagnostics
* Details about the client process.
*
* @return An explicit web screen driver to be used by the P2J client.
*
* @throws RuntimeException
* If the embedded web server could not be started.
*/
@Override
public ScreenDriver doWork(Spawner spawner, BootstrapConfig config, Supplier<String> diagnostics)
{
if (keyStore == null)
{
throw new RuntimeException("Cannot import server key store.");
}
if (!config.processOverrides(clientConfig))
{
LOG.severe("Config overrides are not successfully processed.");
}
String bytes = Base64.byteArrayToBase64(keyStore.getKeyStore());
String pw = keyStore.getKeyStorePassword();
config.setConfigItem("security", "truststore", "alias", keyStore.getTrustServerAlias());
config.setConfigItem("security", "truststore", "bytes", bytes);
config.setConfigItem("access", "password", "truststore", pw);
config.setConfigItem("security", "certificate", "validate", "true");
config.setConfigItem("security", "trust_mgr", "disable", "false");
config.setConfigItem("security", "transport", "refresh", "true");
// get an explicit port and host set by the client
int port = config.getInt(ConfigItem.PORT, 0);
String host = config.getString(ConfigItem.HOST, null);
boolean dedicatedMode = config.getBoolean("client", "web", "dedicatedMode", false);
// dedicated mode section placed just before the driver created
if (dedicatedMode)
{
host = "localhost";
config.setConfigItem(ConfigItem.HOST, host);
}
ClientCore.outputDiagnostics(config, diagnostics);
// create web screen driver
ScreenDriver<?> driver = gui ? new GuiWebDriver(config) : new ChuiWebDriver(config);
driver.init();
String uri = ((EmbeddedWebServer) driver).startupServer(keyStore, config, host, port);
String forwardedHost = config.getString(ConfigItem.PROXY_HOST, null);
if (forwardedHost != null &&
!forwardedHost.isEmpty() &&
uri != null)
{
String forwardedProto = config.getString(ConfigItem.PROXY_PROTOCOL, null);
String webRoot = config.getString(ConfigItem.WEB_ROOT, null);
uri = getForwardedUri(forwardedProto, forwardedHost, webRoot);
}
if (uri == null)
{
spawner.releaseClient(uuid);
throw new RuntimeException("Cannot start embedded server.");
}
if (gui)
{
try
{
((GuiWebDriver) driver).setOriginUrl(new URL(uri));
}
catch (MalformedURLException e)
{
// this should never happen
throw new RuntimeException(e);
}
}
// client initialization is done, report embedded server URI back to the server
spawner.clientIsReady(uuid, uri);
((EmbeddedWebServer) driver).waitInitialization();
return driver;
}
}
/**
* Releases the system resources for this spawned client.
*/
@Override
public void releaseClient()
{
if (webClientsManager != null)
{
webClientsManager.freeWebClientResources(buildParams.getUuid());
}
}
/**
* Returns a list of client configs in the option format "category:group:key=value".
*
* @param uuid
* Remote client identifier uuid.
* @param projectToken
* The project token.
*
* @return See above.
*/
public List<String> getClientConfig(String uuid, String projectToken)
{
// the remote call is only applicable for web
if (!WebClientBuilder.UUID_OPTIONS.containsKey(uuid))
{
return null;
}
WebClientBuilder.PostInitOptions postInitOptions = WebClientBuilder.UUID_OPTIONS.remove(uuid);
WebDriverType driverType = postInitOptions.getDriverType();
Map<String, String> options = new HashMap<>();
List<String> cfgOverrides = null;
List<String> webOverrides = null;
if (projectToken == null || projectToken.isEmpty())
{
WebClientBuilderOptions webOptions = WebDriverHandler.driverOptionsMap.get(driverType);
options.putAll(webOptions.getDefaults());
cfgOverrides = webOptions.getBaseOptions().getCfgOverrides();
webOverrides = postInitOptions.getWebOverrides();
}
else
{
StandardServer.setProjectToken(projectToken);
options.putAll(WebClientBuilderOptions.readConfigs());
String cfgOverridesString = ConfigItem.CFG_OVERRIDES.read(null);
if (cfgOverridesString != null)
{
cfgOverrides = Arrays.asList(cfgOverridesString.split(" "));
}
String dirWebOverridesString = ConfigItem.CFG_OVERRIDES_WEB.read(null);
if (dirWebOverridesString != null)
{
List<String> dirWebOverrides = Arrays.asList(dirWebOverridesString.split(" "));
webOverrides = postInitOptions.getWebOverrides(dirWebOverrides);
}
}
options.putAll(postInitOptions.getRuntimeOptions());
List<String> bootstrapValues = prepareBootstrapValues(options,
driverType.isGui(),
postInitOptions.getRemote(),
cfgOverrides,
webOverrides);
// removes options that should not be overridden client-side
bootstrapValues.removeIf(value -> value == null || value.contains(ConfigItem.PROJECT_TOKEN.fullOption()));
return bootstrapValues;
}
/**
* Get the external web client uri assigned by the reverse proxy server.
*
* @return The external uri of this web client
*/
private static String getForwardedUri(String forwardedProto, String forwardedHost, String webRoot)
{
URI uri = URI.create(forwardedProto + "://" + forwardedHost + webRoot + "/");
//rewrite uri
return uri.toString();
}
/**
* @param options
* Base options.
* @param isGui
* Flag to indicate if it's a gui web driver.
* @param isRemote
* Flag to indicate if it's a remote spawn.
* @param cfgOverrides
* clientConfig/cfgOverrides resolved with the project token.
* @param webOverrides
* webClient/webOverrides + runtime. Default startup procedure (for embedded). Forked session
* cfgOverrides. client:web:embedded. Selected UI theme. webRoot, login / logout url.
*
* @return List of bootstrap configs in the format category:group:key=value.
*/
private List<String> prepareBootstrapValues(Map<String, String> options,
boolean isGui,
boolean isRemote,
List<String> cfgOverrides,
List<String> webOverrides)
{
List<String> cmd = new ArrayList<>();
if (isGui)
{
// GUI driver options
cmd.add(ConfigItem.DRIVER_TYPE.fullOption() + "=" + OutputManager.GUI_DRIVER_WEB);
add(cmd, ConfigItem.DESKTOP_HEIGHT, options);
add(cmd, ConfigItem.DESKTOP_WIDTH, options);
add(cmd, ConfigItem.TASKBAR, options);
add(cmd, ConfigItem.GRAPHICS_CACHED, options);
add(cmd, ConfigItem.DISABLE_PIXEL_MANIPULATION, options);
add(cmd, ConfigItem.RENDERER, options);
add(cmd, ConfigItem.TASKBAR_STYLE, options);
}
else
{
// ChUI driver options
cmd.add(ConfigItem.DRIVER_TYPE.fullOption() + "=" + OutputManager.CHUI_DRIVER_WEB);
add(cmd, ConfigItem.ROWS, options);
add(cmd, ConfigItem.COLS, options);
add(cmd, ConfigItem.BACKGROUND, options);
add(cmd, ConfigItem.FOREGROUND, options);
add(cmd, ConfigItem.SELECTION, options);
add(cmd, ConfigItem.FONT_NAME, options);
add(cmd, ConfigItem.FONT_SIZE, options);
add(cmd, ConfigItem.CLIPBOARD, options);
}
// Common Websocket parameters:
add(cmd, ConfigItem.WEB_SOCKET_TIMEOUT, options);
add(cmd, ConfigItem.WATCHDOG_TIMEOUT, options);
add(cmd, ConfigItem.MAX_BINARY_MSG_SIZE, options);
add(cmd, ConfigItem.MAX_TEXT_MSG_SIZE, options);
add(cmd, ConfigItem.MAX_IDLE_TIME, options);
add(cmd, ConfigItem.CONNECT_RETRY_DELAY, options);
add(cmd, ConfigItem.TRY_CONNECT_MSG, options);
add(cmd, ConfigItem.SERVER_UNAVAILABLE_MSG, options);
add(cmd, ConfigItem.CONNECTION_RESTORED_MSG, options);
add(cmd, ConfigItem.PING_PONG_INTERVAL, options);
add(cmd, ConfigItem.MAX_LOST_PINGS, options);
add(cmd, ConfigItem.PING_DELAY, options);
//http configuration settings for the embedded web server
add(cmd, ConfigItem.MAX_OUT_BUFFER_SIZE, options);
add(cmd, ConfigItem.MAX_OUT_AGGREGATION_SIZE, options);
add(cmd, ConfigItem.MAX_HTTP_IDLE_TIME, options);
add(cmd, ConfigItem.MAX_RESPONSE_HEADER_SIZE, options);
add(cmd, ConfigItem.MAX_REQUEST_HEADER_SIZE, options);
add(cmd, ConfigItem.JS_DEBUG_LOGGING, options);
add(cmd, ConfigItem.CACHE_SIZE, options);
if (options.get(ConfigItem.PORT.name()) != null)
{
add(cmd, ConfigItem.PORT, options);
}
String cfgHost = options.get(ConfigItem.HOST.name());
if (cfgHost != null)
{
// do not send this parameter on remote spawn, let the remote web server select the IP
if (!isRemote)
{
cmd.add(ConfigItem.HOST.fullOption() + "=" + cfgHost);
}
}
String lang = options.get(ConfigItem.LANG.name());
if (lang != null)
{
cmd.add(ConfigItem.LANG.fullOption() + "=" + lang);
}
String forwardedHost = options.get(ConfigItem.PROXY_HOST.name());
if (forwardedHost != null)
{
add(cmd, ConfigItem.PROXY_PROTOCOL, options);
add(cmd, ConfigItem.PROXY_HOST, options);
String webRoot = options.get(ConfigItem.WEB_ROOT.name());
if (webRoot != null)
{
cmd.add(ConfigItem.WEB_ROOT.fullOption() + "=" + webRoot);
}
}
add(cmd, ConfigItem.USE_LOCALSTORAGE, options);
add(cmd, ConfigItem.BROADCAST_CHANNEL_PING_TIMEOUT, options);
if (cfgOverrides != null)
{
cmd.addAll(cfgOverrides);
}
if (webOverrides != null)
{
cmd.addAll(webOverrides);
}
return cmd;
}
/**
* Convenience method to shorten the syntax of adding new options to the list of client params.
*
* @param cmd
* The list of entries comprising the command.
* @param clientConfig
* The config to add to the command.
* @param webClientOptions
* The options to get the value from.
*/
private void add(List<String> cmd, ConfigItem clientConfig, Map<String, String> webClientOptions)
{
cmd.add(clientConfig.fullOption() + "=" + webClientOptions.get(clientConfig.name()));
}
}