%% @doc HTTP/2 Standalone Client Escript %% %% A standalone HTTP/2 client that can be run from the command line. %% %% Usage: %% ``` %% h2_client URL [options] %% %% Options: %% -X, --method METHOD HTTP method (default: GET) %% -H, --header K:V Add request header (repeatable) %% -d, --data DATA Request body %% --cert FILE Client certificate (PEM) %% --key FILE Client private key (PEM) %% --cacerts FILE CA certificates (PEM) %% --insecure Skip certificate verification %% -o, --output FILE Write response to file %% --timeout SEC Connection timeout (default: 30) %% -v, --verbose Show detailed output %% -h, --help Show this help %% ''' %% -module(h2_client). -export([main/1]). -define(DEFAULT_TIMEOUT, 30). %% ============================================================================ %% Escript Entry Point %% ============================================================================ -spec main([string()]) -> no_return(). main(Args) -> case parse_args(Args) of {ok, Opts} -> run_client(Opts); {error, Reason} -> io:format(standard_error, "Error: ~s~n~n", [Reason]), usage(), halt(1); help -> usage(), halt(0) end. %% ============================================================================ %% Argument Parsing %% ============================================================================ parse_args(Args) -> parse_args(Args, #{ method => <<"GET">>, headers => [], timeout => ?DEFAULT_TIMEOUT, verbose => false, insecure => false }). parse_args([], #{url := _} = Opts) -> %% Reverse headers to maintain order {ok, Opts#{headers => lists:reverse(maps:get(headers, Opts))}}; parse_args([], _Opts) -> {error, "Missing URL argument"}; parse_args(["-h"|_], _Opts) -> help; parse_args(["--help"|_], _Opts) -> help; parse_args(["-X", Method|Rest], Opts) -> parse_args(Rest, Opts#{method => list_to_binary(string:uppercase(Method))}); parse_args(["--method", Method|Rest], Opts) -> parse_args(Rest, Opts#{method => list_to_binary(string:uppercase(Method))}); parse_args(["-H", Header|Rest], Opts) -> parse_args(["--header", Header|Rest], Opts); parse_args(["--header", Header|Rest], #{headers := Headers} = Opts) -> case string:split(Header, ":") of [Name, Value] -> H = {list_to_binary(string:trim(Name)), list_to_binary(string:trim(Value))}, parse_args(Rest, Opts#{headers => [H|Headers]}); _ -> {error, io_lib:format("Invalid header format: ~s (expected Name:Value)", [Header])} end; parse_args(["-d", Data|Rest], Opts) -> parse_args(Rest, Opts#{data => list_to_binary(Data)}); parse_args(["--data", Data|Rest], Opts) -> parse_args(Rest, Opts#{data => list_to_binary(Data)}); parse_args(["--cert", File|Rest], Opts) -> case filelib:is_regular(File) of true -> parse_args(Rest, Opts#{cert => File}); false -> {error, io_lib:format("Certificate file not found: ~s", [File])} end; parse_args(["--key", File|Rest], Opts) -> case filelib:is_regular(File) of true -> parse_args(Rest, Opts#{key => File}); false -> {error, io_lib:format("Key file not found: ~s", [File])} end; parse_args(["--cacerts", File|Rest], Opts) -> case filelib:is_regular(File) of true -> parse_args(Rest, Opts#{cacerts => File}); false -> {error, io_lib:format("CA certificates file not found: ~s", [File])} end; parse_args(["--insecure"|Rest], Opts) -> parse_args(Rest, Opts#{insecure => true}); parse_args(["-o", File|Rest], Opts) -> parse_args(Rest, Opts#{output => File}); parse_args(["--output", File|Rest], Opts) -> parse_args(Rest, Opts#{output => File}); parse_args(["--timeout", Timeout|Rest], Opts) -> case catch list_to_integer(Timeout) of N when is_integer(N), N > 0 -> parse_args(Rest, Opts#{timeout => N}); _ -> {error, io_lib:format("Invalid timeout: ~s", [Timeout])} end; parse_args(["-v"|Rest], Opts) -> parse_args(Rest, Opts#{verbose => true}); parse_args(["--verbose"|Rest], Opts) -> parse_args(Rest, Opts#{verbose => true}); parse_args([[$-|_] = Unknown|_], _Opts) -> {error, io_lib:format("Unknown option: ~s", [Unknown])}; parse_args([URL|Rest], Opts) -> {ok, ParsedURL} = parse_url(URL), parse_args(Rest, maps:merge(Opts, ParsedURL)). parse_url(URL) -> %% Simple URL parser for https://host:port/path case URL of "https://" ++ Rest -> parse_url_parts(Rest, https); "http://" ++ Rest -> parse_url_parts(Rest, http); _ -> %% Assume https parse_url_parts(URL, https) end. parse_url_parts(Rest, Scheme) -> %% Split host:port/path {HostPort, Path} = case string:split(Rest, "/") of [HP] -> {HP, "/"}; [HP, PathPart] -> {HP, "/" ++ PathPart} end, %% Split host:port {Host, Port} = case string:split(HostPort, ":") of [H] -> DefaultPort = case Scheme of https -> 443; http -> 80 end, {H, DefaultPort}; [H, PortStr] -> case catch list_to_integer(PortStr) of N when is_integer(N) -> {H, N}; _ -> {H, 443} end end, {ok, #{ url => Rest, scheme => Scheme, host => Host, port => Port, path => Path }}. usage() -> io:format( "Usage: h2_client URL [OPTIONS]~n" "~n" "HTTP/2 reference client~n" "~n" "Arguments:~n" " URL Target URL (https://host:port/path)~n" "~n" "Options:~n" " -X, --method METHOD HTTP method (default: GET)~n" " -H, --header K:V Add request header (can be repeated)~n" " -d, --data DATA Request body~n" " --cert FILE Client certificate (PEM)~n" " --key FILE Client private key (PEM)~n" " --cacerts FILE CA certificates (PEM)~n" " --insecure Skip certificate verification~n" " -o, --output FILE Write response body to file~n" " --timeout SEC Connection timeout (default: ~p)~n" " -v, --verbose Show detailed output~n" " -h, --help Show this help~n" "~n" "Examples:~n" " h2_client https://localhost:8443/~n" " h2_client https://example.com/ -v~n" " h2_client https://example.com/api -X POST -d '{\"key\":\"value\"}'~n" " h2_client https://localhost:8443/ --insecure~n" "~n", [?DEFAULT_TIMEOUT]). %% ============================================================================ %% Client Implementation %% ============================================================================ run_client(Opts) -> %% Start required applications ok = application:ensure_started(crypto), ok = application:ensure_started(asn1), ok = application:ensure_started(public_key), ok = application:ensure_started(ssl), Host = maps:get(host, Opts), Port = maps:get(port, Opts), Path = maps:get(path, Opts), Method = maps:get(method, Opts), Headers = maps:get(headers, Opts), Timeout = maps:get(timeout, Opts) * 1000, Verbose = maps:get(verbose, Opts), log(Verbose, "Connecting to ~s:~p~n", [Host, Port]), %% Build SSL options SSLOpts = build_ssl_opts(Opts), %% Connect ConnectOpts = #{ transport => ssl, ssl_opts => SSLOpts, timeout => Timeout }, case h2:connect(Host, Port, ConnectOpts) of {ok, Conn} -> log(Verbose, "Connected, sending request~n", []), %% Add host header if not present Headers1 = case proplists:is_defined(<<"host">>, Headers) of true -> Headers; false -> [{<<"host">>, list_to_binary(Host)} | Headers] end, %% Send request Result = case maps:find(data, Opts) of {ok, Data} -> h2:request(Conn, Method, list_to_binary(Path), Headers1, Data); error -> h2:request(Conn, Method, list_to_binary(Path), Headers1) end, case Result of {ok, StreamId} -> log(Verbose, "Request sent on stream ~p~n", [StreamId]), receive_response(Conn, StreamId, Opts); {error, Reason} -> io:format(standard_error, "Request failed: ~p~n", [Reason]), h2:close(Conn), halt(1) end; {error, Reason} -> io:format(standard_error, "Connection failed: ~p~n", [Reason]), halt(1) end. build_ssl_opts(Opts) -> BaseOpts = case maps:get(insecure, Opts) of true -> [{verify, verify_none}]; false -> [{verify, verify_peer}, {depth, 10}, {cacerts, public_key:cacerts_get()}] end, %% Add client certificate if provided CertOpts = case maps:find(cert, Opts) of {ok, CertFile} -> [{certfile, CertFile}]; error -> [] end, KeyOpts = case maps:find(key, Opts) of {ok, KeyFile} -> [{keyfile, KeyFile}]; error -> [] end, %% Add custom CA certificates if provided CAOpts = case maps:find(cacerts, Opts) of {ok, CAFile} -> [{cacertfile, CAFile}]; error -> [] end, %% Server name indication Host = maps:get(host, Opts), SNIOpts = [{server_name_indication, Host}], BaseOpts ++ CertOpts ++ KeyOpts ++ CAOpts ++ SNIOpts. receive_response(Conn, StreamId, Opts) -> Verbose = maps:get(verbose, Opts), receive_response_loop(Conn, StreamId, Opts, Verbose, <<>>, undefined). receive_response_loop(Conn, StreamId, Opts, Verbose, BodyAcc, Status) -> Timeout = maps:get(timeout, Opts) * 1000, receive {h2, Conn, {response, StreamId, ResponseStatus, Headers}} -> log(Verbose, "~n< HTTP/2 ~p~n", [ResponseStatus]), print_headers(Verbose, Headers), receive_response_loop(Conn, StreamId, Opts, Verbose, BodyAcc, ResponseStatus); {h2, Conn, {data, StreamId, Data, true}} -> %% Final data FullBody = <>, handle_response_body(FullBody, Status, Opts, Verbose), h2:close(Conn), case Status of S when S >= 200, S < 400 -> halt(0); _ -> halt(1) end; {h2, Conn, {data, StreamId, Data, false}} -> %% More data coming NewAcc = <>, receive_response_loop(Conn, StreamId, Opts, Verbose, NewAcc, Status); {h2, Conn, {trailers, StreamId, Trailers}} -> log(Verbose, "~n< Trailers:~n", []), print_headers(Verbose, Trailers), receive_response_loop(Conn, StreamId, Opts, Verbose, BodyAcc, Status); {h2, Conn, {stream_reset, StreamId, ErrorCode}} -> io:format(standard_error, "~nStream reset: ~p~n", [ErrorCode]), h2:close(Conn), halt(1); {h2, Conn, {goaway, _LastStreamId, ErrorCode}} -> io:format(standard_error, "~nConnection closed: ~p~n", [ErrorCode]), h2:close(Conn), halt(1); {h2, Conn, closed} -> io:format(standard_error, "~nConnection closed unexpectedly~n", []), halt(1) after Timeout -> io:format(standard_error, "~nTimeout waiting for response~n", []), h2:close(Conn), halt(1) end. handle_response_body(Body, Status, Opts, Verbose) -> case maps:find(output, Opts) of {ok, OutputFile} -> case file:write_file(OutputFile, Body) of ok -> log(Verbose, "~nWrote ~p bytes to ~s~n", [byte_size(Body), OutputFile]); {error, Reason} -> io:format(standard_error, "Failed to write output: ~p~n", [Reason]) end; error -> %% Print to stdout case Verbose of true -> io:format("~n~s~n", [Body]); false -> case Status of S when S >= 200, S < 400 -> io:format("~s", [Body]); _ -> io:format(standard_error, "~s", [Body]) end end end. print_headers(true, Headers) -> lists:foreach(fun({Name, Value}) -> io:format("< ~s: ~s~n", [Name, Value]) end, Headers); print_headers(false, _) -> ok. log(true, Format, Args) -> io:format(standard_error, "* " ++ Format, Args); log(false, _Format, _Args) -> ok. -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). parse_url_test_() -> [ ?_assertMatch({ok, #{host := "example.com", port := 443, path := "/"}}, parse_url("https://example.com/")), ?_assertMatch({ok, #{host := "example.com", port := 8443, path := "/api"}}, parse_url("https://example.com:8443/api")), ?_assertMatch({ok, #{host := "localhost", port := 443, path := "/"}}, parse_url("localhost")) ]. parse_args_test_() -> [ ?_assertMatch({ok, #{method := <<"GET">>, host := "example.com"}}, parse_args(["https://example.com/"], #{method => <<"GET">>, headers => [], timeout => 30, verbose => false, insecure => false})), ?_assertMatch({ok, #{method := <<"POST">>}}, parse_args(["-X", "post", "https://example.com/"], #{method => <<"GET">>, headers => [], timeout => 30, verbose => false, insecure => false})) ]. -endif.