diff --git a/docs/reference/commands/create-enrollment-token.asciidoc b/docs/reference/commands/create-enrollment-token.asciidoc new file mode 100644 index 0000000000000..4fd95f1b7bef4 --- /dev/null +++ b/docs/reference/commands/create-enrollment-token.asciidoc @@ -0,0 +1,59 @@ +[roles="xpack"] +[[create-enrollment-token]] + +== elasticsearch-create-enrollment-token + +The `elasticsearch-create-enrollment-token` command creates enrollment tokens for +{es} nodes and {kib} instances. + +[discrete] +=== Synopsis + +[source,shell] +---- +bin/elasticsearch-create-enrollment-token +[-f, --force] [-h, --help] [-E ] [-s, --scope] +---- + +[discrete] +=== Description + +Use this command to create enrollment tokens, which you can use to enroll new +{es} nodes to an existing cluster or configure {kib} instances to communicate +with an existing {es} cluster that has security features enabled. +The command generates (and subsequently removes) a temporary user in the +<> to run the request that creates enrollment tokens. +IMPORTANT: You cannot use this tool if the file realm is disabled in your +`elasticsearch.yml` file. + +This command uses an HTTP connection to connect to the cluster and run the user +management requests. The command automatically attempts to establish the connection +over HTTPS by using the `xpack.security.http.ssl` settings in +the `elasticsearch.yml` file. If you do not use the default configuration directory, +ensure that the `ES_PATH_CONF` environment variable returns the +correct path before you run the `elasticsearch-create-enrollment-token` command. You can +override settings in your `elasticsearch.yml` file by using the `-E` command +option. For more information about debugging connection failures, see +<>. + +[discrete] +[[create-enrollment-token-parameters]] +=== Parameters + +`-E `:: Configures a standard {es} or {xpack} setting. + +`-f, --force`:: Forces the command to run against an unhealthy cluster. + +`-h, --help`:: Returns all of the command parameters. + +`-s, --scope`:: Specifies the scope of the generated token. Supported values are `node` and `kibana`. + +[discrete] +=== Examples + +The following command creates an enrollment token for enrolling an {es} node into a cluster: + +[source,shell] +---- +bin/elasticsearch-create-enrollment-token -s node +---- diff --git a/docs/reference/commands/index.asciidoc b/docs/reference/commands/index.asciidoc index 270667add876c..1bf71038e1e35 100644 --- a/docs/reference/commands/index.asciidoc +++ b/docs/reference/commands/index.asciidoc @@ -9,6 +9,7 @@ tasks from the command line: * <> * <> +* <> * <> * <> * <> @@ -22,6 +23,7 @@ tasks from the command line: include::certgen.asciidoc[] include::certutil.asciidoc[] +include::create-enrollment-token.asciidoc[] include::croneval.asciidoc[] include::keystore.asciidoc[] include::node-tool.asciidoc[] diff --git a/x-pack/plugin/security/src/main/bin/elasticsearch-create-enrollment-token b/x-pack/plugin/security/src/main/bin/elasticsearch-create-enrollment-token new file mode 100644 index 0000000000000..ce17ab6022c6c --- /dev/null +++ b/x-pack/plugin/security/src/main/bin/elasticsearch-create-enrollment-token @@ -0,0 +1,11 @@ +#!/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.enrollment.tool.CreateEnrollmentTokenTool \ + ES_ADDITIONAL_SOURCES="x-pack-env;x-pack-security-env" \ + "`dirname "$0"`"/elasticsearch-cli \ + "$@" diff --git a/x-pack/plugin/security/src/main/bin/elasticsearch-create-enrollment-token.bat b/x-pack/plugin/security/src/main/bin/elasticsearch-create-enrollment-token.bat new file mode 100644 index 0000000000000..39027ef697727 --- /dev/null +++ b/x-pack/plugin/security/src/main/bin/elasticsearch-create-enrollment-token.bat @@ -0,0 +1,21 @@ +@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.enrollment.tool.CreateEnrollmentTokenTool +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% diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenTool.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenTool.java new file mode 100644 index 0000000000000..b01b550367f92 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenTool.java @@ -0,0 +1,89 @@ +/* + * 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.enrollment.tool; + +import joptsimple.OptionSet; +import joptsimple.OptionSpec; + +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.settings.KeyStoreWrapper; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.core.CheckedFunction; +import org.elasticsearch.env.Environment; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.security.enrollment.CreateEnrollmentToken; +import org.elasticsearch.xpack.security.tool.BaseRunAsSuperuserCommand; +import org.elasticsearch.xpack.security.tool.CommandLineHttpClient; + +import java.util.List; +import java.util.function.Function; + +public class CreateEnrollmentTokenTool extends BaseRunAsSuperuserCommand { + + private final OptionSpec scope; + private final CheckedFunction createEnrollmentTokenFunction; + static final List ALLOWED_SCOPES = List.of("node", "kibana"); + + CreateEnrollmentTokenTool() { + this( + environment -> new CommandLineHttpClient(environment), + environment -> KeyStoreWrapper.load(environment.configFile()), + environment -> new CreateEnrollmentToken(environment) + ); + } + + CreateEnrollmentTokenTool( + Function clientFunction, + CheckedFunction keyStoreFunction, + CheckedFunction createEnrollmentTokenFunction + ) { + super(clientFunction, keyStoreFunction, "Creates enrollment tokens for elasticsearch nodes and kibana instances"); + this.createEnrollmentTokenFunction = createEnrollmentTokenFunction; + scope = parser.acceptsAll(List.of("scope", "s"), "The scope of this enrollment token, can be either \"node\" or \"kibana\"") + .withRequiredArg() + .required(); + } + + public static void main(String[] args) throws Exception { + exit(new CreateEnrollmentTokenTool().main(args, Terminal.DEFAULT)); + } + + @Override + protected void validate(Terminal terminal, OptionSet options, Environment env) throws Exception { + if (XPackSettings.ENROLLMENT_ENABLED.get(env.settings()) == false) { + throw new UserException( + ExitCodes.CONFIG, + "[xpack.security.enrollment.enabled] must be set to `true` to create an enrollment token" + ); + } + final String tokenScope = scope.value(options); + if (ALLOWED_SCOPES.contains(tokenScope) == false) { + terminal.errorPrintln("The scope of this enrollment token, can only be one of " + ALLOWED_SCOPES); + throw new UserException(ExitCodes.USAGE, "Invalid scope"); + } + } + + @Override + protected void executeCommand(Terminal terminal, OptionSet options, Environment env, String username, SecureString password) + throws Exception { + final String tokenScope = scope.value(options); + try { + CreateEnrollmentToken createEnrollmentTokenService = createEnrollmentTokenFunction.apply(env); + if (tokenScope.equals("node")) { + terminal.println(createEnrollmentTokenService.createNodeEnrollmentToken(username, password)); + } else { + terminal.println(createEnrollmentTokenService.createKibanaEnrollmentToken(username, password)); + } + } catch (Exception e) { + terminal.errorPrintln("Unable to create enrollment token for scope [" + tokenScope + "]"); + throw new UserException(ExitCodes.CANT_CREATE, e.getMessage(), e.getCause()); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/BaseRunAsSuperuserCommand.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/BaseRunAsSuperuserCommand.java new file mode 100644 index 0000000000000..29708d6cb4e13 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/BaseRunAsSuperuserCommand.java @@ -0,0 +1,296 @@ +/* + * 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.tool; + +import joptsimple.OptionSet; +import joptsimple.OptionSpecBuilder; + +import org.elasticsearch.Version; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.KeyStoreAwareCommand; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.io.Streams; +import org.elasticsearch.common.settings.KeyStoreWrapper; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.core.CheckedFunction; +import org.elasticsearch.env.Environment; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.core.security.authc.RealmConfig; +import org.elasticsearch.xpack.core.security.authc.RealmSettings; +import org.elasticsearch.xpack.core.security.authc.file.FileRealmSettings; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.security.authc.file.FileUserPasswdStore; +import org.elasticsearch.xpack.security.authc.file.FileUserRolesStore; +import org.elasticsearch.xpack.security.support.FileAttributesChecker; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * A {@link KeyStoreAwareCommand} that can be extended fpr any CLI tool that needs to allow a local user with + * filesystem write access to perform actions on the node as a superuser. It leverages temporary file realm users + * with a `superuser` role. + */ +public abstract class BaseRunAsSuperuserCommand extends KeyStoreAwareCommand { + + private static final String[] ROLES = new String[] { "superuser" }; + private static final int PASSWORD_LENGTH = 14; + + private final OptionSpecBuilder force; + private final Function clientFunction; + private final CheckedFunction keyStoreFunction; + final SecureRandom secureRandom = new SecureRandom(); + + public BaseRunAsSuperuserCommand( + Function clientFunction, + CheckedFunction keyStoreFunction, + String description + ) { + super(description); + this.clientFunction = clientFunction; + this.keyStoreFunction = keyStoreFunction; + force = parser.acceptsAll(List.of("f", "force"), + "Use this option to force execution of the command against a cluster that is currently unhealthy."); + } + + @Override + protected final void execute(Terminal terminal, OptionSet options, Environment env) throws Exception { + validate(terminal, options, env); + ensureFileRealmEnabled(env.settings()); + KeyStoreWrapper keyStoreWrapper = keyStoreFunction.apply(env); + final Environment newEnv; + final Settings settings; + if (keyStoreWrapper != null) { + decryptKeyStore(keyStoreWrapper, terminal); + Settings.Builder settingsBuilder = Settings.builder(); + settingsBuilder.put(env.settings(), true); + if (settingsBuilder.getSecureSettings() == null) { + settingsBuilder.setSecureSettings(keyStoreWrapper); + } + settings = settingsBuilder.build(); + newEnv = new Environment(settings, env.configFile()); + } else { + newEnv = env; + settings = env.settings(); + } + + final String username = generateUsername(); + try (SecureString password = new SecureString(generatePassword(PASSWORD_LENGTH))){ + final Hasher hasher = Hasher.resolve(XPackSettings.PASSWORD_HASHING_ALGORITHM.get(settings)); + final Path passwordFile = FileUserPasswdStore.resolveFile(newEnv); + final Path rolesFile = FileUserRolesStore.resolveFile(newEnv); + FileAttributesChecker attributesChecker = new FileAttributesChecker(passwordFile, rolesFile); + // Store the roles file first so that when we get to store the user, it will definitely be a superuser + Map userRoles = FileUserRolesStore.parseFile(rolesFile, null); + if (userRoles == null) { + throw new IllegalStateException("File realm configuration file [" + rolesFile + "] is missing"); + } + userRoles = new HashMap<>(userRoles); + userRoles.put(username, ROLES); + FileUserRolesStore.writeFile(userRoles, rolesFile); + + Map users = FileUserPasswdStore.parseFile(passwordFile, null, settings); + if (users == null) { + throw new IllegalStateException("File realm configuration file [" + passwordFile + "] is missing"); + } + users = new HashMap<>(users); + users.put(username, hasher.hash(password)); + FileUserPasswdStore.writeFile(users, passwordFile); + + attributesChecker.check(terminal); + final boolean forceExecution = options.has(force); + checkClusterHealthWithRetries(newEnv, terminal, username, password, 5, forceExecution); + executeCommand(terminal, options, newEnv, username, password); + } catch (Exception e) { + int exitCode; + if (e instanceof UserException) { + exitCode = ((UserException) e).exitCode; + } else { + exitCode = ExitCodes.DATA_ERROR; + } + throw new UserException(exitCode, e.getMessage()); + } finally { + cleanup(terminal, newEnv, username); + } + } + + /** + * Removes temporary file realm user from users and roles file + */ + private void cleanup(Terminal terminal, Environment env, String username) throws Exception { + final Path passwordFile = FileUserPasswdStore.resolveFile(env); + final Path rolesFile = FileUserRolesStore.resolveFile(env); + final List errorMessages = new ArrayList<>(); + FileAttributesChecker attributesChecker = new FileAttributesChecker(passwordFile, rolesFile); + + Map users = FileUserPasswdStore.parseFile(passwordFile, null, env.settings()); + if (users == null) { + errorMessages.add("File realm configuration file [" + passwordFile + "] is missing"); + } else { + users = new HashMap<>(users); + char[] passwd = users.remove(username); + if (passwd != null) { + // No need to overwrite, if the user was already removed + FileUserPasswdStore.writeFile(users, passwordFile); + Arrays.fill(passwd, '\0'); + } + } + Map userRoles = FileUserRolesStore.parseFile(rolesFile, null); + if (userRoles == null) { + errorMessages.add("File realm configuration file [" + rolesFile + "] is missing"); + } else { + userRoles = new HashMap<>(userRoles); + String[] roles = userRoles.remove(username); + if (roles != null) { + // No need to overwrite, if the user was already removed + FileUserRolesStore.writeFile(userRoles, rolesFile); + } + } + if ( errorMessages.isEmpty() == false ) { + throw new UserException(ExitCodes.CONFIG, String.join(" , ", errorMessages)); + } + attributesChecker.check(terminal); + } + + private void ensureFileRealmEnabled(Settings settings) throws Exception { + final Map realms = RealmSettings.getRealmSettings(settings); + Map fileRealmSettings = realms.entrySet().stream() + .filter(e -> e.getKey().getType().equals(FileRealmSettings.TYPE)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + if (fileRealmSettings.size() == 1) { + final String fileRealmName = fileRealmSettings.entrySet().iterator().next().getKey().getName(); + if (RealmSettings.ENABLED_SETTING.apply(FileRealmSettings.TYPE) + .getConcreteSettingForNamespace(fileRealmName) + .get(settings) == false) throw new UserException(ExitCodes.CONFIG, "File realm must be enabled"); + } + // Else it's either explicitly enabled, or not defined in the settings so it is implicitly enabled. + } + + /** + * Checks that we can connect to the cluster and that the cluster health is not RED. It optionally handles + * retries as the file realm might not have reloaded the users file yet in order to authenticate our + * newly created file realm user. + */ + private void checkClusterHealthWithRetries(Environment env, Terminal terminal, String username, SecureString password, int retries, + boolean force) throws Exception { + CommandLineHttpClient client = clientFunction.apply(env); + final URL clusterHealthUrl = createURL(new URL(client.getDefaultURL()), "_cluster/health", "?pretty"); + final HttpResponse response; + try { + response = client.execute("GET", clusterHealthUrl, username, password, () -> null, this::responseBuilder); + } catch (Exception e) { + throw new UserException(ExitCodes.UNAVAILABLE, "Failed to determine the health of the cluster. ", e); + } + final int responseStatus = response.getHttpStatus(); + if (responseStatus != HttpURLConnection.HTTP_OK) { + // We try to write the roles file first and then the users one, but theoretically we could have loaded the users + // before we have actually loaded the roles so we also retry on 403 ( temp user is found but has no roles ) + if ((responseStatus == HttpURLConnection.HTTP_UNAUTHORIZED || responseStatus == HttpURLConnection.HTTP_FORBIDDEN) + && retries > 0 ) { + terminal.println( + Terminal.Verbosity.VERBOSE, + "Unexpected http status [" + responseStatus + "] while attempting to determine cluster health. Will retry at most " + + retries + + " more times." + ); + Thread.sleep(1000); + retries -= 1; + checkClusterHealthWithRetries(env, terminal, username, password, retries, force); + } else { + throw new UserException( + ExitCodes.DATA_ERROR, + "Failed to determine the health of the cluster. Unexpected http status [" + responseStatus + "]" + ); + } + } else { + final String clusterStatus = Objects.toString(response.getResponseBody().get("status"), ""); + if (clusterStatus.isEmpty()) { + throw new UserException( + ExitCodes.DATA_ERROR, + "Failed to determine the health of the cluster. Cluster health API did not return a status value." + ); + } else if ("red".equalsIgnoreCase(clusterStatus) && force == false) { + terminal.errorPrintln("Failed to determine the health of the cluster. Cluster health is currently RED."); + terminal.errorPrintln("This means that some cluster data is unavailable and your cluster is not fully functional."); + terminal.errorPrintln("The cluster logs (https://www.elastic.co/guide/en/elasticsearch/reference/" + + Version.CURRENT.major + "." + Version.CURRENT.minor + "/logging.html)" + + " might contain information/indications for the underlying cause"); + terminal.errorPrintln( + "It is recommended that you resolve the issues with your cluster before continuing"); + terminal.errorPrintln("It is very likely that the command will fail when run against an unhealthy cluster."); + terminal.errorPrintln(""); + terminal.errorPrintln("If you still want to attempt to execute this command against an unhealthy cluster," + + " you can pass the `-f` parameter."); + throw new UserException(ExitCodes.UNAVAILABLE, + "Failed to determine the health of the cluster. Cluster health is currently RED."); + } + // else it is yellow or green so we can continue + } + } + + HttpResponse.HttpResponseBuilder responseBuilder(InputStream is) throws IOException { + final HttpResponse.HttpResponseBuilder httpResponseBuilder = new HttpResponse.HttpResponseBuilder(); + final String responseBody = Streams.readFully(is).utf8ToString(); + httpResponseBuilder.withResponseBody(responseBody); + return httpResponseBuilder; + } + + char[] generatePassword(int passwordLength) { + final char[] passwordChars = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~!@#$%^&*-_=+?").toCharArray(); + char[] characters = new char[passwordLength]; + for (int i = 0; i < passwordLength; ++i) { + characters[i] = passwordChars[secureRandom.nextInt(passwordChars.length)]; + } + return characters; + } + + private String generateUsername() { + final char[] usernameChars = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").toCharArray(); + int usernameLength = 8; + char[] characters = new char[usernameLength]; + for (int i = 0; i < usernameLength; ++i) { + characters[i] = usernameChars[secureRandom.nextInt(usernameChars.length)]; + } + return "enrollment_autogenerated_" + new String(characters); + } + + URL createURL(URL url, String path, String query) throws MalformedURLException, URISyntaxException { + return new URL(url, (url.toURI().getPath() + path).replaceAll("/+", "/") + query); + } + + /** + * This is called after we have created a temporary superuser in the file realm and verified that its + * credentials work. The username and password of the generated user are passed as parameters. Overriding methods should + * not try to close the password. + */ + protected abstract void executeCommand(Terminal terminal, OptionSet options, Environment env, String username, SecureString password) + throws Exception; + + /** + * This method is called before we attempt to crete a temporary superuser in the file realm. Commands that + * implement {@link BaseRunAsSuperuserCommand} can do preflight checks such as parsing and validating options without + * the need to go through the process of attempting to create and remove the temporary user unnecessarily. + */ + protected abstract void validate(Terminal terminal, OptionSet options, Environment env) throws Exception ; +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/enrollment/CreateEnrollmentTokenTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/enrollment/CreateEnrollmentTokenTests.java index 82fba30bd425c..0294e0261f6f5 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/enrollment/CreateEnrollmentTokenTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/enrollment/CreateEnrollmentTokenTests.java @@ -69,9 +69,9 @@ public void setupMocks() throws Exception { .put("xpack.security.enabled", true) .put("xpack.http.ssl.enabled", true) .put("xpack.security.authc.api_key.enabled", true) - .put("xpack.http.ssl.truststore.path", "httpCa.p12") + .put("xpack.http.ssl.truststore.path", httpCaPath) .put("xpack.security.http.ssl.enabled", true) - .put("xpack.security.http.ssl.keystore.path", "httpCa.p12") + .put("xpack.security.http.ssl.keystore.path", httpCaPath) .put("xpack.security.enrollment.enabled", "true") .setSecureSettings(secureSettings) .put("path.home", tempDir) @@ -208,7 +208,7 @@ public void testFailedNoCaInKeystore() throws Exception { .put("xpack.http.ssl.enabled", true) .put("xpack.security.authc.api_key.enabled", true) .put("xpack.security.http.ssl.enabled", true) - .put("xpack.security.http.ssl.keystore.path", "transport.p12") + .put("xpack.security.http.ssl.keystore.path", httpNoCaPath) .put("xpack.security.enrollment.enabled", "true") .setSecureSettings(secureSettings) .put("path.home", tempDir) @@ -261,7 +261,7 @@ public void testFailedManyCaInKeystore() throws Exception { .put("xpack.http.ssl.enabled", true) .put("xpack.security.authc.api_key.enabled", true) .put("xpack.security.http.ssl.enabled", true) - .put("xpack.security.http.ssl.keystore.path", "httpCa2.p12") + .put("xpack.security.http.ssl.keystore.path", httpNoCaPath) .put("xpack.security.enrollment.enabled", "true") .setSecureSettings(secureSettings) .put("path.home", tempDir) @@ -330,9 +330,9 @@ public void testEnrollmentNotEnabled() throws Exception { .put("xpack.security.enabled", true) .put("xpack.http.ssl.enabled", true) .put("xpack.security.authc.api_key.enabled", true) - .put("xpack.http.ssl.truststore.path", "httpCa.p12") + .put("xpack.http.ssl.truststore.path", httpCaPath) .put("xpack.security.http.ssl.enabled", true) - .put("xpack.security.http.ssl.keystore.path", "httpCa.p12") + .put("xpack.security.http.ssl.keystore.path", httpCaPath) .setSecureSettings(secureSettings) .put("path.home", tempDir) .build(); diff --git a/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/README.asciidoc b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/README.asciidoc index eb4400e78a4bc..7d89bad929034 100644 --- a/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/README.asciidoc +++ b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/README.asciidoc @@ -17,13 +17,13 @@ $ES_HOME/bin/elasticsearch-certutil ca --out ca.p12 --pass "password" === Generate the transport layer keystore [source,shell] ----------------------------------------------------------------------------------------------------------- -$ES_HOME/bin/elasticsearch-certutil cert --out transport.p12 --ca ca.p12 --ca-pass "password" +$ES_HOME/bin/elasticsearch-certutil cert --out transport.p12 --ca ca.p12 --ca-pass "password" --pass "password" ----------------------------------------------------------------------------------------------------------- === Generate the HTTP layer keystore [source,shell] ----------------------------------------------------------------------------------------------------------- -$ES_HOME/bin/elasticsearch-certutil cert --out httpCa.p12 --ca ca.p12 --ca-pass password \ +$ES_HOME/bin/elasticsearch-certutil cert --out httpCa.p12 --ca ca.p12 --ca-pass password --pass "password" \ --dns=localhost --dns=localhost.localdomain --dns=localhost4 --dns=localhost4.localdomain4 \ --dns=localhost6 --dns=localhost6.localdomain6 \ --ip=127.0.0.1 --ip=0:0:0:0:0:0:0:1 diff --git a/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/BaseRunAsSuperuserCommandTests.java b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/BaseRunAsSuperuserCommandTests.java new file mode 100644 index 0000000000000..3f65572952942 --- /dev/null +++ b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/BaseRunAsSuperuserCommandTests.java @@ -0,0 +1,295 @@ +/* + * 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.enrollment.tool; + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; + +import joptsimple.OptionSet; + +import org.elasticsearch.Version; +import org.elasticsearch.cli.Command; +import org.elasticsearch.cli.CommandTestCase; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.CheckedSupplier; +import org.elasticsearch.common.settings.KeyStoreWrapper; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.core.CheckedFunction; +import org.elasticsearch.core.PathUtilsForTesting; +import org.elasticsearch.core.internal.io.IOUtils; +import org.elasticsearch.env.Environment; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.security.tool.BaseRunAsSuperuserCommand; +import org.elasticsearch.xpack.security.tool.CommandLineHttpClient; +import org.elasticsearch.xpack.security.tool.HttpResponse; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.elasticsearch.test.SecurityIntegTestCase.getFastStoredHashAlgoForTests; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.stringContainsInOrder; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +public class BaseRunAsSuperuserCommandTests extends CommandTestCase { + + private CommandLineHttpClient client; + private KeyStoreWrapper keyStoreWrapper; + private static Hasher hasher; + static FileSystem jimfs; + private Path confDir; + private Settings settings; + + @Override + protected Command newCommand() { + return new DummyRunAsSuperuserCommand(environment -> client, environment -> keyStoreWrapper) { + @Override + protected Environment createEnv(Map settings) throws UserException { + return new Environment(BaseRunAsSuperuserCommandTests.this.settings, confDir); + } + }; + } + + @BeforeClass + public static void setupJimfs() { + String view = randomFrom("basic", "posix"); + Configuration conf = Configuration.unix().toBuilder().setAttributeViews(view).build(); + jimfs = Jimfs.newFileSystem(conf); + PathUtilsForTesting.installMock(jimfs); + } + + @Before + public void setup() throws Exception { + Path homeDir = jimfs.getPath("eshome"); + IOUtils.rm(homeDir); + confDir = homeDir.resolve("config"); + Files.createDirectories(confDir); + Files.write(confDir.resolve("users"), List.of(), StandardCharsets.UTF_8); + Files.write(confDir.resolve("users_roles"), List.of(), StandardCharsets.UTF_8); + hasher = getFastStoredHashAlgoForTests(); + settings = Settings.builder() + .put("path.home", homeDir) + .put(XPackSettings.PASSWORD_HASHING_ALGORITHM.getKey(), hasher.name()) + .build(); + + this.client = mock(CommandLineHttpClient.class); + when(client.getDefaultURL()).thenReturn("https://localhost:9200"); + + URL url = new URL(client.getDefaultURL()); + HttpResponse healthResponse = new HttpResponse( + HttpURLConnection.HTTP_OK, + Map.of("status", randomFrom("yellow", "green")) + ); + when( + client.execute( + anyString(), + eq(clusterHealthUrl(url)), + anyString(), + any(SecureString.class), + any(CheckedSupplier.class), + any(CheckedFunction.class) + ) + ).thenReturn(healthResponse); + } + + @AfterClass + public static void closeJimfs() throws IOException { + if (jimfs != null) { + jimfs.close(); + jimfs = null; + } + } + + public void testSuccessfulCommand() throws Exception { + execute(); + assertThat(terminal.getOutput(), is(emptyString())); + assertThat(terminal.getErrorOutput(), is(emptyString())); + assertNoUsers(); + assertNoUsersRoles(); + } + + public void testFailureWhenFileRealmIsDisabled() throws Exception { + settings = Settings.builder() + .put(settings) + .put("xpack.security.authc.realms.file." + randomAlphaOfLength(8) + ".enabled", false) + .build(); + UserException e = expectThrows(UserException.class, this::execute); + assertThat(e.getMessage(), equalTo("File realm must be enabled")); + assertThat(terminal.getOutput(), is(emptyString())); + assertNoUsers(); + assertNoUsersRoles(); + } + + public void testUsersFileIsMissing() throws Exception { + final Path usersPath = confDir.resolve("users"); + Files.delete(usersPath); + UserException e = expectThrows(UserException.class, this::execute); + assertThat(e.getMessage(), equalTo("File realm configuration file [/work/" + usersPath + "] is missing")); + assertThat(terminal.getOutput(), is(emptyString())); + } + + public void testUsersRolesFileIsMissing() throws Exception { + final Path rolesPath = confDir.resolve("users_roles"); + Files.delete(rolesPath); + UserException e = expectThrows(UserException.class, this::execute); + assertThat(e.getMessage(), + equalTo("File realm configuration file [/work/" + rolesPath + "] is missing")); + assertThat(terminal.getOutput(), is(emptyString())); + } + + public void testUnhealthyCluster() throws Exception { + URL url = new URL(client.getDefaultURL()); + HttpResponse healthResponse = + new HttpResponse(HttpURLConnection.HTTP_OK, Map.of("status", randomFrom("red"))); + when(client.execute(anyString(), eq(clusterHealthUrl(url)), anyString(), any(SecureString.class), any(CheckedSupplier.class), + any(CheckedFunction.class))).thenReturn(healthResponse); + UserException e = expectThrows(UserException.class, this::execute); + assertThat(e.exitCode, equalTo(ExitCodes.UNAVAILABLE)); + assertThat(e.getMessage(), containsString("RED")); + assertThat(terminal.getOutput(), is(emptyString())); + String error = terminal.getErrorOutput(); + assertThat(error, stringContainsInOrder("Failed to determine the health of the cluster. Cluster health is currently RED.", + "This means that some cluster data is unavailable and your cluster is not fully functional.", + "The cluster logs (https://www.elastic.co/guide/en/elasticsearch/reference/" + + Version.CURRENT.major + "." + Version.CURRENT.minor + "/logging.html)" + + " might contain information/indications for the underlying cause")); + assertNoUsers(); + assertNoUsersRoles(); + } + + public void testUnhealthyClusterWithForce() throws Exception { + URL url = new URL(client.getDefaultURL()); + HttpResponse healthResponse = + new HttpResponse(HttpURLConnection.HTTP_OK, Map.of("status", randomFrom("red"))); + when(client.execute(anyString(), eq(clusterHealthUrl(url)), anyString(), any(SecureString.class), any(CheckedSupplier.class), + any(CheckedFunction.class))).thenReturn(healthResponse); + execute("-f"); + assertThat(terminal.getOutput(), is(emptyString())); + assertThat(terminal.getErrorOutput(), is(emptyString())); + assertNoUsers(); + assertNoUsersRoles(); + } + + public void testWillRetryOnUnauthorized() throws Exception { + URL url = new URL(client.getDefaultURL()); + HttpResponse unauthorizedResponse = + new HttpResponse(HttpURLConnection.HTTP_UNAUTHORIZED, Map.of()); + when(client.execute(anyString(), eq(clusterHealthUrl(url)), anyString(), any(SecureString.class), any(CheckedSupplier.class), + any(CheckedFunction.class))).thenReturn(unauthorizedResponse); + UserException e = expectThrows(UserException.class, () -> execute("--verbose")); + String verboseOutput = terminal.getOutput(); + assertThat(verboseOutput.split("\\n").length, equalTo(5)); + assertThat(verboseOutput, + containsString("Unexpected http status [401] while attempting to determine cluster health. Will retry at most")); + assertThat(e.exitCode, equalTo(ExitCodes.DATA_ERROR)); + assertNoUsers(); + assertNoUsersRoles(); + } + + public void testWithPasswordProtectedKeystore() throws Exception { + this.keyStoreWrapper = mock(KeyStoreWrapper.class); + when(keyStoreWrapper.isLoaded()).thenReturn(true); + when(keyStoreWrapper.hasPassword()).thenReturn(true); + doAnswer(new Answer() { + @Override + public Void answer(InvocationOnMock invocation) throws Throwable { + Object[] args = invocation.getArguments(); + char[] password = (char[]) args[0]; + if (Arrays.equals(password, "keystore-password".toCharArray()) == false) { + throw new GeneralSecurityException("Wrong password"); + } + return null; + } + }).when(keyStoreWrapper).decrypt(any()); + terminal.addSecretInput("some-other-password"); + UserException e = expectThrows(UserException.class, this::execute); + assertThat(e.exitCode, equalTo(ExitCodes.DATA_ERROR)); + assertNoUsers(); + assertNoUsersRoles(); + assertThat(terminal.getOutput(), is(emptyString())); + terminal.addSecretInput("keystore-password"); + execute(); + assertThat(terminal.getOutput(), is(emptyString())); + assertNoUsers(); + assertNoUsersRoles(); + } + + private void assertNoUsers() throws Exception { + List lines = Files.readAllLines(confDir.resolve("users"), StandardCharsets.UTF_8); + assertThat(lines.size(), equalTo(0)); + } + + private void assertNoUsersRoles() throws Exception { + List lines = Files.readAllLines(confDir.resolve("users_roles"), StandardCharsets.UTF_8); + assertThat(lines.size(), equalTo(0)); + } + + private URL clusterHealthUrl(URL url) throws MalformedURLException, URISyntaxException { + return new URL(url, (url.toURI().getPath() + "/_cluster/health").replaceAll("/+", "/") + "?pretty"); + } + + /** + * {@link DummyRunAsSuperuserCommand#executeCommand(Terminal, OptionSet, Environment, String, SecureString)} is executed while the file + * realm user is persisted in file and still valid. We check that the username and the password that would be passed to extending + * Classes as parameters are what is actually created and stored in the file realm. + */ + static class DummyRunAsSuperuserCommand extends BaseRunAsSuperuserCommand { + DummyRunAsSuperuserCommand( + Function clientFunction, + CheckedFunction keyStoreFunction + ) { + super(clientFunction, keyStoreFunction, "dummy command"); + } + + @Override + protected void executeCommand(Terminal terminal, OptionSet options, Environment env, String username, SecureString password) + throws Exception { + final Path confDir = jimfs.getPath(env.settings().get("path.home")).resolve("config"); + List lines = Files.readAllLines(confDir.resolve("users"), StandardCharsets.UTF_8); + assertThat(lines.size(), equalTo(1)); + assertThat(lines.get(0), containsString(username)); + final char[] passwordHashFromFile = lines.get(0).split(":")[1].toCharArray(); + hasher.verify(password, passwordHashFromFile); + } + + @Override + protected void validate(Terminal terminal, OptionSet options, Environment env) throws Exception { + + } + } +} diff --git a/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenToolTests.java b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenToolTests.java new file mode 100644 index 0000000000000..d7b662684c828 --- /dev/null +++ b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenToolTests.java @@ -0,0 +1,212 @@ +/* + * 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.enrollment.tool; + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; + +import org.elasticsearch.cli.Command; +import org.elasticsearch.cli.CommandTestCase; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.CheckedSupplier; +import org.elasticsearch.common.settings.KeyStoreWrapper; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.core.CheckedFunction; +import org.elasticsearch.core.PathUtilsForTesting; +import org.elasticsearch.core.internal.io.IOUtils; +import org.elasticsearch.env.Environment; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.security.enrollment.CreateEnrollmentToken; +import org.elasticsearch.xpack.security.tool.CommandLineHttpClient; +import org.elasticsearch.xpack.security.tool.HttpResponse; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +public class CreateEnrollmentTokenToolTests extends CommandTestCase { + + static FileSystem jimfs; + String pathHomeParameter; + Path confDir; + Settings settings; + + private CommandLineHttpClient client; + private KeyStoreWrapper keyStoreWrapper; + private CreateEnrollmentToken createEnrollmentTokenService; + + @Override + protected Command newCommand() { + return new CreateEnrollmentTokenTool(environment -> client, environment -> keyStoreWrapper, + environment -> createEnrollmentTokenService) { + @Override + protected Environment createEnv(Map settings) throws UserException { + return new Environment(CreateEnrollmentTokenToolTests.this.settings, confDir); + } + }; + } + + @BeforeClass + public static void muteInFips(){ + assumeFalse("Enrollment mode is not supported in FIPS mode.", inFipsJvm()); + } + + @BeforeClass + public static void setupJimfs() { + String view = randomFrom("basic", "posix"); + Configuration conf = Configuration.unix().toBuilder().setAttributeViews(view).build(); + jimfs = Jimfs.newFileSystem(conf); + PathUtilsForTesting.installMock(jimfs); + } + + @Before + public void setup() throws Exception { + Path homeDir = jimfs.getPath("eshome"); + IOUtils.rm(homeDir); + confDir = homeDir.resolve("config"); + Files.createDirectories(confDir); + Files.write(confDir.resolve("users"), List.of(), StandardCharsets.UTF_8); + Files.write(confDir.resolve("users_roles"), List.of(), StandardCharsets.UTF_8); + settings = Settings.builder() + .put("path.home", homeDir) + .put("xpack.security.enrollment.enabled", true) + .build(); + pathHomeParameter = "-Epath.home=" + homeDir; + + this.keyStoreWrapper = mock(KeyStoreWrapper.class); + when(keyStoreWrapper.isLoaded()).thenReturn(true); + + this.client = mock(CommandLineHttpClient.class); + when(client.getDefaultURL()).thenReturn("https://localhost:9200"); + + URL url = new URL(client.getDefaultURL()); + HttpResponse healthResponse = + new HttpResponse(HttpURLConnection.HTTP_OK, Map.of("status", randomFrom("yellow", "green"))); + when(client.execute(anyString(), eq(clusterHealthUrl(url)), anyString(), any(SecureString.class), any(CheckedSupplier.class), + any(CheckedFunction.class))).thenReturn(healthResponse); + + this.createEnrollmentTokenService = mock(CreateEnrollmentToken.class); + when(createEnrollmentTokenService.createKibanaEnrollmentToken(anyString(), any(SecureString.class))) + .thenReturn("eyJ2ZXIiOiI4LjAuMCIsImFkciI6WyJbOjoxXTo5MjAwIiwiMTI3LjAuMC4xOjkyMDAiXSwiZmdyIjoiOWQ4MTRmYzdiNDQ0MWE0MWJlMDA5ZmQ0" + + "MzlkOWU5MzRiMDZiMjZjZjk4N2I1YzNjOGU0OWI1NmQ2MGYzMmMxMiIsImtleSI6Im5NMmVYbm9CbnBvT3ZncGFiaWU5OlRHaHF5UU9UVENhUEJpOVZQak1i" + + "OWcifQ=="); + when(createEnrollmentTokenService.createNodeEnrollmentToken(anyString(), any(SecureString.class))) + .thenReturn("eyJ2ZXIiOiI4LjAuMCIsImFkciI6WyJbOjoxXTo5MjAwIiwiMTI3LjAuMC4xOjkyMDAiXSwiZmdyIjoiOWQ4MTRmYzdiNDQ0MWE0MWJlMDA5ZmQ0" + + "MzlkOWU5MzRiMDZiMjZjZjk4N2I1YzNjOGU0OWI1NmQ2MGYzMmMxMiIsImtleSI6IndLTmZYSG9CQTFPMHI4UXBOV25FOnRkdUgzTmNTVHNTOGN0c3AwaWNU" + + "eEEifQ=="); + } + + @AfterClass + public static void closeJimfs() throws IOException { + if (jimfs != null) { + jimfs.close(); + jimfs = null; + } + } + + public void testCreateToken() throws Exception { + String scope = randomBoolean() ? "node" : "kibana"; + String output = execute("--scope", scope); + if (scope.equals("kibana")) { + assertThat(output, containsString("WU5OlRHaHF5UU9UVENhUEJpOVZQak1iOWcifQ==")); + } else { + assertThat(output, containsString("25FOnRkdUgzTmNTVHNTOGN0c3AwaWNUeEEifQ==")); + } + } + + public void testInvalidScope() throws Exception { + String scope = randomAlphaOfLength(14); + UserException e = expectThrows(UserException.class, () -> { + execute(randomFrom("-s", "--s"), scope); + }); + assertThat(e.exitCode, equalTo(ExitCodes.USAGE)); + assertThat(e.getMessage(), equalTo("Invalid scope")); + assertThat(terminal.getErrorOutput(), + containsString("The scope of this enrollment token, can only be one of "+ CreateEnrollmentTokenTool.ALLOWED_SCOPES)); + } + + public void testUnhealthyCluster() throws Exception { + String scope = randomBoolean() ? "node" : "kibana"; + URL url = new URL(client.getDefaultURL()); + HttpResponse healthResponse = + new HttpResponse(HttpURLConnection.HTTP_OK, Map.of("status", randomFrom("red"))); + when(client.execute(anyString(), eq(clusterHealthUrl(url)), anyString(), any(SecureString.class), any(CheckedSupplier.class), + any(CheckedFunction.class))).thenReturn(healthResponse); + UserException e = expectThrows(UserException.class, () -> { + execute(randomFrom("-s", "--s"), scope); + }); + assertThat(e.exitCode, equalTo(ExitCodes.UNAVAILABLE)); + assertThat(e.getMessage(), containsString("RED")); + } + + public void testUnhealthyClusterWithForce() throws Exception { + String scope = randomBoolean() ? "node" : "kibana"; + String output = execute("--scope", scope); + if (scope.equals("kibana")) { + assertThat(output, containsString("WU5OlRHaHF5UU9UVENhUEJpOVZQak1iOWcifQ==")); + } else { + assertThat(output, containsString("25FOnRkdUgzTmNTVHNTOGN0c3AwaWNUeEEifQ==")); + } + } + + public void testEnrollmentDisabled() throws Exception { + settings = Settings.builder() + .put(settings) + .put(XPackSettings.ENROLLMENT_ENABLED.getKey(), false) + .build(); + + String scope = randomBoolean() ? "node" : "kibana"; + UserException e = expectThrows(UserException.class, () -> { + execute(randomFrom("-s", "--s"), scope); + }); + assertThat(e.exitCode, equalTo(ExitCodes.CONFIG)); + assertThat(e.getMessage(), + equalTo("[xpack.security.enrollment.enabled] must be set to `true` to create an enrollment token")); + } + + public void testUnableToCreateToken() throws Exception { + this.createEnrollmentTokenService = mock(CreateEnrollmentToken.class); + when(createEnrollmentTokenService.createKibanaEnrollmentToken(anyString(), any(SecureString.class))) + .thenThrow(new IllegalStateException("example exception message")); + when(createEnrollmentTokenService.createNodeEnrollmentToken(anyString(), any(SecureString.class))) + .thenThrow(new IllegalStateException("example exception message")); + String scope = randomBoolean() ? "node" : "kibana"; + UserException e = expectThrows(UserException.class, () -> { + execute(randomFrom("-s", "--s"), scope); + }); + assertThat(e.exitCode, equalTo(ExitCodes.CANT_CREATE)); + assertThat(e.getMessage(), + equalTo("example exception message")); + } + + private URL clusterHealthUrl(URL url) throws MalformedURLException, URISyntaxException { + return new URL(url, (url.toURI().getPath() + "/_cluster/health").replaceAll("/+", "/") + "?pretty"); + } +}