Skip to main content

src/aws@internal@providers@imds.erl

-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
    ).