-
Notifications
You must be signed in to change notification settings - Fork 3.7k
/
awscli-compatible.ts
349 lines (314 loc) · 12.2 KB
/
awscli-compatible.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
import * as child_process from 'child_process';
import * as os from 'os';
import * as path from 'path';
import * as util from 'util';
import * as AWS from 'aws-sdk';
import * as fs from 'fs-extra';
import * as promptly from 'promptly';
import { debug } from '../../logging';
import { PatchedSharedIniFileCredentials } from './aws-sdk-inifile';
import { SharedIniFile } from './sdk_ini_file';
/**
* Behaviors to match AWS CLI
*
* See these links:
*
* https://docs.aws.amazon.com/cli/latest/topic/config-vars.html
* https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html
*/
export class AwsCliCompatible {
/**
* Build an AWS CLI-compatible credential chain provider
*
* This is similar to the default credential provider chain created by the SDK
* except:
*
* 1. Accepts profile argument in the constructor (the SDK must have it prepopulated
* in the environment).
* 2. Conditionally checks EC2 credentials, because checking for EC2
* credentials on a non-EC2 machine may lead to long delays (in the best case)
* or an exception (in the worst case).
* 3. Respects $AWS_SHARED_CREDENTIALS_FILE.
* 4. Respects $AWS_DEFAULT_PROFILE in addition to $AWS_PROFILE.
*/
public static async credentialChain(options: CredentialChainOptions = {}) {
// To match AWS CLI behavior, if a profile is explicitly given using --profile,
// we use that to the exclusion of everything else (note: this does not apply
// to AWS_PROFILE, environment credentials still take precedence over AWS_PROFILE)
if (options.profile) {
await forceSdkToReadConfigIfPresent();
const theProfile = options.profile;
return new AWS.CredentialProviderChain([
() => profileCredentials(theProfile),
() => new AWS.ProcessCredentials({ profile: theProfile }),
]);
}
const implicitProfile = process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE || 'default';
const sources = [
() => new AWS.EnvironmentCredentials('AWS'),
() => new AWS.EnvironmentCredentials('AMAZON'),
];
if (await fs.pathExists(credentialsFileName())) {
// Force reading the `config` file if it exists by setting the appropriate
// environment variable.
await forceSdkToReadConfigIfPresent();
sources.push(() => profileCredentials(implicitProfile));
sources.push(() => new AWS.ProcessCredentials({ profile: implicitProfile }));
}
if (options.containerCreds ?? hasEcsCredentials()) {
sources.push(() => new AWS.ECSCredentials());
} else if (hasWebIdentityCredentials()) {
// else if: we have found WebIdentityCredentials as provided by EKS ServiceAccounts
sources.push(() => new AWS.TokenFileWebIdentityCredentials());
} else if (options.ec2instance ?? await isEc2Instance()) {
// else if: don't get EC2 creds if we should have gotten ECS or EKS creds
// ECS and EKS instances also run on EC2 boxes but the creds represent something different.
// Same behavior as upstream code.
sources.push(() => new AWS.EC2MetadataCredentials());
}
return new AWS.CredentialProviderChain(sources);
function profileCredentials(profileName: string) {
return new PatchedSharedIniFileCredentials({
profile: profileName,
filename: credentialsFileName(),
httpOptions: options.httpOptions,
tokenCodeFn,
});
}
}
/**
* Return the default region in a CLI-compatible way
*
* Mostly copied from node_loader.js, but with the following differences to make it
* AWS CLI compatible:
*
* 1. Takes a profile name as an argument (instead of forcing it to be taken from $AWS_PROFILE).
* This requires having made a copy of the SDK's `SharedIniFile` (the original
* does not take an argument).
* 2. $AWS_DEFAULT_PROFILE and $AWS_DEFAULT_REGION are also respected.
*
* Lambda and CodeBuild set the $AWS_REGION variable.
*/
public static async region(options: RegionOptions = {}): Promise<string> {
const profile = options.profile || process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE || 'default';
// Defaults inside constructor
const toCheck = [
{ filename: credentialsFileName(), profile },
{ isConfig: true, filename: configFileName(), profile },
{ isConfig: true, filename: configFileName(), profile: 'default' },
];
let region = process.env.AWS_REGION || process.env.AMAZON_REGION ||
process.env.AWS_DEFAULT_REGION || process.env.AMAZON_DEFAULT_REGION;
while (!region && toCheck.length > 0) {
const opts = toCheck.shift()!;
if (await fs.pathExists(opts.filename)) {
const configFile = new SharedIniFile(opts);
const section = await configFile.getProfile(opts.profile);
region = section?.region;
}
}
if (!region && (options.ec2instance ?? await isEc2Instance())) {
debug('Looking up AWS region in the EC2 Instance Metadata Service (IMDS).');
const imdsOptions = {
httpOptions: { timeout: 1000, connectTimeout: 1000 }, maxRetries: 2,
};
const metadataService = new AWS.MetadataService(imdsOptions);
let token;
try {
token = await getImdsV2Token(metadataService);
} catch (e) {
debug(`No IMDSv2 token: ${e}`);
}
try {
region = await getRegionFromImds(metadataService, token);
debug(`AWS region from IMDS: ${region}`);
} catch (e) {
debug(`Unable to retrieve AWS region from IMDS: ${e}`);
}
}
if (!region) {
const usedProfile = !profile ? '' : ` (profile: "${profile}")`;
region = 'us-east-1'; // This is what the AWS CLI does
debug(`Unable to determine AWS region from environment or AWS configuration${usedProfile}, defaulting to '${region}'`);
}
return region;
}
}
/**
* Return whether it looks like we'll have ECS credentials available
*/
function hasEcsCredentials(): boolean {
return (AWS.ECSCredentials.prototype as any).isConfiguredForEcsCredentials();
}
/**
* Return whether it looks like we'll have WebIdentityCredentials (that's what EKS uses) available
* No check like hasEcsCredentials available, so have to implement our own.
* @see https://github.com/aws/aws-sdk-js/blob/3ccfd94da07234ae87037f55c138392f38b6881d/lib/credentials/token_file_web_identity_credentials.js#L59
*/
function hasWebIdentityCredentials(): boolean {
return Boolean(process.env.AWS_ROLE_ARN && process.env.AWS_WEB_IDENTITY_TOKEN_FILE);
}
/**
* Return whether we're on an EC2 instance
*/
async function isEc2Instance() {
if (isEc2InstanceCache === undefined) {
debug("Determining if we're on an EC2 instance.");
let instance = false;
if (process.platform === 'win32') {
// https://docs.aws.amazon.com/AWSEC2/latest/WindowsGuide/identify_ec2_instances.html
try {
const result = await util.promisify(child_process.exec)('wmic path win32_computersystemproduct get uuid', { encoding: 'utf-8' });
// output looks like
// UUID
// EC2AE145-D1DC-13B2-94ED-01234ABCDEF
const lines = result.stdout.toString().split('\n');
instance = lines.some(x => matchesRegex(/^ec2/i, x));
} catch (e) {
// Modern machines may not have wmic.exe installed. No reason to fail, just assume it's not an EC2 instance.
debug(`Checking using WMIC failed, assuming NOT an EC2 instance: ${e.message} (pass --ec2creds to force)`);
instance = false;
}
} else {
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/identify_ec2_instances.html
const files: Array<[string, RegExp]> = [
// This recognizes the Xen hypervisor based instances (pre-5th gen)
['/sys/hypervisor/uuid', /^ec2/i],
// This recognizes the new Hypervisor (5th-gen instances and higher)
// Can't use the advertised file '/sys/devices/virtual/dmi/id/product_uuid' because it requires root to read.
// Instead, sys_vendor contains something like 'Amazon EC2'.
['/sys/devices/virtual/dmi/id/sys_vendor', /ec2/i],
];
for (const [file, re] of files) {
if (matchesRegex(re, readIfPossible(file))) {
instance = true;
break;
}
}
}
debug(instance ? 'Looks like an EC2 instance.' : 'Does not look like an EC2 instance.');
isEc2InstanceCache = instance;
}
return isEc2InstanceCache;
}
let isEc2InstanceCache: boolean | undefined = undefined;
/**
* Attempts to get a Instance Metadata Service V2 token
*/
async function getImdsV2Token(metadataService: AWS.MetadataService): Promise<string> {
debug('Attempting to retrieve an IMDSv2 token.');
return new Promise((resolve, reject) => {
metadataService.request(
'/latest/api/token',
{
method: 'PUT',
headers: { 'x-aws-ec2-metadata-token-ttl-seconds': '60' },
},
(err: AWS.AWSError, token: string | undefined) => {
if (err) {
reject(err);
} else if (!token) {
reject(new Error('IMDS did not return a token.'));
} else {
resolve(token);
}
});
});
}
/**
* Attempts to get the region from the Instance Metadata Service
*/
async function getRegionFromImds(metadataService: AWS.MetadataService, token: string | undefined): Promise<string> {
debug('Retrieving the AWS region from the IMDS.');
let options: { method?: string | undefined; headers?: { [key: string]: string; } | undefined; } = {};
if (token) {
options = { headers: { 'x-aws-ec2-metadata-token': token } };
}
return new Promise((resolve, reject) => {
metadataService.request(
'/latest/dynamic/instance-identity/document',
options,
(err: AWS.AWSError, instanceIdentityDocument: string | undefined) => {
if (err) {
reject(err);
} else if (!instanceIdentityDocument) {
reject(new Error('IMDS did not return an Instance Identity Document.'));
} else {
try {
resolve(JSON.parse(instanceIdentityDocument).region);
} catch (e) {
reject(e);
}
}
});
});
}
function homeDir() {
return process.env.HOME || process.env.USERPROFILE
|| (process.env.HOMEPATH ? ((process.env.HOMEDRIVE || 'C:/') + process.env.HOMEPATH) : null) || os.homedir();
}
function credentialsFileName() {
return process.env.AWS_SHARED_CREDENTIALS_FILE || path.join(homeDir(), '.aws', 'credentials');
}
function configFileName() {
return process.env.AWS_CONFIG_FILE || path.join(homeDir(), '.aws', 'config');
}
/**
* Force the JS SDK to honor the ~/.aws/config file (and various settings therein)
*
* For example, there is just *NO* way to do AssumeRole credentials as long as AWS_SDK_LOAD_CONFIG is not set,
* or read credentials from that file.
*
* The SDK crashes if the variable is set but the file does not exist, so conditionally set it.
*/
async function forceSdkToReadConfigIfPresent() {
if (await fs.pathExists(configFileName())) {
process.env.AWS_SDK_LOAD_CONFIG = '1';
}
}
function matchesRegex(re: RegExp, s: string | undefined) {
return s !== undefined && re.exec(s) !== null;
}
/**
* Read a file if it exists, or return undefined
*
* Not async because it is used in the constructor
*/
function readIfPossible(filename: string): string | undefined {
try {
if (!fs.pathExistsSync(filename)) { return undefined; }
return fs.readFileSync(filename, { encoding: 'utf-8' });
} catch (e) {
debug(e);
return undefined;
}
}
export interface CredentialChainOptions {
readonly profile?: string;
readonly ec2instance?: boolean;
readonly containerCreds?: boolean;
readonly httpOptions?: AWS.HTTPOptions;
}
export interface RegionOptions {
readonly profile?: string;
readonly ec2instance?: boolean;
}
/**
* Ask user for MFA token for given serial
*
* Result is send to callback function for SDK to authorize the request
*/
async function tokenCodeFn(serialArn: string, cb: (err?: Error, token?: string) => void): Promise<void> {
debug('Require MFA token for serial ARN', serialArn);
try {
const token: string = await promptly.prompt(`MFA token for ${serialArn}: `, {
trim: true,
default: '',
});
debug('Successfully got MFA token from user');
cb(undefined, token);
} catch (err) {
debug('Failed to get MFA token', err);
cb(err);
}
}