|
7 | 7 | -module(imds). |
8 | 8 |
|
9 | 9 | -export([role_name/0, zone/0, instance_id/0, public_hostname/0, get_session_token/0, |
10 | | - imds_response/3, imds_response/4]). |
| 10 | + imds_response/3, imds_response/4, get_imdsv2_token/0]). |
11 | 11 |
|
12 | 12 | -define(IMDS_HOST, erliam_config:g(imds_host, "169.254.169.254")). |
13 | 13 | -define(IMDS_VERSION, erliam_config:g(imds_version, "latest")). |
14 | | --define(IMDS_TIMEOUT, 30000). |
| 14 | +-define(IMDS_TIMEOUT, erliam_config:g(imds_timeout, 30000)). |
15 | 15 | -define(IMDS_RETRIES, 3). |
| 16 | +-define(IMDS_TOKEN_TTL, erliam_config:g(imds_token_ttl, "21600")). % 6 hours default |
| 17 | +-define(IMDS_USE_V2, erliam_config:g(imds_use_v2, true)). % Use IMDSv2 by default |
16 | 18 |
|
17 | 19 | %%%% API |
18 | 20 |
|
@@ -44,12 +46,40 @@ get_session_token() -> |
44 | 46 | Error |
45 | 47 | end. |
46 | 48 |
|
| 49 | +-spec get_imdsv2_token() -> {ok, string()} | {error, term()}. |
| 50 | +get_imdsv2_token() -> |
| 51 | + %% Get config values at runtime so they can be overridden in tests |
| 52 | + Timeout = erliam_config:g(imds_timeout, 30000), |
| 53 | + Host = erliam_config:g(imds_host, "169.254.169.254"), |
| 54 | + Url = uri_string:normalize(["http://", Host, "/latest/api/token"]), |
| 55 | + RequestHeaders = [{"X-aws-ec2-metadata-token-ttl-seconds", ?IMDS_TOKEN_TTL}], |
| 56 | + case httpc:request(put, |
| 57 | + {Url, RequestHeaders, "", ""}, |
| 58 | + [{timeout, Timeout}, {connect_timeout, Timeout}], |
| 59 | + [{body_format, binary}], |
| 60 | + erliam:httpc_profile()) |
| 61 | + of |
| 62 | + {ok, {{_, 200, _}, _, Body}} -> |
| 63 | + case unicode:characters_to_list(Body) of |
| 64 | + {error, _, _} -> |
| 65 | + {error, invalid_token_unicode}; |
| 66 | + {incomplete, _, _} -> |
| 67 | + {error, invalid_token_unicode}; |
| 68 | + Token -> |
| 69 | + {ok, Token} |
| 70 | + end; |
| 71 | + {ok, {{_, Code, Status}, _, _}} -> |
| 72 | + {error, {bad_token_response, {Code, Status}}}; |
| 73 | + {error, Reason} -> |
| 74 | + {error, Reason} |
| 75 | + end. |
| 76 | + |
47 | 77 | %% Make a GET request to the given URL, expecting (accepting) the given mime types, and |
48 | 78 | %% with the given request timeout in milliseconds. |
49 | 79 | -spec imds_response(string(), [string()], pos_integer()) -> |
50 | 80 | {ok, term()} | {error, term()}. |
51 | 81 | imds_response(Url, MimeTypes, Timeout) -> |
52 | | - RequestHeaders = [{"Accept", string:join(MimeTypes, ", ")}], |
| 82 | + RequestHeaders = build_request_headers(MimeTypes), |
53 | 83 | case httpc:request(get, |
54 | 84 | {Url, RequestHeaders}, |
55 | 85 | [{timeout, Timeout}], |
@@ -88,6 +118,26 @@ imds_response(Url, MimeTypes, Timeout, Retries) -> |
88 | 118 |
|
89 | 119 | %%%% INTERNAL FUNCTIONS |
90 | 120 |
|
| 121 | +%% Build request headers for IMDS requests, including IMDSv2 token if enabled. |
| 122 | +-spec build_request_headers([string()]) -> [{string(), string()}]. |
| 123 | +build_request_headers(MimeTypes) -> |
| 124 | + AcceptHeader = {"Accept", string:join(MimeTypes, ", ")}, |
| 125 | + case ?IMDS_USE_V2 of |
| 126 | + true -> |
| 127 | + case get_imdsv2_token() of |
| 128 | + {ok, Token} -> |
| 129 | + [AcceptHeader, {"X-aws-ec2-metadata-token", Token}]; |
| 130 | + {error, Reason} -> |
| 131 | + %% Log warning but fall back to IMDSv1 |
| 132 | + error_logger:warning_msg("Failed to obtain IMDSv2 token: ~p, " |
| 133 | + "falling back to IMDSv1~n", |
| 134 | + [Reason]), |
| 135 | + [AcceptHeader] |
| 136 | + end; |
| 137 | + false -> |
| 138 | + [AcceptHeader] |
| 139 | + end. |
| 140 | + |
91 | 141 | %% Call the given Transform function with the result of a successful call to |
92 | 142 | %% imds_response/4, or return the error which resulted from that call. |
93 | 143 | -spec imds_transform_response(string(), [string()], function()) -> |
@@ -205,4 +255,99 @@ metadata_response_to_proplist_test() -> |
205 | 255 | || Key <- [expiration, access_key_id, secret_access_key, token]], |
206 | 256 | ok. |
207 | 257 |
|
| 258 | +%% Test that build_request_headers returns only Accept header when IMDSv2 is disabled |
| 259 | +build_request_headers_v1_test() -> |
| 260 | + %% Temporarily disable IMDSv2 for this test |
| 261 | + OldValue = application:get_env(erliam, imds_use_v2, true), |
| 262 | + application:set_env(erliam, imds_use_v2, false), |
| 263 | + try |
| 264 | + MimeTypes = ["text/plain", "application/json"], |
| 265 | + Headers = build_request_headers(MimeTypes), |
| 266 | + %% Should only contain Accept header |
| 267 | + ?assertEqual(1, length(Headers)), |
| 268 | + ?assertEqual({"Accept", "text/plain, application/json"}, hd(Headers)) |
| 269 | + after |
| 270 | + application:set_env(erliam, imds_use_v2, OldValue) |
| 271 | + end. |
| 272 | + |
| 273 | +%% Test that build_request_headers falls back to IMDSv1 when token request fails |
| 274 | +build_request_headers_v2_fallback_test() -> |
| 275 | + with_imdsv2_enabled(fun() -> |
| 276 | + mock_imdsv2_token_failure(), |
| 277 | + try |
| 278 | + Headers = build_request_headers(["text/plain"]), |
| 279 | + ?assertEqual([{"Accept", "text/plain"}], Headers) |
| 280 | + after |
| 281 | + meck:unload(httpc) |
| 282 | + end |
| 283 | + end). |
| 284 | + |
| 285 | +%% Test that build_request_headers includes token header when IMDSv2 succeeds |
| 286 | +build_request_headers_v2_success_test() -> |
| 287 | + with_imdsv2_enabled(fun() -> |
| 288 | + mock_imdsv2_token_success("test-token-12345"), |
| 289 | + try |
| 290 | + Headers = build_request_headers(["text/plain"]), |
| 291 | + ?assertEqual([{"Accept", "text/plain"}, |
| 292 | + {"X-aws-ec2-metadata-token", "test-token-12345"}], |
| 293 | + Headers) |
| 294 | + after |
| 295 | + meck:unload(httpc) |
| 296 | + end |
| 297 | + end). |
| 298 | + |
| 299 | +%% Helper function to run a test with IMDSv2 enabled |
| 300 | +with_imdsv2_enabled(Fun) -> |
| 301 | + OldValue = application:get_env(erliam, imds_use_v2, true), |
| 302 | + application:set_env(erliam, imds_use_v2, true), |
| 303 | + try |
| 304 | + Fun() |
| 305 | + after |
| 306 | + application:set_env(erliam, imds_use_v2, OldValue) |
| 307 | + end. |
| 308 | + |
| 309 | +%% Helper function to mock IMDSv2 token retrieval failure |
| 310 | +mock_imdsv2_token_failure() -> |
| 311 | + meck:new(httpc, [passthrough, unstick]), |
| 312 | + meck:expect(httpc, |
| 313 | + request, |
| 314 | + fun(put, {_Url, _Headers, _, _}, _HTTPOptions, _Options, _Profile) -> |
| 315 | + {error, |
| 316 | + {failed_connect, |
| 317 | + [{to_address, {"169.254.169.254", 80}}, {inet, [inet], econnrefused}]}} |
| 318 | + end). |
| 319 | + |
| 320 | +%% Helper function to mock successful IMDSv2 token retrieval |
| 321 | +mock_imdsv2_token_success(Token) -> |
| 322 | + meck:new(httpc, [passthrough, unstick]), |
| 323 | + meck:expect(httpc, |
| 324 | + request, |
| 325 | + fun(put, {_Url, _Headers, _, _}, _HTTPOptions, _Options, _Profile) -> |
| 326 | + {ok, {{ignore, 200, ignore}, [], list_to_binary(Token)}} |
| 327 | + end). |
| 328 | + |
| 329 | +%% Test that imds_url generates correct URLs |
| 330 | +imds_url_test() -> |
| 331 | + Expected = "http://169.254.169.254/latest/meta-data/instance-id", |
| 332 | + ?assertEqual(Expected, imds_url("instance-id")). |
| 333 | + |
| 334 | +%% Test that token response parsing handles invalid JSON |
| 335 | +get_code_invalid_json_test() -> |
| 336 | + %% jiffy will throw an error for invalid JSON |
| 337 | + %% Our get_code function should catch this and return an error |
| 338 | + Result = |
| 339 | + try get_code(<<"not json">>) of |
| 340 | + Val -> |
| 341 | + Val |
| 342 | + catch |
| 343 | + _:_ -> |
| 344 | + {error, invalid_token_json} |
| 345 | + end, |
| 346 | + ?assertMatch({error, _}, Result). |
| 347 | + |
| 348 | +%% Test that token response parsing handles non-Success codes |
| 349 | +get_code_failure_test() -> |
| 350 | + Body = <<"{\"Code\":\"Failure\"}">>, |
| 351 | + ?assertEqual({error, failed_token_response}, get_code(Body)). |
| 352 | + |
208 | 353 | -endif. |
0 commit comments