Browse Source

Allow node to enroll to cluster on startup (#77718)

The functionality to enroll a new node to a cluster was
introduced in #77292 as a CLI tool. This change replaces this
CLI tool with the option to trigger the enrollment functionality 
on startup of elasticsearch via a named argument that can be 
passed to the elasticsearch startup script (--enrollment-token)
so that the users that want to enroll a node to a cluster can do 
this with one command instead of two. 

In a followup PR we are introducing a CLI tool version of this
functionality, that can be used to reconfigure packaged
installations.
Ioannis Kakavas 4 years ago
parent
commit
5d3b6bf2f7
29 changed files with 764 additions and 1226 deletions
  1. 1 0
      client/rest-high-level/qa/ssl-enabled/src/javaRestTest/java/org/elasticsearch/client/EnrollmentIT.java
  2. 4 3
      client/rest-high-level/qa/ssl-enabled/src/javaRestTest/java/org/elasticsearch/client/documentation/EnrollmentDocumentationIT.java
  3. 19 7
      client/rest-high-level/src/main/java/org/elasticsearch/client/security/NodeEnrollmentResponse.java
  4. 6 6
      distribution/packages/src/common/scripts/postinst
  5. 1 1
      distribution/packages/src/common/scripts/postrm
  6. 40 15
      distribution/src/bin/elasticsearch
  7. 53 15
      distribution/src/bin/elasticsearch.bat
  8. 5 3
      docs/java-rest/high-level/security/enroll_node.asciidoc
  9. 1 0
      qa/os/build.gradle
  10. 2 2
      qa/os/src/test/java/org/elasticsearch/packaging/test/DockerTests.java
  11. 133 0
      qa/os/src/test/java/org/elasticsearch/packaging/test/EnrollNodeToClusterTests.java
  12. 32 5
      qa/os/src/test/java/org/elasticsearch/packaging/test/EnrollmentProcessTests.java
  13. 2 2
      qa/os/src/test/java/org/elasticsearch/packaging/test/PackagesSecurityAutoConfigurationTests.java
  14. 3 3
      qa/os/src/test/java/org/elasticsearch/packaging/test/PackagingTestCase.java
  15. 19 6
      qa/os/src/test/java/org/elasticsearch/packaging/util/Archives.java
  16. 0 1
      qa/os/src/test/java/org/elasticsearch/packaging/util/Installation.java
  17. 2 1
      qa/os/src/test/java/org/elasticsearch/packaging/util/ServerUtils.java
  18. 1 1
      qa/os/src/test/java/org/elasticsearch/packaging/util/docker/Docker.java
  19. 9 6
      x-pack/docs/en/rest-api/security/enroll-node.asciidoc
  20. 2 2
      x-pack/docs/en/security/configuring-stack-security.asciidoc
  21. 5 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/CommandLineHttpClient.java
  22. 14 3
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/NodeEnrollmentResponse.java
  23. 9 4
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/enrollment/NodeEnrollementResponseTests.java
  24. 381 138
      x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/AutoConfigureNode.java
  25. 0 654
      x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/EnrollNodeToCluster.java
  26. 0 314
      x-pack/plugin/security/cli/src/test/java/org/elasticsearch/xpack/security/cli/EnrollNodeToClusterTests.java
  27. 0 12
      x-pack/plugin/security/src/main/bin/elasticsearch-enroll-node
  28. 0 21
      x-pack/plugin/security/src/main/bin/elasticsearch-enroll-node.bat
  29. 20 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/enrollment/TransportNodeEnrollmentAction.java

+ 1 - 0
client/rest-high-level/qa/ssl-enabled/src/javaRestTest/java/org/elasticsearch/client/EnrollmentIT.java

@@ -76,6 +76,7 @@ public class EnrollmentIT  extends ESRestHighLevelClientTestCase {
         assertThat(nodeEnrollmentResponse, notNullValue());
         assertThat(nodeEnrollmentResponse.getHttpCaKey(), endsWith("K2S3vidA="));
         assertThat(nodeEnrollmentResponse.getHttpCaCert(), endsWith("LfkRjirc="));
+        assertThat(nodeEnrollmentResponse.getTransportCaCert(), endsWith("3J9+kpgIbE"));
         assertThat(nodeEnrollmentResponse.getTransportKey(), endsWith("1I+r8vOQ=="));
         assertThat(nodeEnrollmentResponse.getTransportCert(), endsWith("OpTdtgJo="));
         List<String> nodesAddresses = nodeEnrollmentResponse.getNodesAddresses();

+ 4 - 3
client/rest-high-level/qa/ssl-enabled/src/javaRestTest/java/org/elasticsearch/client/documentation/EnrollmentDocumentationIT.java

@@ -75,9 +75,10 @@ public class EnrollmentDocumentationIT extends ESRestHighLevelClientTestCase {
             // tag::node-enrollment-response
             String httpCaKey = response.getHttpCaKey(); // <1>
             String httpCaCert = response.getHttpCaCert(); // <2>
-            String transportKey = response.getTransportKey(); // <3>
-            String transportCert = response.getTransportCert(); // <4>
-            List<String> nodesAddresses = response.getNodesAddresses();  // <5>
+            String transportCaCert = response.getTransportCaCert(); // <3>
+            String transportKey = response.getTransportKey(); // <4>
+            String transportCert = response.getTransportCert(); // <5>
+            List<String> nodesAddresses = response.getNodesAddresses();  // <6>
             // end::node-enrollment-response
         }
 

+ 19 - 7
client/rest-high-level/src/main/java/org/elasticsearch/client/security/NodeEnrollmentResponse.java

@@ -21,14 +21,16 @@ public class NodeEnrollmentResponse {
 
     private final String httpCaKey;
     private final String httpCaCert;
+    private final String transportCaCert;
     private final String transportKey;
     private final String transportCert;
     private final List<String> nodesAddresses;
 
-    public NodeEnrollmentResponse(String httpCaKey, String httpCaCert, String transportKey, String transportCert,
+    public NodeEnrollmentResponse(String httpCaKey, String httpCaCert, String transportCaCert, String transportKey, String transportCert,
                                   List<String> nodesAddresses){
         this.httpCaKey = httpCaKey;
         this.httpCaCert = httpCaCert;
+        this.transportCaCert = transportCaCert;
         this.transportKey = transportKey;
         this.transportCert = transportCert;
         this.nodesAddresses = Collections.unmodifiableList(nodesAddresses);
@@ -46,6 +48,10 @@ public class NodeEnrollmentResponse {
         return transportKey;
     }
 
+    public String getTransportCaCert() {
+        return transportCaCert;
+    }
+
     public String getTransportCert() {
         return transportCert;
     }
@@ -56,6 +62,7 @@ public class NodeEnrollmentResponse {
 
     private static final ParseField HTTP_CA_KEY = new ParseField("http_ca_key");
     private static final ParseField HTTP_CA_CERT = new ParseField("http_ca_cert");
+    private static final ParseField TRANSPORT_CA_CERT = new ParseField("transport_ca_cert");
     private static final ParseField TRANSPORT_KEY = new ParseField("transport_key");
     private static final ParseField TRANSPORT_CERT = new ParseField("transport_cert");
     private static final ParseField NODES_ADDRESSES = new ParseField("nodes_addresses");
@@ -66,15 +73,17 @@ public class NodeEnrollmentResponse {
         new ConstructingObjectParser<>(NodeEnrollmentResponse.class.getName(), true, a -> {
             final String httpCaKey = (String) a[0];
             final String httpCaCert = (String) a[1];
-            final String transportKey = (String) a[2];
-            final String transportCert = (String) a[3];
-            final List<String> nodesAddresses = (List<String>) a[4];
-            return new NodeEnrollmentResponse(httpCaKey, httpCaCert, transportKey, transportCert, nodesAddresses);
+            final String transportCaCert = (String) a[2];
+            final String transportKey = (String) a[3];
+            final String transportCert = (String) a[4];
+            final List<String> nodesAddresses = (List<String>) a[5];
+            return new NodeEnrollmentResponse(httpCaKey, httpCaCert, transportCaCert, transportKey, transportCert, nodesAddresses);
         });
 
     static {
         PARSER.declareString(ConstructingObjectParser.constructorArg(), HTTP_CA_KEY);
         PARSER.declareString(ConstructingObjectParser.constructorArg(), HTTP_CA_CERT);
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), TRANSPORT_CA_CERT);
         PARSER.declareString(ConstructingObjectParser.constructorArg(), TRANSPORT_KEY);
         PARSER.declareString(ConstructingObjectParser.constructorArg(), TRANSPORT_CERT);
         PARSER.declareStringArray(ConstructingObjectParser.constructorArg(), NODES_ADDRESSES);
@@ -88,12 +97,15 @@ public class NodeEnrollmentResponse {
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;
         NodeEnrollmentResponse that = (NodeEnrollmentResponse) o;
-        return httpCaKey.equals(that.httpCaKey) && httpCaCert.equals(that.httpCaCert) && transportKey.equals(that.transportKey)
+        return httpCaKey.equals(that.httpCaKey)
+            && httpCaCert.equals(that.httpCaCert)
+            && transportCaCert.equals(that.transportCaCert)
+            && transportKey.equals(that.transportKey)
             && transportCert.equals(that.transportCert)
             && nodesAddresses.equals(that.nodesAddresses);
     }
 
     @Override public int hashCode() {
-        return Objects.hash(httpCaKey, httpCaCert, transportKey, transportCert, nodesAddresses);
+        return Objects.hash(httpCaKey, httpCaCert, transportCaCert, transportKey, transportCert, nodesAddresses);
     }
 }

+ 6 - 6
distribution/packages/src/common/scripts/postinst

@@ -57,12 +57,12 @@ if [ "x$IS_UPGRADE" != "xtrue" ]; then
     # Don't exit immediately on error, we want to hopefully print some helpful banners
     set +e
     # Attempt to auto-configure security, this seems to be an installation
-    if ES_MAIN_CLASS=org.elasticsearch.xpack.security.cli.ConfigInitialNode \
+    if ES_MAIN_CLASS=org.elasticsearch.xpack.security.cli.AutoConfigureNode \
     ES_ADDITIONAL_SOURCES="x-pack-env;x-pack-security-env" \
     ES_ADDITIONAL_CLASSPATH_DIRECTORIES=lib/tools/security-cli \
     /usr/share/elasticsearch/bin/elasticsearch-cli <<< ""; then
         # Above command runs as root and TLS keystores are created group-owned by root. It's simple to correct the ownership here
-        for dir in "${ES_PATH_CONF}"/tls_auto_config_initial_node_*
+        for dir in "${ES_PATH_CONF}"/tls_auto_config_*
         do
             chown root:elasticsearch "${dir}"/http_keystore_local_node.p12
             chown root:elasticsearch "${dir}"/http_ca.crt
@@ -83,13 +83,13 @@ if [ "x$IS_UPGRADE" != "xtrue" ]; then
             echo "You can complete the following actions at any time:"
             echo
             echo "Reset the password of the elastic built-in superuser with "
-            echo "'/usr/share/bin/elasticsearch-reset-password -u elastic'."
+            echo "'/usr/share/elasticsearch/bin/elasticsearch-reset-password -u elastic'."
             echo
             echo "Generate an enrollment token for Kibana instances with "
-            echo " 'bin/elasticsearch-create-enrollment-token -s kibana'."
+            echo " '/usr/share/elasticsearch/bin/elasticsearch-create-enrollment-token -s kibana'."
             echo
             echo "Generate an enrollment token for Elasticsearch nodes with "
-            echo "'bin/elasticsearch-create-enrollment-token -s node'."
+            echo "'/usr/share/elasticsearch/bin/elasticsearch-create-enrollment-token -s node'."
             echo
             echo "-------------------------------------------------------------------------------------------------"
         fi
@@ -108,7 +108,7 @@ if [ "x$IS_UPGRADE" != "xtrue" ]; then
             echo "However, authentication and authorization are still enabled."
             echo
             echo "You can reset the password of the elastic built-in superuser with "
-            echo "'/usr/share/bin/elasticsearch-reset-password -u elastic' at any time."
+            echo "'/usr/share/elasticsearch/bin/elasticsearch-reset-password -u elastic' at any time."
             echo "-------------------------------------------------------------------------------------------------"
         fi
     fi

+ 1 - 1
distribution/packages/src/common/scripts/postrm

@@ -103,7 +103,7 @@ if [ "$REMOVE_DIRS" = "true" ]; then
 
     # delete the security auto config directory if we are purging
     if [ "$REMOVE_SECURITY_AUTO_CONFIG_DIRECTORY" = "true" ]; then
-      for dir in "${ES_PATH_CONF}"/tls_auto_config_initial_node_*
+      for dir in "${ES_PATH_CONF}"/tls_auto_config_*
       do
         echo -n "Deleting security auto-configuration directory..."
         rm -rf "${dir}"

+ 40 - 15
distribution/src/bin/elasticsearch

@@ -18,16 +18,29 @@ source "`dirname "$0"`"/elasticsearch-env
 CHECK_KEYSTORE=true
 ATTEMPT_SECURITY_AUTO_CONFIG=true
 DAEMONIZE=false
-for option in "$@"; do
-  case "$option" in
-    -h|--help|-V|--version)
-      CHECK_KEYSTORE=false
-      ATTEMPT_SECURITY_AUTO_CONFIG=false
-      ;;
-    -d|--daemonize)
-      DAEMONIZE=true
-      ;;
-  esac
+ENROLL_TO_CLUSTER=false
+# Store original arg array as we will be shifting through it below
+ARG_LIST=("$@")
+
+while [ $# -gt 0 ]; do
+   if [[ $1 == "--enrollment-token" ]]; then
+     if [ $ENROLL_TO_CLUSTER = true ]; then
+       echo "Multiple --enrollment-token parameters are not allowed" 1>&2
+       exit 1
+     fi
+     ENROLL_TO_CLUSTER=true
+     ATTEMPT_SECURITY_AUTO_CONFIG=false
+     ENROLLMENT_TOKEN="$2"
+     shift
+   elif [[ $1 == "-h" || $1 == "--help" || $1 == "-V" || $1 == "--version" ]]; then
+     CHECK_KEYSTORE=false
+     ATTEMPT_SECURITY_AUTO_CONFIG=false
+   elif [[ $1 == "-d" || $1 == "--daemonize" ]]; then
+     DAEMONIZE=true
+   fi
+   if [[ $# -gt 0 ]]; then
+     shift
+   fi
 done
 
 if [ -z "$ES_TMPDIR" ]; then
@@ -47,16 +60,21 @@ then
   fi
 fi
 
-if [[ $ATTEMPT_SECURITY_AUTO_CONFIG = true ]]; then
+if [[ $ENROLL_TO_CLUSTER = true ]]; then
+  ES_MAIN_CLASS=org.elasticsearch.xpack.security.cli.AutoConfigureNode \
+  ES_ADDITIONAL_SOURCES="x-pack-env;x-pack-security-env" \
+  ES_ADDITIONAL_CLASSPATH_DIRECTORIES=lib/tools/security-cli \
+  bin/elasticsearch-cli "${ARG_LIST[@]}" <<<"$KEYSTORE_PASSWORD"
+elif [[ $ATTEMPT_SECURITY_AUTO_CONFIG = true ]]; then
   # It is possible that an auto-conf failure prevents the node from starting, but this is only the exceptional case (exit code 1).
   # Most likely an auto-conf failure will leave the configuration untouched (exit codes 73, 78 and 80), optionally printing a message
   # if the error is uncommon or unexpected, but it should otherwise let the node to start as usual.
   # It is passed in all the command line options in order to read the node settings ones (-E), while the other parameters are ignored
   # (a small caveat is that it also inspects the -v option in order to provide more information on how auto config went)
-  if ES_MAIN_CLASS=org.elasticsearch.xpack.security.cli.ConfigInitialNode \
+  if ES_MAIN_CLASS=org.elasticsearch.xpack.security.cli.AutoConfigureNode \
     ES_ADDITIONAL_SOURCES="x-pack-env;x-pack-security-env" \
     ES_ADDITIONAL_CLASSPATH_DIRECTORIES=lib/tools/security-cli \
-    bin/elasticsearch-cli "$@" <<<"$KEYSTORE_PASSWORD"; then
+    bin/elasticsearch-cli "${ARG_LIST[@]}" <<<"$KEYSTORE_PASSWORD"; then
       :
   else
     retval=$?
@@ -77,6 +95,13 @@ fi
 #   - fourth, ergonomic JVM options are applied
 ES_JAVA_OPTS=`export ES_TMPDIR; "$JAVA" "$XSHARE" -cp "$ES_CLASSPATH" org.elasticsearch.tools.launchers.JvmOptionsParser "$ES_PATH_CONF" "$ES_HOME/plugins"`
 
+# Remove enrollment related parameters before passing the arg list to Elasticsearch
+for i in "${!ARG_LIST[@]}"; do
+  if [[ ${ARG_LIST[i]} = "--enrollment-token" || ${ARG_LIST[i]} = "$ENROLLMENT_TOKEN" ]]; then
+    unset 'ARG_LIST[i]'
+  fi
+done
+
 # manual parsing to find out, if process should be detached
 if [[ $DAEMONIZE = false ]]; then
   exec \
@@ -90,7 +115,7 @@ if [[ $DAEMONIZE = false ]]; then
     -Des.bundled_jdk="$ES_BUNDLED_JDK" \
     -cp "$ES_CLASSPATH" \
     org.elasticsearch.bootstrap.Elasticsearch \
-    "$@" <<<"$KEYSTORE_PASSWORD"
+    "${ARG_LIST[@]}" <<<"$KEYSTORE_PASSWORD"
 else
   exec \
     "$JAVA" \
@@ -103,7 +128,7 @@ else
     -Des.bundled_jdk="$ES_BUNDLED_JDK" \
     -cp "$ES_CLASSPATH" \
     org.elasticsearch.bootstrap.Elasticsearch \
-    "$@" \
+    "${ARG_LIST[@]}" \
     <<<"$KEYSTORE_PASSWORD" &
   retval=$?
   pid=$!

+ 53 - 15
distribution/src/bin/elasticsearch.bat

@@ -5,14 +5,15 @@ setlocal enableextensions
 
 SET params='%*'
 SET checkpassword=Y
+SET enrolltocluster=N
 SET attemptautoconfig=Y
 
 :loop
 FOR /F "usebackq tokens=1* delims= " %%A IN (!params!) DO (
+    SET previous=!current!
     SET current=%%A
     SET params='%%B'
 	SET silent=N
-
 	IF "!current!" == "-s" (
 		SET silent=Y
 	)
@@ -38,14 +39,33 @@ FOR /F "usebackq tokens=1* delims= " %%A IN (!params!) DO (
 		SET attemptautoconfig=N
 	)
 
+	IF "!current!" == "--enrollment-token" (
+	    IF "!enrolltocluster!" == "Y" (
+	        ECHO "Multiple --enrollment-token parameters are not allowed" 1>&2
+	        goto exitwithone
+	    )
+		SET enrolltocluster=Y
+		SET attemptautoconfig=N
+	)
+
+	IF "!previous!" == "--enrollment-token" (
+		SET enrollmenttoken="!current!"
+	)
+
 	IF "!silent!" == "Y" (
 		SET nopauseonerror=Y
 	) ELSE (
-	    IF "x!newparams!" NEQ "x" (
-	        SET newparams=!newparams! !current!
-        ) ELSE (
-            SET newparams=!current!
-        )
+	    SET SHOULD_SKIP=false
+		IF "!previous!" == "--enrollment-token" SET SHOULD_SKIP=true
+		IF "!current!" == "--enrollment-token" SET SHOULD_SKIP=true
+		IF "!SHOULD_SKIP!" == "false" (
+			IF "x!newparams!" NEQ "x" (
+				SET newparams=!newparams! !current!
+			) ELSE (
+				SET newparams=!current!
+			)
+		)
+
 	)
 
     IF "x!params!" NEQ "x" (
@@ -73,13 +93,21 @@ IF "%checkpassword%"=="Y" (
   )
 )
 
+rem windows batch pipe will choke on special characters in strings
+SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^^=^^^^!
+SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^&=^^^&!
+SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^|=^^^|!
+SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^<=^^^<!
+SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^>=^^^>!
+SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^\=^^^\!
+
 IF "%attemptautoconfig%"=="Y" (
     ECHO.!KEYSTORE_PASSWORD!| %JAVA% %ES_JAVA_OPTS% ^
       -Des.path.home="%ES_HOME%" ^
       -Des.path.conf="%ES_PATH_CONF%" ^
       -Des.distribution.flavor="%ES_DISTRIBUTION_FLAVOR%" ^
       -Des.distribution.type="%ES_DISTRIBUTION_TYPE%" ^
-      -cp "!ES_CLASSPATH!;!ES_HOME!/lib/tools/security-cli/*;!ES_HOME!/modules/x-pack-core/*;!ES_HOME!/modules/x-pack-security/*" "org.elasticsearch.xpack.security.cli.ConfigInitialNode" !newparams!
+      -cp "!ES_CLASSPATH!;!ES_HOME!/lib/tools/security-cli/*;!ES_HOME!/modules/x-pack-core/*;!ES_HOME!/modules/x-pack-security/*" "org.elasticsearch.xpack.security.cli.AutoConfigureNode" !newparams!
     SET SHOULDEXIT=Y
     IF !ERRORLEVEL! EQU 0 SET SHOULDEXIT=N
     IF !ERRORLEVEL! EQU 73 SET SHOULDEXIT=N
@@ -90,6 +118,19 @@ IF "%attemptautoconfig%"=="Y" (
     )
 )
 
+IF "!enrolltocluster!"=="Y" (
+    ECHO.!KEYSTORE_PASSWORD!| %JAVA% %ES_JAVA_OPTS% ^
+      -Des.path.home="%ES_HOME%" ^
+      -Des.path.conf="%ES_PATH_CONF%" ^
+      -Des.distribution.flavor="%ES_DISTRIBUTION_FLAVOR%" ^
+      -Des.distribution.type="%ES_DISTRIBUTION_TYPE%" ^
+      -cp "!ES_CLASSPATH!;!ES_HOME!/lib/tools/security-cli/*;!ES_HOME!/modules/x-pack-core/*;!ES_HOME!/modules/x-pack-security/*" "org.elasticsearch.xpack.security.cli.AutoConfigureNode" ^
+      !newparams! --enrollment-token %enrollmenttoken%
+	IF !ERRORLEVEL! NEQ 0 (
+	    exit /b !ERRORLEVEL!
+	)
+)
+
 if not defined ES_TMPDIR (
   for /f "tokens=* usebackq" %%a in (`CALL %JAVA% -cp "!ES_CLASSPATH!" "org.elasticsearch.tools.launchers.TempDirectory"`) do set  ES_TMPDIR=%%a
 )
@@ -111,14 +152,6 @@ if "%MAYBE_JVM_OPTIONS_PARSER_FAILED%" == "jvm_options_parser_failed" (
   exit /b 1
 )
 
-rem windows batch pipe will choke on special characters in strings
-SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^^=^^^^!
-SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^&=^^^&!
-SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^|=^^^|!
-SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^<=^^^<!
-SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^>=^^^>!
-SET KEYSTORE_PASSWORD=!KEYSTORE_PASSWORD:^\=^^^\!
-
 ECHO.!KEYSTORE_PASSWORD!| %JAVA% %ES_JAVA_OPTS% -Delasticsearch ^
   -Des.path.home="%ES_HOME%" -Des.path.conf="%ES_PATH_CONF%" ^
   -Des.distribution.flavor="%ES_DISTRIBUTION_FLAVOR%" ^
@@ -129,3 +162,8 @@ ECHO.!KEYSTORE_PASSWORD!| %JAVA% %ES_JAVA_OPTS% -Delasticsearch ^
 endlocal
 endlocal
 exit /b %ERRORLEVEL%
+
+rem this hack is ugly but necessary because we can't exit with /b X from within the argument parsing loop.
+rem exit 1 (without /b) would work for powershell but it will terminate the cmd process when run in cmd
+:exitwithone
+    exit /b 1

+ 5 - 3
docs/java-rest/high-level/security/enroll_node.asciidoc

@@ -33,11 +33,13 @@ include-tagged::{doc-tests}/EnrollmentDocumentationIT.java[{api}-response]
 for the HTTP layer, as a Base64 encoded string of the ASN.1 DER encoding of the key.
 <2> The CA certificate that can be used by the new node in order to sign its certificate
 for the HTTP layer, as a Base64 encoded string of the ASN.1 DER encoding of the certificate.
-<3> The private key that the node can use for  TLS for its transport layer, as a Base64
+<3> The CA certificate that is used to sign the TLS certificate for the transport layer, as
+a Base64 encoded string of the ASN.1 DER encoding of the certificate.
+<4> The private key that the node can use for  TLS for its transport layer, as a Base64
 encoded string of the ASN.1 DER encoding of the key.
-<4> The certificate that the node can use for  TLS for its transport layer, as a Base64
+<5> The certificate that the node can use for  TLS for its transport layer, as a Base64
 encoded string of the ASN.1 DER encoding of the certificate.
-<5> A list of transport addresses in the form of `host:port` for the nodes that are already
+<6> A list of transport addresses in the form of `host:port` for the nodes that are already
 members of the cluster.
 
 

+ 1 - 0
qa/os/build.gradle

@@ -13,6 +13,7 @@ plugins {
 dependencies {
   testImplementation project(':server')
   testImplementation project(':libs:elasticsearch-core')
+  testImplementation(testArtifact(project(':x-pack:plugin:core')))
   testImplementation "junit:junit:${versions.junit}"
   testImplementation "org.hamcrest:hamcrest:${versions.hamcrest}"
   testImplementation "com.carrotsearch.randomizedtesting:randomizedtesting-runner:${versions.randomizedrunner}"

+ 2 - 2
qa/os/src/test/java/org/elasticsearch/packaging/test/DockerTests.java

@@ -244,7 +244,7 @@ public class DockerTests extends PackagingTestCase {
         copyFromContainer(installation.config("elasticsearch.yml"), tempDir.resolve("elasticsearch.yml"));
         copyFromContainer(installation.config("elasticsearch.keystore"), tempDir.resolve("elasticsearch.keystore"));
         copyFromContainer(installation.config("log4j2.properties"), tempDir.resolve("log4j2.properties"));
-        final Path autoConfigurationDir = findInContainer(installation.config, "d", "\"tls_auto_config_initial_node_*\"");
+        final Path autoConfigurationDir = findInContainer(installation.config, "d", "\"tls_auto_config_*\"");
         final String autoConfigurationDirName = autoConfigurationDir.getFileName().toString();
         copyFromContainer(autoConfigurationDir, tempDir.resolve(autoConfigurationDirName));
 
@@ -336,7 +336,7 @@ public class DockerTests extends PackagingTestCase {
         copyFromContainer(installation.config("jvm.options"), tempEsConfigDir);
         copyFromContainer(installation.config("elasticsearch.keystore"), tempEsConfigDir);
         copyFromContainer(installation.config("log4j2.properties"), tempEsConfigDir);
-        final Path autoConfigurationDir = findInContainer(installation.config, "d", "\"tls_auto_config_initial_node_*\"");
+        final Path autoConfigurationDir = findInContainer(installation.config, "d", "\"tls_auto_config_*\"");
         final String autoConfigurationDirName = autoConfigurationDir.getFileName().toString();
         copyFromContainer(autoConfigurationDir, tempEsConfigDir.resolve(autoConfigurationDirName));
 

+ 133 - 0
qa/os/src/test/java/org/elasticsearch/packaging/test/EnrollNodeToClusterTests.java

@@ -0,0 +1,133 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.packaging.test;
+
+import org.elasticsearch.Version;
+import org.elasticsearch.cli.ExitCodes;
+import org.elasticsearch.packaging.util.Archives;
+import org.elasticsearch.packaging.util.Distribution;
+import org.elasticsearch.packaging.util.Platforms;
+import org.elasticsearch.packaging.util.Shell;
+import org.elasticsearch.xpack.core.security.EnrollmentToken;
+import org.junit.BeforeClass;
+
+import java.util.List;
+
+import static org.elasticsearch.packaging.util.Archives.installArchive;
+import static org.elasticsearch.packaging.util.Archives.verifyArchiveInstallation;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.Assume.assumeTrue;
+
+public class EnrollNodeToClusterTests extends PackagingTestCase {
+
+    @BeforeClass
+    public static void filterDistros() {
+        assumeTrue("only archives", distribution.isArchive());
+    }
+
+    public void test10Install() throws Exception {
+        installation = installArchive(sh, distribution());
+        verifyArchiveInstallation(installation, distribution());
+    }
+
+    public void test20EnrollToClusterWithEmptyTokenValue() throws Exception {
+        Shell.Result result = Archives.runElasticsearchStartCommand(installation, sh, null, List.of("--enrollment-token"), false);
+        // something in our tests wrap the error code to 1 on windows
+        // TODO investigate this and remove this guard
+        if (distribution.platform != Distribution.Platform.WINDOWS) {
+            assertThat(result.exitCode, equalTo(ExitCodes.USAGE));
+        }
+        verifySecurityNotAutoConfigured(installation);
+    }
+
+    public void test30EnrollToClusterWithInvalidToken() throws Exception {
+        Shell.Result result = Archives.runElasticsearchStartCommand(
+            installation,
+            sh,
+            null,
+            List.of("--enrollment-token", "somerandomcharsthatarenotabase64encodedjsonstructure"),
+            false
+        );
+        // something in our tests wrap the error code to 1 on windows
+        // TODO investigate this and remove this guard
+        if (distribution.platform != Distribution.Platform.WINDOWS) {
+            assertThat(result.exitCode, equalTo(ExitCodes.DATA_ERROR));
+        }
+        verifySecurityNotAutoConfigured(installation);
+    }
+
+    public void test40EnrollmentFailsForConfiguredNode() throws Exception {
+        // auto-config requires that the archive owner and the process user be the same,
+        Platforms.onWindows(() -> sh.chown(installation.config, installation.getOwner()));
+        startElasticsearch();
+        verifySecurityAutoConfigured(installation);
+        stopElasticsearch();
+        Shell.Result result = Archives.runElasticsearchStartCommand(
+            installation,
+            sh,
+            null,
+            List.of("--enrollment-token", generateMockEnrollmentToken()),
+            false
+        );
+        // something in our tests wrap the error code to 1 on windows
+        // TODO investigate this and remove this guard
+        if (distribution.platform != Distribution.Platform.WINDOWS) {
+            assertThat(result.exitCode, equalTo(ExitCodes.NOOP));
+        }
+        Platforms.onWindows(() -> sh.chown(installation.config));
+    }
+
+    public void test50MultipleValuesForEnrollmentToken() throws Exception {
+        // if invoked with --enrollment-token tokenA tokenB tokenC, only tokenA is read
+        Shell.Result result = Archives.runElasticsearchStartCommand(
+            installation,
+            sh,
+            null,
+            List.of("--enrollment-token", generateMockEnrollmentToken(), "some-other-token", "some-other-token", "some-other-token"),
+            false
+        );
+        // Assert we used the first value which is a proper enrollment token but failed because the node is already configured ( 80 )
+        // something in our tests wrap the error code to 1 on windows
+        // TODO investigate this and remove this guard
+        if (distribution.platform != Distribution.Platform.WINDOWS) {
+            assertThat(result.exitCode, equalTo(ExitCodes.NOOP));
+        }
+    }
+
+    public void test60MultipleParametersForEnrollmentTokenAreNotAllowed() throws Exception {
+        // if invoked with --enrollment-token tokenA --enrollment-token tokenB --enrollment-token tokenC, we exit
+        Shell.Result result = Archives.runElasticsearchStartCommand(
+            installation,
+            sh,
+            null,
+            List.of(
+                "--enrollment-token",
+                "some-other-token",
+                "--enrollment-token",
+                "some-other-token",
+                "--enrollment-token",
+                generateMockEnrollmentToken()
+            ),
+            false
+        );
+        assertThat(result.stderr, containsString("Multiple --enrollment-token parameters are not allowed"));
+        assertThat(result.exitCode, equalTo(1));
+    }
+
+    private String generateMockEnrollmentToken() throws Exception {
+        EnrollmentToken enrollmentToken = new EnrollmentToken(
+            "some-api-key",
+            "e8864fa9cb5a8053ea84a48581a6c9bef619f8f6aaa58a632aac3e0a25d43ea9",
+            Version.CURRENT.toString(),
+            List.of("localhost:9200")
+        );
+        return enrollmentToken.getEncoded();
+    }
+}

+ 32 - 5
qa/os/src/test/java/org/elasticsearch/packaging/test/EnrollmentProcessTests.java

@@ -14,6 +14,11 @@ import org.elasticsearch.packaging.util.Distribution;
 import org.elasticsearch.packaging.util.Shell;
 import org.junit.BeforeClass;
 
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.Socket;
+import java.util.List;
+
 import static org.elasticsearch.packaging.util.Archives.installArchive;
 import static org.elasticsearch.packaging.util.Archives.verifyArchiveInstallation;
 import static org.elasticsearch.packaging.util.FileUtils.getCurrentVersion;
@@ -36,7 +41,7 @@ public class EnrollmentProcessTests extends PackagingTestCase {
         setFileSuperuser("test_superuser", "test_superuser_password");
         sh.getEnv().put("ES_JAVA_OPTS", "-Xms1g -Xmx1g");
         Shell.Result startFirstNode = awaitElasticsearchStartupWithResult(
-            Archives.startElasticsearchWithTty(installation, sh, null, false)
+            Archives.startElasticsearchWithTty(installation, sh, null, List.of(), false)
         );
         assertThat(startFirstNode.isSuccess(), is(true));
         // Verify that the first node was auto-configured for security
@@ -48,14 +53,36 @@ public class EnrollmentProcessTests extends PackagingTestCase {
         // installation now points to the second node
         installation = installArchive(sh, distribution(), getRootTempDir().resolve("elasticsearch-node2"), getCurrentVersion(), true);
         // auto-configure security using the enrollment token
-        installation.executables().enrollToExistingCluster.run("--enrollment-token " + enrollmentToken);
-        // Verify that the second node was also configured (via enrollment) for security
-        verifySecurityAutoConfigured(installation);
         Shell.Result startSecondNode = awaitElasticsearchStartupWithResult(
-            Archives.startElasticsearchWithTty(installation, sh, null, false)
+            Archives.startElasticsearchWithTty(installation, sh, null, List.of("--enrollment-token", enrollmentToken), false)
         );
+        // ugly hack, wait for the second node to actually start and join the cluster, all of our current tooling expects/assumes
+        // a single installation listening on 9200
+        // TODO Make our packaging test methods aware of multiple installations, see https://github.com/elastic/elasticsearch/issues/79688
+        waitForSecondNode();
         assertThat(startSecondNode.isSuccess(), is(true));
+        verifySecurityAutoConfigured(installation);
         // verify that the two nodes formed a cluster
         assertThat(makeRequest("https://localhost:9200/_cluster/health"), containsString("\"number_of_nodes\":2"));
     }
+
+    private void waitForSecondNode() {
+        int retries = 60;
+        while (retries > 0) {
+            retries -= 1;
+            try (Socket s = new Socket(InetAddress.getLoopbackAddress(), 9201)) {
+                return;
+            } catch (IOException e) {
+                // ignore, only want to establish a connection
+            }
+
+            try {
+                Thread.sleep(2000);
+            } catch (InterruptedException interrupted) {
+                Thread.currentThread().interrupt();
+                return;
+            }
+        }
+        throw new RuntimeException("Elasticsearch second node did not start listening on 9201");
+    }
 }

+ 2 - 2
qa/os/src/test/java/org/elasticsearch/packaging/test/PackagesSecurityAutoConfigurationTests.java

@@ -109,8 +109,8 @@ public class PackagesSecurityAutoConfigurationTests extends PackagingTestCase {
     private Predicate<String> errorOutput() {
         Predicate<String> p1 = output -> output.contains("Failed to auto-configure security features.");
         Predicate<String> p2 = output -> output.contains("However, authentication and authorization are still enabled.");
-        Predicate<String> p3 = output -> output.contains("You can reset the password of the elastic built-in superuser with ");
-        Predicate<String> p4 = output -> output.contains("'/usr/share/bin/elasticsearch-reset-password -u elastic' at any time.");
+        Predicate<String> p3 = output -> output.contains("You can reset the password of the elastic built-in superuser with");
+        Predicate<String> p4 = output -> output.contains("/usr/share/elasticsearch/bin/elasticsearch-reset-password -u elastic");
         return p1.and(p2).and(p3).and(p4);
     }
 

+ 3 - 3
qa/os/src/test/java/org/elasticsearch/packaging/test/PackagingTestCase.java

@@ -320,9 +320,9 @@ public abstract class PackagingTestCase extends Assert {
             case TAR:
             case ZIP:
                 if (useTty) {
-                    return Archives.startElasticsearchWithTty(installation, sh, password, daemonize);
+                    return Archives.startElasticsearchWithTty(installation, sh, password, List.of(), daemonize);
                 } else {
-                    return Archives.runElasticsearchStartCommand(installation, sh, password, daemonize);
+                    return Archives.runElasticsearchStartCommand(installation, sh, password, List.of(), daemonize);
                 }
             case DEB:
             case RPM:
@@ -732,7 +732,7 @@ public abstract class PackagingTestCase extends Assert {
         assertThat(configLines, not(contains(containsString("automatically generated in order to configure Security"))));
         Path caCert = ServerUtils.getCaCert(installation);
         if (caCert != null) {
-            assertThat(caCert.toString(), Matchers.not(Matchers.containsString("tls_auto_config_initial_node")));
+            assertThat(caCert.toString(), Matchers.not(Matchers.containsString("tls_auto_config")));
         }
     }
 

+ 19 - 6
qa/os/src/test/java/org/elasticsearch/packaging/util/Archives.java

@@ -232,12 +232,13 @@ public class Archives {
             .forEach(configFile -> assertThat(es.config(configFile), file(File, owner, owner, p660)));
     }
 
-    public static Shell.Result startElasticsearch(Installation installation, Shell sh) {
-        return runElasticsearchStartCommand(installation, sh, null, true);
-    }
-
-    public static Shell.Result startElasticsearchWithTty(Installation installation, Shell sh, String keystorePassword, boolean daemonize)
-        throws Exception {
+    public static Shell.Result startElasticsearchWithTty(
+        Installation installation,
+        Shell sh,
+        String keystorePassword,
+        List<String> parameters,
+        boolean daemonize
+    ) throws Exception {
         final Path pidFile = installation.home.resolve("elasticsearch.pid");
         final Installation.Executables bin = installation.executables();
 
@@ -248,6 +249,9 @@ public class Archives {
             command.add("-d");
         }
         command.add("-v"); // verbose auto-configuration
+        if (parameters != null && parameters.isEmpty() == false) {
+            command.addAll(parameters);
+        }
         String script = String.format(
             Locale.ROOT,
             "expect -c \"$(cat<<EXPECT\n"
@@ -273,6 +277,7 @@ public class Archives {
         Installation installation,
         Shell sh,
         String keystorePassword,
+        List<String> parameters,
         boolean daemonize
     ) {
         final Path pidFile = installation.home.resolve("elasticsearch.pid");
@@ -302,6 +307,9 @@ public class Archives {
             command.add("-v"); // verbose auto-configuration
             command.add("-p");
             command.add(pidFile.toString());
+            if (parameters != null && parameters.isEmpty() == false) {
+                command.addAll(parameters);
+            }
             if (keystorePassword != null) {
                 command.add("<<<'" + keystorePassword + "'");
             }
@@ -324,6 +332,7 @@ public class Archives {
                 powerShellProcessUserSetup = "";
             }
             // this starts the server in the background. the -d flag is unsupported on windows
+            final String parameterString = parameters != null && parameters.isEmpty() == false ? String.join(" ", parameters) : "";
             return sh.run(
                 "$processInfo = New-Object System.Diagnostics.ProcessStartInfo; "
                     + "$processInfo.FileName = '"
@@ -331,6 +340,7 @@ public class Archives {
                     + "'; "
                     + "$processInfo.Arguments = '-v -p "
                     + installation.home.resolve("elasticsearch.pid")
+                    + parameterString
                     + "'; "
                     + powerShellProcessUserSetup
                     + "$processInfo.RedirectStandardOutput = $true; "
@@ -378,6 +388,9 @@ public class Archives {
             command.add("-v"); // verbose auto-configuration
             command.add("-p");
             command.add(installation.home.resolve("elasticsearch.pid").toString());
+            if (parameters != null && parameters.isEmpty() == false) {
+                command.addAll(parameters);
+            }
             return sh.runIgnoreExitCode(String.join(" ", command));
         }
     }

+ 0 - 1
qa/os/src/test/java/org/elasticsearch/packaging/util/Installation.java

@@ -201,7 +201,6 @@ public class Installation {
         public final Executable setupPasswordsTool = new Executable("elasticsearch-setup-passwords");
         public final Executable resetPasswordTool = new Executable("elasticsearch-reset-password");
         public final Executable createEnrollmentToken = new Executable("elasticsearch-create-enrollment-token");
-        public final Executable enrollToExistingCluster = new Executable("elasticsearch-enroll-node");
         public final Executable sqlCli = new Executable("elasticsearch-sql-cli");
         public final Executable syskeygenTool = new Executable("elasticsearch-syskeygen");
         public final Executable usersTool = new Executable("elasticsearch-users");

+ 2 - 1
qa/os/src/test/java/org/elasticsearch/packaging/util/ServerUtils.java

@@ -155,6 +155,7 @@ public class ServerUtils {
             } catch (IOException e) {
                 // ignore, only want to establish a connection
             }
+
             try {
                 Thread.sleep(2000);
             } catch (InterruptedException interrupted) {
@@ -172,7 +173,7 @@ public class ServerUtils {
     public static Path getCaCert(Installation installation) throws IOException {
         if (installation.distribution.isDocker()) {
             final Path tempDir = PackagingTestCase.createTempDir("docker-ssl");
-            final Path autoConfigurationDir = findInContainer(installation.config, "d", "\"tls_auto_config_initial_node_*\"");
+            final Path autoConfigurationDir = findInContainer(installation.config, "d", "\"tls_auto_config_*\"");
             if (autoConfigurationDir != null) {
                 final Path hostHttpCaCert = tempDir.resolve("http_ca.crt");
                 copyFromContainer(autoConfigurationDir.resolve("http_ca.crt"), hostHttpCaCert);

+ 1 - 1
qa/os/src/test/java/org/elasticsearch/packaging/util/docker/Docker.java

@@ -437,7 +437,7 @@ public class Docker {
 
         Stream.of("jvm.options", "log4j2.properties", "role_mapping.yml", "roles.yml", "users", "users_roles")
             .forEach(configFile -> assertThat(es.config(configFile), file("root", "root", p664)));
-        // We write to the elasticsearch.yml and elasticsearch.keystore in ConfigInitialNode so it gets owned by elasticsearch.
+        // We write to the elasticsearch.yml and elasticsearch.keystore in AutoConfigureNode so it gets owned by elasticsearch.
         assertThat(es.config("elasticsearch.yml"), file("elasticsearch", "root", p664));
         assertThat(es.config("elasticsearch.keystore"), file("elasticsearch", "root", p660));
 

+ 9 - 6
x-pack/docs/en/rest-api/security/enroll-node.asciidoc

@@ -37,9 +37,10 @@ The API returns a response such as
 {
   "http_ca_key" : "MIIJlAIBAzCCCVoGCSqGSIb3DQEHAaCCCUsEgglHMIIJQzCCA98GCSqGSIb3DQ....vsDfsA3UZBAjEPfhubpQysAICCAA=", <1>
   "http_ca_cert" : "MIIJlAIBAzCCCVoGCSqGSIb3DQEHAaCCCUsEgglHMIIJQzCCA98GCSqGSIb3DQ....vsDfsA3UZBAjEPfhubpQysAICCAA=", <2>
-  "transport_key" : "MIIEJgIBAzCCA98GCSqGSIb3DQEHAaCCA9AEggPMMIIDyDCCA8QGCSqGSIb3....YuEiOXvqZ6jxuVSQ0CAwGGoA==", <3>
-  "transport_cert" : "MIIEJgIBAzCCA98GCSqGSIb3DQEHAaCCA9AEggPMMIIDyDCCA8QGCSqGSIb3....YuEiOXvqZ6jxuVSQ0CAwGGoA==", <4>
-  "nodes_addresses" : [                          <5>
+  "transport_ca_cert" : "MIIJlAIBAzCCCVoGCSqGSIb3DQEHAaCCCUsEgglHMIIJQzCCA98GCSqG....vsDfsA3UZBAjEPfhubpQysAICCAA=", <3>
+  "transport_key" : "MIIEJgIBAzCCA98GCSqGSIb3DQEHAaCCA9AEggPMMIIDyDCCA8QGCSqGSIb3....YuEiOXvqZ6jxuVSQ0CAwGGoA==", <4>
+  "transport_cert" : "MIIEJgIBAzCCA98GCSqGSIb3DQEHAaCCA9AEggPMMIIDyDCCA8QGCSqGSIb3....YuEiOXvqZ6jxuVSQ0CAwGGoA==", <5>
+  "nodes_addresses" : [                          <6>
     "192.168.1.2:9300"
   ]
 }
@@ -48,9 +49,11 @@ The API returns a response such as
     for the HTTP layer, as a Base64 encoded string of the ASN.1 DER encoding of the key.
 <2> The CA certificate that can be used by the new node in order to sign its certificate
     for the HTTP layer, as a Base64 encoded string of the ASN.1 DER encoding of the certificate.
-<3> The private key that the node can use for  TLS for its transport layer, as a Base64 encoded
+<3> The CA certificate that is used to sign the TLS certificate for the transport layer, as
+    a Base64 encoded string of the ASN.1 DER encoding of the certificate.
+<4> The private key that the node can use for TLS for its transport layer, as a Base64 encoded
     string of the ASN.1 DER encoding of the key.
-<4> The certificate that the node can use for  TLS for its transport layer, as a Base64 encoded
+<5> The certificate that the node can use for TLS for its transport layer, as a Base64 encoded
     string of the ASN.1 DER encoding of the certificate.
-<5> A list of transport addresses in the form of `host:port` for the nodes that are already
+<6> A list of transport addresses in the form of `host:port` for the nodes that are already
     members of the cluster.

+ 2 - 2
x-pack/docs/en/security/configuring-stack-security.asciidoc

@@ -47,7 +47,7 @@ bin/elasticsearch-security-config
 ----
 +
 The `elasticsearch-security-config` tool generates the following security
-certificates and keys in `config/tls_auto_config_initial_node_<timestamp>`:
+certificates and keys in `config/tls_auto_config_<timestamp>`:
 +
 --
 `http_ca.crt`::
@@ -87,7 +87,7 @@ prompted:
 +
 [source,shell]
 ----
-curl --cacert config/tls_auto_config_initial_node_<timestamp>/http_ca.crt \
+curl --cacert config/tls_auto_config_<timestamp>/http_ca.crt \
 -u elastic https://localhost:9200 <1>
 ----
 // NOTCONSOLE

+ 5 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/CommandLineHttpClient.java

@@ -333,6 +333,9 @@ public class CommandLineHttpClient {
             }
 
             public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+                if (chain.length < 2) {
+                    throw new CertificateException("CA certificate not in chain, or self-signed certificate");
+                }
                 final Certificate caCertFromChain = chain[1];
                 MessageDigest sha256 = MessageDigests.sha256();
                 sha256.update(caCertFromChain.getEncoded());
@@ -341,7 +344,8 @@ public class CommandLineHttpClient {
                 }
             }
 
-            @Override public X509Certificate[] getAcceptedIssuers() {
+            @Override
+            public X509Certificate[] getAcceptedIssuers() {
                 return new X509Certificate[0];
             }
         };

+ 14 - 3
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/NodeEnrollmentResponse.java

@@ -25,10 +25,12 @@ public final class NodeEnrollmentResponse extends ActionResponse implements ToXC
     private static final ParseField HTTP_CA_CERT = new ParseField("http_ca_cert");
     private static final ParseField TRANSPORT_KEY = new ParseField("transport_key");
     private static final ParseField TRANSPORT_CERT = new ParseField("transport_cert");
+    private static final ParseField TRANSPORT_CA_CERT = new ParseField("transport_ca_cert");
     private static final ParseField NODES_ADDRESSES = new ParseField("nodes_addresses");
 
     private final String httpCaKey;
     private final String httpCaCert;
+    private final String transportCaCert;
     private final String transportKey;
     private final String transportCert;
     private final List<String> nodesAddresses;
@@ -37,15 +39,17 @@ public final class NodeEnrollmentResponse extends ActionResponse implements ToXC
         super(in);
         httpCaKey = in.readString();
         httpCaCert = in.readString();
+        transportCaCert = in.readString();
         transportKey = in.readString();
         transportCert = in.readString();
         nodesAddresses = in.readStringList();
     }
 
-    public NodeEnrollmentResponse(String httpCaKey, String httpCaCert, String transportKey, String transportCert,
+    public NodeEnrollmentResponse(String httpCaKey, String httpCaCert, String transportCaCert, String transportKey, String transportCert,
                                   List<String> nodesAddresses) {
         this.httpCaKey = httpCaKey;
         this.httpCaCert = httpCaCert;
+        this.transportCaCert = transportCaCert;
         this.transportKey = transportKey;
         this.transportCert = transportCert;
         this.nodesAddresses = nodesAddresses;
@@ -59,6 +63,8 @@ public final class NodeEnrollmentResponse extends ActionResponse implements ToXC
         return httpCaCert;
     }
 
+    public String getTransportCaCert() { return transportCaCert; }
+
     public String getTransportKey() {
         return transportKey;
     }
@@ -74,6 +80,7 @@ public final class NodeEnrollmentResponse extends ActionResponse implements ToXC
     @Override public void writeTo(StreamOutput out) throws IOException {
         out.writeString(httpCaKey);
         out.writeString(httpCaCert);
+        out.writeString(transportCaCert);
         out.writeString(transportKey);
         out.writeString(transportCert);
         out.writeStringCollection(nodesAddresses);
@@ -83,6 +90,7 @@ public final class NodeEnrollmentResponse extends ActionResponse implements ToXC
         builder.startObject();
         builder.field(HTTP_CA_KEY.getPreferredName(), httpCaKey);
         builder.field(HTTP_CA_CERT.getPreferredName(), httpCaCert);
+        builder.field(TRANSPORT_CA_CERT.getPreferredName(), transportCaCert);
         builder.field(TRANSPORT_KEY.getPreferredName(), transportKey);
         builder.field(TRANSPORT_CERT.getPreferredName(), transportCert);
         builder.field(NODES_ADDRESSES.getPreferredName(), nodesAddresses);
@@ -93,12 +101,15 @@ public final class NodeEnrollmentResponse extends ActionResponse implements ToXC
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;
         NodeEnrollmentResponse that = (NodeEnrollmentResponse) o;
-        return httpCaKey.equals(that.httpCaKey) && httpCaCert.equals(that.httpCaCert) && transportKey.equals(that.transportKey)
+        return httpCaKey.equals(that.httpCaKey)
+            && httpCaCert.equals(that.httpCaCert)
+            && transportCaCert.equals(that.transportCaCert)
+            && transportKey.equals(that.transportKey)
             && transportCert.equals(that.transportCert)
             && nodesAddresses.equals(that.nodesAddresses);
     }
 
     @Override public int hashCode() {
-        return Objects.hash(httpCaKey, httpCaCert, transportKey, transportCert, nodesAddresses);
+        return Objects.hash(httpCaKey, httpCaCert, transportCaCert, transportKey, transportCert, nodesAddresses);
     }
 }

+ 9 - 4
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/enrollment/NodeEnrollementResponseTests.java

@@ -29,6 +29,7 @@ public class NodeEnrollementResponseTests extends AbstractXContentTestCase<NodeE
                 NodeEnrollmentResponse serialized = new NodeEnrollmentResponse(in);
                 assertThat(response.getHttpCaKey(), is(serialized.getHttpCaKey()));
                 assertThat(response.getHttpCaCert(), is(serialized.getHttpCaCert()));
+                assertThat(response.getTransportCaCert(), is(serialized.getTransportCaCert()));
                 assertThat(response.getTransportKey(), is(serialized.getTransportKey()));
                 assertThat(response.getTransportCert(), is(serialized.getTransportCert()));
                 assertThat(response.getNodesAddresses(), is(serialized.getNodesAddresses()));
@@ -38,6 +39,7 @@ public class NodeEnrollementResponseTests extends AbstractXContentTestCase<NodeE
 
     @Override protected NodeEnrollmentResponse createTestInstance() {
         return new NodeEnrollmentResponse(
+            randomAlphaOfLengthBetween(50, 100),
             randomAlphaOfLengthBetween(50, 100),
             randomAlphaOfLengthBetween(50, 100),
             randomAlphaOfLengthBetween(50, 100),
@@ -55,6 +57,7 @@ public class NodeEnrollementResponseTests extends AbstractXContentTestCase<NodeE
 
     private static final ParseField HTTP_CA_KEY = new ParseField("http_ca_key");
     private static final ParseField HTTP_CA_CERT = new ParseField("http_ca_cert");
+    private static final ParseField TRANSPORT_CA_CERT = new ParseField("transport_ca_cert");
     private static final ParseField TRANSPORT_KEY = new ParseField("transport_key");
     private static final ParseField TRANSPORT_CERT = new ParseField("transport_cert");
     private static final ParseField NODES_ADDRESSES = new ParseField("nodes_addresses");
@@ -65,15 +68,17 @@ public class NodeEnrollementResponseTests extends AbstractXContentTestCase<NodeE
         new ConstructingObjectParser<>("node_enrollment_response", true, a -> {
             final String httpCaKey = (String) a[0];
             final String httpCaCert = (String) a[1];
-            final String transportKey = (String) a[2];
-            final String transportCert = (String) a[3];
-            final List<String> nodesAddresses = (List<String>) a[4];
-            return new NodeEnrollmentResponse(httpCaKey, httpCaCert, transportKey, transportCert, nodesAddresses);
+            final String transportCaCert = (String) a[2];
+            final String transportKey = (String) a[3];
+            final String transportCert = (String) a[4];
+            final List<String> nodesAddresses = (List<String>) a[5];
+            return new NodeEnrollmentResponse(httpCaKey, httpCaCert, transportCaCert, transportKey, transportCert, nodesAddresses);
         });
 
     static {
         PARSER.declareString(ConstructingObjectParser.constructorArg(), HTTP_CA_KEY);
         PARSER.declareString(ConstructingObjectParser.constructorArg(), HTTP_CA_CERT);
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), TRANSPORT_CA_CERT);
         PARSER.declareString(ConstructingObjectParser.constructorArg(), TRANSPORT_KEY);
         PARSER.declareString(ConstructingObjectParser.constructorArg(), TRANSPORT_CERT);
         PARSER.declareStringArray(ConstructingObjectParser.constructorArg(), NODES_ADDRESSES);

+ 381 - 138
x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/ConfigInitialNode.java → x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/AutoConfigureNode.java

@@ -9,11 +9,14 @@ package org.elasticsearch.xpack.security.cli;
 
 import joptsimple.OptionSet;
 
+import joptsimple.OptionSpec;
+
 import org.apache.commons.io.FileUtils;
 import org.apache.lucene.util.SetOnce;
 import org.bouncycastle.asn1.x509.GeneralName;
 import org.bouncycastle.asn1.x509.GeneralNames;
 import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
+import org.elasticsearch.ExceptionsHelper;
 import org.elasticsearch.cli.EnvironmentAwareCommand;
 import org.elasticsearch.cli.ExitCodes;
 import org.elasticsearch.cli.Terminal;
@@ -21,6 +24,7 @@ import org.elasticsearch.cli.UserException;
 import org.elasticsearch.cluster.coordination.ClusterBootstrapService;
 import org.elasticsearch.cluster.node.DiscoveryNode;
 import org.elasticsearch.cluster.node.DiscoveryNodeRole;
+import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.UUIDs;
 import org.elasticsearch.common.network.NetworkAddress;
 import org.elasticsearch.common.network.NetworkService;
@@ -36,12 +40,18 @@ import org.elasticsearch.env.Environment;
 import org.elasticsearch.http.HttpTransportSettings;
 import org.elasticsearch.node.Node;
 import org.elasticsearch.xpack.core.XPackSettings;
+import org.elasticsearch.xpack.core.security.CommandLineHttpClient;
+import org.elasticsearch.xpack.core.security.EnrollmentToken;
+import org.elasticsearch.xpack.core.security.HttpResponse;
+import org.elasticsearch.xpack.core.ssl.CertParsingUtils;
 
 import java.io.BufferedWriter;
+import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.net.InetAddress;
+import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.LinkOption;
@@ -54,17 +64,27 @@ import java.nio.file.attribute.PosixFilePermissions;
 import java.nio.file.attribute.UserPrincipal;
 import java.security.KeyPair;
 import java.security.KeyStore;
+import java.security.PrivateKey;
 import java.security.cert.Certificate;
 import java.security.cert.X509Certificate;
 import java.time.ZoneOffset;
 import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Base64;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Set;
+import java.util.function.BiFunction;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import javax.security.auth.x500.X500Principal;
 
+import static org.elasticsearch.common.ssl.PemUtils.parsePKCS8PemString;
+import static org.elasticsearch.discovery.SettingsBasedSeedHostsProvider.DISCOVERY_SEED_HOSTS_SETTING;
+import static org.elasticsearch.xpack.core.security.CommandLineHttpClient.createURL;
+
 /**
  * Configures a new cluster node, by appending to the elasticsearch.yml, so that it forms a single node cluster with
  * Security enabled. Used to configure only the initial node of a cluster, and only before the first time that the node
@@ -74,47 +94,61 @@ import javax.security.auth.x500.X500Principal;
  * This will NOT run if Security is explicitly configured or if the existing configuration otherwise clashes with the
  * intent of this (i.e. the node is configured so it might not form a single node cluster).
  */
-public final class ConfigInitialNode extends EnvironmentAwareCommand {
+public class AutoConfigureNode extends EnvironmentAwareCommand {
 
     public static final String AUTO_CONFIG_ALT_DN = "CN=Elasticsearch security auto-configuration HTTP CA";
     // the transport keystore is also used as a truststore
+    private static final String SIGNATURE_ALGORITHM = "SHA256withRSA";
     private static final String TRANSPORT_AUTOGENERATED_KEYSTORE_NAME = "transport_keystore_all_nodes";
     private static final String TRANSPORT_AUTOGENERATED_KEY_ALIAS = "transport_all_nodes_key";
     private static final String TRANSPORT_AUTOGENERATED_CERT_ALIAS = "transport_all_nodes_cert";
     private static final int TRANSPORT_CERTIFICATE_DAYS = 99 * 365;
+    private static final int TRANSPORT_CA_CERTIFICATE_DAYS = 99 * 365;
     private static final int TRANSPORT_KEY_SIZE = 4096;
-    private static final String HTTP_AUTOGENERATED_KEYSTORE_NAME = "http_keystore_local_node";
-    private static final String HTTP_AUTOGENERATED_CA_NAME = "http_ca";
+    private static final int TRANSPORT_CA_KEY_SIZE = 4096;
+    static final String HTTP_AUTOGENERATED_KEYSTORE_NAME = "http_keystore_local_node";
+    static final String HTTP_AUTOGENERATED_CA_NAME = "http_ca";
     private static final int HTTP_CA_CERTIFICATE_DAYS = 3 * 365;
     private static final int HTTP_CA_KEY_SIZE = 4096;
     private static final int HTTP_CERTIFICATE_DAYS = 2 * 365;
     private static final int HTTP_KEY_SIZE = 4096;
-    private static final String TLS_CONFIG_DIR_NAME_PREFIX = "tls_auto_config_initial_node_";
+    static final String TLS_CONFIG_DIR_NAME_PREFIX = "tls_auto_config_";
+    static final String AUTO_CONFIGURATION_START_MARKER =
+        "#----------------------- Security auto configuration start -----------------------#";
+    static final String AUTO_CONFIGURATION_END_MARKER =
+        "#----------------------- Security auto configuration end -------------------------#";
 
-    public ConfigInitialNode() {
-        super("Generates all the necessary security configuration for the initial node of a new secure cluster");
-        // This "cli utility" must be invoked EXCLUSIVELY from the node startup script, where it is passed all the
+    private final OptionSpec<String> enrollmentTokenParam = parser.accepts("enrollment-token", "The enrollment token to use")
+        .withRequiredArg();
+    private final BiFunction<Environment, String, CommandLineHttpClient> clientFunction;
+
+    public AutoConfigureNode(BiFunction<Environment, String, CommandLineHttpClient> clientFunction) {
+        super("Generates all the necessary security configuration for a node in a secured cluster");
+        // This "cli utility" is invoked from the node startup script, where it is passed all the
         // node startup options unfiltered. It cannot consume most of them, but it does need to inspect the `-E` ones.
         parser.allowsUnrecognizedOptions();
+        this.clientFunction = clientFunction;
+    }
+
+    public AutoConfigureNode() {
+        this(CommandLineHttpClient::new);
     }
 
     public static void main(String[] args) throws Exception {
-        exit(new ConfigInitialNode().main(args, Terminal.DEFAULT));
+        exit(new AutoConfigureNode().main(args, Terminal.DEFAULT));
     }
 
     @Override
     protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
-        // Silently skipping security auto configuration because node considered as restarting.
+        final boolean inEnrollmentMode = options.has(enrollmentTokenParam);
+
+        // skipping security auto configuration because node considered as restarting.
         for (Path dataPath : env.dataFiles()) {
             if (Files.isDirectory(dataPath) && false == isDirEmpty(dataPath)) {
-                terminal.println(Terminal.Verbosity.VERBOSE,
-                    "Skipping security auto configuration because it appears that the node is not starting up for the first time.");
-                terminal.println(Terminal.Verbosity.VERBOSE,
-                    "The node might already be part of a cluster and this auto setup utility is designed to configure Security for new " +
-                        "clusters only.");
-                // we wish the node to start as usual during a restart
-                // but still the exit code should indicate that this has not been run
-                throw new UserException(ExitCodes.NOOP, null);
+                final String msg = "Skipping security auto configuration because it appears that the node is not starting up for the "
+                    + "first time. The node might already be part of a cluster and this auto setup utility is designed to configure "
+                    + "Security for new clusters only.";
+                notifyOfFailure(inEnrollmentMode, terminal, Terminal.Verbosity.VERBOSE, ExitCodes.NOOP, msg);
             }
         }
 
@@ -124,46 +158,37 @@ public final class ConfigInitialNode extends EnvironmentAwareCommand {
         // it is odd for the `elasticsearch.yml` file to be missing or not be a regular (the node won't start)
         // but auto configuration should not be concerned with fixing it (by creating the file) and let the node startup fail
         if (false == Files.exists(ymlPath) || false == Files.isRegularFile(ymlPath, LinkOption.NOFOLLOW_LINKS)) {
-            terminal.println(
-                Terminal.Verbosity.NORMAL,
-                String.format(
-                    Locale.ROOT,
-                    "Skipping security auto configuration because the configuration file [%s] is missing or is not a regular file",
-                    ymlPath
-                )
+            final String msg = String.format(
+                Locale.ROOT,
+                "Skipping security auto configuration because the configuration file [%s] is missing or is not a regular file",
+                ymlPath
             );
-            throw new UserException(ExitCodes.CONFIG, null);
+            notifyOfFailure(inEnrollmentMode, terminal, Terminal.Verbosity.NORMAL, ExitCodes.CONFIG, msg);
         }
         // If the node's yml configuration is not readable, most probably auto-configuration isn't run under the suitable user
         if (false == Files.isReadable(ymlPath)) {
-            terminal.println(
-                Terminal.Verbosity.NORMAL,
-                String.format(
-                    Locale.ROOT,
-                    "Skipping security auto configuration because the current user does not have permission to read "
-                        + " configuration file [%s]",
-                    ymlPath
-                )
+            final String msg = String.format(
+                Locale.ROOT,
+                "Skipping security auto configuration because the current user does not have permission to read "
+                    + " configuration file [%s]",
+                ymlPath
             );
-            throw new UserException(ExitCodes.NOOP, null);
+            notifyOfFailure(inEnrollmentMode, terminal, Terminal.Verbosity.NORMAL, ExitCodes.NOOP, msg);
         }
         // Inform that auto-configuration will not run if keystore cannot be read.
         if (Files.exists(keystorePath)
             && (false == Files.isRegularFile(keystorePath, LinkOption.NOFOLLOW_LINKS) || false == Files.isReadable(keystorePath))) {
-            terminal.println(
-                Terminal.Verbosity.NORMAL,
-                String.format(
-                    Locale.ROOT,
-                    "Skipping security auto configuration because the node keystore file [%s] is not a readable regular file",
-                    keystorePath
-                )
+            final String msg = String.format(
+                Locale.ROOT,
+                "Skipping security auto configuration because the node keystore file [%s] is not a readable regular file",
+                keystorePath
             );
-            throw new UserException(ExitCodes.NOOP, null);
+            notifyOfFailure(inEnrollmentMode, terminal, Terminal.Verbosity.NORMAL, ExitCodes.NOOP, msg);
         }
 
         // only perform auto-configuration if the existing configuration is not conflicting (eg Security already enabled)
         // if it is, silently skip auto configuration
-        checkExistingConfiguration(env.settings(), terminal);
+        checkExistingConfiguration(env.settings(), inEnrollmentMode, terminal);
 
         final ZonedDateTime autoConfigDate = ZonedDateTime.now(ZoneOffset.UTC);
         final String instantAutoConfigName = TLS_CONFIG_DIR_NAME_PREFIX + autoConfigDate.toInstant().getEpochSecond();
@@ -206,64 +231,195 @@ public final class ConfigInitialNode extends EnvironmentAwareCommand {
                     + newFileOwner.getName()
             );
         }
-        final KeyPair transportKeyPair;
+        final X509Certificate transportCaCert;
+        final PrivateKey transportKey;
         final X509Certificate transportCert;
-        final KeyPair httpCAKeyPair;
-        final X509Certificate httpCACert;
-        final KeyPair httpKeyPair;
+        final PrivateKey httpCaKey;
+        final X509Certificate httpCaCert;
+        final PrivateKey httpKey;
         final X509Certificate httpCert;
+        final List<String> transportAddresses;
+
+        if (inEnrollmentMode) {
+            // this is an enrolling node, get HTTP CA key/certificate and transport layer key/certificate from another node
+            final EnrollmentToken enrollmentToken;
+            try {
+                enrollmentToken = EnrollmentToken.decodeFromString(enrollmentTokenParam.value(options));
+            } catch (Exception e) {
+                try {
+                    deleteDirectory(instantAutoConfigDir);
+                } catch (Exception ex) {
+                    e.addSuppressed(ex);
+                }
+                terminal.errorPrintln(Terminal.Verbosity.VERBOSE, "");
+                terminal.errorPrintln(
+                    Terminal.Verbosity.VERBOSE,
+                    "Failed to parse enrollment token : " + enrollmentTokenParam.value(options) + " . Error was: " + e.getMessage()
+                );
+                terminal.errorPrintln(Terminal.Verbosity.VERBOSE, "");
+                terminal.errorPrintln(Terminal.Verbosity.VERBOSE, ExceptionsHelper.stackTrace(e));
+                terminal.errorPrintln(Terminal.Verbosity.VERBOSE, "");
+                throw new UserException(ExitCodes.DATA_ERROR, "Aborting auto configuration. Invalid enrollment token", e);
+            }
+
+            final CommandLineHttpClient client = clientFunction.apply(env, enrollmentToken.getFingerprint());
+
+            // We don't wait for cluster health here. If the user has a token, it means that at least the first node has started
+            // successfully so we expect the cluster to be healthy already. If not, this is a sign of a problem and we should bail.
+            HttpResponse enrollResponse = null;
+            URL enrollNodeUrl = null;
+            for (String address : enrollmentToken.getBoundAddress()) {
+                enrollNodeUrl = createURL(new URL("https://" + address), "/_security/enroll/node", "");
+                enrollResponse = client.execute(
+                    "GET",
+                    enrollNodeUrl,
+                    new SecureString(enrollmentToken.getApiKey().toCharArray()),
+                    () -> null,
+                    CommandLineHttpClient::responseBuilder
+                );
+                if (enrollResponse.getHttpStatus() == 200) {
+                    break;
+                }
+            }
+            if (enrollResponse == null || enrollResponse.getHttpStatus() != 200) {
+                deleteDirectory(instantAutoConfigDir);
+                throw new UserException(
+                    ExitCodes.UNAVAILABLE,
+                    "Aborting enrolling to cluster. "
+                        + "Could not communicate with the initial node in any of the addresses from the enrollment token. All of "
+                        + enrollmentToken.getBoundAddress()
+                        + "where attempted."
+                );
+            }
+            final Map<String, Object> responseMap = enrollResponse.getResponseBody();
+            if (responseMap == null) {
+                deleteDirectory(instantAutoConfigDir);
+                throw new UserException(
+                    ExitCodes.DATA_ERROR,
+                    "Aborting enrolling to cluster. Empty response when calling the enroll node API (" + enrollNodeUrl + ")"
+                );
+            }
+            final List<String> missingFields = new ArrayList<>();
+            final String httpCaKeyPem = (String) responseMap.get("http_ca_key");
+            if (Strings.isNullOrEmpty(httpCaKeyPem)) {
+                missingFields.add("http_ca_key");
+            }
+            final String httpCaCertPem = (String) responseMap.get("http_ca_cert");
+            if (Strings.isNullOrEmpty(httpCaCertPem)) {
+                missingFields.add("http_ca_cert");
+            }
+            final String transportKeyPem = (String) responseMap.get("transport_key");
+            if (Strings.isNullOrEmpty(transportKeyPem)) {
+                missingFields.add("transport_key");
+            }
+            final String transportCaCertPem = (String) responseMap.get("transport_ca_cert");
+            if (Strings.isNullOrEmpty(transportCaCertPem)) {
+                missingFields.add("transport_ca_cert");
+            }
+            final String transportCertPem = (String) responseMap.get("transport_cert");
+            if (Strings.isNullOrEmpty(transportCertPem)) {
+                missingFields.add("transport_cert");
+            }
+            transportAddresses = getTransportAddresses(responseMap);
+            if (null == transportAddresses || transportAddresses.isEmpty()) {
+                missingFields.add("nodes_addresses");
+            }
+            if (false == missingFields.isEmpty()) {
+                deleteDirectory(instantAutoConfigDir);
+                throw new UserException(
+                    ExitCodes.DATA_ERROR,
+                    "Aborting enrolling to cluster. Invalid response when calling the enroll node API ("
+                        + enrollNodeUrl
+                        + "). "
+                        + "The following fields were empty or missing : "
+                        + missingFields
+                );
+            }
+            transportCaCert = parseCertificateFromPem(transportCaCertPem, terminal);
+            httpCaKey = parseKeyFromPem(httpCaKeyPem, terminal);
+            httpCaCert = parseCertificateFromPem(httpCaCertPem, terminal);
+            transportKey = parseKeyFromPem(transportKeyPem, terminal);
+            transportCert = parseCertificateFromPem(transportCertPem, terminal);
+        } else {
+            // this is the initial node, generate HTTP CA key/certificate and transport layer key/certificate ourselves
+            try {
+                transportAddresses = List.of();
+                final X500Principal certificatePrincipal = new X500Principal("CN=" + System.getenv("HOSTNAME"));
+                final X500Principal caPrincipal = new X500Principal(AUTO_CONFIG_ALT_DN);
+                final GeneralNames subjectAltNames = getSubjectAltNames();
+                // self-signed CA for transport layer
+                final KeyPair transportCaKeyPair = CertGenUtils.generateKeyPair(TRANSPORT_CA_KEY_SIZE);
+                final PrivateKey transportCaKey = transportCaKeyPair.getPrivate();
+                transportCaCert = CertGenUtils.generateSignedCertificate(
+                    caPrincipal,
+                    null,
+                    transportCaKeyPair,
+                    null,
+                    null,
+                    true,
+                    TRANSPORT_CA_CERTIFICATE_DAYS,
+                    SIGNATURE_ALGORITHM
+                );
+                // transport key/certificate
+                final KeyPair transportKeyPair = CertGenUtils.generateKeyPair(TRANSPORT_KEY_SIZE);
+                transportKey = transportKeyPair.getPrivate();
+                transportCert = CertGenUtils.generateSignedCertificate(
+                    certificatePrincipal,
+                    subjectAltNames,
+                    transportKeyPair,
+                    transportCaCert,
+                    transportCaKey,
+                    false,
+                    TRANSPORT_CERTIFICATE_DAYS,
+                    SIGNATURE_ALGORITHM
+                );
+                final KeyPair httpCaKeyPair = CertGenUtils.generateKeyPair(HTTP_CA_KEY_SIZE);
+                httpCaKey = httpCaKeyPair.getPrivate();
+                // self-signed CA
+                httpCaCert = CertGenUtils.generateSignedCertificate(
+                    caPrincipal,
+                    null,
+                    httpCaKeyPair,
+                    null,
+                    null,
+                    true,
+                    HTTP_CA_CERTIFICATE_DAYS,
+                    SIGNATURE_ALGORITHM
+                );
+            } catch (Throwable t) {
+                deleteDirectory(instantAutoConfigDir);
+                // this is an error which mustn't be ignored during node startup
+                // the exit code for unhandled Exceptions is "1"
+                throw t;
+            }
+        }
+
+        // in either case, generate this node's HTTP key/certificate
         try {
-            // the transport key-pair is the same across the cluster and is trusted without hostname verification (it is self-signed),
             final X500Principal certificatePrincipal = new X500Principal("CN=" + System.getenv("HOSTNAME"));
-            final X500Principal caPrincipal = new X500Principal(AUTO_CONFIG_ALT_DN);
-            // this does DNS resolve and could block
             final GeneralNames subjectAltNames = getSubjectAltNames();
 
-            transportKeyPair = CertGenUtils.generateKeyPair(TRANSPORT_KEY_SIZE);
-            // self-signed which is not a CA
-            transportCert = CertGenUtils.generateSignedCertificate(
-                certificatePrincipal,
-                subjectAltNames,
-                transportKeyPair,
-                null,
-                null,
-                false,
-                TRANSPORT_CERTIFICATE_DAYS,
-                "SHA256withRSA"
-            );
-            httpCAKeyPair = CertGenUtils.generateKeyPair(HTTP_CA_KEY_SIZE);
-            // self-signed CA
-            httpCACert = CertGenUtils.generateSignedCertificate(
-                caPrincipal,
-                null,
-                httpCAKeyPair,
-                null,
-                null,
-                true,
-                HTTP_CA_CERTIFICATE_DAYS,
-                "SHA256withRSA"
-            );
-            httpKeyPair = CertGenUtils.generateKeyPair(HTTP_KEY_SIZE);
+            final KeyPair httpKeyPair = CertGenUtils.generateKeyPair(HTTP_KEY_SIZE);
+            httpKey = httpKeyPair.getPrivate();
             // non-CA
             httpCert = CertGenUtils.generateSignedCertificate(
                 certificatePrincipal,
                 subjectAltNames,
                 httpKeyPair,
-                httpCACert,
-                httpCAKeyPair.getPrivate(),
+                httpCaCert,
+                httpCaKey,
                 false,
                 HTTP_CERTIFICATE_DAYS,
-                "SHA256withRSA"
+                SIGNATURE_ALGORITHM
             );
 
             // the HTTP CA PEM file is provided "just in case". The node doesn't use it, but clients (configured manually, outside of the
             // enrollment process) might indeed need it, and it is currently impossible to retrieve it
-
             fullyWriteFile(instantAutoConfigDir, HTTP_AUTOGENERATED_CA_NAME + ".crt", false, stream -> {
                 try (
                     JcaPEMWriter pemWriter = new JcaPEMWriter(new BufferedWriter(new OutputStreamWriter(stream, StandardCharsets.UTF_8)))
                 ) {
-                    pemWriter.writeObject(httpCACert);
+                    pemWriter.writeObject(httpCaCert);
                 }
             });
         } catch (Throwable t) {
@@ -305,7 +461,7 @@ public final class ConfigInitialNode extends EnvironmentAwareCommand {
                 // Still display a message, because this can be tricky to figure out (why auto-conf did not run) if by mistake.
                 throw new UserException(
                     ExitCodes.CONFIG,
-                    "Aborting auto configuration because the node keystore contains password " + "settings already"
+                    "Aborting auto configuration because the node keystore contains password settings already"
                 );
             }
             try (SecureString transportKeystorePassword = newKeystorePassword()) {
@@ -314,12 +470,11 @@ public final class ConfigInitialNode extends EnvironmentAwareCommand {
                 // the PKCS12 keystore and the contained private key use the same password
                 transportKeystore.setKeyEntry(
                     TRANSPORT_AUTOGENERATED_KEY_ALIAS,
-                    transportKeyPair.getPrivate(),
+                    transportKey,
                     transportKeystorePassword.getChars(),
                     new Certificate[] { transportCert }
                 );
-                // the transport keystore is used as a trustore too, hence it must contain a certificate entry
-                transportKeystore.setCertificateEntry(TRANSPORT_AUTOGENERATED_CERT_ALIAS, transportCert);
+                transportKeystore.setCertificateEntry(TRANSPORT_AUTOGENERATED_CERT_ALIAS, transportCaCert);
                 fullyWriteFile(
                     instantAutoConfigDir,
                     TRANSPORT_AUTOGENERATED_KEYSTORE_NAME + ".p12",
@@ -337,15 +492,15 @@ public final class ConfigInitialNode extends EnvironmentAwareCommand {
                 // both keys are encrypted using the same password as the PKCS12 keystore they're contained in
                 httpKeystore.setKeyEntry(
                     HTTP_AUTOGENERATED_KEYSTORE_NAME + "_ca",
-                    httpCAKeyPair.getPrivate(),
+                    httpCaKey,
                     httpKeystorePassword.getChars(),
-                    new Certificate[] { httpCACert }
+                    new Certificate[] { httpCaCert }
                 );
                 httpKeystore.setKeyEntry(
                     HTTP_AUTOGENERATED_KEYSTORE_NAME,
-                    httpKeyPair.getPrivate(),
+                    httpKey,
                     httpKeystorePassword.getChars(),
-                    new Certificate[] { httpCert, httpCACert }
+                    new Certificate[] { httpCert, httpCaCert }
                 );
                 fullyWriteFile(
                     instantAutoConfigDir,
@@ -390,6 +545,7 @@ public final class ConfigInitialNode extends EnvironmentAwareCommand {
                         bw.newLine();
                     }
                     bw.newLine();
+                    bw.write(AUTO_CONFIGURATION_START_MARKER);
                     bw.newLine();
                     bw.write("###################################################################################");
                     bw.newLine();
@@ -397,11 +553,9 @@ public final class ConfigInitialNode extends EnvironmentAwareCommand {
                     bw.newLine();
                     bw.write("# have been automatically generated in order to configure Security.               #");
                     bw.newLine();
-                    bw.write("# These have been generated the first time that the new node was started, without #");
-                    bw.newLine();
-                    bw.write("# joining or enrolling to an existing cluster and only if Security had not been   #");
+                    bw.write("# These have been generated the first time that the new node was started, only    #");
                     bw.newLine();
-                    bw.write("# explicitly configured beforehand.                                               #");
+                    bw.write("# if Security had not been explicitly configured beforehand.                      #");
                     bw.newLine();
                     bw.write(String.format(Locale.ROOT, "# %-79s #", ""));
                     bw.newLine();
@@ -449,19 +603,31 @@ public final class ConfigInitialNode extends EnvironmentAwareCommand {
                         "xpack.security.http.ssl.keystore.path: " + instantAutoConfigDir.resolve(HTTP_AUTOGENERATED_KEYSTORE_NAME + ".p12")
                     );
                     bw.newLine();
-
-                    // we have configured TLS on the transport layer with newly generated certs and keys,
-                    // hence this node cannot form a multi-node cluster
-                    // if we don't set the following the node might trip the discovery bootstrap check
-                    if (false == DiscoveryModule.isSingleNodeDiscovery(env.settings())
-                        && false == ClusterBootstrapService.INITIAL_MASTER_NODES_SETTING.exists(env.settings())) {
+                    if (inEnrollmentMode) {
                         bw.newLine();
-                        bw.write("# The initial node with security auto-configured must form a cluster on its own,");
+                        bw.write("# We set seed.hosts so that the node can actually discover the existing nodes in the cluster");
                         bw.newLine();
-                        bw.write("# and all the subsequent nodes should be added via the node enrollment flow");
-                        bw.newLine();
-                        bw.write(ClusterBootstrapService.INITIAL_MASTER_NODES_SETTING.getKey() + ": [\"${HOSTNAME}\"]");
+                        bw.write(
+                            DISCOVERY_SEED_HOSTS_SETTING.getKey()
+                                + ": ["
+                                + transportAddresses.stream().map(p -> '"' + p + '"').collect(Collectors.joining(", "))
+                                + "]"
+                        );
                         bw.newLine();
+                    } else {
+                        // we have configured TLS on the transport layer with newly generated certs and keys,
+                        // hence this node cannot form a multi-node cluster
+                        // if we don't set the following the node might trip the discovery bootstrap check
+                        if (false == DiscoveryModule.isSingleNodeDiscovery(env.settings())
+                            && false == ClusterBootstrapService.INITIAL_MASTER_NODES_SETTING.exists(env.settings())) {
+                            bw.newLine();
+                            bw.write("# The initial node with security auto-configured must form a cluster on its own,");
+                            bw.newLine();
+                            bw.write("# and all the subsequent nodes should be added via the node enrollment flow");
+                            bw.newLine();
+                            bw.write(ClusterBootstrapService.INITIAL_MASTER_NODES_SETTING.getKey() + ": [\"${HOSTNAME}\"]");
+                            bw.newLine();
+                        }
                     }
 
                     // if any address settings have been set, assume the admin has thought it through wrt to addresses,
@@ -481,6 +647,8 @@ public final class ConfigInitialNode extends EnvironmentAwareCommand {
                         bw.write(HttpTransportSettings.SETTING_HTTP_HOST.getKey() + ": [_local_, _site_]");
                         bw.newLine();
                     }
+                    bw.write(AUTO_CONFIGURATION_END_MARKER);
+                    bw.newLine();
                 }
             });
         } catch (Throwable t) {
@@ -510,6 +678,16 @@ public final class ConfigInitialNode extends EnvironmentAwareCommand {
         Files.deleteIfExists(keystoreBackupPath);
     }
 
+    private void notifyOfFailure(boolean inEnrollmentMode, Terminal terminal, Terminal.Verbosity verbosity, int exitCode, String message)
+        throws UserException {
+        if (inEnrollmentMode) {
+            throw new UserException(exitCode, message);
+        } else {
+            terminal.println(verbosity, message);
+            throw new UserException(exitCode, null);
+        }
+    }
+
     @SuppressForbidden(reason = "Uses File API because the commons io library does, which is useful for file manipulation")
     private void deleteDirectory(Path directory) throws IOException {
         FileUtils.deleteDirectory(directory.toFile());
@@ -536,66 +714,80 @@ public final class ConfigInitialNode extends EnvironmentAwareCommand {
      * in which case auto-configuration is SILENTLY skipped.
      * This assumes the user knows what they are doing when configuring the node.
      */
-    void checkExistingConfiguration(Settings settings, Terminal terminal) throws UserException {
+    void checkExistingConfiguration(Settings settings, boolean inEnrollmentMode, Terminal terminal) throws UserException {
         // Allow the user to explicitly set that they don't want auto-configuration for security, regardless of our heuristics
         if (XPackSettings.SECURITY_AUTOCONFIGURATION_ENABLED.get(settings) == false) {
-            terminal.println(
+            notifyOfFailure(
+                inEnrollmentMode,
+                terminal,
                 Terminal.Verbosity.VERBOSE,
+                ExitCodes.NOOP,
                 "Skipping security auto configuration because [" + XPackSettings.SECURITY_AUTOCONFIGURATION_ENABLED.getKey() + "] is false"
             );
-            throw new UserException(ExitCodes.NOOP, null);
         }
-        // Silently skip security auto configuration when Security is already configured.
+        // Skip security auto configuration when Security is already configured.
         // Security is enabled implicitly, but if the admin chooses to enable it explicitly then
         // skip the TLS auto-configuration, as this is a sign that the admin is opting for the default behavior
         if (XPackSettings.SECURITY_ENABLED.exists(settings)) {
             // do not try to validate, correct or fill in any incomplete security configuration,
             // instead rely on the regular node startup to do this validation
-            terminal.println(
+            notifyOfFailure(
+                inEnrollmentMode,
+                terminal,
                 Terminal.Verbosity.VERBOSE,
+                ExitCodes.NOOP,
                 "Skipping security auto configuration because it appears that security is already configured."
             );
-            throw new UserException(ExitCodes.NOOP, null);
         }
+
+        // Skipping security auto configuration because TLS is already configured
+        if (false == settings.getByPrefix(XPackSettings.TRANSPORT_SSL_PREFIX).isEmpty()
+            || false == settings.getByPrefix(XPackSettings.HTTP_SSL_PREFIX).isEmpty()) {
+            // zero validation for the TLS settings as well, let the node boot and do its thing
+            notifyOfFailure(
+                inEnrollmentMode,
+                terminal,
+                Terminal.Verbosity.VERBOSE,
+                ExitCodes.NOOP,
+                "Skipping security auto configuration because it appears that TLS is already configured."
+            );
+        }
+
         // Security auto configuration must not run if the node is configured for multi-node cluster formation (bootstrap or join).
         // This is because transport TLS with newly generated certs will hinder cluster formation because the other nodes cannot trust yet.
         if (false == isInitialClusterNode(settings)) {
-            terminal.println(
+            notifyOfFailure(
+                inEnrollmentMode,
+                terminal,
                 Terminal.Verbosity.VERBOSE,
+                ExitCodes.NOOP,
                 "Skipping security auto configuration because this node is configured to bootstrap or to join a "
                     + "multi-node cluster, which is not supported."
             );
-            throw new UserException(ExitCodes.NOOP, null);
-        }
-        // Silently skip security auto configuration because node cannot become master.
-        boolean canBecomeMaster = DiscoveryNode.isMasterNode(settings)
-            && false == DiscoveryNode.hasRole(settings, DiscoveryNodeRole.VOTING_ONLY_NODE_ROLE);
-        if (false == canBecomeMaster) {
-            terminal.println(
-                Terminal.Verbosity.VERBOSE,
-                "Skipping security auto configuration because the node is configured such that it cannot become master."
-            );
-            throw new UserException(ExitCodes.NOOP, null);
-        }
-        // Silently skip security auto configuration, because the node cannot contain the Security index data
-        boolean canHoldSecurityIndex = DiscoveryNode.canContainData(settings);
-        if (false == canHoldSecurityIndex) {
-            terminal.println(
-                Terminal.Verbosity.VERBOSE,
-                "Skipping security auto configuration because the node is configured such that it cannot contain data."
-            );
-            throw new UserException(ExitCodes.NOOP, null);
         }
-        // Silently skipping security auto configuration because TLS is already configured
-        if (false == settings.getByPrefix(XPackSettings.TRANSPORT_SSL_PREFIX).isEmpty()
-            || false == settings.getByPrefix(XPackSettings.HTTP_SSL_PREFIX).isEmpty()) {
-            // zero validation for the TLS settings as well, let the node boot and do its thing
-            terminal.println(
-                Terminal.Verbosity.VERBOSE,
-                "Skipping security auto configuration because it appears that TLS is already configured."
-            );
-            throw new UserException(ExitCodes.NOOP, null);
+
+        if (inEnrollmentMode == false) {
+            // Silently skip security auto configuration because node cannot become master.
+            boolean canBecomeMaster = DiscoveryNode.isMasterNode(settings)
+                && false == DiscoveryNode.hasRole(settings, DiscoveryNodeRole.VOTING_ONLY_NODE_ROLE);
+            if (false == canBecomeMaster) {
+                terminal.println(
+                    Terminal.Verbosity.VERBOSE,
+                    "Skipping security auto configuration because the node is configured such that it cannot become master."
+                );
+                throw new UserException(ExitCodes.NOOP, null);
+            }
+            // Silently skip security auto configuration, because the node cannot contain the Security index data
+            boolean canHoldSecurityIndex = DiscoveryNode.canContainData(settings);
+            if (false == canHoldSecurityIndex) {
+                terminal.println(
+                    Terminal.Verbosity.VERBOSE,
+                    "Skipping security auto configuration because the node is configured such that it cannot contain data."
+                );
+                throw new UserException(ExitCodes.NOOP, null);
+            }
         }
+
         // auto-configuration runs even if the realms are configured in any way,
         // including defining file based users (defining realms is permitted without touching
         // the xpack.security.enabled setting)
@@ -658,4 +850,55 @@ public final class ConfigInitialNode extends EnvironmentAwareCommand {
             return false == dirContentsStream.findAny().isPresent();
         }
     }
+
+    private X509Certificate parseCertificateFromPem(String pemFormattedCert, Terminal terminal) throws Exception {
+        try {
+            final List<Certificate> certs = CertParsingUtils.readCertificates(
+                Base64.getDecoder().wrap(new ByteArrayInputStream(pemFormattedCert.getBytes(StandardCharsets.UTF_8)))
+            );
+            if (certs.size() != 1) {
+                throw new IllegalStateException("Enroll node API returned multiple certificates");
+            }
+            return (X509Certificate) certs.get(0);
+        } catch (Exception e) {
+            terminal.errorPrintln(Terminal.Verbosity.VERBOSE, "");
+            terminal.errorPrintln(
+                Terminal.Verbosity.VERBOSE,
+                "Failed to parse Certificate from the response of the Enroll Node API: " + e.getMessage()
+            );
+            terminal.errorPrintln(Terminal.Verbosity.VERBOSE, "");
+            terminal.errorPrintln(Terminal.Verbosity.VERBOSE, ExceptionsHelper.stackTrace(e));
+            terminal.errorPrintln(Terminal.Verbosity.VERBOSE, "");
+            throw new UserException(
+                ExitCodes.DATA_ERROR,
+                "Aborting enrolling to cluster. Failed to parse Certificate from the response of the Enroll Node API",
+                e
+            );
+        }
+    }
+
+    private PrivateKey parseKeyFromPem(String pemFormattedKey, Terminal terminal) throws UserException {
+        try {
+            return parsePKCS8PemString(pemFormattedKey);
+        } catch (Exception e) {
+            terminal.errorPrintln(Terminal.Verbosity.VERBOSE, "");
+            terminal.errorPrintln(
+                Terminal.Verbosity.VERBOSE,
+                "Failed to parse Private Key from the response of the Enroll Node API: " + e.getMessage()
+            );
+            terminal.errorPrintln(Terminal.Verbosity.VERBOSE, "");
+            terminal.errorPrintln(Terminal.Verbosity.VERBOSE, ExceptionsHelper.stackTrace(e));
+            terminal.errorPrintln(Terminal.Verbosity.VERBOSE, "");
+            throw new UserException(
+                ExitCodes.DATA_ERROR,
+                "Aborting enrolling to cluster. Failed to parse Private Key from the response of the Enroll Node API",
+                e
+            );
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<String> getTransportAddresses(Map<String, Object> responseMap) {
+        return (List<String>) responseMap.get("nodes_addresses");
+    }
 }

+ 0 - 654
x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/EnrollNodeToCluster.java

@@ -1,654 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-package org.elasticsearch.xpack.security.cli;
-
-import joptsimple.OptionSet;
-
-import joptsimple.OptionSpec;
-
-import org.apache.lucene.util.SetOnce;
-import org.bouncycastle.asn1.x509.GeneralName;
-import org.bouncycastle.asn1.x509.GeneralNames;
-import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
-import org.elasticsearch.cli.ExitCodes;
-import org.elasticsearch.cli.KeyStoreAwareCommand;
-import org.elasticsearch.cli.SuppressForbidden;
-import org.elasticsearch.cli.Terminal;
-import org.elasticsearch.cli.UserException;
-import org.elasticsearch.common.Strings;
-import org.elasticsearch.common.UUIDs;
-import org.elasticsearch.common.network.NetworkAddress;
-import org.elasticsearch.common.network.NetworkService;
-import org.elasticsearch.common.network.NetworkUtils;
-import org.elasticsearch.common.settings.KeyStoreWrapper;
-import org.elasticsearch.common.settings.SecureString;
-import org.elasticsearch.common.settings.Settings;
-import org.elasticsearch.core.CheckedConsumer;
-import org.elasticsearch.core.Tuple;
-import org.elasticsearch.env.Environment;
-import org.elasticsearch.http.HttpTransportSettings;
-import org.elasticsearch.xpack.core.XPackSettings;
-import org.elasticsearch.xpack.core.ssl.CertParsingUtils;
-import org.elasticsearch.xpack.core.security.EnrollmentToken;
-import org.elasticsearch.xpack.core.security.CommandLineHttpClient;
-import org.elasticsearch.xpack.core.security.HttpResponse;
-
-import javax.security.auth.x500.X500Principal;
-import java.io.BufferedWriter;
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.net.InetAddress;
-import java.net.URL;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.LinkOption;
-import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
-import java.nio.file.StandardOpenOption;
-import java.nio.file.attribute.PosixFileAttributeView;
-import java.nio.file.attribute.PosixFilePermission;
-import java.nio.file.attribute.PosixFilePermissions;
-import java.nio.file.attribute.UserPrincipal;
-import java.security.KeyPair;
-import java.security.KeyStore;
-import java.security.PrivateKey;
-import java.security.cert.Certificate;
-import java.security.cert.X509Certificate;
-import java.time.ZoneOffset;
-import java.time.ZonedDateTime;
-import java.util.Base64;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.BiFunction;
-import java.util.stream.Collectors;
-
-import static org.elasticsearch.common.ssl.PemUtils.parsePKCS8PemString;
-import static org.elasticsearch.discovery.SettingsBasedSeedHostsProvider.DISCOVERY_SEED_HOSTS_SETTING;
-import static org.elasticsearch.xpack.core.security.CommandLineHttpClient.createURL;
-
-/**
- * Configures a node to join an existing cluster with security features enabled.
- */
-public class EnrollNodeToCluster extends KeyStoreAwareCommand {
-
-    private final OptionSpec<String> enrollmentTokenParam = parser.accepts("enrollment-token", "The enrollment token to use")
-        .withRequiredArg()
-        .required();
-    private final BiFunction<Environment, String, CommandLineHttpClient> clientFunction;
-
-    static final String TLS_CONFIG_DIR_NAME_PREFIX = "tls_auto_config_node_";
-    static final String HTTP_AUTOGENERATED_KEYSTORE_NAME = "http_keystore_local_node";
-    static final String HTTP_AUTOGENERATED_CA_NAME = "http_ca";
-    static final String TRANSPORT_AUTOGENERATED_KEYSTORE_NAME = "transport_keystore_all_nodes";
-    static final String TRANSPORT_AUTOGENERATED_KEY_ALIAS = "transport_all_nodes_key";
-    static final String TRANSPORT_AUTOGENERATED_CERT_ALIAS = "transport_all_nodes_cert";
-    private static final int HTTP_CERTIFICATE_DAYS = 2 * 365;
-    private static final int HTTP_KEY_SIZE = 4096;
-
-    public EnrollNodeToCluster(BiFunction<Environment, String, CommandLineHttpClient> clientFunction) {
-        super("Configures security so that this node can join an existing cluster");
-        this.clientFunction = clientFunction;
-    }
-
-    public EnrollNodeToCluster() {
-        this(CommandLineHttpClient::new);
-    }
-
-    public static void main(String[] args) throws Exception {
-        exit(new EnrollNodeToCluster().main(args, Terminal.DEFAULT));
-    }
-
-    @Override
-    protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
-
-        for (Path dataPath : env.dataFiles()) {
-            // TODO: Files.list leaks a file handle because the stream is not closed
-            // this effectively doesn't matter since enroll is run in a separate, short lived, process
-            // but it should be fixed...
-            if (Files.isDirectory(dataPath) && Files.list(dataPath).findAny().isPresent()) {
-                throw new UserException(
-                    ExitCodes.CONFIG,
-                    "Aborting enrolling to cluster. It appears that this is not the first time this node starts."
-                );
-            }
-        }
-
-        final Path ymlPath = env.configFile().resolve("elasticsearch.yml");
-        final Path keystorePath = KeyStoreWrapper.keystorePath(env.configFile());
-        if (false == Files.exists(ymlPath)
-            || false == Files.isRegularFile(ymlPath, LinkOption.NOFOLLOW_LINKS)
-            || false == Files.isReadable(ymlPath)) {
-            throw new UserException(
-                ExitCodes.CONFIG,
-                String.format(
-                    Locale.ROOT,
-                    "Aborting enrolling to cluster. The configuration file [%s] is not a readable regular file",
-                    ymlPath
-                )
-            );
-        }
-
-        if (Files.exists(keystorePath)
-            && (false == Files.isRegularFile(keystorePath, LinkOption.NOFOLLOW_LINKS) || false == Files.isReadable(keystorePath))) {
-            throw new UserException(
-                ExitCodes.CONFIG,
-                String.format(Locale.ROOT, "Aborting enrolling to cluster. The keystore [%s] is not a readable regular file", ymlPath)
-            );
-        }
-
-        checkExistingConfiguration(env.settings());
-
-        final ZonedDateTime autoConfigDate = ZonedDateTime.now(ZoneOffset.UTC);
-        final String instantAutoConfigName = TLS_CONFIG_DIR_NAME_PREFIX + autoConfigDate.toInstant().getEpochSecond();
-        final Path instantAutoConfigDir = env.configFile().resolve(instantAutoConfigName);
-        try {
-            // it is useful to pre-create the sub-config dir in order to check that the config dir is writable and that file owners match
-            Files.createDirectory(instantAutoConfigDir);
-            // set permissions to 750, don't rely on umask, we assume auto configuration preserves ownership so we don't have to
-            // grant "group" or "other" permissions
-            PosixFileAttributeView view = Files.getFileAttributeView(instantAutoConfigDir, PosixFileAttributeView.class);
-            if (view != null) {
-                view.setPermissions(PosixFilePermissions.fromString("rwxr-x---"));
-            }
-        } catch (Exception e) {
-            try {
-                Files.deleteIfExists(instantAutoConfigDir);
-            } catch (Exception ex) {
-                e.addSuppressed(ex);
-            }
-            throw new UserException(
-                ExitCodes.CANT_CREATE,
-                "Aborting enrolling to cluster. Could not create auto configuration directory",
-                e
-            );
-        }
-
-        final UserPrincipal newFileOwner = Files.getOwner(instantAutoConfigDir, LinkOption.NOFOLLOW_LINKS);
-        if (false == newFileOwner.equals(Files.getOwner(env.configFile(), LinkOption.NOFOLLOW_LINKS))) {
-            Files.deleteIfExists(instantAutoConfigDir);
-            throw new UserException(ExitCodes.CONFIG, "Aborting enrolling to cluster. config dir ownership mismatch");
-        }
-
-        final EnrollmentToken enrollmentToken;
-        try {
-            enrollmentToken = EnrollmentToken.decodeFromString(enrollmentTokenParam.value(options));
-        } catch (Exception e) {
-            try {
-                Files.deleteIfExists(instantAutoConfigDir);
-            } catch (Exception ex) {
-                e.addSuppressed(ex);
-            }
-            throw new UserException(ExitCodes.DATA_ERROR, "Aborting enrolling to cluster. Invalid enrollment token", e);
-        }
-
-        final CommandLineHttpClient client = clientFunction.apply(env, enrollmentToken.getFingerprint());
-
-        // We don't wait for cluster health here. If the user has a token, it means that at least the first node has started
-        // successfully so we expect the cluster to be healthy already. If not, this is a sign of a problem and we should bail.
-        HttpResponse enrollResponse = null;
-        URL enrollNodeUrl = null;
-        for (String address: enrollmentToken.getBoundAddress()) {
-            enrollNodeUrl = createURL(new URL("https://" + address), "/_security/enroll/node", "");
-            enrollResponse = client.execute("GET",
-                enrollNodeUrl,
-                new SecureString(enrollmentToken.getApiKey().toCharArray()),
-                () -> null,
-                CommandLineHttpClient::responseBuilder);
-            if (enrollResponse.getHttpStatus() == 200 ){
-                break;
-            }
-        }
-        if (enrollResponse == null || enrollResponse.getHttpStatus() != 200) {
-            Files.deleteIfExists(instantAutoConfigDir);
-            throw new UserException(
-                ExitCodes.UNAVAILABLE,
-                "Aborting enrolling to cluster. " +
-                    "Could not communicate with the initial node in any of the addresses from the enrollment token. All of " +
-                    enrollmentToken.getBoundAddress() +
-                    "where attempted."
-            );
-        }
-        final Map<String, Object> responseMap = enrollResponse.getResponseBody();
-        if (responseMap == null) {
-            Files.deleteIfExists(instantAutoConfigDir);
-            throw new UserException(
-                ExitCodes.DATA_ERROR,
-                "Aborting enrolling to cluster. Empty response when calling the enroll node API (" + enrollNodeUrl + ")"
-            );
-        }
-        final String httpCaKeyPem = (String) responseMap.get("http_ca_key");
-        final String httpCaCertPem = (String) responseMap.get("http_ca_cert");
-        final String transportKeyPem = (String) responseMap.get("transport_key");
-        final String transportCertPem = (String) responseMap.get("transport_cert");
-        @SuppressWarnings("unchecked")
-        final List<String> transportAddresses = (List<String>) responseMap.get("nodes_addresses");
-        if (Strings.isNullOrEmpty(httpCaCertPem)
-            || Strings.isNullOrEmpty(httpCaKeyPem)
-            || Strings.isNullOrEmpty(transportKeyPem)
-            || Strings.isNullOrEmpty(transportCertPem)
-            || null == transportAddresses) {
-            Files.deleteIfExists(instantAutoConfigDir);
-            throw new UserException(
-                ExitCodes.DATA_ERROR,
-                "Aborting enrolling to cluster. Invalid response when calling the enroll node API (" + enrollNodeUrl + ")"
-            );
-        }
-
-        final Tuple<PrivateKey, X509Certificate> httpCa = parseKeyCertFromPem(httpCaKeyPem, httpCaCertPem);
-        final PrivateKey httpCaKey = httpCa.v1();
-        final X509Certificate httpCaCert = httpCa.v2();
-        final Tuple<PrivateKey, X509Certificate> transport = parseKeyCertFromPem(transportKeyPem, transportCertPem);
-        final PrivateKey transportKey = transport.v1();
-        final X509Certificate transportCert = transport.v2();
-
-        final X500Principal certificatePrincipal = new X500Principal("CN=Autogenerated by Elasticsearch");
-        // this does DNS resolve and could block
-        final GeneralNames subjectAltNames = getSubjectAltNames();
-
-        final KeyPair nodeHttpKeyPair;
-        final X509Certificate nodeHttpCert;
-
-        try {
-            nodeHttpKeyPair = CertGenUtils.generateKeyPair(HTTP_KEY_SIZE);
-            nodeHttpCert = CertGenUtils.generateSignedCertificate(
-                certificatePrincipal,
-                subjectAltNames,
-                nodeHttpKeyPair,
-                httpCaCert,
-                httpCaKey,
-                false,
-                HTTP_CERTIFICATE_DAYS,
-                null
-            );
-        } catch (Exception e) {
-            try {
-                Files.deleteIfExists(instantAutoConfigDir);
-            } catch (Exception ex) {
-                e.addSuppressed(ex);
-            }
-            throw new UserException(
-                ExitCodes.IO_ERROR,
-                "Aborting enrolling to cluster. Failed to generate necessary key and certificate material",
-                e
-            );
-        }
-
-        try {
-            fullyWriteFile(instantAutoConfigDir, HTTP_AUTOGENERATED_CA_NAME + ".crt", false, stream -> {
-                try (
-                    JcaPEMWriter pemWriter =
-                        new JcaPEMWriter(new BufferedWriter(new OutputStreamWriter(stream, StandardCharsets.UTF_8)))) {
-                    pemWriter.writeObject(httpCaCert);
-                }
-            });
-        } catch (Exception e) {
-            try {
-                Files.deleteIfExists(instantAutoConfigDir);
-            } catch (Exception ex) {
-                e.addSuppressed(ex);
-            }
-            throw new UserException(
-                ExitCodes.IO_ERROR,
-                "Aborting enrolling to cluster. Could not store necessary key and certificates.",
-                e
-            );
-        }
-
-        // save original keystore before updating (replacing)
-        final Path keystoreBackupPath = env.configFile()
-            .resolve(KeyStoreWrapper.KEYSTORE_FILENAME + "." + autoConfigDate.toInstant().getEpochSecond() + ".orig");
-        if (Files.exists(keystorePath)) {
-            try {
-                Files.copy(keystorePath, keystoreBackupPath, StandardCopyOption.COPY_ATTRIBUTES);
-            } catch (Exception e) {
-                try {
-                    Files.deleteIfExists(instantAutoConfigDir);
-                } catch (Exception ex) {
-                    e.addSuppressed(ex);
-                }
-                throw new UserException(
-                    ExitCodes.IO_ERROR,
-                    "Aborting enrolling to cluster. Could not create backup of existing keystore file",
-                    e
-                );
-            }
-        }
-
-        final SetOnce<SecureString> nodeKeystorePassword = new SetOnce<>();
-        try (KeyStoreWrapper nodeKeystore = KeyStoreWrapper.bootstrap(env.configFile(), () -> {
-            nodeKeystorePassword.set(new SecureString(terminal.readSecret("", KeyStoreWrapper.MAX_PASSPHRASE_LENGTH)));
-            return nodeKeystorePassword.get().clone();
-        })) {
-            // do not overwrite keystore entries
-            // instead expect the user to manually remove them themselves
-            if (nodeKeystore.getSettingNames().contains("xpack.security.transport.ssl.keystore.secure_password")
-                || nodeKeystore.getSettingNames().contains("xpack.security.transport.ssl.truststore.secure_password")
-                || nodeKeystore.getSettingNames().contains("xpack.security.http.ssl.keystore.secure_password")) {
-                throw new UserException(
-                    ExitCodes.CONFIG,
-                    "Aborting enrolling to cluster. The node keystore contains TLS related settings already."
-                );
-            }
-            try (SecureString httpKeystorePassword = newKeystorePassword()) {
-                final KeyStore httpKeystore = KeyStore.getInstance("PKCS12");
-                httpKeystore.load(null);
-                httpKeystore.setKeyEntry(
-                    HTTP_AUTOGENERATED_KEYSTORE_NAME + "_ca",
-                    httpCaKey,
-                    httpKeystorePassword.getChars(),
-                    new Certificate[] { httpCaCert }
-                );
-                httpKeystore.setKeyEntry(
-                    HTTP_AUTOGENERATED_KEYSTORE_NAME,
-                    nodeHttpKeyPair.getPrivate(),
-                    httpKeystorePassword.getChars(),
-                    new Certificate[] { nodeHttpCert, httpCaCert }
-                );
-                fullyWriteFile(
-                    instantAutoConfigDir,
-                    HTTP_AUTOGENERATED_KEYSTORE_NAME + ".p12",
-                    false,
-                    stream -> httpKeystore.store(stream, httpKeystorePassword.getChars())
-                );
-                nodeKeystore.setString("xpack.security.http.ssl.keystore.secure_password", httpKeystorePassword.getChars());
-            }
-
-            try (SecureString transportKeystorePassword = newKeystorePassword()) {
-                KeyStore transportKeystore = KeyStore.getInstance("PKCS12");
-                transportKeystore.load(null);
-                // the PKCS12 keystore and the contained private key use the same password
-                transportKeystore.setKeyEntry(
-                    TRANSPORT_AUTOGENERATED_KEY_ALIAS,
-                    transportKey,
-                    transportKeystorePassword.getChars(),
-                    new Certificate[] { transportCert }
-                );
-                // the transport keystore is used as a truststore too, hence it must contain a certificate entry
-                transportKeystore.setCertificateEntry(TRANSPORT_AUTOGENERATED_CERT_ALIAS, transportCert);
-                fullyWriteFile(
-                    instantAutoConfigDir,
-                    TRANSPORT_AUTOGENERATED_KEYSTORE_NAME + ".p12",
-                    false,
-                    stream -> transportKeystore.store(stream, transportKeystorePassword.getChars())
-                );
-                nodeKeystore.setString("xpack.security.transport.ssl.keystore.secure_password", transportKeystorePassword.getChars());
-                // we use the same PKCS12 file for the keystore and the truststore
-                nodeKeystore.setString("xpack.security.transport.ssl.truststore.secure_password", transportKeystorePassword.getChars());
-            }
-            // finally overwrites the node keystore (if the keystore have been successfully written)
-            nodeKeystore.save(env.configFile(), nodeKeystorePassword.get() == null ? new char[0] : nodeKeystorePassword.get().getChars());
-        } catch (Exception e) {
-            // restore keystore to revert possible keystore bootstrap
-            try {
-                if (Files.exists(keystoreBackupPath)) {
-                    Files.move(
-                        keystoreBackupPath,
-                        keystorePath,
-                        StandardCopyOption.REPLACE_EXISTING,
-                        StandardCopyOption.ATOMIC_MOVE,
-                        StandardCopyOption.COPY_ATTRIBUTES
-                    );
-                } else {
-                    Files.deleteIfExists(keystorePath);
-                }
-            } catch (Exception ex) {
-                e.addSuppressed(ex);
-            }
-            try {
-                Files.deleteIfExists(instantAutoConfigDir);
-            } catch (Exception ex) {
-                e.addSuppressed(ex);
-            }
-            throw new UserException(
-                ExitCodes.IO_ERROR,
-                "Aborting enrolling to cluster. Could not store necessary key and certificates.",
-                e
-            );
-        } finally {
-            if (nodeKeystorePassword.get() != null) {
-                nodeKeystorePassword.get().close();
-            }
-        }
-
-        // We have everything, let's write to the config
-        try {
-            List<String> existingConfigLines = Files.readAllLines(ymlPath, StandardCharsets.UTF_8);
-            fullyWriteFile(env.configFile(), "elasticsearch.yml", true, stream -> {
-                try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(stream, StandardCharsets.UTF_8))) {
-                    // start with the existing config lines
-                    for (String line : existingConfigLines) {
-                        bw.write(line);
-                        bw.newLine();
-                    }
-                    bw.newLine();
-                    bw.newLine();
-                    bw.write("###################################################################################");
-                    bw.newLine();
-                    bw.write("# The following settings, and associated TLS certificates and keys configuration, #");
-                    bw.newLine();
-                    bw.write("# have been automatically generated in order to configure Security.               #");
-                    bw.newLine();
-                    bw.write("# These have been generated the first time that the new node was started, when    #");
-                    bw.newLine();
-                    bw.write("# enrolling to an existing cluster                                                #");
-                    bw.write(String.format(Locale.ROOT, "# %-79s #", ""));
-                    bw.newLine();
-                    bw.write(String.format(Locale.ROOT, "# %-79s #", autoConfigDate));
-                    // TODO add link to docs
-                    bw.newLine();
-                    bw.write("###################################################################################");
-                    bw.newLine();
-                    bw.newLine();
-                    bw.write(XPackSettings.SECURITY_ENABLED.getKey() + ": true");
-                    bw.newLine();
-                    bw.newLine();
-                    // Set enrollment mode to true unless user explicitly set it to false themselves
-                    if (false == (env.settings().hasValue(XPackSettings.ENROLLMENT_ENABLED.getKey())
-                        && false == XPackSettings.ENROLLMENT_ENABLED.get(env.settings()))) {
-                        bw.write(XPackSettings.ENROLLMENT_ENABLED.getKey() + ": true");
-                        bw.newLine();
-                        bw.newLine();
-                    }
-
-                    bw.write("xpack.security.transport.ssl.enabled: true");
-                    bw.newLine();
-                    bw.write("# All the nodes use the same key and certificate on the inter-node connection");
-                    bw.newLine();
-                    bw.write("xpack.security.transport.ssl.verification_mode: certificate");
-                    bw.newLine();
-                    bw.write(
-                        "xpack.security.transport.ssl.keystore.path: "
-                            + instantAutoConfigDir.resolve(TRANSPORT_AUTOGENERATED_KEYSTORE_NAME + ".p12")
-                    );
-                    bw.newLine();
-                    // we use the keystore as a truststore in order to minimize the number of auto-generated resources,
-                    // and also because a single file is more idiomatic to the scheme of a shared secret between the cluster nodes
-                    // no one should only need the TLS cert without the associated key for the transport layer
-                    bw.write(
-                        "xpack.security.transport.ssl.truststore.path: "
-                            + instantAutoConfigDir.resolve(TRANSPORT_AUTOGENERATED_KEYSTORE_NAME + ".p12")
-                    );
-                    bw.newLine();
-
-                    bw.newLine();
-                    bw.write("xpack.security.http.ssl.enabled: true");
-                    bw.newLine();
-                    bw.write(
-                        "xpack.security.http.ssl.keystore.path: " + instantAutoConfigDir.resolve(HTTP_AUTOGENERATED_KEYSTORE_NAME + ".p12")
-                    );
-                    bw.newLine();
-                    bw.write("# We set seed.hosts so that the node can actually discover the existing nodes in the cluster");
-                    bw.newLine();
-                    bw.write(
-                        DISCOVERY_SEED_HOSTS_SETTING.getKey()
-                            + ": ["
-                            + transportAddresses.stream().map(p -> '"' + p + '"').collect(Collectors.joining(", "))
-                            + "]"
-                    );
-                    bw.newLine();
-
-                    // if any address settings have been set, assume the admin has thought it through wrt to addresses,
-                    // and don't try to be smart and mess with that
-                    if (false == (env.settings().hasValue(HttpTransportSettings.SETTING_HTTP_HOST.getKey())
-                        || env.settings().hasValue(HttpTransportSettings.SETTING_HTTP_BIND_HOST.getKey())
-                        || env.settings().hasValue(HttpTransportSettings.SETTING_HTTP_PUBLISH_HOST.getKey())
-                        || env.settings().hasValue(NetworkService.GLOBAL_NETWORK_HOST_SETTING.getKey())
-                        || env.settings().hasValue(NetworkService.GLOBAL_NETWORK_BIND_HOST_SETTING.getKey())
-                        || env.settings().hasValue(NetworkService.GLOBAL_NETWORK_PUBLISH_HOST_SETTING.getKey()))) {
-                        bw.newLine();
-                        bw.write(
-                            "# With security now configured, which includes user authentication over HTTPs, "
-                                + "it's reasonable to serve requests on the local network too"
-                        );
-                        bw.newLine();
-                        bw.write(HttpTransportSettings.SETTING_HTTP_HOST.getKey() + ": [_local_, _site_]");
-                        bw.newLine();
-                    }
-                }
-            });
-        } catch (Exception e) {
-            try {
-                if (Files.exists(keystoreBackupPath)) {
-                    Files.move(
-                        keystoreBackupPath,
-                        keystorePath,
-                        StandardCopyOption.REPLACE_EXISTING,
-                        StandardCopyOption.ATOMIC_MOVE,
-                        StandardCopyOption.COPY_ATTRIBUTES
-                    );
-                } else {
-                    Files.deleteIfExists(keystorePath);
-                }
-            } catch (Exception ex) {
-                e.addSuppressed(ex);
-            }
-            try {
-                Files.deleteIfExists(instantAutoConfigDir);
-            } catch (Exception ex) {
-                e.addSuppressed(ex);
-            }
-            throw new UserException(
-                ExitCodes.IO_ERROR,
-                "Aborting enrolling to cluster. Could not persist configuration in elasticsearch.yml",
-                e
-            );
-        }
-        // only delete the backed up file if all went well
-        Files.deleteIfExists(keystoreBackupPath);
-
-    }
-
-    private static void fullyWriteFile(Path basePath, String fileName, boolean replace, CheckedConsumer<OutputStream, Exception> writer)
-        throws Exception {
-        boolean success = false;
-        Path filePath = basePath.resolve(fileName);
-        if (false == replace && Files.exists(filePath)) {
-            throw new UserException(
-                ExitCodes.IO_ERROR,
-                String.format(Locale.ROOT, "Output file [%s] already exists and will not be replaced", filePath)
-            );
-        }
-        // the default permission
-        Set<PosixFilePermission> permission = PosixFilePermissions.fromString("rw-rw----");
-        // if replacing, use the permission of the replaced file
-        if (Files.exists(filePath)) {
-            PosixFileAttributeView view = Files.getFileAttributeView(filePath, PosixFileAttributeView.class);
-            if (view != null) {
-                permission = view.readAttributes().permissions();
-            }
-        }
-        Path tmpPath = basePath.resolve(fileName + "." + UUIDs.randomBase64UUID() + ".tmp");
-        try (OutputStream outputStream = Files.newOutputStream(tmpPath, StandardOpenOption.CREATE_NEW)) {
-            writer.accept(outputStream);
-            PosixFileAttributeView view = Files.getFileAttributeView(tmpPath, PosixFileAttributeView.class);
-            if (view != null) {
-                view.setPermissions(permission);
-            }
-            success = true;
-        } finally {
-            if (success) {
-                if (replace) {
-                    if (Files.exists(filePath, LinkOption.NOFOLLOW_LINKS)
-                        && false == Files.getOwner(tmpPath, LinkOption.NOFOLLOW_LINKS)
-                            .equals(Files.getOwner(filePath, LinkOption.NOFOLLOW_LINKS))) {
-                        Files.deleteIfExists(tmpPath);
-                        String message = String.format(
-                            Locale.ROOT,
-                            "will not overwrite file at [%s], because this incurs changing the file owner",
-                            filePath
-                        );
-                        throw new UserException(ExitCodes.CONFIG, message);
-                    }
-                    Files.move(tmpPath, filePath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
-                } else {
-                    Files.move(tmpPath, filePath, StandardCopyOption.ATOMIC_MOVE);
-                }
-            }
-            Files.deleteIfExists(tmpPath);
-        }
-    }
-
-    SecureString newKeystorePassword() {
-        return UUIDs.randomBase64UUIDSecureString();
-    }
-
-    @SuppressForbidden(reason = "DNS resolve InetAddress#getCanonicalHostName used to populate auto generated HTTPS cert")
-    private GeneralNames getSubjectAltNames() throws IOException {
-        Set<GeneralName> generalNameSet = new HashSet<>();
-        // use only ipv4 addresses
-        // ipv6 can also technically be used, but they are many and they are long
-        for (InetAddress ip : NetworkUtils.getAllIPV4Addresses()) {
-            String ipString = NetworkAddress.format(ip);
-            generalNameSet.add(new GeneralName(GeneralName.iPAddress, ipString));
-            String reverseFQDN = ip.getCanonicalHostName();
-            if (false == ipString.equals(reverseFQDN)) {
-                // reverse FQDN successful
-                generalNameSet.add(new GeneralName(GeneralName.dNSName, reverseFQDN));
-            }
-        }
-        return new GeneralNames(generalNameSet.toArray(new GeneralName[0]));
-    }
-
-    private Tuple<PrivateKey, X509Certificate> parseKeyCertFromPem(String pemFormattedKey, String pemFormattedCert) throws UserException {
-        final PrivateKey key;
-        final X509Certificate cert;
-        try {
-            final List<Certificate> certs = CertParsingUtils.readCertificates(
-                Base64.getDecoder().wrap(new ByteArrayInputStream(pemFormattedCert.getBytes(StandardCharsets.UTF_8)))
-            );
-            if (certs.size() != 1) {
-                throw new IllegalStateException("Enroll node API returned multiple certificates");
-            }
-            cert = (X509Certificate) certs.get(0);
-            key = parsePKCS8PemString(pemFormattedKey);
-            return new Tuple<>(key, cert);
-        } catch (Exception e) {
-            throw new UserException(
-                ExitCodes.DATA_ERROR,
-                "Aborting enrolling to cluster. Failed to parse Private Key and Certificate from the response of the Enroll Node API",
-                e
-            );
-        }
-    }
-
-    void checkExistingConfiguration(Settings settings) throws UserException {
-        if (XPackSettings.SECURITY_ENABLED.exists(settings)) {
-            throw new UserException(ExitCodes.CONFIG, "Aborting enrolling to cluster. It appears that security is already configured.");
-        }
-        if (false == settings.getByPrefix(XPackSettings.TRANSPORT_SSL_PREFIX).isEmpty() ||
-            false == settings.getByPrefix(XPackSettings.HTTP_SSL_PREFIX).isEmpty()) {
-            throw new UserException(ExitCodes.CONFIG, "Aborting enrolling to cluster. It appears that TLS is already configured.");
-        }
-    }
-
-}

+ 0 - 314
x-pack/plugin/security/cli/src/test/java/org/elasticsearch/xpack/security/cli/EnrollNodeToClusterTests.java

@@ -1,314 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-package org.elasticsearch.xpack.security.cli;
-
-import com.google.common.jimfs.Configuration;
-import com.google.common.jimfs.Jimfs;
-
-import org.elasticsearch.Version;
-import org.elasticsearch.cli.Command;
-import org.elasticsearch.cli.CommandTestCase;
-import org.elasticsearch.cli.UserException;
-import org.elasticsearch.common.CheckedSupplier;
-import org.elasticsearch.common.settings.SecureString;
-import org.elasticsearch.common.settings.Settings;
-import org.elasticsearch.common.ssl.PemUtils;
-import org.elasticsearch.core.CheckedFunction;
-import org.elasticsearch.core.PathUtilsForTesting;
-import org.elasticsearch.core.SuppressForbidden;
-import org.elasticsearch.core.internal.io.IOUtils;
-import org.elasticsearch.env.Environment;
-import org.elasticsearch.xpack.core.security.CommandLineHttpClient;
-import org.elasticsearch.xpack.core.security.EnrollmentToken;
-import org.elasticsearch.xpack.core.security.HttpResponse;
-import org.elasticsearch.xpack.core.ssl.CertParsingUtils;
-import org.junit.AfterClass;
-import org.junit.Before;
-import org.junit.BeforeClass;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.HttpURLConnection;
-import java.net.URL;
-import java.nio.file.FileSystem;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.security.KeyStore;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-import static org.elasticsearch.xpack.security.cli.EnrollNodeToCluster.HTTP_AUTOGENERATED_CA_NAME;
-import static org.elasticsearch.xpack.security.cli.EnrollNodeToCluster.HTTP_AUTOGENERATED_KEYSTORE_NAME;
-import static org.elasticsearch.xpack.security.cli.EnrollNodeToCluster.TLS_CONFIG_DIR_NAME_PREFIX;
-import static org.elasticsearch.xpack.security.cli.EnrollNodeToCluster.TRANSPORT_AUTOGENERATED_CERT_ALIAS;
-import static org.elasticsearch.xpack.security.cli.EnrollNodeToCluster.TRANSPORT_AUTOGENERATED_KEYSTORE_NAME;
-import static org.elasticsearch.xpack.security.cli.EnrollNodeToCluster.TRANSPORT_AUTOGENERATED_KEY_ALIAS;
-import static org.hamcrest.Matchers.equalTo;
-import static org.hamcrest.Matchers.is;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
-public class EnrollNodeToClusterTests extends CommandTestCase {
-
-    static FileSystem jimfs;
-    private Path confDir;
-    private CommandLineHttpClient client;
-    static String keystoresPassword;
-    Settings settings;
-
-    @Override
-    protected Command newCommand() {
-        return new EnrollNodeToCluster(((environment, pinnedCaCertFingerprint) -> client)) {
-            @Override
-            protected Environment createEnv(Map<String, String> settings) {
-                return new Environment(EnrollNodeToClusterTests.this.settings, confDir);
-            }
-
-            @Override
-            SecureString newKeystorePassword() {
-                return new SecureString(keystoresPassword.toCharArray());
-            }
-        };
-    }
-
-    @BeforeClass
-    public static void setupJimfs() {
-        Configuration conf = Configuration.unix().toBuilder().setAttributeViews("posix", "owner").build();
-        jimfs = Jimfs.newFileSystem(conf);
-        PathUtilsForTesting.installMock(jimfs);
-    }
-
-    @Before
-    @SuppressWarnings("unchecked")
-    @SuppressForbidden(reason = "Cannot use getDataPath() as Paths.get() throws UnsupportedOperationException for jimfs")
-    public void setup() throws Exception {
-        Path homeDir = jimfs.getPath("eshome");
-        IOUtils.rm(homeDir);
-        confDir = homeDir.resolve("config");
-        Files.createDirectories(confDir);
-        settings = Settings.builder().put("path.home", homeDir).build();
-        Files.createFile(confDir.resolve("elasticsearch.yml"));
-        String httpCaCertPemString = Files.readAllLines(
-            Paths.get(getClass().getResource("http_ca.crt").toURI()).toAbsolutePath().normalize()
-        ).stream().filter(l -> l.contains("-----") == false).collect(Collectors.joining());
-        String httpCaKeyPemString = Files.readAllLines(
-            Paths.get(getClass().getResource("http_ca.key").toURI()).toAbsolutePath().normalize()
-        ).stream().filter(l -> l.contains("-----") == false).collect(Collectors.joining());
-        String transportKeyPemString = Files.readAllLines(
-            Paths.get(getClass().getResource("transport.key").toURI()).toAbsolutePath().normalize()
-        ).stream().filter(l -> l.contains("-----") == false).collect(Collectors.joining());
-        String transportCertPemString = Files.readAllLines(
-            Paths.get(getClass().getResource("transport.crt").toURI()).toAbsolutePath().normalize()
-        ).stream().filter(l -> l.contains("-----") == false).collect(Collectors.joining());
-
-        HttpResponse nodeEnrollResponse = new HttpResponse(
-            HttpURLConnection.HTTP_OK,
-            Map.of(
-                "http_ca_key",
-                httpCaKeyPemString,
-                "http_ca_cert",
-                httpCaCertPemString,
-                "transport_key",
-                transportKeyPemString,
-                "transport_cert",
-                transportCertPemString,
-                "nodes_addresses",
-                List.of("127.0.0.1:9300", "192.168.1.10:9301")
-            )
-        );
-        this.client = mock(CommandLineHttpClient.class);
-        when(client.execute(anyString(), any(URL.class), any(SecureString.class), any(CheckedSupplier.class), any(CheckedFunction.class)))
-            .thenReturn(nodeEnrollResponse);
-        keystoresPassword = randomAlphaOfLengthBetween(14, 18);
-    }
-
-    @AfterClass
-    public static void closeJimfs() throws IOException {
-        if (jimfs != null) {
-            jimfs.close();
-            jimfs = null;
-        }
-    }
-
-    @SuppressForbidden(reason = "Cannot use getDataPath() as Paths.get() throws UnsupportedOperationException for jimfs")
-    public void testEnrollmentSuccess() throws Exception {
-        final EnrollmentToken enrollmentToken = new EnrollmentToken(
-            randomAlphaOfLength(12),
-            randomAlphaOfLength(12),
-            Version.CURRENT.toString(),
-            List.of("127.0.0.1:9200")
-        );
-        execute("--enrollment-token", enrollmentToken.getEncoded());
-        final Path autoConfigDir = assertAutoConfigurationFilesCreated();
-        assertTransportKeystore(
-            autoConfigDir.resolve(TRANSPORT_AUTOGENERATED_KEYSTORE_NAME + ".p12"),
-            Paths.get(getClass().getResource("transport.key").toURI()).toAbsolutePath().normalize(),
-            Paths.get(getClass().getResource("transport.crt").toURI()).toAbsolutePath().normalize()
-        );
-        assertHttpKeystore(
-            autoConfigDir.resolve(HTTP_AUTOGENERATED_KEYSTORE_NAME + ".p12"),
-            Paths.get(getClass().getResource("http_ca.key").toURI()).toAbsolutePath().normalize(),
-            Paths.get(getClass().getResource("http_ca.crt").toURI()).toAbsolutePath().normalize()
-        );
-    }
-
-    public void testEnrollmentExitsOnAlreadyConfiguredNode() throws Exception {
-        final EnrollmentToken enrollmentToken = new EnrollmentToken(
-            randomAlphaOfLength(12),
-            randomAlphaOfLength(12),
-            Version.CURRENT.toString(),
-            List.of("127.0.0.1:9200")
-        );
-        Path dataDir = Files.createDirectory(jimfs.getPath("eshome").resolve("data"));
-        Files.createFile(dataDir.resolve("foo"));
-        settings = Settings.builder().put(settings).put("path.data", dataDir).put("xpack.security.enrollment.enabled", true).build();
-        UserException e = expectThrows(UserException.class, () -> execute("--enrollment-token", enrollmentToken.getEncoded()));
-        assertThat(e.getMessage(), equalTo("Aborting enrolling to cluster. It appears that this is not the first time this node starts."));
-        assertAutoConfigurationFilesNotCreated();
-    }
-
-    public void testEnrollmentExitsOnInvalidEnrollmentToken() throws Exception {
-        final EnrollmentToken enrollmentToken = new EnrollmentToken(
-            randomAlphaOfLength(12),
-            randomAlphaOfLength(12),
-            Version.CURRENT.toString(),
-            List.of("127.0.0.1:9200")
-        );
-
-        UserException e = expectThrows(
-            UserException.class,
-            () -> execute(
-                "--enrollment-token",
-                enrollmentToken.getEncoded().substring(0, enrollmentToken.getEncoded().length() - randomIntBetween(6, 12))
-            )
-        );
-        assertThat(e.getMessage(), equalTo("Aborting enrolling to cluster. Invalid enrollment token"));
-        assertAutoConfigurationFilesNotCreated();
-    }
-
-    @SuppressWarnings("unchecked")
-    public void testEnrollmentExitsOnUnexpectedResponse() throws Exception {
-        when(client.execute(anyString(), any(URL.class), any(SecureString.class), any(CheckedSupplier.class), any(CheckedFunction.class)))
-            .thenReturn(new HttpResponse(randomFrom(401, 403, 500), Map.of()));
-        final EnrollmentToken enrollmentToken = new EnrollmentToken(
-            randomAlphaOfLength(12),
-            randomAlphaOfLength(12),
-            Version.CURRENT.toString(),
-            List.of("127.0.0.1:9200")
-        );
-        UserException e = expectThrows(UserException.class, () -> execute("--enrollment-token", enrollmentToken.getEncoded()));
-        assertThat(
-            e.getMessage(),
-            equalTo(
-                "Aborting enrolling to cluster. "
-                    + "Could not communicate with the initial node in any of the addresses from the enrollment token. All of "
-                    + enrollmentToken.getBoundAddress()
-                    + "where attempted."
-            )
-        );
-        assertAutoConfigurationFilesNotCreated();
-    }
-
-    public void testEnrollmentExitsOnExistingSecurityConfiguration() throws Exception {
-        settings = Settings.builder().put(settings).put("xpack.security.enabled", true).build();
-        final EnrollmentToken enrollmentToken = new EnrollmentToken(
-            randomAlphaOfLength(12),
-            randomAlphaOfLength(12),
-            Version.CURRENT.toString(),
-            List.of("127.0.0.1:9200")
-        );
-        UserException e = expectThrows(UserException.class, () -> execute("--enrollment-token", enrollmentToken.getEncoded()));
-        assertThat(e.getMessage(), equalTo("Aborting enrolling to cluster. It appears that security is already configured."));
-        assertAutoConfigurationFilesNotCreated();
-    }
-
-    public void testEnrollmentExitsOnExistingTlsConfiguration() throws Exception {
-        settings = Settings.builder()
-            .put(settings)
-            .put("xpack.security.transport.ssl.enabled", true)
-            .put("xpack.security.http.ssl.enabled", true)
-            .build();
-        final EnrollmentToken enrollmentToken = new EnrollmentToken(
-            randomAlphaOfLength(12),
-            randomAlphaOfLength(12),
-            Version.CURRENT.toString(),
-            List.of("127.0.0.1:9200")
-        );
-        UserException e = expectThrows(UserException.class, () -> execute("--enrollment-token", enrollmentToken.getEncoded()));
-        assertThat(e.getMessage(), equalTo("Aborting enrolling to cluster. It appears that TLS is already configured."));
-        assertAutoConfigurationFilesNotCreated();
-    }
-
-    private Path assertAutoConfigurationFilesCreated() throws Exception {
-        List<Path> f = Files.find(
-            confDir,
-            2,
-            ((path, basicFileAttributes) -> Files.isDirectory(path) && path.getFileName().toString().startsWith(TLS_CONFIG_DIR_NAME_PREFIX))
-        ).collect(Collectors.toList());
-        assertThat(f.size(), equalTo(1));
-        final Path autoConfigDir = f.get(0);
-        assertThat(Files.isRegularFile(autoConfigDir.resolve(HTTP_AUTOGENERATED_CA_NAME + ".crt")), is(true));
-        assertThat(Files.isRegularFile(autoConfigDir.resolve(HTTP_AUTOGENERATED_KEYSTORE_NAME + ".p12")), is(true));
-        assertThat(Files.isRegularFile(autoConfigDir.resolve(TRANSPORT_AUTOGENERATED_KEYSTORE_NAME + ".p12")), is(true));
-
-        return autoConfigDir;
-    }
-
-    private void assertAutoConfigurationFilesNotCreated() throws Exception {
-        List<Path> f = Files.find(
-            confDir,
-            2,
-            ((path, basicFileAttributes) -> Files.isDirectory(path) && path.getFileName().toString().startsWith(TLS_CONFIG_DIR_NAME_PREFIX))
-        ).collect(Collectors.toList());
-        assertThat(f.size(), equalTo(0));
-    }
-
-    private void assertTransportKeystore(Path keystorePath, Path keyPath, Path certPath) throws Exception {
-        try (InputStream in = Files.newInputStream(keystorePath)) {
-            final KeyStore keyStore = KeyStore.getInstance("PKCS12");
-            keyStore.load(in, keystoresPassword.toCharArray());
-            assertThat(keyStore.size(), equalTo(2));
-            assertThat(keyStore.isKeyEntry(TRANSPORT_AUTOGENERATED_KEY_ALIAS), is(true));
-            assertThat(keyStore.isCertificateEntry(TRANSPORT_AUTOGENERATED_CERT_ALIAS), is(true));
-            assertThat(
-                keyStore.getKey(TRANSPORT_AUTOGENERATED_KEY_ALIAS, keystoresPassword.toCharArray()),
-                equalTo(PemUtils.readPrivateKey(keyPath, () -> null))
-            );
-            assertThat(
-                keyStore.getCertificate(TRANSPORT_AUTOGENERATED_CERT_ALIAS),
-                equalTo(CertParsingUtils.readX509Certificate(certPath))
-            );
-            assertThat(keyStore.getCertificate(TRANSPORT_AUTOGENERATED_KEY_ALIAS), equalTo(CertParsingUtils.readX509Certificate(certPath)));
-        }
-    }
-
-    private void assertHttpKeystore(Path keystorePath, Path keyPath, Path certPath) throws Exception {
-        try (InputStream in = Files.newInputStream(keystorePath)) {
-            final KeyStore keyStore = KeyStore.getInstance("PKCS12");
-            keyStore.load(in, keystoresPassword.toCharArray());
-            assertThat(keyStore.size(), equalTo(2));
-            assertThat(keyStore.isKeyEntry(HTTP_AUTOGENERATED_KEYSTORE_NAME + "_ca"), is(true));
-            assertThat(keyStore.isKeyEntry(HTTP_AUTOGENERATED_KEYSTORE_NAME), is(true));
-            assertThat(
-                keyStore.getCertificate(HTTP_AUTOGENERATED_KEYSTORE_NAME + "_ca"),
-                equalTo(CertParsingUtils.readX509Certificate(certPath))
-            );
-            assertThat(
-                keyStore.getKey(HTTP_AUTOGENERATED_KEYSTORE_NAME + "_ca", keystoresPassword.toCharArray()),
-                equalTo(PemUtils.readPrivateKey(keyPath, () -> null))
-            );
-            keyStore.getCertificate(HTTP_AUTOGENERATED_KEYSTORE_NAME).verify(CertParsingUtils.readX509Certificate(certPath).getPublicKey());
-            // Certificate#verify didn't throw
-        }
-    }
-
-}

+ 0 - 12
x-pack/plugin/security/src/main/bin/elasticsearch-enroll-node

@@ -1,12 +0,0 @@
-#!/bin/bash
-
-# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
-# or more contributor license agreements. Licensed under the Elastic License
-# 2.0; you may not use this file except in compliance with the Elastic License
-# 2.0.
-
-ES_MAIN_CLASS=org.elasticsearch.xpack.security.cli.EnrollNodeToCluster \
-  ES_ADDITIONAL_SOURCES="x-pack-env;x-pack-security-env" \
-  ES_ADDITIONAL_CLASSPATH_DIRECTORIES=lib/tools/security-cli \
-  "$(dirname "$0")/elasticsearch-cli" \
-  "$@"

+ 0 - 21
x-pack/plugin/security/src/main/bin/elasticsearch-enroll-node.bat

@@ -1,21 +0,0 @@
-@echo off
-
-rem Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
-rem or more contributor license agreements. Licensed under the Elastic License
-rem 2.0; you may not use this file except in compliance with the Elastic License
-rem 2.0.
-
-setlocal enabledelayedexpansion
-setlocal enableextensions
-
-set ES_MAIN_CLASS=org.elasticsearch.xpack.security.cli.EnrollNodeToCluster
-set ES_ADDITIONAL_SOURCES=x-pack-env;x-pack-security-env
-set ES_ADDITIONAL_CLASSPATH_DIRECTORIES=lib/tools/security-cli
-call "%~dp0elasticsearch-cli.bat " ^
-  %%* ^
-  || goto exit
-
-endlocal
-endlocal
-:exit
-exit /b %ERRORLEVEL%

+ 20 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/enrollment/TransportNodeEnrollmentAction.java

@@ -15,6 +15,7 @@ import org.elasticsearch.action.admin.cluster.node.info.NodesInfoRequest;
 import org.elasticsearch.action.support.ActionFilters;
 import org.elasticsearch.action.support.HandledTransportAction;
 import org.elasticsearch.client.Client;
+import org.elasticsearch.common.ssl.StoredCertificate;
 import org.elasticsearch.core.Tuple;
 import org.elasticsearch.common.inject.Inject;
 import org.elasticsearch.common.ssl.SslKeyConfig;
@@ -81,6 +82,23 @@ public class TransportNodeEnrollmentAction extends HandledTransportAction<NodeEn
                 "Unable to enroll node. Elasticsearch node transport layer SSL configuration contains multiple keys"));
             return;
         }
+        final List<X509Certificate> transportCaCertificates;
+        try {
+            transportCaCertificates = ((StoreKeyConfig) transportKeyConfig).getConfiguredCertificates()
+                .stream()
+                .map(StoredCertificate::getCertificate)
+                .filter(x509Certificate -> x509Certificate.getBasicConstraints() != -1)
+                .collect(Collectors.toList());
+        } catch (Exception e) {
+            listener.onFailure(new ElasticsearchException("Unable to enroll node. Cannot retrieve CA certificate " +
+                "for the transport layer of the Elasticsearch node.", e));
+            return;
+        }
+        if (transportCaCertificates.size() != 1) {
+            listener.onFailure(new ElasticsearchException(
+                "Unable to enroll Elasticsearch node. Elasticsearch node transport layer SSL configuration Keystore " +
+                    "[xpack.security.transport.ssl.keystore] doesn't contain a single CA certificate"));
+        }
 
         if (httpCaKeysAndCertificates.isEmpty()) {
             listener.onFailure(new IllegalStateException(
@@ -104,12 +122,14 @@ public class TransportNodeEnrollmentAction extends HandledTransportAction<NodeEn
                 try {
                     final String httpCaKey = Base64.getEncoder().encodeToString(httpCaKeysAndCertificates.get(0).v1().getEncoded());
                     final String httpCaCert = Base64.getEncoder().encodeToString(httpCaKeysAndCertificates.get(0).v2().getEncoded());
+                    final String transportCaCert = Base64.getEncoder().encodeToString(transportCaCertificates.get(0).getEncoded());
                     final String transportKey =
                         Base64.getEncoder().encodeToString(transportKeysAndCertificates.get(0).v1().getEncoded());
                     final String transportCert =
                         Base64.getEncoder().encodeToString(transportKeysAndCertificates.get(0).v2().getEncoded());
                     listener.onResponse(new NodeEnrollmentResponse(httpCaKey,
                         httpCaCert,
+                        transportCaCert,
                         transportKey,
                         transportCert,
                         nodeList));