ManagedWebServer.java

/*
** Module   : ManagedWebServer.java
** Abstract : Implements Managed Web Server to prove the ownership of the target domain.
**
** Copyright (c) 2017-2023, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------Description----------------------------------
** 001 SBI 20171030 First version.
** 002 SBI 20171112 Added CertificateSuite usage.
** 003 TJD 20220331 Replaced deprecated new SslContextFactory() with SslContextFactory.Server()
** 004 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
*/
/*
** 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.security;

import java.io.*;
import java.security.*;
import java.security.cert.*;
import java.security.cert.Certificate;
import java.util.concurrent.*;

import com.goldencode.p2j.util.logging.*;
import org.eclipse.jetty.client.*;
import org.eclipse.jetty.client.api.*;
import org.eclipse.jetty.client.api.Response.CompleteListener;
import org.eclipse.jetty.http.*;
import org.eclipse.jetty.server.handler.*;
import org.eclipse.jetty.util.ssl.*;
import org.kohsuke.args4j.*;

import com.goldencode.p2j.security.SSLCertFactory.*;
import com.goldencode.p2j.web.*;
import com.goldencode.util.*;



/**
 * Represents the functionality to be able to start an instance of the managed web server that
 * responds on SNI requests to the subject provided by the ACME server in order to prove
 * the ownership of this host.
 */
public class ManagedWebServer
{
   /** The request timeout */
   private static final long REQUEST_TIMEOUT = 1000;
   
   /** The file name of the server private certificate */
   private static final String SERVER_KEY_FILE = "tlssni.key";

   /** The file name of the server public certificate */
   private static final String SERVER_CRT_FILE = "tlssni.crt";
   
   /** The server alias */
   private static final String SERVER_ALIAS = "managed-server";
   
   /** The server key store */
   private static final String SERVER_STORE = "managed-server.store";
   
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(ManagedWebServer.class);
   
   /** The secure web server */
   private final SecureWebServer server;
   
   /** The private key store */
   private final KeyStore keyStore;
   
   /** The public key store */
   private final KeyStore certStore;
   
   /** The certificate factory */
   private SSLCertFactory factory;
   
   /** The http client */
   private HttpClient client;
   
   /** Indicates if the server is ready to respond on SNI requests to the subject. */
   private boolean isReady;
   
   /**
    * Starts an instance of the managed https web server that responds on SNI requests to the
    * subject provided by the ACME server in order to prove the ownership of this host.
    * 
    * @param    host
    *           The host address
    * @param    port
    *           The available host port
    * @param    subject
    *           The provided virtual host name by ACME server in order to prove the ownership of
    *           this host. It can be written to follow this host name pattern
    *           "f7ea254ebdbfc4a41637cb08294f3721.772069a9f4ff4bf7cc88bc40ba9f01b2.acme.invalid".
    * 
    * @throws   Exception
    *           Iff at least one of the web server and its client is failed to start.
    */
   public ManagedWebServer(String host, int port, String subject) throws Exception
   {
      factory = new BCCertFactory();
      
      keyStore = SSLCertGenUtil.createEmptyStore();
      
      certStore = SSLCertGenUtil.createEmptyStore();
      
      CertificateSuite suite = factory.loadCertificateSuite(SERVER_CRT_FILE, SERVER_KEY_FILE, null);
      
      
      try
      {
         Key rootKey = suite.privateKey;
         Certificate rootCert = suite.publicKey;
         
         // key entry password
         String keyEntryPassword = RandomWordGenerator.create();

         keyStore.setKeyEntry(SERVER_ALIAS, rootKey, keyEntryPassword.toCharArray(),
                           new Certificate[] { rootCert });
         
         // key store password
         String keyStorePassword = RandomWordGenerator.create();
         
         keyStore.store(new FileOutputStream(SERVER_STORE), keyStorePassword.toCharArray());
         
         certStore.setCertificateEntry(SERVER_ALIAS, suite.publicKey);
         
         server = new SecureWebServer(SERVER_STORE, keyStorePassword, keyEntryPassword);
      }
      catch (KeyStoreException |
             NoSuchAlgorithmException |
             CertificateException |
             IOException e)
      {
         throw new SSLCertGenException(e);
      }

      ResourceHandler rootHandler = new ResourceHandler();
      rootHandler.setDirectoriesListed(false);
      
      ContextHandler rootContext = new ContextHandler();
      rootContext.setContextPath("/");
      rootContext.setHandler(rootHandler);

      rootContext.addVirtualHosts(
               new String[] {subject});
      
      ContextHandler handler = new ContextHandler("/");
      handler.setHandler(rootContext);
      server.addHandler(handler);
      server.startup(host, port);
      
      SslContextFactory sslContextFactory = new SslContextFactory.Server();
      
      sslContextFactory.setTrustStore(certStore);
      
      client = new HttpClient(sslContextFactory);
      client.start();
   }

   /**
    * Waits if the server is ready or the elapsed time exceeds the given timeout until the first
    * event occurs.
    *  
    * @param    timeout
    *           The given timeout
    * 
    * @return   True iff the server is ready.
    */
   public boolean waitUntilReady(long timeout)
   {
      isReady = false;
      
      CountDownLatch ready = new CountDownLatch(1);
      
      CompleteListener callback = new CompleteListener()
      {
         
         @Override
         public void onComplete(Result result)
         {
            isReady = !result.isFailed();
            if (isReady)
            {
               ready.countDown();
            }
         }
      };
      
      long startWaiting = System.currentTimeMillis();
      
      long elapsedTime = 0;
      
      while(!isReady && ready.getCount() == 1)
      {
         isServerReady(callback, REQUEST_TIMEOUT);
         try
         {
            ready.await(REQUEST_TIMEOUT, TimeUnit.MILLISECONDS);
         }
         catch (InterruptedException e)
         {
         }
         finally
         {
            elapsedTime = System.currentTimeMillis() - startWaiting;
         }
         
         if (elapsedTime >= timeout)
         {
            ready.countDown();
         }
      }
      
      return isReady;
   }
   
   /**
    * Tests asynchronously if the target server is ready.
    *  
    * @param    callback
    *           The given callback
    * @param    timeout
    *           The https request timeout
    */
   private void isServerReady(CompleteListener callback, long timeout)
   {
      StringBuilder connectTest = new StringBuilder();
      connectTest.append(HttpScheme.HTTPS.asString())
                 .append("://")
                 .append(server.getHost()).append(":")
                 .append(server.getPort()).append("/");
      client.newRequest(connectTest.toString())
            .timeout(timeout, TimeUnit.MILLISECONDS)
            .send(callback);
   }
   
   /**
    * Stops the web server and its client.
    * 
    * @throws   Exception
    *           iff the shutdown operation is failed
    */
   public void shutdown()
   throws Exception
   {
      server.shutdown();
      server.join();
      client.stop();
   }
   
   /**
    * Starts the managed web server from the given command line arguments following this pattern:
    * -host "127.0.0.1" -port 8888 -subject "test.acme.invalid".
    * 
    * @param    args
    *           The provided parameters
    */
   public static void main(String[] args)
   {
      InputParameters inputParameters = new InputParameters();
      
      CmdLineParser parser = new CmdLineParser(inputParameters);
      
      try
      {
         parser.parseArgument(args);
      }
      catch (CmdLineException e)
      {
         LOG.severe("", e);
         StringOutputStream outputStream = new StringOutputStream();
         parser.printUsage(outputStream);
         LOG.info(outputStream.getData());
         return;
      }
      
      try
      {
         
         ManagedWebServer mws = new ManagedWebServer(inputParameters.host,
                                                     inputParameters.port,
                                                     inputParameters.subject);
         
         boolean testResult = mws.waitUntilReady(10000);
         
         String msg;
         
         if (testResult)
         {
            msg = "The server is started successfully.";
         }
         else
         {
            msg = "The server is failed to start.";
         }
         
         System.out.println(msg);
         
         if (!testResult)
         {
            System.exit(-1);
         }
      }
      catch (Exception e)
      {
         LOG.severe("", e);
      }
   }
   
   /**
    * Defines the managed web server input parameters.
    */
   static class InputParameters
   {
      /**
       * The server host address. Can be set via "-host" option.
       */
      @Option(name="-host",
              usage="The server host address.")
      public String host;

      /**
       * The server port. Can be set via "-port" option.
       */
      @Option(name="-port", usage="The server port.")
      public Integer port;

      /**
       * The provided subject. Can be set via "-subject" option.
       */
      @Option(name="-subject",
              usage="The provided subject.")
      public String subject;
   }
}