Skip to content

Commit

Permalink
(FACT-3042) retrieve IMDSv2 by default
Browse files Browse the repository at this point in the history
This commit changes the way Facter retrieves
ec2_metadata by favoring IMDSv2 over IMDSv1.

This is achieved by trying to retrieve an AWS token
and add it to the `X-aws-ec2-metadata-token`.
If the token cannot be retrieved, IMDSv1 is used.
  • Loading branch information
gimmyxd committed May 20, 2021
1 parent ddd5bde commit 8c32341
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 24 deletions.
1 change: 1 addition & 0 deletions facter.gemspec
Expand Up @@ -32,6 +32,7 @@ Gem::Specification.new do |spec|
spec.add_development_dependency 'rubocop-rspec', '~> 1.38'
spec.add_development_dependency 'simplecov', '~> 0.17.1'
spec.add_development_dependency 'sys-filesystem', '~> 1.3'
spec.add_development_dependency 'timecop', '~> 0.9'
spec.add_development_dependency 'webmock', '~> 3.12'
spec.add_development_dependency 'yard', '~> 0.9'

Expand Down
9 changes: 8 additions & 1 deletion lib/facter/resolvers/ec2.rb
Expand Up @@ -51,14 +51,21 @@ def build_path_component(line)

def get_data_from(url)
headers = {}
headers['X-aws-ec2-metadata-token'] = Facter::Util::Resolvers::AwsToken.get if ENV['AWS_IMDSv2']
headers['X-aws-ec2-metadata-token'] = v2_token
Facter::Util::Resolvers::Http.get_request(url, headers, { session: determine_session_timeout })
end

def determine_session_timeout
session_env = ENV['EC2_SESSION_TIMEOUT']
session_env ? session_env.to_i : EC2_SESSION_TIMEOUT
end

def v2_token
@v2_token ||= begin
token = Facter::Util::Resolvers::AwsToken.get
token == '' ? nil : token
end
end
end
end
end
Expand Down
130 changes: 108 additions & 22 deletions spec/facter/resolvers/ec2_spec.rb
Expand Up @@ -3,48 +3,134 @@
describe Facter::Resolvers::Ec2 do
subject(:ec2) { Facter::Resolvers::Ec2 }

let(:uri) { 'http://169.254.169.254/latest/meta-data/' }
let(:userdata_uri) { 'http://169.254.169.254/latest/user-data/' }
let(:base_uri) { 'http://169.254.169.254/latest' }
let(:userdata_uri) { "#{base_uri}/user-data/" }
let(:metadata_uri) { "#{base_uri}/meta-data/" }
let(:token_uri) { "#{base_uri}/api/token" }
let(:log_spy) { instance_spy(Facter::Log) }

before do
allow(Facter::Util::Resolvers::Http).to receive(:get_request).with(uri, {}, { session: 5 }).and_return(output)
allow(Facter::Util::Resolvers::Http).to receive(:get_request).with(userdata_uri, {}, { session: 5 }).and_return('')
ec2.instance_variable_set(:@log, log_spy)
Facter::Util::Resolvers::Http.instance_variable_set(:@log, log_spy)
end

after do
ec2.invalidate_cache
Facter::Util::Resolvers::AwsToken.reset
Facter::Util::Resolvers::Http.instance_variable_set(:@log, nil)
end

context 'when no exception is thrown' do
let(:output) { "security-credentials/\nami-id" }
let(:ami_uri) { 'http://169.254.169.254/latest/meta-data/ami-id' }
let(:ami_id) { 'some_id_123' }
shared_examples_for 'ec2' do
let(:paths) do
{
'meta-data/' => "instance_type\nami_id\nsecurity-groups",
'meta-data/instance_type' => 'c1.medium',
'meta-data/ami_id' => 'ami-5d2dc934',
'meta-data/security-groups' => "group1\ngroup2"
}
end

before do
allow(Facter::Util::Resolvers::Http).to receive(:get_request)
.with(ami_uri, {}, { session: 5 }).and_return(ami_id)
stub_request(:get, userdata_uri).with(headers: headers).to_return(status: 200, body: 'userdata')
paths.each_pair do |path, body|
stub_request(:get, "#{base_uri}/#{path}").with(headers: headers).to_return(status: 200, body: body)
end
end

it 'returns ec2 metadata' do
expect(ec2.resolve(:metadata)).to eq({ 'ami-id' => 'some_id_123' })
context 'with common metadata paths' do
it 'recursively fetches all the ec2 metadata' do
expect(ec2.resolve(:metadata)).to match(
{
'instance_type' => 'c1.medium',
'ami_id' => 'ami-5d2dc934',
'security-groups' => "group1\ngroup2"
}
)
end

it 'returns userdata' do
expect(ec2.resolve(:userdata)).to eql('userdata')
end

it 'parses ec2 network/ directory as a multi-level hash' do
network_hash = {
'network' => {
'interfaces' => {
'macs' => {
'12:34:56:78:9a:bc' => {
'accountId' => '41234'
}
}
}
}
}
stub_request(:get, metadata_uri).with(headers: headers).to_return(status: 200, body: 'network/')
stub_request(:get, "#{metadata_uri}network/")
.with(headers: headers)
.to_return(status: 200, body: 'interfaces/')
stub_request(:get, "#{metadata_uri}network/interfaces/")
.with(headers: headers)
.to_return(status: 200, body: 'macs/')
stub_request(:get, "#{metadata_uri}network/interfaces/macs/")
.with(headers: headers)
.to_return(status: 200, body: '12:34:56:78:9a:bc/')
stub_request(:get, "#{metadata_uri}network/interfaces/macs/12:34:56:78:9a:bc/")
.with(headers: headers)
.to_return(status: 200, body: 'accountId')
stub_request(:get, "#{metadata_uri}network/interfaces/macs/12:34:56:78:9a:bc/accountId")
.with(headers: headers)
.to_return(status: 200, body: '41234')

expect(ec2.resolve(:metadata)).to match(hash_including(network_hash))
end

it 'fetches the available data' do
stub_request(:get, "#{metadata_uri}instance_type").with(headers: headers).to_return(status: 404)

expect(ec2.resolve(:metadata)).to match(
{
'instance_type' => '',
'ami_id' => 'ami-5d2dc934',
'security-groups' => "group1\ngroup2"
}
)
end
end

it 'returns empty ec2 userdata as response code is not 200' do
expect(ec2.resolve(:userdata)).to eq('')
context 'when an exception is thrown' do
before do
stub_request(:get, userdata_uri).to_raise(StandardError)
stub_request(:get, metadata_uri).to_raise(StandardError)
end

it 'returns empty ec2 metadata' do
expect(ec2.resolve(:metadata)).to eql({})
end

it 'returns empty ec2 userdata' do
expect(ec2.resolve(:userdata)).to eql('')
end
end
end

context 'when an exception is thrown' do
let(:output) { 'security-credentials/' }

it 'returns empty ec2 metadata' do
expect(ec2.resolve(:metadata)).to eq({})
context 'when IMDSv2' do
before do
stub_request(:put, token_uri).to_return(status: 200, body: token)
end

it 'returns empty ec2 userdata' do
expect(ec2.resolve(:userdata)).to eq('')
let(:token) { 'v2_token' }
let(:headers) { { 'X-Aws-Ec2-Metadata-Token' => token } }

it_behaves_like 'ec2'
end

context 'when IMDSv1' do
before do
stub_request(:put, token_uri).to_return(status: 404, body: 'Not Found')
end

let(:token) { nil }
let(:headers) { { 'Accept' => '*/*' } }

it_behaves_like 'ec2'
end
end
6 changes: 5 additions & 1 deletion spec/facter/util/resolvers/aws_token_spec.rb
Expand Up @@ -7,6 +7,10 @@
Facter::Util::Resolvers::AwsToken.reset
end

after do
Facter::Util::Resolvers::AwsToken.reset
end

describe '#get' do
it 'calls Facter::Util::Resolvers::Http.put_request' do
allow(Facter::Util::Resolvers::Http).to receive(:put_request)
Expand All @@ -24,7 +28,7 @@
it 'makes a second request if token is expired' do
allow(Facter::Util::Resolvers::Http).to receive(:put_request).and_return('token')
aws_token.get(1)
sleep 1
Timecop.travel(Time.now + 2)
aws_token.get(1)
expect(Facter::Util::Resolvers::Http).to have_received(:put_request).twice
end
Expand Down
1 change: 1 addition & 0 deletions spec/spec_helper.rb
Expand Up @@ -19,6 +19,7 @@
require 'open3'
require 'thor'
require 'fileutils'
require 'timecop'

require_relative '../lib/facter/resolvers/base_resolver'

Expand Down

0 comments on commit 8c32341

Please sign in to comment.