LowLevelSocketListenerImpl.java

/*
** Module   : LowLevelSocketListenerImpl.java
** Abstract : Implementation of a server socket.
**
** Copyright (c) 2013-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- --------------------------------Description-----------------------------------
** 001 CA  20130908 Created initial version.
** 002 HC  20131031 Changed resource ID data type to Long, see issue #2183.
** 003 OM  20131018 Updated to conform the new SessionListener API.
** 004 CA  20140805 ThinClient is explicitly imported, to isolate ChUI class usage outside of ChUI
**                  packages.
** 005 EVL 20160217 Clean up comments from symbols invalid for Solaris to compile.
** 006 OM  20160321 Improved enableSSLConnections() method.
** 007 EVL 20160406 Javadoc fix.
** 008 RFB 20210902 Added protocol parameter so that additional socket protocols like TLSv1.2 can be used.
**                  Also fixed NPE when the store cannot be found. Ref. #4366.
**     CA  20220329 Do not attempt to cleanup sockets if the WorkArea has not been created (as the client 
**                  didn't use them).
**     CA  20220514 The active session is now set for leaf sessions, too - this is required for a change in  
**                  RemoteObject.obtainInstance, where first a check is done for an existing local proxy, and 
**                  after that a network instance is obtained (a requirement for the server-side OS resources
**                  support, like memptr).
** 009 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 010 GBB 20240826 Moving LowLevelSocketImpl to osresource package.
*/
/*
** 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.util;

import java.io.*;
import java.security.*;
import java.util.*;
import java.util.logging.*;
import java.net.*;
import java.net.Socket;
import javax.net.ssl.*;
import com.goldencode.p2j.cfg.*;
import com.goldencode.p2j.net.*;
import com.goldencode.p2j.net.Session;
import com.goldencode.p2j.security.*;
import com.goldencode.p2j.ui.*;
import com.goldencode.p2j.ui.chui.ThinClient;
import com.goldencode.p2j.util.logging.*;
import com.goldencode.p2j.util.osresource.*;

/**
 * This class acts as a delegate for the server-side to the create java socket.
 */
public class LowLevelSocketListenerImpl
implements LowLevelSocketListener,
           SessionListener
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(LowLevelSocketListenerImpl.class);

   /** Token used to authenticate with the dispatcher when registering APIs. */
   private static Object modToken = null;

   /** The context-local data of this class: a registry of server sockets by their ID. */
   private static ContextLocal<Map<Integer, ServerSocketData>> sockets = 
   new ContextLocal<Map<Integer, ServerSocketData>>()
   {
      protected Map<Integer,ServerSocketData> initialValue()
      {
         return new HashMap<>();
      };
   };
   
   /** The single instance available of this class. */
   private static final LowLevelSocketListenerImpl instance = new LowLevelSocketListenerImpl();
   
   /**
    * The password for access to server-side SSL certificate store. Default is blank. 
    */
   private static String keyStorePassword = null;
   
   
   /**
    * Private c'tor, to not allow explicit usage of this class. Its intended goal is to be 
    * exported as a network server.
    */
   private LowLevelSocketListenerImpl()
   {
      // no-op
   }
   
   /**
    * Initialize the client-side socket support:
    * <ul>
    *    <li>export the singleton instance of this class as a network server;</li>
    *    <li>initialize server side SSL by setting keystore access password.</li>
    * </ul>
    *
    * @param   cfg
    *          The configuration to use for setup of the client.
    * @param   session
    *          The P2J session. May be <code>null</code> if running in embedded mode.
    * @param   local
    *          <code>true</code> to register purely on a local basis (no remote/network access to these 
    *          methods).
    */
   public static synchronized void initialize(BootstrapConfig cfg, 
                                              Session session, 
                                              boolean local)
   {
      final Class<?> ifc = LowLevelSocketListener.class;
      modToken = RemoteObject.registerServer(ifc, instance, modToken, local);
      
      if (session != null)
      {
         session.addSessionListener(instance);
      }
      keyStorePassword = cfg.getString("ssl-socket", "keystore", "password", "");
   }

   /**
    * Create a server socket and let it listen on the given port.
    * 
    * @param    resourceId
    *           The ID of the associated {@link SocketImpl} resource on server side.
    * @param    port
    *           The port number.
    * @param    qsize
    *           The backlog size for this socket.
    * 
    * @return   The ID of the associated {@link LowLevelSocketListenerImpl} instance when 
    *           the connection was established.
    *           
    * @throws   IOException
    *           In case of errors while starting the server.
    */
   @Override
   public int enableConnections(long resourceId, int port, int qsize)
   throws IOException
   {
      ServerSocket socket = new ServerSocket(port, qsize);

      int id = Utils.uniqueId();

      Map<Integer, ServerSocketData> map = sockets.get();
      synchronized (map)
      {
         ServerSocketData data = new ServerSocketData(id, resourceId, socket, null);
         map.put(id, data);
         
         data.startThreads();
      }

      return id;
   }

   /**
    * Create a SSL server socket and let it listen on the given port.
    * <p>
    * Following are some findings/implementation notes:
    * <ul>
    *    <li>{@code nosessioncache} - this disables the SSL session cache. To emulate this, we use 
    *        {@link SSLSessionContext#setSessionCacheSize} to set the cache size to 1;</li>
    *    <li>{@code sessiontimeout} - represents "The maximum amount of time, in seconds, that the 
    *        server waits before it rejects a SSL client's request to resume a session. The 
    *        default value is 180 seconds."  This parameter looks like is something which should 
    *        be set by the client and not by the server, as (CA thinking) it means "if client is 
    *        idle more than x seconds, than terminate connection". <br>
    *        We use {@link SSLSessionContext#setSessionTimeout} to invalidate the the
    *        {@link SSLSession} object so future connections cannot resume the SSL session;</li>
    *    <li>documentation states that OpenEdge keeps the private keys in a global, per-
    *        installation store, is the {@code <DLC>/keys} folder. These files are in ASCII PEM
    *        format and include both the key and the certificate. Similarly, we store each alias
    *        in separate store files, similar to 4GL, however, the format differ and the keys/ 
    *        certificates must be imported (but keeping same name);</li>
    *    <li>there is a {@code default_server} key with the default password set to 
    *        {@code password}. This is used when connecting in SSL mode, but no key alias or 
    *        password is specified in the connection string. The default values were automatically
    *        selected on P2J server-side before calling this method;</li>
    *    <li>the password specified via {@code keyaliaspasswd} option in ENABLE-CONNECTIONS is
    *        encrypted in the 4GL source code, but it was already decrypted before passing it in
    *        as argument.</li>
    * </ul>
    *
    * @param   resourceId
    *          The ID of the associated {@link SocketImpl} resource on server side.
    * @param   port
    *          The port number.
    * @param   qsize
    *          The backlog size for this socket.
    * @param   keyalias
    *          The alias for the digital certificate.
    * @param   keyaliaspasswd
    *          The password for the key of the digital certificate.
    * @param   nosessioncache
    *          Flag indicating the SSL client session cache is disabled.
    * @param   sessiontimeout
    *          The number of seconds before the SSL client request is timeout.
    * @param   protocol
    *          The standard name of the requested protocol as defined by the SSLContext class.
    * 
    * @return  The ID of the associated {@link LowLevelSocketListenerImpl} instance when 
    *          the connection was established.
    *
    * @throws  IOException
    *          In case of errors while starting the server socket.
    * @throws  GeneralSecurityException
    *          In case of SSL-related errors while starting the server socket. 
    */
   @Override
   public int enableSSLConnections(long    resourceId,
                                   int     port,
                                   int     qsize,
                                   String  keyalias,
                                   String  keyaliaspasswd,
                                   boolean nosessioncache,
                                   int     sessiontimeout,
                                   String  protocol)
   throws IOException,
          GeneralSecurityException
   {
      int id = -1;
      
      String keyStoreName = ThinClient.getLocalFileName(keyalias + ".store");
      
      // When null, KeyStore.load fails with NPE
      if (keyStoreName == null)
      {
          return -1;
      }
      
      // file-level password for keystore. Same for all stores. The password is passed in as
      // a bootstrap parameter value with "ssl-socket:keystore:password" key   
      char keyStorePass[] = keyStorePassword.toCharArray();
      
      // keyaliaspasswd is already decrypted on p2j-server side
      char ctPass[] = keyaliaspasswd.toCharArray();
      
      // open key store, check the existence of the requested alias
      KeyStore ks = KeyStore.getInstance("JKS");
      ks.load(new FileInputStream(keyStoreName), keyStorePass);
      if (!ks.isKeyEntry(keyalias))
      {
         // requested alias was not found in keystore
         return -1;
      }
      
      // create a key manager
      KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509");
      kmf.init(ks, ctPass);
      
      // create the SSL context
      SSLContext sslCtx = SSLContext.getInstance(protocol);
      sslCtx.init(kmf.getKeyManagers(), null, null);
      
      SSLSessionContext clSessCtx = sslCtx.getClientSessionContext();
      if (clSessCtx != null)
      {
         if (nosessioncache)
         {
            clSessCtx.setSessionCacheSize(1);
         }
         clSessCtx.setSessionTimeout(sessiontimeout);
      }
      else
      {
         if (LOG.isLoggable(Level.FINE))
         {
            LOG.log(Level.FINE, 
                    "nosessioncache and sessiontimeout could not be set in SSL context");
         }
      }
      SSLServerSocketFactory factory = sslCtx.getServerSocketFactory();
      SSLServerSocket secure = (SSLServerSocket) factory.createServerSocket(port);
      
      id = Utils.uniqueId();
      
      Map<Integer, ServerSocketData> map = sockets.get();
      synchronized (map)
      {
         ServerSocketData data = new ServerSocketData(id, resourceId, null, secure);
         map.put(id, data);
         
         data.listener.start();
      }
      
      return id;
   }
   
   /**
    * Terminates the listening loop for this server socket. The existing established connections
    * are not terminated.
    * 
    * @param    id
    *           The ID of {@link LowLevelSocketImpl} instance on P2J Client side.
    *           
    * @return   <code>true</code> if no errors were encountered during disconnect.
    */
   @Override
   public boolean disableConnections(int id)
   {
      Map<Integer, ServerSocketData> map = sockets.get();
      synchronized (map)
      {
         ServerSocketData server = map.remove(id);
         server.terminate();
      }
      
      return true;
   }

   /**
    * When the P2J client session is ending, terminate all active server sockets.
    * 
    * @param    session
    *           The session that is ending.
    */
   @Override
   public void terminate(Session session)
   {
      Map<Integer, ServerSocketData> data = sockets.get(false);
      
      if (data == null)
      {
         // no sockets were created in this client session.
         return;
      }
      
      synchronized (data)
      {
         Map<Integer, ServerSocketData> copy = new HashMap<Integer, ServerSocketData>(data);
         for (ServerSocketData server : copy.values())
         {
            disableConnections(server.id);
         }
      }
   }

   /**
    * This callback will be called after the security context has been set up.
    *
    * @param    session
    *           The session that is starting.
    */
   @Override
   public void initialize(Session session)
   {
      // no-ops
   }

   /**
    * Container of various server-socket related data.
    */
   private static class ServerSocketData
   {
      /** The local ID of this server socket. */
      private final int id;
      
      /** The non-ssl server socket. <code>null</code> if ssl mode. */
      private final ServerSocket socket;
      
      /** The ssl server socket. <code>null</code> if no-ssl mode. */
      private final SSLServerSocket sslSocket;
      
      /** Instance of this class will start listening for connections in a different thread. */
      private final ConnectListener listener;
      
      /** Flag indicating that the server socket is terminated. */
      private boolean terminated = false;
      
      /**
       * Save the given data in a new {@link ServerSocketData} instance.
       * 
       * @param    id
       *           The local ID for this server socket.
       * @param    resourceId
       *           The resource ID of the server socket on P2J serer side.
       * @param    socket
       *           The server-socket for non-ssl mode; <code>null</code> if ssl mode.
       * @param    sslSocket
       *           The server-socket for ssl mode; <code>null</code> if no-ssl mode.
       */
      public ServerSocketData(int             id,
                              long            resourceId,
                              ServerSocket    socket,
                              SSLServerSocket sslSocket)
      {
         this.id = id;
         this.socket = socket;
         this.sslSocket = sslSocket;
         
         this.listener = new ConnectListener(resourceId, this);
      }
      
      /**
       * Start the {@link #listener connect listener} thread.
       */
      public void startThreads()
      {
         this.listener.start();
      }

      /**
       * Terminate this server socket.
       */
      public synchronized void terminate()
      {
         this.terminated = true;
         
         try
         {
            if (socket != null)
            {
               socket.close();
            }
            else 
            {
               sslSocket.close();
            }
         }
         catch (IOException e)
         {
            if (LOG.isLoggable(Level.FINER))
            {
               final String msg = "Error terminating server socket [%d]";
               LOG.log(Level.FINER, String.format(msg, id), e);
            }

            // ignore exceptions triggered by socket close
         }
      }
      
      /**
       * Check if this server socket is terminated.
       * 
       * @return   The {@link #terminated} flag.
       */
      public synchronized boolean isTerminated()
      {
         return terminated;
      }
   }
   
   /**
    * Start a loop in a different thread and listen for incoming connections.
    */
   private static class ConnectListener
   implements Runnable
   {
      /** The remote resource ID for this server-socket. */
      private final long resourceId;
      
      /** Back reference to the data for this server socket. */
      private final ServerSocketData data;

      /**
       * Initialize this instance.
       * 
       * @param    resourceId
       *           The ID of the {@link SocketListener resource} on P2J server side.
       * @param    data
       *           The associated {@link ServerSocketData}.
       */
      public ConnectListener(long resourceId, ServerSocketData data)
      {
         this.resourceId = resourceId;
         this.data = data;
      }

      /**
       * Listen for incoming connections, in a different thread.
       */
      public void run()
      {
         try
         {
            ServerExports server = ThinClient.getInstance().getServer();
            
            while (!data.isTerminated())
            {
               LowLevelSocketImpl.SocketData clientData = accept();
               
               if (clientData != null)
               {
                  server.connectEvent(resourceId, clientData.getResourceId());
               }
            }
         }
         finally
         {
            SessionManager.get().deregisterAsyncThread(Thread.currentThread());
         }
      }
      
      /**
       * Start a new thread, in which this instance will be executed.  The thread's name will be
       * <code>Server socket listener [%d]</code>, where <code>%d</code> is replaced with the 
       * {@link LowLevelSocketImpl.SocketData#id} value.
       * <p>
       * This thread is marked as async, as all outgoing requests must be processed async, in
       * their own {@link Dispatcher dispatcher}.
       */
      public void start()
      {
         Thread t = new AssociatedThread(this);
         // mark this thread as async
         SessionManager.get().registerAsyncThread(t);
         String name = String.format("Server socket listener [%d]", data.id);
         t.setName(name);
         t.start();
      }

      /**
       * Wait for a new incoming connection.
       * 
       * @return   The {@link LowLevelSocketImpl.SocketData} for this client socket.
       */
      private LowLevelSocketImpl.SocketData accept()
      {
         boolean ssl = (data.sslSocket != null);
         LowLevelSocketImpl.SocketData cldata = null;
         
         try
         {
            Socket client = ssl ? data.sslSocket.accept() : data.socket.accept();

            cldata = LowLevelSocketImpl.registerClientSocket(client, ssl);
         }
         catch (IOException e)
         {
            if (LOG.isLoggable(Level.FINER))
            {
               final String msg = "Error while accepting a new connection on [%d]";
               LOG.log(Level.FINER, String.format(msg, data.id), e);
            }

            // ignore errors
         }
         
         return cldata;
      }
   }
}