WebEngine.java

package com.renomad.minum.web;

import com.renomad.minum.state.Constants;
import com.renomad.minum.logging.ILogger;
import com.renomad.minum.security.ITheBrig;
import com.renomad.minum.state.Context;

import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URL;
import java.nio.file.Path;
import java.security.*;
import java.util.concurrent.ExecutorService;

import static com.renomad.minum.web.HttpServerType.ENCRYPTED_HTTP;
import static com.renomad.minum.web.HttpServerType.PLAIN_TEXT_HTTP;

/**
 * This class contains the basic internet capabilities.
 * <br><br>
 * Think of this class as managing some of the lowest-level internet
 * communications we need to handle to support a web application. Sockets,
 * Servers, Threads, that kind of stuff.
 */
final class WebEngine {

  private final ITheBrig theBrig;
  private final Constants constants;
  private final Context context;
  private final ExecutorService executorService;
  private final WebFramework webFramework;

  WebEngine(Context context, WebFramework webFramework) {
    this.logger = context.getLogger();
    this.logger.logDebug(() -> "Using a supplied logger in WebEngine");
    this.theBrig = context.getFullSystem() != null ? context.getFullSystem().getTheBrig() : null;
    this.constants = context.getConstants();
    this.context = context;
    this.executorService = context.getExecutorService();
    this.webFramework = webFramework;
  }

  private final ILogger logger;
  static final String HTTP_CRLF = "\r\n";

  IServer startServer() {
    int port = constants.serverPort;
      ServerSocket ss;
      try {
          ss = new ServerSocket(port);
      } catch (Exception e) {
          throw new WebServerException(e);
      }
      logger.logDebug(() -> String.format("Just created a new ServerSocket: %s", ss));
    IServer server = new Server(ss, context, "http server", theBrig, webFramework, executorService, PLAIN_TEXT_HTTP);
    logger.logDebug(() -> String.format("Just created a new Server: %s", server));
    server.start();
    String hostname = constants.hostName;
    logger.logDebug(() -> String.format("%s started at http://%s:%s", server, hostname, port));
    return server;
  }

  IServer startSslServer() {

    /*
     * If we find the keystore and pass in the system properties
     */
    final var useExternalKeystore = isProvidedKeystoreProperties(constants.keystorePath, constants.keystorePassword, logger);
    KeyStoreResult keystoreResult = getKeyStoreResult(useExternalKeystore, constants.keystorePath, constants.keystorePassword, logger);

    int port = constants.secureServerPort;
    ServerSocket ss = createSslSocketWithSpecificKeystore(port, keystoreResult.keystoreUrl(), keystoreResult.keystorePassword());
    logger.logDebug(() -> String.format("Just created a new ServerSocket: %s", ss));
    IServer server = new Server(ss, context, "https server", theBrig, webFramework, executorService, ENCRYPTED_HTTP);
    logger.logDebug(() -> String.format("Just created a new SSL Server: %s", server));
    server.start();
    String hostname = constants.hostName;
    logger.logDebug(() -> String.format("%s started at https://%s:%s", server, hostname, port));
    return server;
  }

  static KeyStoreResult getKeyStoreResult(
          boolean useExternalKeystore,
          String keystorePath,
          String keystorePassword,
          ILogger logger) {
    if (useExternalKeystore) {
      logger.logDebug(() -> "Using keystore and password referenced in minum.config");
    } else {
      logger.logDebug(() -> "Using the default (self-signed / testing-only) certificate");
    }

    final URL keystoreUrl;
    try {
        keystoreUrl = useExternalKeystore ?
                Path.of(keystorePath).toUri().toURL() :
                WebEngine.class.getResource("/certs/keystore");
    } catch (Exception e) {
        throw new WebServerException("Error while building keystoreUrl: " + e);
    }
    final String keystorePasswordFinal = useExternalKeystore ?
          keystorePassword :
          "passphrase";
    return new KeyStoreResult(keystoreUrl, keystorePasswordFinal);
  }

  record KeyStoreResult(URL keystoreUrl, String keystorePassword) { }


  /**
   * Look into the system properties to see whether values have been
   * set for the keystore and keystorePassword keys.
   * <p>
   * the key for keystore is:  javax.net.ssl.keyStore
   * the key for keystorePassword is: javax.net.ssl.keyStorePassword
   * <p>
   * It's smart, if you are creating a server that will run
   * with a genuine signed certificate, to have those files
   * stored somewhere and then set these system properties.  That
   * way, it's a characteristic of a particular server - it's not
   * needed to bundle the certificate with the actual server in
   * any way.
   * <p>
   * We *do* bundle a cert, but it's for testing and is self-signed.
   */
  static Boolean isProvidedKeystoreProperties(String keystorePath, String keystorePassword, ILogger logger) {

    // get the directory to the keystore from a system property
    boolean hasKeystore = ! (keystorePath == null || keystorePath.isBlank());
    if (! hasKeystore) {
      logger.logDebug(() -> "Keystore system property was not set");
    }

    // get the password to that keystore from a system property
    boolean hasKeystorePassword = ! (keystorePassword == null || keystorePassword.isBlank());
    if (! hasKeystorePassword) {
      logger.logDebug(() -> "keystorePassword system property was not set");
    }

    return hasKeystore && hasKeystorePassword;
  }


  /**
   * Create an SSL Socket using a specified keystore
   */
  ServerSocket createSslSocketWithSpecificKeystore(int sslPort, URL keystoreUrl, String keystorePassword) {
    try (InputStream keystoreInputStream = keystoreUrl.openStream()) {
      final var keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
      char[] passwordCharArray = keystorePassword.toCharArray();
      keyStore.load(keystoreInputStream, passwordCharArray);

      final var keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
      keyManagerFactory.init(keyStore, passwordCharArray);

      final var keyManagers = keyManagerFactory.getKeyManagers();

      final var sslContext = SSLContext.getInstance("TLSv1.3");
      sslContext.init(keyManagers, null, new SecureRandom());

      final var socketFactory = sslContext.getServerSocketFactory();
      return socketFactory.createServerSocket(sslPort);
    } catch (Exception ex) {
      logger.logDebug(ex::getMessage);
      throw new WebServerException(ex);
    }
  }

  /**
   * Create a client {@link ISocketWrapper} connected to the running host server
   */
  ISocketWrapper startClient(Socket socket) throws IOException {
    logger.logDebug(() -> String.format("Just created new client socket: %s", socket));
    return new SocketWrapper(socket, null, logger, constants.socketTimeoutMillis, constants.hostName);
  }

  /**
   * Intentionally return just the default object toString, this is only used
   * to differentiate between multiple instances in memory.
   */
  @Override
  public String toString() {
    return super.toString();
  }

}