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