refactor progress
This commit is contained in:
9
fluxer_relay/src/fluxer_relay.app.src
Normal file
9
fluxer_relay/src/fluxer_relay.app.src
Normal file
@@ -0,0 +1,9 @@
|
||||
{application, fluxer_relay, [
|
||||
{description, "Fluxer Relay Server"},
|
||||
{vsn, "0.1.0"},
|
||||
{registered, []},
|
||||
{mod, {fluxer_relay_app, []}},
|
||||
{applications, [kernel, stdlib, crypto, cowboy, gun, lager]},
|
||||
{env, []},
|
||||
{modules, []}
|
||||
]}.
|
||||
44
fluxer_relay/src/relay/fluxer_relay_app.erl
Normal file
44
fluxer_relay/src/relay/fluxer_relay_app.erl
Normal file
@@ -0,0 +1,44 @@
|
||||
%% 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_app).
|
||||
-behaviour(application).
|
||||
-export([start/2, stop/1]).
|
||||
|
||||
-spec start(application:start_type(), term()) -> {ok, pid()} | {error, term()}.
|
||||
start(_StartType, _StartArgs) ->
|
||||
fluxer_relay_env:load(),
|
||||
fluxer_relay_instance_discovery:init(),
|
||||
Port = fluxer_relay_env:get(port),
|
||||
Dispatch = cowboy_router:compile([
|
||||
{'_', [
|
||||
{<<"/_health">>, fluxer_relay_health_handler, []},
|
||||
{<<"/api/[...]">>, fluxer_relay_http_handler, []},
|
||||
{<<"/gateway">>, fluxer_relay_ws_handler, []}
|
||||
]}
|
||||
]),
|
||||
{ok, _} = cowboy:start_clear(http, [{port, Port}], #{
|
||||
env => #{dispatch => Dispatch},
|
||||
idle_timeout => 120000,
|
||||
request_timeout => 60000
|
||||
}),
|
||||
lager:info("Fluxer Relay started on port ~p", [Port]),
|
||||
fluxer_relay_sup:start_link().
|
||||
|
||||
-spec stop(term()) -> ok.
|
||||
stop(_State) ->
|
||||
ok.
|
||||
204
fluxer_relay/src/relay/fluxer_relay_config.erl
Normal file
204
fluxer_relay/src/relay/fluxer_relay_config.erl
Normal file
@@ -0,0 +1,204 @@
|
||||
%% 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_config).
|
||||
|
||||
-export([load/0, load_from/1]).
|
||||
|
||||
-type config() :: map().
|
||||
-type log_level() :: debug | info | notice | warning | error | critical | alert | emergency.
|
||||
|
||||
-spec load() -> config().
|
||||
load() ->
|
||||
case os:getenv("FLUXER_CONFIG") of
|
||||
false -> erlang:error({missing_env, "FLUXER_CONFIG"});
|
||||
"" -> erlang:error({missing_env, "FLUXER_CONFIG"});
|
||||
Path -> load_from(Path)
|
||||
end.
|
||||
|
||||
-spec load_from(string()) -> config().
|
||||
load_from(Path) when is_list(Path) ->
|
||||
case file:read_file(Path) of
|
||||
{ok, Content} ->
|
||||
Json = json:decode(Content),
|
||||
build_config(Json);
|
||||
{error, Reason} ->
|
||||
erlang:error({json_read_failed, Path, Reason})
|
||||
end.
|
||||
|
||||
-spec build_config(map()) -> config().
|
||||
build_config(Json) ->
|
||||
Service = get_map(Json, [<<"services">>, <<"relay">>]),
|
||||
Federation = get_map(Json, [<<"federation">>]),
|
||||
Telemetry = get_map(Json, [<<"telemetry">>]),
|
||||
#{
|
||||
port => get_int(Service, <<"port">>, 8090),
|
||||
upstream_api_host => get_string(Service, <<"upstream_api_host">>, "localhost:8080"),
|
||||
upstream_gateway_host => get_string(Service, <<"upstream_gateway_host">>, "localhost:8081"),
|
||||
upstream_use_tls => get_bool(Service, <<"upstream_use_tls">>, false),
|
||||
instance_domain => get_string(Federation, <<"instance_domain">>, "localhost"),
|
||||
instance_public_key => get_optional_binary(Federation, <<"instance_public_key">>),
|
||||
instance_private_key => get_optional_binary(Federation, <<"instance_private_key">>),
|
||||
allowed_origins => get_string_list(Service, <<"allowed_origins">>, []),
|
||||
max_connections_per_instance => get_int(Service, <<"max_connections_per_instance">>, 1000),
|
||||
connection_timeout_ms => get_int(Service, <<"connection_timeout_ms">>, 30000),
|
||||
idle_timeout_ms => get_int(Service, <<"idle_timeout_ms">>, 120000),
|
||||
logger_level => get_log_level(Service, <<"logger_level">>, info),
|
||||
telemetry => #{
|
||||
enabled => get_bool(Telemetry, <<"enabled">>, false),
|
||||
otlp_endpoint => get_string(Telemetry, <<"otlp_endpoint">>, ""),
|
||||
service_name => get_string(Telemetry, <<"service_name">>, "fluxer-relay")
|
||||
}
|
||||
}.
|
||||
|
||||
-spec get_map(map(), [binary()]) -> map().
|
||||
get_map(Map, Keys) ->
|
||||
case get_in(Map, Keys) of
|
||||
Value when is_map(Value) -> Value;
|
||||
_ -> #{}
|
||||
end.
|
||||
|
||||
-spec get_int(map(), binary(), integer()) -> integer().
|
||||
get_int(Map, Key, Default) when is_integer(Default) ->
|
||||
to_int(get_value(Map, Key), Default).
|
||||
|
||||
-spec get_bool(map(), binary(), boolean()) -> boolean().
|
||||
get_bool(Map, Key, Default) when is_boolean(Default) ->
|
||||
to_bool(get_value(Map, Key), Default).
|
||||
|
||||
-spec get_string(map(), binary(), string()) -> string().
|
||||
get_string(Map, Key, Default) when is_list(Default) ->
|
||||
to_string(get_value(Map, Key), Default).
|
||||
|
||||
-spec get_optional_binary(map(), binary()) -> binary() | undefined.
|
||||
get_optional_binary(Map, Key) ->
|
||||
case get_value(Map, Key) of
|
||||
undefined -> undefined;
|
||||
Value -> to_binary(Value, undefined)
|
||||
end.
|
||||
|
||||
-spec get_string_list(map(), binary(), [string()]) -> [string()].
|
||||
get_string_list(Map, Key, Default) ->
|
||||
case get_value(Map, Key) of
|
||||
undefined -> Default;
|
||||
List when is_list(List) ->
|
||||
[to_string(Item, "") || Item <- List, Item =/= undefined];
|
||||
_ -> Default
|
||||
end.
|
||||
|
||||
-spec get_log_level(map(), binary(), log_level()) -> log_level().
|
||||
get_log_level(Map, Key, Default) when is_atom(Default) ->
|
||||
Value = get_value(Map, Key),
|
||||
case normalize_log_level(Value) of
|
||||
undefined -> Default;
|
||||
Level -> Level
|
||||
end.
|
||||
|
||||
-spec get_in(term(), [binary()]) -> term().
|
||||
get_in(Map, [Key | Rest]) when is_map(Map) ->
|
||||
case get_value(Map, Key) of
|
||||
undefined -> undefined;
|
||||
Value when Rest =:= [] -> Value;
|
||||
Value -> get_in(Value, Rest)
|
||||
end;
|
||||
get_in(_, _) ->
|
||||
undefined.
|
||||
|
||||
-spec get_value(term(), binary()) -> term().
|
||||
get_value(Map, Key) when is_map(Map) ->
|
||||
case maps:get(Key, Map, undefined) of
|
||||
undefined when is_binary(Key) ->
|
||||
maps:get(binary_to_list(Key), Map, undefined);
|
||||
Value ->
|
||||
Value
|
||||
end.
|
||||
|
||||
-spec to_int(term(), integer() | undefined) -> integer() | undefined.
|
||||
to_int(Value, _Default) when is_integer(Value) ->
|
||||
Value;
|
||||
to_int(Value, _Default) when is_float(Value) ->
|
||||
trunc(Value);
|
||||
to_int(Value, Default) ->
|
||||
case to_string(Value, "") of
|
||||
"" ->
|
||||
Default;
|
||||
Str ->
|
||||
case string:to_integer(Str) of
|
||||
{Int, _} when is_integer(Int) -> Int;
|
||||
{error, _} -> Default
|
||||
end
|
||||
end.
|
||||
|
||||
-spec to_bool(term(), boolean() | undefined) -> boolean() | undefined.
|
||||
to_bool(Value, _Default) when is_boolean(Value) ->
|
||||
Value;
|
||||
to_bool(Value, Default) when is_atom(Value) ->
|
||||
case Value of
|
||||
true -> true;
|
||||
false -> false;
|
||||
_ -> Default
|
||||
end;
|
||||
to_bool(Value, Default) ->
|
||||
case string:lowercase(to_string(Value, "")) of
|
||||
"true" -> true;
|
||||
"1" -> true;
|
||||
"false" -> false;
|
||||
"0" -> false;
|
||||
_ -> Default
|
||||
end.
|
||||
|
||||
-spec to_string(term(), string()) -> string().
|
||||
to_string(Value, Default) when is_list(Default) ->
|
||||
case Value of
|
||||
undefined -> Default;
|
||||
Bin when is_binary(Bin) -> binary_to_list(Bin);
|
||||
Str when is_list(Str) -> Str;
|
||||
Atom when is_atom(Atom) -> atom_to_list(Atom);
|
||||
_ -> Default
|
||||
end.
|
||||
|
||||
-spec to_binary(term(), binary() | undefined) -> binary() | undefined.
|
||||
to_binary(Value, Default) ->
|
||||
case Value of
|
||||
undefined -> Default;
|
||||
Bin when is_binary(Bin) -> Bin;
|
||||
Str when is_list(Str) -> list_to_binary(Str);
|
||||
Atom when is_atom(Atom) -> list_to_binary(atom_to_list(Atom));
|
||||
_ -> Default
|
||||
end.
|
||||
|
||||
-spec normalize_log_level(term()) -> log_level() | undefined.
|
||||
normalize_log_level(undefined) ->
|
||||
undefined;
|
||||
normalize_log_level(Level) when is_atom(Level) ->
|
||||
normalize_log_level(atom_to_list(Level));
|
||||
normalize_log_level(Level) when is_binary(Level) ->
|
||||
normalize_log_level(binary_to_list(Level));
|
||||
normalize_log_level(Level) when is_list(Level) ->
|
||||
case string:lowercase(string:trim(Level)) of
|
||||
"debug" -> debug;
|
||||
"info" -> info;
|
||||
"notice" -> notice;
|
||||
"warning" -> warning;
|
||||
"error" -> error;
|
||||
"critical" -> critical;
|
||||
"alert" -> alert;
|
||||
"emergency" -> emergency;
|
||||
_ -> undefined
|
||||
end;
|
||||
normalize_log_level(_) ->
|
||||
undefined.
|
||||
128
fluxer_relay/src/relay/fluxer_relay_connection_manager.erl
Normal file
128
fluxer_relay/src/relay/fluxer_relay_connection_manager.erl
Normal file
@@ -0,0 +1,128 @@
|
||||
%% 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_connection_manager).
|
||||
-behaviour(gen_server).
|
||||
|
||||
-export([
|
||||
start_link/0,
|
||||
register_connection/2,
|
||||
unregister_connection/1,
|
||||
get_connection_count/1,
|
||||
get_all_connections/0
|
||||
]).
|
||||
|
||||
-export([
|
||||
init/1,
|
||||
handle_call/3,
|
||||
handle_cast/2,
|
||||
handle_info/2,
|
||||
terminate/2
|
||||
]).
|
||||
|
||||
-define(TABLE, fluxer_relay_connections).
|
||||
|
||||
-spec start_link() -> {ok, pid()} | {error, term()}.
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||
|
||||
-spec register_connection(binary(), pid()) -> ok | {error, limit_exceeded}.
|
||||
register_connection(InstanceDomain, Pid) ->
|
||||
gen_server:call(?MODULE, {register, InstanceDomain, Pid}).
|
||||
|
||||
-spec unregister_connection(pid()) -> ok.
|
||||
unregister_connection(Pid) ->
|
||||
gen_server:cast(?MODULE, {unregister, Pid}).
|
||||
|
||||
-spec get_connection_count(binary()) -> non_neg_integer().
|
||||
get_connection_count(InstanceDomain) ->
|
||||
gen_server:call(?MODULE, {count, InstanceDomain}).
|
||||
|
||||
-spec get_all_connections() -> map().
|
||||
get_all_connections() ->
|
||||
gen_server:call(?MODULE, get_all).
|
||||
|
||||
-spec init([]) -> {ok, map()}.
|
||||
init([]) ->
|
||||
ets:new(?TABLE, [named_table, bag, public, {read_concurrency, true}]),
|
||||
State = #{
|
||||
monitors => #{}
|
||||
},
|
||||
{ok, State}.
|
||||
|
||||
-spec handle_call(term(), {pid(), term()}, map()) -> {reply, term(), map()}.
|
||||
handle_call({register, InstanceDomain, Pid}, _From, State) ->
|
||||
MaxConns = fluxer_relay_env:get(max_connections_per_instance),
|
||||
CurrentCount = length(ets:lookup(?TABLE, InstanceDomain)),
|
||||
case CurrentCount >= MaxConns of
|
||||
true ->
|
||||
{reply, {error, limit_exceeded}, State};
|
||||
false ->
|
||||
ets:insert(?TABLE, {InstanceDomain, Pid}),
|
||||
MonRef = erlang:monitor(process, Pid),
|
||||
Monitors = maps:get(monitors, State),
|
||||
NewMonitors = Monitors#{MonRef => {InstanceDomain, Pid}},
|
||||
lager:debug("Registered connection from ~s (count: ~p)", [InstanceDomain, CurrentCount + 1]),
|
||||
{reply, ok, State#{monitors => NewMonitors}}
|
||||
end;
|
||||
|
||||
handle_call({count, InstanceDomain}, _From, State) ->
|
||||
Count = length(ets:lookup(?TABLE, InstanceDomain)),
|
||||
{reply, Count, State};
|
||||
|
||||
handle_call(get_all, _From, State) ->
|
||||
AllEntries = ets:tab2list(?TABLE),
|
||||
Grouped = lists:foldl(
|
||||
fun({Domain, Pid}, Acc) ->
|
||||
Pids = maps:get(Domain, Acc, []),
|
||||
Acc#{Domain => [Pid | Pids]}
|
||||
end,
|
||||
#{},
|
||||
AllEntries
|
||||
),
|
||||
{reply, Grouped, State};
|
||||
|
||||
handle_call(_Request, _From, State) ->
|
||||
{reply, {error, unknown_request}, State}.
|
||||
|
||||
-spec handle_cast(term(), map()) -> {noreply, map()}.
|
||||
handle_cast({unregister, Pid}, State) ->
|
||||
ets:match_delete(?TABLE, {'_', Pid}),
|
||||
{noreply, State};
|
||||
|
||||
handle_cast(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec handle_info(term(), map()) -> {noreply, map()}.
|
||||
handle_info({'DOWN', MonRef, process, Pid, _Reason}, State) ->
|
||||
Monitors = maps:get(monitors, State),
|
||||
case maps:get(MonRef, Monitors, undefined) of
|
||||
undefined ->
|
||||
{noreply, State};
|
||||
{InstanceDomain, Pid} ->
|
||||
ets:delete_object(?TABLE, {InstanceDomain, Pid}),
|
||||
NewMonitors = maps:remove(MonRef, Monitors),
|
||||
lager:debug("Connection from ~s terminated", [InstanceDomain]),
|
||||
{noreply, State#{monitors => NewMonitors}}
|
||||
end;
|
||||
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec terminate(term(), map()) -> ok.
|
||||
terminate(_Reason, _State) ->
|
||||
ok.
|
||||
82
fluxer_relay/src/relay/fluxer_relay_env.erl
Normal file
82
fluxer_relay/src/relay/fluxer_relay_env.erl
Normal file
@@ -0,0 +1,82 @@
|
||||
%% 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_env).
|
||||
|
||||
-export([load/0, get/1, get_optional/1, get_map/0, patch/1, update/1]).
|
||||
|
||||
-define(CONFIG_TERM_KEY, {fluxer_relay, runtime_config}).
|
||||
|
||||
-type config() :: map().
|
||||
|
||||
-spec load() -> config().
|
||||
load() ->
|
||||
Config = build_config(),
|
||||
apply_system_config(Config),
|
||||
set_config(Config).
|
||||
|
||||
-spec get(atom()) -> term().
|
||||
get(Key) when is_atom(Key) ->
|
||||
Map = get_map(),
|
||||
maps:get(Key, Map, undefined).
|
||||
|
||||
-spec get_optional(atom()) -> term().
|
||||
get_optional(Key) when is_atom(Key) ->
|
||||
?MODULE:get(Key).
|
||||
|
||||
-spec get_map() -> config().
|
||||
get_map() ->
|
||||
ensure_loaded().
|
||||
|
||||
-spec patch(map()) -> config().
|
||||
patch(Patch) when is_map(Patch) ->
|
||||
Map = get_map(),
|
||||
set_config(maps:merge(Map, Patch)).
|
||||
|
||||
-spec update(fun((config()) -> config())) -> config().
|
||||
update(Fun) when is_function(Fun, 1) ->
|
||||
Map = get_map(),
|
||||
set_config(Fun(Map)).
|
||||
|
||||
-spec set_config(config()) -> config().
|
||||
set_config(Config) when is_map(Config) ->
|
||||
persistent_term:put(?CONFIG_TERM_KEY, Config),
|
||||
Config.
|
||||
|
||||
-spec ensure_loaded() -> config().
|
||||
ensure_loaded() ->
|
||||
case persistent_term:get(?CONFIG_TERM_KEY, undefined) of
|
||||
Map when is_map(Map) ->
|
||||
Map;
|
||||
_ ->
|
||||
load()
|
||||
end.
|
||||
|
||||
-spec build_config() -> config().
|
||||
build_config() ->
|
||||
fluxer_relay_config:load().
|
||||
|
||||
-spec apply_system_config(config()) -> ok.
|
||||
apply_system_config(Config) ->
|
||||
apply_logger_config(Config),
|
||||
ok.
|
||||
|
||||
-spec apply_logger_config(config()) -> ok.
|
||||
apply_logger_config(Config) ->
|
||||
LoggerLevel = maps:get(logger_level, Config, info),
|
||||
lager:set_loglevel(lager_console_backend, LoggerLevel),
|
||||
ok.
|
||||
32
fluxer_relay/src/relay/fluxer_relay_health_handler.erl
Normal file
32
fluxer_relay/src/relay/fluxer_relay_health_handler.erl
Normal file
@@ -0,0 +1,32 @@
|
||||
%% 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_health_handler).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
|
||||
-spec init(cowboy_req:req(), term()) -> {ok, cowboy_req:req(), term()}.
|
||||
init(Req, State) ->
|
||||
Response = json:encode(#{
|
||||
<<"status">> => <<"ok">>,
|
||||
<<"service">> => <<"fluxer-relay">>
|
||||
}),
|
||||
Req2 = cowboy_req:reply(200, #{
|
||||
<<"content-type">> => <<"application/json">>
|
||||
}, Response, Req),
|
||||
{ok, Req2, State}.
|
||||
164
fluxer_relay/src/relay/fluxer_relay_http_handler.erl
Normal file
164
fluxer_relay/src/relay/fluxer_relay_http_handler.erl
Normal file
@@ -0,0 +1,164 @@
|
||||
%% 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}.
|
||||
240
fluxer_relay/src/relay/fluxer_relay_instance_discovery.erl
Normal file
240
fluxer_relay/src/relay/fluxer_relay_instance_discovery.erl
Normal file
@@ -0,0 +1,240 @@
|
||||
%% 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_instance_discovery).
|
||||
|
||||
-export([
|
||||
init/0,
|
||||
discover_gateway/1,
|
||||
clear_cache/0,
|
||||
clear_cache/1
|
||||
]).
|
||||
|
||||
-define(CACHE_TABLE, fluxer_relay_instance_cache).
|
||||
-define(CACHE_TTL_MS, 300000). %% 5 minutes
|
||||
-define(HTTP_TIMEOUT_MS, 10000). %% 10 seconds
|
||||
-define(HTTP_CONNECT_TIMEOUT_MS, 5000). %% 5 seconds
|
||||
|
||||
-type gateway_info() :: #{
|
||||
host := string(),
|
||||
port := inet:port_number(),
|
||||
use_tls := boolean()
|
||||
}.
|
||||
|
||||
-spec init() -> ok.
|
||||
init() ->
|
||||
case application:ensure_all_started(inets) of
|
||||
{ok, _} -> ok;
|
||||
{error, {already_started, inets}} -> ok
|
||||
end,
|
||||
case application:ensure_all_started(ssl) of
|
||||
{ok, _} -> ok;
|
||||
{error, {already_started, ssl}} -> ok
|
||||
end,
|
||||
case ets:info(?CACHE_TABLE) of
|
||||
undefined ->
|
||||
ets:new(?CACHE_TABLE, [
|
||||
named_table,
|
||||
public,
|
||||
set,
|
||||
{read_concurrency, true}
|
||||
]),
|
||||
lager:info("Instance discovery cache table created");
|
||||
_ ->
|
||||
ok
|
||||
end,
|
||||
ok.
|
||||
|
||||
-spec discover_gateway(binary() | string()) -> {ok, gateway_info()} | {error, term()}.
|
||||
discover_gateway(InstanceDomain) when is_binary(InstanceDomain) ->
|
||||
discover_gateway(binary_to_list(InstanceDomain));
|
||||
discover_gateway(InstanceDomain) when is_list(InstanceDomain) ->
|
||||
case get_cached(InstanceDomain) of
|
||||
{ok, GatewayInfo} ->
|
||||
lager:debug("Cache hit for instance ~s", [InstanceDomain]),
|
||||
{ok, GatewayInfo};
|
||||
miss ->
|
||||
lager:debug("Cache miss for instance ~s, fetching discovery", [InstanceDomain]),
|
||||
fetch_and_cache(InstanceDomain)
|
||||
end.
|
||||
|
||||
-spec clear_cache() -> ok.
|
||||
clear_cache() ->
|
||||
case ets:info(?CACHE_TABLE) of
|
||||
undefined ->
|
||||
ok;
|
||||
_ ->
|
||||
ets:delete_all_objects(?CACHE_TABLE),
|
||||
lager:info("Instance discovery cache cleared"),
|
||||
ok
|
||||
end.
|
||||
|
||||
-spec clear_cache(binary() | string()) -> ok.
|
||||
clear_cache(InstanceDomain) when is_binary(InstanceDomain) ->
|
||||
clear_cache(binary_to_list(InstanceDomain));
|
||||
clear_cache(InstanceDomain) when is_list(InstanceDomain) ->
|
||||
case ets:info(?CACHE_TABLE) of
|
||||
undefined ->
|
||||
ok;
|
||||
_ ->
|
||||
ets:delete(?CACHE_TABLE, InstanceDomain),
|
||||
lager:debug("Cache cleared for instance ~s", [InstanceDomain]),
|
||||
ok
|
||||
end.
|
||||
|
||||
|
||||
-spec get_cached(string()) -> {ok, gateway_info()} | miss.
|
||||
get_cached(InstanceDomain) ->
|
||||
case ets:info(?CACHE_TABLE) of
|
||||
undefined ->
|
||||
miss;
|
||||
_ ->
|
||||
Now = erlang:system_time(millisecond),
|
||||
case ets:lookup(?CACHE_TABLE, InstanceDomain) of
|
||||
[{InstanceDomain, GatewayInfo, ExpiresAt}] when ExpiresAt > Now ->
|
||||
{ok, GatewayInfo};
|
||||
[{InstanceDomain, _GatewayInfo, _ExpiresAt}] ->
|
||||
ets:delete(?CACHE_TABLE, InstanceDomain),
|
||||
miss;
|
||||
[] ->
|
||||
miss
|
||||
end
|
||||
end.
|
||||
|
||||
-spec fetch_and_cache(string()) -> {ok, gateway_info()} | {error, term()}.
|
||||
fetch_and_cache(InstanceDomain) ->
|
||||
case fetch_instance_info(InstanceDomain) of
|
||||
{ok, GatewayInfo} ->
|
||||
cache_result(InstanceDomain, GatewayInfo),
|
||||
{ok, GatewayInfo};
|
||||
{error, Reason} = Error ->
|
||||
lager:warning("Failed to fetch instance info for ~s: ~p", [InstanceDomain, Reason]),
|
||||
Error
|
||||
end.
|
||||
|
||||
-spec cache_result(string(), gateway_info()) -> ok.
|
||||
cache_result(InstanceDomain, GatewayInfo) ->
|
||||
case ets:info(?CACHE_TABLE) of
|
||||
undefined ->
|
||||
ok;
|
||||
_ ->
|
||||
ExpiresAt = erlang:system_time(millisecond) + ?CACHE_TTL_MS,
|
||||
ets:insert(?CACHE_TABLE, {InstanceDomain, GatewayInfo, ExpiresAt}),
|
||||
lager:debug("Cached gateway info for ~s (expires in ~p ms)", [InstanceDomain, ?CACHE_TTL_MS]),
|
||||
ok
|
||||
end.
|
||||
|
||||
-spec fetch_instance_info(string()) -> {ok, gateway_info()} | {error, term()}.
|
||||
fetch_instance_info(InstanceDomain) ->
|
||||
Url = "https://" ++ InstanceDomain ++ "/.well-known/fluxer",
|
||||
HttpOptions = [
|
||||
{timeout, ?HTTP_TIMEOUT_MS},
|
||||
{connect_timeout, ?HTTP_CONNECT_TIMEOUT_MS},
|
||||
{ssl, [{verify, verify_peer}, {cacerts, public_key:cacerts_get()}]}
|
||||
],
|
||||
Options = [{body_format, binary}],
|
||||
case httpc:request(get, {Url, []}, HttpOptions, Options) of
|
||||
{ok, {{_, 200, _}, _Headers, Body}} ->
|
||||
parse_instance_response(Body);
|
||||
{ok, {{_, StatusCode, _}, _Headers, Body}} ->
|
||||
lager:warning("Instance discovery failed for ~s: HTTP ~p, body: ~s",
|
||||
[InstanceDomain, StatusCode, Body]),
|
||||
{error, {http_error, StatusCode}};
|
||||
{error, Reason} ->
|
||||
lager:warning("HTTP request failed for ~s: ~p", [InstanceDomain, Reason]),
|
||||
{error, {request_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec parse_instance_response(binary()) -> {ok, gateway_info()} | {error, term()}.
|
||||
parse_instance_response(Body) ->
|
||||
try
|
||||
Json = json:decode(Body),
|
||||
case Json of
|
||||
#{<<"endpoints">> := Endpoints} ->
|
||||
case maps:get(<<"gateway">>, Endpoints, undefined) of
|
||||
undefined ->
|
||||
lager:warning("No gateway endpoint in instance response"),
|
||||
{error, no_gateway_endpoint};
|
||||
GatewayUrl when is_binary(GatewayUrl) ->
|
||||
parse_gateway_url(binary_to_list(GatewayUrl))
|
||||
end;
|
||||
_ ->
|
||||
lager:warning("Invalid instance response: missing endpoints"),
|
||||
{error, invalid_response}
|
||||
end
|
||||
catch
|
||||
error:badarg ->
|
||||
lager:warning("Failed to parse instance JSON response"),
|
||||
{error, json_parse_error}
|
||||
end.
|
||||
|
||||
-spec parse_gateway_url(string()) -> {ok, gateway_info()} | {error, term()}.
|
||||
parse_gateway_url(Url) ->
|
||||
case parse_url_components(Url) of
|
||||
{ok, Scheme, Host, Port} ->
|
||||
UseTls = case Scheme of
|
||||
"wss" -> true;
|
||||
"ws" -> false;
|
||||
"https" -> true;
|
||||
"http" -> false;
|
||||
_ -> true %% Default to TLS for unknown schemes
|
||||
end,
|
||||
GatewayInfo = #{
|
||||
host => Host,
|
||||
port => Port,
|
||||
use_tls => UseTls
|
||||
},
|
||||
lager:debug("Parsed gateway URL ~s -> ~p", [Url, GatewayInfo]),
|
||||
{ok, GatewayInfo};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec parse_url_components(string()) -> {ok, string(), string(), inet:port_number()} | {error, term()}.
|
||||
parse_url_components(Url) ->
|
||||
case string:split(Url, "://") of
|
||||
[Scheme, Rest] ->
|
||||
HostPortPath = case string:split(Rest, "/") of
|
||||
[HostPort, _Path] -> HostPort;
|
||||
[HostPort] -> HostPort
|
||||
end,
|
||||
parse_host_port(Scheme, HostPortPath);
|
||||
_ ->
|
||||
{error, invalid_url_format}
|
||||
end.
|
||||
|
||||
-spec parse_host_port(string(), string()) -> {ok, string(), string(), inet:port_number()} | {error, term()}.
|
||||
parse_host_port(Scheme, HostPort) ->
|
||||
DefaultPort = case Scheme of
|
||||
"wss" -> 443;
|
||||
"ws" -> 80;
|
||||
"https" -> 443;
|
||||
"http" -> 80;
|
||||
_ -> 443
|
||||
end,
|
||||
case string:split(HostPort, ":") of
|
||||
[Host, PortStr] ->
|
||||
try
|
||||
Port = list_to_integer(PortStr),
|
||||
{ok, Scheme, Host, Port}
|
||||
catch
|
||||
error:badarg ->
|
||||
{error, invalid_port}
|
||||
end;
|
||||
[Host] ->
|
||||
{ok, Scheme, Host, DefaultPort}
|
||||
end.
|
||||
46
fluxer_relay/src/relay/fluxer_relay_sup.erl
Normal file
46
fluxer_relay/src/relay/fluxer_relay_sup.erl
Normal file
@@ -0,0 +1,46 @@
|
||||
%% 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_sup).
|
||||
-behaviour(supervisor).
|
||||
-export([start_link/0, init/1]).
|
||||
|
||||
-spec start_link() -> {ok, pid()} | {error, term()}.
|
||||
start_link() ->
|
||||
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
|
||||
|
||||
-spec init([]) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
|
||||
init([]) ->
|
||||
SupFlags = #{
|
||||
strategy => one_for_one,
|
||||
intensity => 5,
|
||||
period => 10
|
||||
},
|
||||
Children = [
|
||||
child_spec(fluxer_relay_connection_manager, fluxer_relay_connection_manager)
|
||||
],
|
||||
{ok, {SupFlags, Children}}.
|
||||
|
||||
-spec child_spec(atom(), module()) -> supervisor:child_spec().
|
||||
child_spec(Id, Module) ->
|
||||
#{
|
||||
id => Id,
|
||||
start => {Module, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker
|
||||
}.
|
||||
419
fluxer_relay/src/relay/fluxer_relay_ws_handler.erl
Normal file
419
fluxer_relay/src/relay/fluxer_relay_ws_handler.erl
Normal file
@@ -0,0 +1,419 @@
|
||||
%% 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_ws_handler).
|
||||
-behaviour(cowboy_websocket).
|
||||
|
||||
-export([
|
||||
init/2,
|
||||
websocket_init/1,
|
||||
websocket_handle/2,
|
||||
websocket_info/2,
|
||||
terminate/3
|
||||
]).
|
||||
|
||||
-export([
|
||||
encode_multiplexed_frame/3,
|
||||
decode_multiplexed_frame/1
|
||||
]).
|
||||
|
||||
-define(ENCRYPTED_FRAME_PREFIX, 16#FE).
|
||||
-define(MULTIPLEXED_FRAME_PREFIX, 16#FD).
|
||||
|
||||
-spec init(cowboy_req:req(), term()) -> {cowboy_websocket, cowboy_req:req(), map(), map()}.
|
||||
init(Req, _State) ->
|
||||
Headers = cowboy_req:headers(Req),
|
||||
QsVals = cowboy_req:parse_qs(Req),
|
||||
OriginFromQs = proplists:get_value(<<"origin">>, QsVals, undefined),
|
||||
OriginFromHeader = maps:get(<<"x-relay-origin">>, Headers, undefined),
|
||||
OriginInstance = case OriginFromQs of
|
||||
undefined -> OriginFromHeader;
|
||||
Val -> Val
|
||||
end,
|
||||
TokenFromQs = proplists:get_value(<<"relay_token">>, QsVals, undefined),
|
||||
TokenFromHeader = maps:get(<<"x-relay-token">>, Headers, undefined),
|
||||
RelayToken = case TokenFromQs of
|
||||
undefined -> TokenFromHeader;
|
||||
Val2 -> Val2
|
||||
end,
|
||||
InitState = #{
|
||||
origin_instance => OriginInstance,
|
||||
relay_token => RelayToken,
|
||||
headers => Headers,
|
||||
instances => #{},
|
||||
conn_to_instance => #{},
|
||||
sequences => #{}
|
||||
},
|
||||
IdleTimeout = fluxer_relay_env:get(idle_timeout_ms),
|
||||
{cowboy_websocket, Req, InitState, #{idle_timeout => IdleTimeout}}.
|
||||
|
||||
-spec websocket_init(map()) -> {[{text, binary()}], map()}.
|
||||
websocket_init(State) ->
|
||||
case validate_connection(State) of
|
||||
ok ->
|
||||
lager:info("Relay WebSocket connection initialised"),
|
||||
{[], State};
|
||||
{error, Reason} ->
|
||||
ErrorMsg = json:encode(#{
|
||||
<<"op">> => 9,
|
||||
<<"d">> => #{<<"message">> => Reason}
|
||||
}),
|
||||
{[{close, 4000, Reason}], State#{error => ErrorMsg}}
|
||||
end.
|
||||
|
||||
-spec validate_connection(map()) -> ok | {error, binary()}.
|
||||
validate_connection(State) ->
|
||||
OriginInstance = maps:get(origin_instance, State),
|
||||
RelayToken = maps:get(relay_token, State),
|
||||
case {OriginInstance, RelayToken} of
|
||||
{undefined, _} ->
|
||||
{error, <<"Missing origin parameter">>};
|
||||
{_, undefined} ->
|
||||
{error, <<"Missing relay_token parameter">>};
|
||||
{_, _} ->
|
||||
ok
|
||||
end.
|
||||
|
||||
-spec websocket_handle({text | binary, binary()}, map()) -> {[{text | binary, binary()}], map()}.
|
||||
websocket_handle({text, _Data}, State) ->
|
||||
lager:warning("Received unsupported text frame in multiplexed relay"),
|
||||
{[], State};
|
||||
websocket_handle({binary, Data}, State) ->
|
||||
case is_multiplexed_frame(Data) of
|
||||
true ->
|
||||
case decode_multiplexed_frame(Data) of
|
||||
{ok, InstanceId, Seq, EncryptedPayload} ->
|
||||
handle_multiplexed_frame(InstanceId, Seq, EncryptedPayload, State);
|
||||
{error, Reason} ->
|
||||
lager:warning("Failed to decode multiplexed frame: ~p", [Reason]),
|
||||
{[], State}
|
||||
end;
|
||||
false ->
|
||||
lager:warning("Received non-multiplexed binary frame - ignoring"),
|
||||
{[], State}
|
||||
end;
|
||||
websocket_handle(_Frame, State) ->
|
||||
{[], State}.
|
||||
|
||||
-spec is_multiplexed_frame(binary()) -> boolean().
|
||||
is_multiplexed_frame(<<?MULTIPLEXED_FRAME_PREFIX, _Rest/binary>>) ->
|
||||
true;
|
||||
is_multiplexed_frame(_) ->
|
||||
false.
|
||||
|
||||
-spec encode_multiplexed_frame(binary(), non_neg_integer(), binary()) -> binary().
|
||||
encode_multiplexed_frame(InstanceId, Seq, EncryptedPayload) when is_binary(InstanceId), is_integer(Seq), is_binary(EncryptedPayload) ->
|
||||
InstanceIdLen = byte_size(InstanceId),
|
||||
PayloadLen = byte_size(EncryptedPayload),
|
||||
<<?MULTIPLEXED_FRAME_PREFIX:8,
|
||||
InstanceIdLen:16/big-unsigned,
|
||||
InstanceId/binary,
|
||||
Seq:32/big-unsigned,
|
||||
PayloadLen:32/big-unsigned,
|
||||
EncryptedPayload/binary>>.
|
||||
|
||||
-spec decode_multiplexed_frame(binary()) -> {ok, binary(), non_neg_integer(), binary()} | {error, term()}.
|
||||
decode_multiplexed_frame(<<?MULTIPLEXED_FRAME_PREFIX:8, Rest/binary>>) ->
|
||||
decode_multiplexed_frame_body(Rest);
|
||||
decode_multiplexed_frame(_) ->
|
||||
{error, invalid_prefix}.
|
||||
|
||||
-spec decode_multiplexed_frame_body(binary()) -> {ok, binary(), non_neg_integer(), binary()} | {error, term()}.
|
||||
decode_multiplexed_frame_body(<<InstanceIdLen:16/big-unsigned, Rest/binary>>) when byte_size(Rest) >= InstanceIdLen ->
|
||||
case Rest of
|
||||
<<InstanceId:InstanceIdLen/binary, Seq:32/big-unsigned, PayloadLen:32/big-unsigned, Payload/binary>> ->
|
||||
case byte_size(Payload) >= PayloadLen of
|
||||
true ->
|
||||
<<EncryptedPayload:PayloadLen/binary, _Trailing/binary>> = Payload,
|
||||
{ok, InstanceId, Seq, EncryptedPayload};
|
||||
false ->
|
||||
{error, payload_too_short}
|
||||
end;
|
||||
_ ->
|
||||
{error, incomplete_header}
|
||||
end;
|
||||
decode_multiplexed_frame_body(_) ->
|
||||
{error, incomplete_instance_id}.
|
||||
|
||||
|
||||
-spec handle_multiplexed_frame(binary(), non_neg_integer(), binary(), map()) -> {[{text | binary, binary()}], map()}.
|
||||
handle_multiplexed_frame(InstanceId, Seq, EncryptedPayload, State) ->
|
||||
Instances = maps:get(instances, State, #{}),
|
||||
case maps:get(InstanceId, Instances, undefined) of
|
||||
undefined ->
|
||||
case connect_to_instance(InstanceId, State) of
|
||||
{ok, NewState} ->
|
||||
forward_to_instance(InstanceId, EncryptedPayload, Seq, NewState);
|
||||
{error, Reason} ->
|
||||
lager:error("Failed to connect to instance ~s: ~p", [InstanceId, Reason]),
|
||||
ErrorPayload = json:encode(#{
|
||||
<<"error">> => <<"connection_failed">>,
|
||||
<<"instance">> => InstanceId,
|
||||
<<"reason">> => list_to_binary(io_lib:format("~p", [Reason]))
|
||||
}),
|
||||
ErrorFrame = encode_multiplexed_frame(InstanceId, 0, ErrorPayload),
|
||||
{[{binary, ErrorFrame}], State}
|
||||
end;
|
||||
_ConnInfo ->
|
||||
forward_to_instance(InstanceId, EncryptedPayload, Seq, State)
|
||||
end.
|
||||
|
||||
-spec connect_to_instance(binary(), map()) -> {ok, map()} | {error, term()}.
|
||||
connect_to_instance(InstanceId, State) ->
|
||||
Config = fluxer_relay_env:get_map(),
|
||||
Timeout = maps:get(connection_timeout_ms, Config, 30000),
|
||||
|
||||
OriginInstance = maps:get(origin_instance, State),
|
||||
GatewayResult = case InstanceId of
|
||||
OriginInstance ->
|
||||
UpstreamHost = maps:get(upstream_gateway_host, Config, "localhost:8081"),
|
||||
Tls = maps:get(upstream_use_tls, Config, false),
|
||||
{H, P} = parse_host_port(UpstreamHost, Tls),
|
||||
{ok, #{host => H, port => P, use_tls => Tls}};
|
||||
_ ->
|
||||
fluxer_relay_instance_discovery:discover_gateway(InstanceId)
|
||||
end,
|
||||
|
||||
case GatewayResult of
|
||||
{ok, #{host := Host, port := Port, use_tls := UseTls}} ->
|
||||
connect_to_gateway(Host, Port, UseTls, InstanceId, Timeout, State);
|
||||
{error, DiscoveryError} ->
|
||||
lager:error("Instance discovery failed for ~s: ~p", [InstanceId, DiscoveryError]),
|
||||
{error, {discovery_failed, DiscoveryError}}
|
||||
end.
|
||||
|
||||
-spec connect_to_gateway(string(), inet:port_number(), boolean(), binary(), non_neg_integer(), map()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
connect_to_gateway(Host, Port, UseTls, InstanceId, Timeout, State) ->
|
||||
OriginInstance = maps:get(origin_instance, State),
|
||||
Transport = case UseTls of true -> tls; false -> tcp end,
|
||||
GunOpts = #{
|
||||
transport => Transport,
|
||||
connect_timeout => Timeout,
|
||||
ws_opts => #{compress => true}
|
||||
},
|
||||
|
||||
case gun:open(Host, Port, GunOpts) of
|
||||
{ok, ConnPid} ->
|
||||
case gun:await_up(ConnPid, Timeout) of
|
||||
{ok, _Protocol} ->
|
||||
RelayToken = maps:get(relay_token, State, <<>>),
|
||||
WsHeaders = [
|
||||
{<<"x-relay-origin">>, OriginInstance},
|
||||
{<<"x-relay-target">>, InstanceId},
|
||||
{<<"x-relay-proxied">>, <<"true">>},
|
||||
{<<"x-relay-token">>, RelayToken}
|
||||
],
|
||||
StreamRef = gun:ws_upgrade(ConnPid, "/", WsHeaders),
|
||||
case gun:await(ConnPid, StreamRef, Timeout) of
|
||||
{upgrade, [<<"websocket">>], _Headers} ->
|
||||
lager:info("Connected to instance gateway ~s", [InstanceId]),
|
||||
|
||||
Instances = maps:get(instances, State, #{}),
|
||||
ConnToInstance = maps:get(conn_to_instance, State, #{}),
|
||||
|
||||
NewInstances = maps:put(InstanceId, #{
|
||||
conn => ConnPid,
|
||||
stream => StreamRef
|
||||
}, Instances),
|
||||
NewConnToInstance = maps:put(ConnPid, InstanceId, ConnToInstance),
|
||||
|
||||
NewState = State#{
|
||||
instances => NewInstances,
|
||||
conn_to_instance => NewConnToInstance
|
||||
},
|
||||
{ok, NewState};
|
||||
{error, Reason} ->
|
||||
gun:close(ConnPid),
|
||||
lager:error("WebSocket upgrade failed for ~s: ~p", [InstanceId, Reason]),
|
||||
{error, ws_upgrade_failed}
|
||||
end;
|
||||
{error, Reason} ->
|
||||
gun:close(ConnPid),
|
||||
lager:error("Failed to connect to instance ~s: ~p", [InstanceId, Reason]),
|
||||
{error, connection_failed}
|
||||
end;
|
||||
{error, Reason} ->
|
||||
lager:error("Failed to open connection to instance ~s: ~p", [InstanceId, Reason]),
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec forward_to_instance(binary(), binary(), non_neg_integer(), map()) -> {[{text | binary, binary()}], map()}.
|
||||
forward_to_instance(InstanceId, EncryptedPayload, Seq, State) ->
|
||||
Instances = maps:get(instances, State, #{}),
|
||||
case maps:get(InstanceId, Instances, undefined) of
|
||||
undefined ->
|
||||
lager:warning("No connection found for instance ~s", [InstanceId]),
|
||||
{[], State};
|
||||
#{conn := ConnPid, stream := StreamRef} ->
|
||||
Sequences = maps:get(sequences, State, #{}),
|
||||
NewSequences = maps:put(InstanceId, Seq, Sequences),
|
||||
|
||||
gun:ws_send(ConnPid, StreamRef, {binary, EncryptedPayload}),
|
||||
{[], State#{sequences => NewSequences}}
|
||||
end.
|
||||
|
||||
-spec find_instance_by_conn(pid(), map()) -> binary() | undefined.
|
||||
find_instance_by_conn(ConnPid, State) ->
|
||||
ConnToInstance = maps:get(conn_to_instance, State, #{}),
|
||||
maps:get(ConnPid, ConnToInstance, undefined).
|
||||
|
||||
-spec remove_instance_connection(binary(), map()) -> map().
|
||||
remove_instance_connection(InstanceId, State) ->
|
||||
Instances = maps:get(instances, State, #{}),
|
||||
ConnToInstance = maps:get(conn_to_instance, State, #{}),
|
||||
Sequences = maps:get(sequences, State, #{}),
|
||||
|
||||
ConnPid = case maps:get(InstanceId, Instances, undefined) of
|
||||
undefined -> undefined;
|
||||
#{conn := Pid} -> Pid
|
||||
end,
|
||||
|
||||
NewInstances = maps:remove(InstanceId, Instances),
|
||||
NewConnToInstance = case ConnPid of
|
||||
undefined -> ConnToInstance;
|
||||
_ -> maps:remove(ConnPid, ConnToInstance)
|
||||
end,
|
||||
NewSequences = maps:remove(InstanceId, Sequences),
|
||||
|
||||
State#{
|
||||
instances => NewInstances,
|
||||
conn_to_instance => NewConnToInstance,
|
||||
sequences => NewSequences
|
||||
}.
|
||||
|
||||
-spec websocket_info(term(), map()) -> {[{text | binary, binary()}], map()} | {[{close, integer(), binary()}], map()}.
|
||||
websocket_info({gun_ws, ConnPid, _StreamRef, {text, Data}}, State) ->
|
||||
case find_instance_by_conn(ConnPid, State) of
|
||||
undefined ->
|
||||
lager:warning("Received message from unknown connection pid"),
|
||||
{[], State};
|
||||
InstanceId ->
|
||||
{Seq, NewState} = get_and_increment_downstream_seq(InstanceId, State),
|
||||
Frame = encode_multiplexed_frame(InstanceId, Seq, Data),
|
||||
{[{binary, Frame}], NewState}
|
||||
end;
|
||||
websocket_info({gun_ws, ConnPid, _StreamRef, {binary, Data}}, State) ->
|
||||
case find_instance_by_conn(ConnPid, State) of
|
||||
undefined ->
|
||||
lager:warning("Received message from unknown connection pid"),
|
||||
{[], State};
|
||||
InstanceId ->
|
||||
{Seq, NewState} = get_and_increment_downstream_seq(InstanceId, State),
|
||||
Frame = encode_multiplexed_frame(InstanceId, Seq, Data),
|
||||
{[{binary, Frame}], NewState}
|
||||
end;
|
||||
websocket_info({gun_ws, ConnPid, _StreamRef, close}, State) ->
|
||||
case find_instance_by_conn(ConnPid, State) of
|
||||
undefined ->
|
||||
lager:warning("Received close from unknown connection pid"),
|
||||
{[], State};
|
||||
InstanceId ->
|
||||
lager:info("Instance ~s WebSocket closed", [InstanceId]),
|
||||
NewState = remove_instance_connection(InstanceId, State),
|
||||
ClosePayload = json:encode(#{
|
||||
<<"event">> => <<"instance_disconnected">>,
|
||||
<<"instance">> => InstanceId,
|
||||
<<"code">> => 1000,
|
||||
<<"reason">> => <<"closed">>
|
||||
}),
|
||||
Frame = encode_multiplexed_frame(InstanceId, 0, ClosePayload),
|
||||
{[{binary, Frame}], NewState}
|
||||
end;
|
||||
websocket_info({gun_ws, ConnPid, _StreamRef, {close, Code, Reason}}, State) ->
|
||||
case find_instance_by_conn(ConnPid, State) of
|
||||
undefined ->
|
||||
lager:warning("Received close from unknown connection pid: code=~p reason=~s", [Code, Reason]),
|
||||
{[], State};
|
||||
InstanceId ->
|
||||
lager:info("Instance ~s WebSocket closed with code ~p: ~s", [InstanceId, Code, Reason]),
|
||||
NewState = remove_instance_connection(InstanceId, State),
|
||||
ClosePayload = json:encode(#{
|
||||
<<"event">> => <<"instance_disconnected">>,
|
||||
<<"instance">> => InstanceId,
|
||||
<<"code">> => Code,
|
||||
<<"reason">> => Reason
|
||||
}),
|
||||
Frame = encode_multiplexed_frame(InstanceId, 0, ClosePayload),
|
||||
{[{binary, Frame}], NewState}
|
||||
end;
|
||||
websocket_info({gun_down, ConnPid, _Protocol, Reason, _KilledStreams}, State) ->
|
||||
case find_instance_by_conn(ConnPid, State) of
|
||||
undefined ->
|
||||
lager:warning("Connection down from unknown pid: ~p", [Reason]),
|
||||
{[], State};
|
||||
InstanceId ->
|
||||
lager:warning("Instance ~s connection down: ~p", [InstanceId, Reason]),
|
||||
NewState = remove_instance_connection(InstanceId, State),
|
||||
ClosePayload = json:encode(#{
|
||||
<<"event">> => <<"instance_disconnected">>,
|
||||
<<"instance">> => InstanceId,
|
||||
<<"code">> => 1001,
|
||||
<<"reason">> => <<"connection_lost">>
|
||||
}),
|
||||
Frame = encode_multiplexed_frame(InstanceId, 0, ClosePayload),
|
||||
{[{binary, Frame}], NewState}
|
||||
end;
|
||||
websocket_info({gun_error, ConnPid, _StreamRef, Reason}, State) ->
|
||||
case find_instance_by_conn(ConnPid, State) of
|
||||
undefined ->
|
||||
lager:warning("Error from unknown connection pid: ~p", [Reason]),
|
||||
{[], State};
|
||||
InstanceId ->
|
||||
lager:error("Instance ~s WebSocket error: ~p", [InstanceId, Reason]),
|
||||
NewState = remove_instance_connection(InstanceId, State),
|
||||
ErrorPayload = json:encode(#{
|
||||
<<"event">> => <<"instance_error">>,
|
||||
<<"instance">> => InstanceId,
|
||||
<<"code">> => 1011,
|
||||
<<"reason">> => list_to_binary(io_lib:format("~p", [Reason]))
|
||||
}),
|
||||
Frame = encode_multiplexed_frame(InstanceId, 0, ErrorPayload),
|
||||
{[{binary, Frame}], NewState}
|
||||
end;
|
||||
websocket_info(_Info, State) ->
|
||||
{[], State}.
|
||||
|
||||
-spec get_and_increment_downstream_seq(binary(), map()) -> {non_neg_integer(), map()}.
|
||||
get_and_increment_downstream_seq(InstanceId, State) ->
|
||||
Sequences = maps:get(sequences, State, #{}),
|
||||
CurrentSeq = maps:get(InstanceId, Sequences, 0),
|
||||
NextSeq = CurrentSeq + 1,
|
||||
NewSequences = maps:put(InstanceId, NextSeq, Sequences),
|
||||
{NextSeq, State#{sequences => NewSequences}}.
|
||||
|
||||
-spec terminate(term(), term(), map()) -> ok.
|
||||
terminate(_Reason, _Req, State) ->
|
||||
Instances = maps:get(instances, State, #{}),
|
||||
maps:foreach(
|
||||
fun(_InstanceId, #{conn := InstConnPid}) ->
|
||||
gun:close(InstConnPid)
|
||||
end,
|
||||
Instances
|
||||
),
|
||||
ok.
|
||||
|
||||
-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.
|
||||
Reference in New Issue
Block a user