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:
parent
e82a70ca6c
commit
5d3b6bf2f7
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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=$!
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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" \
|
||||
"$@"
|
|
@ -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%
|
|
@ -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));
|
||||
|
|
Loading…
Reference in New Issue