diff --git a/elixir/README.md b/elixir/README.md index 9f80ce64d..d04f92a63 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -63,7 +63,14 @@ Now you can verify that it's working by connecting to a websocket: ```elixir ❯ export GATEWAY_TOKEN_FROM_SEEDS="SFMyNTY.g2gDaAJtAAAAJDNjZWYwNTY2LWFkZmQtNDhmZS1hMGYxLTU4MDY3OTYwOGY2Zm0AAABAamp0enhSRkpQWkdCYy1vQ1o5RHkyRndqd2FIWE1BVWRwenVScjJzUnJvcHg3NS16bmhfeHBfNWJUNU9uby1yYm4GAJXr4emIAWIAAVGA.jz0s-NohxgdAXeRMjIQ9kLBOyd7CmKXWi2FHY-Op8GM" -❯ websocat --header="User-Agent: iOS/12.7 (iPhone) connlib/0.7.412" "ws://127.0.0.1:8081/gateway/websocket?token=${GATEWAY_TOKEN_FROM_SEEDS}&external_id=thisisrandomandpersistent&name_suffix=kkX1&public_key=kceI60D6PrwOIiGoVz6hD7VYCgD1H57IVQlPJTTieUE=" +❯ websocat --header="User-Agent: iOS/12.7 (iPhone) connlib/0.7.412" "ws://127.0.0.1:13000/gateway/websocket?token=${GATEWAY_TOKEN_FROM_SEEDS}&external_id=thisisrandomandpersistent&name_suffix=kkX1&public_key=kceI60D6PrwOIiGoVz6hD7VYCgD1H57IVQlPJTTieUE=" + +# After this you need to join the `gateway` topic. +# For details on this structure see https://hexdocs.pm/phoenix/Phoenix.Socket.Message.html +❯ {"event":"phx_join","topic":"gateway","payload":{},"ref":"unique_string_ref","join_ref":"unique_join_ref"} + +{"ref":"unique_string_ref","payload":{"status":"ok","response":{}},"topic":"gateway","event":"phx_reply"} +{"ref":null,"payload":{"interface":{"ipv6":"fd00:2011:1111::35:f630","ipv4":"100.77.125.87"},"ipv4_masquerade_enabled":true,"ipv6_masquerade_enabled":true},"topic":"gateway","event":"init"} ``` @@ -79,7 +86,7 @@ Now you can verify that it's working by connecting to a websocket: # After this you need to join the `relay` topic and pass a `stamp_secret` in the payload. # For details on this structure see https://hexdocs.pm/phoenix/Phoenix.Socket.Message.html -> {"event":"phx_join","topic":"relay","payload":{"stamp_secret":"makemerandomplz"},"ref":"unique_string_ref","join_ref":"unique_join_ref"} +❯ {"event":"phx_join","topic":"relay","payload":{"stamp_secret":"makemerandomplz"},"ref":"unique_string_ref","join_ref":"unique_join_ref"} {"event":"phx_reply","payload":{"response":{},"status":"ok"},"ref":"unique_string_ref","topic":"relay"} {"event":"init","payload":{},"ref":null,"topic":"relay"} diff --git a/elixir/apps/api/lib/api/device/channel.ex b/elixir/apps/api/lib/api/device/channel.ex index dd1510699..ef18e2488 100644 --- a/elixir/apps/api/lib/api/device/channel.ex +++ b/elixir/apps/api/lib/api/device/channel.ex @@ -21,6 +21,7 @@ defmodule API.Device.Channel do @impl true def handle_info(:after_join, socket) do API.Endpoint.subscribe("device:#{socket.assigns.device.id}") + :ok = Devices.connect_device(socket.assigns.device) {:ok, resources} = Domain.Resources.list_resources(socket.assigns.subject) @@ -30,8 +31,6 @@ defmodule API.Device.Channel do interface: Views.Interface.render(socket.assigns.device) }) - :ok = Devices.connect_device(socket.assigns.device) - {:noreply, socket} end @@ -40,6 +39,8 @@ defmodule API.Device.Channel do {:stop, :token_expired, socket} end + # This message is sent by the gateway when it is ready + # to accept the connection from the device def handle_info( {:connect, socket_ref, resource_id, gateway_public_key, rtc_session_description}, socket @@ -80,15 +81,23 @@ defmodule API.Device.Channel do end @impl true - def handle_in("list_relays", %{"resource_id" => resource_id}, socket) do + def handle_in("prepare_connection", %{"resource_id" => resource_id} = attrs, socket) do + connected_gateway_ids = Map.get(attrs, "connected_gateway_ids", []) + with {:ok, resource} <- Resources.fetch_resource_by_id(resource_id, socket.assigns.subject), # :ok = Resource.authorize(resource, socket.assigns.subject), + {:ok, [_ | _] = gateways} <- + Gateways.list_connected_gateways_for_resource(resource), {:ok, [_ | _] = relays} <- Relays.list_connected_relays_for_resource(resource) do + gateway = Gateways.load_balance_gateways(gateways, connected_gateway_ids) + reply = {:ok, %{ relays: Views.Relay.render_many(relays, socket.assigns.subject.expires_at), - resource_id: resource_id + resource_id: resource_id, + gateway_id: gateway.id, + gateway_remote_ip: gateway.last_seen_remote_ip }} {:reply, reply, socket} @@ -98,9 +107,43 @@ defmodule API.Device.Channel do end end + # This message is sent by the device when it already has connection to a gateway, + # but wants to connect to a new resource + def handle_in( + "reuse_connection", + %{ + "gateway_id" => gateway_id, + "resource_id" => resource_id + }, + socket + ) do + with {:ok, resource} <- Resources.fetch_resource_by_id(resource_id, socket.assigns.subject), + # :ok = Resource.authorize(resource, socket.assigns.subject), + {:ok, gateway} <- Gateways.fetch_gateway_by_id(gateway_id, socket.assigns.subject), + true <- Gateways.gateway_can_connect_to_resource?(gateway, resource) do + :ok = + API.Gateway.Channel.broadcast( + gateway, + {:allow_access, + %{ + device_id: socket.assigns.device.id, + resource_id: resource.id, + authorization_expires_at: socket.assigns.subject.expires_at + }} + ) + + {:noreply, socket} + else + {:error, :not_found} -> {:reply, {:error, :not_found}, socket} + false -> {:reply, {:error, :offline}, socket} + end + end + + # This message is sent by the device when it wants to connect to a new gateway def handle_in( "request_connection", %{ + "gateway_id" => gateway_id, "resource_id" => resource_id, "device_rtc_session_description" => device_rtc_session_description, "device_preshared_key" => preshared_key @@ -109,17 +152,15 @@ defmodule API.Device.Channel do ) do with {:ok, resource} <- Resources.fetch_resource_by_id(resource_id, socket.assigns.subject), # :ok = Resource.authorize(resource, socket.assigns.subject), - {:ok, [_ | _] = gateways} <- - Gateways.list_connected_gateways_for_resource(resource) do - gateway = Enum.random(gateways) - + {:ok, gateway} <- Gateways.fetch_gateway_by_id(gateway_id, socket.assigns.subject), + true <- Gateways.gateway_can_connect_to_resource?(gateway, resource) do :ok = API.Gateway.Channel.broadcast( gateway, {:request_connection, {self(), socket_ref(socket)}, %{ device_id: socket.assigns.device.id, - resource_id: resource_id, + resource_id: resource.id, authorization_expires_at: socket.assigns.subject.expires_at, device_rtc_session_description: device_rtc_session_description, device_preshared_key: preshared_key @@ -129,7 +170,7 @@ defmodule API.Device.Channel do {:noreply, socket} else {:error, :not_found} -> {:reply, {:error, :not_found}, socket} - {:ok, []} -> {:reply, {:error, :offline}, socket} + false -> {:reply, {:error, :offline}, socket} end end end diff --git a/elixir/apps/api/lib/api/gateway/channel.ex b/elixir/apps/api/lib/api/gateway/channel.ex index da86bb672..433d8464f 100644 --- a/elixir/apps/api/lib/api/gateway/channel.ex +++ b/elixir/apps/api/lib/api/gateway/channel.ex @@ -18,7 +18,8 @@ defmodule API.Gateway.Channel do @impl true def handle_info(:after_join, socket) do - API.Endpoint.subscribe("gateway:#{socket.assigns.gateway.id}") + :ok = Gateways.connect_gateway(socket.assigns.gateway) + :ok = API.Endpoint.subscribe("gateway:#{socket.assigns.gateway.id}") push(socket, "init", %{ interface: Views.Interface.render(socket.assigns.gateway), @@ -27,11 +28,25 @@ defmodule API.Gateway.Channel do ipv6_masquerade_enabled: true }) - :ok = Gateways.connect_gateway(socket.assigns.gateway) - {:noreply, socket} end + def handle_info({:allow_access, attrs}, socket) do + %{ + device_id: device_id, + resource_id: resource_id, + authorization_expires_at: authorization_expires_at + } = attrs + + resource = Resources.fetch_resource_by_id!(resource_id) + + push(socket, "allow_access", %{ + device_id: device_id, + resource: Views.Resource.render(resource), + expires_at: DateTime.to_unix(authorization_expires_at, :second) + }) + end + def handle_info({:request_connection, {channel_pid, socket_ref}, attrs}, socket) do %{ device_id: device_id, diff --git a/elixir/apps/api/test/api/device/channel_test.exs b/elixir/apps/api/test/api/device/channel_test.exs index e59f715e0..0c4166bfc 100644 --- a/elixir/apps/api/test/api/device/channel_test.exs +++ b/elixir/apps/api/test/api/device/channel_test.exs @@ -111,9 +111,9 @@ defmodule API.Device.ChannelTest do end end - describe "handle_in/3 list_relays" do + describe "handle_in/3 prepare_connection" do test "returns error when resource is not found", %{socket: socket} do - ref = push(socket, "list_relays", %{"resource_id" => Ecto.UUID.generate()}) + ref = push(socket, "prepare_connection", %{"resource_id" => Ecto.UUID.generate()}) assert_reply ref, :error, :not_found end @@ -121,24 +121,58 @@ defmodule API.Device.ChannelTest do dns_resource: resource, socket: socket } do - ref = push(socket, "list_relays", %{"resource_id" => resource.id}) + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) assert_reply ref, :error, :offline end - test "returns list of online relays", %{ + test "returns error when all gateways are offline", %{ + dns_resource: resource, + socket: socket + } do + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) + assert_reply ref, :error, :offline + end + + test "returns error when all gateways connected to the resource are offline", %{ account: account, dns_resource: resource, socket: socket } do + gateway = GatewaysFixtures.create_gateway(account: account) + :ok = Domain.Gateways.connect_gateway(gateway) + + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) + assert_reply ref, :error, :offline + end + + test "returns online gateway and relays connected to the resource", %{ + account: account, + dns_resource: resource, + gateway: gateway, + socket: socket + } do + # Online Relay global_relay_group = RelaysFixtures.create_global_group() global_relay = RelaysFixtures.create_relay(group: global_relay_group, ipv6: nil) relay = RelaysFixtures.create_relay(account: account) stamp_secret = Ecto.UUID.generate() :ok = Domain.Relays.connect_relay(relay, stamp_secret) - ref = push(socket, "list_relays", %{"resource_id" => resource.id}) + # Online Gateway + :ok = Domain.Gateways.connect_gateway(gateway) + + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) resource_id = resource.id - assert_reply ref, :ok, %{relays: relays, resource_id: ^resource_id} + + assert_reply ref, :ok, %{ + relays: relays, + gateway_id: gateway_id, + gateway_remote_ip: gateway_last_seen_remote_ip, + resource_id: ^resource_id + } + + assert gateway_id == gateway.id + assert gateway_last_seen_remote_ip == gateway.last_seen_remote_ip ipv4_stun_uri = "stun:#{relay.ipv4}:#{relay.port}" ipv4_turn_uri = "turn:#{relay.ipv4}:#{relay.port}" @@ -181,16 +215,100 @@ defmodule API.Device.ChannelTest do assert is_binary(salt) :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) - ref = push(socket, "list_relays", %{"resource_id" => resource.id}) + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) assert_reply ref, :ok, %{relays: relays} assert length(relays) == 6 end end - describe "handle_in/3 request_connection" do - test "returns error when resource is not found", %{socket: socket} do + describe "handle_in/3 reuse_connection" do + test "returns error when resource is not found", %{gateway: gateway, socket: socket} do attrs = %{ "resource_id" => Ecto.UUID.generate(), + "gateway_id" => gateway.id + } + + ref = push(socket, "reuse_connection", attrs) + assert_reply ref, :error, :not_found + end + + test "returns error when gateway is not found", %{dns_resource: resource, socket: socket} do + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => Ecto.UUID.generate() + } + + ref = push(socket, "reuse_connection", attrs) + assert_reply ref, :error, :not_found + end + + test "returns error when gateway is not connected to resource", %{ + account: account, + dns_resource: resource, + socket: socket + } do + gateway = GatewaysFixtures.create_gateway(account: account) + :ok = Domain.Gateways.connect_gateway(gateway) + + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id + } + + ref = push(socket, "reuse_connection", attrs) + assert_reply ref, :error, :offline + end + + test "returns error when gateway is offline", %{ + dns_resource: resource, + gateway: gateway, + socket: socket + } do + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id + } + + ref = push(socket, "reuse_connection", attrs) + assert_reply ref, :error, :offline + end + + test "broadcasts allow_access to the gateways and then returns connect message", %{ + dns_resource: resource, + gateway: gateway, + device: device, + socket: socket + } do + resource_id = resource.id + device_id = device.id + + :ok = Domain.Gateways.connect_gateway(gateway) + Phoenix.PubSub.subscribe(Domain.PubSub, API.Gateway.Socket.id(gateway)) + + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id + } + + push(socket, "reuse_connection", attrs) + + assert_receive {:allow_access, payload} + + assert %{ + resource_id: ^resource_id, + device_id: ^device_id, + authorization_expires_at: authorization_expires_at + } = payload + + assert authorization_expires_at == socket.assigns.subject.expires_at + end + end + + describe "handle_in/3 request_connection" do + test "returns error when resource is not found", %{gateway: gateway, socket: socket} do + attrs = %{ + "resource_id" => Ecto.UUID.generate(), + "gateway_id" => gateway.id, "device_rtc_session_description" => "RTC_SD", "device_preshared_key" => "PSK" } @@ -199,12 +317,29 @@ defmodule API.Device.ChannelTest do assert_reply ref, :error, :not_found end - test "returns error when all gateways are offline", %{ + test "returns error when gateway is not found", %{dns_resource: resource, socket: socket} do + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => Ecto.UUID.generate(), + "device_rtc_session_description" => "RTC_SD", + "device_preshared_key" => "PSK" + } + + ref = push(socket, "request_connection", attrs) + assert_reply ref, :error, :not_found + end + + test "returns error when gateway is not connected to resource", %{ + account: account, dns_resource: resource, socket: socket } do + gateway = GatewaysFixtures.create_gateway(account: account) + :ok = Domain.Gateways.connect_gateway(gateway) + attrs = %{ "resource_id" => resource.id, + "gateway_id" => gateway.id, "device_rtc_session_description" => "RTC_SD", "device_preshared_key" => "PSK" } @@ -213,20 +348,18 @@ defmodule API.Device.ChannelTest do assert_reply ref, :error, :offline end - test "returns error when all gateways connected to the resource are offline", %{ - account: account, + test "returns error when gateway is offline", %{ dns_resource: resource, + gateway: gateway, socket: socket } do attrs = %{ "resource_id" => resource.id, + "gateway_id" => gateway.id, "device_rtc_session_description" => "RTC_SD", "device_preshared_key" => "PSK" } - gateway = GatewaysFixtures.create_gateway(account: account) - :ok = Domain.Gateways.connect_gateway(gateway) - ref = push(socket, "request_connection", attrs) assert_reply ref, :error, :offline end @@ -246,6 +379,7 @@ defmodule API.Device.ChannelTest do attrs = %{ "resource_id" => resource.id, + "gateway_id" => gateway.id, "device_rtc_session_description" => "RTC_SD", "device_preshared_key" => "PSK" } diff --git a/elixir/apps/api/test/api/gateway/channel_test.exs b/elixir/apps/api/test/api/gateway/channel_test.exs index 9417deac9..673e93502 100644 --- a/elixir/apps/api/test/api/gateway/channel_test.exs +++ b/elixir/apps/api/test/api/gateway/channel_test.exs @@ -61,6 +61,44 @@ defmodule API.Gateway.ChannelTest do end end + describe "handle_info/2 :allow_access" do + test "pushes allow_access message", %{ + device: device, + resource: resource, + relay: relay, + socket: socket + } do + expires_at = DateTime.utc_now() |> DateTime.add(30, :second) + + stamp_secret = Ecto.UUID.generate() + :ok = Domain.Relays.connect_relay(relay, stamp_secret) + + send( + socket.channel_pid, + {:allow_access, + %{ + device_id: device.id, + resource_id: resource.id, + authorization_expires_at: expires_at + }} + ) + + assert_push "allow_access", payload + + assert payload.resource == %{ + address: resource.address, + id: resource.id, + name: resource.name, + type: :dns, + ipv4: resource.ipv4, + ipv6: resource.ipv6 + } + + assert payload.device_id == device.id + assert DateTime.from_unix!(payload.expires_at) == DateTime.truncate(expires_at, :second) + end + end + describe "handle_info/2 :request_connection" do test "pushes request_connection message", %{ device: device, diff --git a/elixir/apps/api/test/api/relay/channel_test.exs b/elixir/apps/api/test/api/relay/channel_test.exs index 1f70e6ad9..cf74ee833 100644 --- a/elixir/apps/api/test/api/relay/channel_test.exs +++ b/elixir/apps/api/test/api/relay/channel_test.exs @@ -34,7 +34,7 @@ defmodule API.Relay.ChannelTest do |> socket("relay:#{relay.id}", %{relay: relay}) |> subscribe_and_join(API.Relay.Channel, "relay", %{stamp_secret: stamp_secret}) - presence = Domain.Relays.Presence.list("relays:") + presence = Domain.Relays.Presence.list("relays") assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, relay.id) assert is_number(online_at) diff --git a/elixir/apps/domain/lib/domain/actors.ex b/elixir/apps/domain/lib/domain/actors.ex index 4e2f2e37f..855841eb8 100644 --- a/elixir/apps/domain/lib/domain/actors.ex +++ b/elixir/apps/domain/lib/domain/actors.ex @@ -203,10 +203,12 @@ defmodule Domain.Actors do end def create_actor(%Auth.Provider{} = provider, provider_identifier, attrs) do + {provider_attrs, attrs} = Map.pop(attrs, "provider", %{}) + Ecto.Multi.new() |> Ecto.Multi.insert(:actor, Actor.Changeset.create_changeset(provider.account_id, attrs)) |> Ecto.Multi.run(:identity, fn _repo, %{actor: actor} -> - Auth.create_identity(actor, provider, provider_identifier) + Auth.create_identity(actor, provider, provider_identifier, provider_attrs) end) |> Repo.transaction() |> case do diff --git a/elixir/apps/domain/lib/domain/actors/actor.ex b/elixir/apps/domain/lib/domain/actors/actor.ex index 9e6c3fc1b..9119e6a22 100644 --- a/elixir/apps/domain/lib/domain/actors/actor.ex +++ b/elixir/apps/domain/lib/domain/actors/actor.ex @@ -6,12 +6,12 @@ defmodule Domain.Actors.Actor do field :name, :string - has_many :identities, Domain.Auth.Identity + has_many :identities, Domain.Auth.Identity, where: [deleted_at: nil] belongs_to :account, Domain.Accounts.Account has_many :memberships, Domain.Actors.Membership, on_replace: :delete - has_many :groups, through: [:memberships, :group] + has_many :groups, through: [:memberships, :group], where: [deleted_at: nil] field :disabled_at, :utc_datetime_usec field :deleted_at, :utc_datetime_usec diff --git a/elixir/apps/domain/lib/domain/actors/group.ex b/elixir/apps/domain/lib/domain/actors/group.ex index 9759d1bee..61d1daa9a 100644 --- a/elixir/apps/domain/lib/domain/actors/group.ex +++ b/elixir/apps/domain/lib/domain/actors/group.ex @@ -9,7 +9,7 @@ defmodule Domain.Actors.Group do field :provider_identifier, :string has_many :memberships, Domain.Actors.Membership, on_replace: :delete - has_many :actors, through: [:memberships, :actor] + has_many :actors, through: [:memberships, :actor], where: [deleted_at: nil] belongs_to :account, Domain.Accounts.Account diff --git a/elixir/apps/domain/lib/domain/auth/provider.ex b/elixir/apps/domain/lib/domain/auth/provider.ex index 2f3fe27b3..0bdd5422c 100644 --- a/elixir/apps/domain/lib/domain/auth/provider.ex +++ b/elixir/apps/domain/lib/domain/auth/provider.ex @@ -11,7 +11,7 @@ defmodule Domain.Auth.Provider do belongs_to :account, Domain.Accounts.Account - has_many :groups, Domain.Actors.Group + has_many :groups, Domain.Actors.Group, where: [deleted_at: nil] field :created_by, Ecto.Enum, values: ~w[system identity]a belongs_to :created_by_identity, Domain.Auth.Identity diff --git a/elixir/apps/domain/lib/domain/devices.ex b/elixir/apps/domain/lib/domain/devices.ex index 1cba291a1..b66efbcae 100644 --- a/elixir/apps/domain/lib/domain/devices.ex +++ b/elixir/apps/domain/lib/domain/devices.ex @@ -169,7 +169,6 @@ defmodule Domain.Devices do end def connect_device(%Device{} = device) do - # TODO: use new Phoenix.Tracker instead Phoenix.PubSub.subscribe(Domain.PubSub, "actor:#{device.actor_id}") {:ok, _} = diff --git a/elixir/apps/domain/lib/domain/gateways.ex b/elixir/apps/domain/lib/domain/gateways.ex index 935369f62..2600832df 100644 --- a/elixir/apps/domain/lib/domain/gateways.ex +++ b/elixir/apps/domain/lib/domain/gateways.ex @@ -1,7 +1,7 @@ defmodule Domain.Gateways do use Supervisor alias Domain.{Repo, Auth, Validator} - alias Domain.Resources + alias Domain.{Accounts, Resources} alias Domain.Gateways.{Authorizer, Gateway, Group, Token, Presence} def start_link(opts) do @@ -16,23 +16,82 @@ defmodule Domain.Gateways do Supervisor.init(children, strategy: :one_for_one) end - def fetch_group_by_id(id, %Auth.Subject{} = subject) do + def fetch_group_by_id(id, %Auth.Subject{} = subject, opts \\ []) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()), true <- Validator.valid_uuid?(id) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + Group.Query.by_id(id) |> Authorizer.for_subject(subject) |> Repo.fetch() + |> case do + {:ok, group} -> + group = + group + |> Repo.preload(preload) + |> maybe_preload_online_status() + + {:ok, group} + + {:error, reason} -> + {:error, reason} + end else false -> {:error, :not_found} other -> other end end - def list_groups(%Auth.Subject{} = subject) do + def list_groups(%Auth.Subject{} = subject, opts \\ []) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do - Group.Query.all() - |> Authorizer.for_subject(subject) - |> Repo.list() + {preload, _opts} = Keyword.pop(opts, :preload, []) + + {:ok, groups} = + Group.Query.all() + |> Authorizer.for_subject(subject) + |> Repo.list() + + groups = + groups + |> Repo.preload(preload) + |> maybe_preload_online_statuses() + + {:ok, groups} + end + end + + # TODO: this is ugly! + defp maybe_preload_online_status(group) do + if Ecto.assoc_loaded?(group.gateways) do + connected_gateways = Presence.list("gateway_groups:#{group.id}") + + gateways = + Enum.map(group.gateways, fn gateway -> + %{gateway | online?: Map.has_key?(connected_gateways, gateway.id)} + end) + + %{group | gateways: gateways} + else + group + end + end + + defp maybe_preload_online_statuses([]), do: [] + + defp maybe_preload_online_statuses([group | _] = groups) do + connected_gateways = Presence.list("gateways:#{group.account_id}") + + if Ecto.assoc_loaded?(group.gateways) do + Enum.map(groups, fn group -> + gateways = + Enum.map(group.gateways, fn gateway -> + %{gateway | online?: Map.has_key?(connected_gateways, gateway.id)} + end) + + %{group | gateways: gateways} + end) + else + groups end end @@ -102,16 +161,31 @@ defmodule Domain.Gateways do end def fetch_gateway_by_id(id, %Auth.Subject{} = subject, opts \\ []) do - {preload, _opts} = Keyword.pop(opts, :preload, []) + required_permissions = + {:one_of, + [ + Authorizer.manage_gateways_permission(), + Authorizer.connect_gateways_permission() + ]} - with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()), + with :ok <- Auth.ensure_has_permissions(subject, required_permissions), true <- Validator.valid_uuid?(id) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + Gateway.Query.by_id(id) |> Authorizer.for_subject(subject) |> Repo.fetch() |> case do - {:ok, gateway} -> {:ok, Repo.preload(gateway, preload)} - {:error, reason} -> {:error, reason} + {:ok, gateway} -> + gateway = + gateway + |> Repo.preload(preload) + |> preload_online_status() + + {:ok, gateway} + + {:error, reason} -> + {:error, reason} end else false -> {:error, :not_found} @@ -124,6 +198,7 @@ defmodule Domain.Gateways do Gateway.Query.by_id(id) |> Repo.one!() + |> preload_online_status() |> Repo.preload(preload) end @@ -136,10 +211,31 @@ defmodule Domain.Gateways do |> Authorizer.for_subject(subject) |> Repo.list() - {:ok, Repo.preload(gateways, preload)} + gateways = + gateways + |> Repo.preload(preload) + |> preload_online_statuses(subject.account.id) + + {:ok, gateways} end end + # TODO: make it function of a preload, so that we don't pull this data when we don't need to + defp preload_online_status(%Gateway{} = gateway) do + case Presence.get_by_key("gateways:#{gateway.account_id}", gateway.id) do + [] -> %{gateway | online?: false} + %{metas: [_ | _]} -> %{gateway | online?: true} + end + end + + defp preload_online_statuses(gateways, account_id) do + connected_gateways = Presence.list("gateways:#{account_id}") + + Enum.map(gateways, fn gateway -> + %{gateway | online?: Map.has_key?(connected_gateways, gateway.id)} + end) + end + def list_connected_gateways_for_resource(%Resources.Resource{} = resource) do connected_gateways = Presence.list("gateways:#{resource.account_id}") @@ -158,6 +254,21 @@ defmodule Domain.Gateways do {:ok, gateways} end + def gateway_can_connect_to_resource?(%Gateway{} = gateway, %Resources.Resource{} = resource) do + connected_gateway_ids = Presence.list("gateways:#{resource.account_id}") |> Map.keys() + + cond do + gateway.id not in connected_gateway_ids -> + false + + not Resources.connected?(resource, gateway) -> + false + + true -> + true + end + end + def change_gateway(%Gateway{} = gateway, attrs \\ %{}) do Gateway.Changeset.update_changeset(gateway, attrs) end @@ -199,6 +310,13 @@ defmodule Domain.Gateways do Gateway.Query.by_id(gateway.id) |> Authorizer.for_subject(subject) |> Repo.fetch_and_update(with: &Gateway.Changeset.update_changeset(&1, attrs)) + |> case do + {:ok, gateway} -> + {:ok, preload_online_status(gateway)} + + {:error, reason} -> + {:error, reason} + end end end @@ -207,6 +325,26 @@ defmodule Domain.Gateways do Gateway.Query.by_id(gateway.id) |> Authorizer.for_subject(subject) |> Repo.fetch_and_update(with: &Gateway.Changeset.delete_changeset/1) + |> case do + {:ok, gateway} -> + {:ok, preload_online_status(gateway)} + + {:error, reason} -> + {:error, reason} + end + end + end + + def load_balance_gateways(gateways) do + Enum.random(gateways) + end + + def load_balance_gateways(gateways, preferred_gateway_ids) do + gateways + |> Enum.filter(&(&1.id in preferred_gateway_ids)) + |> case do + [] -> load_balance_gateways(gateways) + preferred_gateways -> load_balance_gateways(preferred_gateways) end end @@ -239,11 +377,18 @@ defmodule Domain.Gateways do online_at: System.system_time(:second) }) + {:ok, _} = + Presence.track(self(), "gateway_groups:#{gateway.group_id}", gateway.id, %{}) + :ok end - def fetch_gateway_config!(%Gateway{} = _gateway) do - Application.fetch_env!(:domain, __MODULE__) + def subscribe_for_gateways_presence_in_account(%Accounts.Account{} = account) do + Phoenix.PubSub.subscribe(Domain.PubSub, "gateways:#{account.id}") + end + + def subscribe_for_gateways_presence_in_group(%Group{} = group) do + Phoenix.PubSub.subscribe(Domain.PubSub, "gateway_groups:#{group.id}") end defp fetch_config! do diff --git a/elixir/apps/domain/lib/domain/gateways/authorizer.ex b/elixir/apps/domain/lib/domain/gateways/authorizer.ex index aa98d0355..7d39a9715 100644 --- a/elixir/apps/domain/lib/domain/gateways/authorizer.ex +++ b/elixir/apps/domain/lib/domain/gateways/authorizer.ex @@ -3,6 +3,7 @@ defmodule Domain.Gateways.Authorizer do alias Domain.Gateways.{Gateway, Group} def manage_gateways_permission, do: build(Gateway, :manage) + def connect_gateways_permission, do: build(Gateway, :connect) @impl Domain.Auth.Authorizer @@ -13,12 +14,18 @@ defmodule Domain.Gateways.Authorizer do end def list_permissions_for_role(_) do - [] + [ + connect_gateways_permission() + ] end @impl Domain.Auth.Authorizer def for_subject(queryable, %Subject{} = subject) do cond do + has_permission?(subject, connect_gateways_permission()) -> + # TODO: evaluate the resource policy for the subject + by_account_id(queryable, subject) + has_permission?(subject, manage_gateways_permission()) -> by_account_id(queryable, subject) end diff --git a/elixir/apps/domain/lib/domain/gateways/gateway.ex b/elixir/apps/domain/lib/domain/gateways/gateway.ex index bece0860f..7e8eff071 100644 --- a/elixir/apps/domain/lib/domain/gateways/gateway.ex +++ b/elixir/apps/domain/lib/domain/gateways/gateway.ex @@ -16,6 +16,8 @@ defmodule Domain.Gateways.Gateway do field :last_seen_version, :string field :last_seen_at, :utc_datetime_usec + field :online?, :boolean, virtual: true + belongs_to :account, Domain.Accounts.Account belongs_to :group, Domain.Gateways.Group belongs_to :token, Domain.Gateways.Token diff --git a/elixir/apps/domain/lib/domain/gateways/group.ex b/elixir/apps/domain/lib/domain/gateways/group.ex index a5abf1ed4..50f8f022a 100644 --- a/elixir/apps/domain/lib/domain/gateways/group.ex +++ b/elixir/apps/domain/lib/domain/gateways/group.ex @@ -6,8 +6,8 @@ defmodule Domain.Gateways.Group do field :tags, {:array, :string}, default: [] belongs_to :account, Domain.Accounts.Account - has_many :gateways, Domain.Gateways.Gateway, foreign_key: :group_id - has_many :tokens, Domain.Gateways.Token, foreign_key: :group_id + has_many :gateways, Domain.Gateways.Gateway, foreign_key: :group_id, where: [deleted_at: nil] + has_many :tokens, Domain.Gateways.Token, foreign_key: :group_id, where: [deleted_at: nil] has_many :connections, Domain.Resources.Connection, foreign_key: :gateway_group_id diff --git a/elixir/apps/domain/lib/domain/relays.ex b/elixir/apps/domain/lib/domain/relays.ex index 828e15fb6..48f16f088 100644 --- a/elixir/apps/domain/lib/domain/relays.ex +++ b/elixir/apps/domain/lib/domain/relays.ex @@ -1,7 +1,7 @@ defmodule Domain.Relays do use Supervisor alias Domain.{Repo, Auth, Validator} - alias Domain.Resources + alias Domain.{Accounts, Resources} alias Domain.Relays.{Authorizer, Relay, Group, Token, Presence} def start_link(opts) do @@ -16,23 +16,82 @@ defmodule Domain.Relays do Supervisor.init(children, strategy: :one_for_one) end - def fetch_group_by_id(id, %Auth.Subject{} = subject) do + def fetch_group_by_id(id, %Auth.Subject{} = subject, opts \\ []) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()), true <- Validator.valid_uuid?(id) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + Group.Query.by_id(id) |> Authorizer.for_subject(subject) |> Repo.fetch() + |> case do + {:ok, group} -> + group = + group + |> Repo.preload(preload) + |> maybe_preload_online_status() + + {:ok, group} + + {:error, reason} -> + {:error, reason} + end else false -> {:error, :not_found} other -> other end end - def list_groups(%Auth.Subject{} = subject) do + def list_groups(%Auth.Subject{} = subject, opts \\ []) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do - Group.Query.all() - |> Authorizer.for_subject(subject) - |> Repo.list() + {preload, _opts} = Keyword.pop(opts, :preload, []) + + {:ok, groups} = + Group.Query.all() + |> Authorizer.for_subject(subject) + |> Repo.list() + + groups = + groups + |> Repo.preload(preload) + |> maybe_preload_online_statuses() + + {:ok, groups} + end + end + + # TODO: this is ugly! + defp maybe_preload_online_status(group) do + if Ecto.assoc_loaded?(group.relays) do + connected_relays = Presence.list("relay_groups:#{group.id}") + + relays = + Enum.map(group.relays, fn relay -> + %{relay | online?: Map.has_key?(connected_relays, relay.id)} + end) + + %{group | relays: relays} + else + group + end + end + + defp maybe_preload_online_statuses([]), do: [] + + defp maybe_preload_online_statuses([group | _] = groups) do + connected_relays = Presence.list("relays:#{group.account_id}") + + if Ecto.assoc_loaded?(group.relays) do + Enum.map(groups, fn group -> + relays = + Enum.map(group.relays, fn relay -> + %{relay | online?: Map.has_key?(connected_relays, relay.id)} + end) + + %{group | relays: relays} + end) + else + groups end end @@ -117,12 +176,26 @@ defmodule Domain.Relays do end end - def fetch_relay_by_id(id, %Auth.Subject{} = subject) do + def fetch_relay_by_id(id, %Auth.Subject{} = subject, opts \\ []) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()), true <- Validator.valid_uuid?(id) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + Relay.Query.by_id(id) |> Authorizer.for_subject(subject) |> Repo.fetch() + |> case do + {:ok, gateway} -> + gateway = + gateway + |> Repo.preload(preload) + |> preload_online_status() + + {:ok, gateway} + + {:error, reason} -> + {:error, reason} + end else false -> {:error, :not_found} other -> other @@ -135,20 +208,47 @@ defmodule Domain.Relays do Relay.Query.by_id(id) |> Repo.one!() |> Repo.preload(preload) + |> preload_online_status() end - def list_relays(%Auth.Subject{} = subject) do + def list_relays(%Auth.Subject{} = subject, opts \\ []) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do - Relay.Query.all() - |> Authorizer.for_subject(subject) - |> Repo.list() + {preload, _opts} = Keyword.pop(opts, :preload, []) + + {:ok, relays} = + Relay.Query.all() + |> Authorizer.for_subject(subject) + |> Repo.list() + + relays = + relays + |> Repo.preload(preload) + |> preload_online_statuses(subject.account.id) + + {:ok, relays} end end + # TODO: make it function of a preload, so that we don't pull this data when we don't need to + defp preload_online_status(%Relay{} = relay) do + case Presence.get_by_key("relays:#{relay.account_id}", relay.id) do + [] -> %{relay | online?: false} + %{metas: [_ | _]} -> %{relay | online?: true} + end + end + + defp preload_online_statuses(relays, account_id) do + connected_relays = Presence.list("relays:#{account_id}") + + Enum.map(relays, fn relay -> + %{relay | online?: Map.has_key?(connected_relays, relay.id)} + end) + end + def list_connected_relays_for_resource(%Resources.Resource{} = resource) do connected_relays = Map.merge( - Presence.list("relays:"), + Presence.list("relays"), Presence.list("relays:#{resource.account_id}") ) @@ -156,7 +256,7 @@ defmodule Domain.Relays do connected_relays |> Map.keys() |> Relay.Query.by_ids() - |> Relay.Query.by_account_id(resource.account_id) + |> Relay.Query.public_or_by_account_id(resource.account_id) |> Repo.all() |> Enum.map(fn relay -> %{metas: [%{secret: stamp_secret}]} = Map.get(connected_relays, relay.id) @@ -227,15 +327,34 @@ defmodule Domain.Relays do end def connect_relay(%Relay{} = relay, secret) do + scope = + if relay.account_id do + ":#{relay.account_id}" + else + "" + end + {:ok, _} = - Presence.track(self(), "relays:#{relay.account_id}", relay.id, %{ + Presence.track(self(), "relays#{scope}", relay.id, %{ online_at: System.system_time(:second), secret: secret }) + {:ok, _} = + Presence.track(self(), "relay_groups:#{relay.group_id}", relay.id, %{}) + :ok end + def subscribe_for_relays_presence_in_account(%Accounts.Account{} = account) do + Phoenix.PubSub.subscribe(Domain.PubSub, "relays") + Phoenix.PubSub.subscribe(Domain.PubSub, "relays:#{account.id}") + end + + def subscribe_for_relays_presence_in_group(%Group{} = group) do + Phoenix.PubSub.subscribe(Domain.PubSub, "relay_groups:#{group.id}") + end + defp fetch_config! do Domain.Config.fetch_env!(:domain, __MODULE__) end diff --git a/elixir/apps/domain/lib/domain/relays/group.ex b/elixir/apps/domain/lib/domain/relays/group.ex index f7fc2fe49..37b1f1bac 100644 --- a/elixir/apps/domain/lib/domain/relays/group.ex +++ b/elixir/apps/domain/lib/domain/relays/group.ex @@ -5,8 +5,8 @@ defmodule Domain.Relays.Group do field :name, :string belongs_to :account, Domain.Accounts.Account - has_many :relays, Domain.Relays.Relay, foreign_key: :group_id - has_many :tokens, Domain.Relays.Token, foreign_key: :group_id + has_many :relays, Domain.Relays.Relay, foreign_key: :group_id, where: [deleted_at: nil] + has_many :tokens, Domain.Relays.Token, foreign_key: :group_id, where: [deleted_at: nil] field :created_by, Ecto.Enum, values: ~w[system identity]a belongs_to :created_by_identity, Domain.Auth.Identity diff --git a/elixir/apps/domain/lib/domain/relays/relay.ex b/elixir/apps/domain/lib/domain/relays/relay.ex index 4ec8150d8..cd436315f 100644 --- a/elixir/apps/domain/lib/domain/relays/relay.ex +++ b/elixir/apps/domain/lib/domain/relays/relay.ex @@ -14,6 +14,8 @@ defmodule Domain.Relays.Relay do field :stamp_secret, :string, virtual: true + field :online?, :boolean, virtual: true + belongs_to :account, Domain.Accounts.Account belongs_to :group, Domain.Relays.Group belongs_to :token, Domain.Relays.Token diff --git a/elixir/apps/domain/lib/domain/relays/relay/changeset.ex b/elixir/apps/domain/lib/domain/relays/relay/changeset.ex index 91689d82e..aea358d39 100644 --- a/elixir/apps/domain/lib/domain/relays/relay/changeset.ex +++ b/elixir/apps/domain/lib/domain/relays/relay/changeset.ex @@ -22,7 +22,9 @@ defmodule Domain.Relays.Relay.Changeset do |> validate_required_one_of(~w[ipv4 ipv6]a) |> validate_number(:port, greater_than_or_equal_to: 1, less_than_or_equal_to: 65_535) |> unique_constraint(:ipv4, name: :relays_account_id_ipv4_index) + |> unique_constraint(:ipv4, name: :relays_ipv4_index) |> unique_constraint(:ipv6, name: :relays_account_id_ipv6_index) + |> unique_constraint(:ipv6, name: :relays_ipv6_index) |> put_change(:last_seen_at, DateTime.utc_now()) |> put_relay_version() |> put_change(:account_id, token.account_id) diff --git a/elixir/apps/domain/lib/domain/relays/relay/query.ex b/elixir/apps/domain/lib/domain/relays/relay/query.ex index e64393e1b..492ee9c5a 100644 --- a/elixir/apps/domain/lib/domain/relays/relay/query.ex +++ b/elixir/apps/domain/lib/domain/relays/relay/query.ex @@ -19,6 +19,10 @@ defmodule Domain.Relays.Relay.Query do end def by_account_id(queryable \\ all(), account_id) do + where(queryable, [relays: relays], relays.account_id == ^account_id) + end + + def public_or_by_account_id(queryable \\ all(), account_id) do where( queryable, [relays: relays], diff --git a/elixir/apps/domain/lib/domain/resources.ex b/elixir/apps/domain/lib/domain/resources.ex index 8905828be..d4276da3d 100644 --- a/elixir/apps/domain/lib/domain/resources.ex +++ b/elixir/apps/domain/lib/domain/resources.ex @@ -1,7 +1,7 @@ defmodule Domain.Resources do alias Domain.{Repo, Validator, Auth} alias Domain.Gateways - alias Domain.Resources.{Authorizer, Resource} + alias Domain.Resources.{Authorizer, Resource, Connection} def fetch_resource_by_id(id, %Auth.Subject{} = subject, opts \\ []) do {preload, _opts} = Keyword.pop(opts, :preload, []) @@ -183,4 +183,13 @@ defmodule Domain.Resources do end end end + + def connected?( + %Resource{account_id: account_id} = resource, + %Gateways.Gateway{account_id: account_id} = gateway + ) do + Connection.Query.by_resource_id(resource.id) + |> Connection.Query.by_gateway_group_id(gateway.group_id) + |> Repo.exists?() + end end diff --git a/elixir/apps/domain/lib/domain/resources/connection/query.ex b/elixir/apps/domain/lib/domain/resources/connection/query.ex index f89e0ba08..effae7eb8 100644 --- a/elixir/apps/domain/lib/domain/resources/connection/query.ex +++ b/elixir/apps/domain/lib/domain/resources/connection/query.ex @@ -8,4 +8,16 @@ defmodule Domain.Resources.Connection.Query do def by_account_id(queryable \\ all(), account_id) do where(queryable, [connections: connections], connections.account_id == ^account_id) end + + def by_resource_id(queryable \\ all(), resource_id) do + where(queryable, [connections: connections], connections.resource_id == ^resource_id) + end + + def by_gateway_group_id(queryable \\ all(), gateway_group_id) do + where( + queryable, + [connections: connections], + connections.gateway_group_id == ^gateway_group_id + ) + end end diff --git a/elixir/apps/domain/lib/domain/resources/resource.ex b/elixir/apps/domain/lib/domain/resources/resource.ex index e1e75ee3c..aa53ad9c4 100644 --- a/elixir/apps/domain/lib/domain/resources/resource.ex +++ b/elixir/apps/domain/lib/domain/resources/resource.ex @@ -17,7 +17,7 @@ defmodule Domain.Resources.Resource do belongs_to :account, Domain.Accounts.Account has_many :connections, Domain.Resources.Connection, on_replace: :delete - has_many :gateway_groups, through: [:connections, :gateway_group] + has_many :gateway_groups, through: [:connections, :gateway_group], where: [deleted_at: nil] field :created_by, Ecto.Enum, values: ~w[identity]a belongs_to :created_by_identity, Domain.Auth.Identity diff --git a/elixir/apps/domain/priv/repo/migrations/20230405182924_create_relays.exs b/elixir/apps/domain/priv/repo/migrations/20230405182924_create_relays.exs index 9e5fdce93..d7c87774b 100644 --- a/elixir/apps/domain/priv/repo/migrations/20230405182924_create_relays.exs +++ b/elixir/apps/domain/priv/repo/migrations/20230405182924_create_relays.exs @@ -43,5 +43,21 @@ defmodule Domain.Repo.Migrations.CreateRelays do where: "deleted_at IS NULL AND ipv6 IS NOT NULL" ) ) + + create( + index(:relays, [:ipv4], + unique: true, + name: :relays_ipv4_index, + where: "account_id IS NULL AND deleted_at IS NULL AND ipv4 IS NOT NULL" + ) + ) + + create( + index(:relays, [:ipv6], + unique: true, + name: :relays_ipv6_index, + where: "account_id IS NULL AND deleted_at IS NULL AND ipv6 IS NOT NULL" + ) + ) end end diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index a4e39eb99..0488b68a7 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -15,10 +15,20 @@ maybe_repo_update = fn resource, values -> end end -{:ok, account} = Accounts.create_account(%{name: "Firezone Account"}) +{:ok, account} = + Accounts.create_account(%{ + name: "Firezone Account", + slug: "firezone" + }) + account = maybe_repo_update.(account, id: "c89bcc8c-9392-4dae-a40d-888aef6d28e0") -{:ok, other_account} = Accounts.create_account(%{name: "Other Corp Account"}) +{:ok, other_account} = + Accounts.create_account(%{ + name: "Other Corp Account", + slug: "not-firezone" + }) + other_account = maybe_repo_update.(other_account, id: "9b9290bf-e1bc-4dd3-b401-511908262690") IO.puts("Created accounts: ") @@ -31,11 +41,18 @@ IO.puts("") {:ok, email_provider} = Auth.create_provider(account, %{ - name: "email", + name: "Email", adapter: :email, adapter_config: %{} }) +{:ok, token_provider} = + Auth.create_provider(account, %{ + name: "Token", + adapter: :token, + adapter_config: %{} + }) + {:ok, _oidc_provider} = Auth.create_provider(account, %{ name: "Vault", @@ -85,6 +102,15 @@ admin_actor_email = "firezone@localhost" name: "Firezone Admin" }) +{:ok, service_account_actor} = + Actors.create_actor(token_provider, "backup-manager", %{ + "type" => :service_account, + "name" => "Backup Manager", + "provider" => %{ + expires_at: DateTime.utc_now() |> DateTime.add(365, :day) + } + }) + {:ok, unprivileged_actor_userpass_identity} = Auth.create_identity(unprivileged_actor, userpass_provider, unprivileged_actor_email, %{ "password" => "Firezone1234", @@ -118,7 +144,7 @@ other_admin_actor_email = "other@localhost" name: "Other Admin" }) -{:ok, other_unprivileged_actor_userpass_identity} = +{:ok, _other_unprivileged_actor_userpass_identity} = Auth.create_identity( other_unprivileged_actor, other_userpass_provider, @@ -135,33 +161,6 @@ other_admin_actor_email = "other@localhost" "password_confirmation" => "Firezone1234" }) -if client_secret = System.get_env("SEEDS_GOOGLE_OIDC_CLIENT_SECRET") do - {:ok, google_provider} = - Auth.create_provider(account, %{ - name: "Google Workspace", - adapter: :google_workspace, - adapter_config: %{ - "client_id" => - "1064313638613-0bttveunfv27l72s3h6th13kk16pj9l1.apps.googleusercontent.com", - "client_secret" => client_secret - } - }) - - google_provider = - Ecto.Changeset.change(google_provider, id: "8614a622-6c24-48aa-b1a4-2c6c04b6cbab") - |> Repo.update!() - - google_workspace_uid = System.get_env("SEEDS_GOOGLE_WORKSPACE_USER_ID") - - {:ok, _admin_actor_google_workspace_identity} = - Auth.create_identity(admin_actor, google_provider, google_workspace_uid, %{}) - - IO.puts("") - IO.puts("Google Workspace provider: #{google_provider.id}") - IO.puts(" User ID: #{google_workspace_uid}") - IO.puts("") -end - unprivileged_actor_token = hd(unprivileged_actor.identities).provider_virtual_state.sign_in_token admin_actor_token = hd(admin_actor.identities).provider_virtual_state.sign_in_token @@ -191,9 +190,16 @@ for {type, login, password, email_token} <- [ IO.puts(" #{login}, #{type}, password: #{password}, email token: #{email_token} (exp in 15m)") end +service_account_identity = hd(service_account_actor.identities) +service_account_token = service_account_identity.provider_virtual_state.secret + +IO.puts( + " #{service_account_identity.provider_identifier}, #{service_account_actor.type}, token: #{service_account_token}" +) + IO.puts("") -user_iphone = +_user_iphone = Domain.Devices.upsert_device( %{ name: "FZ User iPhone", @@ -204,7 +210,7 @@ user_iphone = unprivileged_subject ) -admin_iphone = +_admin_iphone = Domain.Devices.upsert_device( %{ name: "FZ Admin iPhone", @@ -291,7 +297,7 @@ IO.puts("") gateway_group = account |> Gateways.Group.Changeset.create_changeset( - %{name_prefix: "mycro-aws-gws", tokens: [%{}]}, + %{name_prefix: "mycro-aws-gws", tags: ["aws", "in-da-cloud"], tokens: [%{}]}, admin_subject ) |> Repo.insert!() diff --git a/elixir/apps/domain/test/domain/gateways_test.exs b/elixir/apps/domain/test/domain/gateways_test.exs index 90e6ab163..00c1335be 100644 --- a/elixir/apps/domain/test/domain/gateways_test.exs +++ b/elixir/apps/domain/test/domain/gateways_test.exs @@ -404,7 +404,15 @@ defmodule Domain.GatewaysTest do assert fetch_gateway_by_id(Ecto.UUID.generate(), subject) == {:error, {:unauthorized, - [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + [ + missing_permissions: [ + {:one_of, + [ + Gateways.Authorizer.manage_gateways_permission(), + Gateways.Authorizer.connect_gateways_permission() + ]} + ] + ]}} end # TODO: add a test that soft-deleted assocs are not preloaded @@ -435,12 +443,21 @@ defmodule Domain.GatewaysTest do account: account, subject: subject } do - GatewaysFixtures.create_gateway(account: account) - GatewaysFixtures.create_gateway(account: account) + offline_gateway = GatewaysFixtures.create_gateway(account: account) + online_gateway = GatewaysFixtures.create_gateway(account: account) + :ok = connect_gateway(online_gateway) GatewaysFixtures.create_gateway() assert {:ok, gateways} = list_gateways(subject) assert length(gateways) == 2 + + online_gateway_id = online_gateway.id + offline_gateway_id = offline_gateway.id + + assert %{ + true: [%{id: ^online_gateway_id}], + false: [%{id: ^offline_gateway_id}] + } = Enum.group_by(gateways, & &1.online?) end test "returns error when subject has no permission to manage gateways", %{ @@ -462,8 +479,8 @@ defmodule Domain.GatewaysTest do {:ok, gateways} = list_gateways(subject, preload: [:group, :account]) assert length(gateways) == 2 - assert Enum.all?(gateways, fn g -> Ecto.assoc_loaded?(g.group) end) == true - assert Enum.all?(gateways, fn g -> Ecto.assoc_loaded?(g.account) end) == true + assert Enum.all?(gateways, &Ecto.assoc_loaded?(&1.group)) + assert Enum.all?(gateways, &Ecto.assoc_loaded?(&1.account)) end end @@ -506,6 +523,37 @@ defmodule Domain.GatewaysTest do end end + describe "gateway_can_connect_to_resource?/2" do + test "returns true when gateway can connect to resource", %{account: account} do + gateway = GatewaysFixtures.create_gateway(account: account) + :ok = connect_gateway(gateway) + + resource = + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: gateway.group_id}] + ) + + assert gateway_can_connect_to_resource?(gateway, resource) + end + + test "returns false when gateway cannot connect to resource", %{account: account} do + gateway = GatewaysFixtures.create_gateway(account: account) + :ok = connect_gateway(gateway) + + resource = ResourcesFixtures.create_resource(account: account) + + refute gateway_can_connect_to_resource?(gateway, resource) + end + + test "returns false when gateway is offline", %{account: account} do + gateway = GatewaysFixtures.create_gateway(account: account) + resource = ResourcesFixtures.create_resource(account: account) + + refute gateway_can_connect_to_resource?(gateway, resource) + end + end + describe "change_gateway/1" do test "returns changeset with given changes" do gateway = GatewaysFixtures.create_gateway() @@ -749,6 +797,37 @@ defmodule Domain.GatewaysTest do end end + describe "load_balance_gateways/1" do + test "returns random gateway" do + gateways = Enum.map(1..10, fn _ -> GatewaysFixtures.create_gateway() end) + assert Enum.member?(gateways, load_balance_gateways(gateways)) + end + end + + describe "load_balance_gateways/2" do + test "returns random gateway if no gateways are already connected" do + gateways = Enum.map(1..10, fn _ -> GatewaysFixtures.create_gateway() end) + assert Enum.member?(gateways, load_balance_gateways(gateways, [])) + end + + test "reuses gateway that is already connected to reduce the latency" do + gateways = Enum.map(1..10, fn _ -> GatewaysFixtures.create_gateway() end) + [connected_gateway | _] = gateways + + assert load_balance_gateways(gateways, [connected_gateway.id]) == connected_gateway + end + + test "returns random gateway from the connected ones" do + gateways = Enum.map(1..10, fn _ -> GatewaysFixtures.create_gateway() end) + [connected_gateway1, connected_gateway2 | _] = gateways + + assert load_balance_gateways(gateways, [connected_gateway1.id, connected_gateway2.id]) in [ + connected_gateway1, + connected_gateway2 + ] + end + end + describe "encode_token!/1" do test "returns encoded token" do token = GatewaysFixtures.create_token() diff --git a/elixir/apps/domain/test/domain/jobs/executors/global_test.exs b/elixir/apps/domain/test/domain/jobs/executors/global_test.exs index d25519bd7..6201bf8d3 100644 --- a/elixir/apps/domain/test/domain/jobs/executors/global_test.exs +++ b/elixir/apps/domain/test/domain/jobs/executors/global_test.exs @@ -75,6 +75,8 @@ defmodule Domain.Jobs.Executors.GlobalTest do Process.exit(leader_pid, :kill) assert_receive {:EXIT, ^leader_pid, :killed} + Process.sleep(100) + %{leader: [new_leader_pid], fallback: [fallback_pid]} = Enum.group_by([fallback1_pid, fallback2_pid], fn pid -> case :sys.get_state(pid) do diff --git a/elixir/apps/domain/test/domain/relays_test.exs b/elixir/apps/domain/test/domain/relays_test.exs index ccf8bba1f..57b77c6ed 100644 --- a/elixir/apps/domain/test/domain/relays_test.exs +++ b/elixir/apps/domain/test/domain/relays_test.exs @@ -656,6 +656,41 @@ defmodule Domain.RelaysTest do assert Repo.aggregate(Domain.Network.Address, :count) == 0 end + + test "updates global relay when it already exists", %{ + token: token + } do + group = RelaysFixtures.create_global_group() + relay = RelaysFixtures.create_relay(group: group, token: token) + + attrs = + RelaysFixtures.relay_attrs( + ipv4: relay.ipv4, + last_seen_remote_ip: relay.ipv4, + last_seen_user_agent: "iOS/12.5 (iPhone) connlib/0.7.411" + ) + + assert {:ok, updated_relay} = upsert_relay(token, attrs) + + assert Repo.aggregate(Relays.Relay, :count, :id) == 1 + + assert updated_relay.last_seen_remote_ip.address == attrs.last_seen_remote_ip.address + assert updated_relay.last_seen_user_agent == attrs.last_seen_user_agent + assert updated_relay.last_seen_user_agent != relay.last_seen_user_agent + assert updated_relay.last_seen_version == "0.7.411" + assert updated_relay.last_seen_at + assert updated_relay.last_seen_at != relay.last_seen_at + + assert updated_relay.token_id == token.id + assert updated_relay.group_id == token.group_id + + assert updated_relay.ipv4 == relay.ipv4 + assert updated_relay.ipv6.address == attrs.ipv6 + assert updated_relay.ipv6 != relay.ipv6 + assert updated_relay.port == 3478 + + assert Repo.aggregate(Domain.Network.Address, :count) == 0 + end end describe "delete_relay/2" do diff --git a/elixir/apps/domain/test/domain/resources_test.exs b/elixir/apps/domain/test/domain/resources_test.exs index 1dc09ed8c..24f74bf87 100644 --- a/elixir/apps/domain/test/domain/resources_test.exs +++ b/elixir/apps/domain/test/domain/resources_test.exs @@ -684,4 +684,38 @@ defmodule Domain.ResourcesTest do [missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}} end end + + describe "connected?/2" do + test "returns true when resource has a connection to a gateway", %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account, subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account, group: group) + + resource = + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + + assert connected?(resource, gateway) + end + + test "raises resource and gateway don't belong to the same account" do + gateway = GatewaysFixtures.create_gateway() + resource = ResourcesFixtures.create_resource() + + assert_raise FunctionClauseError, fn -> + connected?(resource, gateway) + end + end + + test "returns false when resource has no connection to a gateway", %{account: account} do + gateway = GatewaysFixtures.create_gateway(account: account) + resource = ResourcesFixtures.create_resource(account: account) + + refute connected?(resource, gateway) + end + end end diff --git a/elixir/apps/domain/test/support/fixtures/gateways_fixtures.ex b/elixir/apps/domain/test/support/fixtures/gateways_fixtures.ex index 788e5b67e..68e0fa3e9 100644 --- a/elixir/apps/domain/test/support/fixtures/gateways_fixtures.ex +++ b/elixir/apps/domain/test/support/fixtures/gateways_fixtures.ex @@ -109,7 +109,7 @@ defmodule Domain.GatewaysFixtures do attrs = gateway_attrs(attrs) {:ok, gateway} = Gateways.upsert_gateway(token, attrs) - gateway + %{gateway | online?: false} end def delete_gateway(gateway) do diff --git a/elixir/apps/domain/test/support/fixtures/relays_fixtures.ex b/elixir/apps/domain/test/support/fixtures/relays_fixtures.ex index 2fdb3ac92..5987a07c6 100644 --- a/elixir/apps/domain/test/support/fixtures/relays_fixtures.ex +++ b/elixir/apps/domain/test/support/fixtures/relays_fixtures.ex @@ -114,7 +114,7 @@ defmodule Domain.RelaysFixtures do attrs = relay_attrs(attrs) {:ok, relay} = Relays.upsert_relay(token, attrs) - relay + %{relay | online?: false} end def delete_relay(relay) do diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index d79fa9110..b643256a2 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -57,25 +57,23 @@ defmodule Web.CoreComponents do def code_block(assigns) do ~H""" - - + <%= render_slot(@inner_block) %> - + <.icon name="hero-clipboard-document" data-icon class={~w[ absolute bottom-1 right-1 h-5 w-5 transition text-gray-500 group-hover:text-white ]} /> - + """ end @@ -103,44 +101,46 @@ defmodule Web.CoreComponents do def tabs(assigns) do ~H""" -
- +
+
+ <%= for tab <- @tab do %> + <% end %> - -
-
- <%= for tab <- @tab do %> - - <% end %> +
""" end @@ -483,7 +483,7 @@ defmodule Web.CoreComponents do ## Examples ```heex - <.intersperse :let={item}> + <.intersperse_blocks> <:separator> | @@ -499,7 +499,7 @@ defmodule Web.CoreComponents do <:item> settings - + ``` Renders the following markup: @@ -532,6 +532,7 @@ defmodule Web.CoreComponents do end attr :type, :string, default: "default" + attr :rest, :global slot :inner_block, required: true def badge(assigns) do @@ -546,7 +547,7 @@ defmodule Web.CoreComponents do assigns = assign(assigns, colors: colors) ~H""" - + <%= render_slot(@inner_block) %> """ @@ -583,6 +584,29 @@ defmodule Web.CoreComponents do """ end + @doc """ + Renders online or offline status using an `online?` field of the schema. + """ + attr :schema, :any, required: true + + def connection_status(assigns) do + assigns = assign_new(assigns, :relative_to, fn -> DateTime.utc_now() end) + + ~H""" + <.badge + type={if @schema.online?, do: "success", else: "danger"} + title={ + if @schema.last_seen_at, + do: + "Last seen #{Cldr.DateTime.Relative.to_string!(@schema.last_seen_at, Web.CLDR, relative_to: @relative_to)}", + else: "Never connected" + } + > + <%= if @schema.online?, do: "Online", else: "Offline" %> + + """ + end + @doc """ Renders username """ diff --git a/elixir/apps/web/lib/web/components/form_components.ex b/elixir/apps/web/lib/web/components/form_components.ex index 40fe4d771..170bc61fa 100644 --- a/elixir/apps/web/lib/web/components/form_components.ex +++ b/elixir/apps/web/lib/web/components/form_components.ex @@ -28,7 +28,7 @@ defmodule Web.FormComponents do attr :type, :string, default: "text", values: ~w(checkbox color date datetime-local email file hidden month number password - range radio search select tel text textarea time url week) + range radio search select tel text textarea taglist time url week) attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]" @@ -130,6 +130,52 @@ defmodule Web.FormComponents do end # All other inputs text, datetime-local, url, password, etc. are handled here... + def input(%{type: "taglist"} = assigns) do + values = + if is_nil(assigns.value), + do: [], + else: Enum.map(assigns.value, &Phoenix.HTML.Form.normalize_value("text", &1)) + + assigns = assign(assigns, :values, values) + + ~H""" +
+ <.label for={@id}><%= @label %> + +
+ + <.button + type="button" + phx-click={"delete:#{@name}"} + phx-value-index={index} + class="align-middle ml-2 inline-block whitespace-nowrap" + > + <.icon name="hero-minus" /> Delete + +
+ + <.button type="button" phx-click={"add:#{@name}"} class="mt-2"> + <.icon name="hero-plus" /> Add + + + <.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %> +
+ """ + end + def input(assigns) do ~H"""
diff --git a/elixir/apps/web/lib/web/components/layouts/app.html.heex b/elixir/apps/web/lib/web/components/layouts/app.html.heex index f42e4c899..afc50ee9c 100644 --- a/elixir/apps/web/lib/web/components/layouts/app.html.heex +++ b/elixir/apps/web/lib/web/components/layouts/app.html.heex @@ -111,10 +111,17 @@ Devices - <.sidebar_item navigate={~p"/#{@account}/gateways"} icon="hero-arrow-left-on-rectangle-solid"> + <.sidebar_item + navigate={~p"/#{@account}/gateway_groups"} + icon="hero-arrow-left-on-rectangle-solid" + > Gateways + <.sidebar_item navigate={~p"/#{@account}/relay_groups"} icon="hero-arrows-right-left"> + Relays + + <.sidebar_item navigate={~p"/#{@account}/resources"} icon="hero-server-stack-solid"> Resources diff --git a/elixir/apps/web/lib/web/components/table_components.ex b/elixir/apps/web/lib/web/components/table_components.ex index 1ccfd7048..e2d489ff1 100644 --- a/elixir/apps/web/lib/web/components/table_components.ex +++ b/elixir/apps/web/lib/web/components/table_components.ex @@ -7,6 +7,79 @@ defmodule Web.TableComponents do import Web.Gettext import Web.CoreComponents + attr :columns, :any, required: true, doc: "col slot taken from parent component" + attr :actions, :any, required: true, doc: "action slot taken from parent component" + + def table_header(assigns) do + ~H""" + + + + <%= col[:label] %> + <.icon + :if={col[:sortable] == "true"} + name="hero-chevron-up-down-solid" + class="w-4 h-4 ml-1" + /> + + + <%= gettext("Actions") %> + + + + """ + end + + attr :id, :any, default: nil, doc: "the function for generating the row id" + attr :row, :map, required: true, doc: "the row data" + attr :click, :any, default: nil, doc: "the function for handling phx-click on each row" + + attr :columns, :any, required: true, doc: "col slot taken from parent component" + attr :actions, :list, required: true, doc: "action slot taken from parent component" + + attr :mapper, :any, + default: &Function.identity/1, + doc: "the function for mapping each row before calling the :col and :action slots" + + def table_row(assigns) do + ~H""" + + + <%= render_slot(col, @mapper.(@row)) %> + + + + + + + """ + end + @doc ~S""" Renders a table with generic styling. @@ -42,61 +115,17 @@ defmodule Web.TableComponents do ~H"""
- - - - - - + <.table_header columns={@col} actions={@action} /> - - - - + <.table_row + :for={row <- @rows} + columns={@col} + actions={@action} + row={row} + id={@row_id && @row_id.(row)} + click={@row_click} + mapper={@row_item} + />
- <%= col[:label] %> - <.icon - :if={col[:sortable] == "true"} - name="hero-chevron-up-down-solid" - class="w-4 h-4 ml-1" - /> - - <%= gettext("Actions") %> -
- <%= render_slot(col, @row_item.(row)) %> - - - -
@@ -120,8 +149,15 @@ defmodule Web.TableComponents do """ attr :id, :string, required: true - attr :rows, :list, required: true + attr :groups, :list, required: true + attr :group_id, :any, default: nil, doc: "the function for generating the group id" + attr :row_id, :any, default: nil, doc: "the function for generating the row id" + attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + + attr :group_items, :any, + required: true, + doc: "a mapper which is used to get list of rows for a group" attr :row_item, :any, default: &Function.identity/1, @@ -132,67 +168,36 @@ defmodule Web.TableComponents do attr :sortable, :string end + slot :group, required: true + slot :action, doc: "the slot for showing user actions in the last table column" def table_with_groups(assigns) do + assigns = + with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do + assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) + end + ~H""" - - - - + <.table_header columns={@col} actions={@action} /> + + + + - - - <%= for {group, items} <- @rows do %> - - - - - - - - - <% end %> + + <.table_row + :for={row <- @group_items.(group)} + columns={@col} + actions={@action} + row={row} + id={@row_id && @row_id.(row)} + click={@row_click} + mapper={@row_item} + />
- <%= col[:label] %> - <.icon - :if={col[:sortable] == "true"} - name="hero-chevron-up-down-solid" - class="w-4 h-4 ml-1" - /> - - <%= gettext("Actions") %> -
+ <%= render_slot(@group, group) %> +
- <%= group.name_prefix %> -
- <%= render_slot(col, item) %> - - - -
""" diff --git a/elixir/apps/web/lib/web/live/gateway_groups/edit.ex b/elixir/apps/web/lib/web/live/gateway_groups/edit.ex new file mode 100644 index 000000000..61154d86a --- /dev/null +++ b/elixir/apps/web/lib/web/live/gateway_groups/edit.ex @@ -0,0 +1,87 @@ +defmodule Web.GatewayGroups.Edit do + use Web, :live_view + alias Domain.Gateways + + def mount(%{"id" => id} = _params, _session, socket) do + with {:ok, group} <- Gateways.fetch_group_by_id(id, socket.assigns.subject) do + changeset = Gateways.change_group(group) + {:ok, assign(socket, group: group, form: to_form(changeset))} + else + {:error, :not_found} -> raise Web.LiveErrors.NotFoundError + end + end + + def handle_event("delete:group[tags]", %{"index" => index}, socket) do + changeset = socket.assigns.form.source + values = Ecto.Changeset.fetch_field!(changeset, :tags) || [] + values = List.delete_at(values, String.to_integer(index)) + changeset = Ecto.Changeset.put_change(changeset, :tags, values) + {:noreply, assign(socket, form: to_form(changeset))} + end + + def handle_event("add:group[tags]", _params, socket) do + changeset = socket.assigns.form.source + values = Ecto.Changeset.fetch_field!(changeset, :tags) || [] + changeset = Ecto.Changeset.put_change(changeset, :tags, values ++ [""]) + {:noreply, assign(socket, form: to_form(changeset))} + end + + def handle_event("change", %{"group" => attrs}, socket) do + changeset = + Gateways.change_group(socket.assigns.group, attrs) + |> Map.put(:action, :insert) + + {:noreply, assign(socket, form: to_form(changeset))} + end + + def handle_event("submit", %{"group" => attrs}, socket) do + with {:ok, group} <- + Gateways.update_group(socket.assigns.group, attrs, socket.assigns.subject) do + socket = redirect(socket, to: ~p"/#{socket.assigns.account}/gateway_groups/#{group}") + {:noreply, socket} + else + {:error, changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/gateway_groups"}>Gateway Instance Groups + <.breadcrumb path={~p"/#{@account}/gateway_groups/#{@group}"}> + <%= @group.name_prefix %> + + <.breadcrumb path={~p"/#{@account}/gateway_groups/#{@group}/edit"}>Edit + + <.header> + <:title> + Editing Gateway Instance Group <%= @group.name_prefix %> + + + +
+
+ <.form for={@form} phx-change={:change} phx-submit={:submit}> +
+
+ <.input + label="Name Prefix" + field={@form[:name_prefix]} + placeholder="Name of this Gateway Instance Group" + required + /> +
+
+ <.input label="Tags" type="taglist" field={@form[:tags]} placeholder="Tag" /> +
+
+ <.submit_button> + Save + + +
+
+ """ + end +end diff --git a/elixir/apps/web/lib/web/live/gateways/index.ex b/elixir/apps/web/lib/web/live/gateway_groups/index.ex similarity index 53% rename from elixir/apps/web/lib/web/live/gateways/index.ex rename to elixir/apps/web/lib/web/live/gateway_groups/index.ex index 35b493af1..d6d761045 100644 --- a/elixir/apps/web/lib/web/live/gateways/index.ex +++ b/elixir/apps/web/lib/web/live/gateway_groups/index.ex @@ -1,50 +1,72 @@ -defmodule Web.Gateways.Index do +defmodule Web.GatewayGroups.Index do use Web, :live_view - alias Domain.Gateways - alias Domain.Resources def mount(_params, _session, socket) do subject = socket.assigns.subject - {:ok, gateways} = Gateways.list_gateways(subject, preload: :group) - {_, resources} = - Enum.map_reduce(gateways, %{}, fn g, acc -> - {:ok, count} = Resources.count_resources_for_gateway(g, subject) - {count, Map.put(acc, g.id, count)} - end) + with {:ok, groups} <- + Gateways.list_groups(subject, preload: [:gateways, connections: [:resource]]) do + :ok = Gateways.subscribe_for_gateways_presence_in_account(socket.assigns.account) + {:ok, assign(socket, groups: groups)} + end + end - grouped_gateways = Enum.group_by(gateways, fn g -> g.group end) - - socket = - assign(socket, - grouped_gateways: grouped_gateways, - resources: resources - ) - - {:ok, socket} + def handle_info(%Phoenix.Socket.Broadcast{topic: "gateways:" <> _account_id}, socket) do + subject = socket.assigns.subject + {:ok, groups} = Gateways.list_groups(subject, preload: [:gateways, connections: [:resource]]) + {:noreply, assign(socket, groups: groups)} end def render(assigns) do ~H""" <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> - <.breadcrumb path={~p"/#{@account}/gateways"}>Gateways + <.breadcrumb path={~p"/#{@account}/gateway_groups"}>Gateway Instance Groups <.header> <:title> All gateways <:actions> - <.add_button navigate={~p"/#{@account}/gateways/new"}> + <.add_button navigate={~p"/#{@account}/gateway_groups/new"}> Add Instance Group
- <.resource_filter /> - <.table_with_groups id="grouped-gateways" rows={@grouped_gateways} row_id={&"gateway-#{&1.id}"}> - <:col label="INSTANCE GROUP"> + + <.table_with_groups + id="grouped-gateways" + groups={@groups} + group_items={& &1.gateways} + row_id={&"gateway-#{&1.id}"} + > + <:group :let={group}> + <.link + navigate={~p"/#{@account}/gateway_groups/#{group.id}"} + class="font-bold text-blue-600 dark:text-blue-500 hover:underline" + > + <%= group.name_prefix %> + + <%= if not Enum.empty?(group.tags), do: "(" <> Enum.join(group.tags, ", ") <> ")" %> + +
+ Resources: + <.intersperse_blocks> + <:separator>, + + <:item :for={connection <- group.connections}> + <.link + navigate={~p"/#{@account}/resources/#{connection.resource}"} + class="font-medium text-blue-600 dark:text-blue-500 hover:underline inline-block" + phx-no-format + ><%= connection.resource.name %> + + +
+ + <:col :let={gateway} label="INSTANCE"> <.link navigate={~p"/#{@account}/gateways/#{gateway.id}"} @@ -61,39 +83,17 @@ defmodule Web.Gateways.Index do <%= gateway.ipv6 %> - <:col :let={gateway} label="RESOURCES"> - <.badge> - <%= @resources[gateway.id] || "0" %> - + + <:col :let={gateway} label="STATUS"> + <.connection_status schema={gateway} /> - <:col :let={_gateway} label="STATUS"> - <.badge type="success"> - TODO: Online - - - <:action :let={gateway}> - <.link - navigate={~p"/#{@account}/gateways/#{gateway.id}"} - class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white" - > - Show - - - <:action :let={_gateway}> - - Delete - - - <.paginator page={3} total_pages={100} collection_base_path={~p"/#{@account}/gateways"} /> +
""" end - defp resource_filter(assigns) do + def resource_filter(assigns) do ~H"""
diff --git a/elixir/apps/web/lib/web/live/gateway_groups/new.ex b/elixir/apps/web/lib/web/live/gateway_groups/new.ex new file mode 100644 index 000000000..5b99c8f41 --- /dev/null +++ b/elixir/apps/web/lib/web/live/gateway_groups/new.ex @@ -0,0 +1,132 @@ +defmodule Web.GatewayGroups.New do + use Web, :live_view + alias Domain.Gateways + + def mount(_params, _session, socket) do + changeset = Gateways.new_group() + {:ok, assign(socket, form: to_form(changeset), group: nil)} + end + + def handle_event("delete:group[tags]", %{"index" => index}, socket) do + changeset = socket.assigns.form.source + values = Ecto.Changeset.fetch_field!(changeset, :tags) || [] + values = List.delete_at(values, String.to_integer(index)) + changeset = Ecto.Changeset.put_change(changeset, :tags, values) + {:noreply, assign(socket, form: to_form(changeset))} + end + + def handle_event("add:group[tags]", _params, socket) do + changeset = socket.assigns.form.source + values = Ecto.Changeset.fetch_field!(changeset, :tags) || [] + changeset = Ecto.Changeset.put_change(changeset, :tags, values ++ [""]) + {:noreply, assign(socket, form: to_form(changeset))} + end + + def handle_event("change", %{"group" => attrs}, socket) do + changeset = + Gateways.new_group(attrs) + |> Map.put(:action, :insert) + + {:noreply, assign(socket, form: to_form(changeset))} + end + + def handle_event("submit", %{"group" => attrs}, socket) do + attrs = Map.put(attrs, "tokens", [%{}]) + + with {:ok, group} <- + Gateways.create_group(attrs, socket.assigns.subject) do + :ok = Gateways.subscribe_for_gateways_presence_in_group(group) + {:noreply, assign(socket, group: group)} + else + {:error, changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + def handle_info(%Phoenix.Socket.Broadcast{topic: "gateway_groups:" <> _account_id}, socket) do + socket = + redirect(socket, to: ~p"/#{socket.assigns.account}/gateway_groups/#{socket.assigns.group}") + + {:noreply, socket} + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/gateway_groups"}>Gateway Instance Groups + <.breadcrumb path={~p"/#{@account}/gateway_groups/new"}>Add + + + <.header> + <:title :if={is_nil(@group)}> + Add a new Gateway Instance Group + + <:title :if={not is_nil(@group)}> + Deploy your Gateway Instance + + + +
+
+ <.form :if={is_nil(@group)} for={@form} phx-change={:change} phx-submit={:submit}> +
+
+ <.input + label="Name Prefix" + field={@form[:name_prefix]} + placeholder="Name of this Gateway Instance Group" + required + /> +
+ +
+ <.input label="Tags" type="taglist" field={@form[:tags]} placeholder="Tag" /> +
+
+ + <.submit_button> + Save + + + +
+
+ Select deployment method: +
+ <.tabs id="deployment-instructions"> + <:tab id="docker-instructions" label="Docker"> + <.code_block id="code-sample-docker" class="w-full rounded-b-lg" phx-no-format> + docker run -d \
+   --name=firezone-gateway-0 \
+   --restart=always \
+   -v /dev/net/tun:/dev/net/tun \
+   -e FZ_SECRET=<%= Gateways.encode_token!(hd(@group.tokens)) %> \
+   us-east1-docker.pkg.dev/firezone/firezone/gateway:stable + + + <:tab id="systemd-instructions" label="Systemd"> + <.code_block id="code-sample-systemd" class="w-full rounded-b-lg" phx-no-format> + [Unit]
+ Description=zigbee2mqtt
+ After=network.target
+
+ [Service]
+ ExecStart=/usr/bin/npm start
+ WorkingDirectory=/opt/zigbee2mqtt
+ StandardOutput=inherit
+ StandardError=inherit
+ Restart=always
+ User=pi + + + + +
+ Waiting for gateway connection... +
+
+
+
+ """ + end +end diff --git a/elixir/apps/web/lib/web/live/gateway_groups/show.ex b/elixir/apps/web/lib/web/live/gateway_groups/show.ex new file mode 100644 index 000000000..82fc45c7d --- /dev/null +++ b/elixir/apps/web/lib/web/live/gateway_groups/show.ex @@ -0,0 +1,148 @@ +defmodule Web.GatewayGroups.Show do + use Web, :live_view + alias Domain.Gateways + + def mount(%{"id" => id} = _params, _session, socket) do + with {:ok, group} <- + Gateways.fetch_group_by_id(id, socket.assigns.subject, + preload: [ + gateways: [token: [created_by_identity: [:actor]]], + connections: [:resource], + created_by_identity: [:actor] + ] + ) do + :ok = Gateways.subscribe_for_gateways_presence_in_group(group) + {:ok, assign(socket, group: group)} + else + {:error, :not_found} -> raise Web.LiveErrors.NotFoundError + end + end + + def handle_info(%Phoenix.Socket.Broadcast{topic: "gateway_groups:" <> _account_id}, socket) do + socket = + redirect(socket, to: ~p"/#{socket.assigns.account}/gateway_groups/#{socket.assigns.group}") + + {:noreply, socket} + end + + def handle_event("delete", _params, socket) do + # TODO: make sure tokens are all deleted too! + {:ok, _group} = Gateways.delete_group(socket.assigns.group, socket.assigns.subject) + {:noreply, redirect(socket, to: ~p"/#{socket.assigns.account}/gateway_groups")} + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/gateway_groups"}>Gateway Instance Groups + <.breadcrumb path={~p"/#{@account}/gateway_groups/#{@group}"}> + <%= @group.name_prefix %> + + + <.header> + <:title> + Gateway Instance Group: <%= @group.name_prefix %> + + <:actions> + <.edit_button navigate={~p"/#{@account}/gateway_groups/#{@group}/edit"}> + Edit Instance Group + + + + +
+ <.vertical_table> + <.vertical_table_row> + <:label>Instance Group Name + <:value><%= @group.name_prefix %> + + <.vertical_table_row> + <:label>Tags + <:value> + <.badge :for={tag <- @group.tags} class="ml-2"> + <%= tag %> + + + + <.vertical_table_row> + <:label>Created + <:value> + <.datetime datetime={@group.inserted_at} /> by <.owner schema={@group} /> + + + + +
+
+

+ Gateway Instances +

+
+
+
+ <.table id="gateways" rows={@group.gateways}> + <:col :let={gateway} label="INSTANCE"> + <.link + navigate={~p"/#{@account}/gateways/#{gateway.id}"} + class="font-medium text-blue-600 dark:text-blue-500 hover:underline" + > + <%= gateway.name_suffix %> + + + <:col :let={gateway} label="REMOTE IP"> + + <%= gateway.ipv4 %> + + + <%= gateway.ipv6 %> + + + <:col :let={gateway} label="TOKEN CREATED AT"> + <.datetime datetime={gateway.token.inserted_at} /> by <.owner schema={gateway.token} /> + + <:col :let={gateway} label="STATUS"> + <.connection_status schema={gateway} /> + + +
+ +
+
+

+ Linked Resources +

+
+
+
+ <.table id="resources" rows={@group.connections} row_item={& &1.resource}> + <:col :let={resource} label="NAME"> + <.link + navigate={~p"/#{@account}/resources/#{resource.id}"} + class="font-medium text-blue-600 dark:text-blue-500 hover:underline" + > + <%= resource.name %> + + + <:col :let={resource} label="ADDRESS"> + <%= resource.address %> + + +
+
+ + <.header> + <:title> + Danger zone + + <:actions> + <.delete_button + phx-click="delete" + data-confirm="Are you sure want to delete this gateway group and disconnect all it's gateways?" + > + Delete Gateway Instance Group + + + + """ + end +end diff --git a/elixir/apps/web/lib/web/live/gateways/edit.ex b/elixir/apps/web/lib/web/live/gateways/edit.ex deleted file mode 100644 index 4cf098421..000000000 --- a/elixir/apps/web/lib/web/live/gateways/edit.ex +++ /dev/null @@ -1,53 +0,0 @@ -defmodule Web.Gateways.Edit do - use Web, :live_view - - alias Domain.Gateways - - def mount(%{"id" => id} = _params, _session, socket) do - {:ok, gateway} = Gateways.fetch_gateway_by_id(id, socket.assigns.subject, preload: :group) - {:ok, assign(socket, gateway: gateway)} - end - - def render(assigns) do - ~H""" - <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> - <.breadcrumb path={~p"/#{@account}/gateways"}>Gateways - <.breadcrumb path={~p"/#{@account}/gateways/#{@gateway}"}> - <%= @gateway.name_suffix %> - - <.breadcrumb path={~p"/#{@account}/gateways/#{@gateway}/edit"}>Edit - - <.header> - <:title> - Editing Gateway <%= @gateway.name_suffix %> - - - -
-
-

Gateway details

-
-
-
- <.label for="gateway-name"> - Name - - -
-
- <.submit_button> - Save - -
-
-
- """ - end -end diff --git a/elixir/apps/web/lib/web/live/gateways/new.ex b/elixir/apps/web/lib/web/live/gateways/new.ex deleted file mode 100644 index 07b061c49..000000000 --- a/elixir/apps/web/lib/web/live/gateways/new.ex +++ /dev/null @@ -1,86 +0,0 @@ -defmodule Web.Gateways.New do - use Web, :live_view - - def render(assigns) do - ~H""" - <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> - <.breadcrumb path={~p"/#{@account}/gateways"}>Gateways - <.breadcrumb path={~p"/#{@account}/gateways/new"}>Add Gateway - - - <.header> - <:title> - Add a new Gateway - - - -
-
-

Gateway details

-
-
-
- <.label for="gateway-name"> - Name - - -
-
- <.label> - Select a deployment method - -
- <.tabs id="deployment-instructions"> - <:tab id="docker-instructions" label="Docker"> - <.code_block id="code-sample-docker"> - docker run -d \ - --name=zigbee2mqtt \ - --restart=always \ - -v /opt/zigbee2mqtt/data:/app/data \ - -v /run/udev:/run/udev:ro \ - --device=/dev/ttyACM0 \ - --net=host \ - koenkk/zigbee2mqtt - - - <:tab id="systemd-instructions" label="Systemd"> - <.code_block id="code-sample-systemd"> - [Unit] - Description=zigbee2mqtt - After=network.target - - [Service] - ExecStart=/usr/bin/npm start - WorkingDirectory=/opt/zigbee2mqtt - StandardOutput=inherit - StandardError=inherit - Restart=always - User=pi - - - -
- - -
- <.p> - Waiting for gateway connection... - -
-
-
-
- """ - end -end diff --git a/elixir/apps/web/lib/web/live/gateways/show.ex b/elixir/apps/web/lib/web/live/gateways/show.ex index 5e4ab69dd..250051fa8 100644 --- a/elixir/apps/web/lib/web/live/gateways/show.ex +++ b/elixir/apps/web/lib/web/live/gateways/show.ex @@ -1,19 +1,52 @@ defmodule Web.Gateways.Show do use Web, :live_view - alias Domain.Gateways - alias Domain.Resources def mount(%{"id" => id} = _params, _session, socket) do - {:ok, gateway} = Gateways.fetch_gateway_by_id(id, socket.assigns.subject, preload: :group) - {:ok, resources} = Resources.list_resources_for_gateway(gateway, socket.assigns.subject) - {:ok, assign(socket, gateway: gateway, resources: resources)} + with {:ok, gateway} <- + Gateways.fetch_gateway_by_id(id, socket.assigns.subject, preload: :group) do + :ok = Gateways.subscribe_for_gateways_presence_in_group(gateway.group) + {:ok, assign(socket, gateway: gateway)} + else + {:error, :not_found} -> raise Web.LiveErrors.NotFoundError + end + end + + def handle_info( + %Phoenix.Socket.Broadcast{topic: "gateway_groups:" <> _account_id, payload: payload}, + socket + ) do + if Map.has_key?(payload.joins, socket.assigns.gateway.id) or + Map.has_key?(payload.leaves, socket.assigns.gateway.id) do + {:ok, gateway} = + Gateways.fetch_gateway_by_id(socket.assigns.gateway.id, socket.assigns.subject, + preload: :group + ) + + {:noreply, assign(socket, gateway: gateway)} + else + {:noreply, socket} + end + end + + def handle_event("delete", _params, socket) do + {:ok, _gateway} = Gateways.delete_gateway(socket.assigns.gateway, socket.assigns.subject) + + socket = + redirect(socket, + to: ~p"/#{socket.assigns.account}/gateway_groups/#{socket.assigns.gateway.group}" + ) + + {:noreply, socket} end def render(assigns) do ~H""" <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> - <.breadcrumb path={~p"/#{@account}/gateways"}>Gateways + <.breadcrumb path={~p"/#{@account}/gateway_groups"}>Gateway Instance Groups + <.breadcrumb path={~p"/#{@account}/gateway_groups/#{@gateway.group}"}> + <%= @gateway.group.name_prefix %> + <.breadcrumb path={~p"/#{@account}/gateways/#{@gateway}"}> <%= @gateway.name_suffix %> @@ -41,7 +74,7 @@ defmodule Web.Gateways.Show do <.vertical_table_row> <:label>Status <:value> - <.badge type="success">TODO: Online + <.connection_status schema={@gateway} /> <.vertical_table_row> @@ -58,8 +91,6 @@ defmodule Web.Gateways.Show do <:value> <.relative_datetime datetime={@gateway.last_seen_at} /> -
- <%= @gateway.last_seen_at %> <.vertical_table_row> @@ -79,11 +110,15 @@ defmodule Web.Gateways.Show do <:value>TODO: 4.43 GB up, 1.23 GB down <.vertical_table_row> - <:label>Gateway Version + <:label>Version <:value> - <%= "Gateway Version: #{@gateway.last_seen_version}" %> -
- <%= "User Agent: #{@gateway.last_seen_user_agent}" %> + <%= @gateway.last_seen_version %> + + + <.vertical_table_row> + <:label>User Agent + <:value> + <%= @gateway.last_seen_user_agent %> <.vertical_table_row> @@ -92,36 +127,13 @@ defmodule Web.Gateways.Show do
- -
-
-

- Linked Resources -

-
-
-
- <.table id="resources" rows={@resources}> - <:col :let={resource} label="NAME"> - <.link - navigate={~p"/#{@account}/resources/#{resource.id}"} - class="font-medium text-blue-600 dark:text-blue-500 hover:underline" - > - <%= resource.name %> - - - <:col :let={resource} label="ADDRESS"> - <%= resource.address %> - - -
<.header> <:title> Danger zone <:actions> - <.delete_button> + <.delete_button phx-click="delete"> Delete Gateway diff --git a/elixir/apps/web/lib/web/live/policies/index.ex b/elixir/apps/web/lib/web/live/policies/index.ex index c68240370..a2bb1c4c7 100644 --- a/elixir/apps/web/lib/web/live/policies/index.ex +++ b/elixir/apps/web/lib/web/live/policies/index.ex @@ -60,7 +60,7 @@ defmodule Web.Policies.Index do - <.paginator page={3} total_pages={100} collection_base_path={~p"/#{@account}/gateways"} /> + <.paginator page={3} total_pages={100} collection_base_path={~p"/#{@account}/gateway_groups"} />
""" end diff --git a/elixir/apps/web/lib/web/live/relay_groups/edit.ex b/elixir/apps/web/lib/web/live/relay_groups/edit.ex new file mode 100644 index 000000000..538ccf10d --- /dev/null +++ b/elixir/apps/web/lib/web/live/relay_groups/edit.ex @@ -0,0 +1,69 @@ +defmodule Web.RelayGroups.Edit do + use Web, :live_view + alias Domain.Relays + + def mount(%{"id" => id} = _params, _session, socket) do + with {:ok, group} <- Relays.fetch_group_by_id(id, socket.assigns.subject) do + changeset = Relays.change_group(group) + {:ok, assign(socket, group: group, form: to_form(changeset))} + else + {:error, :not_found} -> raise Web.LiveErrors.NotFoundError + end + end + + def handle_event("change", %{"group" => attrs}, socket) do + changeset = + Relays.change_group(socket.assigns.group, attrs) + |> Map.put(:action, :insert) + + {:noreply, assign(socket, form: to_form(changeset))} + end + + def handle_event("submit", %{"group" => attrs}, socket) do + with {:ok, group} <- + Relays.update_group(socket.assigns.group, attrs, socket.assigns.subject) do + socket = redirect(socket, to: ~p"/#{socket.assigns.account}/relay_groups/#{group}") + {:noreply, socket} + else + {:error, changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/relay_groups"}>Relay Instance Groups + <.breadcrumb path={~p"/#{@account}/relay_groups/#{@group}"}> + <%= @group.name %> + + <.breadcrumb path={~p"/#{@account}/relay_groups/#{@group}/edit"}>Edit + + <.header> + <:title> + Editing Relay Instance Group <%= @group.name %> + + + +
+
+ <.form for={@form} phx-change={:change} phx-submit={:submit}> +
+
+ <.input + label="Name Prefix" + field={@form[:name]} + placeholder="Name of this Relay Instance Group" + required + /> +
+
+ <.submit_button> + Save + + +
+
+ """ + end +end diff --git a/elixir/apps/web/lib/web/live/relay_groups/index.ex b/elixir/apps/web/lib/web/live/relay_groups/index.ex new file mode 100644 index 000000000..0fb145aa5 --- /dev/null +++ b/elixir/apps/web/lib/web/live/relay_groups/index.ex @@ -0,0 +1,128 @@ +defmodule Web.RelayGroups.Index do + use Web, :live_view + alias Domain.Relays + + def mount(_params, _session, socket) do + subject = socket.assigns.subject + + with {:ok, groups} <- + Relays.list_groups(subject, preload: [:relays]) do + :ok = Relays.subscribe_for_relays_presence_in_account(socket.assigns.account) + {:ok, assign(socket, groups: groups)} + end + end + + def handle_info(%Phoenix.Socket.Broadcast{topic: "relays" <> _account_id_or_nothing}, socket) do + subject = socket.assigns.subject + {:ok, groups} = Relays.list_groups(subject, preload: [:relays]) + {:noreply, assign(socket, groups: groups)} + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/relay_groups"}>Relay Instance Groups + + <.header> + <:title> + All relays + + <:actions> + <.add_button navigate={~p"/#{@account}/relay_groups/new"}> + Add Instance Group + + + + +
+ + <.table_with_groups + id="grouped-relays" + groups={@groups} + group_items={& &1.relays} + row_id={&"relay-#{&1.id}"} + > + <:group :let={group}> + <.link + :if={not is_nil(group.account_id)} + navigate={~p"/#{@account}/relay_groups/#{group.id}"} + class="font-bold text-blue-600 dark:text-blue-500 hover:underline" + > + <%= group.name %> + + + <%= group.name %> + + + + <:col :let={relay} label="INSTANCE"> + <.link + :if={relay.account_id} + navigate={~p"/#{@account}/relays/#{relay.id}"} + class="font-medium text-blue-600 dark:text-blue-500 hover:underline" + > + + <%= relay.ipv4 %> + + + <%= relay.ipv6 %> + + +
+ + <%= relay.ipv4 %> + + + <%= relay.ipv6 %> + +
+ + + <:col :let={relay} label="TYPE"> + <%= if relay.account_id, do: "self-hosted", else: "firezone-owned" %> + + + <:col :let={relay} label="STATUS"> + <.connection_status schema={relay} /> + + + +
+ """ + end + + def resource_filter(assigns) do + ~H""" +
+
+
+ +
+
+ <.icon name="hero-magnifying-glass" class="w-5 h-5 text-gray-500 dark:text-gray-400" /> +
+ +
+
+
+ <.button_group> + <:first> + All + + <:middle> + Online + + <:last> + Deleted + + +
+ """ + end +end diff --git a/elixir/apps/web/lib/web/live/relay_groups/new.ex b/elixir/apps/web/lib/web/live/relay_groups/new.ex new file mode 100644 index 000000000..328c23b82 --- /dev/null +++ b/elixir/apps/web/lib/web/live/relay_groups/new.ex @@ -0,0 +1,113 @@ +defmodule Web.RelayGroups.New do + use Web, :live_view + alias Domain.Relays + + def mount(_params, _session, socket) do + changeset = Relays.new_group() + {:ok, assign(socket, form: to_form(changeset), group: nil)} + end + + def handle_event("change", %{"group" => attrs}, socket) do + changeset = + Relays.new_group(attrs) + |> Map.put(:action, :insert) + + {:noreply, assign(socket, form: to_form(changeset))} + end + + def handle_event("submit", %{"group" => attrs}, socket) do + attrs = Map.put(attrs, "tokens", [%{}]) + + with {:ok, group} <- + Relays.create_group(attrs, socket.assigns.subject) do + :ok = Relays.subscribe_for_relays_presence_in_group(group) + {:noreply, assign(socket, group: group)} + else + {:error, changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + def handle_info(%Phoenix.Socket.Broadcast{topic: "relay_groups:" <> _account_id}, socket) do + socket = + redirect(socket, to: ~p"/#{socket.assigns.account}/relay_groups/#{socket.assigns.group}") + + {:noreply, socket} + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/relay_groups"}>Relay Instance Groups + <.breadcrumb path={~p"/#{@account}/relay_groups/new"}>Add + + + <.header> + <:title :if={is_nil(@group)}> + Add a new Relay Instance Group + + <:title :if={not is_nil(@group)}> + Deploy your Relay Instance + + + +
+
+ <.form :if={is_nil(@group)} for={@form} phx-change={:change} phx-submit={:submit}> +
+
+ <.input + label="Name Prefix" + field={@form[:name]} + placeholder="Name of this Relay Instance Group" + required + /> +
+
+ + <.submit_button> + Save + + + +
+
+ Select deployment method: +
+ <.tabs id="deployment-instructions"> + <:tab id="docker-instructions" label="Docker"> + <.code_block id="code-sample-docker" class="w-full rounded-b-lg" phx-no-format> + docker run -d \
+   --name=firezone-relay-0 \
+   --restart=always \
+   -v /dev/net/tun:/dev/net/tun \
+   -e PORTAL_TOKEN=<%= Relays.encode_token!(hd(@group.tokens)) %> \
+   us-east1-docker.pkg.dev/firezone/firezone/relay:stable + + + <:tab id="systemd-instructions" label="Systemd"> + <.code_block id="code-sample-systemd" class="w-full rounded-b-lg" phx-no-format> + [Unit]
+ Description=zigbee2mqtt
+ After=network.target
+
+ [Service]
+ ExecStart=/usr/bin/npm start
+ WorkingDirectory=/opt/zigbee2mqtt
+ StandardOutput=inherit
+ StandardError=inherit
+ Restart=always
+ User=pi + + + + +
+ Waiting for relay connection... +
+
+
+
+ """ + end +end diff --git a/elixir/apps/web/lib/web/live/relay_groups/show.ex b/elixir/apps/web/lib/web/live/relay_groups/show.ex new file mode 100644 index 000000000..05e0afa7e --- /dev/null +++ b/elixir/apps/web/lib/web/live/relay_groups/show.ex @@ -0,0 +1,113 @@ +defmodule Web.RelayGroups.Show do + use Web, :live_view + alias Domain.Relays + + def mount(%{"id" => id} = _params, _session, socket) do + with {:ok, group} <- + Relays.fetch_group_by_id(id, socket.assigns.subject, + preload: [ + relays: [token: [created_by_identity: [:actor]]], + created_by_identity: [:actor] + ] + ) do + :ok = Relays.subscribe_for_relays_presence_in_group(group) + {:ok, assign(socket, group: group)} + else + {:error, :not_found} -> raise Web.LiveErrors.NotFoundError + end + end + + def handle_info(%Phoenix.Socket.Broadcast{topic: "relay_groups:" <> _account_id}, socket) do + socket = + redirect(socket, to: ~p"/#{socket.assigns.account}/relay_groups/#{socket.assigns.group}") + + {:noreply, socket} + end + + def handle_event("delete", _params, socket) do + # TODO: make sure tokens are all deleted too! + {:ok, _group} = Relays.delete_group(socket.assigns.group, socket.assigns.subject) + {:noreply, redirect(socket, to: ~p"/#{socket.assigns.account}/relay_groups")} + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/relay_groups"}>Relay Instance Groups + <.breadcrumb path={~p"/#{@account}/relay_groups/#{@group}"}> + <%= @group.name %> + + + <.header> + <:title> + Relay Instance Group: <%= @group.name %> + + <:actions :if={@group.account_id}> + <.edit_button navigate={~p"/#{@account}/relay_groups/#{@group}/edit"}> + Edit Instance Group + + + + +
+ <.vertical_table> + <.vertical_table_row> + <:label>Instance Group Name + <:value><%= @group.name %> + + <.vertical_table_row> + <:label>Created + <:value> + <.datetime datetime={@group.inserted_at} /> by <.owner schema={@group} /> + + + + +
+
+

+ Relay Instances +

+
+
+
+ <.table id="relays" rows={@group.relays}> + <:col :let={relay} label="INSTANCE"> + <.link + navigate={~p"/#{@account}/relays/#{relay.id}"} + class="font-medium text-blue-600 dark:text-blue-500 hover:underline" + > + + <%= relay.ipv4 %> + + + <%= relay.ipv6 %> + + + + <:col :let={relay} label="TOKEN CREATED AT"> + <.datetime datetime={relay.token.inserted_at} /> by <.owner schema={relay.token} /> + + <:col :let={relay} label="STATUS"> + <.connection_status schema={relay} /> + + +
+
+ + <.header> + <:title> + Danger zone + + <:actions :if={@group.account_id}> + <.delete_button + phx-click="delete" + data-confirm="Are you sure want to delete this relay group and disconnect all it's relays?" + > + Delete Relay Instance Group + + + + """ + end +end diff --git a/elixir/apps/web/lib/web/live/relays/show.ex b/elixir/apps/web/lib/web/live/relays/show.ex new file mode 100644 index 000000000..0f05ca638 --- /dev/null +++ b/elixir/apps/web/lib/web/live/relays/show.ex @@ -0,0 +1,137 @@ +defmodule Web.Relays.Show do + use Web, :live_view + alias Domain.Relays + + def mount(%{"id" => id} = _params, _session, socket) do + with {:ok, relay} <- + Relays.fetch_relay_by_id(id, socket.assigns.subject, preload: :group) do + :ok = Relays.subscribe_for_relays_presence_in_group(relay.group) + {:ok, assign(socket, relay: relay)} + else + {:error, :not_found} -> raise Web.LiveErrors.NotFoundError + end + end + + def handle_info( + %Phoenix.Socket.Broadcast{topic: "relay_groups:" <> _account_id, payload: payload}, + socket + ) do + if Map.has_key?(payload.joins, socket.assigns.relay.id) or + Map.has_key?(payload.leaves, socket.assigns.relay.id) do + {:ok, relay} = + Relays.fetch_relay_by_id(socket.assigns.relay.id, socket.assigns.subject, preload: :group) + + {:noreply, assign(socket, relay: relay)} + else + {:noreply, socket} + end + end + + def handle_event("delete", _params, socket) do + {:ok, _relay} = Relays.delete_relay(socket.assigns.relay, socket.assigns.subject) + + socket = + redirect(socket, + to: ~p"/#{socket.assigns.account}/relay_groups/#{socket.assigns.relay.group}" + ) + + {:noreply, socket} + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/relay_groups"}>Relay Instance Groups + <.breadcrumb path={~p"/#{@account}/relay_groups/#{@relay.group}"}> + <%= @relay.group.name %> + + <.breadcrumb path={~p"/#{@account}/relays/#{@relay}"}> + <%= @relay.ipv4 || @relay.ipv6 %> + + + <.header> + <:title> + Relay: + <.intersperse_blocks> + <:separator>,  + + <:item :for={ip <- [@relay.ipv4, @relay.ipv6]} :if={not is_nil(ip)}> + <%= @relay.ipv4 %> + + + + + +
+ <.vertical_table> + <.vertical_table_row> + <:label>Instance Group Name + <:value><%= @relay.group.name %> + + <.vertical_table_row> + <:label>Status + <:value> + <.connection_status schema={@relay} /> + + + <.vertical_table_row> + <:label>Location + <:value> + + <%= @relay.last_seen_remote_ip %> + + + + <.vertical_table_row> + <:label> + Last seen + + <:value> + <.relative_datetime datetime={@relay.last_seen_at} /> + + + <.vertical_table_row> + <:label>Remote IPv4 + <:value> + <%= @relay.ipv4 %> + + + <.vertical_table_row> + <:label>Remote IPv6 + <:value> + <%= @relay.ipv6 %> + + + + <.vertical_table_row> + <:label>Version + <:value> + <%= @relay.last_seen_version %> + + + <.vertical_table_row> + <:label>User Agent + <:value> + <%= @relay.last_seen_user_agent %> + + + <.vertical_table_row> + <:label>Deployment Method + <:value>TODO: Docker + + +
+ + <.header> + <:title> + Danger zone + + <:actions :if={@relay.account_id}> + <.delete_button phx-click="delete"> + Delete Relay + + + + """ + end +end diff --git a/elixir/apps/web/lib/web/live/resources/index.ex b/elixir/apps/web/lib/web/live/resources/index.ex index 6c75725f9..082983849 100644 --- a/elixir/apps/web/lib/web/live/resources/index.ex +++ b/elixir/apps/web/lib/web/live/resources/index.ex @@ -45,7 +45,7 @@ defmodule Web.Resources.Index do <:col :let={resource} label="GATEWAY INSTANCE GROUP"> <.link :for={gateway_group <- resource.gateway_groups} - navigate={~p"/#{@account}/gateways"} + navigate={~p"/#{@account}/gateway_groups"} class="font-medium text-blue-600 dark:text-blue-500 hover:underline" > <.badge type="info"> @@ -53,7 +53,7 @@ defmodule Web.Resources.Index do - <:col :let={_resource} label="GROUPS"> + <:col :let={_resource} label="AUTHORIZED GROUPS"> TODO <.link navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> <.badge>Engineering diff --git a/elixir/apps/web/lib/web/live/resources/show.ex b/elixir/apps/web/lib/web/live/resources/show.ex index e17004a03..14c9dfb84 100644 --- a/elixir/apps/web/lib/web/live/resources/show.ex +++ b/elixir/apps/web/lib/web/live/resources/show.ex @@ -5,15 +5,13 @@ defmodule Web.Resources.Show do def mount(%{"id" => id} = _params, _session, socket) do {:ok, resource} = - Resources.fetch_resource_by_id(id, socket.assigns.subject, preload: :gateway_groups) + Resources.fetch_resource_by_id(id, socket.assigns.subject, + preload: [:gateway_groups, created_by_identity: [:actor]] + ) {:ok, assign(socket, resource: resource)} end - defp pretty_print_date(date) do - "#{date.month}/#{date.day}/#{date.year} #{date.hour}:#{date.minute}:#{date.second}" - end - defp pretty_print_filter(filter) do case filter.protocol do :all -> @@ -69,15 +67,17 @@ defmodule Web.Resources.Show do <.vertical_table_row> <:label> - Traffic restriction + Traffic Filtering Rules <:value> - <%= for filter <- @resource.filters do %> +
+ No traffic filtering rules +
+
<%= pretty_print_filter(filter) %> -
- <% end %> +
<.vertical_table_row> @@ -85,15 +85,7 @@ defmodule Web.Resources.Show do Created <:value> - <%= pretty_print_date(@resource.inserted_at) %> by - (TODO: - <.link - class="text-blue-600 hover:underline" - navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} - > - Andrew Dryga - - ) + <.datetime datetime={@resource.inserted_at} /> by <.owner schema={@resource} /> @@ -110,15 +102,12 @@ defmodule Web.Resources.Show do <.table id="gateway_instance_groups" rows={@resource.gateway_groups}> <:col :let={gateway_group} label="NAME"> <.link - navigate={~p"/#{@account}/gateways"} + navigate={~p"/#{@account}/gateway_groups"} class="font-medium text-blue-600 dark:text-blue-500 hover:underline" > <%= gateway_group.name_prefix %> - <:col :let={_gateway_group} label="Status"> - <.badge type="success">TODO: Online -
diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/components.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/components.ex index be18efdd3..335d2b6ec 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/components.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/components.ex @@ -22,7 +22,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Components do ] } id={"scope-#{name}"} - class="w-full mb-4 whitespace-nowrap" + class="w-full mb-4 whitespace-nowrap rounded-lg" > <%= scope %> @@ -44,7 +44,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Components do ] } id={"redirect_url-#{type}"} - class="w-full mb-4 whitespace-nowrap" + class="w-full mb-4 whitespace-nowrap rounded-lg" > <%= redirect_url %> diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/components.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/components.ex index c31ac8f86..bace99682 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/components.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/components.ex @@ -13,7 +13,7 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Components do <.code_block :for={scope <- [:openid, :email, :profile]} id={"scope-#{scope}"} - class="w-full mb-4 whitespace-nowrap" + class="w-full mb-4 whitespace-nowrap rounded-lg" > <%= scope %> @@ -29,7 +29,7 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Components do ] } id={"redirect_url-#{type}"} - class="w-full mb-4 whitespace-nowrap" + class="w-full mb-4 whitespace-nowrap rounded-lg" > <%= redirect_url %> diff --git a/elixir/apps/web/lib/web/router.ex b/elixir/apps/web/lib/web/router.ex index 225c5f1f5..06423406e 100644 --- a/elixir/apps/web/lib/web/router.ex +++ b/elixir/apps/web/lib/web/router.ex @@ -112,13 +112,28 @@ defmodule Web.Router do live "/:id", Show end - scope "/gateways", Gateways do + scope "/relay_groups", RelayGroups do live "/", Index live "/new", New live "/:id/edit", Edit live "/:id", Show end + scope "/relays", Relays do + live "/:id", Show + end + + scope "/gateway_groups", GatewayGroups do + live "/", Index + live "/new", New + live "/:id/edit", Edit + live "/:id", Show + end + + scope "/gateways", Gateways do + live "/:id", Show + end + scope "/resources", Resources do live "/", Index live "/new", New diff --git a/rust/connlib/libs/client/src/control.rs b/rust/connlib/libs/client/src/control.rs index 85780098f..a8c4e4b9a 100644 --- a/rust/connlib/libs/client/src/control.rs +++ b/rust/connlib/libs/client/src/control.rs @@ -1,6 +1,6 @@ use std::{sync::Arc, time::Duration}; -use crate::messages::{Connect, EgressMessages, InitClient, Messages, Relays}; +use crate::messages::{Connect, ConnectionDetails, EgressMessages, InitClient, Messages}; use boringtun::x25519::StaticSecret; use libs_common::{ control::{ErrorInfo, ErrorReply, MessageResult, PhoenixSenderWithTopic}, @@ -9,18 +9,23 @@ use libs_common::{ }; use async_trait::async_trait; -use firezone_tunnel::{ControlSignal, Tunnel}; +use firezone_tunnel::{ControlSignal, Request, Tunnel}; use tokio::sync::mpsc::Receiver; #[async_trait] impl ControlSignal for ControlSignaler { - async fn signal_connection_to(&self, resource: &ResourceDescription) -> Result<()> { + async fn signal_connection_to( + &self, + resource: &ResourceDescription, + connected_gateway_ids: Vec, + ) -> Result<()> { self.control_signal // It's easier if self is not mut .clone() .send_with_ref( - EgressMessages::ListRelays { + EgressMessages::PrepareConnection { resource_id: resource.id(), + connected_gateway_ids, }, // The resource id functions as the connection id since we can only have one connection // outgoing for each resource. @@ -120,18 +125,23 @@ impl ControlPlane { } #[tracing::instrument(level = "trace", skip(self))] - fn relays( + fn connection_details( &self, - Relays { + ConnectionDetails { + gateway_id, resource_id, relays, - }: Relays, + .. + }: ConnectionDetails, ) { let tunnel = Arc::clone(&self.tunnel); let mut control_signaler = self.control_signaler.clone(); tokio::spawn(async move { - match tunnel.request_connection(resource_id, relays).await { - Ok(connection_request) => { + let err = match tunnel + .request_connection(resource_id, gateway_id, relays) + .await + { + Ok(Request::NewConnection(connection_request)) => { if let Err(err) = control_signaler .control_signal // TODO: create a reference number and keep track for the response @@ -141,15 +151,32 @@ impl ControlPlane { ) .await { - tunnel.cleanup_connection(resource_id); - let _ = tunnel.callbacks().on_error(&err); + err + } else { + return; } } - Err(err) => { - tunnel.cleanup_connection(resource_id); - let _ = tunnel.callbacks().on_error(&err); + Ok(Request::ReuseConnection(connection_request)) => { + if let Err(err) = control_signaler + .control_signal + // TODO: create a reference number and keep track for the response + .send_with_ref( + EgressMessages::ReuseConnection(connection_request), + resource_id, + ) + .await + { + err + } else { + return; + } } - } + Err(err) => err, + }; + + tunnel.cleanup_connection(resource_id); + tracing::error!("Error request connection details: {err}"); + let _ = tunnel.callbacks().on_error(&err); }); } @@ -157,7 +184,9 @@ impl ControlPlane { pub(super) async fn handle_message(&mut self, msg: Messages) -> Result<()> { match msg { Messages::Init(init) => self.init(init).await?, - Messages::Relays(connection_details) => self.relays(connection_details), + Messages::ConnectionDetails(connection_details) => { + self.connection_details(connection_details) + } Messages::Connect(connect) => self.connect(connect).await, Messages::ResourceAdded(resource) => self.add_resource(resource).await?, Messages::ResourceRemoved(resource) => self.remove_resource(resource.id), diff --git a/rust/connlib/libs/client/src/messages.rs b/rust/connlib/libs/client/src/messages.rs index b55176393..1a7e26155 100644 --- a/rust/connlib/libs/client/src/messages.rs +++ b/rust/connlib/libs/client/src/messages.rs @@ -1,7 +1,11 @@ +use std::net::IpAddr; + use firezone_tunnel::RTCSessionDescription; use serde::{Deserialize, Serialize}; -use libs_common::messages::{Id, Interface, Key, Relay, RequestConnection, ResourceDescription}; +use libs_common::messages::{ + Id, Interface, Key, Relay, RequestConnection, ResourceDescription, ReuseConnection, +}; #[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone)] pub struct InitClient { @@ -15,6 +19,14 @@ pub struct RemoveResource { pub id: Id, } +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct ConnectionDetails { + pub relays: Vec, + pub resource_id: Id, + pub gateway_id: Id, + pub gateway_remote_ip: IpAddr, +} + #[derive(Debug, Deserialize, Serialize, Clone)] pub struct Connect { pub gateway_rtc_session_description: RTCSessionDescription, @@ -32,15 +44,6 @@ impl PartialEq for Connect { impl Eq for Connect {} -/// List of relays -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] -pub struct Relays { - /// Resource id corresponding to the relay - pub resource_id: Id, - /// The actual list of relays - pub relays: Vec, -} - // These messages are the messages that can be received // by a client. #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] @@ -61,7 +64,7 @@ pub enum IngressMessages { #[serde(untagged)] #[allow(clippy::large_enum_variant)] pub enum ReplyMessages { - Relays(Relays), + ConnectionDetails(ConnectionDetails), Connect(Connect), } @@ -70,7 +73,7 @@ pub enum ReplyMessages { #[allow(clippy::large_enum_variant)] pub enum Messages { Init(InitClient), - Relays(Relays), + ConnectionDetails(ConnectionDetails), Connect(Connect), // Resources: arrive in an orderly fashion @@ -93,7 +96,7 @@ impl From for Messages { impl From for Messages { fn from(value: ReplyMessages) -> Self { match value { - ReplyMessages::Relays(m) => Self::Relays(m), + ReplyMessages::ConnectionDetails(m) => Self::ConnectionDetails(m), ReplyMessages::Connect(m) => Self::Connect(m), } } @@ -102,11 +105,16 @@ impl From for Messages { // These messages can be sent from a client to a control pane #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case", tag = "event", content = "payload")] -// TODO: We will need to re-visit webrtc-rs -#[allow(clippy::large_enum_variant)] +// large_enum_variant: TODO: We will need to re-visit webrtc-rs +// enum_variant_names: These are the names in the portal! +#[allow(clippy::large_enum_variant, clippy::enum_variant_names)] pub enum EgressMessages { - ListRelays { resource_id: Id }, + PrepareConnection { + resource_id: Id, + connected_gateway_ids: Vec, + }, RequestConnection(RequestConnection), + ReuseConnection(ReuseConnection), } #[cfg(test)] @@ -121,7 +129,7 @@ mod test { use chrono::NaiveDateTime; - use crate::messages::{EgressMessages, Relays, ReplyMessages}; + use crate::messages::{ConnectionDetails, EgressMessages, ReplyMessages}; use super::{IngressMessages, InitClient}; @@ -213,16 +221,18 @@ mod test { fn list_relays_message() { let m = PhoenixMessage::::new( "device", - EgressMessages::ListRelays { + EgressMessages::PrepareConnection { resource_id: "f16ecfa0-a94f-4bfd-a2ef-1cc1f2ef3da3".parse().unwrap(), + connected_gateway_ids: vec![], }, None, ); let message = r#" { - "event": "list_relays", + "event": "prepare_connection", "payload": { - "resource_id": "f16ecfa0-a94f-4bfd-a2ef-1cc1f2ef3da3" + "resource_id": "f16ecfa0-a94f-4bfd-a2ef-1cc1f2ef3da3", + "connected_gateway_ids": [] }, "ref":null, "topic": "device" @@ -233,10 +243,12 @@ mod test { } #[test] - fn list_relays_reply() { + fn connection_details_reply() { let m = PhoenixMessage::::new_reply( "device", - ReplyMessages::Relays(Relays { + ReplyMessages::ConnectionDetails(ConnectionDetails { + gateway_id: "73037362-715d-4a83-a749-f18eadd970e6".parse().unwrap(), + gateway_remote_ip: "172.28.0.1".parse().unwrap(), resource_id: "f16ecfa0-a94f-4bfd-a2ef-1cc1f2ef3da3".parse().unwrap(), relays: vec![ Relay::Stun(Stun { @@ -271,6 +283,9 @@ mod test { "event": "phx_reply", "payload": { "response": { + "resource_id": "f16ecfa0-a94f-4bfd-a2ef-1cc1f2ef3da3", + "gateway_id": "73037362-715d-4a83-a749-f18eadd970e6", + "gateway_remote_ip": "172.28.0.1", "relays": [ { "type":"stun", @@ -293,8 +308,7 @@ mod test { "type": "turn", "uri": "turn:::1:3478", "username": "1686629954:dpHxHfNfOhxPLfMG" - }], - "resource_id": "f16ecfa0-a94f-4bfd-a2ef-1cc1f2ef3da3" + }] }, "status":"ok" } diff --git a/rust/connlib/libs/common/src/messages.rs b/rust/connlib/libs/common/src/messages.rs index 3cee21715..ad6dafd3a 100644 --- a/rust/connlib/libs/common/src/messages.rs +++ b/rust/connlib/libs/common/src/messages.rs @@ -35,6 +35,8 @@ pub struct Peer { /// make use of this message type. #[derive(Debug, Deserialize, Serialize, Clone)] pub struct RequestConnection { + /// Gateway id for the connection + pub gateway_id: Id, /// Resource id the request is for. pub resource_id: Id, /// The preshared key the client generated for the connection that it is trying to establish. @@ -43,6 +45,18 @@ pub struct RequestConnection { pub device_rtc_session_description: RTCSessionDescription, } +/// Represent a request to reuse an existing gateway connection from a client to a given resource. +/// +/// While this is a client-only message it's hosted in common since the tunnel +/// make use of this message type. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct ReuseConnection { + /// Resource id the request is for. + pub resource_id: Id, + /// Id of the gateway we want to re-use + pub gateway_id: Id, +} + // Custom implementation of partial eq to ignore client_rtc_sdp impl PartialEq for RequestConnection { fn eq(&self, other: &Self) -> bool { diff --git a/rust/connlib/libs/gateway/src/control.rs b/rust/connlib/libs/gateway/src/control.rs index 4ca9cbd95..eae70eb00 100644 --- a/rust/connlib/libs/gateway/src/control.rs +++ b/rust/connlib/libs/gateway/src/control.rs @@ -4,11 +4,13 @@ use boringtun::x25519::StaticSecret; use firezone_tunnel::{ControlSignal, Tunnel}; use libs_common::{ control::{MessageResult, PhoenixSenderWithTopic}, - messages::ResourceDescription, + messages::{Id, ResourceDescription}, Callbacks, ControlSession, Result, }; use tokio::sync::mpsc::Receiver; +use crate::messages::AllowAccess; + use super::messages::{ ConnectionReady, EgressMessages, IngressMessages, InitGateway, RequestConnection, }; @@ -27,7 +29,11 @@ struct ControlSignaler { #[async_trait] impl ControlSignal for ControlSignaler { - async fn signal_connection_to(&self, resource: &ResourceDescription) -> Result<()> { + async fn signal_connection_to( + &self, + resource: &ResourceDescription, + _connected_gateway_ids: Vec, + ) -> Result<()> { tracing::warn!("A message to network resource: {resource:?} was discarded, gateways aren't meant to be used as clients."); Ok(()) } @@ -102,8 +108,15 @@ impl ControlPlane { } #[tracing::instrument(level = "trace", skip(self))] - fn add_resource(&self, resource: ResourceDescription) { - todo!() + fn allow_access( + &self, + AllowAccess { + device_id, + resource, + expires_at, + }: AllowAccess, + ) { + self.tunnel.allow_access(resource, device_id, expires_at) } #[tracing::instrument(level = "trace", skip(self))] @@ -113,9 +126,9 @@ impl ControlPlane { IngressMessages::RequestConnection(connection_request) => { self.connection_request(connection_request) } - IngressMessages::AddResource(resource) => self.add_resource(resource), - IngressMessages::RemoveResource(_) => todo!(), - IngressMessages::UpdateResource(_) => todo!(), + IngressMessages::AllowAccess(allow_access) => { + self.allow_access(allow_access); + } } Ok(()) } diff --git a/rust/connlib/libs/gateway/src/messages.rs b/rust/connlib/libs/gateway/src/messages.rs index 975237de4..bc3e7c4eb 100644 --- a/rust/connlib/libs/gateway/src/messages.rs +++ b/rust/connlib/libs/gateway/src/messages.rs @@ -71,6 +71,14 @@ pub struct RemoveResource { pub id: Id, } +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct AllowAccess { + pub device_id: Id, + pub resource: ResourceDescription, + #[serde(with = "ts_seconds")] + pub expires_at: DateTime, +} + // These messages are the messages that can be received // either by a client or a gateway by the client. #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] @@ -80,9 +88,7 @@ pub struct RemoveResource { pub enum IngressMessages { Init(InitGateway), RequestConnection(RequestConnection), - AddResource(ResourceDescription), - RemoveResource(RemoveResource), - UpdateResource(ResourceDescription), + AllowAccess(AllowAccess), } // These messages can be sent from a gateway diff --git a/rust/connlib/libs/tunnel/src/control_protocol.rs b/rust/connlib/libs/tunnel/src/control_protocol.rs index cc4c7dee7..c40e62656 100644 --- a/rust/connlib/libs/tunnel/src/control_protocol.rs +++ b/rust/connlib/libs/tunnel/src/control_protocol.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use tracing::instrument; use libs_common::{ - messages::{Id, Key, Relay, RequestConnection, ResourceDescription}, + messages::{Id, Key, Relay, RequestConnection, ResourceDescription, ReuseConnection}, Callbacks, Error, Result, }; use rand_core::OsRng; @@ -24,6 +24,13 @@ use crate::{peer::Peer, ControlSignal, PeerConfig, Tunnel}; mod candidate_parser; +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(clippy::large_enum_variant)] +pub enum Request { + NewConnection(RequestConnection), + ReuseConnection(ReuseConnection), +} + impl Tunnel where C: ControlSignal + Send + Sync + 'static, @@ -35,9 +42,8 @@ where data_channel: Arc, index: u32, peer_config: PeerConfig, - expires_at: Option>, conn_id: Id, - resources: Option, + resources: Option<(ResourceDescription, DateTime)>, ) -> Result<()> { tracing::trace!( "New datachannel opened for peer with ips: {:?}", @@ -58,13 +64,21 @@ where index, &peer_config, channel, - expires_at, conn_id, resources, )); { + // Watch out! we need 2 locks, make sure you don't lock both at the same time anywhere else + let mut gateway_awaiting_connection = self.gateway_awaiting_connection.lock(); let mut peers_by_ip = self.peers_by_ip.write(); + // In the gateway this will always be none, no harm done + if let Some(awaiting_ips) = gateway_awaiting_connection.remove(&conn_id) { + for ip in awaiting_ips { + peer.add_allowed_ip(ip); + peers_by_ip.insert(ip, Arc::clone(&peer)); + } + } for ip in peer_config.ips { peers_by_ip.insert(ip, Arc::clone(&peer)); } @@ -149,8 +163,47 @@ where pub async fn request_connection( self: &Arc, resource_id: Id, + gateway_id: Id, relays: Vec, - ) -> Result { + ) -> Result { + self.resources_gateways + .lock() + .insert(resource_id, gateway_id); + let resource_description = self + .resources + .read() + .get_by_id(&resource_id) + .ok_or(Error::UnknownResource)? + .clone(); + { + let mut gateway_awaiting_connection = self.gateway_awaiting_connection.lock(); + if let Some(g) = gateway_awaiting_connection.get_mut(&gateway_id) { + g.extend(resource_description.ips()); + return Ok(Request::ReuseConnection(ReuseConnection { + resource_id, + gateway_id, + })); + } else { + gateway_awaiting_connection.insert(gateway_id, vec![]); + } + } + { + let mut peers_by_ip = self.peers_by_ip.write(); + let peer = peers_by_ip + .iter() + .find_map(|(_, p)| (p.conn_id == gateway_id).then_some(p)) + .cloned(); + if let Some(peer) = peer { + for ip in resource_description.ips() { + peer.add_allowed_ip(ip); + peers_by_ip.insert(ip, Arc::clone(&peer)); + } + return Ok(Request::ReuseConnection(ReuseConnection { + resource_id, + gateway_id, + })); + } + } let peer_connection = self.initialize_peer_request(relays).await?; self.set_connection_state_update(&peer_connection); @@ -161,17 +214,11 @@ where let preshared_key = StaticSecret::random_from_rng(OsRng); let p_key = preshared_key.clone(); - let resource_description = tunnel - .resources - .read() - .get_by_id(&resource_id) - .expect("TODO") - .clone(); data_channel.on_open(Box::new(move || { - tracing::trace!("new data channel opened!"); Box::pin(async move { + tracing::trace!("new data channel opened!"); let index = tunnel.next_index(); - let Some(gateway_public_key) = tunnel.gateway_public_keys.lock().remove(&resource_id) else { + let Some(gateway_public_key) = tunnel.gateway_public_keys.lock().remove(&gateway_id) else { tunnel.cleanup_connection(resource_id); tracing::warn!("Opened ICE channel with gateway without ever receiving public key"); let _ = tunnel.callbacks.on_error(&Error::ControlProtocolError); @@ -184,7 +231,7 @@ where preshared_key: p_key, }; - if let Err(e) = tunnel.handle_channel_open(d, index, peer_config, None, resource_id, None).await { + if let Err(e) = tunnel.handle_channel_open(d, index, peer_config, gateway_id, None).await { tracing::error!("Couldn't establish wireguard link after channel was opened: {e}"); let _ = tunnel.callbacks.on_error(&e); tunnel.cleanup_connection(resource_id); @@ -207,13 +254,14 @@ where self.peer_connections .lock() - .insert(resource_id, peer_connection); + .insert(gateway_id, peer_connection); - Ok(RequestConnection { + Ok(Request::NewConnection(RequestConnection { resource_id, + gateway_id, device_preshared_key: Key(preshared_key.to_bytes()), device_rtc_session_description: local_description, - }) + })) } /// Called when a response to [Tunnel::request_connection] is ready. @@ -231,15 +279,21 @@ where mut rtc_sdp: RTCSessionDescription, gateway_public_key: PublicKey, ) -> Result<()> { + let gateway_id = *self + .resources_gateways + .lock() + .get(&resource_id) + .ok_or(Error::UnknownResource)?; let peer_connection = self .peer_connections .lock() - .get(&resource_id) + .get(&gateway_id) .ok_or(Error::UnknownResource)? .clone(); self.gateway_public_keys .lock() - .insert(resource_id, gateway_public_key); + .insert(gateway_id, gateway_public_key); + let mut sdp = rtc_sdp.unmarshal()?; // We don't want to allow tunnel-over-tunnel as it leads to some weirdness @@ -248,6 +302,7 @@ where for m in sdp.media_descriptions.iter_mut() { self.sdp_remove_resource_attributes(&mut m.attributes); } + rtc_sdp.sdp = sdp.marshal(); peer_connection.set_remote_description(rtc_sdp).await?; @@ -315,9 +370,8 @@ where data_channel, index, peer, - Some(expires_at), client_id, - Some(resource), + Some((resource, expires_at)), ) .await { @@ -355,6 +409,22 @@ where Ok(local_desc) } + pub fn allow_access( + &self, + resource: ResourceDescription, + client_id: Id, + expires_at: DateTime, + ) { + if let Some(peer) = self + .peers_by_ip + .write() + .iter_mut() + .find_map(|(_, p)| (p.conn_id == client_id).then_some(p)) + { + peer.add_resource(resource, expires_at); + } + } + /// Clean up a connection to a resource. pub fn cleanup_connection(&self, id: Id) { self.awaiting_connection.lock().remove(&id); diff --git a/rust/connlib/libs/tunnel/src/ip_packet.rs b/rust/connlib/libs/tunnel/src/ip_packet.rs index 7bd1b7a49..a1484d63f 100644 --- a/rust/connlib/libs/tunnel/src/ip_packet.rs +++ b/rust/connlib/libs/tunnel/src/ip_packet.rs @@ -165,6 +165,13 @@ impl<'a> IpPacket<'a> { } } + pub(crate) fn source(&self) -> IpAddr { + match self { + Self::Ipv4Packet(p) => p.get_source().into(), + Self::Ipv6Packet(p) => p.get_source().into(), + } + } + pub(crate) fn udp_checksum(&self, dgm: &UdpPacket<'_>) -> u16 { match self { Self::Ipv4Packet(p) => ipv4_checksum(dgm, &p.get_source(), &p.get_destination()), diff --git a/rust/connlib/libs/tunnel/src/lib.rs b/rust/connlib/libs/tunnel/src/lib.rs index 55e496a4e..f41728269 100644 --- a/rust/connlib/libs/tunnel/src/lib.rs +++ b/rust/connlib/libs/tunnel/src/lib.rs @@ -44,6 +44,7 @@ use libs_common::{ use device_channel::{create_iface, DeviceChannel}; use tun::IfaceConfig; +pub use control_protocol::Request; pub use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; use index::{check_packet_index, IndexLfsr}; @@ -125,9 +126,14 @@ pub trait ControlSignal { /// Signals to the control plane an intent to initiate a connection to the given resource. /// /// Used when a packet is found to a resource we have no connection stablished but is within the list of resources available for the client. - async fn signal_connection_to(&self, resource: &ResourceDescription) -> Result<()>; + async fn signal_connection_to( + &self, + resource: &ResourceDescription, + connected_gateway_ids: Vec, + ) -> Result<()>; } +// TODO: We should use newtypes for each kind of Id /// Tunnel is a wireguard state machine that uses webrtc's ICE channels instead of UDP sockets /// to communicate between peers. pub struct Tunnel { @@ -142,8 +148,10 @@ pub struct Tunnel { peers_by_ip: RwLock>>, peer_connections: Mutex>>, awaiting_connection: Mutex>, + gateway_awaiting_connection: Mutex>>, + resources_gateways: Mutex>, webrtc_api: API, - resources: RwLock, + resources: RwLock>, control_signaler: C, gateway_public_keys: Mutex>, callbacks: CallbackErrorFacade, @@ -176,6 +184,8 @@ where let resources = Default::default(); let awaiting_connection = Default::default(); let gateway_public_keys = Default::default(); + let resources_gateways = Default::default(); + let gateway_awaiting_connection = Default::default(); // ICE let mut media_engine = MediaEngine::default(); @@ -207,7 +217,9 @@ where device_channel, resources, awaiting_connection, + gateway_awaiting_connection, control_signaler, + resources_gateways, callbacks: CallbackErrorFacade(callbacks), }) } @@ -261,18 +273,32 @@ where Ok(()) } + async fn stop_peer(&self, index: u32, conn_id: Id) { + self.peers_by_ip.write().retain(|_, p| p.index != index); + let conn = self.peer_connections.lock().remove(&conn_id); + if let Some(conn) = conn { + if let Err(e) = conn.close().await { + tracing::error!("Problem while trying to close channel: {e:?}"); + let _ = self.callbacks().on_error(&e.into()); + } + } + } + async fn peer_refresh(&self, peer: &Peer, dst_buf: &mut [u8; MAX_UDP_SIZE]) { let update_timers_result = peer.update_timers(&mut dst_buf[..]); match update_timers_result { TunnResult::Done => {} - TunnResult::Err(WireGuardError::ConnectionExpired) => { - tracing::error!("Connection expired"); + TunnResult::Err(WireGuardError::ConnectionExpired) + | TunnResult::Err(WireGuardError::NoCurrentSession) => { + self.stop_peer(peer.index, peer.conn_id).await; + let _ = peer.shutdown().await; } TunnResult::Err(e) => tracing::error!(message = "Timer error", error = ?e), TunnResult::WriteToNetwork(packet) => { peer.send_infallible(packet, &self.callbacks).await } + _ => panic!("Unexpected result from update_timers"), }; } @@ -293,7 +319,8 @@ where let mut peers_by_ip = self.peers_by_ip.write(); for (_, peer) in peers_by_ip.iter() { - if !peer.is_valid() { + peer.expire_resources(); + if peer.is_emptied() { tracing::trace!("Peer connection with index {} expired", peer.index); let conn = self.peer_connections.lock().remove(&peer.conn_id); let p = peer.clone(); @@ -309,7 +336,7 @@ where } } - peers_by_ip.retain(|_, p| p.is_valid()); + peers_by_ip.retain(|_, p| !p.is_emptied()); } fn start_peers_refresh_timer(self: &Arc) { @@ -413,10 +440,7 @@ where // We found a peer, use it to decapsulate the message+ let mut flush = false; match decapsulate_result { - TunnResult::Done => { - let conn_id = peer.conn_id; - tracing::trace!("Wireguard connection done with peer: {conn_id}"); - } + TunnResult::Done => {} TunnResult::Err(err) => { tracing::error!("Error decapsulating packet: {err:?}"); let _ = tunnel.callbacks().on_error(&err.into()); @@ -460,7 +484,6 @@ where } fn get_resource(&self, buff: &[u8]) -> Option { - // TODO: Check if DNS packet, in that case parse and get dns let addr = Tunn::dst_address(buff)?; let resources = self.resources.read(); match addr { @@ -513,18 +536,15 @@ where }; let (encapsulate_result, channel, peer_index, conn_id) = { - let peers_by_ip = dev.peers_by_ip.read(); - match peers_by_ip.longest_match(dst_addr).map(|p| p.1) { + match dev.peers_by_ip.read().longest_match(dst_addr).map(|p| p.1) { Some(peer) => { - // TODO: check that the translation maps to the ip - // (we actually have multiple ips so we need a map here) - if peer.translated_resource_address.read().is_some() { - let Some(mut packet) = MutableIpPacket::new(&mut src[..res]) else { + let Some(mut packet) = MutableIpPacket::new(&mut src[..res]) else { tracing::error!("Developer error: we should never see a packet through the tunnel wire that isn't ip"); continue; }; - - let resource = peer.resource.as_ref().expect("Developer error: only peers with resource should have a resource_address"); + if let Some(resource) = + peer.get_translation(packet.to_immutable().source()) + { let ResourceDescription::Dns(resource) = resource else { tracing::error!("Developer error: only dns resources should have a resource_address"); continue; @@ -563,10 +583,22 @@ where awaiting_connection.insert(id); let dev = Arc::clone(&dev); + let mut connected_gateway_ids: Vec<_> = dev + .gateway_awaiting_connection + .lock() + .clone() + .into_keys() + .collect(); + connected_gateway_ids.extend( + dev.resources_gateways.lock().values().collect::>(), + ); + tracing::trace!( + "Currently connected gateways: {connected_gateway_ids:?}" + ); tokio::spawn(async move { if let Err(e) = dev .control_signaler - .signal_connection_to(&resource) + .signal_connection_to(&resource, connected_gateway_ids) .await { // Not a deadlock because this is a different task @@ -583,12 +615,12 @@ where }; match encapsulate_result { - TunnResult::Done => { - tracing::trace!( - "tunnel for resource corresponding to {dst_addr} was finalized" - ); - dev.peers_by_ip.write().retain(|_, p| p.index != peer_index); + TunnResult::Done => {} + TunnResult::Err(WireGuardError::ConnectionExpired) + | TunnResult::Err(WireGuardError::NoCurrentSession) => { + dev.stop_peer(peer_index, conn_id).await } + TunnResult::Err(e) => { tracing::error!(message = "Encapsulate error for resource corresponding to {dst_addr}", error = ?e); let _ = dev.callbacks.on_error(&e.into()); @@ -604,19 +636,10 @@ where webrtc::sctp::Error::ErrStreamClosed ) ) { - dev.peers_by_ip.write().retain(|_, p| p.index != peer_index); - let _ = channel.close().await; - let conn = dev.peer_connections.lock().remove(&conn_id); - if let Some(conn) = conn { - if let Err(e) = conn.close().await { - tracing::error!( - "Problem while trying to close channel: {e:?}" - ); - let _ = dev.callbacks().on_error(&e.into()); - } - } + dev.stop_peer(peer_index, conn_id).await; } let _ = dev.callbacks.on_error(&e.into()); + return; } } _ => panic!("Unexpected result from encapsulate"), diff --git a/rust/connlib/libs/tunnel/src/peer.rs b/rust/connlib/libs/tunnel/src/peer.rs index bcec0b868..ffe107c30 100644 --- a/rust/connlib/libs/tunnel/src/peer.rs +++ b/rust/connlib/libs/tunnel/src/peer.rs @@ -1,4 +1,4 @@ -use std::{net::IpAddr, sync::Arc}; +use std::{collections::HashMap, net::IpAddr, sync::Arc}; use boringtun::noise::{Tunn, TunnResult}; use bytes::Bytes; @@ -12,18 +12,19 @@ use libs_common::{ use parking_lot::{Mutex, RwLock}; use webrtc::data::data_channel::DataChannel; +use crate::resource_table::ResourceTable; + use super::PeerConfig; +type ExpiryingResource = (ResourceDescription, DateTime); + pub(crate) struct Peer { pub tunnel: Mutex, pub index: u32, - pub allowed_ips: IpNetworkTable<()>, + pub allowed_ips: RwLock>, pub channel: Arc, - pub expires_at: Option>, pub conn_id: Id, - // For now each peer manages a single resource(none in case of a client). - // In the future (after firezone/firezone#1825) we will use a `ResourceTable`. - pub resource: Option, + pub resources: Option>>, // Here we store the address that we obtained for the resource that the peer corresponds to. // This can have the following problem: // 1. Peer sends packet to address.com and it resolves to 1.1.1.1 @@ -33,7 +34,7 @@ pub(crate) struct Peer { // so, TODO: store multiple ips and expire them. // Note that this case is quite an unlikely edge case so I wouldn't prioritize this fix // TODO: Also check if there's any case where we want to talk to ipv4 and ipv6 from the same peer. - pub translated_resource_address: RwLock>, + pub translated_resource_addresses: RwLock>, } impl Peer { @@ -49,17 +50,15 @@ impl Peer { index: u32, config: &PeerConfig, channel: Arc, - expires_at: Option>, - conn_id: Id, - resource: Option, + gateway_id: Id, + resource: Option<(ResourceDescription, DateTime)>, ) -> Self { Self::new( Mutex::new(tunnel), index, config.ips.clone(), channel, - expires_at, - conn_id, + gateway_id, resource, ) } @@ -69,26 +68,41 @@ impl Peer { index: u32, ips: Vec, channel: Arc, - expires_at: Option>, - conn_id: Id, - resource: Option, + gateway_id: Id, + resource: Option<(ResourceDescription, DateTime)>, ) -> Peer { let mut allowed_ips = IpNetworkTable::new(); for ip in ips { allowed_ips.insert(ip, ()); } + let allowed_ips = RwLock::new(allowed_ips); + let resources = resource.map(|r| { + let mut resource_table = ResourceTable::new(); + resource_table.insert(r); + RwLock::new(resource_table) + }); Peer { tunnel, index, allowed_ips, channel, - expires_at, - conn_id, - resource, - translated_resource_address: Default::default(), + conn_id: gateway_id, + resources, + translated_resource_addresses: Default::default(), } } + pub(crate) fn get_translation(&self, ip: IpAddr) -> Option { + let id = self.translated_resource_addresses.read().get(&ip).cloned(); + self.resources.as_ref().and_then(|resources| { + id.and_then(|id| resources.read().get_by_id(&id).map(|r| r.0.clone())) + }) + } + + pub(crate) fn add_allowed_ip(&self, ip: IpNetwork) { + self.allowed_ips.write().insert(ip, ()); + } + pub(crate) fn update_timers<'a>(&self, dst: &'a mut [u8]) -> TunnResult<'a> { self.tunnel.lock().update_timers(dst) } @@ -98,23 +112,42 @@ impl Peer { Ok(()) } - pub(crate) fn is_valid(&self) -> bool { - !self - .expires_at - .is_some_and(|expires_at| expires_at <= Utc::now()) + pub(crate) fn is_emptied(&self) -> bool { + self.resources.as_ref().is_some_and(|r| r.read().is_empty()) + } + + pub(crate) fn expire_resources(&self) { + if let Some(resources) = &self.resources { + // TODO: We could move this to resource_table and make it way faster + let expire_resources: Vec<_> = resources + .read() + .values() + .filter(|(_, e)| e <= &Utc::now()) + .cloned() + .collect(); + { + // Oh oh! 2 Mutexes + let mut resources = resources.write(); + let mut translated_resource_addresses = self.translated_resource_addresses.write(); + for r in expire_resources { + resources.cleanup_resource(&r); + translated_resource_addresses.retain(|_, &mut i| r.0.id() != i); + } + } + } + } + + pub(crate) fn add_resource(&self, resource: ResourceDescription, expires_at: DateTime) { + if let Some(resources) = &self.resources { + resources.write().insert((resource, expires_at)) + } } pub(crate) fn is_allowed(&self, addr: IpAddr) -> bool { - self.allowed_ips.longest_match(addr).is_some() + self.allowed_ips.read().longest_match(addr).is_some() } - pub(crate) fn update_translated_resource_address(&self, addr: IpAddr) { - if !self - .translated_resource_address - .read() - .is_some_and(|stored| stored == addr) - { - *self.translated_resource_address.write() = Some(addr); - } + pub(crate) fn update_translated_resource_address(&self, id: Id, addr: IpAddr) { + self.translated_resource_addresses.write().insert(addr, id); } } diff --git a/rust/connlib/libs/tunnel/src/resource_sender.rs b/rust/connlib/libs/tunnel/src/resource_sender.rs index 959887159..0d30a69c5 100644 --- a/rust/connlib/libs/tunnel/src/resource_sender.rs +++ b/rust/connlib/libs/tunnel/src/resource_sender.rs @@ -36,7 +36,7 @@ where pub(crate) async fn send_to_resource(&self, peer: &Arc, addr: IpAddr, packet: &mut [u8]) { if peer.is_allowed(addr) { - let Some(resource) = &peer.resource else { + let Some(resources) = &peer.resources else { // If there's no associated resource it means that we are in a client, then the packet comes from a gateway // and we just trust gateways. // In gateways this should never happen. @@ -53,38 +53,38 @@ where return; }; + let Some(resource) = resources.read().get_by_ip(dst).map(|r| r.0.clone()) else { + tracing::warn!( + "client tried to hijack the tunnel for resource itsn't allowed." + ); + return; + }; + let (dst_addr, _dst_port) = match resource { // Note: for now no translation is needed for the ip since we do a peer/connection per resource ResourceDescription::Dns(r) => { - if r.ipv4 == dst || r.ipv6 == dst { - let mut address = r.address.split(':'); - let Some(dst_addr) = address.next() else { + let mut address = r.address.split(':'); + let Some(dst_addr) = address.next() else { tracing::error!("invalid DNS name for resource: {}", r.address); let _ = self.callbacks().on_error(&Error::InvalidResource(r.address.clone())); return; }; - let Ok(mut dst_addr) = format!("{dst_addr}:0").to_socket_addrs() else { + let Ok(mut dst_addr) = format!("{dst_addr}:0").to_socket_addrs() else { tracing::warn!("Couldn't resolve name addr: {addr}"); return; }; - let Some(dst_addr) = dst_addr.find_map(|d| Self::get_matching_version_ip(addr, d.ip())) else { + let Some(dst_addr) = dst_addr.find_map(|d| Self::get_matching_version_ip(addr, d.ip())) else { tracing::warn!("Couldn't resolve name addr: {addr}"); return; }; - peer.update_translated_resource_address(dst_addr); - ( - dst_addr, - address - .next() - .map(str::parse::) - .and_then(std::result::Result::ok), - ) - } else { - tracing::warn!( - "client tried to hijack the tunnel for resource itsn't allowed." - ); - return; - } + peer.update_translated_resource_address(r.id, dst_addr); + ( + dst_addr, + address + .next() + .map(str::parse::) + .and_then(std::result::Result::ok), + ) } ResourceDescription::Cidr(r) => { if r.address.contains(dst) { diff --git a/rust/connlib/libs/tunnel/src/resource_table.rs b/rust/connlib/libs/tunnel/src/resource_table.rs index 21c62533b..969448dc9 100644 --- a/rust/connlib/libs/tunnel/src/resource_table.rs +++ b/rust/connlib/libs/tunnel/src/resource_table.rs @@ -1,47 +1,74 @@ //! A resource table is a custom type that allows us to store a resource under an id and possibly multiple ips or even network ranges use std::{collections::HashMap, net::IpAddr, ptr::NonNull}; +use chrono::{DateTime, Utc}; use ip_network_table::IpNetworkTable; use libs_common::messages::{Id, ResourceDescription}; +pub(crate) trait Resource { + fn description(&self) -> &ResourceDescription; +} + +impl Resource for ResourceDescription { + fn description(&self) -> &ResourceDescription { + self + } +} + +impl Resource for (ResourceDescription, DateTime) { + fn description(&self) -> &ResourceDescription { + &self.0 + } +} + // Oh boy... here we go /// The resource table type /// /// This is specifically crafted for our use case, so the API is particularly made for us and not generic -pub(crate) struct ResourceTable { - id_table: HashMap, - network_table: IpNetworkTable>, - dns_name: HashMap>, +pub(crate) struct ResourceTable { + id_table: HashMap, + network_table: IpNetworkTable>, + dns_name: HashMap>, } // SAFETY: We actually hold a hashmap internally that the pointers points to -unsafe impl Send for ResourceTable {} +unsafe impl Send for ResourceTable {} // SAFETY: we don't allow interior mutability of the pointers we hold, in fact we don't allow ANY mutability! // (this is part of the reason why the API is so limiting, it is easier to reason about. -unsafe impl Sync for ResourceTable {} +unsafe impl Sync for ResourceTable {} -impl Default for ResourceTable { - fn default() -> ResourceTable { +impl Default for ResourceTable { + fn default() -> ResourceTable { ResourceTable::new() } } -impl ResourceTable { +impl ResourceTable { /// Creates a new `ResourceTable` - pub fn new() -> ResourceTable { + pub fn new() -> ResourceTable { ResourceTable { network_table: IpNetworkTable::new(), id_table: HashMap::new(), dns_name: HashMap::new(), } } +} - pub fn values(&self) -> impl Iterator { +impl ResourceTable +where + T: Resource + Clone, +{ + pub fn values(&self) -> impl Iterator { self.id_table.values() } + /// Tells you if it's empty + pub fn is_empty(&self) -> bool { + self.id_table.is_empty() + } + /// Gets the resource by ip - pub fn get_by_ip(&self, ip: impl Into) -> Option<&ResourceDescription> { + pub fn get_by_ip(&self, ip: impl Into) -> Option<&T> { // SAFETY: if we found the pointer, due to our internal consistency rules it is in the id_table self.network_table .longest_match(ip) @@ -49,12 +76,12 @@ impl ResourceTable { } /// Gets the resource by id - pub fn get_by_id(&self, id: &Id) -> Option<&ResourceDescription> { + pub fn get_by_id(&self, id: &Id) -> Option<&T> { self.id_table.get(id) } /// Gets the resource by name - pub fn get_by_name(&self, name: impl AsRef) -> Option<&ResourceDescription> { + pub fn get_by_name(&self, name: impl AsRef) -> Option<&T> { // SAFETY: if we found the pointer, due to our internal consistency rules it is in the id_table self.dns_name .get(name.as_ref()) @@ -62,10 +89,10 @@ impl ResourceTable { } // SAFETY: resource_description must still be in storage since we are going to reference it. - unsafe fn remove_resource(&mut self, resource_description: NonNull) { + unsafe fn remove_resource(&mut self, resource_description: NonNull) { let id = { let res = resource_description.as_ref(); - match res { + match res.description() { ResourceDescription::Dns(r) => { self.dns_name.remove(&r.address); self.network_table.remove(r.ipv4); @@ -81,8 +108,8 @@ impl ResourceTable { self.id_table.remove(&id); } - fn cleanup_resource(&mut self, resource_description: &ResourceDescription) { - match resource_description { + pub(crate) fn cleanup_resource(&mut self, resource_description: &T) { + match resource_description.description() { ResourceDescription::Dns(r) => { if let Some(res) = self.id_table.get(&r.id) { // SAFETY: We are consistent that if the item exists on any of the containers it still exists in the storage @@ -143,13 +170,13 @@ impl ResourceTable { /// This means that a match in IP or dns name will discard all old values. /// /// This is done so that we don't have dangling values. - pub fn insert(&mut self, resource_description: ResourceDescription) { + pub fn insert(&mut self, resource_description: T) { self.cleanup_resource(&resource_description); - let id = resource_description.id(); + let id = resource_description.description().id(); self.id_table.insert(id, resource_description); // we just inserted it we can unwrap let res = self.id_table.get(&id).unwrap(); - match res { + match res.description() { ResourceDescription::Dns(r) => { self.network_table.insert(r.ipv4, res.into()); self.network_table.insert(r.ipv6, res.into()); @@ -162,6 +189,10 @@ impl ResourceTable { } pub fn resource_list(&self) -> Vec { - self.id_table.values().cloned().collect() + self.id_table + .values() + .map(|r| r.description()) + .cloned() + .collect() } }