소스 검색

Support IMDSv2 for EC2 Discovery (#84410)

Support a session-oriented method (IMDSv2) for accessing the instance's metadata. If it's not supported, fallback to IMDSv1.

[AWS Docs](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html) 

Resolves #80398
Artem Prigoda 3 년 전
부모
커밋
892d2a2a94

+ 6 - 0
docs/changelog/84410.yaml

@@ -0,0 +1,6 @@
+pr: 84410
+summary: Support IMDSv2 for EC2 Discovery
+area: Discovery-Plugins
+type: enhancement
+issues:
+ - 80398

+ 13 - 7
plugins/discovery-ec2/qa/amazon-ec2/src/yamlRestTest/java/org/elasticsearch/discovery/ec2/AmazonEC2Fixture.java

@@ -21,6 +21,7 @@ import org.elasticsearch.test.fixture.AbstractHttpFixture;
 
 import java.io.IOException;
 import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -42,6 +43,9 @@ import static java.nio.charset.StandardCharsets.UTF_8;
  */
 public class AmazonEC2Fixture extends AbstractHttpFixture {
 
+    private static final String IMDSV_2_TOKEN = "imdsv2-token";
+    private static final String X_AWS_EC_2_METADATA_TOKEN = "X-aws-ec2-metadata-token";
+
     private final Path nodes;
     private final boolean instanceProfile;
     private final boolean containerCredentials;
@@ -87,28 +91,30 @@ public class AmazonEC2Fixture extends AbstractHttpFixture {
                 return new Response(RestStatus.OK.getStatus(), contentType("text/xml; charset=UTF-8"), responseBody);
             }
         }
-        if ("/latest/meta-data/local-ipv4".equals(request.getPath()) && (HttpGet.METHOD_NAME.equals(request.getMethod()))) {
+        if ("/latest/meta-data/local-ipv4".equals(request.getPath())
+            && (HttpGet.METHOD_NAME.equals(request.getMethod()))
+            && request.getHeaders().getOrDefault(X_AWS_EC_2_METADATA_TOKEN, "").equals(IMDSV_2_TOKEN)) {
             return new Response(RestStatus.OK.getStatus(), TEXT_PLAIN_CONTENT_TYPE, "127.0.0.1".getBytes(UTF_8));
         }
 
         if (instanceProfile
             && "/latest/meta-data/iam/security-credentials/".equals(request.getPath())
-            && HttpGet.METHOD_NAME.equals(request.getMethod())) {
+            && HttpGet.METHOD_NAME.equals(request.getMethod())
+            && request.getHeaders().getOrDefault(X_AWS_EC_2_METADATA_TOKEN, "").equals(IMDSV_2_TOKEN)) {
             final Map<String, String> headers = new HashMap<>(contentType("text/plain"));
             return new Response(RestStatus.OK.getStatus(), headers, "my_iam_profile".getBytes(UTF_8));
         }
 
-        if (instanceProfile && "/latest/api/token".equals(request.getPath()) && HttpPut.METHOD_NAME.equals(request.getMethod())) {
-            // TODO: Implement IMDSv2 behavior here. For now this just returns a 403 which makes the SDK fall back to IMDSv1
-            // which is implemented in this fixture
-            return new Response(RestStatus.FORBIDDEN.getStatus(), TEXT_PLAIN_CONTENT_TYPE, EMPTY_BYTE);
+        if ("/latest/api/token".equals(request.getPath()) && HttpPut.METHOD_NAME.equals(request.getMethod())) {
+            return new Response(RestStatus.OK.getStatus(), TEXT_PLAIN_CONTENT_TYPE, IMDSV_2_TOKEN.getBytes(StandardCharsets.UTF_8));
         }
 
         if ((containerCredentials
             && "/ecs_credentials_endpoint".equals(request.getPath())
             && HttpGet.METHOD_NAME.equals(request.getMethod()))
             || ("/latest/meta-data/iam/security-credentials/my_iam_profile".equals(request.getPath())
-                && HttpGet.METHOD_NAME.equals(request.getMethod()))) {
+                && HttpGet.METHOD_NAME.equals(request.getMethod())
+                && request.getHeaders().getOrDefault(X_AWS_EC_2_METADATA_TOKEN, "").equals(IMDSV_2_TOKEN))) {
             final Date expiration = new Date(new Date().getTime() + TimeUnit.DAYS.toMillis(1));
             final String response = """
                 {

+ 59 - 0
plugins/discovery-ec2/src/main/java/org/elasticsearch/discovery/ec2/AwsEc2Utils.java

@@ -0,0 +1,59 @@
+/*
+ * 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.discovery.ec2;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.core.SuppressForbidden;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+
+class AwsEc2Utils {
+
+    private static final Logger logger = LogManager.getLogger(AwsEc2Utils.class);
+    private static final int CONNECT_TIMEOUT = 2000;
+    private static final int METADATA_TOKEN_TTL_SECONDS = 10;
+    static final String X_AWS_EC_2_METADATA_TOKEN = "X-aws-ec2-metadata-token";
+
+    @SuppressForbidden(reason = "We call getInputStream in doPrivileged and provide SocketPermission")
+    static Optional<String> getMetadataToken(String metadataTokenUrl) {
+        if (Strings.isNullOrEmpty(metadataTokenUrl)) {
+            return Optional.empty();
+        }
+        // Gets a new IMDSv2 token https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html
+        return SocketAccess.doPrivileged(() -> {
+            HttpURLConnection urlConnection;
+            try {
+                urlConnection = (HttpURLConnection) new URL(metadataTokenUrl).openConnection();
+                urlConnection.setRequestMethod("PUT");
+                urlConnection.setConnectTimeout(CONNECT_TIMEOUT);
+                urlConnection.setRequestProperty("X-aws-ec2-metadata-token-ttl-seconds", String.valueOf(METADATA_TOKEN_TTL_SECONDS));
+            } catch (IOException e) {
+                logger.warn("Unable to access the IMDSv2 URI: " + metadataTokenUrl, e);
+                return Optional.empty();
+            }
+            try (
+                var in = urlConnection.getInputStream();
+                var reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))
+            ) {
+                return Optional.ofNullable(reader.readLine()).filter(s -> s.isBlank() == false);
+            } catch (IOException e) {
+                logger.warn("Unable to get a session token from IMDSv2 URI: " + metadataTokenUrl, e);
+                return Optional.empty();
+            }
+        });
+    }
+}

+ 7 - 2
plugins/discovery-ec2/src/main/java/org/elasticsearch/discovery/ec2/Ec2DiscoveryPlugin.java

@@ -41,6 +41,8 @@ import java.util.List;
 import java.util.Map;
 import java.util.function.Supplier;
 
+import static org.elasticsearch.discovery.ec2.AwsEc2Utils.X_AWS_EC_2_METADATA_TOKEN;
+
 public class Ec2DiscoveryPlugin extends Plugin implements DiscoveryPlugin, ReloadablePlugin {
 
     private static final Logger logger = LogManager.getLogger(Ec2DiscoveryPlugin.class);
@@ -122,13 +124,14 @@ public class Ec2DiscoveryPlugin extends Plugin implements DiscoveryPlugin, Reloa
         // Adds a node attribute for the ec2 availability zone
         final String azMetadataUrl = EC2MetadataUtils.getHostAddressForEC2MetadataService()
             + "/latest/meta-data/placement/availability-zone";
-        builder.put(getAvailabilityZoneNodeAttributes(settings, azMetadataUrl));
+        String azMetadataTokenUrl = EC2MetadataUtils.getHostAddressForEC2MetadataService() + "/latest/api/token";
+        builder.put(getAvailabilityZoneNodeAttributes(settings, azMetadataUrl, azMetadataTokenUrl));
         return builder.build();
     }
 
     // pkg private for testing
     @SuppressForbidden(reason = "We call getInputStream in doPrivileged and provide SocketPermission")
-    static Settings getAvailabilityZoneNodeAttributes(Settings settings, String azMetadataUrl) {
+    static Settings getAvailabilityZoneNodeAttributes(Settings settings, String azMetadataUrl, String azMetadataTokenUrl) {
         if (AwsEc2Service.AUTO_ATTRIBUTE_SETTING.get(settings) == false) {
             return Settings.EMPTY;
         }
@@ -141,6 +144,8 @@ public class Ec2DiscoveryPlugin extends Plugin implements DiscoveryPlugin, Reloa
             logger.debug("obtaining ec2 [placement/availability-zone] from ec2 meta-data url {}", url);
             urlConnection = SocketAccess.doPrivilegedIOException(url::openConnection);
             urlConnection.setConnectTimeout(2000);
+            AwsEc2Utils.getMetadataToken(azMetadataTokenUrl)
+                .ifPresent(token -> urlConnection.setRequestProperty(X_AWS_EC_2_METADATA_TOKEN, token));
         } catch (final IOException e) {
             // should not happen, we know the url is not malformed, and openConnection does not actually hit network
             throw new UncheckedIOException(e);

+ 6 - 0
plugins/discovery-ec2/src/main/java/org/elasticsearch/discovery/ec2/Ec2NameResolver.java

@@ -25,6 +25,8 @@ import java.net.URL;
 import java.net.URLConnection;
 import java.nio.charset.StandardCharsets;
 
+import static org.elasticsearch.discovery.ec2.AwsEc2Utils.X_AWS_EC_2_METADATA_TOKEN;
+
 /**
  * Resolves certain ec2 related 'meta' hostnames into an actual hostname
  * obtained from ec2 meta-data.
@@ -81,11 +83,15 @@ class Ec2NameResolver implements CustomNameResolver {
     public InetAddress[] resolve(Ec2HostnameType type) throws IOException {
         InputStream in = null;
         String metadataUrl = EC2MetadataUtils.getHostAddressForEC2MetadataService() + "/latest/meta-data/" + type.ec2Name;
+        String metadataTokenUrl = EC2MetadataUtils.getHostAddressForEC2MetadataService() + "/latest/api/token";
         try {
             URL url = new URL(metadataUrl);
             logger.debug("obtaining ec2 hostname from ec2 meta-data url {}", url);
             URLConnection urlConnection = SocketAccess.doPrivilegedIOException(url::openConnection);
             urlConnection.setConnectTimeout(2000);
+            AwsEc2Utils.getMetadataToken(metadataTokenUrl)
+                .ifPresent(token -> urlConnection.setRequestProperty(X_AWS_EC_2_METADATA_TOKEN, token));
+
             in = SocketAccess.doPrivilegedIOException(urlConnection::getInputStream);
             BufferedReader urlReader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
 

+ 117 - 20
plugins/discovery-ec2/src/test/java/org/elasticsearch/discovery/ec2/Ec2DiscoveryPluginTests.java

@@ -15,30 +15,36 @@ import com.amazonaws.auth.BasicAWSCredentials;
 import com.amazonaws.auth.BasicSessionCredentials;
 import com.amazonaws.services.ec2.AbstractAmazonEC2;
 import com.amazonaws.services.ec2.AmazonEC2;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
 
 import org.elasticsearch.common.settings.MockSecureSettings;
 import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.core.SuppressForbidden;
+import org.elasticsearch.mocksocket.MockHttpServer;
 import org.elasticsearch.node.Node;
 import org.elasticsearch.test.ESTestCase;
 
 import java.io.IOException;
 import java.io.UncheckedIOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Arrays;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
 
 import static org.hamcrest.Matchers.instanceOf;
 import static org.hamcrest.Matchers.is;
 
+@SuppressForbidden(reason = "Uses an HttpServer to emulate the Instance Metadata Service")
 public class Ec2DiscoveryPluginTests extends ESTestCase {
 
-    private Settings getNodeAttributes(Settings settings, String url) {
+    private Settings getNodeAttributes(Settings settings, String url, String tokenUrl) {
         final Settings realSettings = Settings.builder().put(AwsEc2Service.AUTO_ATTRIBUTE_SETTING.getKey(), true).put(settings).build();
-        return Ec2DiscoveryPlugin.getAvailabilityZoneNodeAttributes(realSettings, url);
+        return Ec2DiscoveryPlugin.getAvailabilityZoneNodeAttributes(realSettings, url, tokenUrl);
     }
 
-    private void assertNodeAttributes(Settings settings, String url, String expected) {
-        final Settings additional = getNodeAttributes(settings, url);
+    private void assertNodeAttributes(Settings settings, String url, String tokenUrl, String expected) {
+        final Settings additional = getNodeAttributes(settings, url, tokenUrl);
         if (expected == null) {
             assertTrue(additional.isEmpty());
         } else {
@@ -48,34 +54,82 @@ public class Ec2DiscoveryPluginTests extends ESTestCase {
 
     public void testNodeAttributesDisabled() {
         final Settings settings = Settings.builder().put(AwsEc2Service.AUTO_ATTRIBUTE_SETTING.getKey(), false).build();
-        assertNodeAttributes(settings, "bogus", null);
+        assertNodeAttributes(settings, "bogus", "", null);
     }
 
     public void testNodeAttributes() throws Exception {
-        final Path zoneUrl = createTempFile();
-        Files.write(zoneUrl, Arrays.asList("us-east-1c"));
-        assertNodeAttributes(Settings.EMPTY, zoneUrl.toUri().toURL().toString(), "us-east-1c");
+        try (var metadataServer = metadataServerWithoutToken()) {
+            assertNodeAttributes(Settings.EMPTY, metadataServer.metadataUri(), "", "us-east-1c");
+        }
     }
 
     public void testNodeAttributesBogusUrl() {
-        final UncheckedIOException e = expectThrows(UncheckedIOException.class, () -> getNodeAttributes(Settings.EMPTY, "bogus"));
+        final UncheckedIOException e = expectThrows(UncheckedIOException.class, () -> getNodeAttributes(Settings.EMPTY, "bogus", ""));
         assertNotNull(e.getCause());
         final String msg = e.getCause().getMessage();
         assertTrue(msg, msg.contains("no protocol: bogus"));
     }
 
     public void testNodeAttributesEmpty() throws Exception {
-        final Path zoneUrl = createTempFile();
-        final IllegalStateException e = expectThrows(
-            IllegalStateException.class,
-            () -> getNodeAttributes(Settings.EMPTY, zoneUrl.toUri().toURL().toString())
-        );
-        assertTrue(e.getMessage(), e.getMessage().contains("no ec2 metadata returned"));
+        try (MetadataServer metadataServer = new MetadataServer("/metadata", exchange -> {
+            exchange.sendResponseHeaders(200, -1);
+            exchange.close();
+        })) {
+            final IllegalStateException e = expectThrows(
+                IllegalStateException.class,
+                () -> getNodeAttributes(Settings.EMPTY, metadataServer.metadataUri(), "")
+            );
+            assertTrue(e.getMessage(), e.getMessage().contains("no ec2 metadata returned"));
+        }
     }
 
     public void testNodeAttributesErrorLenient() throws Exception {
-        final Path dne = createTempDir().resolve("dne");
-        assertNodeAttributes(Settings.EMPTY, dne.toUri().toURL().toString(), null);
+        try (var metadataServer = new MetadataServer("/metadata", exchange -> {
+            exchange.sendResponseHeaders(404, -1);
+            exchange.close();
+        })) {
+            assertNodeAttributes(Settings.EMPTY, metadataServer.metadataUri(), "", null);
+        }
+    }
+
+    public void testNodeAttributesWithToken() throws Exception {
+        try (var metadataServer = new MetadataServer("/metadata", exchange -> {
+            assertEquals("imdsv2-token", exchange.getRequestHeaders().getFirst("X-aws-ec2-metadata-token"));
+            exchange.sendResponseHeaders(200, 0);
+            exchange.getResponseBody().write("us-east-1c".getBytes(StandardCharsets.UTF_8));
+            exchange.close();
+        }, "/latest/api/token", exchange -> {
+            assertEquals("PUT", exchange.getRequestMethod());
+            assertEquals("10", exchange.getRequestHeaders().getFirst("X-aws-ec2-metadata-token-ttl-seconds"));
+            exchange.sendResponseHeaders(200, 0);
+            exchange.getResponseBody().write("imdsv2-token".getBytes(StandardCharsets.UTF_8));
+            exchange.close();
+        })) {
+            assertNodeAttributes(Settings.EMPTY, metadataServer.metadataUri(), metadataServer.tokenUri(), "us-east-1c");
+        }
+    }
+
+    public void testTokenMetadataApiIsMisbehaving() throws Exception {
+        try (var metadataServer = new MetadataServer("/metadata", exchange -> {
+            assertNull(exchange.getRequestHeaders().getFirst("X-aws-ec2-metadata-token"));
+            exchange.sendResponseHeaders(200, 0);
+            exchange.getResponseBody().write("us-east-1c".getBytes(StandardCharsets.UTF_8));
+            exchange.close();
+        }, "/latest/api/token", HttpExchange::close)) {
+            assertNodeAttributes(Settings.EMPTY, metadataServer.metadataUri(), metadataServer.tokenUri(), "us-east-1c");
+        }
+    }
+
+    public void testTokenMetadataApiIsNotAvailable() throws Exception {
+        try (var metadataServer = metadataServerWithoutToken()) {
+            assertNodeAttributes(Settings.EMPTY, metadataServer.metadataUri(), metadataServer.tokenUri(), "us-east-1c");
+        }
+    }
+
+    public void testBogusTokenMetadataUrl() throws Exception {
+        try (var metadataServer = metadataServerWithoutToken();) {
+            assertNodeAttributes(Settings.EMPTY, metadataServer.metadataUri(), "bogus", "us-east-1c");
+        }
     }
 
     public void testDefaultEndpoint() throws IOException {
@@ -206,4 +260,47 @@ public class Ec2DiscoveryPluginTests extends ESTestCase {
         @Override
         public void shutdown() {}
     }
+
+    @SuppressForbidden(reason = "Uses an HttpServer to emulate the Instance Metadata Service")
+    private static MetadataServer metadataServerWithoutToken() throws IOException {
+        return new MetadataServer("/metadata", exchange -> {
+            assertNull(exchange.getRequestHeaders().getFirst("X-aws-ec2-metadata-token"));
+            exchange.sendResponseHeaders(200, 0);
+            exchange.getResponseBody().write("us-east-1c".getBytes(StandardCharsets.UTF_8));
+            exchange.close();
+        });
+    }
+
+    @SuppressForbidden(reason = "Uses an HttpServer to emulate the Instance Metadata Service")
+    private static class MetadataServer implements AutoCloseable {
+
+        private final HttpServer httpServer;
+
+        private MetadataServer(String metadataPath, HttpHandler metadataHandler) throws IOException {
+            this(metadataPath, metadataHandler, null, null);
+        }
+
+        private MetadataServer(String metadataPath, HttpHandler metadataHandler, String tokenPath, HttpHandler tokenHandler)
+            throws IOException {
+            httpServer = MockHttpServer.createHttp(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+            httpServer.createContext(metadataPath, metadataHandler);
+            if (tokenPath != null && tokenHandler != null) {
+                httpServer.createContext(tokenPath, tokenHandler);
+            }
+            httpServer.start();
+        }
+
+        @Override
+        public void close() throws Exception {
+            httpServer.stop(0);
+        }
+
+        private String metadataUri() {
+            return "http://" + httpServer.getAddress().getHostString() + ":" + httpServer.getAddress().getPort() + "/metadata";
+        }
+
+        private String tokenUri() {
+            return "http://" + httpServer.getAddress().getHostString() + ":" + httpServer.getAddress().getPort() + "/latest/api/token";
+        }
+    }
 }