diff --git a/priv/www/hijack.css b/priv/www/hijack.css new file mode 100644 index 0000000000000000000000000000000000000000..3eeb14e7d17995ce5cce7861495941c07c46ca35 --- /dev/null +++ b/priv/www/hijack.css @@ -0,0 +1,31 @@ +body { + padding: 1rem; +} + +th, td { + padding: 0.6rem 0.75rem; +} + +tr.active { + background: #eeeeee; +} + +code { + padding: 1rem; +} + +#error, #request { + display: none; +} + +.button { + margin-right: 1rem; + height: 2.4rem; + line-height: 2.4rem; +} + +.nowrap { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/priv/www/hijack.js b/priv/www/hijack.js new file mode 100644 index 0000000000000000000000000000000000000000..05f128add862532541f88d65ddd87b2f9296418c --- /dev/null +++ b/priv/www/hijack.js @@ -0,0 +1,92 @@ +var currentItem; + +function poll() { + $.getJSON('/esi/hijack_www:requests', function (items) { + for (var i in items) { + var item = items[i]; + var row = document.createElement("tr"); + $(row).data("item", JSON.stringify(item)); + var fields = '<td>' + item.request.host + '</td><td>' + item.request.method + '</td><td>' + item.request.path + '</td>'; + if (typeof item.response !== 'undefined') { + $(row).append(fields + '<td>' + item.response.status + '</td>'); + } else if (typeof item.error !== 'undefined') { + $(row).append(fields + '<td>ERR</td>'); + } + $('#requests').prepend(row); + } + }); + window.setTimeout(poll, 1000); +} + +function display(item) { + currentItem = item; + $("#intro").hide(); + if (typeof item.response !== 'undefined') { + $("#error").hide(); + $("#request_status").html(item.request.method + " " + item.request.path + " → " + item.response.status); + displayHeaders("#request_headers > tbody", item.request.headers); + displayHeaders("#response_headers > tbody", item.response.headers); + $("#request_headers").show(); + $("#response_headers").show(); + $("#request_body").hide().children("code").text(item.request.body); + $("#response_body").hide().children("code").text(item.response.body); + $("code").each(function (i, block) { + hljs.highlightBlock(block); + }); + $("#request").show(); + } else if (typeof item.error !== 'undefined') { + $("#request").hide(); + $("#error_status").html(item.request.method + " " + item.request.path); + $("#error_reason").text(item.error); + $("#error").show(); + } +} + +function displayHeaders(id, headers) { + $(id).empty(); + for (var name in headers) { + var value = headers[name]; + $(id).append('<tr><td class="nowrap">' + name + '</td><td>' + value + '</td></tr>'); + } +} + +$(document).ready(function () { + window.setTimeout(poll, 500); + $('#requests').on("click", function (e) { + $('#requests').children("tr").removeClass("active"); + display($(e.target).parent().addClass("active").data("item")); + }); + $('#clear_button').on("click", function () { + $("#requests").empty(); + $("#error").hide(); + $("#request").hide(); + $("#intro").show(); + }); + $('#request_header_button').on("click", function () { + $("#request_headers").show(); + $("#request_body").hide(); + }); + $('#request_body_button').on("click", function () { + $("#request_headers").hide(); + $("#request_body").show(); + }); + $('#request_replay_button').on("click", function () { + $.ajax({ + type: 'POST', + url: '/esi/hijack_www:replay', + data: JSON.stringify(currentItem.request), + success: function (data) { + }, + contentType: "application/json", + dataType: 'json' + }); + }); + $('#response_header_button').on("click", function () { + $("#response_headers").show(); + $("#response_body").hide(); + }); + $('#response_body_button').on("click", function () { + $("#response_headers").hide(); + $("#response_body").show(); + }); +}); diff --git a/priv/www/index.html b/priv/www/index.html index 414f86883e2cb0071f0bdec21a959e92e7841589..e2ab6a2a37e262cb167e21675ba2abd2f0a81bb9 100644 --- a/priv/www/index.html +++ b/priv/www/index.html @@ -1,14 +1,102 @@ <!DOCTYPE html> <html lang="en"> <head> - <meta charset="UTF-8"> - <title></title> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Hijack</title> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/3.0.3/normalize.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.1.0/milligram.css"> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.3.0/styles/xcode.min.css"> + <link rel="stylesheet" href="hijack.css"> <script src="https://cdnjs.cloudflare.com/ajax/libs/zepto/1.1.6/zepto.js"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.3.0/highlight.min.js"></script> + <script src="hijack.js"></script> </head> <body> -<h1>Hello</h1> +<div class="container"> + <div class="row"> + <div class="column"> + + <h2 class="float-left">All Requests</h2> + <button id="clear_button" class="button float-right">Clear</button> + + <table> + <thead> + <tr> + <th>Host</th> + <th>Method</th> + <th>Path</th> + <th>Status</th> + </tr> + </thead> + <tbody id="requests"> + </tbody> + </table> + + </div> + <div class="column"> + + <div id="intro"> + Welcome to Hijack + </div> + + <div id="error"> + + <h2 id="error_status"></h2> + + <h3 id="error_reason"></h3> + + </div> + + <div id="request"> + + <h2 id="request_status"></h2> + + <h3>Request</h3> + + <button id="request_header_button" class="button float-left">Headers</button> + <button id="request_body_button" class="button float-left">Body</button> + <button id="request_replay_button" class="button float-right">Replay</button> + + <table id="request_headers"> + <thead> + <tr> + <th>Name</th> + <th>Value</th> + </tr> + </thead> + <tbody> + </tbody> + </table> + + <br/> + <pre id="request_body"><code></code></pre> + + <h3>Response</h3> + + <button id="response_header_button" class="button float-left">Headers</button> + <button id="response_body_button" class="button float-left">Body</button> + + <table id="response_headers"> + <thead> + <tr> + <th>Name</th> + <th>Value</th> + </tr> + </thead> + <tbody> + </tbody> + </table> + + <br/> + <pre id="response_body"><code></code></pre> + + </div> + + </div> + </div> +</div> </body> </html> diff --git a/rebar.config b/rebar.config index a443400b4fe8ec28a3618d2e77a4eda927c83575..e815776c76da17507553ff90ee5ec94ace4da1cb 100644 --- a/rebar.config +++ b/rebar.config @@ -7,7 +7,8 @@ {getopt, "0.8.2"}, {msgpack, "0.4.0"}, {ranch, "1.1.0"}, - {gun, "1.0.0-pre.1"} + {gun, "1.0.0-pre.1"}, + {jsx, "2.8.0"} ]}. {profiles, [ @@ -18,6 +19,9 @@ {eunit_opts, [{report, {eunit_surefire, [{dir, "_build/test"}]}}]}, {erl_opts, [debug_info, nowarn_unused_vars]} ]}, + {development, [ + {erl_opts, [debug_info, {d, 'debug', true}]} + ]}, {production, [ {erl_opts, [no_debug_info, warnings_as_errors]} ]} diff --git a/rebar.lock b/rebar.lock index e6b84a28718d37104dbc08c0d3f17c0592f06c73..fd278418846e6fe27db407289e88f0d8c3c46172 100644 --- a/rebar.lock +++ b/rebar.lock @@ -1,5 +1,6 @@ [{<<"cowlib">>,{pkg,<<"cowlib">>,<<"1.3.0">>},1}, {<<"getopt">>,{pkg,<<"getopt">>,<<"0.8.2">>},0}, {<<"gun">>,{pkg,<<"gun">>,<<"1.0.0-pre.1">>},0}, + {<<"jsx">>,{pkg,<<"jsx">>,<<"2.8.0">>},0}, {<<"msgpack">>,{pkg,<<"msgpack">>,<<"0.4.0">>},0}, {<<"ranch">>,{pkg,<<"ranch">>,<<"1.1.0">>},0}]. diff --git a/src/hijack.erl b/src/hijack.erl index ec64e21bbad84700d031ba3d18a96799b85bbd68..f21d0fae07067c88be9dca6adaa72715a51c8bd2 100644 --- a/src/hijack.erl +++ b/src/hijack.erl @@ -22,6 +22,12 @@ httpd_port }). +-ifdef(debug). +-define(LOG(Format, Data), io:format("{~p,~p}: " ++ Format ++ "~n", [?MODULE, ?LINE] ++ Data)). +-else. +-define(LOG(Format, Data), true). +-endif. + main(Args) -> application:ensure_all_started(gun), {ok, {Opts, _}} = getopt:parse(?OPT_SPEC, Args), @@ -77,7 +83,13 @@ start_httpd(State = #state{opts = Opts}) -> {port, Port}, {erl_script_alias, {"/esi", [hijack_www]}}, {server_name, "hijack"}, - {server_root, "."} + {server_root, "."}, + {mime_types, [ + {"html", "text/html"}, + {"htm", "text/html"}, + {"css", "text/css"}, + {"js", "application/javascript"} + ]} ], ServiceConfig = try escript:script_name() of File -> @@ -119,7 +131,7 @@ execute(State = #state{opts = Opts}) -> {TargetHost, TargetPort} = parse_host_and_port(proplists:get_value(target, Opts), 80), NewState = State#state{transport = Transport, consul_socket = Socket, target_host = TargetHost, target_port = TargetPort}, Parent = self(), - spawn( + spawn_link( fun() -> transport_loop(Parent, Transport, Socket) end), @@ -146,6 +158,7 @@ transport_loop(Parent, Transport, Socket) -> {ok, Data} -> case msgpack:unpack(Data) of {ok, Terms} -> + ?LOG("RCV ~p", [Terms]), Parent ! {socket_recv, Terms}, transport_loop(Parent, Transport, Socket); {error, _Reason} -> @@ -176,6 +189,8 @@ loop(State) -> })); socket_closed -> ok; + {response, <<"replay">>, _Status, _Headers, _Body} -> + loop(State); {response, From, Status, Headers, Body} -> loop(send_to_consul(State, #{ <<"from">> => From, @@ -193,14 +208,18 @@ loop(State) -> <<"from">> => From, <<"error">> => list_to_binary(io_lib:format("~p", [Reason])) })); + {replay, Request} -> + loop(handle(State, Request#{<<"from">> => <<"replay">>})), + loop(State); Msg -> - io:format("loop recv ~p~n", [Msg]), + ?LOG("Unandled message: ~p", [Msg]), loop(State) end. send_to_consul(State = #state{transport = Transport, consul_socket = Socket}, Map) -> case Transport:send(Socket, msgpack:pack(Map)) of ok -> + ?LOG("SND ~p", [Map]), State; {error, Reason} -> io:format("Error while sending: ~p~n", [Reason]), @@ -224,6 +243,11 @@ handle(State = #state{opts = Opts}, #{<<"action">> := <<"authorized">>, <<"usern handle(State, #{<<"action">> := <<"bind">>, <<"host">> := Host, <<"path">> := Path, <<"mode">> := Mode}) -> io:format("Ready, ~sing ~s/~s~n", [Mode, Host, Path]), + Parent = self(), + register(www_state, spawn_link( + fun() -> + www_state_loop(Parent, {State, [], []}) + end)), start_httpd(State); handle(State = #state{transport = Transport, consul_socket = Socket}, #{<<"action">> := <<"error">>, <<"message">> := Message}) -> @@ -233,7 +257,7 @@ handle(State = #state{transport = Transport, consul_socket = Socket}, #{<<"actio handle(State = #state{target_host = Host, target_port = Port}, Request = #{<<"from">> := _From}) -> Parent = self(), - spawn( + spawn_link( fun() -> {ok, Pid} = gun:open(Host, Port), gun_loop(Parent, Request, Pid, undefined), @@ -245,18 +269,22 @@ handle(State, #{<<"action">> := <<"pong">>}) -> State; handle(State, Terms) -> - io:format("~p~n", [Terms]), + ?LOG("Unandled terms ~p", [Terms]), State. gun_loop(Parent, Request = #{<<"from">> := From}, Pid, StreamRef) -> receive {gun_up, Pid, _Protocol} -> - gun_loop(Parent, Request, Pid, request(Pid, Request)); + NewStreamRef = request(Pid, Request), + www_state ! {request, NewStreamRef, Request}, + gun_loop(Parent, Request, Pid, NewStreamRef); {gun_down, Pid, _Protocol, _Reason, _, _} -> Parent ! {gun_down, Pid}; {gun_error, Pid, StreamRef, Reason} -> + www_state ! {error, StreamRef, Reason}, Parent ! {gun_error, Pid, From, Reason}; {gun_response, Pid, StreamRef, fin, Status, Headers} -> + www_state ! {response, StreamRef, {Status, Headers, <<>>}}, Parent ! {response, From, Status, Headers, <<>>}; {gun_response, Pid, StreamRef, nofin, Status, Headers} -> gun_loop_data(Parent, Request, Pid, StreamRef, Status, Headers, <<>>) @@ -269,8 +297,10 @@ gun_loop_data(Parent, Request = #{<<"from">> := From}, Pid, StreamRef, Status, H {gun_down, Pid, _Protocol, _Reason, _, _} -> Parent ! {gun_down, Pid}; {gun_error, Pid, StreamRef, Reason} -> + www_state ! {error, StreamRef, Reason}, Parent ! {gun_error, Pid, From, Reason}; {gun_data, Pid, StreamRef, fin, Data} -> + www_state ! {response, StreamRef, {Status, Headers, <<Buffer/binary, Data/binary>>}}, Parent ! {response, From, Status, Headers, <<Buffer/binary, Data/binary>>}; {gun_data, Pid, StreamRef, nofin, Data} -> gun_loop_data(Parent, Request, Pid, StreamRef, Status, Headers, <<Buffer/binary, Data/binary>>) @@ -287,3 +317,48 @@ request(Pid, Request = #{<<"method">> := Method}) when Method == <<"POST">>; Method == <<"PUT">>;Method == <<"PATCH">> -> #{<<"path">> := Path, <<"headers">> := Headers, <<"body">> := Body} = Request, gun:request(Pid, Method, Path, maps:to_list(Headers), Body). + +www_state_loop(Parent, {State, Pending, Complete}) -> + receive + {request, Ref, Request} -> + NewPending = [{Ref, Request} | Pending], + www_state_loop(Parent, {State, NewPending, Complete}); + {response, Ref, {Status, Headers, Body}} -> + Response = #{ + <<"status">> => Status, + <<"headers">> => maps:from_list(Headers), + <<"body">> => Body + }, + case lists:keytake(Ref, 1, Pending) of + {value, {Ref, Request}, NewPending} -> + Entry = #{ + <<"request">> => Request, + <<"response">> => Response + }, + NewComplete = [Entry | Complete], + www_state_loop(Parent, {State, NewPending, NewComplete}); + false -> + www_state_loop(Parent, {State, Pending, Complete}) + end; + {error, Ref, Reason} -> + case lists:keytake(Ref, 1, Pending) of + {value, {Ref, Request}, NewPending} -> + Entry = #{ + <<"request">> => Request, + <<"error">> => Reason + }, + NewComplete = [Entry | Complete], + www_state_loop(Parent, {State, NewPending, NewComplete}); + false -> + www_state_loop(Parent, {State, Pending, Complete}) + end; + {get, Pid, true} -> + Pid ! {requests, Complete}, + www_state_loop(Parent, {State, Pending, []}); + {get, Pid, _} -> + Pid ! {requests, Complete}, + www_state_loop(Parent, {State, Pending, Complete}); + Msg -> + Parent ! Msg, + www_state_loop(Parent, {State, Pending, Complete}) + end. diff --git a/src/hijack_www.erl b/src/hijack_www.erl index c3d1ccde497bee971e989f7d41ae08c7a231d610..1d3f475315093720d4c0c6154f7f583457fb0751 100644 --- a/src/hijack_www.erl +++ b/src/hijack_www.erl @@ -1,12 +1,26 @@ -module(hijack_www). -export([ - echo/3, - hello/3 + replay/3, + requests/3 ]). -echo(SessionID, _Env, Input) -> - mod_esi:deliver(SessionID, Input). +replay(SessionID, _Env, Input) -> + try + www_state ! {replay, jsx:decode(list_to_binary(Input), [return_maps])}, + mod_esi:deliver(SessionID, "ok") + catch + _:_ -> + mod_esi:deliver(SessionID, "error") + end. -hello(SessionID, _Env, _Input) -> - mod_esi:deliver(SessionID, "world"). +requests(SessionID, _Env, _Input) -> + Requests = get_requests(true), + BinaryJSON = jsx:encode(Requests), + StringJSON = binary_to_list(BinaryJSON), + mod_esi:deliver(SessionID, "Content-Type:application/json\r\n\r\n"), + mod_esi:deliver(SessionID, StringJSON). + +get_requests(Purge) -> + www_state ! {get, self(), Purge}, + receive {requests, Requests} -> Requests end.