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

    }