Browse Source

Add 'show' command to the keystore CLI (#76693)

This adds a new "elasticsearch-keystore show" command that displays
the value of a single secure setting from the keystore.

An optional `-o` (or `--output`) parameter can be used to direct
output to a file.

The `-o` option is required for binary keystore values
because the CLI `Terminal` class does not support writing binary data.
Hence this command:

    elasticsearch-keystore show xpack.watcher.encryption_key > watcher.key

would not produce a file with the correct contents.

Co-authored-by: Ioannis Kakavas <ikakavas@protonmail.com>
Tim Vernum 4 years ago
parent
commit
6125067145

+ 1 - 0
distribution/tools/keystore-cli/src/main/java/org/elasticsearch/cli/keystore/KeyStoreCli.java

@@ -20,6 +20,7 @@ public class KeyStoreCli extends LoggingAwareMultiCommand {
         super("A tool for managing settings stored in the elasticsearch keystore");
         subcommands.put("create", new CreateKeyStoreCommand());
         subcommands.put("list", new ListKeyStoreCommand());
+        subcommands.put("show", new ShowKeyStoreCommand());
         subcommands.put("add", new AddStringKeyStoreCommand());
         subcommands.put("add-file", new AddFileKeyStoreCommand());
         subcommands.put("remove", new RemoveSettingKeyStoreCommand());

+ 87 - 0
distribution/tools/keystore-cli/src/main/java/org/elasticsearch/cli/keystore/ShowKeyStoreCommand.java

@@ -0,0 +1,87 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.cli.keystore;
+
+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.bytes.BytesReference;
+import org.elasticsearch.common.settings.KeyStoreWrapper;
+import org.elasticsearch.env.Environment;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+/**
+ * A subcommand for the keystore cli to show the value of a setting in the keystore.
+ */
+public class ShowKeyStoreCommand extends BaseKeyStoreCommand {
+
+    private final OptionSpec<String> arguments;
+
+    public ShowKeyStoreCommand() {
+        super("Show a value from the keystore", true);
+        this.arguments = parser.nonOptions("setting name");
+    }
+
+    @Override
+    protected void executeCommand(Terminal terminal, OptionSet options, Environment env) throws Exception {
+        final List<String> names = arguments.values(options);
+        if (names.size() != 1) {
+            throw new UserException(ExitCodes.USAGE, "Must provide a single setting name to show");
+        }
+        final String settingName = names.get(0);
+
+        final KeyStoreWrapper keyStore = getKeyStore();
+        if (keyStore.getSettingNames().contains(settingName) == false) {
+            throw new UserException(ExitCodes.CONFIG, "Setting [" + settingName + "] does not exist in the keystore.");
+        }
+
+        try (InputStream input = keyStore.getFile(settingName)) {
+            final BytesReference bytes = org.elasticsearch.common.io.Streams.readFully(input);
+            try {
+                byte[] array = BytesReference.toBytes(bytes);
+                CharBuffer text = StandardCharsets.UTF_8.newDecoder()
+                    .onMalformedInput(CodingErrorAction.REPORT)
+                    .onUnmappableCharacter(CodingErrorAction.REPORT)
+                    .decode(ByteBuffer.wrap(array));
+
+                // This is not strictly true, but it's the best heuristic we have.
+                // Without it we risk appending a newline to a binary file that happens to be valid UTF8
+                final boolean isFileOutput = terminal.getOutputStream() != null;
+                if (isFileOutput) {
+                    terminal.print(Terminal.Verbosity.SILENT, text.toString());
+                } else {
+                    terminal.println(Terminal.Verbosity.SILENT, text);
+                }
+            } catch (CharacterCodingException e) {
+                final OutputStream output = terminal.getOutputStream();
+                if (output != null) {
+                    bytes.writeTo(output);
+                } else {
+                    terminal.errorPrintln(Terminal.Verbosity.VERBOSE, e.toString());
+                    terminal.errorPrintln(
+                        "The value for the setting [" + settingName + "] is not a string and cannot be printed to the console"
+                    );
+                    throw new UserException(ExitCodes.IO_ERROR, "Please redirect binary output to a file instead");
+                }
+            }
+        }
+    }
+
+}

+ 5 - 1
distribution/tools/keystore-cli/src/test/java/org/elasticsearch/cli/keystore/KeyStoreCommandTestCase.java

@@ -71,10 +71,14 @@ public abstract class KeyStoreCommandTestCase extends CommandTestCase {
         for (int i = 0; i < settings.length; i += 2) {
             keystore.setString(settings[i], settings[i + 1].toCharArray());
         }
-        keystore.save(env.configFile(), password.toCharArray());
+        saveKeystore(keystore, password);
         return keystore;
     }
 
+    void saveKeystore(KeyStoreWrapper keystore, String password) throws Exception {
+        keystore.save(env.configFile(), password.toCharArray());
+    }
+
     KeyStoreWrapper loadKeystore(String password) throws Exception {
         KeyStoreWrapper keystore = KeyStoreWrapper.load(env.configFile());
         keystore.decrypt(password.toCharArray());

+ 135 - 0
distribution/tools/keystore-cli/src/test/java/org/elasticsearch/cli/keystore/ShowKeyStoreCommandTests.java

@@ -0,0 +1,135 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.cli.keystore;
+
+import org.elasticsearch.cli.Command;
+import org.elasticsearch.cli.ExitCodes;
+import org.elasticsearch.cli.UserException;
+import org.elasticsearch.common.settings.KeyStoreWrapper;
+import org.elasticsearch.env.Environment;
+
+import java.util.Map;
+
+import static org.hamcrest.Matchers.anyOf;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+
+public class ShowKeyStoreCommandTests extends KeyStoreCommandTestCase {
+
+    @Override
+    protected Command newCommand() {
+        return new ShowKeyStoreCommand() {
+            @Override
+            protected Environment createEnv(Map<String, String> settings) throws UserException {
+                return env;
+            }
+        };
+    }
+
+    public void testErrorOnMissingKeystore() throws Exception {
+        final UserException e = expectThrows(UserException.class, this::execute);
+        assertEquals(ExitCodes.DATA_ERROR, e.exitCode);
+        assertThat(e.getMessage(), containsString("keystore not found"));
+    }
+
+    public void testErrorOnMissingParameter() throws Exception {
+        createKeystore("");
+        final UserException e = expectThrows(UserException.class, this::execute);
+        assertEquals(ExitCodes.USAGE, e.exitCode);
+        assertThat(e.getMessage(), containsString("Must provide a single setting name to show"));
+    }
+
+    public void testErrorWhenSettingDoesNotExist() throws Exception {
+        final String password = getPossibleKeystorePassword();
+        createKeystore(password);
+        terminal.addSecretInput(password);
+        final UserException e = expectThrows(UserException.class, () -> execute("not.a.value"));
+        assertEquals(ExitCodes.CONFIG, e.exitCode);
+        assertThat(e.getMessage(), containsString("Setting [not.a.value] does not exist in the keystore"));
+    }
+
+    public void testPrintSingleValueToTerminal() throws Exception {
+        final String password = getPossibleKeystorePassword();
+        final String value = randomAlphaOfLengthBetween(6, 12);
+        createKeystore(password, "reindex.ssl.keystore.password", value);
+        terminal.addSecretInput(password);
+        terminal.setHasOutputStream(false);
+        execute("reindex.ssl.keystore.password");
+        assertEquals(value + "\n", terminal.getOutput());
+    }
+
+    public void testShowBinaryValue() throws Exception {
+        final String password = getPossibleKeystorePassword();
+        final byte[] value = randomByteArrayOfLength(randomIntBetween(16, 2048));
+        KeyStoreWrapper ks = createKeystore(password);
+        ks.setFile("binary.file", value);
+        saveKeystore(ks, password);
+
+        terminal.addSecretInput(password);
+        terminal.setHasOutputStream(true);
+
+        execute("binary.file");
+        assertThat(terminal.getOutputBytes(), equalTo(value));
+    }
+
+    public void testErrorIfOutputBinaryToTerminal() throws Exception {
+        final String password = getPossibleKeystorePassword();
+        final byte[] value = randomByteArrayOfLength(randomIntBetween(16, 2048));
+        KeyStoreWrapper ks = createKeystore(password);
+        ks.setFile("binary.file", value);
+        saveKeystore(ks, password);
+
+        terminal.addSecretInput(password);
+        terminal.setHasOutputStream(false);
+
+        UserException e = expectThrows(UserException.class, () -> execute("binary.file"));
+        assertEquals(e.getMessage(), ExitCodes.IO_ERROR, e.exitCode);
+        assertThat(e.getMessage(), containsString("Please redirect binary output to a file instead"));
+
+    }
+
+    public void testErrorOnIncorrectPassword() throws Exception {
+        String password = "keystorepassword";
+        createKeystore(password, "foo", "bar");
+        terminal.addSecretInput("thewrongkeystorepassword");
+        UserException e = expectThrows(UserException.class, () -> execute("keystore.seed"));
+        assertEquals(e.getMessage(), ExitCodes.DATA_ERROR, e.exitCode);
+        if (inFipsJvm()) {
+            assertThat(
+                e.getMessage(),
+                anyOf(
+                    containsString("Provided keystore password was incorrect"),
+                    containsString("Keystore has been corrupted or tampered with")
+                )
+            );
+        } else {
+            assertThat(e.getMessage(), containsString("Provided keystore password was incorrect"));
+        }
+    }
+
+    public void testRetrieveFromUnprotectedKeystore() throws Exception {
+        assumeFalse("Cannot open unprotected keystore on FIPS JVM", inFipsJvm());
+        final String name = randomAlphaOfLengthBetween(6, 12);
+        final String value = randomAlphaOfLengthBetween(6, 12);
+        createKeystore("", name, value);
+        final boolean console = randomBoolean();
+        if (console) {
+            terminal.setHasOutputStream(false);
+        }
+
+        execute(name);
+        // Not prompted for a password
+
+        if (console) {
+            assertEquals(value + "\n", terminal.getOutput());
+        } else {
+            assertEquals(value, terminal.getOutput());
+        }
+    }
+}

+ 43 - 5
docs/reference/commands/keystore.asciidoc

@@ -11,10 +11,15 @@ in the {es} keystore.
 [source,shell]
 --------------------------------------------------
 bin/elasticsearch-keystore
-([add <settings>] [-f] [--stdin] |
-[add-file (<setting> <path>)+] | [create] [-p] |
-[list] | [passwd] | [remove <setting>] | [upgrade])
-[-h, --help] ([-s, --silent] | [-v, --verbose])
+( [add <settings>] [-f] [--stdin]
+| [add-file (<setting> <path>)+]
+| [create] [-p]
+| [list]
+| [passwd]
+| [remove <setting>]
+| [show [-o <output-file>] <setting>]
+| [upgrade]
+) [-h, --help] ([-s, --silent] | [-v, --verbose])
 --------------------------------------------------
 
 [discrete]
@@ -29,7 +34,11 @@ same value on every node. Therefore you must run this command on every node.
 When the keystore is password-protected, you must supply the password each time
 {es} starts.
 
-Modifications to the keystore do not take effect until you restart {es}.
+Modifications to the keystore are not automatically applied to the running {es}
+node.
+Any changes to the keystore will take effect when you restart {es}.
+Some secure settings can be explicitly <<reloadable-secure-settings, reloaded>>
+without restart.
 
 Only some settings are designed to be read from the keystore. However, there
 is no validation to block unsupported settings from the keystore and they can
@@ -74,6 +83,13 @@ not password protected, you can use this command to set a password.
 `remove <settings>`:: Removes settings from the keystore. Multiple setting
 names can be specified as arguments to the `remove` command.
 
+`show <setting>`:: Displays the value of a single setting in the keystore.
+Pass the `-o` (or `--output`) parameter to write the setting to a file.
+If writing to the standard output (the terminal) the setting's value is always
+interpretted as a UTF-8 string. If the setting contains binary data (for example
+for data that was added via the `add-file` command), always use the `-o` option
+to write to a file.
+
 `-s, --silent`:: Shows minimal output.
 
 `-x, --stdin`:: When used with the `add` parameter, you can pass the settings values
@@ -193,6 +209,28 @@ bin/elasticsearch-keystore add-file \
 If the {es} keystore is password protected, you are prompted to enter the
 password.
 
+[discrete]
+[[show-keystore-value]]
+==== Show settings in the keystore
+
+To display the value of a setting in the keystorem use the `show` command:
+
+[source,sh]
+----------------------------------------------------------------
+bin/elasticsearch-keystore show the.name.of.the.setting.to.show
+----------------------------------------------------------------
+
+If the setting contains binary data you should write it to a file with the
+`-o` (or `--output`) option:
+
+[source,sh]
+----------------------------------------------------------------
+bin/elasticsearch-keystore show -o my_file binary.setting.name
+----------------------------------------------------------------
+
+If the {es} keystore is password protected, you are prompted to enter the
+password.
+
 [discrete]
 [[remove-settings]]
 ==== Remove settings from the keystore

+ 23 - 2
libs/cli/src/main/java/org/elasticsearch/cli/Terminal.java

@@ -8,10 +8,13 @@
 
 package org.elasticsearch.cli;
 
+import org.elasticsearch.core.Nullable;
+
 import java.io.BufferedReader;
 import java.io.Console;
 import java.io.IOException;
 import java.io.InputStreamReader;
+import java.io.OutputStream;
 import java.io.PrintWriter;
 import java.io.Reader;
 import java.nio.charset.Charset;
@@ -82,18 +85,25 @@ public abstract class Terminal {
     /** Returns a Writer which can be used to write to the terminal directly using standard output. */
     public abstract PrintWriter getWriter();
 
+    /**
+     * Returns an OutputStream which can be used to write to the terminal directly using standard output.
+     * May return {@code null} if this Terminal is not capable of binary output
+      */
+    @Nullable
+    public abstract OutputStream getOutputStream();
+
     /** Returns a Writer which can be used to write to the terminal directly using standard error. */
     public PrintWriter getErrorWriter() {
         return ERROR_WRITER;
     }
 
     /** Prints a line to the terminal at {@link Verbosity#NORMAL} verbosity level. */
-    public final void println(String msg) {
+    public final void println(CharSequence msg) {
         println(Verbosity.NORMAL, msg);
     }
 
     /** Prints a line to the terminal at {@code verbosity} level. */
-    public final void println(Verbosity verbosity, String msg) {
+    public final void println(Verbosity verbosity, CharSequence msg) {
         print(verbosity, msg + lineSeparator);
     }
 
@@ -213,6 +223,11 @@ public abstract class Terminal {
             return CONSOLE.writer();
         }
 
+        @Override
+        public OutputStream getOutputStream() {
+            return null;
+        }
+
         @Override
         public String readText(String prompt) {
             return CONSOLE.readLine("%s", prompt);
@@ -253,6 +268,12 @@ public abstract class Terminal {
             return WRITER;
         }
 
+        @Override
+        @SuppressForbidden(reason = "Use system.out in CLI framework")
+        public OutputStream getOutputStream() {
+            return System.out;
+        }
+
         @Override
         public String readText(String text) {
             getErrorWriter().print(text); // prompts should go to standard error to avoid mixing with list output

+ 16 - 0
test/framework/src/main/java/org/elasticsearch/cli/MockTerminal.java

@@ -9,6 +9,7 @@
 package org.elasticsearch.cli;
 
 import java.io.ByteArrayOutputStream;
+import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.io.UnsupportedEncodingException;
@@ -39,6 +40,7 @@ public class MockTerminal extends Terminal {
     private final List<String> secretInput = new ArrayList<>();
     private int secretIndex = 0;
 
+    private boolean hasOutputStream = true;
     public MockTerminal() {
         super("\n"); // always *nix newlines for tests
     }
@@ -64,11 +66,20 @@ public class MockTerminal extends Terminal {
         return writer;
     }
 
+    @Override
+    public OutputStream getOutputStream() {
+        return hasOutputStream ? stdoutBuffer : null;
+    }
+
     @Override
     public PrintWriter getErrorWriter() {
         return errorWriter;
     }
 
+    public void setHasOutputStream(boolean hasOutputStream) {
+        this.hasOutputStream = hasOutputStream;
+    }
+
     /** Adds an an input that will be return from {@link #readText(String)}. Values are read in FIFO order. */
     public void addTextInput(String input) {
         textInput.add(input);
@@ -84,6 +95,11 @@ public class MockTerminal extends Terminal {
         return stdoutBuffer.toString("UTF-8");
     }
 
+    /** Returns all bytes  written to this terminal. */
+    public byte[] getOutputBytes() {
+        return stdoutBuffer.toByteArray();
+    }
+
     /** Returns all output written to this terminal. */
     public String getErrorOutput() throws UnsupportedEncodingException {
         return stderrBuffer.toString("UTF-8");