Files
fx-test/fluxer/fluxer_gateway/src/gateway/hot_reload_handler.erl
Vish 3b9d759b4b feat: add fluxer upstream source and self-hosting documentation
- Clone of github.com/fluxerapp/fluxer (official upstream)
- SELF_HOSTING.md: full VM rebuild procedure, architecture overview,
  service reference, step-by-step setup, troubleshooting, seattle reference
- dev/.env.example: all env vars with secrets redacted and generation instructions
- dev/livekit.yaml: LiveKit config template with placeholder keys
- fluxer-seattle/: existing seattle deployment setup scripts
2026-03-13 00:55:14 -07:00

316 lines
11 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(hot_reload_handler).
-export([init/2]).
-define(JSON_HEADERS, #{<<"content-type">> => <<"application/json">>}).
-define(MAX_MODULES, 600).
-define(MAX_BODY_BYTES, 26214400).
-type purge_mode() :: none | soft | hard.
-spec init(cowboy_req:req(), term()) -> {ok, cowboy_req:req(), term()}.
init(Req0, State) ->
case cowboy_req:method(Req0) of
<<"POST">> ->
handle_post(Req0, State);
_ ->
Req = cowboy_req:reply(405, #{<<"allow">> => <<"POST">>}, <<>>, Req0),
{ok, Req, State}
end.
-spec handle_post(cowboy_req:req(), term()) -> {ok, cowboy_req:req(), term()}.
handle_post(Req0, State) ->
case authorize(Req0) of
ok ->
case read_body(Req0) of
{ok, Decoded, Req1} ->
handle_reload(Decoded, Req1, State);
{error, Status, ErrorBody, Req1} ->
respond(Status, ErrorBody, Req1, State)
end;
{error, Req1} ->
{ok, Req1, State}
end.
-spec authorize(cowboy_req:req()) -> ok | {error, cowboy_req:req()}.
authorize(Req0) ->
case cowboy_req:header(<<"authorization">>, Req0) of
undefined ->
Req = cowboy_req:reply(
401,
?JSON_HEADERS,
json:encode(#{<<"error">> => <<"Unauthorized">>}),
Req0
),
{error, Req};
AuthHeader ->
authorize_with_secret(AuthHeader, Req0)
end.
-spec authorize_with_secret(binary(), cowboy_req:req()) -> ok | {error, cowboy_req:req()}.
authorize_with_secret(AuthHeader, Req0) ->
case fluxer_gateway_env:get(admin_reload_secret) of
undefined ->
Req = cowboy_req:reply(
500,
?JSON_HEADERS,
json:encode(#{<<"error">> => <<"admin reload secret not configured">>}),
Req0
),
{error, Req};
Secret when is_binary(Secret) ->
check_auth_header(AuthHeader, <<"Bearer ", Secret/binary>>, Req0);
Secret when is_list(Secret) ->
check_auth_header(AuthHeader, <<"Bearer ", (list_to_binary(Secret))/binary>>, Req0)
end.
-spec check_auth_header(binary(), binary(), cowboy_req:req()) -> ok | {error, cowboy_req:req()}.
check_auth_header(AuthHeader, Expected, Req0) ->
case secure_compare(AuthHeader, Expected) of
true ->
ok;
false ->
Req = cowboy_req:reply(
401,
?JSON_HEADERS,
json:encode(#{<<"error">> => <<"Unauthorized">>}),
Req0
),
{error, Req}
end.
-spec secure_compare(binary(), binary()) -> boolean().
secure_compare(Left, Right) when is_binary(Left), is_binary(Right) ->
case byte_size(Left) =:= byte_size(Right) of
true ->
crypto:hash_equals(Left, Right);
false ->
false
end.
-spec read_body(cowboy_req:req()) ->
{ok, map(), cowboy_req:req()} | {error, pos_integer(), map(), cowboy_req:req()}.
read_body(Req0) ->
case cowboy_req:body_length(Req0) of
Length when is_integer(Length), Length > ?MAX_BODY_BYTES ->
{error, 413, #{<<"error">> => <<"Request body too large">>}, Req0};
_ ->
read_body_chunks(Req0, <<>>)
end.
-spec read_body_chunks(cowboy_req:req(), binary()) ->
{ok, map(), cowboy_req:req()} | {error, pos_integer(), map(), cowboy_req:req()}.
read_body_chunks(Req0, Acc) ->
case cowboy_req:read_body(Req0, #{length => 1048576}) of
{ok, Body, Req1} ->
FullBody = <<Acc/binary, Body/binary>>,
decode_body(FullBody, Req1);
{more, Body, Req1} ->
NewAcc = <<Acc/binary, Body/binary>>,
case byte_size(NewAcc) > ?MAX_BODY_BYTES of
true ->
{error, 413, #{<<"error">> => <<"Request body too large">>}, Req1};
false ->
read_body_chunks(Req1, NewAcc)
end
end.
-spec decode_body(binary(), cowboy_req:req()) ->
{ok, map(), cowboy_req:req()} | {error, pos_integer(), map(), cowboy_req:req()}.
decode_body(<<>>, Req0) ->
{ok, #{}, Req0};
decode_body(Body, Req0) ->
case catch json:decode(Body) of
{'EXIT', _Reason} ->
{error, 400, #{<<"error">> => <<"Invalid JSON payload">>}, Req0};
Decoded when is_map(Decoded) ->
{ok, Decoded, Req0};
_ ->
{error, 400, #{<<"error">> => <<"Invalid request body">>}, Req0}
end.
-spec handle_reload(map(), cowboy_req:req(), term()) -> {ok, cowboy_req:req(), term()}.
handle_reload(Params, Req0, State) ->
try
Purge = parse_purge(maps:get(<<"purge">>, Params, <<"soft">>)),
case maps:get(<<"beams">>, Params, undefined) of
undefined ->
handle_modules_reload(Params, Purge, Req0, State);
Beams when is_list(Beams) ->
handle_beams_reload(Beams, Purge, Req0, State);
_ ->
respond(400, #{<<"error">> => <<"beams must be an array">>}, Req0, State)
end
catch
error:badarg ->
respond(400, #{<<"error">> => <<"Invalid module name or beam payload">>}, Req0, State);
error:invalid_beam ->
respond(400, #{<<"error">> => <<"Invalid module name or beam payload">>}, Req0, State);
error:{beam_module_mismatch, _, _} ->
respond(400, #{<<"error">> => <<"Invalid module name or beam payload">>}, Req0, State);
_:_Reason ->
respond(500, #{<<"error">> => <<"Internal error">>}, Req0, State)
end.
-spec handle_beams_reload([map()], purge_mode(), cowboy_req:req(), term()) ->
{ok, cowboy_req:req(), term()}.
handle_beams_reload(Beams, Purge, Req0, State) ->
case length(Beams) =< ?MAX_MODULES of
true ->
Pairs = decode_beams(Beams),
{ok, Results} = hot_reload:reload_beams(Pairs, #{purge => Purge}),
respond(200, #{<<"results">> => Results}, Req0, State);
false ->
respond(400, #{<<"error">> => <<"Too many modules">>}, Req0, State)
end.
-spec handle_modules_reload(map(), purge_mode(), cowboy_req:req(), term()) ->
{ok, cowboy_req:req(), term()}.
handle_modules_reload(Params, Purge, Req0, State) ->
case maps:get(<<"modules">>, Params, []) of
[] ->
{ok, Results} = hot_reload:reload_all_changed(Purge),
respond(200, #{<<"results">> => Results}, Req0, State);
Modules when is_list(Modules) ->
case length(Modules) =< ?MAX_MODULES of
true ->
ModuleAtoms = lists:map(fun to_module_atom/1, Modules),
{ok, Results} = hot_reload:reload_modules(ModuleAtoms, #{purge => Purge}),
respond(200, #{<<"results">> => Results}, Req0, State);
false ->
respond(400, #{<<"error">> => <<"Too many modules">>}, Req0, State)
end;
_ ->
respond(400, #{<<"error">> => <<"modules must be an array">>}, Req0, State)
end.
-spec decode_beams([map()]) -> [{atom(), binary()}].
decode_beams(Beams) ->
lists:map(
fun(Elem) ->
case Elem of
#{<<"module">> := Mod0, <<"beam_b64">> := B640} ->
ModBin = to_binary(Mod0),
Module = to_module_atom(ModBin),
B64Bin = to_binary(B640),
BeamBin = base64:decode(B64Bin),
case beam_lib:md5(BeamBin) of
{ok, {Module, _}} -> ok;
{ok, {Other, _}} -> erlang:error({beam_module_mismatch, Module, Other});
_ -> erlang:error(invalid_beam)
end,
{Module, BeamBin};
_ ->
erlang:error(badarg)
end
end,
Beams
).
-spec to_binary(binary() | list()) -> binary().
to_binary(B) when is_binary(B) ->
B;
to_binary(L) when is_list(L) ->
list_to_binary(L);
to_binary(_) ->
erlang:error(badarg).
-spec parse_purge(binary() | atom()) -> purge_mode().
parse_purge(<<"none">>) -> none;
parse_purge(<<"soft">>) -> soft;
parse_purge(<<"hard">>) -> hard;
parse_purge(none) -> none;
parse_purge(soft) -> soft;
parse_purge(hard) -> hard;
parse_purge(_) -> soft.
-spec to_module_atom(binary() | list()) -> atom().
to_module_atom(B) when is_binary(B) ->
case is_allowed_module_name(B) of
true -> erlang:binary_to_atom(B, utf8);
false -> erlang:error(badarg)
end;
to_module_atom(L) when is_list(L) ->
to_module_atom(list_to_binary(L));
to_module_atom(_) ->
erlang:error(badarg).
-spec is_allowed_module_name(binary()) -> boolean().
is_allowed_module_name(Bin) when is_binary(Bin) ->
byte_size(Bin) > 0 andalso byte_size(Bin) < 128 andalso
is_safe_chars(Bin) andalso has_allowed_prefix(Bin).
-spec is_safe_chars(binary()) -> boolean().
is_safe_chars(Bin) ->
lists:all(
fun(C) ->
(C >= $a andalso C =< $z) orelse
(C >= $0 andalso C =< $9) orelse
(C =:= $_)
end,
binary_to_list(Bin)
).
-spec has_allowed_prefix(binary()) -> boolean().
has_allowed_prefix(Bin) ->
Prefixes = [
<<"fluxer_">>,
<<"gateway">>,
<<"gateway_http_">>,
<<"session">>,
<<"guild">>,
<<"presence">>,
<<"push">>,
<<"push_dispatcher">>,
<<"call">>,
<<"health">>,
<<"hot_reload">>,
<<"rpc_client">>,
<<"rendezvous">>,
<<"process_">>,
<<"metrics_">>,
<<"dm_voice">>,
<<"voice_">>,
<<"constants">>,
<<"validation">>,
<<"backoff_">>,
<<"list_ops">>,
<<"map_utils">>,
<<"type_conv">>,
<<"utils">>,
<<"user_utils">>,
<<"snowflake_">>,
<<"custom_status">>,
<<"otel_">>,
<<"event_">>
],
lists:any(
fun(P) ->
Sz = byte_size(P),
byte_size(Bin) >= Sz andalso binary:part(Bin, 0, Sz) =:= P
end,
Prefixes
).
-spec respond(pos_integer(), map(), cowboy_req:req(), term()) -> {ok, cowboy_req:req(), term()}.
respond(Status, Body, Req0, State) ->
Req = cowboy_req:reply(Status, ?JSON_HEADERS, json:encode(Body), Req0),
{ok, Req, State}.