Skip to content

Commit

Permalink
Enroll node API (#72129)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jkakavas committed May 12, 2021
1 parent 310342f commit b826703
Show file tree
Hide file tree
Showing 35 changed files with 1,027 additions and 10 deletions.
4 changes: 4 additions & 0 deletions client/rest-high-level/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
Binary file added client/rest-high-level/httpCa.p12
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -273,4 +273,5 @@ public Cancellable existsComponentTemplateAsync(ComponentTemplatesExistRequest c
return restHighLevelClient.performRequestAsync(componentTemplatesRequest,
ClusterRequestConverters::componentTemplatesExist, options, RestHighLevelClient::convertExistsResponse, listener, emptySet());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@
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;
Expand Down Expand Up @@ -1130,4 +1132,27 @@ public Cancellable delegatePkiAuthenticationAsync(DelegatePkiAuthenticationReque
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());
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -386,4 +386,5 @@ public void testComponentTemplates() throws Exception {

assertFalse(exist);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
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;
Expand All @@ -42,9 +43,12 @@
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 {

Expand Down Expand Up @@ -152,6 +156,19 @@ public void testPutRole() throws Exception {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -696,4 +696,5 @@ public void onFailure(Exception e) {

assertTrue(latch.await(30L, TimeUnit.SECONDS));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
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;
Expand Down Expand Up @@ -2563,6 +2564,51 @@ public void onFailure(Exception e) {
}
}

@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)) {
Expand Down
Binary file added client/rest-high-level/transport.p12
Binary file not shown.
2 changes: 2 additions & 0 deletions docs/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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:*'

Expand Down
Binary file added docs/httpCa.p12
Binary file not shown.
64 changes: 64 additions & 0 deletions docs/java-rest/high-level/cluster/enroll_node.asciidoc
Original file line number Diff line number Diff line change
@@ -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
Binary file added docs/transport.p12
Binary file not shown.

0 comments on commit b826703

Please sign in to comment.