feat(push): gate relay delivery on operator consent (#2984)

This commit is contained in:
Hampus
2026-09-27 20:33:10 +02:00
committed by GitHub
parent 336b8b7dcd
commit 01f53a168d
22 changed files with 1012 additions and 26 deletions
@@ -19,6 +19,7 @@
-type gateway_role() :: websocket | sessions | presence | guilds | calls | push | all.
-define(MAX_CLUSTER_STATIC_PEERS, 256).
-define(DEFAULT_MANAGED_RELAY_HOSTS, <<"push.fluxer.com">>).
-spec load() -> config().
load() ->
@@ -87,6 +88,12 @@ env_gateway_base_config() ->
<<"push_endpoint_guard_enabled">> => env_bool(
"FLUXER_GATEWAY_PUSH_ENDPOINT_GUARD_ENABLED", true
),
<<"push_managed_relay_hosts">> => env_binary(
"FLUXER_GATEWAY_PUSH_MANAGED_RELAY_HOSTS", ?DEFAULT_MANAGED_RELAY_HOSTS
),
<<"push_relay_consent_accepted">> => env_bool(
"FLUXER_GATEWAY_PUSH_RELAY_CONSENT_ACCEPTED", false
),
<<"push_outbox_request_timeout_ms">> => env_int(
"FLUXER_GATEWAY_PUSH_OUTBOX_REQUEST_TIMEOUT_MS", 100000
),
@@ -268,6 +275,12 @@ build_push_config(Service, Public) ->
push_endpoint_guard_enabled => get_bool(
Service, <<"push_endpoint_guard_enabled">>, true
),
push_managed_relay_hosts => parse_host_list(
get_binary(Service, <<"push_managed_relay_hosts">>, ?DEFAULT_MANAGED_RELAY_HOSTS)
),
push_relay_consent_accepted => get_bool(
Service, <<"push_relay_consent_accepted">>, false
),
push_outbox_max_queue => get_int(Service, <<"push_outbox_max_queue">>, 10000),
push_outbox_max_inflight => get_int(Service, <<"push_outbox_max_inflight">>, 64),
push_outbox_request_timeout_ms => get_int(
@@ -590,6 +603,24 @@ to_binary(Str, _) when is_list(Str) -> list_to_binary(config_char_list(Str));
to_binary(Atom, _) when is_atom(Atom) -> list_to_binary(atom_to_list(Atom));
to_binary(_, Default) -> Default.
-spec parse_host_list(binary()) -> [binary()].
parse_host_list(Bin) ->
parse_host_list(string:lexemes(binary_to_list(Bin), ", \t"), []).
-spec parse_host_list([string()], [binary()]) -> [binary()].
parse_host_list([], Acc) ->
lists:reverse(Acc);
parse_host_list([Host | Rest], Acc) ->
parse_host_list(Rest, [list_to_binary(lower_string(Host)) | Acc]).
-spec lower_string(string()) -> string().
lower_string(Value) ->
[lower_char(Char) || Char <- Value].
-spec lower_char(char()) -> char().
lower_char(Char) when Char >= $A, Char =< $Z -> Char + 32;
lower_char(Char) -> Char.
-spec parse_node_list(binary() | undefined) -> [node()].
parse_node_list(undefined) ->
[];
@@ -37,7 +37,8 @@
rollout_basis_points := non_neg_integer(),
rollout_salt := binary(),
included := user_id_set(),
excluded := user_id_set()
excluded := user_id_set(),
relay_consent_accepted := boolean()
}.
-type state() :: #{
nats_subscription := term(),
@@ -170,7 +171,8 @@ default_config() ->
rollout_basis_points => 0,
rollout_salt => ?DEFAULT_SALT,
included => #{},
excluded => #{}
excluded => #{},
relay_consent_accepted => false
}.
-spec fetch_config_from_api() -> store_result().
@@ -254,7 +256,8 @@ config_fields() ->
{rollout_basis_points, <<"rollout_basis_points">>, fun validate_basis_points/1},
{rollout_salt, <<"rollout_salt">>, fun validate_salt/1},
{included, <<"included_user_ids">>, fun validate_user_ids/1},
{excluded, <<"excluded_user_ids">>, fun validate_user_ids/1}
{excluded, <<"excluded_user_ids">>, fun validate_user_ids/1},
{relay_consent_accepted, <<"relay_consent_accepted">>, fun validate_enabled/1}
].
-spec validate_field(
@@ -419,7 +422,7 @@ result_index(rejected) -> 4.
log_config_transitions(Previous, Current) ->
lists:foreach(
fun(Key) -> log_key_transition(Key, Previous, Current) end,
[enabled, rollout_basis_points, config_version]
[enabled, rollout_basis_points, config_version, relay_consent_accepted]
).
-spec log_key_transition(atom(), config(), config()) -> ok.
@@ -435,3 +438,22 @@ log_key_transition(Key, Previous, Current) ->
[Key, PreviousValue, CurrentValue]
)
end.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
an_accepted_relay_notice_is_read_off_the_wire_config_test() ->
{ok, Config} = validate_config(#{<<"relay_consent_accepted">> => true}),
?assertEqual(true, maps:get(relay_consent_accepted, Config)).
a_wire_config_without_a_relay_notice_has_not_been_accepted_test() ->
{ok, Config} = validate_config(#{<<"enabled">> => true}),
?assertEqual(false, maps:get(relay_consent_accepted, Config)).
a_relay_notice_that_is_not_a_boolean_is_refused_test() ->
?assertMatch(
{error, {invalid_field, <<"relay_consent_accepted">>, _}},
validate_config(#{<<"relay_consent_accepted">> => <<"yes">>})
).
-endif.
@@ -21,6 +21,12 @@
-define(OVERLOAD_MAX_DELAY_MS, 4000).
-define(VAPID_TOKEN_TTL_SECONDS, 43200).
-define(VAPID_TOKEN_SKEW_SECONDS, 60).
-define(DEFAULT_MANAGED_RELAY_HOSTS, ["push.fluxer.com"]).
-define(MANAGED_RELAY_PATH_PREFIXES, [
"/relay/v1/apns/",
"/relay/v1/apns-voip/",
"/relay/v1/fcm/"
]).
-type push_response() :: {ok, integer(), term(), binary()} | {error, term()}.
@@ -40,12 +46,94 @@ send_webpush_notification(UserId, Subscription, Payload) ->
send_to_allowed_endpoint(UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload) ->
case push_endpoint_guard:check(Endpoint) of
ok ->
send_with_vapid(UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload);
send_to_consented_endpoint(
UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload
);
{error, Reason} ->
log_endpoint_rejected(UserId, SubscriptionId, Reason),
false
end.
-spec send_to_consented_endpoint(integer(), binary(), binary(), binary(), binary(), map()) ->
false | {true, map()}.
send_to_consented_endpoint(UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload) ->
case relay_consent_missing(Endpoint) of
false ->
send_with_vapid(UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload);
true ->
log_endpoint_rejected(UserId, SubscriptionId, relay_consent_required),
false
end.
-spec relay_consent_missing(binary()) -> boolean().
relay_consent_missing(Endpoint) ->
not relay_consent_accepted() andalso is_managed_relay_endpoint(Endpoint).
-spec relay_consent_accepted() -> boolean().
relay_consent_accepted() ->
env_relay_consent_accepted() orelse instance_relay_consent_accepted().
-spec env_relay_consent_accepted() -> boolean().
env_relay_consent_accepted() ->
case fluxer_gateway_env:get(push_relay_consent_accepted) of
Accepted when is_boolean(Accepted) -> Accepted;
_ -> false
end.
-spec instance_relay_consent_accepted() -> boolean().
instance_relay_consent_accepted() ->
case maps:get(relay_consent_accepted, push_delivery_config:config(), false) of
Accepted when is_boolean(Accepted) -> Accepted;
_ -> false
end.
-spec is_managed_relay_endpoint(binary()) -> boolean().
is_managed_relay_endpoint(Endpoint) ->
case safe_parse_endpoint(Endpoint) of
{ok, Parsed} ->
Scheme = lower_string(to_string(maps:get(scheme, Parsed, ""))),
Host = lower_string(to_string(maps:get(host, Parsed, ""))),
Path = to_string(maps:get(path, Parsed, "")),
Scheme =:= "https" andalso
lists:member(Host, managed_relay_hosts()) andalso
is_managed_relay_path(Path);
error ->
false
end.
-spec safe_parse_endpoint(binary()) -> {ok, map()} | error.
safe_parse_endpoint(Endpoint) ->
try uri_string:parse(binary_to_list(Endpoint)) of
Parsed when is_map(Parsed) -> {ok, Parsed};
_ -> error
catch
_:_ -> error
end.
-spec is_managed_relay_path(string()) -> boolean().
is_managed_relay_path(Path) ->
lists:any(fun(Prefix) -> lists:prefix(Prefix, Path) end, ?MANAGED_RELAY_PATH_PREFIXES).
-spec managed_relay_hosts() -> [string()].
managed_relay_hosts() ->
case fluxer_gateway_env:get(push_managed_relay_hosts) of
Hosts when is_list(Hosts) -> [lower_string(to_string(Host)) || Host <- Hosts];
_ -> ?DEFAULT_MANAGED_RELAY_HOSTS
end.
-spec to_string(term()) -> string().
to_string(Value) when is_list(Value) -> Value;
to_string(Value) when is_binary(Value) -> binary_to_list(Value);
to_string(_Value) -> "".
-spec lower_string(string()) -> string().
lower_string(Value) ->
[lower_char(Char) || Char <- Value].
-spec lower_char(char()) -> char().
lower_char(Char) when Char >= $A, Char =< $Z -> Char + 32;
lower_char(Char) -> Char.
-spec log_endpoint_rejected(integer(), binary(), term()) -> ok.
log_endpoint_rejected(UserId, SubscriptionId, Reason) ->
logger:debug(
@@ -775,10 +863,100 @@ capture_web_push(Payload) ->
vapid_env_meck(vapid_email) -> <<"[email protected]">>;
vapid_env_meck(vapid_public_key) -> <<"public-key">>;
vapid_env_meck(vapid_private_key) -> <<"private-key">>;
vapid_env_meck(push_relay_consent_accepted) -> true;
vapid_env_meck(push_managed_relay_hosts) -> [<<"push.fluxer.com">>];
vapid_env_meck(Key) -> meck:passthrough([Key]).
capture_request_meck(push, post, _Endpoint, Headers, Body, _Opts) ->
self() ! {captured_push, Headers, Body},
{ok, 201, [], <<>>}.
a_managed_relay_endpoint_is_refused_without_operator_consent_test() ->
?assertEqual(no_push_request, attempt_push(false, managed_relay_endpoint(<<"apns">>))).
every_managed_relay_leg_is_refused_without_operator_consent_test() ->
lists:foreach(
fun(Leg) ->
?assertEqual(no_push_request, attempt_push(false, managed_relay_endpoint(Leg)))
end,
[<<"apns">>, <<"apns-voip">>, <<"fcm">>]
).
a_managed_relay_endpoint_is_delivered_once_the_operator_consents_test() ->
Endpoint = managed_relay_endpoint(<<"apns">>),
?assertEqual(Endpoint, attempt_push(true, Endpoint)).
a_notice_accepted_in_the_instance_config_lets_the_managed_relay_send_through_test() ->
Endpoint = managed_relay_endpoint(<<"apns">>),
?assertEqual(Endpoint, attempt_push(false, true, Endpoint)).
a_unified_push_endpoint_is_delivered_whatever_the_operator_accepted_test() ->
Endpoint = <<"https://ntfy.sh/upZzH87cT9jJCc?up=1">>,
?assertEqual(Endpoint, attempt_push(false, Endpoint)),
?assertEqual(Endpoint, attempt_push(true, Endpoint)).
a_relay_we_do_not_operate_is_delivered_without_consent_test() ->
Endpoint = <<"https://push.example.org/relay/v1/apns/stable/production/token">>,
?assertEqual(Endpoint, attempt_push(false, Endpoint)).
managed_relay_endpoint(Leg) ->
<<"https://push.fluxer.com/relay/v1/", Leg/binary, "/stable/production/",
(binary:copy(<<"a">>, 64))/binary>>.
attempt_push(EnvConsent, Endpoint) ->
attempt_push(EnvConsent, false, Endpoint).
attempt_push(EnvConsent, InstanceConsent, Endpoint) ->
{PeerPub, _PeerPriv} = crypto:generate_key(ecdh, prime256v1),
Subscription = #{
<<"endpoint">> => Endpoint,
<<"p256dh_key">> => push_utils:base64url_encode(PeerPub),
<<"auth_key">> => push_utils:base64url_encode(crypto:strong_rand_bytes(16)),
<<"subscription_id">> => <<"sub-1">>
},
ok = push_ets_cache:init(),
ok = meck:new(fluxer_gateway_env, [passthrough, no_link]),
ok = meck:new(push_utils, [passthrough, no_link]),
ok = meck:new(gateway_http_client, [passthrough, no_link]),
ok = meck:new(push_endpoint_guard, [passthrough, no_link]),
ok = meck:new(push_delivery_config, [passthrough, no_link]),
try
ok = meck:expect(push_endpoint_guard, check, fun(_Endpoint) -> ok end),
ok = meck:expect(push_delivery_config, config, fun() ->
#{relay_consent_accepted => InstanceConsent}
end),
ok = meck:expect(fluxer_gateway_env, get, consent_env_meck(EnvConsent)),
ok = meck:expect(push_utils, generate_vapid_token, fun(_Claims, _Public, _Private) ->
<<"vapid-token">>
end),
ok = meck:expect(gateway_http_client, request, fun requested_endpoint_meck/6),
?assertEqual(
false, send_webpush_notification(42, Subscription, alert_payload(<<"Hello">>))
),
receive
{push_requested, Requested} -> Requested
after 100 ->
no_push_request
end
after
meck:unload(push_delivery_config),
meck:unload(push_endpoint_guard),
meck:unload(gateway_http_client),
meck:unload(push_utils),
meck:unload(fluxer_gateway_env)
end.
consent_env_meck(EnvConsent) ->
fun
(push_relay_consent_accepted) -> EnvConsent;
(push_managed_relay_hosts) -> [<<"push.fluxer.com">>];
(Key) -> vapid_env_meck(Key)
end.
-spec requested_endpoint_meck(atom(), atom(), binary(), list(), binary(), term()) ->
{ok, non_neg_integer(), list(), binary()}.
requested_endpoint_meck(push, post, Endpoint, _Headers, _Body, _Opts) ->
self() ! {push_requested, Endpoint},
{ok, 201, [], <<>>}.
-endif.