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.
This commit is contained in:
Ioannis Kakavas 2021-10-27 08:13:49 +03:00 committed by GitHub
parent e82a70ca6c
commit 5d3b6bf2f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 766 additions and 1228 deletions

View File

@ -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();

View File

@ -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
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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}"

View File

@ -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=$!

View File

@ -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

View File

@ -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.

View File

@ -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}"

View File

@ -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));

View File

@ -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();
}
}

View File

@ -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");
}
}

View File

@ -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);
}

View File

@ -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")));
}
}

View File

@ -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));
}
}

View File

@ -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");

View File

@ -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);

View File

@ -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));

View File

@ -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.

View File

@ -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

View File

@ -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];
}
};

View File

@ -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);
}
}

View File

@ -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()));
@ -42,6 +43,7 @@ public class NodeEnrollementResponseTests extends AbstractXContentTestCase<NodeE
randomAlphaOfLengthBetween(50, 100),
randomAlphaOfLengthBetween(50, 100),
randomAlphaOfLengthBetween(50, 100),
randomAlphaOfLengthBetween(50, 100),
randomList(10, () -> buildNewFakeTransportAddress().toString()));
}
@ -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);

View File

@ -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.write("# These have been generated the first time that the new node was started, only #");
bw.newLine();
bw.write("# joining or enrolling to an existing cluster and only if Security had not been #");
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);
}
// 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(
Terminal.Verbosity.VERBOSE,
"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
// 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(
notifyOfFailure(
inEnrollmentMode,
terminal,
Terminal.Verbosity.VERBOSE,
ExitCodes.NOOP,
"Skipping security auto configuration because it appears that TLS is already configured."
);
throw new UserException(ExitCodes.NOOP, null);
}
// 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)) {
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."
);
}
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");
}
}

View File

@ -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.");
}
}
}

View File

@ -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
}
}
}

View File

@ -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" \
"$@"

View File

@ -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%

View File

@ -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));