From aee792e155be1ffd48b7e8a2efb2edd678bc5a2d Mon Sep 17 00:00:00 2001 From: sid <48153483+siddsriv@users.noreply.github.com> Date: Mon, 18 Mar 2024 17:01:38 -0400 Subject: [PATCH] feat(ec2-metadata-service): implement utils for ec2 metadata service (imds) (#5796) * feat(ec2-imds-utils): creating dir and interface for metadata service * feat(ec2-metadata-service): adding initial request method and some refactoring * feat(ec2-metadata-service): adding fetchMetadataToken function * fix(ec2-metadata-service): handle direct text responses only * feat(ec2-metadata-service): adding util functions * chore(ec2-metadata-service): fix return format for consistency * fix(ec2-metadata-service): modifications/refactor for util functions and an initial test * fix(ec2-metadata-service): fix fetchMetadataToken path * feat(ec2-metadata-service): adding requestWithToken and fetchImdsJson * chore(ec2-metadata-service): adding tsconfig files * chore(ec2-metadata-service): add index.ts * chore(ec2-metadata-service): rename function to get host * test(ec2-metadata-service): initial e2e test expts * feat(ec2-metadata-service): add withToken flag for request and delete configs that can be imported * test(ec2-metadata-service): built, test to confirm req/resp * chore(ec2-metadata-service): add credential-provider-imds dep * feat(ec2-metadata-service): adding config selectors * fix(ec2-metadata-service): fix typechecking for imdsv1 disable selector config loader * feat(ec2-metadata-service): add IMDSv1 fallback handling * test(ec2-metadata-service): tests for IMDSv1 fallback cases * chore(ec2-metadata-service): add README * chore(ec2-metadata-service): update workspace version --- packages/ec2-metadata-service/LICENSE | 201 ++++++++++++++++++ packages/ec2-metadata-service/README.md | 36 ++++ .../ec2-metadata-service/jest.config.e2e.js | 5 + packages/ec2-metadata-service/jest.config.js | 5 + packages/ec2-metadata-service/package.json | 60 ++++++ .../ec2-metadata-service/src/ConfigLoaders.ts | 61 ++++++ packages/ec2-metadata-service/src/Endpoint.ts | 7 + .../ec2-metadata-service/src/EndpointMode.ts | 7 + .../src/MetadataService.e2e.spec.ts | 97 +++++++++ .../src/MetadataService.ts | 115 ++++++++++ .../src/MetadataServiceOptions.d.ts | 30 +++ packages/ec2-metadata-service/src/index.ts | 1 + .../ec2-metadata-service/tsconfig.cjs.json | 9 + .../ec2-metadata-service/tsconfig.es.json | 10 + .../ec2-metadata-service/tsconfig.types.json | 9 + 15 files changed, 653 insertions(+) create mode 100644 packages/ec2-metadata-service/LICENSE create mode 100644 packages/ec2-metadata-service/README.md create mode 100644 packages/ec2-metadata-service/jest.config.e2e.js create mode 100644 packages/ec2-metadata-service/jest.config.js create mode 100644 packages/ec2-metadata-service/package.json create mode 100644 packages/ec2-metadata-service/src/ConfigLoaders.ts create mode 100644 packages/ec2-metadata-service/src/Endpoint.ts create mode 100644 packages/ec2-metadata-service/src/EndpointMode.ts create mode 100644 packages/ec2-metadata-service/src/MetadataService.e2e.spec.ts create mode 100644 packages/ec2-metadata-service/src/MetadataService.ts create mode 100644 packages/ec2-metadata-service/src/MetadataServiceOptions.d.ts create mode 100644 packages/ec2-metadata-service/src/index.ts create mode 100644 packages/ec2-metadata-service/tsconfig.cjs.json create mode 100644 packages/ec2-metadata-service/tsconfig.es.json create mode 100644 packages/ec2-metadata-service/tsconfig.types.json diff --git a/packages/ec2-metadata-service/LICENSE b/packages/ec2-metadata-service/LICENSE new file mode 100644 index 000000000000..74d4e5c31f2e --- /dev/null +++ b/packages/ec2-metadata-service/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed 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. \ No newline at end of file diff --git a/packages/ec2-metadata-service/README.md b/packages/ec2-metadata-service/README.md new file mode 100644 index 000000000000..23baf67c7ae0 --- /dev/null +++ b/packages/ec2-metadata-service/README.md @@ -0,0 +1,36 @@ +# @aws-sdk/ec2-metadata-service + +[![NPM version](https://img.shields.io/npm/v/@aws-sdk/ec2-metadata-service/latest.svg)](https://www.npmjs.com/package/@aws-sdk/ec2-metadata-service) +[![NPM downloads](https://img.shields.io/npm/dm/@aws-sdk/ec2-metadata-service.svg)](https://www.npmjs.com/package/@aws-sdk/ec2-metadata-service) + +This package provides utils to access [EC2 Instance Metadata Service (IMDS)](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html) from the AWS SDK for JavaScript v3. + +### Usage + +The basic usage of EC2 IMDS utils in the AWS SDK for JavaScript v3 is as follows: + +JavaScript example + +``` +const { MetadataService } = require("@aws-sdk/ec2-metadata-service"); + +const metadataService = new MetadataService({}); +const token = await metadataService.fetchMetadataToken(); // fetches token explicitly +const metadata = await metadataService.request("/latest/meta-data/", {}); // request metadata from IMDSv2 (uses a token to make the request by default if `disableFetchToken` is not set to true) +``` + +ES6 example + +``` +import { MetadataService } from "@aws-sdk/ec2-metadata-service"; + +const metadataService = new MetadataService({}); +const token = await metadataService.fetchMetadataToken(); // fetches token explicitly +const metadata = await metadataService.request("/latest/meta-data/", {}); // request metadata from IMDSv2 (uses a token to make the request by default if `disableFetchToken` is not set to true) +``` + +### Notes + +Note that by default, requests to IMDS are in accordance with [IMDSv2](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html). + +Read more about Instance Metadata here: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html diff --git a/packages/ec2-metadata-service/jest.config.e2e.js b/packages/ec2-metadata-service/jest.config.e2e.js new file mode 100644 index 000000000000..c3aa6055ef75 --- /dev/null +++ b/packages/ec2-metadata-service/jest.config.e2e.js @@ -0,0 +1,5 @@ +module.exports = { + preset: "ts-jest", + testMatch: ["**/*.e2e.spec.ts"], + bail: true, +}; diff --git a/packages/ec2-metadata-service/jest.config.js b/packages/ec2-metadata-service/jest.config.js new file mode 100644 index 000000000000..a8d1c2e49912 --- /dev/null +++ b/packages/ec2-metadata-service/jest.config.js @@ -0,0 +1,5 @@ +const base = require("../../jest.config.base.js"); + +module.exports = { + ...base, +}; diff --git a/packages/ec2-metadata-service/package.json b/packages/ec2-metadata-service/package.json new file mode 100644 index 000000000000..1797851456cf --- /dev/null +++ b/packages/ec2-metadata-service/package.json @@ -0,0 +1,60 @@ +{ + "name": "@aws-sdk/ec2-metadata-service", + "version": "3.495.0", + "scripts": { + "build": "concurrently 'yarn:build:cjs' 'yarn:build:es' 'yarn:build:types'", + "build:cjs": "node ../../scripts/compilation/inline ec2-metadata-service", + "build:es": "tsc -p tsconfig.es.json", + "build:include:deps": "lerna run --scope $npm_package_name --include-dependencies build", + "build:types": "tsc -p tsconfig.types.json", + "build:types:downlevel": "downlevel-dts dist-types dist-types/ts3.4", + "clean": "rimraf ./dist-* && rimraf *.tsbuildinfo", + "test": "jest --passWithNoTests", + "test:e2e": "jest -c jest.config.e2e.js" + }, + "author": { + "name": "AWS SDK for JavaScript Team", + "url": "https://aws.amazon.com/javascript/" + }, + "license": "Apache-2.0", + "main": "./dist-cjs/index.js", + "module": "./dist-es/index.js", + "types": "./dist-types/index.d.ts", + "dependencies": { + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/util-stream": "^2.1.1", + "@aws-sdk/types": "*", + "@aws-sdk/protocol-http": "*", + "@smithy/node-config-provider": "^2.2.1" + }, + "devDependencies": { + "@tsconfig/recommended": "1.0.1", + "@types/node": "^14.14.31", + "concurrently": "7.0.0", + "downlevel-dts": "0.10.1", + "rimraf": "3.0.2", + "typescript": "~4.9.5", + "@aws-sdk/credential-providers": "*" + }, + "engines": { + "node": ">=14.0.0" + }, + "typesVersions": { + "<4.0": { + "dist-types/*": [ + "dist-types/ts3.4/*" + ] + } + }, + "files": [ + "dist-*/**" + ], + "homepage": "https://github.com/aws/aws-sdk-js-v3/tree/main/packages/ec2-metadata-service", + "repository": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-js-v3.git", + "directory": "packages/ec2-metadata-service" + } +} diff --git a/packages/ec2-metadata-service/src/ConfigLoaders.ts b/packages/ec2-metadata-service/src/ConfigLoaders.ts new file mode 100644 index 000000000000..a1cbc1c0e782 --- /dev/null +++ b/packages/ec2-metadata-service/src/ConfigLoaders.ts @@ -0,0 +1,61 @@ +import { LoadedConfigSelectors } from "@smithy/node-config-provider"; + +import { Endpoint } from "./Endpoint"; +import { EndpointMode } from "./EndpointMode"; + +/** + * @internal + */ +export const ENV_ENDPOINT_NAME = "AWS_EC2_METADATA_SERVICE_ENDPOINT"; +/** + * @internal + */ +export const CONFIG_ENDPOINT_NAME = "ec2_metadata_service_endpoint"; + +/** + * @internal + */ +export const ENDPOINT_SELECTORS: LoadedConfigSelectors = { + environmentVariableSelector: (env) => env[ENV_ENDPOINT_NAME], + configFileSelector: (profile) => profile[CONFIG_ENDPOINT_NAME], + default: Endpoint.IPv4, +}; + +/** + * @internal + */ +export const ENV_ENDPOINT_MODE_NAME = "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE"; +/** + * @internal + */ +export const CONFIG_ENDPOINT_MODE_NAME = "ec2_metadata_service_endpoint_mode"; + +/** + * @internal + */ +export const ENDPOINT_MODE_SELECTORS: LoadedConfigSelectors = { + environmentVariableSelector: (env) => env[ENV_ENDPOINT_MODE_NAME], + configFileSelector: (profile) => profile[CONFIG_ENDPOINT_MODE_NAME], + default: EndpointMode.IPv4, +}; + +/** + * @internal + */ +export const AWS_EC2_METADATA_V1_DISABLED = "AWS_EC2_METADATA_V1_DISABLED"; +/** + * @internal + */ +export const PROFILE_AWS_EC2_METADATA_V1_DISABLED = "ec2_metadata_v1_disabled"; +/** + * @internal + */ +export const IMDSv1_DISABLED_SELECTORS: LoadedConfigSelectors = { + environmentVariableSelector: (env) => + env[AWS_EC2_METADATA_V1_DISABLED] ? env[AWS_EC2_METADATA_V1_DISABLED] !== "false" : undefined, + configFileSelector: (profile) => + profile[PROFILE_AWS_EC2_METADATA_V1_DISABLED] + ? profile[PROFILE_AWS_EC2_METADATA_V1_DISABLED] !== "false" + : undefined, + default: false, +}; diff --git a/packages/ec2-metadata-service/src/Endpoint.ts b/packages/ec2-metadata-service/src/Endpoint.ts new file mode 100644 index 000000000000..a02276be6b2d --- /dev/null +++ b/packages/ec2-metadata-service/src/Endpoint.ts @@ -0,0 +1,7 @@ +/** + * @internal + */ +export enum Endpoint { + IPv4 = "http://169.254.169.254", + IPv6 = "http://[fd00:ec2::254]", +} diff --git a/packages/ec2-metadata-service/src/EndpointMode.ts b/packages/ec2-metadata-service/src/EndpointMode.ts new file mode 100644 index 000000000000..695469be9b30 --- /dev/null +++ b/packages/ec2-metadata-service/src/EndpointMode.ts @@ -0,0 +1,7 @@ +/** + * @internal + */ +export enum EndpointMode { + IPv4 = "IPv4", + IPv6 = "IPv6", +} diff --git a/packages/ec2-metadata-service/src/MetadataService.e2e.spec.ts b/packages/ec2-metadata-service/src/MetadataService.e2e.spec.ts new file mode 100644 index 000000000000..1824a4c0e247 --- /dev/null +++ b/packages/ec2-metadata-service/src/MetadataService.e2e.spec.ts @@ -0,0 +1,97 @@ +import { fromInstanceMetadata } from "@aws-sdk/credential-providers"; + +import { MetadataService } from "./MetadataService"; + +describe("MetadataService E2E Tests", () => { + let metadataService; + const provider = fromInstanceMetadata({ timeout: 1000, maxRetries: 0 }); + let metadataServiceAvailable; + + beforeAll(async () => { + try { + await provider(); + metadataServiceAvailable = true; + } catch (err) { + metadataServiceAvailable = false; + } + console.log("Metadata Service availability: ", metadataServiceAvailable); + metadataService = new MetadataService({}); + const config = await metadataService.config; + console.log("IMDS Endpoint: ", config.endpoint); + }); + + it("should fetch metadata token successfully", async () => { + // This test will only pass if run on an EC2 instance or an environment with access to the EC2 Metadata Service + if (!metadataServiceAvailable) { + return; + } + const token = await metadataService.fetchMetadataToken(); + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + expect(token.length).toBeGreaterThan(0); + }); + + it("should fetch metadata successfully with token", async () => { + if (!metadataServiceAvailable) { + return; + } + const metadata = await metadataService.request("/latest/meta-data/", {}); + expect(metadata).toBeDefined(); + expect(typeof metadata).toBe("string"); + const lines = metadata.split("\n").map((line) => line.trim()); + expect(lines.length).toBeGreaterThan(5); + expect(lines).toContain("instance-id"); + expect(lines).toContain("services/"); + }); + + it("should fetch metadata successfully (without token -- disableFetchToken set to true)", async () => { + if (!metadataServiceAvailable) { + return; + } + metadataService.disableFetchToken = true; // make request without token + const metadata = await metadataService.request("/latest/meta-data/", {}); + expect(metadata).toBeDefined(); + expect(typeof metadata).toBe("string"); + expect(metadata.length).toBeGreaterThan(0); + const lines = metadata.split("\n").map((line) => line.trim()); + expect(lines.length).toBeGreaterThan(5); + expect(lines).toContain("instance-id"); + expect(lines).toContain("services/"); + }); + + it("should handle TimeoutError by falling back to IMDSv1", async () => { + if (!metadataServiceAvailable) { + return; + } + jest.spyOn(metadataService, "fetchMetadataToken").mockImplementation(async () => { + throw { name: "TimeoutError" }; // Simulating TimeoutError + }); + // Attempt to fetch metadata, expecting IMDSv1 fallback (request without token) + const metadata = await metadataService.request("/latest/meta-data/", {}); + expect(metadata).toBeDefined(); + expect(typeof metadata).toBe("string"); + const lines = metadata.split("\n").map((line) => line.trim()); + expect(lines.length).toBeGreaterThan(5); + expect(lines).toContain("instance-id"); + expect(lines).toContain("services/"); + }); + + it("should handle specific error codes by falling back to IMDSv1", async () => { + if (!metadataServiceAvailable) { + return; + } + const httpErrors = [403, 404, 405]; + for (const errorCode of httpErrors) { + jest.spyOn(metadataService, "fetchMetadataToken").mockImplementationOnce(async () => { + throw { statusCode: errorCode }; + }); + const metadata = await metadataService.request("/latest/meta-data/", {}); + expect(metadata).toBeDefined(); + expect(typeof metadata).toBe("string"); + const lines = metadata.split("\n").map((line) => line.trim()); + expect(lines.length).toBeGreaterThan(5); + expect(lines).toContain("instance-id"); + expect(lines).toContain("services/"); + } + }); +}); diff --git a/packages/ec2-metadata-service/src/MetadataService.ts b/packages/ec2-metadata-service/src/MetadataService.ts new file mode 100644 index 000000000000..5bb2035cdad8 --- /dev/null +++ b/packages/ec2-metadata-service/src/MetadataService.ts @@ -0,0 +1,115 @@ +import { HttpRequest } from "@aws-sdk/protocol-http"; +import { HttpHandlerOptions } from "@aws-sdk/types"; +import { loadConfig } from "@smithy/node-config-provider"; +import { NodeHttpHandler } from "@smithy/node-http-handler"; +import { sdkStreamMixin } from "@smithy/util-stream"; + +import { ENDPOINT_SELECTORS, IMDSv1_DISABLED_SELECTORS } from "./ConfigLoaders"; +import { MetadataServiceOptions } from "./MetadataServiceOptions"; + +/** + * @public + */ +export class MetadataService { + private disableFetchToken: boolean; + private config: Promise; + /** + * Creates a new MetadataService object with a given set of options. + */ + constructor(options: MetadataServiceOptions = {}) { + this.config = (async () => { + const profile = options?.profile || process.env.AWS_PROFILE; + return { + endpoint: options.endpoint ?? (await loadConfig(ENDPOINT_SELECTORS, { profile })()), + httpOptions: { + timeout: options?.httpOptions?.timeout || 0, + }, + ec2MetadataV1Disabled: + options?.ec2MetadataV1Disabled ?? (await loadConfig(IMDSv1_DISABLED_SELECTORS, { profile })()), + }; + })(); + this.disableFetchToken = options?.disableFetchToken || false; + } + + async request(path: string, options: { method?: string; headers?: Record }): Promise { + const { endpoint, ec2MetadataV1Disabled } = await this.config; + const handler = new NodeHttpHandler(); + const endpointUrl = new URL(endpoint!); + const headers = options.headers || {}; + /** + * If IMDSv1 is disabled and disableFetchToken is true, throw an error + * Refer: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html + */ + if (this.disableFetchToken && ec2MetadataV1Disabled) { + throw new Error("IMDSv1 is disabled and fetching token is disabled, cannot make the request."); + } + /** + * Make request with token if disableFetchToken is not true (IMDSv2). + * Note that making the request call with token will result in an additional request to fetch the token. + */ + if (!this.disableFetchToken) { + try { + headers["x-aws-ec2-metadata-token"] = await this.fetchMetadataToken(); + } catch (err) { + if (ec2MetadataV1Disabled) { + // If IMDSv1 is disabled and token fetch fails (IMDSv2 fails), rethrow the error + throw err; + } + // If token fetch fails and IMDSv1 is not disabled, proceed without token (IMDSv1 fallback) + } + } // else, IMDSv1 fallback mode + const request = new HttpRequest({ + method: options.method || "GET", // Default to GET if no method is specified + headers: headers, + hostname: endpointUrl.hostname, + path: endpointUrl.pathname + path, + protocol: endpointUrl.protocol, + }); + try { + const { response } = await handler.handle(request, {} as HttpHandlerOptions); + if (response.statusCode === 200 && response.body) { + // handle response.body as stream + return sdkStreamMixin(response.body).transformToString(); + } else { + throw new Error(`Request failed with status code ${response.statusCode}`); + } + } catch (error) { + throw new Error(`Error making request to the metadata service: ${error}`); + } + } + + async fetchMetadataToken(): Promise { + /** + * Refer: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-metadata-v2-how-it-works.html + */ + const { endpoint } = await this.config; + const handler = new NodeHttpHandler(); + const endpointUrl = new URL(endpoint!); + const tokenRequest = new HttpRequest({ + method: "PUT", + headers: { + "x-aws-ec2-metadata-token-ttl-seconds": "21600", // 6 hours; + }, + hostname: endpointUrl.hostname, + path: "/latest/api/token", + protocol: endpointUrl.protocol, + }); + try { + const { response } = await handler.handle(tokenRequest, {} as HttpHandlerOptions); + if (response.statusCode === 200 && response.body) { + // handle response.body as a stream + return sdkStreamMixin(response.body).transformToString(); + } else { + throw new Error(`Failed to fetch metadata token with status code ${response.statusCode}`); + } + } catch (error) { + if (error?.statusCode === 400) { + throw new Error(`Error fetching metadata token: ${error}`); + } else if (error.message === "TimeoutError" || [403, 404, 405].includes(error.statusCode)) { + this.disableFetchToken = true; // as per JSv2 and fromInstanceMetadata implementations + throw new Error(`Error fetching metadata token: ${error}. disableFetchToken is enabled`); + } + throw new Error(`Error fetching metadata token: ${error}`); + } + } +} diff --git a/packages/ec2-metadata-service/src/MetadataServiceOptions.d.ts b/packages/ec2-metadata-service/src/MetadataServiceOptions.d.ts new file mode 100644 index 000000000000..49659abc34bb --- /dev/null +++ b/packages/ec2-metadata-service/src/MetadataServiceOptions.d.ts @@ -0,0 +1,30 @@ +/** + * @public + */ +export interface MetadataServiceOptions { + /** + * the endpoint of the instance metadata service. + */ + endpoint?: string; + /** + * a map of options to pass to the underlying HTTP request. + */ + httpOptions?: { + /** + * a timeout value in milliseconds to wait before aborting the connection. Set to 0 for no timeout. + */ + timeout?: number; + }; + /** + * Prevent IMDSv1 fallback. + */ + ec2MetadataV1Disabled?: boolean; + /** + * profile name to check for IMDSv1 settings. + */ + profile?: string; + /** + * when true, metadata service will not fetch token, which indicates usage of IMDSv1 + */ + disableFetchToken?: false; +} diff --git a/packages/ec2-metadata-service/src/index.ts b/packages/ec2-metadata-service/src/index.ts new file mode 100644 index 000000000000..d6326b87399f --- /dev/null +++ b/packages/ec2-metadata-service/src/index.ts @@ -0,0 +1 @@ +export * from "./MetadataService"; diff --git a/packages/ec2-metadata-service/tsconfig.cjs.json b/packages/ec2-metadata-service/tsconfig.cjs.json new file mode 100644 index 000000000000..96198be81644 --- /dev/null +++ b/packages/ec2-metadata-service/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist-cjs", + "rootDir": "src" + }, + "extends": "../../tsconfig.cjs.json", + "include": ["src/"] +} diff --git a/packages/ec2-metadata-service/tsconfig.es.json b/packages/ec2-metadata-service/tsconfig.es.json new file mode 100644 index 000000000000..7f162b266e26 --- /dev/null +++ b/packages/ec2-metadata-service/tsconfig.es.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "lib": [], + "outDir": "dist-es", + "rootDir": "src" + }, + "extends": "../../tsconfig.es.json", + "include": ["src/"] +} diff --git a/packages/ec2-metadata-service/tsconfig.types.json b/packages/ec2-metadata-service/tsconfig.types.json new file mode 100644 index 000000000000..6cdf9f52ea06 --- /dev/null +++ b/packages/ec2-metadata-service/tsconfig.types.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "declarationDir": "dist-types", + "rootDir": "src" + }, + "extends": "../../tsconfig.types.json", + "include": ["src/"] +}