mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(push): gate relay delivery on operator consent (#2984)
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user