fix(config): carry the public port into derived endpoints (#2329)

This commit is contained in:
Hampus
2026-09-01 20:47:18 +02:00
committed by GitHub
parent a93f9dd0af
commit bc40073a02
21 changed files with 2037 additions and 55 deletions
@@ -29,6 +29,7 @@ env_config() ->
#{
<<"env">> => env_binary("FLUXER_ENV", <<"development">>),
<<"internal">> => env_internal_config(),
<<"public">> => env_public_config(),
<<"proxy">> => env_proxy_config(),
<<"services">> => env_services_config(),
<<"auth">> => env_auth_config(),
@@ -42,6 +43,14 @@ env_internal_config() ->
<<"api">> => env_binary("FLUXER_INTERNAL_API_ENDPOINT", <<"http://127.0.0.1:8080">>)
}.
-spec env_public_config() -> map().
env_public_config() ->
#{
<<"base_domain">> => env_optional_binary("FLUXER_BASE_DOMAIN"),
<<"scheme">> => env_optional_binary("FLUXER_PUBLIC_SCHEME"),
<<"port">> => env_optional_binary("FLUXER_PUBLIC_PORT")
}.
-spec env_proxy_config() -> map().
env_proxy_config() ->
#{
@@ -185,12 +194,13 @@ build_config(RawConfig) ->
Apns = get_map(Push, [<<"apns">>]),
Fcm = get_map(Push, [<<"fcm">>]),
Proxy = get_map(RawConfig, [<<"proxy">>]),
Public = get_map(RawConfig, [<<"public">>]),
lists:foldl(fun maps:merge/2, #{}, [
build_core_config(Service, Internal, Nats, Proxy),
build_push_config(Service),
build_push_config(Service, Public),
build_sharding_config(Service),
build_http_config(Service),
build_cluster_config(Service),
build_cluster_config(Service, Public),
build_vapid_config(Vapid),
build_apns_config(Apns),
build_fcm_config(Fcm),
@@ -215,8 +225,8 @@ build_core_config(Service, Internal, Nats, Proxy) ->
)
}.
-spec build_push_config(map()) -> config().
build_push_config(Service) ->
-spec build_push_config(map(), map()) -> config().
build_push_config(Service, Public) ->
#{
push_enabled => get_bool(Service, <<"push_enabled">>, true),
push_user_guild_settings_cache_mb =>
@@ -228,8 +238,8 @@ build_push_config(Service) ->
push_badge_counts_cache_mb => get_int(Service, <<"push_badge_counts_cache_mb">>, 256),
push_badge_counts_cache_ttl_seconds =>
get_int(Service, <<"push_badge_counts_cache_ttl_seconds">>, 60),
static_cdn_endpoint => get_binary(
Service, <<"static_cdn_endpoint">>, <<"http://localhost:8088">>
static_cdn_endpoint => public_endpoint(
get_binary(Service, <<"static_cdn_endpoint">>, <<"http://localhost:8088">>), Public
),
push_dispatcher_max_inflight => get_int(
Service, <<"push_dispatcher_max_inflight">>, 16
@@ -276,8 +286,8 @@ build_http_config(Service) ->
get_int(Service, <<"gateway_http_cleanup_max_age_ms">>, 300000)
}.
-spec build_cluster_config(map()) -> config().
build_cluster_config(Service) ->
-spec build_cluster_config(map(), map()) -> config().
build_cluster_config(Service, Public) ->
#{
cluster_enabled => get_bool(Service, <<"cluster_enabled">>, false),
cluster_discovery_dns_name =>
@@ -290,7 +300,9 @@ build_cluster_config(Service) ->
get_int(Service, <<"cluster_discovery_poll_interval_ms">>, 5000),
cluster_static_peers =>
parse_node_list(get_optional_binary(Service, <<"cluster_static_peers">>)),
media_proxy_endpoint => get_optional_binary(Service, <<"media_proxy_endpoint">>)
media_proxy_endpoint => public_endpoint(
get_optional_binary(Service, <<"media_proxy_endpoint">>), Public
)
}.
-spec build_vapid_config(map()) -> config().
@@ -601,6 +613,17 @@ normalize_gateway_role(Value) when is_list(Value) ->
normalize_gateway_role(_) ->
all.
-spec public_endpoint(binary() | undefined, map()) -> binary() | undefined.
public_endpoint(undefined, _Public) ->
undefined;
public_endpoint(Url, Public) ->
gateway_public_endpoint:normalize(
Url,
get_optional_binary(Public, <<"base_domain">>),
get_optional_binary(Public, <<"scheme">>),
get_optional_int(Public, <<"port">>)
).
-spec optional_string(binary() | undefined) -> string() | undefined.
optional_string(undefined) -> undefined;
optional_string(Bin) when is_binary(Bin) -> binary_to_list(Bin).
@@ -0,0 +1,97 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(gateway_public_endpoint).
-typing([eqwalizer]).
-export([normalize/4]).
-spec normalize(binary(), binary() | undefined, binary() | undefined, integer() | undefined) ->
binary().
normalize(Url, BaseDomain, _PublicScheme, PublicPort) when
is_binary(Url), is_binary(BaseDomain), is_integer(PublicPort), PublicPort > 0
->
case base_domain(BaseDomain) of
<<>> -> Url;
Domain -> insert_port(Url, Domain, PublicPort)
end;
normalize(Url, _BaseDomain, _PublicScheme, _PublicPort) ->
Url.
-spec base_domain(binary()) -> binary().
base_domain(BaseDomain) ->
case string:trim(BaseDomain) of
Trimmed when is_binary(Trimmed) -> trim_root_dot(Trimmed);
_ -> <<>>
end.
-spec insert_port(binary(), binary(), integer()) -> binary().
insert_port(Url, Domain, Port) ->
case binary:split(Url, <<"://">>) of
[Scheme, Rest] -> insert_authority_port(Url, Scheme, Rest, Domain, Port);
[_] -> Url
end.
-spec insert_authority_port(binary(), binary(), binary(), binary(), integer()) -> binary().
insert_authority_port(Url, _Scheme, <<"/", _/binary>>, _Domain, _Port) ->
Url;
insert_authority_port(Url, Scheme, Rest, Domain, Port) ->
{Authority, Tail} = split_authority(Rest),
case insertable(Scheme, Authority, Domain, Port) of
true -> join_authority_port(Scheme, Authority, Tail, Port);
false -> Url
end.
-spec insertable(binary(), binary(), binary(), integer()) -> boolean().
insertable(Scheme, Authority, Domain, Port) ->
Host = after_last(Authority, <<"@">>),
Default = default_port(Scheme),
is_integer(Default) andalso Port =/= Default andalso not has_port(Host) andalso
same_host(Host, Domain).
-spec join_authority_port(binary(), binary(), binary(), integer()) -> binary().
join_authority_port(Scheme, Authority, Tail, Port) ->
PortBin = integer_to_binary(Port),
<<Scheme/binary, "://", Authority/binary, ":", PortBin/binary, Tail/binary>>.
-spec split_authority(binary()) -> {binary(), binary()}.
split_authority(Rest) ->
case binary:match(Rest, [<<"/">>, <<"\\">>, <<"?">>, <<"#">>]) of
{Pos, _} -> split_binary(Rest, Pos);
nomatch -> {Rest, <<>>}
end.
-spec after_last(binary(), binary()) -> binary().
after_last(Bin, Separator) ->
case binary:split(Bin, Separator) of
[_, Rest] -> after_last(Rest, Separator);
[Tail] -> Tail
end.
-spec has_port(binary()) -> boolean().
has_port(Host) ->
binary:match(after_last(Host, <<"]">>), <<":">>) =/= nomatch.
-spec same_host(binary(), binary()) -> boolean().
same_host(<<"[", _/binary>> = Host, Domain) ->
string:equal(Host, Domain, true);
same_host(Host, Domain) ->
string:equal(trim_root_dot(Host), Domain, true).
-spec trim_root_dot(binary()) -> binary().
trim_root_dot(<<>>) ->
<<>>;
trim_root_dot(Host) ->
case binary:last(Host) of
$. -> binary:part(Host, 0, byte_size(Host) - 1);
_ -> Host
end.
-spec default_port(binary()) -> integer() | undefined.
default_port(Scheme) ->
case string:lowercase(Scheme) of
<<"http">> -> 80;
<<"https">> -> 443;
<<"ws">> -> 80;
<<"wss">> -> 443;
_ -> undefined
end.
@@ -150,6 +150,52 @@ optional_string_test() ->
?assertEqual("hello", fluxer_gateway_config:optional_string(<<"hello">>)),
?assertEqual("", fluxer_gateway_config:optional_string(<<>>)).
public_endpoints_env_non_default_port_test() ->
with_envs(
[
{"FLUXER_BASE_DOMAIN", "fluxer.example"},
{"FLUXER_PUBLIC_SCHEME", "https"},
{"FLUXER_PUBLIC_PORT", "8443"},
{"FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT", "https://fluxer.example/media"},
{"FLUXER_GATEWAY_STATIC_CDN_ENDPOINT", "https://fluxer.example"}
],
fun() ->
Config = fluxer_gateway_config:load(),
?assertEqual(
<<"https://fluxer.example:8443/media">>,
maps:get(media_proxy_endpoint, Config)
),
?assertEqual(
<<"https://fluxer.example:8443">>, maps:get(static_cdn_endpoint, Config)
)
end
).
public_endpoints_env_default_port_test() ->
with_envs(
[
{"FLUXER_BASE_DOMAIN", "fluxer.example"},
{"FLUXER_PUBLIC_SCHEME", "https"},
{"FLUXER_PUBLIC_PORT", "443"},
{"FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT", "https://fluxer.example/media"},
{"FLUXER_GATEWAY_STATIC_CDN_ENDPOINT", "https://cdn.othercdn.net"}
],
fun() ->
Config = fluxer_gateway_config:load(),
?assertEqual(
<<"https://fluxer.example/media">>, maps:get(media_proxy_endpoint, Config)
),
?assertEqual(
<<"https://cdn.othercdn.net">>, maps:get(static_cdn_endpoint, Config)
)
end
).
public_endpoints_defaults_test() ->
Config = fluxer_gateway_config:build_config(#{}),
?assertEqual(undefined, maps:get(media_proxy_endpoint, Config)),
?assertEqual(<<"http://localhost:8088">>, maps:get(static_cdn_endpoint, Config)).
with_envs([], Fun) ->
Fun();
with_envs([{Name, Value} | Rest], Fun) ->
@@ -0,0 +1,248 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(gateway_public_endpoint_tests).
-typing([eqwalizer]).
-include_lib("eunit/include/eunit.hrl").
-define(VECTORS_PATH, "../fluxer_common/src/testdata/public_endpoint_vectors.json").
normalize_default_https_install_test() ->
?assertEqual(
<<"https://fluxer.example/media">>,
normalize(<<"https://fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 443)
),
?assertEqual(
<<"https://fluxer.example">>,
normalize(<<"https://fluxer.example">>, <<"fluxer.example">>, <<"https">>, 443)
),
?assertEqual(
<<"wss://fluxer.example/gateway">>,
normalize(<<"wss://fluxer.example/gateway">>, <<"fluxer.example">>, <<"https">>, 443)
).
normalize_default_http_install_test() ->
?assertEqual(
<<"http://fluxer.example/media">>,
normalize(<<"http://fluxer.example/media">>, <<"fluxer.example">>, <<"http">>, 80)
),
?assertEqual(
<<"ws://fluxer.example/gateway">>,
normalize(<<"ws://fluxer.example/gateway">>, <<"fluxer.example">>, <<"http">>, 80)
).
normalize_inserts_non_default_port_test() ->
?assertEqual(
<<"https://fluxer.example:8443/media">>,
normalize(<<"https://fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 8443)
),
?assertEqual(
<<"https://fluxer.example:8443">>,
normalize(<<"https://fluxer.example">>, <<"fluxer.example">>, <<"https">>, 8443)
),
?assertEqual(
<<"http://fluxer.example:8080/media">>,
normalize(<<"http://fluxer.example/media">>, <<"fluxer.example">>, <<"http">>, 8080)
),
?assertEqual(
<<"http://fluxer.example:443/media">>,
normalize(<<"http://fluxer.example/media">>, <<"fluxer.example">>, <<"http">>, 443)
).
normalize_preserves_url_parts_test() ->
?assertEqual(
<<"https://fluxer.example:8443/">>,
normalize(<<"https://fluxer.example/">>, <<"fluxer.example">>, <<"https">>, 8443)
),
?assertEqual(
<<"https://fluxer.example:8443/Media/Path?q=A%20b#Frag">>,
normalize(
<<"https://fluxer.example/Media/Path?q=A%20b#Frag">>,
<<"fluxer.example">>,
<<"https">>,
8443
)
),
?assertEqual(
<<"https://[email protected]:8443/media">>,
normalize(
<<"https://[email protected]/media">>, <<"fluxer.example">>, <<"https">>, 8443
)
).
normalize_host_matching_test() ->
?assertEqual(
<<"https://Fluxer.EXAMPLE.:8443/media">>,
normalize(<<"https://Fluxer.EXAMPLE./media">>, <<"fluxer.example">>, <<"https">>, 8443)
),
?assertEqual(
<<"https://cdn.othercdn.net/assets">>,
normalize(
<<"https://cdn.othercdn.net/assets">>, <<"fluxer.example">>, <<"https">>, 8443
)
),
?assertEqual(
<<"https://media.fluxer.example/media">>,
normalize(
<<"https://media.fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 8443
)
).
normalize_keeps_explicit_port_test() ->
?assertEqual(
<<"https://fluxer.example:9443/media">>,
normalize(
<<"https://fluxer.example:9443/media">>, <<"fluxer.example">>, <<"https">>, 8443
)
),
?assertEqual(
<<"https://fluxer.example:8443/media">>,
normalize(
<<"https://fluxer.example:8443/media">>, <<"fluxer.example">>, <<"https">>, 8443
)
),
?assertEqual(
<<"https://fluxer.example:/media">>,
normalize(<<"https://fluxer.example:/media">>, <<"fluxer.example">>, <<"https">>, 8443)
).
normalize_missing_inputs_test() ->
?assertEqual(
<<"https://fluxer.example/media">>,
normalize(
<<"https://fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, undefined
)
),
?assertEqual(
<<"https://fluxer.example/media">>,
normalize(<<"https://fluxer.example/media">>, undefined, <<"https">>, 8443)
),
?assertEqual(
<<"https://fluxer.example/media">>,
normalize(<<"https://fluxer.example/media">>, <<>>, <<"https">>, 8443)
).
normalize_unparsable_url_test() ->
?assertEqual(
<<"not a url">>,
normalize(<<"not a url">>, <<"fluxer.example">>, <<"https">>, 8443)
),
?assertEqual(<<>>, normalize(<<>>, <<"fluxer.example">>, <<"https">>, 8443)),
?assertEqual(
<<"//fluxer.example/media">>,
normalize(<<"//fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 8443)
).
normalize_ipv6_host_test() ->
?assertEqual(
<<"http://[::1]:19080/media">>,
normalize(<<"http://[::1]/media">>, <<"[::1]">>, <<"http">>, 19080)
),
?assertEqual(
<<"http://[2001:DB8::1]:19080/media">>,
normalize(<<"http://[2001:DB8::1]/media">>, <<"[2001:db8::1]">>, <<"http">>, 19080)
),
?assertEqual(
<<"http://[::1]:8080/media">>,
normalize(<<"http://[::1]:8080/media">>, <<"[::1]">>, <<"http">>, 19080)
).
normalize_userinfo_at_sign_test() ->
?assertEqual(
<<"http://user:p@[email protected]:19080/media">>,
normalize(
<<"http://user:p@[email protected]/media">>, <<"fluxer.example">>, <<"http">>, 19080
)
),
?assertEqual(
<<"http://user:p@[email protected]:19080/media">>,
normalize(
<<"http://user:p@[email protected]:19080/media">>,
<<"fluxer.example">>,
<<"http">>,
19080
)
).
normalize_backslash_authority_test() ->
?assertEqual(
<<"http://fluxer.example:19080\\evil">>,
normalize(<<"http://fluxer.example\\evil">>, <<"fluxer.example">>, <<"http">>, 19080)
).
normalize_zero_port_test() ->
?assertEqual(
<<"http://fluxer.example/media">>,
normalize(<<"http://fluxer.example/media">>, <<"fluxer.example">>, <<"http">>, 0)
).
normalize_single_root_dot_test() ->
?assertEqual(
<<"http://fluxer.example.:19080/media">>,
normalize(<<"http://fluxer.example./media">>, <<"fluxer.example.">>, <<"http">>, 19080)
),
?assertEqual(
<<"http://fluxer.example../media">>,
normalize(<<"http://fluxer.example../media">>, <<"fluxer.example">>, <<"http">>, 19080)
),
?assertEqual(
<<"http://fluxer.example/media">>,
normalize(<<"http://fluxer.example/media">>, <<"fluxer.example..">>, <<"http">>, 19080)
).
normalize_unsupported_scheme_test() ->
?assertEqual(
<<"file:///media">>,
normalize(<<"file:///media">>, <<"fluxer.example">>, <<"http">>, 19080)
),
?assertEqual(
<<"mailto:[email protected]">>,
normalize(<<"mailto:[email protected]">>, <<"fluxer.example">>, <<"http">>, 19080)
).
normalize_matches_shared_vectors_test() ->
Vectors = read_vectors(),
?assertMatch([_ | _], Vectors),
lists:foreach(fun run_vector/1, Vectors).
read_vectors() ->
case file:read_file(?VECTORS_PATH) of
{ok, Contents} ->
decode_vectors(Contents);
{error, Reason} ->
erlang:error({public_endpoint_vectors_unreadable, ?VECTORS_PATH, Reason})
end.
decode_vectors(Contents) ->
case json:decode(Contents) of
[_ | _] = Vectors -> Vectors;
_ -> erlang:error({public_endpoint_vectors_empty, ?VECTORS_PATH})
end.
run_vector(
#{<<"url">> := Url, <<"base_domain">> := BaseDomain, <<"normalized">> := Expected} = Vector
) when is_binary(Url), is_binary(BaseDomain), is_binary(Expected) ->
Port = vector_port(Vector),
?assertEqual(
{Url, BaseDomain, Port, Expected},
{Url, BaseDomain, Port, normalize(Url, BaseDomain, undefined, Port)}
);
run_vector(Vector) ->
erlang:error({public_endpoint_vector_malformed, Vector}).
vector_port(#{<<"public_port">> := null}) ->
undefined;
vector_port(#{<<"public_port">> := Port}) when is_integer(Port) ->
Port;
vector_port(Vector) ->
erlang:error({public_endpoint_vector_malformed, Vector}).
normalize_is_idempotent_test() ->
Once = normalize(
<<"https://fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 8443
),
Twice = normalize(Once, <<"fluxer.example">>, <<"https">>, 8443),
?assertEqual(<<"https://fluxer.example:8443/media">>, Once),
?assertEqual(Once, Twice).
normalize(Url, BaseDomain, Scheme, Port) ->
gateway_public_endpoint:normalize(Url, BaseDomain, Scheme, Port).