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 13 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,51 @@
/*
* 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;

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 = apiKey;
Copy link
Member

Choose a reason for hiding this comment

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

Objects.requireNonNull all of the ctor parameters

this.fingerprint = fingerprint;
this.version = version;
this.bound_address = bound_address;
}

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.

In the last sync we discussed that this should be either 2 methods or 1 method that takes a parameter. One output should be base64encoded and one should be unencoded json

Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
public String encode() throws Exception{
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.

Let's add tests for this method

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();
final String jsonString = Strings.toString(builder);
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,199 @@
/*
* 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 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.XPackSettings;
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 elasticUser = ElasticUser.NAME;
private KeyStoreWrapper keyStoreWrapper;
Copy link
Member

Choose a reason for hiding this comment

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

can keyStoreWrapper be a local variable as it is only used in readBootstrapPassword ?

private final CheckedFunction<Environment, EnrollmentTokenGenerator, Exception> createEnrollmentTokenFunction;
private final Function<Environment, CommandLineHttpClient> clientFunction;
private final CheckedFunction<Environment, KeyStoreWrapper, Exception> keyStoreFunction;
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
SecureString bootstrapPassword;
SecureString credentialsPassword;

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);
}
KeyStoreWrapper getKeyStoreWrapper(Environment env) throws Exception { return keyStoreFunction.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;
parser.allowsUnrecognizedOptions();
Copy link
Member

Choose a reason for hiding this comment

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

this is not needed anymore

}

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 {
if (options.nonOptionArguments().contains("--explicitly-acknowledge-execution") == false) {
throw new UserException(ExitCodes.CONFIG, null);
}
if (env.settings().hasValue(XPackSettings.ENROLLMENT_ENABLED.getKey()) && false ==
XPackSettings.ENROLLMENT_ENABLED.get(env.settings())) {
throw new UserException(ExitCodes.NOOP, null);
}
SecureString password;
Copy link
Member

Choose a reason for hiding this comment

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

I think that if you first generate tokens and then set the password of the elastic user, it would allow you to simplify your code as you don't need to keep track of password here and first use boostrapPassword. You can keep using bootstrapPassword for everyhing and just print the autogenerated password of the user in the end

final CommandLineHttpClient client = getClient(env);
final EnrollmentTokenGenerator enrollmentTokenGenerator = getEnrollmentTokenGenerator(env);
keyStoreWrapper = getKeyStoreWrapper(env);
ReadBootstrapPassword(env, terminal);
try {
client.checkClusterHealthWithRetriesWaitingForCluster(elasticUser, credentialsPassword, 5);
} catch (Exception e) {
throw new UserException(ExitCodes.UNAVAILABLE, null);
}
if (Strings.isNullOrEmpty(bootstrapPassword.toString())) {
password = changeElasticUserPassword(client);
} else {
password = bootstrapPassword;
}
final EnrollmentToken kibanaToken;
Copy link
Member

Choose a reason for hiding this comment

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

nit, you can move the println in the try clause so that you don't have to split the declaration above

try {
kibanaToken = enrollmentTokenGenerator.createKibanaEnrollmentToken(elasticUser, password);
} 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 (Strings.isNullOrEmpty(bootstrapPassword.toString())) {
terminal.println("'elastic' user password: " + password);
}
terminal.println("CA fingerprint: " + kibanaToken.getFingerprint());
terminal.println("Kibana enrollment token: " + kibanaToken.encode());
if (options.nonOptionArguments().contains("--docker")) {
final EnrollmentToken nodeToken;
Copy link
Member

Choose a reason for hiding this comment

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

nit, you can move the println in the try clause so that you don't have to split the declaration above

try {
nodeToken = enrollmentTokenGenerator.createNodeEnrollmentToken(elasticUser, password);
} catch (Exception e) {
throw new UserException(ExitCodes.UNAVAILABLE, null);
}
terminal.println("Node enrollment token: " + nodeToken.encode());
}
}

@Override
public void close() {
if (keyStoreWrapper != null) {
keyStoreWrapper.close();
}
if (bootstrapPassword != null) {
bootstrapPassword.close();
}
if (credentialsPassword != null) {
credentialsPassword.close();
}
}

protected SecureString changeElasticUserPassword(CommandLineHttpClient client) throws Exception {
final URL passwordChangeUrl = changeElasticUserPasswordUrl(client);
final HttpResponse response;
SecureString password = new SecureString(generatePassword(20));
try {
response = client.execute("POST", passwordChangeUrl, elasticUser, credentialsPassword,
() -> {
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;
}

void ReadBootstrapPassword (Environment env, Terminal terminal) throws Exception {
decryptKeyStore(keyStoreWrapper, terminal);
Settings.Builder settingsBuilder = Settings.builder();
settingsBuilder.put(env.settings(), true);
if (settingsBuilder.getSecureSettings() == null) {
settingsBuilder.setSecureSettings(keyStoreWrapper);
}
final Settings settings = settingsBuilder.build();
bootstrapPassword = ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.get(settings);
credentialsPassword = Strings.isNullOrEmpty(bootstrapPassword.toString()) ? KeyStoreWrapper.SEED_SETTING.get(settings) :
bootstrapPassword;

final Environment newEnv = new Environment(settings, env.configFile());
Environment.assertEquivalent(newEnv, env);
}

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

URL changeElasticUserPasswordUrl(CommandLineHttpClient client) throws MalformedURLException, URISyntaxException {
return createURL(new URL(client.getDefaultURL()), "/_security/user/" + elasticUser + "/_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