/* * SPDX-License-Identifier: Apache-2.0 * * The OpenSearch Contributors require contributions made to * this file be licensed under the Apache-2.0 license or a * compatible open source license. */ /* * Licensed to Elasticsearch under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch licenses this file to you under * the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ /* * Modifications Copyright OpenSearch Contributors. See * GitHub history for details. */ package org.opensearch.plugins; import joptsimple.OptionSet; import joptsimple.OptionSpec; import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipFile; import org.apache.lucene.search.spell.LevenshteinDistance; import org.apache.lucene.util.CollectionUtil; import org.apache.lucene.util.Constants; import org.bouncycastle.bcpg.ArmoredInputStream; import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPPublicKey; import org.bouncycastle.openpgp.PGPPublicKeyRingCollection; import org.bouncycastle.openpgp.PGPSignature; import org.bouncycastle.openpgp.PGPSignatureList; import org.bouncycastle.openpgp.PGPUtil; import org.bouncycastle.openpgp.jcajce.JcaPGPObjectFactory; import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator; import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentVerifierBuilderProvider; import org.opensearch.Build; import org.opensearch.Version; import org.opensearch.bootstrap.JarHell; import org.opensearch.cli.EnvironmentAwareCommand; import org.opensearch.cli.ExitCodes; import org.opensearch.cli.Terminal; import org.opensearch.cli.UserException; import org.opensearch.common.SuppressForbidden; import org.opensearch.common.collect.Tuple; import org.opensearch.common.hash.MessageDigests; import org.opensearch.common.util.io.IOUtils; import org.opensearch.env.Environment; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.net.URLConnection; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.nio.file.DirectoryStream; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.PosixFileAttributeView; import java.nio.file.attribute.PosixFileAttributes; import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.PosixFilePermissions; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Enumeration; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.TreeSet; import java.util.stream.Collectors; import static org.opensearch.cli.Terminal.Verbosity.VERBOSE; /** * A command for the plugin cli to install a plugin into opensearch. * * The install command takes a plugin id, which may be any of the following: *
* The installation process first extracts the plugin files into a temporary * directory in order to verify the plugin satisfies the following requirements: *
* A plugin may also contain an optional {@code bin} directory which contains scripts. The * scripts will be installed into a subdirectory of the opensearch bin directory, using * the name of the plugin, and the scripts will be marked executable. *
* A plugin may also contain an optional {@code config} directory which contains configuration
* files specific to the plugin. The config files be installed into a subdirectory of the
* opensearch config directory, using the name of the plugin. If any files to be installed
* already exist, they will be skipped.
*/
class InstallPluginCommand extends EnvironmentAwareCommand {
private static final String PROPERTY_STAGING_ID = "opensearch.plugins.staging";
// exit codes for install
/** A plugin with the same name is already installed. */
static final int PLUGIN_EXISTS = 1;
/** The plugin zip is not properly structured. */
static final int PLUGIN_MALFORMED = 2;
/** The builtin modules, which are plugins, but cannot be installed or removed. */
static final Set
*
*
* @param terminal a terminal to log messages to
* @param urlString the URL of the plugin ZIP
* @param tmpDir a temporary directory to write downloaded files to
* @param officialPlugin true if the plugin is an official plugin
* @param isBatch true if the install is running in batch mode
* @return the path to the downloaded plugin ZIP
* @throws IOException if an I/O exception occurs download or reading files and resources
* @throws PGPException if an exception occurs verifying the downloaded ZIP signature
* @throws UserException if checksum validation fails
*/
private Path downloadAndValidate(
final Terminal terminal,
final String urlString,
final Path tmpDir,
final boolean officialPlugin,
boolean isBatch
) throws IOException, PGPException, UserException {
Path zip = downloadZip(terminal, urlString, tmpDir, isBatch);
pathsToDeleteOnShutdown.add(zip);
String checksumUrlString = urlString + ".sha512";
URL checksumUrl = openUrl(checksumUrlString);
String digestAlgo = "SHA-512";
if (checksumUrl == null && officialPlugin == false) {
// fallback to sha1, until 7.0, but with warning
terminal.println(
"Warning: sha512 not found, falling back to sha1. This behavior is deprecated and will be removed in a "
+ "future release. Please update the plugin to use a sha512 checksum."
);
checksumUrlString = urlString + ".sha1";
checksumUrl = openUrl(checksumUrlString);
digestAlgo = "SHA-1";
}
if (checksumUrl == null) {
throw new UserException(ExitCodes.IO_ERROR, "Plugin checksum missing: " + checksumUrlString);
}
final String expectedChecksum;
try (InputStream in = urlOpenStream(checksumUrl)) {
/*
* The supported format of the SHA-1 files is a single-line file containing the SHA-1. The supported format of the SHA-512 files
* is a single-line file containing the SHA-512 and the filename, separated by two spaces. For SHA-1, we verify that the hash
* matches, and that the file contains a single line. For SHA-512, we verify that the hash and the filename match, and that the
* file contains a single line.
*/
if (digestAlgo.equals("SHA-1")) {
final BufferedReader checksumReader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
expectedChecksum = checksumReader.readLine();
if (checksumReader.readLine() != null) {
throw new UserException(ExitCodes.IO_ERROR, "Invalid checksum file at " + checksumUrl);
}
} else {
final BufferedReader checksumReader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
final String checksumLine = checksumReader.readLine();
final String[] fields = checksumLine.split(" {2}");
if (officialPlugin && fields.length != 2 || officialPlugin == false && fields.length > 2) {
throw new UserException(ExitCodes.IO_ERROR, "Invalid checksum file at " + checksumUrl);
}
expectedChecksum = fields[0];
if (fields.length == 2) {
// checksum line contains filename as well
final String[] segments = URI.create(urlString).getPath().split("/");
final String expectedFile = segments[segments.length - 1];
if (fields[1].equals(expectedFile) == false) {
final String message = String.format(
Locale.ROOT,
"checksum file at [%s] is not for this plugin, expected [%s] but was [%s]",
checksumUrl,
expectedFile,
fields[1]
);
throw new UserException(ExitCodes.IO_ERROR, message);
}
}
if (checksumReader.readLine() != null) {
throw new UserException(ExitCodes.IO_ERROR, "Invalid checksum file at " + checksumUrl);
}
}
}
// read the bytes of the plugin zip in chunks to avoid out of memory errors
try (InputStream zis = Files.newInputStream(zip)) {
try {
final MessageDigest digest = MessageDigest.getInstance(digestAlgo);
final byte[] bytes = new byte[8192];
int read;
while ((read = zis.read(bytes)) != -1) {
assert read > 0 : read;
digest.update(bytes, 0, read);
}
final String actualChecksum = MessageDigests.toHexString(digest.digest());
if (expectedChecksum.equals(actualChecksum) == false) {
throw new UserException(
ExitCodes.IO_ERROR,
digestAlgo + " mismatch, expected " + expectedChecksum + " but got " + actualChecksum
);
}
} catch (final NoSuchAlgorithmException e) {
// this should never happen as we are using SHA-1 and SHA-512 here
throw new AssertionError(e);
}
}
if (officialPlugin) {
verifySignature(zip, urlString);
}
return zip;
}
/**
* Verify the signature of the downloaded plugin ZIP. The signature is obtained from the source of the downloaded plugin by appending
* ".sig" to the URL. It is expected that the plugin is signed with the OpenSearch signing key with ID C2EE2AF6542C03B4.
*
* @param zip the path to the downloaded plugin ZIP
* @param urlString the URL source of the downloade plugin ZIP
* @throws IOException if an I/O exception occurs reading from various input streams
* @throws PGPException if the PGP implementation throws an internal exception during verification
*/
void verifySignature(final Path zip, final String urlString) throws IOException, PGPException {
final String sigUrlString = urlString + ".sig";
final URL sigUrl = openUrl(sigUrlString);
try (
// fin is a file stream over the downloaded plugin zip whose signature to verify
InputStream fin = pluginZipInputStream(zip);
// sin is a URL stream to the signature corresponding to the downloaded plugin zip
InputStream sin = urlOpenStream(sigUrl);
// ain is a input stream to the public key in ASCII-Armor format (RFC4880)
InputStream ain = new ArmoredInputStream(getPublicKey())
) {
final JcaPGPObjectFactory factory = new JcaPGPObjectFactory(PGPUtil.getDecoderStream(sin));
final PGPSignature signature = ((PGPSignatureList) factory.nextObject()).get(0);
// validate the signature has key ID matching our public key ID
final String keyId = Long.toHexString(signature.getKeyID()).toUpperCase(Locale.ROOT);
if (getPublicKeyId().equals(keyId) == false) {
throw new IllegalStateException("key id [" + keyId + "] does not match expected key id [" + getPublicKeyId() + "]");
}
// compute the signature of the downloaded plugin zip
final PGPPublicKeyRingCollection collection = new PGPPublicKeyRingCollection(ain, new JcaKeyFingerprintCalculator());
final PGPPublicKey key = collection.getPublicKey(signature.getKeyID());
signature.init(new JcaPGPContentVerifierBuilderProvider().setProvider(new BouncyCastleFipsProvider()), key);
final byte[] buffer = new byte[1024];
int read;
while ((read = fin.read(buffer)) != -1) {
signature.update(buffer, 0, read);
}
// finally we verify the signature of the downloaded plugin zip matches the expected signature
if (signature.verify() == false) {
throw new IllegalStateException("signature verification for [" + urlString + "] failed");
}
}
}
/**
* An input stream to the raw bytes of the plugin ZIP.
*
* @param zip the path to the downloaded plugin ZIP
* @return an input stream to the raw bytes of the plugin ZIP.
* @throws IOException if an I/O exception occurs preparing the input stream
*/
InputStream pluginZipInputStream(final Path zip) throws IOException {
return Files.newInputStream(zip);
}
/**
* Return the public key ID of the signing key that is expected to have signed the official plugin.
*
* @return the public key ID
*/
String getPublicKeyId() {
return "C2EE2AF6542C03B4";
}
/**
* An input stream to the public key of the signing key.
*
* @return an input stream to the public key
*/
InputStream getPublicKey() {
return InstallPluginCommand.class.getResourceAsStream("/public_key.sig");
}
/**
* Creates a URL and opens a connection.
*
* If the URL returns a 404, {@code null} is returned, otherwise the open URL opject is returned.
*/
// pkg private for tests
URL openUrl(String urlString) throws IOException {
URL checksumUrl = new URL(urlString);
HttpURLConnection connection = (HttpURLConnection) checksumUrl.openConnection();
if (connection.getResponseCode() == 404) {
return null;
}
return checksumUrl;
}
private Path unzip(Path zip, Path pluginsDir) throws IOException, UserException {
// unzip plugin to a staging temp dir
final Path target = stagingDirectory(pluginsDir);
pathsToDeleteOnShutdown.add(target);
try (ZipFile zipFile = new ZipFile(zip, "UTF8", true, false)) {
final Enumeration extends ZipArchiveEntry> entries = zipFile.getEntries();
ZipArchiveEntry entry;
byte[] buffer = new byte[8192];
while (entries.hasMoreElements()) {
entry = entries.nextElement();
if (entry.getName().startsWith("opensearch/")) {
throw new UserException(
PLUGIN_MALFORMED,
"This plugin was built with an older plugin structure."
+ " Contact the plugin author to remove the intermediate \"opensearch\" directory within the plugin zip."
);
}
Path targetFile = target.resolve(entry.getName());
// Using the entry name as a path can result in an entry outside of the plugin dir,
// either if the name starts with the root of the filesystem, or it is a relative
// entry like ../whatever. This check attempts to identify both cases by first
// normalizing the path (which removes foo/..) and ensuring the normalized entry
// is still rooted with the target plugin directory.
if (targetFile.normalize().startsWith(target) == false) {
throw new UserException(
PLUGIN_MALFORMED,
"Zip contains entry name '" + entry.getName() + "' resolving outside of plugin directory"
);
}
// be on the safe side: do not rely on that directories are always extracted
// before their children (although this makes sense, but is it guaranteed?)
if (!Files.isSymbolicLink(targetFile.getParent())) {
Files.createDirectories(targetFile.getParent());
}
if (entry.isDirectory() == false) {
// streams will be auto-closed with try-with-resources
try (OutputStream out = Files.newOutputStream(targetFile); InputStream input = zipFile.getInputStream(entry)) {
input.transferTo(out);
}
}
}
} catch (UserException e) {
IOUtils.rm(target);
throw e;
}
Files.delete(zip);
return target;
}
private Path stagingDirectory(Path pluginsDir) throws IOException {
try {
return Files.createTempDirectory(pluginsDir, ".installing-", PosixFilePermissions.asFileAttribute(PLUGIN_DIR_PERMS));
} catch (IllegalArgumentException e) {
// Jimfs throws an IAE where it should throw an UOE
// remove when google/jimfs#30 is integrated into Jimfs
// and the Jimfs test dependency is upgraded to include
// this pull request
final StackTraceElement[] elements = e.getStackTrace();
if (elements.length >= 1
&& elements[0].getClassName().equals("com.google.common.jimfs.AttributeService")
&& elements[0].getMethodName().equals("setAttributeInternal")) {
return stagingDirectoryWithoutPosixPermissions(pluginsDir);
} else {
throw e;
}
} catch (UnsupportedOperationException e) {
return stagingDirectoryWithoutPosixPermissions(pluginsDir);
}
}
private Path stagingDirectoryWithoutPosixPermissions(Path pluginsDir) throws IOException {
return Files.createTempDirectory(pluginsDir, ".installing-");
}
// checking for existing version of the plugin
private void verifyPluginName(Path pluginPath, String pluginName) throws UserException, IOException {
// don't let user install plugin conflicting with module...
// they might be unavoidably in maven central and are packaged up the same way)
if (MODULES.contains(pluginName)) {
throw new UserException(ExitCodes.USAGE, "plugin '" + pluginName + "' cannot be installed as a plugin, it is a system module");
}
// scan all the installed plugins to see if the plugin being installed already exists
// either with the plugin name or a custom folder name
Path destination = PluginHelper.verifyIfPluginExists(pluginPath, pluginName);
if (Files.exists(destination)) {
final String message = String.format(
Locale.ROOT,
"plugin directory [%s] already exists; if you need to update the plugin, " + "uninstall it first using command 'remove %s'",
destination,
pluginName
);
throw new UserException(PLUGIN_EXISTS, message);
}
}
/** Load information about the plugin, and verify it can be installed with no errors. */
private PluginInfo loadPluginInfo(Terminal terminal, Path pluginRoot, Environment env) throws Exception {
final PluginInfo info = PluginInfo.readFromProperties(pluginRoot);
if (info.hasNativeController()) {
throw new IllegalStateException("plugins can not have native controllers");
}
PluginsService.verifyCompatibility(info);
// checking for existing version of the plugin
verifyPluginName(env.pluginsDir(), info.getName());
PluginsService.checkForFailedPluginRemovals(env.pluginsDir());
terminal.println(VERBOSE, info.toString());
// check for jar hell before any copying
jarHellCheck(info, pluginRoot, env.pluginsDir(), env.modulesDir());
return info;
}
private static final String LIB_TOOLS_PLUGIN_CLI_CLASSPATH_JAR;
static {
LIB_TOOLS_PLUGIN_CLI_CLASSPATH_JAR = String.format(Locale.ROOT, ".+%1$slib%1$stools%1$splugin-cli%1$s[^%1$s]+\\.jar", "(/|\\\\)");
}
/** check a candidate plugin for jar hell before installing it */
void jarHellCheck(PluginInfo candidateInfo, Path candidateDir, Path pluginsDir, Path modulesDir) throws Exception {
// create list of current jars in classpath
final Set