Skip to content

Set elastic password and generate enrollment token #75816

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Aug 13, 2021
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
97f799a
Set elastic password and generate enrollment token
BigPandaToo Jul 28, 2021
45c0080
Set elastic password and generate enrollment token
BigPandaToo Jul 28, 2021
d83f324
Set elastic password and generate enrollment token
BigPandaToo Jul 28, 2021
8e2c773
Remove redefinition
BigPandaToo Jul 29, 2021
3b0ca34
Merge branch 'master' into Pwd_initNode
elasticmachine Jul 30, 2021
6a4821f
Merge branch 'master' into Pwd_initNode
elasticmachine Aug 4, 2021
8d0b281
- Moving code under the tool
BigPandaToo Aug 4, 2021
6e1c717
Addressing PR feedback
BigPandaToo Aug 4, 2021
8ad067f
Addressing PR feedback
BigPandaToo Aug 4, 2021
1d40d0c
Addressing PR feedback
BigPandaToo Aug 4, 2021
b5f464e
Addressing PR feedback
BigPandaToo Aug 4, 2021
791c195
Merge branch 'master' into Pwd_initNode
elasticmachine Aug 4, 2021
e1c996e
- Create EnrollmentToken class
BigPandaToo Aug 9, 2021
7a6b58b
Merge branch 'master' into Pwd_initNode
elasticmachine Aug 9, 2021
eb0eae8
Addressing more PR feedback
BigPandaToo Aug 9, 2021
4c4c69e
Addressing more PR feedback
BigPandaToo Aug 10, 2021
5f9b007
Addressing more PR feedback
BigPandaToo Aug 11, 2021
cebdbc8
Fixing conflict
BigPandaToo Aug 11, 2021
1a169c1
Fixing conflict
BigPandaToo Aug 11, 2021
945bddb
Merge branch 'master' into Pwd_initNode
BigPandaToo Aug 11, 2021
faae76a
Fixing conflict
BigPandaToo Aug 11, 2021
c8c0214
Simplifying tests/ Fixing tests
BigPandaToo Aug 11, 2021
23d1b38
Addressing PR feedback
BigPandaToo Aug 11, 2021
d56fac3
Merge branch 'master' into Pwd_initNode
elasticmachine Aug 11, 2021
14395a8
Moving checkForClusterHealth back to `BaseRunAsSuperuserCommand`;
BigPandaToo Aug 12, 2021
19e228f
Fixing tests
BigPandaToo Aug 12, 2021
d28e251
Some changes to `checkClusterHealthWithRetriesWaitingForCluster':
BigPandaToo Aug 13, 2021
d67d318
Merge branch 'master' into Pwd_initNode
elasticmachine Aug 13, 2021
eb7e14b
Addressing more PR feedback
BigPandaToo Aug 13, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
import java.util.List;
import java.util.function.Function;

import static org.elasticsearch.xpack.security.tool.CommandLineHttpClient.createURL;

public class ResetElasticPasswordTool extends BaseRunAsSuperuserCommand {

private final Function<Environment, CommandLineHttpClient> clientFunction;
Expand Down Expand Up @@ -92,7 +94,7 @@ protected void executeCommand(Terminal terminal, OptionSet options, Environment
username,
password,
() -> requestBodySupplier(elasticPassword),
this::responseBuilder
CommandLineHttpClient::responseBuilder
);
final int responseStatus = httpResponse.getHttpStatus();
if (httpResponse.getHttpStatus() != HttpURLConnection.HTTP_OK) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ void checkClusterHealth(Terminal terminal) throws Exception {
terminal.errorPrintln("Failed to determine the health of the cluster running at " + url);
terminal.errorPrintln("Unexpected response code [" + httpResponse.getHttpStatus() + "] from calling GET " +
route.toString());
final String cause = getErrorCause(httpResponse);
final String cause = CommandLineHttpClient.getErrorCause(httpResponse);
if (cause != null) {
terminal.errorPrintln("Cause: " + cause);
}
Expand Down Expand Up @@ -477,7 +477,7 @@ private void changeUserPassword(String user, SecureString password, Terminal ter
terminal.errorPrintln("");
terminal.errorPrintln(
"Unexpected response code [" + httpResponse.getHttpStatus() + "] from calling PUT " + route.toString());
String cause = getErrorCause(httpResponse);
String cause = CommandLineHttpClient.getErrorCause(httpResponse);
if (cause != null) {
terminal.errorPrintln("Cause: " + cause);
terminal.errorPrintln("");
Expand Down Expand Up @@ -564,32 +564,6 @@ private URL createURL(URL url, String path, String query) throws MalformedURLExc
}
}

private String getErrorCause(HttpResponse httpResponse) {
final Object error = httpResponse.getResponseBody().get("error");
if (error == null) {
return null;
}
if (error instanceof Map) {
Object reason = ((Map) error).get("reason");
if (reason != null) {
return reason.toString();
}
final Object root = ((Map) error).get("root_cause");
if (root != null && root instanceof Map) {
reason = ((Map) root).get("reason");
if (reason != null) {
return reason.toString();
}
final Object type = ((Map) root).get("type");
if (type != null) {
return (String) type;
}
}
return String.valueOf(((Map) error).get("type"));
}
return error.toString();
}

private byte[] toByteArray(InputStream is) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] internalBuffer = new byte[1024];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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;

import org.elasticsearch.common.Strings;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.json.JsonXContent;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Objects;

public class EnrollmentToken {
private final String apiKey;
private final String fingerprint;
private final String version;
private final List<String > bound_address;

public String getApiKey() { return apiKey; }
public String getFingerprint() { return fingerprint; }
public String getVersion() { return version; }
public List<String> getBound_address() { return bound_address; }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bound_address -> boundAddresses


public EnrollmentToken(String apiKey, String fingerprint, String version, List<String> bound_address) {
this.apiKey = Objects.requireNonNull(apiKey);
this.fingerprint = Objects.requireNonNull(fingerprint);
this.version = Objects.requireNonNull(version);
this.bound_address = Objects.requireNonNull(bound_address);
}

public String getRaw() throws Exception {
final XContentBuilder builder = JsonXContent.contentBuilder();
builder.startObject();
builder.field("ver", version);
builder.startArray("adr");
for (String bound_address : bound_address) {
builder.value(bound_address);
}
builder.endArray();
builder.field("fgr", fingerprint);
builder.field("key", apiKey);
builder.endObject();
return Strings.toString(builder);
}

public String encode() throws Exception {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit suggestion : getEnoded()

final String jsonString = getRaw();
return Base64.getUrlEncoder().encodeToString(jsonString.getBytes(StandardCharsets.UTF_8));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,67 +36,52 @@
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

public class CreateEnrollmentToken {
public class EnrollmentTokenGenerator {
protected static final String ENROLL_API_KEY_EXPIRATION = "30m";

private static final Logger logger = LogManager.getLogger(CreateEnrollmentToken.class);
private static final Logger logger = LogManager.getLogger(EnrollmentTokenGenerator.class);
private final Environment environment;
private final SSLService sslService;
private final CommandLineHttpClient client;
private final URL defaultUrl;

public CreateEnrollmentToken(Environment environment) throws MalformedURLException {
public EnrollmentTokenGenerator(Environment environment) throws MalformedURLException {
this(environment, new CommandLineHttpClient(environment));
}

// protected for testing
protected CreateEnrollmentToken(Environment environment, CommandLineHttpClient client) throws MalformedURLException {
protected EnrollmentTokenGenerator(Environment environment, CommandLineHttpClient client) throws MalformedURLException {
this.environment = environment;
this.sslService = new SSLService(environment);
this.client = client;
this.defaultUrl = new URL(client.getDefaultURL());
}

public String createNodeEnrollmentToken(String user, SecureString password) throws Exception {
public EnrollmentToken createNodeEnrollmentToken(String user, SecureString password) throws Exception {
return this.create(user, password, NodeEnrollmentAction.NAME);
}

public String createKibanaEnrollmentToken(String user, SecureString password) throws Exception {
public EnrollmentToken createKibanaEnrollmentToken(String user, SecureString password) throws Exception {
return this.create(user, password, KibanaEnrollmentAction.NAME);
}

protected String create(String user, SecureString password, String action) throws Exception {
protected EnrollmentToken create(String user, SecureString password, String action) throws Exception {
if (XPackSettings.ENROLLMENT_ENABLED.get(environment.settings()) != true) {
throw new IllegalStateException("[xpack.security.enrollment.enabled] must be set to `true` to create an enrollment token");
}
final String fingerprint = getCaFingerprint();
final String apiKey = getApiKeyCredentials(user, password, action);
final Tuple<List<String>, String> httpInfo = getNodeInfo(user, password);

try {
final XContentBuilder builder = JsonXContent.contentBuilder();
builder.startObject();
builder.field("ver", httpInfo.v2());
builder.startArray("adr");
for (String bound_address : httpInfo.v1()) {
builder.value(bound_address);
}
builder.endArray();
builder.field("fgr", fingerprint);
builder.field("key", apiKey);
builder.endObject();
final String jsonString = Strings.toString(builder);
return Base64.getUrlEncoder().encodeToString(jsonString.getBytes(StandardCharsets.UTF_8));
return new EnrollmentToken(apiKey, fingerprint, httpInfo.v2(), httpInfo.v1());
} catch (Exception e) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this error handling is now redundant.

logger.error(("Error generating enrollment token"), e);
throw new IllegalStateException("Error generating enrollment token: " + e.getMessage());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* 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.OptionParser;
import joptsimple.OptionSet;

import joptsimple.OptionSpec;

import org.elasticsearch.cli.ExitCodes;
import org.elasticsearch.cli.KeyStoreAwareCommand;
import org.elasticsearch.cli.Terminal;
import org.elasticsearch.cli.UserException;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.settings.KeyStoreWrapper;
import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.json.JsonXContent;
import org.elasticsearch.core.CheckedFunction;
import org.elasticsearch.env.Environment;
import org.elasticsearch.xpack.core.security.user.ElasticUser;
import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
import org.elasticsearch.xpack.security.enrollment.EnrollmentToken;
import org.elasticsearch.xpack.security.enrollment.EnrollmentTokenGenerator;
import org.elasticsearch.xpack.security.tool.CommandLineHttpClient;
import org.elasticsearch.xpack.security.tool.HttpResponse;

import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.security.SecureRandom;
import java.util.function.Function;

import static org.elasticsearch.xpack.security.tool.CommandLineHttpClient.createURL;

public class BootstrapPasswordAndEnrollmentTokenForInitialNode extends KeyStoreAwareCommand {
private static final String ELASTIC_USER = ElasticUser.NAME;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: redundant

private final CheckedFunction<Environment, EnrollmentTokenGenerator, Exception> createEnrollmentTokenFunction;
private final Function<Environment, CommandLineHttpClient> clientFunction;
private final CheckedFunction<Environment, KeyStoreWrapper, Exception> keyStoreFunction;
private final OptionSpec<Void> docker;
final SecureRandom secureRandom = new SecureRandom();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit make this private as well


// Package-private for testing
CommandLineHttpClient getClient(Environment env) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's merely preference, so just a nit. I wouldn't add these constructors only so that we can mock the method in tests. You can mock the object it returns instead

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need real client in test to test the checkClusterHealthWithRetries

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need real client in test to test the checkClusterHealthWithRetries

Apologies, I don't follow this, can you elaborate ?

You use a real client in tests by doing CommandLineHttpClient client = new CommandLineHttpClient(environment); already. You are not using getClient() for that. In fact, you are using getClient() only with the mock(CommandLineHttpClient)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BigPandaToo I think the point is that either you have a no-arg constructor with accessors that you can mock, or if the constructor takes args you can pass the mocks as args and avoid accessors altogether. There's no point in passing real objects as args to the constructor, but then mocking their accessors.

Hope this helps. Personally, I find it clearer to mock things (accessor methods) rather than have the production code use functions/suppliers instead of plain fields. But I'm OK either way.

return clientFunction.apply(env);
}
EnrollmentTokenGenerator getEnrollmentTokenGenerator(Environment env) throws Exception{
return createEnrollmentTokenFunction.apply(env);
}
OptionParser getParser() { return parser; }

BootstrapPasswordAndEnrollmentTokenForInitialNode() {
this(
environment -> new CommandLineHttpClient(environment),
environment -> KeyStoreWrapper.load(environment.configFile()),
environment -> new EnrollmentTokenGenerator(environment)
);
}

BootstrapPasswordAndEnrollmentTokenForInitialNode(Function<Environment, CommandLineHttpClient> clientFunction,
CheckedFunction<Environment, KeyStoreWrapper, Exception> keyStoreFunction,
CheckedFunction<Environment, EnrollmentTokenGenerator, Exception>
createEnrollmentTokenFunction){
super("Set elastic password and generate enrollment token for initial node");
this.clientFunction = clientFunction;
this.keyStoreFunction = keyStoreFunction;
this.createEnrollmentTokenFunction = createEnrollmentTokenFunction;
docker = parser.accepts("docker", "determine that we are running in docker");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the parameter name is suitable.

We know that we're only going to set this parameter when the auto configuration runs in docker.
But this might change in the future, because what it does has actually nothing to do with docker. It's better to name it for what it does.

I suggest the name of "node-enrollment-token" or "include-node-enrollment-token".

}

public static void main(String[] args) throws Exception {
exit(new BootstrapPasswordAndEnrollmentTokenForInitialNode().main(args, Terminal.DEFAULT));
}

@Override
protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
final char[] keyStorePassword;
Copy link
Member

@jkakavas jkakavas Aug 13, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's not have two variables of different type with almost the same name if we don't need to. You can simply have

final SecureString keystorePassword; 
try {
    keystorePassword = new SecureString(terminal.readSecret(""));
} catch (Exception e) {
    throw new UserException(ExitCodes.USAGE, null);
}

try {
keyStorePassword = terminal.readSecret("");
} catch (Exception e) {
throw new UserException(ExitCodes.USAGE, null);
}

final SecureString keystorePassword = new SecureString(keyStorePassword);
final CommandLineHttpClient client = getClient(env);
final EnrollmentTokenGenerator enrollmentTokenGenerator = getEnrollmentTokenGenerator(env);
final SecureString bootstrapPassword = readBootstrapPassword(env, keystorePassword);
try {
client.checkClusterHealthWithRetriesWaitingForCluster(ELASTIC_USER, bootstrapPassword, 5, false);
} catch (Exception e) {
throw new UserException(ExitCodes.UNAVAILABLE, null);
}
try {
final EnrollmentToken kibanaToken = enrollmentTokenGenerator.createKibanaEnrollmentToken(ELASTIC_USER, bootstrapPassword);
terminal.println("Kibana enrollment token: " + kibanaToken.encode());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the first time I'm bringing this up.
It would be better if we collate all the individual lines into a big String that is then passed to terminal#print.

This way the tool shows output when everything is completely done, and no output if one of the calls fails part way. It's still technically possible to show parts of it but much less likely, especially to the human eye.

It also helps with reordering of the information, without reordering of the endpoint calls (ie keep the elastic password reset last).

terminal.println("CA fingerprint: " + kibanaToken.getFingerprint());
} catch (Exception e) {
throw new UserException(ExitCodes.UNAVAILABLE, null);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think we should have one big try-catch block with all the endpoint calling methods grouped inside. I think it is easier to follow this way.

Lets avoid swallowing exceptions. I know we're not going to show these to users in the first phase but it bothers me every time I see it, and it is distracting.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree on having one block. But what do you mean by swallowing exceptions? We don't swallow them, just don't have custom messages

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you mean by swallowing exceptions? We don't swallow them, just don't have custom messages

        } catch (Exception e) {
            throw new UserException(ExitCodes.UNAVAILABLE, null);
        }

->

       } catch (Exception e) {
           throw new UserException(ExitCodes.UNAVAILABLE, "", e);
       }

if (options.has(docker)) {
try {
final EnrollmentToken nodeToken = enrollmentTokenGenerator.createNodeEnrollmentToken(ELASTIC_USER, bootstrapPassword);
terminal.println("Node enrollment token: " + nodeToken.encode());
} catch (Exception e) {
throw new UserException(ExitCodes.UNAVAILABLE, null);
}
}
if (ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.exists(env.settings()) == false) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't really work.

The problem is that the env.settings() object doesn't contain the settings from the keystore.
If you look at the BaseRunAsSuperuserCommand#execute preamble code (

), the decoded keystore is used to build an environment which then gets passed around.

Instead, you've chosen to build the environment every time you want to read the Secure Setting and then throw it out (the readBootstrapPassword method).

terminal.println("elastic user password: " + setElasticUserPassword(client, bootstrapPassword));
}
}

protected SecureString setElasticUserPassword(CommandLineHttpClient client, SecureString bootstrapPassword) throws Exception {
final URL passwordSetUrl = setElasticUserPasswordUrl(client);
final HttpResponse response;
SecureString password = new SecureString(generatePassword(20));
try {
response = client.execute("POST", passwordSetUrl, ELASTIC_USER, bootstrapPassword,
() -> {
XContentBuilder xContentBuilder = JsonXContent.contentBuilder();
xContentBuilder.startObject().field("password", password).endObject();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can't pass a SecureString as the value in field(), you need to use the char array here and wrap it in a SecureString later or call toString() on the SecureString you already have

return Strings.toString(xContentBuilder);
}, CommandLineHttpClient::responseBuilder);
if (response.getHttpStatus() != HttpURLConnection.HTTP_OK) {
throw new UserException(ExitCodes.UNAVAILABLE, null);
}
} catch (IOException e) {
throw new UserException(ExitCodes.IO_ERROR, null);
}
return password;
}

SecureString readBootstrapPassword(Environment env, SecureString keystorePassword) throws Exception {
final KeyStoreWrapper keyStoreWrapper = keyStoreFunction.apply(env);
keyStoreWrapper.decrypt(keystorePassword.getChars());
Settings.Builder settingsBuilder = Settings.builder();
settingsBuilder.put(env.settings(), true);
if (settingsBuilder.getSecureSettings() == null) {
settingsBuilder.setSecureSettings(keyStoreWrapper);
}
final Settings settings = settingsBuilder.build();
SecureString bootstrapPassword = ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.get(settings);
return bootstrapPassword;
}

URL checkClusterHealthUrl(CommandLineHttpClient client) throws MalformedURLException, URISyntaxException {
return createURL(new URL(client.getDefaultURL()), "_cluster/health", "?pretty");
}

URL setElasticUserPasswordUrl(CommandLineHttpClient client) throws MalformedURLException, URISyntaxException {
return createURL(new URL(client.getDefaultURL()), "/_security/user/" + ELASTIC_USER + "/_password",
"?pretty");
}

protected 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;
}
}
Loading