Files
firezone/elixir/apps/api/lib/api/client/channel.ex
Jamil cafe6554ff refactor(portal): reduce cache memory usage (#10058)
Napkin math shows that we can save substantial memory (~3x or more) on
the API nodes as connected clients/gateways grow if we just store the
fields we need in order to keep the client and gateway state maintained
in the channel pids.

To facilitate this, we create new `Cacheable` structs that represent
their `Domain` cousins, which use byte arrays for `id`s and strip out
unused fields.

Additionally, all business logic involved with maintaining these caches
is now contained within two modules: `Domain.Cache.Client` and
`Domain.Cache.Gateway`, and type specs have been added to aid in static
analysis and code documentation.

Comprehensive testing is now added not only for the cache modules, but
for their associated channel modules as well to ensure we handle
different kinds of edge cases gracefully.

The `Events` nomenclature was renamed to `Changes` to better name what
we are doing: Change-Data-Capture.

Lastly, the following related changes are included in this PR since they
were "in the way" so to speak of getting this done:

- We save the last received LSN in each channel and drop the `change`
with a warning if we receive it twice in a row, or we receive it out of
order
- The client/gateway version compatibility calculations have been moved
to `Domain.Resources` and `Domain.Gateways` and have been simplified to
make them easier to understand and maintain going forward.


Related: #10174 
Fixes: #9392 
Fixes: #9965
Fixes: #9501 
Fixes: #10227

---------

Signed-off-by: Jamil <jamilbk@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-22 21:52:29 +00:00

952 lines
28 KiB
Elixir

defmodule API.Client.Channel do
use API, :channel
alias API.Client.Views
alias Domain.{
Accounts,
Clients,
Cache,
Changes.Change,
Actors,
PubSub,
Resources,
Flows,
Gateways,
Relays,
Policies,
Flows
}
alias Domain.Relays.Presence.Debouncer
require Logger
require OpenTelemetry.Tracer
# For time-based policy conditions, we need to determine whether we still have access
# If not, we need to send resource_deleted so that if it's added back later, the client's
# connlib state will be cleaned up so it can request a new connection.
@recompute_authorized_resources_every :timer.minutes(1)
####################################
##### Channel lifecycle events #####
####################################
@impl true
def join("client", _payload, socket) do
send(self(), :after_join)
{:ok, socket}
end
@impl true
# Called immediately after the client joins the channel
def handle_info(:after_join, socket) do
# Schedule reassessing allowed resources
Process.send_after(
self(),
:recompute_authorized_resources,
@recompute_authorized_resources_every
)
# Get initial list of authorized resources, hydrating the cache
{:ok, resources, [], cache} =
Cache.Client.recompute_connectable_resources(nil, socket.assigns.client)
# Initialize relays
{:ok, relays} = select_relays(socket)
:ok = Enum.each(relays, &Relays.subscribe_to_relay_presence/1)
:ok = maybe_subscribe_for_relays_presence(relays, socket)
# Initialize debouncer for flappy relays
socket = Debouncer.cache_stamp_secrets(socket, relays)
# Track client's presence
:ok = Clients.Presence.connect(socket.assigns.client)
# Subscribe to all account updates
:ok = PubSub.Account.subscribe(socket.assigns.client.account_id)
# Delete any stale flows for resources we may not have access to anymore based on policy conditions
Flows.delete_stale_flows_on_connect(
socket.assigns.client,
Enum.map(resources, &Ecto.UUID.load!(&1.id))
)
push(socket, "init", %{
resources: Views.Resource.render_many(resources),
relays:
Views.Relay.render_many(
relays,
socket.assigns.client.public_key,
socket.assigns.subject.expires_at
),
interface:
Views.Interface.render(%{
socket.assigns.client
| account: socket.assigns.subject.account
})
})
{:noreply, assign(socket, cache: cache)}
end
# Called to actually push relays_presence with a disconnected relay to the client
def handle_info({:push_leave, relay_id, stamp_secret, payload}, socket) do
{:noreply, Debouncer.handle_leave(socket, relay_id, stamp_secret, payload, &push/3)}
end
####################################
##### Reacting to domain events ####
####################################
def handle_info(%Change{lsn: lsn} = change, socket) do
last_lsn = Map.get(socket.assigns, :last_lsn, 0)
if lsn <= last_lsn do
Logger.warning("Out of order or duplicate change received; ignoring",
change: change,
last_lsn: last_lsn
)
{:noreply, socket}
else
socket = assign(socket, last_lsn: lsn)
handle_change(change, socket)
end
end
####################################
##### Reacting to timed events #####
####################################
# This is needed to keep the client's resource list up to date for time-based policy conditions
# since we will not receive any change messages to react to when time-based policies expire.
def handle_info(:recompute_authorized_resources, socket) do
Process.send_after(
self(),
:recompute_authorized_resources,
@recompute_authorized_resources_every
)
{:ok, added_resources, removed_ids, cache} =
Cache.Client.recompute_connectable_resources(socket.assigns.cache, socket.assigns.client)
for resource_id <- removed_ids do
push(socket, "resource_deleted", resource_id)
end
for resource <- added_resources do
push(socket, "resource_created_or_updated", Views.Resource.render(resource))
end
{:noreply, assign(socket, cache: cache)}
end
####################################
#### Reacting to relay presence ####
####################################
def handle_info(
%Phoenix.Socket.Broadcast{
event: "presence_diff",
topic: "presences:relays:" <> relay_id,
payload: %{leaves: leaves}
},
socket
) do
if Map.has_key?(leaves, relay_id) do
:ok = Relays.unsubscribe_from_relay_presence(relay_id)
{:ok, relays} = select_relays(socket, [relay_id])
:ok = maybe_subscribe_for_relays_presence(relays, socket)
:ok =
Enum.each(relays, fn relay ->
# TODO: Why are we unsubscribing and subscribing again?
:ok = Relays.unsubscribe_from_relay_presence(relay)
:ok = Relays.subscribe_to_relay_presence(relay)
end)
payload = %{
disconnected_ids: [relay_id],
connected:
Views.Relay.render_many(
relays,
socket.assigns.client.public_key,
socket.assigns.subject.expires_at
)
}
{:noreply, Debouncer.queue_leave(self(), socket, relay_id, payload)}
else
{:noreply, socket}
end
end
def handle_info(
%Phoenix.Socket.Broadcast{
event: "presence_diff",
topic: "presences:" <> _,
payload: %{joins: joins}
},
socket
) do
if Enum.count(joins) > 0 do
{:ok, relays} = select_relays(socket)
if length(relays) > 0 do
:ok = Relays.unsubscribe_from_relays_presence_in_account(socket.assigns.subject.account)
:ok =
Enum.each(relays, fn relay ->
# TODO: Why are we unsubscribing and subscribing again?
:ok = Relays.unsubscribe_from_relay_presence(relay)
:ok = Relays.subscribe_to_relay_presence(relay)
end)
# Cache new stamp secrets
socket = Debouncer.cache_stamp_secrets(socket, relays)
# If a relay reconnects with a different stamp_secret, disconnect them immediately
joined_ids = Map.keys(joins)
{socket, disconnected_ids} =
Debouncer.cancel_leaves_or_disconnect_immediately(
socket,
joined_ids,
socket.assigns.client.account_id
)
{:ok, relays} = select_relays(socket)
push(socket, "relays_presence", %{
disconnected_ids: disconnected_ids,
connected:
Views.Relay.render_many(
relays,
socket.assigns.client.public_key,
socket.assigns.subject.expires_at
)
})
{:noreply, socket}
else
{:noreply, socket}
end
else
{:noreply, socket}
end
end
#############################################################
##### Forwarding replies from the gateway to the client #####
#############################################################
# This the list of ICE candidates gathered by the gateway and relayed to the client
def handle_info(
{{:ice_candidates, client_id}, gateway_id, candidates},
%{assigns: %{client: %{id: id}}} = socket
)
when client_id == id do
push(socket, "ice_candidates", %{
gateway_id: gateway_id,
candidates: candidates
})
{:noreply, socket}
end
def handle_info(
{{:invalidate_ice_candidates, client_id}, gateway_id, candidates},
%{assigns: %{client: %{id: id}}} = socket
)
when client_id == id do
push(socket, "invalidate_ice_candidates", %{
gateway_id: gateway_id,
candidates: candidates
})
{:noreply, socket}
end
# DEPRECATED IN 1.4
# This message is sent by the gateway when it is ready to accept the connection from the client
def handle_info(
{:connect, socket_ref, rid_bytes, gateway_public_key, payload},
socket
) do
reply(
socket_ref,
{:ok,
%{
resource_id: Ecto.UUID.load!(rid_bytes),
persistent_keepalive: 25,
gateway_public_key: gateway_public_key,
gateway_payload: payload
}}
)
{:noreply, socket}
end
def handle_info(
{:connect, _socket_ref, rid_bytes, gateway_group_id, gateway_id, gateway_public_key,
gateway_ipv4, gateway_ipv6, preshared_key, ice_credentials},
socket
) do
reply_payload = %{
resource_id: Ecto.UUID.load!(rid_bytes),
preshared_key: preshared_key,
client_ice_credentials: ice_credentials.client,
gateway_group_id: gateway_group_id,
gateway_id: gateway_id,
gateway_public_key: gateway_public_key,
gateway_ipv4: gateway_ipv4,
gateway_ipv6: gateway_ipv6,
gateway_ice_credentials: ice_credentials.gateway
}
push(socket, "flow_created", reply_payload)
{:noreply, socket}
end
# Catch-all for messages we don't handle
def handle_info(_message, socket), do: {:noreply, socket}
####################################
##### Client-initiated actions #####
####################################
# This message is sent to the client to request a network flow with a gateway that can serve given resource.
#
# `connected_gateway_ids` is used to indicate that the client is already connected to some of the gateways,
# so the gateway can be reused by multiplexing the connection.
def handle_in(
"create_flow",
%{
"resource_id" => resource_id,
"connected_gateway_ids" => connected_gateway_ids
},
socket
) do
with {:ok, resource, membership_id, policy_id, expires_at} <-
Cache.Client.authorize_resource(
socket.assigns.cache,
socket.assigns.client,
resource_id,
socket.assigns.subject
),
{:ok, gateways} when gateways != [] <-
Gateways.all_compatible_gateways_for_client_and_resource(
socket.assigns.client,
resource,
socket.assigns.subject
) do
location = {
socket.assigns.client.last_seen_remote_ip_location_lat,
socket.assigns.client.last_seen_remote_ip_location_lon
}
gateway = Gateways.load_balance_gateways(location, gateways, connected_gateway_ids)
# TODO: Optimization
# Move this to a Task.start that completes after broadcasting authorize_flow
{:ok, flow} =
Flows.create_flow(
socket.assigns.client,
gateway,
resource_id,
policy_id,
membership_id,
socket.assigns.subject,
expires_at
)
preshared_key = generate_preshared_key(socket.assigns.client, gateway)
ice_credentials = generate_ice_credentials(socket.assigns.client, gateway)
:ok =
PubSub.Account.broadcast(
socket.assigns.client.account_id,
{{:authorize_flow, gateway.id}, {self(), socket_ref(socket)},
%{
client: socket.assigns.client,
resource: resource,
flow_id: flow.id,
authorization_expires_at: expires_at,
ice_credentials: ice_credentials,
preshared_key: preshared_key
}}
)
{:noreply, socket}
else
{:error, :not_found} ->
push(socket, "flow_creation_failed", %{
resource_id: resource_id,
reason: :not_found
})
{:noreply, socket}
{:error, {:forbidden, violated_properties: violated_properties}} ->
push(socket, "flow_creation_failed", %{
resource_id: resource_id,
reason: :forbidden,
violated_properties: violated_properties
})
{:noreply, socket}
{:ok, []} ->
push(socket, "flow_creation_failed", %{
resource_id: resource_id,
reason: :offline
})
{:noreply, socket}
end
end
# DEPRECATED IN 1.4
# The client sends it's message to list relays and select a gateway whenever it wants
# to connect to a resource.
#
# Client can send `connected_gateway_ids` to indicate that it is already connected to
# some of the gateways and can multiplex the connections.
@impl true
def handle_in("prepare_connection", %{"resource_id" => resource_id} = attrs, socket) do
connected_gateway_ids = Map.get(attrs, "connected_gateway_ids", [])
# TODO: Optimization
# Gateway selection and flow authorization shouldn't need to hit the DB
with {:ok, resource, _membership_id, _policy_id, _expires_at} <-
Cache.Client.authorize_resource(
socket.assigns.cache,
socket.assigns.client,
resource_id,
socket.assigns.subject
),
{:ok, gateways} when gateways != [] <-
Gateways.all_compatible_gateways_for_client_and_resource(
socket.assigns.client,
resource,
socket.assigns.subject
) do
location = {
socket.assigns.client.last_seen_remote_ip_location_lat,
socket.assigns.client.last_seen_remote_ip_location_lon
}
gateway = Gateways.load_balance_gateways(location, gateways, connected_gateway_ids)
reply =
{:ok,
%{
resource_id: resource_id,
gateway_group_id: gateway.group_id,
gateway_id: gateway.id,
gateway_remote_ip: gateway.last_seen_remote_ip
}}
{:reply, reply, socket}
else
{:error, :not_found} ->
{:reply, {:error, %{reason: :not_found}}, socket}
{:ok, []} ->
{:reply, {:error, %{reason: :offline}}, socket}
{:error, {:forbidden, violated_properties: violated_properties}} ->
{:reply, {:error, %{reason: :forbidden, violated_properties: violated_properties}},
socket}
end
end
# DEPRECATED IN 1.4
# This message is sent by the client when it already has connection to a gateway,
# but wants to multiplex the connection to access a new resource
def handle_in(
"reuse_connection",
%{
"gateway_id" => gateway_id,
"resource_id" => resource_id,
"payload" => payload
},
socket
) do
with {:ok, resource, membership_id, policy_id, expires_at} <-
Cache.Client.authorize_resource(
socket.assigns.cache,
socket.assigns.client,
resource_id,
socket.assigns.subject
),
{:ok, gateway} <-
Gateways.fetch_gateway_by_id(gateway_id, socket.assigns.subject, preload: :online?),
%Cache.Cacheable.GatewayGroup{} <-
Enum.find(resource.gateway_groups, {:error, :not_found}, fn g ->
g.id == Ecto.UUID.dump!(gateway.group_id)
end),
true <- gateway.online? do
# TODO: Optimization
{:ok, flow} =
Flows.create_flow(
socket.assigns.client,
gateway,
resource_id,
policy_id,
membership_id,
socket.assigns.subject,
expires_at
)
:ok =
PubSub.Account.broadcast(
socket.assigns.client.account_id,
{{:allow_access, gateway.id}, {self(), socket_ref(socket)},
%{
client: socket.assigns.client,
resource: resource,
flow_id: flow.id,
authorization_expires_at: expires_at,
client_payload: payload
}}
)
{:noreply, socket}
else
{:error, :not_found} ->
{:reply, {:error, %{reason: :not_found}}, socket}
{:error, {:forbidden, violated_properties: violated_properties}} ->
{:reply, {:error, %{reason: :forbidden, violated_properties: violated_properties}},
socket}
false ->
{:reply, {:error, %{reason: :offline}}, socket}
end
end
# DEPRECATED IN 1.4
# This message is sent by the client when it wants to connect to a new gateway
# to access a resource
def handle_in(
"request_connection",
%{
"gateway_id" => gateway_id,
"resource_id" => resource_id,
"client_payload" => client_payload,
"client_preshared_key" => preshared_key
},
socket
) do
# Flow authorization can happen out-of-band since we just authorized the resource above
with {:ok, resource, membership_id, policy_id, expires_at} <-
Cache.Client.authorize_resource(
socket.assigns.cache,
socket.assigns.client,
resource_id,
socket.assigns.subject
),
{:ok, gateway} <-
Gateways.fetch_gateway_by_id(gateway_id, socket.assigns.subject, preload: :online?),
%Cache.Cacheable.GatewayGroup{} <-
Enum.find(resource.gateway_groups, {:error, :not_found}, fn g ->
g.id == Ecto.UUID.dump!(gateway.group_id)
end),
true <- gateway.online? do
# TODO: Optimization
{:ok, flow} =
Flows.create_flow(
socket.assigns.client,
gateway,
resource_id,
policy_id,
membership_id,
socket.assigns.subject,
expires_at
)
:ok =
PubSub.Account.broadcast(
socket.assigns.client.account_id,
{{:request_connection, gateway.id}, {self(), socket_ref(socket)},
%{
client: socket.assigns.client,
resource: resource,
flow_id: flow.id,
authorization_expires_at: expires_at,
client_payload: client_payload,
client_preshared_key: preshared_key
}}
)
{:noreply, socket}
else
{:error, :not_found} ->
{:reply, {:error, %{reason: :not_found}}, socket}
{:error, {:forbidden, violated_properties: violated_properties}} ->
{:reply, {:error, %{reason: :forbidden, violated_properties: violated_properties}},
socket}
false ->
{:reply, {:error, %{reason: :offline}}, socket}
end
end
# The client pushes it's ICE candidates list and the list of gateways that need to receive it
def handle_in(
"broadcast_ice_candidates",
%{"candidates" => candidates, "gateway_ids" => gateway_ids},
socket
) do
:ok =
Enum.each(gateway_ids, fn gateway_id ->
PubSub.Account.broadcast(
socket.assigns.client.account_id,
{{:ice_candidates, gateway_id}, socket.assigns.client.id, candidates}
)
end)
{:noreply, socket}
end
def handle_in(
"broadcast_invalidated_ice_candidates",
%{"candidates" => candidates, "gateway_ids" => gateway_ids},
socket
) do
:ok =
Enum.each(gateway_ids, fn gateway_id ->
PubSub.Account.broadcast(
socket.assigns.client.account_id,
{{:invalidate_ice_candidates, gateway_id}, socket.assigns.client.id, candidates}
)
end)
{:noreply, socket}
end
# Catch-all for unknown messages
def handle_in(message, payload, socket) do
Logger.error("Unknown client message", message: message, payload: payload)
{:reply, {:error, %{reason: :unknown_message}}, socket}
end
defp select_relays(socket, except_ids \\ []) do
{:ok, relays} =
Relays.all_connected_relays_for_account(socket.assigns.subject.account, except_ids)
location = {
socket.assigns.client.last_seen_remote_ip_location_lat,
socket.assigns.client.last_seen_remote_ip_location_lon
}
relays = Relays.load_balance_relays(location, relays)
{:ok, relays}
end
defp maybe_subscribe_for_relays_presence(relays, socket) do
if length(relays) > 0 do
:ok
else
Relays.subscribe_to_relays_presence_in_account(socket.assigns.subject.account)
end
end
defp generate_preshared_key(client, gateway) do
Domain.Crypto.psk(client, gateway)
end
# Ice credentials must stay the same for all connections between client and gateway as long as they
# do not loose their state, so we can leverage public_key which is reset on each restart of the client
# or gateway.
defp generate_ice_credentials(client, gateway) do
ice_credential_seed =
[
client.id,
client.public_key,
gateway.id,
gateway.public_key
]
|> Enum.join(":")
ice_credential_seed_hash =
:crypto.hash(:sha256, ice_credential_seed)
|> Base.encode32(case: :lower, padding: false)
[
{:client_username, client_username},
{:client_password, client_password},
{:gateway_username, gateway_username},
{:gateway_password, gateway_password}
] =
Enum.map(
[
client_username: 0..3,
client_password: 4..25,
gateway_username: 26..29,
gateway_password: 30..52
],
fn {key, range} ->
{key, String.slice(ice_credential_seed_hash, range)}
end
)
%{
client: %{username: client_username, password: client_password},
gateway: %{username: gateway_username, password: gateway_password}
}
end
##########################################
#### Handling changes from the domain ####
##########################################
# ACCOUNTS
defp handle_change(
%Change{
op: :update,
old_struct: %Accounts.Account{} = old_account,
struct: %Accounts.Account{} = account
},
socket
) do
# Update our subject's account
subject = %{socket.assigns.subject | account: account}
socket = assign(socket, subject: subject)
if old_account.config != account.config do
client = %{socket.assigns.client | account: account}
payload = %{interface: Views.Interface.render(client)}
:ok = push(socket, "config_changed", payload)
end
{:noreply, socket}
end
# ACTOR_GROUP_MEMBERSHIPS
defp handle_change(
%Change{op: :insert, struct: %Actors.Membership{actor_id: actor_id}},
%{assigns: %{client: %{actor_id: id}}} = socket
)
when id == actor_id do
Cache.Client.add_membership(socket.assigns.cache, socket.assigns.client)
|> push_resource_updates(socket)
end
defp handle_change(
%Change{
op: :delete,
old_struct: %Actors.Membership{actor_id: actor_id} = membership
},
%{assigns: %{client: %{actor_id: id}}} = socket
)
when id == actor_id do
Cache.Client.delete_membership(socket.assigns.cache, membership, socket.assigns.client)
|> push_resource_updates(socket)
end
# CLIENTS
defp handle_change(
%Change{
op: :update,
old_struct: %Clients.Client{} = old_client,
struct: %Clients.Client{id: client_id} = client
},
%{assigns: %{client: %{id: id}}} = socket
)
when id == client_id do
# Maintain our preloaded identity
client = %{client | identity: socket.assigns.client.identity}
socket = assign(socket, client: client)
# Changes in client verification can affect the list of allowed resources
if old_client.verified_at != client.verified_at do
Cache.Client.recompute_connectable_resources(socket.assigns.cache, socket.assigns.client)
|> push_resource_updates(socket)
else
{:noreply, socket}
end
end
defp handle_change(
%Change{op: :delete, old_struct: %Clients.Client{id: id}},
%{assigns: %{client: %{id: client_id}}} = socket
)
when id == client_id do
# TODO: Hard delete
# Deleting a client won't necessary delete its tokens in the case of a headless client.
# So we explicitly handle the deleted client here by forcing it to reconnect.
{:stop, :shutdown, socket}
end
# GATEWAY_GROUPS
defp handle_change(
%Change{
op: :update,
old_struct: %Gateways.Group{name: old_name},
struct: %Gateways.Group{name: name} = group
},
socket
)
when old_name != name do
Cache.Client.update_resources_with_group_name(
socket.assigns.cache,
group,
socket.assigns.client
)
|> push_resource_updates(socket)
end
# POLICIES
defp handle_change(
%Change{op: :insert, struct: %Policies.Policy{} = policy},
socket
) do
Cache.Client.add_policy(
socket.assigns.cache,
policy,
socket.assigns.client,
socket.assigns.subject
)
|> push_resource_updates(socket)
end
defp handle_change(
%Change{
op: :update,
old_struct: %Policies.Policy{
resource_id: old_resource_id,
actor_group_id: old_actor_group_id,
conditions: old_conditions
},
struct: %Policies.Policy{
resource_id: resource_id,
actor_group_id: actor_group_id,
conditions: conditions,
disabled_at: disabled_at
}
} = change,
socket
)
when old_resource_id != resource_id or old_actor_group_id != actor_group_id or
old_conditions != conditions do
# TODO: Optimization
# Breaking update - process this as a delete and then create to make our lives easier.
# We could be smarter here and process the individual side effects more cleverly to avoid
# sending resource_deleted and resource_created_or_updated if the policy is not actually changing
# the client's connectable_resources.
{:noreply, socket} = handle_change(%{change | op: :delete}, socket)
# DO NOT re-add disabled policies
if is_nil(disabled_at) do
handle_change(%{change | op: :insert}, socket)
else
{:noreply, socket}
end
end
# Other update, i.e. description - just update our state
defp handle_change(
%Change{
op: :update,
old_struct: %Policies.Policy{},
struct: %Policies.Policy{} = policy
},
socket
) do
Cache.Client.update_policy(socket.assigns.cache, policy)
|> push_resource_updates(socket)
end
defp handle_change(
%Change{op: :delete, old_struct: %Policies.Policy{} = policy},
socket
) do
Cache.Client.delete_policy(socket.assigns.cache, policy, socket.assigns.client)
|> push_resource_updates(socket)
end
# RESOURCE_CONNECTIONS
defp handle_change(
%Change{
op: :insert,
struct: %Resources.Connection{} = connection
},
socket
) do
Cache.Client.add_resource_connection(
socket.assigns.cache,
connection,
socket.assigns.subject,
socket.assigns.client
)
|> push_resource_updates(socket)
end
defp handle_change(
%Change{
op: :delete,
old_struct: %Resources.Connection{} = connection
},
socket
) do
Cache.Client.delete_resource_connection(
socket.assigns.cache,
connection,
socket.assigns.client
)
|> push_resource_updates(socket)
end
# RESOURCES
defp handle_change(
%Change{
op: :update,
old_struct: %Resources.Resource{},
struct: %Resources.Resource{} = resource
},
socket
) do
Cache.Client.update_resource(
socket.assigns.cache,
resource,
socket.assigns.client
)
|> push_resource_updates(socket)
end
defp handle_change(%Change{}, socket), do: {:noreply, socket}
defp push_resource_updates({:ok, added_resources, removed_ids, cache}, socket) do
# TODO: Multi-site resources
# Currently, connlib doesn't handle resources changing sites, so we need to delete then create.
# We handle that scenario by sending resource_deleted then resource_created_or_updated, so it's
# important that deletions are processed first here.
# See https://github.com/firezone/firezone/issues/9881
for resource_id <- removed_ids do
push(socket, "resource_deleted", resource_id)
end
for resource <- added_resources do
push(socket, "resource_created_or_updated", Views.Resource.render(resource))
end
{:noreply, assign(socket, cache: cache)}
end
end