-module(aws@internal@providers@imds).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/aws/internal/providers/imds.gleam").
-export([default_options/0, fetch/2]).
-export_type([options/0, imds_credentials/0, error/0, raw_credentials/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" IMDSv2 (Instance Metadata Service version 2) flow.\n"
"\n"
" 1. PUT `<endpoint>/latest/api/token` with an `X-aws-ec2-metadata-token-\n"
" ttl-seconds` header → session token in body.\n"
" 2. GET `<endpoint>/latest/meta-data/iam/security-credentials/` with the\n"
" token in `X-aws-ec2-metadata-token` → role name in body.\n"
" 3. GET `<endpoint>/latest/meta-data/iam/security-credentials/<role>` with\n"
" the same token → JSON body with `AccessKeyId`, `SecretAccessKey`,\n"
" `Token`, `Expiration`.\n"
"\n"
" The PUT in step 1 is what makes this \"v2\": it requires a session token\n"
" that an SSRF attacker bouncing requests off the instance can't easily\n"
" obtain. v1 is intentionally not implemented.\n"
"\n"
" Errors are classified so the chain can do the right thing:\n"
" - Step 1 fails (DNS, connect, non-200) → `NotOnInstance` so the chain\n"
" falls through to the next provider quietly.\n"
" - Anything after step 1 succeeds-then-fails → `Failed` so the user sees\n"
" a loud \"you're on EC2 but IMDS is misbehaving\" signal.\n"
).
-type options() :: {options, binary(), integer()}.
-type imds_credentials() :: {imds_credentials,
binary(),
binary(),
binary(),
integer()}.
-type error() :: {not_on_instance, binary()} | {failed, binary()}.
-type raw_credentials() :: {raw_credentials,
binary(),
binary(),
binary(),
binary(),
binary()}.
-file("src/aws/internal/providers/imds.gleam", 35).
-spec default_options() -> options().
default_options() ->
{options, <<"http://169.254.169.254"/utf8>>, 21600}.
-file("src/aws/internal/providers/imds.gleam", 210).
-spec raw_decoder() -> gleam@dynamic@decode:decoder(raw_credentials()).
raw_decoder() ->
gleam@dynamic@decode:field(
<<"Code"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Code) ->
gleam@dynamic@decode:field(
<<"AccessKeyId"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Access_key_id) ->
gleam@dynamic@decode:field(
<<"SecretAccessKey"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Secret_access_key) ->
gleam@dynamic@decode:field(
<<"Token"/utf8>>,
{decoder,
fun gleam@dynamic@decode:decode_string/1},
fun(Token) ->
gleam@dynamic@decode:field(
<<"Expiration"/utf8>>,
{decoder,
fun gleam@dynamic@decode:decode_string/1},
fun(Expiration) ->
gleam@dynamic@decode:success(
{raw_credentials,
Code,
Access_key_id,
Secret_access_key,
Token,
Expiration}
)
end
)
end
)
end
)
end
)
end
).
-file("src/aws/internal/providers/imds.gleam", 225).
-spec decode_credentials(bitstring()) -> {ok, imds_credentials()} |
{error, error()}.
decode_credentials(Body) ->
gleam@result:'try'(
begin
_pipe = gleam@bit_array:to_string(Body),
gleam@result:replace_error(
_pipe,
{failed, <<"non-utf8 credentials body"/utf8>>}
)
end,
fun(Text) ->
gleam@result:'try'(
begin
_pipe@1 = gleam@json:parse(Text, raw_decoder()),
gleam@result:map_error(
_pipe@1,
fun(_) ->
{failed,
<<"credentials body is not the expected JSON shape"/utf8>>}
end
)
end,
fun(Raw) -> case erlang:element(2, Raw) of
<<"Success"/utf8>> ->
gleam@result:'try'(
begin
_pipe@2 = aws_ffi:parse_iso8601(
erlang:element(6, Raw)
),
gleam@result:replace_error(
_pipe@2,
{failed,
<<<<"could not parse Expiration timestamp '"/utf8,
(erlang:element(6, Raw))/binary>>/binary,
"'"/utf8>>}
)
end,
fun(Expires_at) ->
{ok,
{imds_credentials,
erlang:element(3, Raw),
erlang:element(4, Raw),
erlang:element(5, Raw),
Expires_at}}
end
);
Other ->
{error,
{failed,
<<"IMDS returned Code="/utf8, Other/binary>>}}
end end
)
end
).
-file("src/aws/internal/providers/imds.gleam", 189).
-spec describe_http(aws@internal@http_send:http_error()) -> binary().
describe_http(Error) ->
case Error of
{connect_failed, Reason} ->
<<"connect failed: "/utf8, Reason/binary>>;
timeout ->
<<"timeout"/utf8>>;
{invalid_body, Reason@1} ->
<<"invalid body: "/utf8, Reason@1/binary>>;
{other, Reason@2} ->
Reason@2
end.
-file("src/aws/internal/providers/imds.gleam", 158).
-spec token_header(binary()) -> list({binary(), binary()}).
token_header(Token) ->
[{<<"x-aws-ec2-metadata-token"/utf8>>, Token}].
-file("src/aws/internal/providers/imds.gleam", 179).
-spec apply_headers(
gleam@http@request:request(bitstring()),
list({binary(), binary()})
) -> gleam@http@request:request(bitstring()).
apply_headers(Req, Headers) ->
case Headers of
[] ->
Req;
[{K, V} | Rest] ->
apply_headers(gleam@http@request:set_header(Req, K, V), Rest)
end.
-file("src/aws/internal/providers/imds.gleam", 162).
-spec build_request(
gleam@http:method(),
binary(),
list({binary(), binary()}),
bitstring()
) -> {ok, gleam@http@request:request(bitstring())} | {error, binary()}.
build_request(Method, Url, Headers, Body) ->
gleam@result:'try'(
begin
_pipe = gleam@http@request:to(Url),
gleam@result:replace_error(
_pipe,
<<"invalid URL: "/utf8, Url/binary>>
)
end,
fun(Base) ->
Withed = begin
_pipe@1 = Base,
_pipe@2 = gleam@http@request:set_method(_pipe@1, Method),
gleam@http@request:set_body(_pipe@2, Body)
end,
{ok, apply_headers(Withed, Headers)}
end
).
-file("src/aws/internal/providers/imds.gleam", 131).
-spec get_credentials(
fun((gleam@http@request:request(bitstring())) -> {ok,
gleam@http@response:response(bitstring())} |
{error, aws@internal@http_send:http_error()}),
options(),
binary(),
binary()
) -> {ok, imds_credentials()} | {error, error()}.
get_credentials(Send, Options, Token, Role) ->
Url = <<<<(erlang:element(2, Options))/binary,
"/latest/meta-data/iam/security-credentials/"/utf8>>/binary,
Role/binary>>,
gleam@result:'try'(
begin
_pipe = build_request(
get,
Url,
token_header(Token),
gleam_stdlib:identity(<<""/utf8>>)
),
gleam@result:map_error(_pipe, fun(Reason) -> {failed, Reason} end)
end,
fun(Req) ->
gleam@result:'try'(
begin
_pipe@1 = Send(Req),
gleam@result:map_error(
_pipe@1,
fun(E) ->
{failed,
<<"credentials fetch transport: "/utf8,
(describe_http(E))/binary>>}
end
)
end,
fun(Resp) -> case erlang:element(2, Resp) of
200 ->
decode_credentials(erlang:element(4, Resp));
Other ->
{error,
{failed,
<<"credentials endpoint returned status "/utf8,
(erlang:integer_to_binary(Other))/binary>>}}
end end
)
end
).
-file("src/aws/internal/providers/imds.gleam", 104).
-spec get_role(
fun((gleam@http@request:request(bitstring())) -> {ok,
gleam@http@response:response(bitstring())} |
{error, aws@internal@http_send:http_error()}),
options(),
binary()
) -> {ok, binary()} | {error, error()}.
get_role(Send, Options, Token) ->
Url = <<(erlang:element(2, Options))/binary,
"/latest/meta-data/iam/security-credentials/"/utf8>>,
gleam@result:'try'(
begin
_pipe = build_request(
get,
Url,
token_header(Token),
gleam_stdlib:identity(<<""/utf8>>)
),
gleam@result:map_error(_pipe, fun(Reason) -> {failed, Reason} end)
end,
fun(Req) ->
gleam@result:'try'(
begin
_pipe@1 = Send(Req),
gleam@result:map_error(
_pipe@1,
fun(E) ->
{failed,
<<"role listing transport: "/utf8,
(describe_http(E))/binary>>}
end
)
end,
fun(Resp) -> case erlang:element(2, Resp) of
200 ->
_pipe@2 = gleam@bit_array:to_string(
erlang:element(4, Resp)
),
gleam@result:replace_error(
_pipe@2,
{failed, <<"non-utf8 role listing body"/utf8>>}
);
Other ->
{error,
{failed,
<<"role listing returned status "/utf8,
(erlang:integer_to_binary(Other))/binary>>}}
end end
)
end
).
-file("src/aws/internal/providers/imds.gleam", 69).
-spec get_token(
fun((gleam@http@request:request(bitstring())) -> {ok,
gleam@http@response:response(bitstring())} |
{error, aws@internal@http_send:http_error()}),
options()
) -> {ok, binary()} | {error, error()}.
get_token(Send, Options) ->
Url = <<(erlang:element(2, Options))/binary, "/latest/api/token"/utf8>>,
gleam@result:'try'(
begin
_pipe = build_request(
put,
Url,
[{<<"x-aws-ec2-metadata-token-ttl-seconds"/utf8>>,
erlang:integer_to_binary(erlang:element(3, Options))}],
gleam_stdlib:identity(<<""/utf8>>)
),
gleam@result:map_error(
_pipe,
fun(Reason) -> {not_on_instance, Reason} end
)
end,
fun(Req) ->
gleam@result:'try'(
begin
_pipe@1 = Send(Req),
gleam@result:map_error(
_pipe@1,
fun(E) ->
{not_on_instance,
<<"token request transport: "/utf8,
(describe_http(E))/binary>>}
end
)
end,
fun(Resp) -> case erlang:element(2, Resp) of
200 ->
_pipe@2 = gleam@bit_array:to_string(
erlang:element(4, Resp)
),
gleam@result:replace_error(
_pipe@2,
{not_on_instance,
<<"non-utf8 token response body"/utf8>>}
);
Other ->
{error,
{not_on_instance,
<<"token request returned status "/utf8,
(erlang:integer_to_binary(Other))/binary>>}}
end end
)
end
).
-file("src/aws/internal/providers/imds.gleam", 63).
?DOC(
" Run the full IMDSv2 flow. Returns decoded credentials or a categorised\n"
" error. The `send` callback is the only side-effect surface — tests pass\n"
" a stub here.\n"
).
-spec fetch(
fun((gleam@http@request:request(bitstring())) -> {ok,
gleam@http@response:response(bitstring())} |
{error, aws@internal@http_send:http_error()}),
options()
) -> {ok, imds_credentials()} | {error, error()}.
fetch(Send, Options) ->
gleam@result:'try'(
get_token(Send, Options),
fun(Token) ->
gleam@result:'try'(
get_role(Send, Options, Token),
fun(Role) -> get_credentials(Send, Options, Token, Role) end
)
end
).