ClientBuilder.java

/*
** Module   : ClientBuilder.java
** Abstract : build and start a platform process.
**
** Copyright (c) 2013-2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- -------------------------------Description------------------------------------
** 001 MAG 20131118 First version.
** 002 MAG 20140206 Send the referrer address via command line.
** 003 CA  20140206 Refactored to make it unaware of the type of the P2J client being spawned.
** 004 CA  20140224 Redirect STDERR of spawned process to the server STDERR.
** 005 MAG 20140310 Set password.
** 006 MAG 20140710 Implements remote launcher.
** 007 GES 20150211 Removed application arguments support.
** 008 EVL 20160223 Javadoc fixes to make compatible with Oracle Java 8 for Solaris 10.
** 009 GES 20160219 Moved code here from the process builder to share the native secure
**                  connection setup processing.
** 010 OM  20160905 Small optimizations.
** 011 SBI 20171019 Changed to spawn remote clients on dedicated hosts.
** 012 GES 20210205 Allow configFile to reference an absolute path as well as the relative path.
**     IAS 20210325 Added NIO configuration via BootstrapConfig
**     IAS 20210827 Added SSL package seqNo tracking configuration via BootstrapConfig
** 013 SBI 20230306 Added new remoteStart with new parameters following the implementation logic that
**                  allocates network ports before it spawns a remote client process.
**     SBI 20230307 Changed to deliver a uuid of the spawned client to BrokerManager.spawn as a parameter.
** 014 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 015 RFB 20230418 Changed addConfigFile to no longer deep-check the existence of the config
**                  file. Rather, it should just return the configFile, since it won't exist on
**                  the server itself. Ref. #4938.
** 016 GBB 20230825 Adding sso:subject:id to client cmd.
** 017 GBB 20231113 Adding sso:storage:id to client cmd.
** 018 EVL 20240118 Adding optional logger for native spawn process.
** 019 GBB 20240709 Hard-coded config names replaced by ConfigItem constants. Letting concrete 
**                  implementations to provide ClientBuilderParameters.
** 020 GBB 20240718 Moving base client builder configs to a field in the respective options class.
** 021 GBB 20240729 Adding -DenableSlf4jApi jvm arg to spawned clients.
** 022 CA  20240910 'net:socket:nio' is enabled by default only for Java 8.
** 023 CA  20250221 Added constants for the spawner modes.
** 024 AL2 20250523 'net:socket:nio' is disabled by default.
*/

/*
** 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.io.*;
import java.util.*;
import java.util.logging.*;

import com.goldencode.p2j.cfg.*;
import com.goldencode.p2j.net.*;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.logging.*;

/**
 * This class is used to create and spawn a client process using a
 * {@link ProcessBuilder} instance. A special small tools are designed for
 * Linux / Windows OS in order to spawn the process on the OS user account.
 * Both tools are written in C using specific OS API and are build as executable
 * using ant native target. The source code and the make file for this tools are 
 * available in the /src/native process directory. 
 * The password is provided vis stdin redirection on both platforms. 
 * The command line parameters for the spawned process are constructed from default 
 * parameters stored in directory and some specific parameters stored inside a 
 * <code>ClientBuilderParameters</code> structure.   
 */
public abstract class ClientBuilder
{   
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(ClientBuilder.class.getName());

   /** Pending spawn commands to be retrieved by remote clients. */
   private static final Map<String, String[]> pendingCmds = new HashMap<>();
   
   /** A collection of command line tokens */
   protected final List<String> command = new LinkedList<>();
   
   /** Indicate a remote or local spawn */
   protected boolean remote = false;
   
   /** The ProcessBuilder instance */
   private ProcessBuilder pb = new ProcessBuilder();

   /**
    * Returns the {@link ClientBuilderParameters} used for building the client.
    *
    * @return   See above.
    */
   public abstract ClientBuilderParameters getParams();
   
   /**
    * Get the spawn command registered for this UUID.
    * 
    * @param    uuid
    *           The UUID.
    *           
    * @return   See above.
    */
   public static String[] getCommand(String uuid)
   {
      synchronized (pendingCmds)
      {
         return pendingCmds.get(uuid);
      }
   }
   
   /**
    * Remove the spawn command registered for this UUID.
    * 
    * @param    uuid
    *           The UUID.
    */
   public static void removeCommand(String uuid)
   {
      synchronized (pendingCmds)
      {
         pendingCmds.remove(uuid);
      }
   }
   
   /**
    * Add custom client options to the {@link #command}. Don't use for options critical for initial 
    * client configuration.
    */
   protected abstract void addClientOptions();
   
   /**
    * Get the spawn arguments for a specific implementation.
    * 
    * @return   See above.
    */
   protected abstract List<String> getSpawnArguments();

   /**
    * Spawn's new local Java process.
    * 
    * @param   env
    *          A map with environment variables for temporary account.
    *           
    * @return  Process object.
    * 
    * @throws  IOException
    *          If something goes wrong.
    */
   public Process localStart(Map<String, String> env) throws IOException
   {
      // set local
      remote = false;
      
      pb.environment().putAll(env);
      ClientBuilderParameters params = getParams();

      // we start in the spawner's dir
      File spawnerDir = new File(params.getDirOptions().getBaseOptions().getSpawner());
      pb.directory(spawnerDir.getAbsoluteFile().getParentFile());
      
      pb.command(prepareToLaunch());
      
      Process shell = pb.start();

      if (params.isPasswordAuthentication())
      {
         // write password
         OutputStreamWriter os = new OutputStreamWriter(shell.getOutputStream());
         os.write(params.getOsPass());
         os.close();
      }
      
      return shell;
   }

   /**
    * Spawn's new remote Java process.
    * 
    * @param    dedicatedUser
    *           The dedicated user associated with the host on which the spawned client
    *           will be run
    * @param    brokers
    *           List of brokers.
    * @param    env
    *           A map with environment variables for temporary account.
    * 
    * @return   Process object.
    */
   public BrokerSpawnResult remoteStart(String dedicatedUser, Brokers brokers, Map<String, String> env)
   {
      return remoteStart(dedicatedUser, brokers, env, null, null);
   }
   
   /**
    * Spawn's new remote Java process.
    * 
    * @param    dedicatedUser
    *           The dedicated user associated with the host on which the spawned client
    *           will be run
    * @param    brokers
    *           List of brokers.
    * @param    env
    *           A map with environment variables for temporary account.
    * @param    allocator
    *           The web client allocator
    * @param    requestParameters
    *           The given requests parameters
    * 
    * @return   Process object.
    */
   public BrokerSpawnResult remoteStart(String dedicatedUser,
                                        Brokers brokers,
                                        Map<String, String> env, 
                                        WebClientAllocator allocator,
                                        String[] requestParameters)
   {
      // set remote
      remote = true;
      
      // get spawn cmd
      List<String> spawnCmd = prepareToLaunch();
            
      BrokerSpawnParameters args = new BrokerSpawnParameters();
      args.setEnvironment(env);
      args.setCommand(spawnCmd);

      ClientBuilderParameters params = getParams();
      args.setPasswordAuthentication(params.isPasswordAuthentication());
      
      if (args.isPasswordAuthentication())
      {
         args.setPassword(params.getOsPass());
      }
   
      return BrokerManager.spawn(dedicatedUser, brokers, args, allocator, requestParameters, params.getUuid());
   }
   
   /**
    * Initialize the command line parameters for a native secure connection, which is the
    * secure authentication method used for non-interactive clients and those interactive clients
    * which cannot be launched using a password.  These client processes call the {@code spawn}
    * tool using the {@code spawn 0 <secure-port> <server-hostname> <server-alias> <uuid>} 
    * syntax.
    *
    * @param    cmd
    *           The data structure to initialize with the parametes for the native secure
    *           connection.
    */
   void initNativeSecureConnection(List<String> cmd)
   {
      // add the mode that makes this a native secure connection
      cmd.add(ClientSpawner.MODE_AUTH_SERVER);

      ClientBuilderParameters params = getParams();

      // add host and secure port
      if (remote)
      {
         // for remote spawn use placeholders
         cmd.add(BrokerCore.PARAM_PORT);
         cmd.add(BrokerCore.PARAM_HOST);
      }
      else
      {
         // add secure port
         cmd.add(String.valueOf(params.getSecurePort()));
         
         // add host; this is hard-coded to localhost because is expected that the in-process JVM
         // will connect to the same P2J server which initiated the request (as this is the P2J
         // server knowing the command to be executed).
         cmd.add("localhost");
      }
      // add server alias
      cmd.add(params.getServerAlias());
      
      // add UUID
      cmd.add(params.getUuid());

      BootstrapConfig config = SessionManager.get().config();
      
      boolean nio = config.getBoolean(ConfigItem.SERVER_SOCKET_NIO, false);
      cmd.add(ConfigItem.SERVER_SOCKET_NIO.fullOption() + "=" + nio);
      
      boolean trackSeqNo = config.getBoolean(ConfigItem.SERVER_SSL_TRACKSEQNO, false);
      cmd.add(ConfigItem.SERVER_SSL_TRACKSEQNO.fullOption() + "=" + trackSeqNo);
   }

   /**
    * Prepare command line arguments for spawned process before to start.
    * 
    * @return  A list with command line arguments.
    */
   private List<String> prepareToLaunch()
   {
      ClientBuilderParameters params = getParams();
      if (params.getSecurePort() == -1)
      {
         throw new RuntimeException("Clients can be spawned only if the server is running in secure mode!");
      }
      
      // build the final command (which will be directly started or delayed until after P2J authentication
      buildCommand();
       
      // launch the spawner
      List<String> spawnCmd = new ArrayList<>();

      BaseClientBuilderOptions dirOptions = params.getDirOptions().getBaseOptions();
      spawnCmd.add(remote ? BrokerCore.PARAM_SPAWNER : dirOptions.getSpawner());
      
      spawnCmd.addAll(getSpawnArguments());
      
      // set up native logger for spawn process
      if (dirOptions.isSpawnerDebugEnabled())
      {
         spawnCmd.add("-L");
      }
      
      if (LOG.isLoggable(Level.FINE))
      {
         StringBuilder sb = new StringBuilder("Launching a new P2J client using the command: ");
         for (String s : spawnCmd)
         {
            sb.append(s).append(" ");
         }
         
         LOG.log(Level.FINE, sb.toString().trim());
      }
      
      return spawnCmd;
   }
   
   /**
    * Add server properties.
    * 
    * @param   secure
    *          Indicating to setup a secure <code>true</code> 
    *          or an insecure <code>false</code> connection.
    */
   private void addServerOptions(boolean secure)
   {
      // add target server ports
      BootstrapConfig serverConfig = SessionManager.get().config();

      String host = remote ?
         BrokerCore.PARAM_HOST :
         serverConfig.getString(ConfigItem.SERVER_HOST, "localhost");
      addToCommand(ConfigItem.SERVER_HOST, host);
      
      boolean nio = serverConfig.getBoolean(ConfigItem.SERVER_SOCKET_NIO, false);
      addToCommand(ConfigItem.SERVER_SOCKET_NIO, nio);
      
      boolean trackSeqNo = serverConfig.getBoolean(ConfigItem.SERVER_SSL_TRACKSEQNO, false);
      addToCommand(ConfigItem.SERVER_SSL_TRACKSEQNO, trackSeqNo);

      if (secure)
      {
         addToCommand(ConfigItem.SECURE, true);
         if (remote)
         {
            command.add(ConfigItem.SERVER_SECURE_PORT.fullOption() + "=" + BrokerCore.PARAM_PORT);
         }
         else
         {
            addConfigIfExisting(serverConfig, ConfigItem.SERVER_SECURE_PORT);
         }
      }
      else if (!addConfigIfExisting(serverConfig, ConfigItem.SERVER_PORT))
      {
         addConfigIfExisting(serverConfig, ConfigItem.SERVER_INSECURE_PORT);
      }
   }

   /**
    * Get a key value form configuration file. 
    * If key exists add the value to builder command.
    * 
    * @param   config
    *          Configuration file.
    * @param   clientConfig
    *          The client config to be read and added.
    * 
    * @return  <code>true</code> if successfully added, <code>false</code> otherwise.
    */
   private boolean addConfigIfExisting(BootstrapConfig config, ConfigItem<Integer> clientConfig)
   {
      Integer value = config.getInt(clientConfig, null);
      if (value == null)
      {
         return false;
      }
      addToCommand(clientConfig, value);
      return true;
   }

   /**
    * Adds a config option name : value to the command.
    *
    * @param   clientConfig
    *          The client config to be read and added.
    * @param   value
    *          The value to be added.
    */
   private <T> void addToCommand(ConfigItem<T> clientConfig, T value)
   {
      command.add(clientConfig.fullOption() + "=" + value);
   }
   
   /**
    * Add Java CLASSPATH and the main class. For remote clients the CLASSPATH and JVM arguments
    * are inherited from broker process.
    */
   private void addClasspath()
   {
      BaseClientBuilderOptions dirOptions = getParams().getDirOptions().getBaseOptions();
      if (remote)
      {
         command.add(BrokerCore.PARAM_CP);
      }
      else
      {
         // add CLASSPATH
         command.add("-classpath");
         command.add(dirOptions.getClassPath());
      }
      
      // add main class
      Class<?> mainClass = dirOptions.getMainClass();
      command.add(mainClass.getName());
   }

   /**
    * Add client configuration file if exists.
    */
   private void addConfigFile()
   {
      String configFile = getParams().getDirOptions().getBaseOptions().getConfigFile();
      
      if (configFile != null)
      {
         command.add(configFile);
      }
   }
   
   /**
    * Build command for spawned process.
    */
   private void buildCommand()
   {
      ClientBuilderParameters params = getParams();
      BaseClientBuilderOptions options = params.getDirOptions().getBaseOptions();

      // build the final command (which will be directly started or delayed until after P2J
      // authentication
      String userDir = options.getWorkingDirectory();

      // user name
      command.add(params.getOsUser());
      
      // password
      if (!params.isPasswordAuthentication())
      {
         String password = params.getOsPass();
         command.add(password == null ? "\"\"" : password);
      }

      // working directory
      command.add(userDir);
      
      // Java launcher executable
      command.add(options.getCommand());
      
      // JVM arguments
      if (remote)
      {
         // placeholder, will be replaced by broker with CLASSPATH and JVM arguments
         command.add(BrokerCore.PARAM_JVM);
      }
      else
      {
         // add JVM arguments
         command.addAll(options.getJvmArguments());
         
         // native libraries path
         command.add("-Djava.library.path=" + options.getLibPath());
      }

      if (CentralLogger.WRITE_TO_SLF4J_API && !CentralLoggerServer.getConfigs().isServerSide())
      {
         command.add("-D" + CentralLogger.JVM_ARG_ENABLE_SLF4j_API + "=true");
      }
      
      // add CLASSPATH and main class
      addClasspath();
      
      // add client configuration file
      addConfigFile();
      
      // add server options
      addServerOptions(options.isSecure());
      
      // add other client options
      addClientOptions();
      
      command.add(ConfigItem.SPAWNER_UUID.fullOption() + "=" + params.getUuid());
      
      // ensure there are no empty or null arguments. an empty argument will make the 
      // ClientDriver to assume that is a bootstrap config file name and fail on startup.
      command.removeIf(s -> s == null || s.trim().length() == 0);
      
      if (!params.isPasswordAuthentication())
      {
         synchronized (pendingCmds)
         {
            // save this for later use
            pendingCmds.put(params.getUuid(), command.toArray(new String[0]));
         }
      }
   }
}