ClientsToPortsGenerator.java

/*
** Module   : ClientsToPortsGenerator.java
** Abstract : Utility to generate clients names to their own ports based on the directory.
**
** Copyright (c) 2014-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------Description----------------------------------
** 001 SBI 20170628 First version.
** 002 EVL 20170703 Javadoc fix.
** 003 SBI 20171020 Changed to be able to generate clients to backends map based on the assinged
**                  hosts defined bythe directory under the following node brokers/hosts.
** 004 SBI 20200317 Added getPortName.
** 005 SBI 20230411 Changed readHostsFile to return string to index map.
** 006 SBI 20240501 Added loopback address entry to hosts file with localhost entry.
** 007 GBB 20240709 Hard-coded config names replaced by ConfigItem constants.
*/
/*
** 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.net.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.*;
import java.util.*;
import java.util.Map.*;

import com.goldencode.p2j.cfg.*;
import com.goldencode.p2j.directory.*;
import com.goldencode.p2j.util.*;

/**
 * Creates mapping clients to their ports and added corresponding settings under the webClient
 * parent node.
 * TODO refactor this with the other tool: SSLCertGenUtil
 */
public class ClientsToPortsGenerator
{
   /** The generated mapping clients to their ports will be saved into this file */
   private static final String OUTPUT_FILE = "map.clients-to-backends";
   
   /** Valid entries for yes/no options. */
   private static final String[] YES_NO = { "yes", "no" };
   
   /** Read data from the standard input. */
   private final BufferedReader reader;

   /** Configuration used to initialize the directory. */
   private final BootstrapConfig cfg;
   
   /** Instance to access the directory. */
   private DirectoryService ds;
   
   /**
    * Create a new utility.
    * 
    * @param    config
    *           Configuration for accessing the directory.
    */
   public ClientsToPortsGenerator(BootstrapConfig config)
   {
      Console console = System.console();
      
      if (console != null)
      {
         reader = new BufferedReader(console.reader());
      }
      else
      {
         reader = new BufferedReader(new InputStreamReader(System.in));
      }
      
      cfg = new BootstrapConfig(config);
      if (cfg.getString("directory", "xml", "filename", null) == null)
      {
         throw new NullPointerException("The directory file must be specified!");
      }
      
   }
   
   /**
    * Main method to generate mapping clients to their ports. Added corresponding settings
    * under the webClient parent node in the directory.
    * 
    * @param    hostsFilePath
    *           The string representing the file path to the hosts file
    * 
    * @throws   ConfigurationException
    *           In case the {@link #ds directory service} could not be initialized.
    * @throws   IOException
    *           If standard input can not be accessed.
    */
   public void generate(String hostsFilePath)
   throws ConfigurationException,
          IOException
   {
      cfg.setConfigItem("directory", "xml", "must_exist", "true");

      String dirFile = cfg.getString("directory", "xml", "filename", null);
      System.out.println("Initializing service for directory " + dirFile + "...");
      this.ds = DirectoryService.createInstance(cfg);

      if (!ds.bind())
         throw new RuntimeException("bind() failed");

      //read output clients to ports mapping

      String fileName = readLine("Enter the output file name, otherwise "
               + "the default file name will be used '" + OUTPUT_FILE + "':");
      
      if (fileName == null || fileName.isEmpty())
      {
         fileName = OUTPUT_FILE;
      }
      
      String webClientRootPath = Utils.findDirectoryNodePath(ds, "webClient", "container", false);
      
      
      if (webClientRootPath == null)
      {
         System.out.println("This container node /server/default/webClient is not found!");
         return;
      }
      
      String portsRangeRootPath = Utils.findDirectoryNodePath(ds, "webClient/portsRange", "container", false);
      
      if (portsRangeRootPath == null)
      {
         // add portsRange
         openBatch(webClientRootPath);
         String nodeId = webClientRootPath + "/portsRange";
         addNode(nodeId, "container", (Attribute[]) null);
         closeBatch(webClientRootPath);
      }
      
      int from = ConfigItem.CLIENT_PORT_RANGE_FROM.read(-1, ds);
      
      if (from == -1)
      {
         while (true)
         {
            try
            {
               from = Integer.parseInt(readLine("Enter webClient/portsRange/from:"));
               
               if (from <= 1024)
               {
                  System.out.println("Enter a ports number that is greater than the upper bound"
                           + " of priveledge ports number (>1024).");
                  continue;
               }
               
               break;
            }
            catch (NumberFormatException e)
            {
               System.out.println("Incorrect ports number.");
               continue;
            }
         }
         String nodeId = Utils.findDirectoryNodePath(ds, "webClient/portsRange/from", "integer", false);
         String parentNodeId = webClientRootPath + "/portsRange";
         openBatch(parentNodeId);
         if (nodeId == null)
         {
            addNode(parentNodeId + "/from", "integer", from);
         }
         else
         {
            ds.setNodeInteger(nodeId, "value", from);
         }
         closeBatch(parentNodeId);
      }
      
      int to = ConfigItem.CLIENT_PORT_RANGE_TO.read(-1, ds);
      
      if (to < from)
      {
         to = enterPortsRangeTo(from);
         
         String nodeId = Utils.findDirectoryNodePath(ds, "webClient/portsRange/to", "integer", false);
         String parentNodeId = webClientRootPath + "/portsRange";
         openBatch(parentNodeId);
         if (nodeId == null)
         {
            addNode(parentNodeId + "/to", "integer", to);
         }
         else
         {
            ds.setNodeInteger(nodeId, "value", to);
         }
         closeBatch(parentNodeId);
      }
      
      String prefix = ConfigItem.PROXY_CLIENT_NAME_PREFIX.read("");
      
      if (prefix == null || prefix.isEmpty())
      {
         prefix = readLine("Enter a simple short name for webClient/portsRange/namePrefix,"
                  + " otherwise the default prefix will be used 'client':");
         if (prefix == null || prefix.isEmpty())
         {
            prefix = "client";
         }
         String nodeId = Utils.findDirectoryNodePath(ds, "webClient/portsRange/namePrefix", "string", false);
         String parentNodeId = webClientRootPath + "/portsRange";
         openBatch(parentNodeId);
         if (nodeId == null)
         {
            addNode(parentNodeId + "/namePrefix", "string", prefix);
         }
         else
         {
            ds.setNodeString(nodeId, "value", prefix);
         }
         closeBatch(parentNodeId);
      }
      
      Map<String, Integer> hosts = readHostsFile(hostsFilePath);
      
      // fill web client to backends map
      try(FileOutputStream fout = new FileOutputStream(new File("./" + fileName));
               PrintWriter pout = new PrintWriter(fout, true))
      {
         StringBuilder line = new StringBuilder();
         for (Entry<String, Integer> hostEntry : hosts.entrySet())
         {
            for(int i = from; i <= to; i++)
            {
               line.append(getPortName(prefix, from, hostEntry.getValue(), i));
               line.append("    ");
               line.append(hostEntry.getKey()).append(":").append(i);
               pout.println(line.toString());
               line.setLength(0);
            }
         }
      }
      
      System.out.println("Done.");
   }
   
   /**
    * Represents a well-defined mapping from host indices to their names. 
    * 
    * @param    prefix
    *           The client prefix
    * @param    from
    *           The beginning value of the given port range
    * @param    hostIndex
    *           The host index
    * @param    port
    *           The port number
    * 
    * @return   The port name
    */
   public static String getPortName(String prefix,
                                    int from,
                                    Integer hostIndex,
                                    int port)
   {
      StringBuilder portName = new StringBuilder();
      
      portName.append(prefix).append(hostIndex).append(port - from + 1);
      
      return portName.toString();
   }
   
   private int enterPortsRangeTo(int from) throws IOException
   {
      int to;
      
      while (true)
      {
         try
         {
            to = Integer.parseInt(readLine("Enter webClient/portsRange/to:"));
            
            if (to < from)
            {
               System.out.println("Enter a ports number that is greater than or equal to"
                        + " webClient/portsRange/from");
               continue;
            }
            
            break;
         }
         catch (NumberFormatException e)
         {
            System.out.println("Incorrect ports number.");
            continue;
         }
      }

      return to;
   }
   
   /**
    * Delete the specified node and all of its children.
    * 
    * @param    nodeId
    *           The node ID.
    * 
    * @throws   RuntimeException
    *           If the node or one of its children could not be deleted.
    */
   private void deleteNode(String nodeId)
   {
      if (ds.getNodeClass(nodeId) != null)
      {
         String[] children = ds.enumerateNodes(nodeId);
         for (int i = 0; i < children.length; i++)
         {
            String fullId = nodeId + "/" + children[i];
            
            // remove previous entry
            if (!ds.deleteNode(fullId))
            {
               throw new RuntimeException("deleteNode() failed for the " + fullId + " node");
            }
         }

         if (!ds.deleteNode(nodeId))
         {
            throw new RuntimeException("deleteNode() failed for the " + nodeId + " node");
         }
      }
   }
   
   /**
    * Add a new node to the directory, having the specified class and a single attribute
    * with the same class and specified value.
    * 
    * @param    nodeId
    *           The node ID.
    * @param    cls
    *           The node's and attribute's class.
    * @param    value
    *           The attribute's value.
    * 
    * @throws   RuntimeException
    *           If the node could not be added.
    */
   private void addNode(String nodeId, String cls, Object value)
   {
      NodeAttribute nodeAttr = ds.getClassNodeAttribute(cls, "value");
      Attribute attr = new Attribute(nodeAttr, new Object[] { value });
      addNode(nodeId, cls, new Attribute[] { attr });
   }
   
   /**
    * Add a new node to the directory, having the specified class and attributes.
    * 
    * @param    nodeId
    *           The node ID.
    * @param    cls
    *           The node's class.
    * @param    attrs
    *           The node's attributes.
    * 
    * @throws   RuntimeException
    *           If the node could not be added.
    */
   private void addNode(String nodeId, String cls, Attribute[] attrs)
   {
      if (!ds.addNode(nodeId, cls, attrs))
      {
         throw new RuntimeException("addNode() failed for the " + nodeId + " node");
      }
   }
   
   /**
    * Open a batch editing session for the specified node.
    * 
    * @param    node
    *           The node ID.
    * 
    * @throws   RuntimeException
    *           If the batch editing session could not be opened.
    */
   private void openBatch(String node)
   {
      if (!ds.openBatch(node))
      {
         throw new RuntimeException("openBatch() failed for the " + node + " node");
      }
   }
   
   /**
    * Close a batch editing session.
    * 
    * @param    node
    *           The node ID.
    * 
    * @throws   RuntimeException
    *           If the batch editing session could not be closed.
    */
   private void closeBatch(String node)
   {
      if (!ds.closeBatch(true))
         throw new RuntimeException("closeBatch() failed for " + node);
   }

   /**
    * Read a line of text using the created {@link #reader}.
    * 
    * @param    txt
    *           Description to be written to standard output.
    *           
    * @return   The line of text from standard input.
    * 
    * @throws   IOException
    *           If data could not be read.
    */
   private String readLine(String txt)
   throws IOException
   {
      System.out.print(txt);
      
      String line = reader.readLine();
   
      return line.trim();
   }
   
   /**
    * Ask the user to enter one of the specified valid options, using the given message.
    * 
    * @param    msg
    *           The message shown to the user.
    * @param    valid
    *           An array of valid options.
    */
   private String readOption(String msg, String[] valid)
   throws IOException
   {
      String answer = null;
      l1: do
      {
         answer = readLine(msg);
         
         for (String s : valid)
         {
            if (answer.equalsIgnoreCase(s))
            {
               break l1;
            }
         }
      }
      while (true);
      
      return answer;
   }

   
   /**
    * Command line driver.
    * 
    * @param    args
    *           Application command line parameters. File name is the only one expected.
    *
    * @throws    ConfigurationException
    *            In case the {@link #ds directory service} could not be initialized.
    * @throws    IOException
    *            If standard input can not be accessed.
    */
   public static void main(String[] args)
   throws ConfigurationException,
          IOException
   {
      String dirFile = null;

      if (args.length <= 1)
      {
         StringBuilder info = new StringBuilder("Usage: java ");
         info.append(ClientsToPortsGenerator.class.getName()).append(" ");
         info.append("<directory.xml>").append(" ").append("<hosts.txt>");
         System.out.println(info.toString());
         
         return;
      }
      
      dirFile = args[0];
      
      BootstrapConfig cfg = new BootstrapConfig();
      
      cfg.setConfigItem("directory", "backend", "type",        "xml");
      cfg.setConfigItem("directory", "xml",     "filename",    dirFile);
      cfg.setConfigItem("directory", "xml",     "must_exist", "true");
      cfg.setServer(true);

      ClientsToPortsGenerator gen = new ClientsToPortsGenerator(cfg);
      
      gen.generate(args[1]);
   }
   
   /**
    * Reads the given hosts file and returns the hosts map.
    * 
    * @param    filePath
    *           The path to the hosts file
    * 
    * @return   The hosts map that is filled with new data.
    * 
    * @throws   IOException
    *           Iff create, write or read operations applied to the target file is failed
    */
   public static Map<String, Integer> readHostsFile(String filePath)
   throws IOException
   {
      Map<String, Integer> hostsMap = new LinkedHashMap<String, Integer>();
      
      Path p = Paths.get(filePath);
      
      if (!p.toFile().exists())
      {
         FileStore fileStore = Files.getFileStore(p);
         
         FileAttribute[] fileAttributes;
         
         if (fileStore.supportsFileAttributeView("posix:permissions"))
         {
            // create with rw-r--r--
            Set<PosixFilePermission> rwRules = PosixFilePermissions.fromString("rw-r--r--");
            FileAttribute<Set<PosixFilePermission>> fileAttribute =
                                                      PosixFilePermissions.asFileAttribute(rwRules);
            fileAttributes = new FileAttribute[1];
            fileAttributes[0] = fileAttribute;
         }
         else
         {
            fileAttributes = new FileAttribute[0];
         }
         
         Files.createFile(p, fileAttributes);
         
         // write a loopback interface first
         try (BufferedWriter writer = Files.newBufferedWriter(p,
                                                              StandardCharsets.UTF_8,
                                                              StandardOpenOption.WRITE))
         {
            writer.write("localhost 1");
            writer.newLine();
            writer.write("127.0.0.1 1");
            writer.newLine();
         }
      }
      
      try (BufferedReader reader = Files.newBufferedReader(p, StandardCharsets.UTF_8))
      {
         String line;
         
         HostsFileParser parser = new HostsFileParser();
         
         while((line = reader.readLine()) != null)
         {
            if (parser.parse(line).isValidLine())
            {
               if (!hostsMap.containsKey(parser.getHostAddress()))
               {
                  hostsMap.put(parser.getHostAddress(), parser.getValue());
               }
            }
         }
      }
      
      return hostsMap;
   }
   
   /**
    * Appends new host and its value to the hosts file.
    * 
    * @param    filePath
    *           The path to the hosts file
    * @param    hostNameOrIpAddress
    *           The new host address
    * @param    value
    *           The new host value
    * 
    * @return   True iff the host address and its value has been appended successfully, otherwise
    *           returns false.
    */
   public static boolean appendHost(String filePath, String hostNameOrIpAddress, Integer value)
   {
      Path p = Paths.get(filePath);
      if (!p.toFile().exists())
      {
         return false;
      }
      
      try (BufferedWriter writer = Files.newBufferedWriter(p,
                                                           StandardCharsets.UTF_8,
                                                           StandardOpenOption.WRITE,
                                                           StandardOpenOption.APPEND))
      {
         StringBuilder line = new StringBuilder();
         line.append(hostNameOrIpAddress).append(" ").append(value);
         writer.write(line.toString());
         writer.newLine();
      }
      catch (IOException e)
      {
         return false;
      }
      
      return true;
   }
   
   /**
    * Defines the hosts file parser.
    */
   private static class HostsFileParser
   {
      /** The parsed value */
      private int value;
      
      /** The host name or IP4 address */
      private String hostAddress;
      
      /** The result if the last line is parsed successfully or not. */ 
      private boolean validLine;
      
      public HostsFileParser parse(String line)
      {
         validLine = true;
         line = line.trim();
         String[] parsed = line.split(" ");
         if (parsed.length > 0)
         {
            String ip = parsed[0];
            try
            {
               //just check the validity of the host name or ip address
               InetAddress.getByName(ip);
               hostAddress = ip;
               int index = line.indexOf(ip);
               index += ip.length();
               line = line.substring(index);
               line = line.trim();
               try
               {
                  value = Integer.parseInt(line);
               }
               catch(NumberFormatException ex)
               {
                  validLine = false;
               }
            }
            catch(UnknownHostException ex)
            {
               validLine = false;
            }
         }

         return this;
      }

      /**
       * Gets the last successfully parsed host value number.
       * 
       * @return   The host value number
       */
      public int getValue()
      {
         return value;
      }

      /**
       * Gets the last successful parsed IP4 host address.
       * 
       * @return   The IP4 host address
       */
      public String getHostAddress()
      {
         return hostAddress;
      }

      /**
       * Test if the parsed line is valid
       * 
       * @return   True iff the host IP4 address and its host value are retrieved successfully,
       *           otherwise false.
       */
      public boolean isValidLine()
      {
         return validLine;
      }
   }

}