Files
fluxer/fluxer_relay/src/relay/fluxer_relay_http_handler.erl
2026-02-17 12:22:36 +00:00

165 lines
6.9 KiB
Erlang

%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(fluxer_relay_http_handler).
-behaviour(cowboy_handler).
-export([init/2]).
-spec init(cowboy_req:req(), term()) -> {ok, cowboy_req:req(), term()}.
init(Req, State) ->
Method = cowboy_req:method(Req),
Path = cowboy_req:path(Req),
Headers = cowboy_req:headers(Req),
case validate_relay_request(Headers) of
{ok, OriginInstance} ->
proxy_request(Method, Path, Headers, Req, State, OriginInstance);
{error, Reason} ->
error_response(401, Reason, Req, State)
end.
-spec validate_relay_request(map()) -> {ok, binary()} | {error, binary()}.
validate_relay_request(Headers) ->
case maps:get(<<"x-relay-origin">>, Headers, undefined) of
undefined ->
{error, <<"Missing X-Relay-Origin header">>};
Origin ->
case maps:get(<<"x-relay-signature">>, Headers, undefined) of
undefined ->
{error, <<"Missing X-Relay-Signature header">>};
_Signature ->
{ok, Origin}
end
end.
-spec proxy_request(binary(), binary(), map(), cowboy_req:req(), term(), binary()) ->
{ok, cowboy_req:req(), term()}.
proxy_request(Method, Path, Headers, Req, State, OriginInstance) ->
Config = fluxer_relay_env:get_map(),
UpstreamHost = maps:get(upstream_api_host, Config, "localhost:8080"),
UseTls = maps:get(upstream_use_tls, Config, false),
Timeout = maps:get(connection_timeout_ms, Config, 30000),
{Host, Port} = parse_host_port(UpstreamHost, UseTls),
Transport = case UseTls of true -> tls; false -> tcp end,
case gun:open(Host, Port, #{transport => Transport, connect_timeout => Timeout}) of
{ok, ConnPid} ->
case gun:await_up(ConnPid, Timeout) of
{ok, _Protocol} ->
forward_request(ConnPid, Method, Path, Headers, Req, State, OriginInstance);
{error, Reason} ->
gun:close(ConnPid),
lager:error("Failed to connect to upstream: ~p", [Reason]),
error_response(502, <<"Upstream connection failed">>, Req, State)
end;
{error, Reason} ->
lager:error("Failed to open connection to upstream: ~p", [Reason]),
error_response(502, <<"Upstream connection failed">>, Req, State)
end.
-spec forward_request(pid(), binary(), binary(), map(), cowboy_req:req(), term(), binary()) ->
{ok, cowboy_req:req(), term()}.
forward_request(ConnPid, Method, Path, Headers, Req, State, OriginInstance) ->
ProxiedHeaders = add_proxy_headers(Headers, OriginInstance),
{ok, Body, Req2} = read_body(Req),
StreamRef = case Method of
<<"GET">> -> gun:get(ConnPid, Path, maps:to_list(ProxiedHeaders));
<<"POST">> -> gun:post(ConnPid, Path, maps:to_list(ProxiedHeaders), Body);
<<"PUT">> -> gun:put(ConnPid, Path, maps:to_list(ProxiedHeaders), Body);
<<"PATCH">> -> gun:patch(ConnPid, Path, maps:to_list(ProxiedHeaders), Body);
<<"DELETE">> -> gun:delete(ConnPid, Path, maps:to_list(ProxiedHeaders));
<<"OPTIONS">> -> gun:options(ConnPid, Path, maps:to_list(ProxiedHeaders));
<<"HEAD">> -> gun:head(ConnPid, Path, maps:to_list(ProxiedHeaders));
_ -> gun:request(ConnPid, Method, Path, maps:to_list(ProxiedHeaders), Body)
end,
Timeout = fluxer_relay_env:get(connection_timeout_ms),
case gun:await(ConnPid, StreamRef, Timeout) of
{response, fin, Status, RespHeaders} ->
gun:close(ConnPid),
Req3 = cowboy_req:reply(Status, maps:from_list(RespHeaders), <<>>, Req2),
{ok, Req3, State};
{response, nofin, Status, RespHeaders} ->
case gun:await_body(ConnPid, StreamRef, Timeout) of
{ok, RespBody} ->
gun:close(ConnPid),
Req3 = cowboy_req:reply(Status, maps:from_list(RespHeaders), RespBody, Req2),
{ok, Req3, State};
{error, Reason} ->
gun:close(ConnPid),
lager:error("Failed to read upstream response body: ~p", [Reason]),
error_response(502, <<"Upstream read failed">>, Req2, State)
end;
{error, Reason} ->
gun:close(ConnPid),
lager:error("Upstream request failed: ~p", [Reason]),
error_response(502, <<"Upstream request failed">>, Req2, State)
end.
-spec add_proxy_headers(map(), binary()) -> map().
add_proxy_headers(Headers, OriginInstance) ->
FilteredHeaders = filter_relay_headers(Headers),
FilteredHeaders#{
<<"x-forwarded-for">> => OriginInstance,
<<"x-relay-proxied">> => <<"true">>,
<<"x-relay-origin">> => OriginInstance
}.
-spec filter_relay_headers(map()) -> map().
filter_relay_headers(Headers) ->
RelayHeaders = [
<<"x-fluxer-target">>,
<<"x-relay-signature">>,
<<"x-relay-timestamp">>,
<<"x-relay-instance">>,
<<"x-relay-request-id">>
],
lists:foldl(fun(Key, Acc) -> maps:remove(Key, Acc) end, Headers, RelayHeaders).
-spec read_body(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
read_body(Req) ->
read_body(Req, <<>>).
-spec read_body(cowboy_req:req(), binary()) -> {ok, binary(), cowboy_req:req()}.
read_body(Req, Acc) ->
case cowboy_req:read_body(Req) of
{ok, Data, Req2} ->
{ok, <<Acc/binary, Data/binary>>, Req2};
{more, Data, Req2} ->
read_body(Req2, <<Acc/binary, Data/binary>>)
end.
-spec parse_host_port(string(), boolean()) -> {string(), inet:port_number()}.
parse_host_port(HostPort, UseTls) ->
DefaultPort = case UseTls of true -> 443; false -> 80 end,
case string:split(HostPort, ":") of
[Host, PortStr] ->
Port = list_to_integer(PortStr),
{Host, Port};
[Host] ->
{Host, DefaultPort}
end.
-spec error_response(integer(), binary(), cowboy_req:req(), term()) ->
{ok, cowboy_req:req(), term()}.
error_response(Status, Message, Req, State) ->
Response = json:encode(#{
<<"error">> => Message
}),
Req2 = cowboy_req:reply(Status, #{
<<"content-type">> => <<"application/json">>
}, Response, Req),
{ok, Req2, State}.