Skip to content
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

feat(credential-provider-imds): support IMDS for IPv6 endpoints #2660

Merged
merged 16 commits into from
Aug 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions packages/credential-provider-imds/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
},
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/node-config-provider": "3.25.0",
"@aws-sdk/property-provider": "3.25.0",
"@aws-sdk/types": "3.25.0",
"@aws-sdk/url-parser": "3.25.0",
"tslib": "^2.3.0"
},
"devDependencies": {
Expand Down
4 changes: 4 additions & 0 deletions packages/credential-provider-imds/src/config/Endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum Endpoint {
IPv4 = "http://169.254.169.254",
IPv6 = "http://[fd00:ec2::254]",
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { CONFIG_ENDPOINT_NAME, ENDPOINT_CONFIG_OPTIONS, ENV_ENDPOINT_NAME } from "./EndpointConfigOptions";

describe("ENDPOINT_CONFIG_OPTIONS", () => {
describe("environmentVariableSelector", () => {
const { environmentVariableSelector } = ENDPOINT_CONFIG_OPTIONS;
it.each([undefined, "mockEndpoint"])(`when env[${ENV_ENDPOINT_NAME}]: %s`, (mockEndpoint) => {
expect(environmentVariableSelector({ [ENV_ENDPOINT_NAME]: mockEndpoint })).toBe(mockEndpoint);
});
});

describe("configFileSelector", () => {
const { configFileSelector } = ENDPOINT_CONFIG_OPTIONS;
it.each([undefined, "mockEndpoint"])(`when env[${CONFIG_ENDPOINT_NAME}]: %s`, (mockEndpoint) => {
expect(configFileSelector({ [CONFIG_ENDPOINT_NAME]: mockEndpoint })).toBe(mockEndpoint);
});
});

it("default returns undefined", () => {
const { default: defaultKey } = ENDPOINT_CONFIG_OPTIONS;
expect(defaultKey).toBe(undefined);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { LoadedConfigSelectors } from "@aws-sdk/node-config-provider";

export const ENV_ENDPOINT_NAME = "AWS_EC2_METADATA_SERVICE_ENDPOINT";
export const CONFIG_ENDPOINT_NAME = "ec2_metadata_service_endpoint";

export const ENDPOINT_CONFIG_OPTIONS: LoadedConfigSelectors<string | undefined> = {
environmentVariableSelector: (env) => env[ENV_ENDPOINT_NAME],
configFileSelector: (profile) => profile[CONFIG_ENDPOINT_NAME],
default: undefined,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum EndpointMode {
IPv4 = "IPv4",
IPv6 = "IPv6",
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { EndpointMode } from "./EndpointMode";
import {
CONFIG_ENDPOINT_MODE_NAME,
ENDPOINT_MODE_CONFIG_OPTIONS,
ENV_ENDPOINT_MODE_NAME,
} from "./EndpointModeConfigOptions";

describe("ENDPOINT_MODE_CONFIG_OPTIONS", () => {
describe("environmentVariableSelector", () => {
const { environmentVariableSelector } = ENDPOINT_MODE_CONFIG_OPTIONS;
it.each([undefined, "mockEndpointMode"])(`when env[${ENV_ENDPOINT_MODE_NAME}]: %s`, (mockEndpoint) => {
expect(environmentVariableSelector({ [ENV_ENDPOINT_MODE_NAME]: mockEndpoint })).toBe(mockEndpoint);
});
});

describe("configFileSelector", () => {
const { configFileSelector } = ENDPOINT_MODE_CONFIG_OPTIONS;
it.each([undefined, "mockEndpointMode"])(`when env[${CONFIG_ENDPOINT_MODE_NAME}]: %s`, (mockEndpoint) => {
expect(configFileSelector({ [CONFIG_ENDPOINT_MODE_NAME]: mockEndpoint })).toBe(mockEndpoint);
});
});

it(`default returns ${EndpointMode.IPv4}`, () => {
const { default: defaultKey } = ENDPOINT_MODE_CONFIG_OPTIONS;
expect(defaultKey).toBe(EndpointMode.IPv4);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { LoadedConfigSelectors } from "@aws-sdk/node-config-provider";

import { EndpointMode } from "./EndpointMode";

export const ENV_ENDPOINT_MODE_NAME = "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE";
export const CONFIG_ENDPOINT_MODE_NAME = "ec2_metadata_service_endpoint_mode";

export const ENDPOINT_MODE_CONFIG_OPTIONS: LoadedConfigSelectors<string | undefined> = {
environmentVariableSelector: (env) => env[ENV_ENDPOINT_MODE_NAME],
configFileSelector: (profile) => profile[CONFIG_ENDPOINT_MODE_NAME],
default: EndpointMode.IPv4,
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,23 @@ import { httpRequest } from "./remoteProvider/httpRequest";
import { fromImdsCredentials, isImdsCredentials } from "./remoteProvider/ImdsCredentials";
import { providerConfigFromInit } from "./remoteProvider/RemoteProviderInit";
import { retry } from "./remoteProvider/retry";
import { getInstanceMetadataEndpoint } from "./utils/getInstanceMetadataEndpoint";

jest.mock("./remoteProvider/httpRequest");
jest.mock("./remoteProvider/ImdsCredentials");
jest.mock("./remoteProvider/retry");
jest.mock("./remoteProvider/RemoteProviderInit");
jest.mock("./utils/getInstanceMetadataEndpoint");

describe("fromInstanceMetadata", () => {
const host = "169.254.169.254";
const hostname = "127.0.0.1";
const mockTimeout = 1000;
const mockMaxRetries = 3;
const mockToken = "fooToken";
const mockProfile = "fooProfile";

const mockTokenRequestOptions = {
host,
hostname,
path: "/latest/api/token",
method: "PUT",
headers: {
Expand All @@ -29,7 +31,7 @@ describe("fromInstanceMetadata", () => {
};

const mockProfileRequestOptions = {
host,
hostname,
path: "/latest/meta-data/iam/security-credentials/",
timeout: mockTimeout,
headers: {
Expand All @@ -52,6 +54,7 @@ describe("fromInstanceMetadata", () => {
});

beforeEach(() => {
(getInstanceMetadataEndpoint as jest.Mock).mockResolvedValue({ hostname });
(isImdsCredentials as unknown as jest.Mock).mockReturnValue(true);
(providerConfigFromInit as jest.Mock).mockReturnValue({
timeout: mockTimeout,
Expand Down
17 changes: 8 additions & 9 deletions packages/credential-provider-imds/src/fromInstanceMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { httpRequest } from "./remoteProvider/httpRequest";
import { fromImdsCredentials, isImdsCredentials } from "./remoteProvider/ImdsCredentials";
import { providerConfigFromInit, RemoteProviderInit } from "./remoteProvider/RemoteProviderInit";
import { retry } from "./remoteProvider/retry";
import { getInstanceMetadataEndpoint } from "./utils/getInstanceMetadataEndpoint";

const IMDS_IP = "169.254.169.254";
const IMDS_PATH = "/latest/meta-data/iam/security-credentials/";
const IMDS_TOKEN_PATH = "/latest/api/token";

Expand Down Expand Up @@ -51,12 +51,13 @@ export const fromInstanceMetadata = (init: RemoteProviderInit = {}): CredentialP
};

return async () => {
const endpoint = await getInstanceMetadataEndpoint();
if (disableFetchToken) {
return getCredentials(maxRetries, { timeout });
return getCredentials(maxRetries, { ...endpoint, timeout });
} else {
let token: string;
try {
token = (await getMetadataToken({ timeout })).toString();
token = (await getMetadataToken({ ...endpoint, timeout })).toString();
} catch (error) {
if (error?.statusCode === 400) {
throw Object.assign(error, {
Expand All @@ -65,13 +66,14 @@ export const fromInstanceMetadata = (init: RemoteProviderInit = {}): CredentialP
} else if (error.message === "TimeoutError" || [403, 404, 405].includes(error.statusCode)) {
disableFetchToken = true;
}
return getCredentials(maxRetries, { timeout });
return getCredentials(maxRetries, { ...endpoint, timeout });
}
return getCredentials(maxRetries, {
timeout,
...endpoint,
headers: {
"x-aws-ec2-metadata-token": token,
},
timeout,
});
}
};
Expand All @@ -80,23 +82,20 @@ export const fromInstanceMetadata = (init: RemoteProviderInit = {}): CredentialP
const getMetadataToken = async (options: RequestOptions) =>
httpRequest({
...options,
host: IMDS_IP,
path: IMDS_TOKEN_PATH,
method: "PUT",
headers: {
"x-aws-ec2-metadata-token-ttl-seconds": "21600",
},
});

const getProfile = async (options: RequestOptions) =>
(await httpRequest({ ...options, host: IMDS_IP, path: IMDS_PATH })).toString();
const getProfile = async (options: RequestOptions) => (await httpRequest({ ...options, path: IMDS_PATH })).toString();

const getCredentialsFromProfile = async (profile: string, options: RequestOptions) => {
const credsResponse = JSON.parse(
(
await httpRequest({
...options,
host: IMDS_IP,
path: IMDS_PATH + profile,
})
).toString()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { httpRequest } from "./httpRequest";
describe("httpRequest", () => {
const requestSpy = jest.spyOn(http, "request");
let port: number;
const host = "localhost";
const hostname = "localhost";
const path = "/";

const getOpenPort = async (candidatePort = 4321): Promise<number> => {
Expand All @@ -34,9 +34,9 @@ describe("httpRequest", () => {
describe("returns response", () => {
it("defaults to method GET", async () => {
const expectedResponse = "expectedResponse";
const scope = nock(`http://${host}:${port}`).get(path).reply(200, expectedResponse);
const scope = nock(`http://${hostname}:${port}`).get(path).reply(200, expectedResponse);

const response = await httpRequest({ host, path, port });
const response = await httpRequest({ hostname, path, port });
expect(response.toString()).toStrictEqual(expectedResponse);
expect(requestSpy.mock.results[0].value.socket).toHaveProperty("destroyed", true);

Expand All @@ -46,9 +46,21 @@ describe("httpRequest", () => {
it("uses method passed in options", async () => {
const method = "POST";
const expectedResponse = "expectedResponse";
const scope = nock(`http://${host}:${port}`).post(path).reply(200, expectedResponse);
const scope = nock(`http://${hostname}:${port}`).post(path).reply(200, expectedResponse);

const response = await httpRequest({ host, path, port, method });
const response = await httpRequest({ hostname, path, port, method });
expect(response.toString()).toStrictEqual(expectedResponse);
expect(requestSpy.mock.results[0].value.socket).toHaveProperty("destroyed", true);

scope.done();
});

it("works with IPv6 hostname with encapsulated brackets", async () => {
const expectedResponse = "expectedResponse";
const encapsulatedIPv6Hostname = "[::1]";
const scope = nock(`http://${encapsulatedIPv6Hostname}:${port}`).get(path).reply(200, expectedResponse);

const response = await httpRequest({ hostname: encapsulatedIPv6Hostname, path, port });
expect(response.toString()).toStrictEqual(expectedResponse);
expect(requestSpy.mock.results[0].value.socket).toHaveProperty("destroyed", true);

Expand All @@ -59,9 +71,9 @@ describe("httpRequest", () => {
describe("throws error", () => {
const errorOnStatusCode = async (statusCode: number) => {
it(`statusCode: ${statusCode}`, async () => {
const scope = nock(`http://${host}:${port}`).get(path).reply(statusCode, "continue");
const scope = nock(`http://${hostname}:${port}`).get(path).reply(statusCode, "continue");

await expect(httpRequest({ host, path, port })).rejects.toStrictEqual(
await expect(httpRequest({ hostname, path, port })).rejects.toStrictEqual(
Object.assign(new ProviderError("Error response received from instance metadata service"), { statusCode })
);
expect(requestSpy.mock.results[0].value.socket).toHaveProperty("destroyed", true);
Expand All @@ -71,9 +83,9 @@ describe("httpRequest", () => {
};

it("when request throws error", async () => {
const scope = nock(`http://${host}:${port}`).get(path).replyWithError("error");
const scope = nock(`http://${hostname}:${port}`).get(path).replyWithError("error");

await expect(httpRequest({ host, path, port })).rejects.toStrictEqual(
await expect(httpRequest({ hostname, path, port })).rejects.toStrictEqual(
new ProviderError("Unable to connect to instance metadata service")
);
expect(requestSpy.mock.results[0].value.socket).toHaveProperty("destroyed", true);
Expand All @@ -92,12 +104,12 @@ describe("httpRequest", () => {

it("timeout", async () => {
const timeout = 1000;
const scope = nock(`http://${host}:${port}`)
const scope = nock(`http://${hostname}:${port}`)
.get(path)
.delay(timeout * 2)
.reply(200, "expectedResponse");

await expect(httpRequest({ host, path, port, timeout })).rejects.toStrictEqual(
await expect(httpRequest({ hostname, path, port, timeout })).rejects.toStrictEqual(
new ProviderError("TimeoutError from instance metadata service")
);
expect(requestSpy.mock.results[0].value.socket).toHaveProperty("destroyed", true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import { IncomingMessage, request, RequestOptions } from "http";
*/
export function httpRequest(options: RequestOptions): Promise<Buffer> {
return new Promise((resolve, reject) => {
const req = request({ method: "GET", ...options });
const req = request({
method: "GET",
...options,
// Node.js http module doesn't accept hostname with square brackets
// Refs: https://github.com/nodejs/node/issues/39738
hostname: options.hostname?.replace(/^\[(.+)\]$/, "$1"),
});

req.on("error", (err) => {
reject(Object.assign(new ProviderError("Unable to connect to instance metadata service"), err));
Expand Down
Loading