Browse Source

Enroll node API (#72129)

Enroll node API can be used by new nodes in order to join an
existing cluster that has security features enabled. The response
of a call to this API contains all the necessary information that
the new node requires in order to configure itself and bootstrap
trust with the existing cluster.
Ioannis Kakavas 4 years ago
parent
commit
b826703e21
35 changed files with 1027 additions and 10 deletions
  1. 4 0
      client/rest-high-level/build.gradle
  2. BIN
      client/rest-high-level/httpCa.p12
  3. 1 0
      client/rest-high-level/src/main/java/org/elasticsearch/client/ClusterClient.java
  4. 25 0
      client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java
  5. 29 0
      client/rest-high-level/src/main/java/org/elasticsearch/client/security/NodeEnrollmentRequest.java
  6. 108 0
      client/rest-high-level/src/main/java/org/elasticsearch/client/security/NodeEnrollmentResponse.java
  7. 1 0
      client/rest-high-level/src/test/java/org/elasticsearch/client/ClusterClientIT.java
  8. 17 0
      client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityIT.java
  9. 1 0
      client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/ClusterClientDocumentationIT.java
  10. 46 0
      client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java
  11. BIN
      client/rest-high-level/transport.p12
  12. 2 0
      docs/build.gradle
  13. BIN
      docs/httpCa.p12
  14. 64 0
      docs/java-rest/high-level/cluster/enroll_node.asciidoc
  15. BIN
      docs/transport.p12
  16. 24 0
      rest-api-spec/src/main/resources/rest-api-spec/api/security.enroll_node.json
  17. 11 0
      x-pack/docs/en/rest-api/security.asciidoc
  18. 63 0
      x-pack/docs/en/rest-api/security/enroll-node.asciidoc
  19. 2 3
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackPlugin.java
  20. 20 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/NodeEnrollmentAction.java
  21. 28 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/NodeEnrollmentRequest.java
  22. 114 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/NodeEnrollmentResponse.java
  23. 2 2
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/CertParsingUtils.java
  24. 1 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/KeyConfig.java
  25. 2 2
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/SSLConfiguration.java
  26. 4 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/SSLService.java
  27. 22 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/StoreKeyConfig.java
  28. 86 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/enrollment/NodeEnrollementResponseTests.java
  29. 1 0
      x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java
  30. 6 1
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
  31. 124 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/enrollment/TransportNodeEnrollmentAction.java
  32. 58 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/enrollment/RestNodeEnrollmentAction.java
  33. 161 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/enrollment/TransportNodeEnrollmentActionTests.java
  34. BIN
      x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/httpCa.p12
  35. BIN
      x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/transport.p12

+ 4 - 0
client/rest-high-level/build.gradle

@@ -67,6 +67,8 @@ tasks.named('forbiddenApisMain').configure {
 File nodeCert = file("./testnode.crt")
 File nodeTrustStore = file("./testnode.jks")
 File pkiTrustCert = file("./src/test/resources/org/elasticsearch/client/security/delegate_pki/testRootCA.crt")
+File httpCaKeystore = file("./httpCa.p12");
+File transportKeystore = file("./transport.p12");
 
 tasks.named("integTest").configure {
   systemProperty 'tests.rest.async', 'false'
@@ -116,6 +118,8 @@ testClusters.all {
   extraConfigFile nodeCert.name, nodeCert
   extraConfigFile nodeTrustStore.name, nodeTrustStore
   extraConfigFile pkiTrustCert.name, pkiTrustCert
+  extraConfigFile httpCaKeystore.name, httpCaKeystore
+  extraConfigFile transportKeystore.name, transportKeystore
 
   setting 'xpack.searchable.snapshot.shared_cache.size', '1mb'
   setting 'xpack.searchable.snapshot.shared_cache.region_size', '16kb'

BIN
client/rest-high-level/httpCa.p12


+ 1 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/ClusterClient.java

@@ -273,4 +273,5 @@ public final class ClusterClient {
         return restHighLevelClient.performRequestAsync(componentTemplatesRequest,
             ClusterRequestConverters::componentTemplatesExist, options, RestHighLevelClient::convertExistsResponse, listener, emptySet());
     }
+
 }

+ 25 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java

@@ -59,6 +59,8 @@ import org.elasticsearch.client.security.InvalidateApiKeyRequest;
 import org.elasticsearch.client.security.InvalidateApiKeyResponse;
 import org.elasticsearch.client.security.InvalidateTokenRequest;
 import org.elasticsearch.client.security.InvalidateTokenResponse;
+import org.elasticsearch.client.security.NodeEnrollmentRequest;
+import org.elasticsearch.client.security.NodeEnrollmentResponse;
 import org.elasticsearch.client.security.PutPrivilegesRequest;
 import org.elasticsearch.client.security.PutPrivilegesResponse;
 import org.elasticsearch.client.security.PutRoleMappingRequest;
@@ -1130,4 +1132,27 @@ public final class SecurityClient {
         return restHighLevelClient.performRequestAsyncAndParseEntity(request, SecurityRequestConverters::delegatePkiAuthentication, options,
                 DelegatePkiAuthenticationResponse::fromXContent, listener, emptySet());
     }
+
+
+    /**
+     * Allows a node to join to a cluster with security features enabled using the Enroll Node API.
+     * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized
+     * @return the response
+     * @throws IOException in case there is a problem sending the request or parsing back the response
+     */
+    public NodeEnrollmentResponse enrollNode(RequestOptions options) throws IOException {
+        return restHighLevelClient.performRequestAndParseEntity(
+            NodeEnrollmentRequest.INSTANCE, NodeEnrollmentRequest::getRequest,
+            options, NodeEnrollmentResponse::fromXContent, emptySet());
+    }
+
+    /**
+     * Asynchronously allows a node to join to a cluster with security features enabled using the Enroll Node API.
+     * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized
+     * @param listener the listener to be notified upon request completion. The listener will be called with the value {@code true}
+     */
+    public Cancellable enrollNodeAsync(RequestOptions options, ActionListener<NodeEnrollmentResponse> listener) {
+        return restHighLevelClient.performRequestAsyncAndParseEntity(NodeEnrollmentRequest.INSTANCE, NodeEnrollmentRequest::getRequest,
+            options, NodeEnrollmentResponse::fromXContent, listener, emptySet());
+    }
 }

+ 29 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/security/NodeEnrollmentRequest.java

@@ -0,0 +1,29 @@
+/*
+ * 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.client.security;
+
+import org.apache.http.client.methods.HttpGet;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.Validatable;
+
+/**
+ * Retrieves information needed about configuration so that  new node can join a secured cluster
+ */
+public final class NodeEnrollmentRequest implements Validatable {
+
+    public static final NodeEnrollmentRequest INSTANCE = new NodeEnrollmentRequest();
+
+    private NodeEnrollmentRequest(){
+
+    }
+
+    public Request getRequest() {
+        return new Request(HttpGet.METHOD_NAME, "/_security/enroll_node");
+    }
+}

+ 108 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/security/NodeEnrollmentResponse.java

@@ -0,0 +1,108 @@
+/*
+ * 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.client.security;
+
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.XContentParser;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+public class NodeEnrollmentResponse {
+
+    private final String httpCaKey;
+    private final String httpCaCert;
+    private final String transportKey;
+    private final String transportCert;
+    private final String clusterName;
+    private final List<String> nodesAddresses;
+
+    public NodeEnrollmentResponse(String httpCaKey, String httpCaCert, String transportKey, String transportCert, String clusterName,
+                                  List<String> nodesAddresses){
+        this.httpCaKey = httpCaKey;
+        this.httpCaCert = httpCaCert;
+        this.transportKey = transportKey;
+        this.transportCert = transportCert;
+        this.clusterName = clusterName;
+        this.nodesAddresses = Collections.unmodifiableList(nodesAddresses);
+    }
+
+    public String getHttpCaKey() {
+        return httpCaKey;
+    }
+
+    public String getHttpCaCert() {
+        return httpCaCert;
+    }
+
+    public String getTransportKey() {
+        return transportKey;
+    }
+
+    public String getTransportCert() {
+        return transportCert;
+    }
+
+    public String getClusterName() {
+        return clusterName;
+    }
+
+    public List<String> getNodesAddresses() {
+        return nodesAddresses;
+    }
+
+    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_KEY = new ParseField("transport_key");
+    private static final ParseField TRANSPORT_CERT = new ParseField("transport_cert");
+    private static final ParseField CLUSTER_NAME = new ParseField("cluster_name");
+    private static final ParseField NODES_ADDRESSES = new ParseField("nodes_addresses");
+
+    @SuppressWarnings("unchecked")
+    public static final ConstructingObjectParser<NodeEnrollmentResponse, Void>
+        PARSER =
+        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 String clusterName = (String) a[4];
+            final List<String> nodesAddresses = (List<String>) a[5];
+            return new NodeEnrollmentResponse(httpCaKey, httpCaCert, transportKey, transportCert, clusterName, nodesAddresses);
+        });
+
+    static {
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), HTTP_CA_KEY);
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), HTTP_CA_CERT);
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), TRANSPORT_KEY);
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), TRANSPORT_CERT);
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), CLUSTER_NAME);
+        PARSER.declareStringArray(ConstructingObjectParser.constructorArg(), NODES_ADDRESSES);
+    }
+
+    public static NodeEnrollmentResponse fromXContent(XContentParser parser) throws IOException {
+        return PARSER.apply(parser, null);
+    }
+
+    @Override public boolean equals(Object o) {
+        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)
+            && transportCert.equals(that.transportCert) && clusterName.equals(that.clusterName)
+            && nodesAddresses.equals(that.nodesAddresses);
+    }
+
+    @Override public int hashCode() {
+        return Objects.hash(httpCaKey, httpCaCert, transportKey, transportCert, clusterName, nodesAddresses);
+    }
+}

+ 1 - 0
client/rest-high-level/src/test/java/org/elasticsearch/client/ClusterClientIT.java

@@ -386,4 +386,5 @@ public class ClusterClientIT extends ESRestHighLevelClientTestCase {
 
         assertFalse(exist);
     }
+
 }

+ 17 - 0
client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityIT.java

@@ -19,6 +19,7 @@ import org.elasticsearch.client.security.GetRolesRequest;
 import org.elasticsearch.client.security.GetRolesResponse;
 import org.elasticsearch.client.security.GetUsersRequest;
 import org.elasticsearch.client.security.GetUsersResponse;
+import org.elasticsearch.client.security.NodeEnrollmentResponse;
 import org.elasticsearch.client.security.PutRoleRequest;
 import org.elasticsearch.client.security.PutRoleResponse;
 import org.elasticsearch.client.security.PutUserRequest;
@@ -42,9 +43,12 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 
+import static org.hamcrest.Matchers.endsWith;
+import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.is;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.notNullValue;
 
 public class SecurityIT extends ESRestHighLevelClientTestCase {
 
@@ -152,6 +156,19 @@ public class SecurityIT extends ESRestHighLevelClientTestCase {
         assertThat(deleteRoleResponse.isFound(), is(true));
     }
 
+    @AwaitsFix(bugUrl = "Determine behavior for keystore with multiple keys")
+    public void testEnrollNode() throws Exception {
+        final NodeEnrollmentResponse nodeEnrollmentResponse =
+            execute(highLevelClient().security()::enrollNode, highLevelClient().security()::enrollNodeAsync, RequestOptions.DEFAULT);
+        assertThat(nodeEnrollmentResponse, notNullValue());
+        assertThat(nodeEnrollmentResponse.getHttpCaKey(), endsWith("ECAwGGoA=="));
+        assertThat(nodeEnrollmentResponse.getHttpCaCert(), endsWith("ECAwGGoA=="));
+        assertThat(nodeEnrollmentResponse.getTransportKey(), endsWith("fSI09on8AgMBhqA="));
+        assertThat(nodeEnrollmentResponse.getTransportCert(), endsWith("fSI09on8AgMBhqA="));
+        List<String> nodesAddresses = nodeEnrollmentResponse.getNodesAddresses();
+        assertThat(nodesAddresses.size(), equalTo(1));
+    }
+
     private void deleteUser(User user) throws IOException {
         final Request deleteUserRequest = new Request(HttpDelete.METHOD_NAME, "/_security/user/" + user.getUsername());
         highLevelClient().getLowLevelClient().performRequest(deleteUserRequest);

+ 1 - 0
client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/ClusterClientDocumentationIT.java

@@ -696,4 +696,5 @@ public class ClusterClientDocumentationIT extends ESRestHighLevelClientTestCase
 
         assertTrue(latch.await(30L, TimeUnit.SECONDS));
     }
+
 }

+ 46 - 0
client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java

@@ -65,6 +65,7 @@ import org.elasticsearch.client.security.InvalidateApiKeyRequest;
 import org.elasticsearch.client.security.InvalidateApiKeyResponse;
 import org.elasticsearch.client.security.InvalidateTokenRequest;
 import org.elasticsearch.client.security.InvalidateTokenResponse;
+import org.elasticsearch.client.security.NodeEnrollmentResponse;
 import org.elasticsearch.client.security.PutPrivilegesRequest;
 import org.elasticsearch.client.security.PutPrivilegesResponse;
 import org.elasticsearch.client.security.PutRoleMappingRequest;
@@ -2563,6 +2564,51 @@ public class SecurityDocumentationIT extends ESRestHighLevelClientTestCase {
         }
     }
 
+    @AwaitsFix(bugUrl = "Determine behavior for keystores with multiple keys")
+    public void testNodeEnrollment() throws Exception {
+        RestHighLevelClient client = highLevelClient();
+
+        {
+            // tag::node-enrollment-execute
+            NodeEnrollmentResponse response = client.security().enrollNode(RequestOptions.DEFAULT);
+            // end::node-enrollment-execute
+
+            // tag::node-enrollment-response
+            String httpCaKey = response.getHttpCaKey(); // <1>
+            String httpCaCert = response.getHttpCaCert(); // <2>
+            String transportKey = response.getTransportKey(); // <3>
+            String transportCert = response.getTransportCert(); // <4>
+            String clusterName = response.getClusterName(); // <5>
+            List<String> nodesAddresses = response.getNodesAddresses();  // <6>
+            // end::node-enrollment-response
+        }
+
+        {
+            // tag::node-enrollment-execute-listener
+            ActionListener<NodeEnrollmentResponse> listener =
+                new ActionListener<NodeEnrollmentResponse>() {
+                    @Override
+                    public void onResponse(NodeEnrollmentResponse response) {
+                        // <1>
+                    }
+
+
+                    @Override
+                    public void onFailure(Exception e) {
+                        // <2>
+                    }};
+            // end::node-enrollment-execute-listener
+
+            final CountDownLatch latch = new CountDownLatch(1);
+            listener = new LatchedActionListener<>(listener, latch);
+
+            // tag::node-enrollment-execute-async
+            client.security().enrollNodeAsync(RequestOptions.DEFAULT, listener);
+            // end::node-enrollment-execute-async
+            assertTrue(latch.await(30L, TimeUnit.SECONDS));
+        }
+    }
+
     private X509Certificate readCertForPkiDelegation(String certificateName) throws Exception {
         Path path = getDataPath("/org/elasticsearch/client/security/delegate_pki/" + certificateName);
         try (InputStream in = Files.newInputStream(path)) {

BIN
client/rest-high-level/transport.p12


+ 2 - 0
docs/build.gradle

@@ -84,6 +84,8 @@ testClusters.matching { it.name == "integTest"}.configureEach {
   configFile 'KeywordTokenizer.rbbi'
   extraConfigFile 'hunspell/en_US/en_US.aff', project(":server").file('src/test/resources/indices/analyze/conf_dir/hunspell/en_US/en_US.aff')
   extraConfigFile 'hunspell/en_US/en_US.dic', project(":server").file('src/test/resources/indices/analyze/conf_dir/hunspell/en_US/en_US.dic')
+  extraConfigFile 'httpCa.p12', file("./httpCa.p12")
+  extraConfigFile 'transport.p12', file("./transport.p12")
   // Whitelist reindexing from the local node so we can test it.
   setting 'reindex.remote.whitelist', '127.0.0.1:*'
 

BIN
docs/httpCa.p12


+ 64 - 0
docs/java-rest/high-level/cluster/enroll_node.asciidoc

@@ -0,0 +1,64 @@
+--
+:api: node-enrollment
+:request: NodeEnrollmentRequest
+:response: NodeEnrollmentResponse
+--
+
+[id="{upid}-{api}"]
+=== Enroll Node API
+
+Allows a new node to join an existing cluster with security features enabled.
+
+The purpose of the enroll node API is to allow a new node to join an existing cluster
+where security is enabled. The enroll node API response contains all the necessary information
+for the joining node to bootstrap discovery and security related settings so that it
+can successfully join the cluster.
+
+NOTE: The response contains key and certificate material that allows the
+caller to generate valid signed certificates for the HTTP layer of all nodes in the cluster.
+
+include::../execution.asciidoc[]
+
+[id="{upid}-{api}-response"]
+==== Enroll Node Response
+
+The returned +{response}+ allows to retrieve information about the
+executed operation as follows:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests-file}[{api}-response]
+--------------------------------------------------
+<1> The CA private key 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 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 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 string of the ASN.1 DER encoding of the certificate.
+<5> The name of the cluster the new node is joining
+<6> A list of transport addresses in the form of `host:port` for the nodes that are already
+members of the cluster.
+
+
+[id="{upid}-{api}-execute-async"]
+==== Asynchronous Execution
+
+This request can be executed asynchronously using the `security().enrollNodeAsync()`
+method:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests-file}[{api}-execute-async]
+--------------------------------------------------
+
+A typical listener for a `NodeEnrollmentResponse` looks like:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests-file}[{api}-execute-listener]
+--------------------------------------------------
+<1> Called when the execution is successfully completed. The response is
+provided as an argument
+<2> Called in case of failure. The raised exception is provided as an argument

BIN
docs/transport.p12


+ 24 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/security.enroll_node.json

@@ -0,0 +1,24 @@
+{
+  "security.enroll_node":{
+    "documentation":{
+      "url":"https://www.elastic.co/guide/en/elasticsearch/reference/master/security-api-enroll-node.html",
+      "description":"Allows a new node to enroll to an existing cluster with security enabled."
+    },
+    "stability":"stable",
+    "visibility":"public",
+    "headers":{
+      "accept": [ "application/json"],
+      "content_type": ["application/json"]
+    },
+    "url":{
+      "paths":[
+        {
+          "path":"/_security/enroll_node",
+          "methods":[
+            "GET"
+          ]
+        }
+      ]
+    }
+  }
+}

+ 11 - 0
x-pack/docs/en/rest-api/security.asciidoc

@@ -105,6 +105,16 @@ realm when using a custom web application other than Kibana
 * <<security-api-saml-invalidate, Submit a logout request from the IdP>>
 * <<security-api-saml-sp-metadata,Generate SAML metadata>>
 
+[discrete]
+[[security-enrollment-apis]]
+=== Enrollment
+
+You can use the following APIs to allow new nodes to join an existing cluster with
+security enabled or to allow a client to configure itself to communicate with
+a secured {es} cluster
+
+* <<security-api-node-enrollment, Enroll a new node>>
+
 
 include::security/authenticate.asciidoc[]
 include::security/change-password.asciidoc[]
@@ -124,6 +134,7 @@ include::security/delete-roles.asciidoc[]
 include::security/delete-users.asciidoc[]
 include::security/disable-users.asciidoc[]
 include::security/enable-users.asciidoc[]
+include::security/enroll-node.asciidoc[]
 include::security/get-api-keys.asciidoc[]
 include::security/get-app-privileges.asciidoc[]
 include::security/get-builtin-privileges.asciidoc[]

+ 63 - 0
x-pack/docs/en/rest-api/security/enroll-node.asciidoc

@@ -0,0 +1,63 @@
+[[security-api-node-enrollment]]
+=== Enroll Node API
+++++
+<titleabbrev>Enroll Node</titleabbrev>
+++++
+
+Allows a new node to join an existing cluster with security features enabled.
+
+[[security-api-node-enrollment-api-request]]
+==== {api-request-title}
+
+`GET /_security/enroll_node`
+
+[[security-api-node-enrollment-api-prereqs]]
+==== {api-prereq-title}
+
+* You must have the `enroll` <<privileges-list-cluster,cluster privilege>> to use this API.
+
+[[security-api-node-enrollment-api-desc]]
+==== {api-description-title}
+
+The purpose of the enroll node API is to allow a new node to join an existing cluster
+where security is enabled. The enroll node API response contains all the necessary information
+for the joining node to bootstrap discovery and security related settings so that it
+can successfully join the cluster.
+
+NOTE: The response contains key and certificate material that allows the
+caller to generate valid signed certificates for the HTTP layer of all nodes in the cluster.
+
+[[security-api-node-enrollment-api-examples]]
+==== {api-examples-title}
+
+[source,console]
+--------------------------------------------------
+GET /security/enroll_node
+--------------------------------------------------
+// TEST[skip:Determine behavior for keystore with multiple keys]
+The API returns a response such as
+
+[source,console-result]
+--------------------------------------------------
+{
+  "http_ca_key" : "MIIJlAIBAzCCCVoGCSqGSIb3DQEHAaCCCUsEgglHMIIJQzCCA98GCSqGSIb3DQ....vsDfsA3UZBAjEPfhubpQysAICCAA=", <1>
+  "http_ca_cert" : "MIIJlAIBAzCCCVoGCSqGSIb3DQEHAaCCCUsEgglHMIIJQzCCA98GCSqGSIb3DQ....vsDfsA3UZBAjEPfhubpQysAICCAA=", <2>
+  "transport_key" : "MIIEJgIBAzCCA98GCSqGSIb3DQEHAaCCA9AEggPMMIIDyDCCA8QGCSqGSIb3....YuEiOXvqZ6jxuVSQ0CAwGGoA==", <3>
+  "transport_cert" : "MIIEJgIBAzCCA98GCSqGSIb3DQEHAaCCA9AEggPMMIIDyDCCA8QGCSqGSIb3....YuEiOXvqZ6jxuVSQ0CAwGGoA==", <4>
+  "cluster_name" : "cluster-name",               <5>
+  "nodes_addresses" : [                          <6>
+    "192.168.1.2:9300"
+  ]
+}
+--------------------------------------------------
+<1> The CA private key 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 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
+    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
+    string of the ASN.1 DER encoding of the certificate.
+<5> The name of the cluster the new node is joining
+<6> A list of transport addresses in the form of `host:port` for the nodes that are already
+    members of the cluster.

+ 2 - 3
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackPlugin.java

@@ -247,12 +247,11 @@ public class XPackPlugin extends XPackClientPlugin
             clusterState.custom(TokenMetadata.TYPE) != null ||
             metadata.custom(TransformMetadata.TYPE) != null;
     }
-    
+
     @Override
     public Map<String, MetadataFieldMapper.TypeParser> getMetadataMappers() {
         return Map.of(DataTierFieldMapper.NAME, DataTierFieldMapper.PARSER);
     }
-    
 
     @Override
     public Settings additionalSettings() {
@@ -336,7 +335,7 @@ public class XPackPlugin extends XPackClientPlugin
         handlers.add(new RestReloadAnalyzersAction());
         handlers.add(new RestTermsEnumAction());
         handlers.addAll(licensing.getRestHandlers(settings, restController, clusterSettings, indexScopedSettings, settingsFilter,
-                indexNameExpressionResolver, nodesInCluster));
+            indexNameExpressionResolver, nodesInCluster));
         return handlers;
     }
 

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

@@ -0,0 +1,20 @@
+/*
+ * 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.core.security.action.enrollment;
+
+import org.elasticsearch.action.ActionType;
+
+public final class NodeEnrollmentAction extends ActionType<NodeEnrollmentResponse> {
+
+    public static final String NAME = "cluster:admin/xpack/security/enrollment/enroll/node";
+    public static final NodeEnrollmentAction INSTANCE = new NodeEnrollmentAction();
+
+    private NodeEnrollmentAction() {
+        super(NAME, NodeEnrollmentResponse::new);
+    }
+}

+ 28 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/NodeEnrollmentRequest.java

@@ -0,0 +1,28 @@
+/*
+ * 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.core.security.action.enrollment;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.common.io.stream.StreamInput;
+
+import java.io.IOException;
+
+public final class NodeEnrollmentRequest extends ActionRequest {
+
+    public NodeEnrollmentRequest() {
+    }
+
+    public NodeEnrollmentRequest(StreamInput in) throws IOException {
+        super(in);
+    }
+
+    @Override public ActionRequestValidationException validate() {
+        return null;
+    }
+}

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

@@ -0,0 +1,114 @@
+/*
+ * 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.core.security.action.enrollment;
+
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.xcontent.ToXContent;
+import org.elasticsearch.common.xcontent.ToXContentObject;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+
+public final class NodeEnrollmentResponse extends ActionResponse implements ToXContentObject {
+
+    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_KEY = new ParseField("transport_key");
+    private static final ParseField TRANSPORT_CERT = new ParseField("transport_cert");
+    private static final ParseField CLUSTER_NAME = new ParseField("cluster_name");
+    private static final ParseField NODES_ADDRESSES = new ParseField("nodes_addresses");
+
+    private final String httpCaKey;
+    private final String httpCaCert;
+    private final String transportKey;
+    private final String transportCert;
+    private final String clusterName;
+    private final List<String> nodesAddresses;
+
+    public NodeEnrollmentResponse(StreamInput in) throws IOException {
+        super(in);
+        httpCaKey = in.readString();
+        httpCaCert = in.readString();
+        transportKey = in.readString();
+        transportCert = in.readString();
+        clusterName = in.readString();
+        nodesAddresses = in.readStringList();
+    }
+
+    public NodeEnrollmentResponse(String httpCaKey, String httpCaCert, String transportKey, String transportCert, String clusterName,
+                                  List<String> nodesAddresses) {
+        this.httpCaKey = httpCaKey;
+        this.httpCaCert = httpCaCert;
+        this.transportKey = transportKey;
+        this.transportCert = transportCert;
+        this.clusterName = clusterName;
+        this.nodesAddresses = nodesAddresses;
+    }
+
+    public String getHttpCaKey() {
+        return httpCaKey;
+    }
+
+    public String getHttpCaCert() {
+        return httpCaCert;
+    }
+
+    public String getTransportKey() {
+        return transportKey;
+    }
+
+    public String getTransportCert() {
+        return transportCert;
+    }
+
+    public String getClusterName() {
+        return clusterName;
+    }
+
+    public List<String> getNodesAddresses() {
+        return nodesAddresses;
+    }
+
+    @Override public void writeTo(StreamOutput out) throws IOException {
+        out.writeString(httpCaKey);
+        out.writeString(httpCaCert);
+        out.writeString(transportKey);
+        out.writeString(transportCert);
+        out.writeString(clusterName);
+        out.writeStringCollection(nodesAddresses);
+    }
+
+    @Override public XContentBuilder toXContent(XContentBuilder builder, ToXContent.Params params) throws IOException {
+        builder.startObject();
+        builder.field(HTTP_CA_KEY.getPreferredName(), httpCaKey);
+        builder.field(HTTP_CA_CERT.getPreferredName(), httpCaCert);
+        builder.field(TRANSPORT_KEY.getPreferredName(), transportKey);
+        builder.field(TRANSPORT_CERT.getPreferredName(), transportCert);
+        builder.field(CLUSTER_NAME.getPreferredName(), clusterName);
+        builder.field(NODES_ADDRESSES.getPreferredName(), nodesAddresses);
+        return builder.endObject();
+    }
+
+    @Override public boolean equals(Object o) {
+        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)
+            && transportCert.equals(that.transportCert) && clusterName.equals(that.clusterName)
+            && nodesAddresses.equals(that.nodesAddresses);
+    }
+
+    @Override public int hashCode() {
+        return Objects.hash(httpCaKey, httpCaCert, transportKey, transportCert, clusterName, nodesAddresses);
+    }
+}

+ 2 - 2
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/CertParsingUtils.java

@@ -134,7 +134,7 @@ public class CertParsingUtils {
         return readKeyPairsFromKeystore(store, keyPassword);
     }
 
-    static Map<Certificate, Key> readKeyPairsFromKeystore(KeyStore store, Function<String, char[]> keyPassword)
+    public static Map<Certificate, Key> readKeyPairsFromKeystore(KeyStore store, Function<String, char[]> keyPassword)
         throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException {
         final Enumeration<String> enumeration = store.aliases();
         final Map<Certificate, Key> map = new HashMap<>(store.size());
@@ -167,7 +167,7 @@ public class CertParsingUtils {
         return keyManager(keyStore, password, KeyManagerFactory.getDefaultAlgorithm());
     }
 
-    private static KeyStore getKeyStore(Certificate[] certificateChain, PrivateKey privateKey, char[] password)
+    public static KeyStore getKeyStore(Certificate[] certificateChain, PrivateKey privateKey, char[] password)
         throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException {
         KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
         keyStore.load(null, null);

+ 1 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/KeyConfig.java

@@ -29,7 +29,7 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 
-abstract class KeyConfig extends TrustConfig {
+public abstract class KeyConfig extends TrustConfig {
 
     static final KeyConfig NONE = new KeyConfig() {
         @Override

+ 2 - 2
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/SSLConfiguration.java

@@ -48,7 +48,7 @@ public final class SSLConfiguration {
      *
      * @param settings the SSL specific settings; only the settings under a *.ssl. prefix
      */
-    SSLConfiguration(Settings settings) {
+    public SSLConfiguration(Settings settings) {
         this.keyConfig = createKeyConfig(settings);
         this.trustConfig = createTrustConfig(settings, keyConfig);
         this.ciphers = getListOrDefault(SETTINGS_PARSER.ciphers, settings, XPackSettings.DEFAULT_CIPHERS);
@@ -61,7 +61,7 @@ public final class SSLConfiguration {
     /**
      * The configuration for the key, if any, that will be used as part of this ssl configuration
      */
-    KeyConfig keyConfig() {
+    public KeyConfig keyConfig() {
         return keyConfig;
     }
 

+ 4 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/SSLService.java

@@ -793,6 +793,10 @@ public class SSLService {
         return getSSLConfiguration(XPackSettings.HTTP_SSL_PREFIX);
     }
 
+    public SSLConfiguration getTransportSSLConfiguration() {
+        return getSSLConfiguration(XPackSettings.TRANSPORT_SSL_PREFIX);
+    }
+
     private static Map<String, Settings> getMonitoringExporterSettings(Settings settings) {
         Map<String, Settings> sslSettings = new HashMap<>();
         Map<String, Settings> exportersSettings = settings.getGroups("xpack.monitoring.exporters.");

+ 22 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/StoreKeyConfig.java

@@ -8,6 +8,7 @@ package org.elasticsearch.xpack.core.ssl;
 
 import org.elasticsearch.ElasticsearchException;
 import org.elasticsearch.common.Nullable;
+import org.elasticsearch.common.collect.Tuple;
 import org.elasticsearch.common.settings.SecureString;
 import org.elasticsearch.env.Environment;
 import org.elasticsearch.xpack.core.ssl.cert.CertificateInfo;
@@ -38,7 +39,7 @@ import java.util.Objects;
 /**
  * A key configuration that is backed by a {@link KeyStore}
  */
-class StoreKeyConfig extends KeyConfig {
+public class StoreKeyConfig extends KeyConfig {
 
     private static final String KEYSTORE_FILE = "keystore";
 
@@ -154,6 +155,26 @@ class StoreKeyConfig extends KeyConfig {
         }
     }
 
+    public List<Tuple<PrivateKey, X509Certificate>> getPrivateKeyEntries(Environment environment) {
+        try {
+            final KeyStore keyStore = getStore(CertParsingUtils.resolvePath(keyStorePath, environment), keyStoreType, keyStorePassword);
+            List<Tuple<PrivateKey, X509Certificate>> entries = new ArrayList<>();
+            for (Enumeration<String> e = keyStore.aliases(); e.hasMoreElements(); ) {
+                final String alias = e.nextElement();
+                if (keyStore.isKeyEntry(alias)) {
+                    Key key = keyStore.getKey(alias, keyPassword.getChars());
+                    Certificate certificate = keyStore.getCertificate(alias);
+                    if (key instanceof PrivateKey && certificate instanceof X509Certificate) {
+                        entries.add(Tuple.tuple((PrivateKey) key, (X509Certificate) certificate));
+                    }
+                }
+            }
+            return entries;
+        } catch (Exception e) {
+            throw new ElasticsearchException("failed to list keys and certificates", e);
+        }
+    }
+
     private void checkKeyStore(KeyStore keyStore) throws KeyStoreException {
         Enumeration<String> aliases = keyStore.aliases();
         while (aliases.hasMoreElements()) {

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

@@ -0,0 +1,86 @@
+/*
+ * 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.core.security.action.enrollment;
+
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.io.stream.BytesStreamOutput;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.test.AbstractXContentTestCase;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.hamcrest.Matchers.is;
+
+public class NodeEnrollementResponseTests extends AbstractXContentTestCase<NodeEnrollmentResponse> {
+
+    public void testSerialization() throws Exception {
+        NodeEnrollmentResponse response = createTestInstance();
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            response.writeTo(out);
+            try (StreamInput in = out.bytes().streamInput()) {
+                NodeEnrollmentResponse serialized = new NodeEnrollmentResponse(in);
+                assertThat(response.getHttpCaKey(), is(serialized.getHttpCaKey()));
+                assertThat(response.getHttpCaCert(), is(serialized.getHttpCaCert()));
+                assertThat(response.getTransportKey(), is(serialized.getTransportKey()));
+                assertThat(response.getTransportCert(), is(serialized.getTransportCert()));
+                assertThat(response.getClusterName(), is(serialized.getClusterName()));
+                assertThat(response.getNodesAddresses(), is(serialized.getNodesAddresses()));
+            }
+        }
+    }
+
+    @Override protected NodeEnrollmentResponse createTestInstance() {
+        return new NodeEnrollmentResponse(
+            randomAlphaOfLengthBetween(50, 100),
+            randomAlphaOfLengthBetween(50, 100),
+            randomAlphaOfLengthBetween(50, 100),
+            randomAlphaOfLengthBetween(50, 100),
+            randomAlphaOfLength(10),
+            randomList(10, () -> buildNewFakeTransportAddress().toString()));
+    }
+
+    @Override protected NodeEnrollmentResponse doParseInstance(XContentParser parser) throws IOException {
+        return PARSER.apply(parser, null);
+    }
+
+    @Override protected boolean supportsUnknownFields() {
+        return false;
+    }
+
+    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_KEY = new ParseField("transport_key");
+    private static final ParseField TRANSPORT_CERT = new ParseField("transport_cert");
+    private static final ParseField CLUSTER_NAME = new ParseField("cluster_name");
+    private static final ParseField NODES_ADDRESSES = new ParseField("nodes_addresses");
+
+    @SuppressWarnings("unchecked")
+    public static final ConstructingObjectParser<NodeEnrollmentResponse, Void>
+        PARSER =
+        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 String clusterName = (String) a[4];
+            final List<String> nodesAddresses = (List<String>) a[5];
+            return new NodeEnrollmentResponse(httpCaKey, httpCaCert, transportKey, transportCert, clusterName, nodesAddresses);
+        });
+
+    static {
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), HTTP_CA_KEY);
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), HTTP_CA_CERT);
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), TRANSPORT_KEY);
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), TRANSPORT_CERT);
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), CLUSTER_NAME);
+        PARSER.declareStringArray(ConstructingObjectParser.constructorArg(), NODES_ADDRESSES);
+    }
+}

+ 1 - 0
x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java

@@ -172,6 +172,7 @@ public class Constants {
         "cluster:admin/xpack/security/api_key/invalidate",
         "cluster:admin/xpack/security/cache/clear",
         "cluster:admin/xpack/security/delegate_pki",
+        "cluster:admin/xpack/security/enrollment/enroll/node",
         "cluster:admin/xpack/security/oidc/authenticate",
         "cluster:admin/xpack/security/oidc/logout",
         "cluster:admin/xpack/security/oidc/prepare",

+ 6 - 1
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java

@@ -90,6 +90,7 @@ import org.elasticsearch.xpack.core.security.action.DelegatePkiAuthenticationAct
 import org.elasticsearch.xpack.core.security.action.GetApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.GrantApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentAction;
 import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectAuthenticateAction;
 import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutAction;
 import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectPrepareAuthenticationAction;
@@ -160,6 +161,7 @@ import org.elasticsearch.xpack.security.action.TransportDelegatePkiAuthenticatio
 import org.elasticsearch.xpack.security.action.TransportGetApiKeyAction;
 import org.elasticsearch.xpack.security.action.TransportGrantApiKeyAction;
 import org.elasticsearch.xpack.security.action.TransportInvalidateApiKeyAction;
+import org.elasticsearch.xpack.security.action.enrollment.TransportNodeEnrollmentAction;
 import org.elasticsearch.xpack.security.action.filter.SecurityActionFilter;
 import org.elasticsearch.xpack.security.action.oidc.TransportOpenIdConnectAuthenticateAction;
 import org.elasticsearch.xpack.security.action.oidc.TransportOpenIdConnectLogoutAction;
@@ -243,6 +245,7 @@ import org.elasticsearch.xpack.security.rest.action.apikey.RestCreateApiKeyActio
 import org.elasticsearch.xpack.security.rest.action.apikey.RestGetApiKeyAction;
 import org.elasticsearch.xpack.security.rest.action.apikey.RestGrantApiKeyAction;
 import org.elasticsearch.xpack.security.rest.action.apikey.RestInvalidateApiKeyAction;
+import org.elasticsearch.xpack.security.rest.action.enrollment.RestNodeEnrollmentAction;
 import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction;
 import org.elasticsearch.xpack.security.rest.action.oauth2.RestInvalidateTokenAction;
 import org.elasticsearch.xpack.security.rest.action.oidc.RestOpenIdConnectAuthenticateAction;
@@ -884,6 +887,7 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin,
                 new ActionHandler<>(DeleteServiceAccountTokenAction.INSTANCE, TransportDeleteServiceAccountTokenAction.class),
                 new ActionHandler<>(GetServiceAccountCredentialsAction.INSTANCE, TransportGetServiceAccountCredentialsAction.class),
                 new ActionHandler<>(GetServiceAccountAction.INSTANCE, TransportGetServiceAccountAction.class),
+                new ActionHandler<>(NodeEnrollmentAction.INSTANCE, TransportNodeEnrollmentAction.class),
                 usageAction,
                 infoAction);
     }
@@ -948,7 +952,8 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin,
                 new RestCreateServiceAccountTokenAction(settings, getLicenseState()),
                 new RestDeleteServiceAccountTokenAction(settings, getLicenseState()),
                 new RestGetServiceAccountCredentialsAction(settings, getLicenseState()),
-                new RestGetServiceAccountAction(settings, getLicenseState())
+                new RestGetServiceAccountAction(settings, getLicenseState()),
+                new RestNodeEnrollmentAction(settings, getLicenseState())
         );
     }
 

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

@@ -0,0 +1,124 @@
+/*
+ * 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.action.enrollment;
+
+import org.elasticsearch.ElasticsearchException;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.admin.cluster.node.info.NodeInfo;
+import org.elasticsearch.action.admin.cluster.node.info.NodesInfoAction;
+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.cluster.service.ClusterService;
+import org.elasticsearch.common.collect.Tuple;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.transport.TransportInfo;
+import org.elasticsearch.xpack.core.ssl.StoreKeyConfig;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentAction;
+import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentRequest;
+import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentResponse;
+import org.elasticsearch.xpack.core.ssl.KeyConfig;
+import org.elasticsearch.xpack.core.ssl.SSLService;
+
+import java.security.PrivateKey;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.elasticsearch.xpack.core.ClientHelper.SECURITY_ORIGIN;
+import static org.elasticsearch.xpack.core.ClientHelper.executeAsyncWithOrigin;
+
+public class TransportNodeEnrollmentAction extends HandledTransportAction<NodeEnrollmentRequest, NodeEnrollmentResponse> {
+    private final Environment environment;
+    private final ClusterService clusterService;
+    private final SSLService sslService;
+    private final Client client;
+
+    @Inject
+    public TransportNodeEnrollmentAction(TransportService transportService, ClusterService clusterService, SSLService sslService,
+                                         Client client, ActionFilters actionFilters, Environment environment) {
+        super(NodeEnrollmentAction.NAME, transportService, actionFilters, NodeEnrollmentRequest::new);
+        this.environment = environment;
+        this.clusterService = clusterService;
+        this.sslService = sslService;
+        this.client = client;
+    }
+
+    @Override
+    protected void doExecute(Task task, NodeEnrollmentRequest request, ActionListener<NodeEnrollmentResponse> listener) {
+
+        final KeyConfig transportKeyConfig = sslService.getTransportSSLConfiguration().keyConfig();
+        final KeyConfig httpKeyConfig = sslService.getHttpTransportSSLConfiguration().keyConfig();
+        if (transportKeyConfig instanceof StoreKeyConfig == false) {
+            listener.onFailure(new IllegalStateException(
+                "Unable to enroll node. Elasticsearch node transport layer SSL configuration is not configured with a keystore"));
+            return;
+        }
+        if (httpKeyConfig instanceof StoreKeyConfig == false) {
+            listener.onFailure(new IllegalStateException(
+                "Unable to enroll node. Elasticsearch node HTTP layer SSL configuration is not configured with a keystore"));
+            return;
+        }
+        final List<Tuple<PrivateKey, X509Certificate>> transportKeysAndCertificates =
+            ((StoreKeyConfig) transportKeyConfig).getPrivateKeyEntries(environment);
+        final List<Tuple<PrivateKey, X509Certificate>> httpCaKeysAndCertificates =
+            ((StoreKeyConfig) httpKeyConfig).getPrivateKeyEntries(environment).stream()
+                .filter(t -> t.v2().getBasicConstraints() != -1).collect(Collectors.toList());
+        if (transportKeysAndCertificates.isEmpty()) {
+            listener.onFailure(new IllegalStateException(
+                "Unable to enroll node. Elasticsearch node transport layer SSL configuration doesn't contain any keys"));
+            return;
+        } else if (transportKeysAndCertificates.size() > 1) {
+            listener.onFailure(new IllegalStateException(
+                "Unable to enroll node. Elasticsearch node transport layer SSL configuration contains multiple keys"));
+            return;
+        }
+        if (httpCaKeysAndCertificates.isEmpty()) {
+            listener.onFailure(new IllegalStateException(
+                "Unable to enroll node. Elasticsearch node HTTP layer SSL configuration Keystore doesn't contain any " +
+                    "PrivateKey entries where the associated certificate is a CA certificate"));
+            return;
+        } else if (httpCaKeysAndCertificates.size() > 1) {
+            listener.onFailure(new IllegalStateException(
+                "Unable to enroll node. Elasticsearch node HTTP layer SSL configuration Keystore contain multiple " +
+                    "PrivateKey entries where the associated certificate is a CA certificate"));
+            return;
+        }
+
+        final List<String> nodeList = new ArrayList<>();
+        final NodesInfoRequest nodesInfoRequest = new NodesInfoRequest().addMetric(NodesInfoRequest.Metric.TRANSPORT.metricName());
+        executeAsyncWithOrigin(client, SECURITY_ORIGIN, NodesInfoAction.INSTANCE, nodesInfoRequest, ActionListener.wrap(
+            response -> {
+                for (NodeInfo nodeInfo : response.getNodes()) {
+                    nodeList.add(nodeInfo.getInfo(TransportInfo.class).getAddress().publishAddress().toString());
+                }
+            }, listener::onFailure
+        ));
+        try {
+            final String httpCaKey = Base64.getUrlEncoder().encodeToString(httpCaKeysAndCertificates.get(0).v1().getEncoded());
+            final String httpCaCert = Base64.getUrlEncoder().encodeToString(httpCaKeysAndCertificates.get(0).v2().getEncoded());
+            final String transportKey = Base64.getUrlEncoder().encodeToString(transportKeysAndCertificates.get(0).v1().getEncoded());
+            final String transportCert = Base64.getUrlEncoder().encodeToString(transportKeysAndCertificates.get(0).v2().getEncoded());
+            listener.onResponse(new NodeEnrollmentResponse(httpCaKey,
+                httpCaCert,
+                transportKey,
+                transportCert,
+                clusterService.getClusterName().value(),
+                nodeList));
+        } catch (CertificateEncodingException e) {
+            listener.onFailure(new ElasticsearchException("Unable to enroll node", e));
+        }
+    }
+}

+ 58 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/enrollment/RestNodeEnrollmentAction.java

@@ -0,0 +1,58 @@
+/*
+ * 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.rest.action.enrollment;
+
+import org.elasticsearch.client.node.NodeClient;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.BytesRestResponse;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.RestResponse;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.rest.action.RestBuilderListener;
+import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentAction;
+import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentRequest;
+import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentResponse;
+import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler;
+
+import java.io.IOException;
+import java.util.List;
+
+public final class RestNodeEnrollmentAction extends SecurityBaseRestHandler {
+
+    /**
+     * @param settings the node's settings
+     * @param licenseState the license state that will be used to determine if security is licensed
+     */
+    public RestNodeEnrollmentAction(Settings settings, XPackLicenseState licenseState) {
+        super(settings, licenseState);
+    }
+
+    @Override public String getName() {
+        return "node_enroll_action";
+    }
+
+    @Override public List<Route> routes() {
+        return List.of(
+            new Route(RestRequest.Method.GET, "_security/enroll_node")
+        );
+    }
+
+    @Override protected RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException {
+        return restChannel -> client.execute(NodeEnrollmentAction.INSTANCE,
+            new NodeEnrollmentRequest(),
+            new RestBuilderListener<NodeEnrollmentResponse>(restChannel) {
+                @Override public RestResponse buildResponse(
+                    NodeEnrollmentResponse nodeEnrollmentResponse, XContentBuilder builder) throws Exception {
+                    nodeEnrollmentResponse.toXContent(builder, channel.request());
+                    return new BytesRestResponse(RestStatus.OK, builder);
+                }
+            });
+    }
+}

+ 161 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/enrollment/TransportNodeEnrollmentActionTests.java

@@ -0,0 +1,161 @@
+/*
+ * 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.action.enrollment;
+
+import org.elasticsearch.Version;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.admin.cluster.node.info.NodeInfo;
+import org.elasticsearch.action.admin.cluster.node.info.NodesInfoAction;
+import org.elasticsearch.action.admin.cluster.node.info.NodesInfoRequest;
+import org.elasticsearch.action.admin.cluster.node.info.NodesInfoResponse;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.PlainActionFuture;
+import org.elasticsearch.client.Client;
+import org.elasticsearch.cluster.ClusterName;
+import org.elasticsearch.cluster.node.DiscoveryNode;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.transport.BoundTransportAddress;
+import org.elasticsearch.common.transport.TransportAddress;
+import org.elasticsearch.common.util.concurrent.ThreadContext;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.transport.Transport;
+import org.elasticsearch.transport.TransportInfo;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentRequest;
+import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentResponse;
+import org.elasticsearch.xpack.core.ssl.CertParsingUtils;
+import org.elasticsearch.xpack.core.ssl.SSLConfiguration;
+import org.elasticsearch.xpack.core.ssl.SSLService;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.Key;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasKey;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.same;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class TransportNodeEnrollmentActionTests extends ESTestCase {
+
+    @SuppressWarnings("unchecked")
+    public void testDoExecute() throws Exception {
+        final Environment env = mock(Environment.class);
+        Path tempDir = createTempDir();
+        Path httpCaPath = tempDir.resolve("httpCa.p12");
+        Path transportPath = tempDir.resolve("transport.p12");
+        Files.copy(getDataPath("/org/elasticsearch/xpack/security/action/enrollment/httpCa.p12"), httpCaPath);
+        Files.copy(getDataPath("/org/elasticsearch/xpack/security/action/enrollment/transport.p12"), transportPath);
+        when(env.configFile()).thenReturn(tempDir);
+        final SSLService sslService = mock(SSLService.class);
+        final Settings httpSettings = Settings.builder()
+            .put("keystore.path", "httpCa.p12")
+            .put("keystore.password", "password")
+            .build();
+        final SSLConfiguration httpSslConfiguration = new SSLConfiguration(httpSettings);
+        when(sslService.getHttpTransportSSLConfiguration()).thenReturn(httpSslConfiguration);
+        final Settings transportSettings = Settings.builder()
+            .put("keystore.path", "transport.p12")
+            .put("keystore.password", "password")
+            .build();
+        final SSLConfiguration transportSslConfiguration = new SSLConfiguration(transportSettings);
+        when(sslService.getTransportSSLConfiguration()).thenReturn(transportSslConfiguration);
+        final ClusterService clusterService = mock(ClusterService.class);
+        final String clusterName = randomAlphaOfLengthBetween(6, 10);
+        when(clusterService.getClusterName()).thenReturn(new ClusterName(clusterName));
+        final ThreadContext threadContext = new ThreadContext(Settings.EMPTY);
+        final ThreadPool threadPool = mock(ThreadPool.class);
+        when(threadPool.getThreadContext()).thenReturn(threadContext);
+        final Client client = mock(Client.class);
+        when(client.threadPool()).thenReturn(threadPool);
+        final List<NodeInfo> nodeInfos = new ArrayList<>();
+        final int numberOfNodes = randomIntBetween(1, 6);
+        final List<NodesInfoRequest> nodesInfoRequests = new ArrayList<>();
+        for (int i = 0; i < numberOfNodes; i++) {
+            DiscoveryNode n = node(i);
+            nodeInfos.add(new NodeInfo(Version.CURRENT,
+                null,
+                n,
+                null,
+                null,
+                null,
+                null,
+                null,
+                new TransportInfo(new BoundTransportAddress(new TransportAddress[] { n.getAddress() }, n.getAddress()), null, false),
+                null,
+                null,
+                null,
+                null,
+                null));
+        }
+        doAnswer(invocation -> {
+            NodesInfoRequest nodesInfoRequest = (NodesInfoRequest) invocation.getArguments()[1];
+            nodesInfoRequests.add(nodesInfoRequest);
+            ActionListener<NodesInfoResponse> listener = (ActionListener) invocation.getArguments()[2];
+            listener.onResponse(new NodesInfoResponse(new ClusterName("cluster"), nodeInfos, List.of()));
+            return null;
+        }).when(client).execute(same(NodesInfoAction.INSTANCE), any(), any());
+
+        final TransportService transportService = new TransportService(Settings.EMPTY,
+            mock(Transport.class),
+            threadPool,
+            TransportService.NOOP_TRANSPORT_INTERCEPTOR,
+            x -> null,
+            null,
+            Collections.emptySet());
+
+        final TransportNodeEnrollmentAction action =
+            new TransportNodeEnrollmentAction(transportService, clusterService, sslService, client, mock(ActionFilters.class), env);
+        final NodeEnrollmentRequest request = new NodeEnrollmentRequest();
+        final PlainActionFuture<NodeEnrollmentResponse> future = new PlainActionFuture<>();
+        action.doExecute(mock(Task.class), request, future);
+        final NodeEnrollmentResponse response = future.get();
+        assertThat(response.getClusterName(), equalTo(clusterName));
+        assertSameCertificate(response.getHttpCaCert(), httpCaPath, "password".toCharArray(), true);
+        assertSameCertificate(response.getTransportCert(), transportPath, "password".toCharArray(), false);
+        assertThat(response.getNodesAddresses().size(), equalTo(numberOfNodes));
+        assertThat(nodesInfoRequests.size(), equalTo(1));
+    }
+
+    private void assertSameCertificate(String cert, Path original, char[] originalPassword, boolean isCa) throws Exception{
+        Map<Certificate, Key> originalKeysAndCerts = CertParsingUtils.readPkcs12KeyPairs(original, originalPassword, p -> originalPassword);
+        Certificate deserializedCert = CertParsingUtils.readCertificates(
+            new ByteArrayInputStream(Base64.getUrlDecoder().decode(cert.getBytes(StandardCharsets.UTF_8)))).get(0);
+        assertThat(originalKeysAndCerts, hasKey(deserializedCert));
+        assertThat(deserializedCert, instanceOf(X509Certificate.class));
+        if (isCa) {
+            assertThat(((X509Certificate) deserializedCert).getBasicConstraints(), not(-1));
+        } else {
+            assertThat(((X509Certificate) deserializedCert).getBasicConstraints(), is(-1));
+        }
+    }
+
+    private DiscoveryNode node(final int id) {
+        return new DiscoveryNode("node-" + id, Integer.toString(id), buildNewFakeTransportAddress(), Map.of(), Set.of(), Version.CURRENT);
+    }
+}

BIN
x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/httpCa.p12


BIN
x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/transport.p12