Update protocol to reuse gateway connections (#1825)

This is a result of our discussion with @conectado, this PR will add a
new message type which will allow reusing existing connections to the
gateway to access a new resource. We will also change the LB strategy to
be aware of the current device connection so that we will not pick a
different one if we have a connected gateway that can serve a new
resource.

---------

Co-authored-by: conectado <gabrielalejandro7@gmail.com>
This commit is contained in:
Andrew Dryga
2023-08-10 12:41:06 -05:00
committed by GitHub
parent 9b538e92d4
commit 3a5877eaa3
64 changed files with 2540 additions and 704 deletions

View File

@@ -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"}
```
</details>
@@ -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"}

View File

@@ -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

View File

@@ -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,

View File

@@ -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"
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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, _} =

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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],

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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!()

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -57,25 +57,23 @@ defmodule Web.CoreComponents do
def code_block(assigns) do
~H"""
<code id={@id} phx-hook="Copy" class={[~w[
rounded-lg
<div id={@id} phx-hook="Copy" class={[~w[
text-sm text-left sm:text-base text-white
inline-flex items-center
space-x-4 p-4 pl-6
bg-gray-800
relative
], @class]}>
<span class="overflow-x-auto" data-copy>
<code class="block whitespace-nowrap overflow-x-scroll rounded-b-lg" data-copy>
<%= render_slot(@inner_block) %>
</span>
</code>
<.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
]} />
</code>
</div>
"""
end
@@ -103,44 +101,46 @@ defmodule Web.CoreComponents do
def tabs(assigns) do
~H"""
<div class="mb-4 border-b border-gray-200 dark:border-gray-700">
<ul
class="flex flex-wrap -mb-px text-sm font-medium text-center"
id={"#{@id}-ul"}
data-tabs-toggle={"##{@id}"}
role="tablist"
>
<%= for tab <- @tab do %>
<li class="mr-2" role="presentation">
<button
class={~w[
<div class="mb-4">
<div class="border-gray-200 dark:border-gray-700 bg-slate-50 rounded-t-lg">
<ul
class="flex flex-wrap text-sm font-medium text-center"
id={"#{@id}-ul"}
data-tabs-toggle={"##{@id}"}
role="tablist"
>
<%= for tab <- @tab do %>
<li class="mr-2" role="presentation">
<button
class={~w[
inline-block p-4 border-b-2 border-transparent rounded-t-lg
hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300
]}
id={"#{tab.id}-tab"}
data-tabs-target={"##{tab.id}"}
type="button"
role="tab"
aria-controls={tab.id}
aria-selected="false"
>
<%= tab.label %>
</button>
</li>
id={"#{tab.id}-tab"}
data-tabs-target={"##{tab.id}"}
type="button"
role="tab"
aria-controls={tab.id}
aria-selected="false"
>
<%= tab.label %>
</button>
</li>
<% end %>
</ul>
</div>
<div id={@id}>
<%= for tab <- @tab do %>
<div
class="hidden rounded-b-lg bg-gray-50 dark:bg-gray-800"
id={tab.id}
role="tabpanel"
aria-labelledby={"#{tab.id}-tab"}
>
<%= render_slot(tab) %>
</div>
<% end %>
</ul>
</div>
<div id={@id}>
<%= for tab <- @tab do %>
<div
class="hidden p-4 rounded-lg bg-gray-50 dark:bg-gray-800"
id={tab.id}
role="tabpanel"
aria-labelledby={"#{tab.id}-tab"}
>
<%= render_slot(tab) %>
</div>
<% end %>
</div>
</div>
"""
end
@@ -483,7 +483,7 @@ defmodule Web.CoreComponents do
## Examples
```heex
<.intersperse :let={item}>
<.intersperse_blocks>
<:separator>
<span class="sep">|</span>
</:separator>
@@ -499,7 +499,7 @@ defmodule Web.CoreComponents do
<:item>
settings
</:item>
</.intersperse>
</.intersperse_blocks>
```
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"""
<span class={"text-xs font-medium mr-2 px-2.5 py-0.5 rounded #{@colors[@type]}"}>
<span class={"text-xs font-medium mr-2 px-2.5 py-0.5 rounded #{@colors[@type]}"} {@rest}>
<%= render_slot(@inner_block) %>
</span>
"""
@@ -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" %>
</.badge>
"""
end
@doc """
Renders username
"""

View File

@@ -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"""
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<div :for={{value, index} <- Enum.with_index(@values)} class="flex mt-2">
<input
type="text"
name={"#{@name}[]"}
id={@id}
value={value}
class={[
"bg-gray-50 p-2.5 block w-full rounded-lg border text-gray-900 focus:ring-primary-600 text-sm",
"phx-no-feedback:border-gray-300 phx-no-feedback:focus:border-primary-600",
"disabled:bg-slate-50 disabled:text-slate-500 disabled:border-slate-200 disabled:shadow-none",
"border-gray-300 focus:border-primary-600",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
/>
<.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>
</div>
<.button type="button" phx-click={"add:#{@name}"} class="mt-2">
<.icon name="hero-plus" /> Add
</.button>
<.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %></.error>
</div>
"""
end
def input(assigns) do
~H"""
<div phx-feedback-for={@name}>

View File

@@ -111,10 +111,17 @@
Devices
</.sidebar_item>
<.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>
<.sidebar_item navigate={~p"/#{@account}/relay_groups"} icon="hero-arrows-right-left">
Relays
</.sidebar_item>
<.sidebar_item navigate={~p"/#{@account}/resources"} icon="hero-server-stack-solid">
Resources
</.sidebar_item>

View File

@@ -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"""
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th :for={col <- @columns} class="px-4 py-3">
<%= col[:label] %>
<.icon
:if={col[:sortable] == "true"}
name="hero-chevron-up-down-solid"
class="w-4 h-4 ml-1"
/>
</th>
<th :if={not Enum.empty?(@actions)} class="px-4 py-3">
<span class="sr-only"><%= gettext("Actions") %></span>
</th>
</tr>
</thead>
"""
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"""
<tr id={@id} class="border-b dark:border-gray-700">
<td
:for={{col, _i} <- Enum.with_index(@columns)}
phx-click={@click && @click.(@row)}
class={[
"px-4 py-3",
@click && "hover:cursor-pointer"
]}
>
<%= render_slot(col, @mapper.(@row)) %>
</td>
<td :if={@actions != []} class="px-4 py-3 flex items-center justify-end">
<button id={"#{@id}-dropdown-button"} data-dropdown-toggle={"#{@id}-dropdown"} class={~w[
inline-flex items-center p-0.5 text-sm font-medium text-center
text-gray-500 hover:text-gray-800 rounded-lg focus:outline-none
dark:text-gray-400 dark:hover:text-gray-100
]} type="button">
<.icon name="hero-ellipsis-horizontal" class="w-5 h-5" />
</button>
<div id={"#{@id}-dropdown" } class={~w[
hidden z-10 w-44 bg-white rounded divide-y divide-gray-100
shadow border border-gray-300 dark:bg-gray-700 dark:divide-gray-600"
]}>
<ul
class="py-1 text-sm text-gray-700 dark:text-gray-200"
aria-labelledby={"#{@id}-dropdown-button"}
>
<li :for={action <- @actions}>
<%= render_slot(action, @mapper.(@row)) %>
</li>
</ul>
</div>
</td>
</tr>
"""
end
@doc ~S"""
Renders a table with generic styling.
@@ -42,61 +115,17 @@ defmodule Web.TableComponents do
~H"""
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th :for={col <- @col} class="px-4 py-3">
<%= col[:label] %>
<.icon
:if={col[:sortable] == "true"}
name="hero-chevron-up-down-solid"
class="w-4 h-4 ml-1"
/>
</th>
<th class="px-4 py-3">
<span class="sr-only"><%= gettext("Actions") %></span>
</th>
</tr>
</thead>
<.table_header columns={@col} actions={@action} />
<tbody id={@id} phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="border-b dark:border-gray-700">
<td
:for={{col, _i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}
class={[
"px-4 py-3",
@row_click && "hover:cursor-pointer"
]}
>
<%= render_slot(col, @row_item.(row)) %>
</td>
<td :if={@action != []} class="px-4 py-3 flex items-center justify-end">
<button
id={"#{@row_id.(row)}-dropdown-button"}
data-dropdown-toggle={"#{@row_id.(row)}-dropdown"}
class={~w[
inline-flex items-center p-0.5 text-sm font-medium text-center
text-gray-500 hover:text-gray-800 rounded-lg focus:outline-none
dark:text-gray-400 dark:hover:text-gray-100
]}
type="button"
>
<.icon name="hero-ellipsis-horizontal" class="w-5 h-5" />
</button>
<div id={"#{@row_id.(row)}-dropdown" } class={~w[
hidden z-10 w-44 bg-white rounded divide-y divide-gray-100
shadow border border-gray-300 dark:bg-gray-700 dark:divide-gray-600"
]}>
<ul
class="py-1 text-sm text-gray-700 dark:text-gray-200"
aria-labelledby={"#{@row_id.(row)}-dropdown-button"}
>
<li :for={action <- @action}>
<%= render_slot(action, @row_item.(row)) %>
</li>
</ul>
</div>
</td>
</tr>
<.table_row
:for={row <- @rows}
columns={@col}
actions={@action}
row={row}
id={@row_id && @row_id.(row)}
click={@row_click}
mapper={@row_item}
/>
</tbody>
</table>
</div>
@@ -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 class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th :for={col <- @col} class="px-4 py-3">
<%= col[:label] %>
<.icon
:if={col[:sortable] == "true"}
name="hero-chevron-up-down-solid"
class="w-4 h-4 ml-1"
/>
</th>
<th class="px-4 py-3">
<span class="sr-only"><%= gettext("Actions") %></span>
</th>
<.table_header columns={@col} actions={@action} />
<tbody :for={group <- @groups} data-group-id={@group_id && @group_id.(group)}>
<tr class="bg-gray-100">
<td class="px-4 py-2" colspan={length(@col) + 1}>
<%= render_slot(@group, group) %>
</td>
</tr>
</thead>
<tbody>
<%= for {group, items} <- @rows do %>
<tr class="bg-neutral-300">
<td class="px-4 py-2">
<%= group.name_prefix %>
</td>
<td colspan={length(@col)}></td>
</tr>
<tr :for={item <- items} class="border-b dark:border-gray-700">
<td :for={col <- @col} class="px-4 py-3">
<%= render_slot(col, item) %>
</td>
<td :if={@action != []} class="px-4 py-3 flex items-center justify-end">
<button
id={"#{@row_id.(item)}-dropdown-button"}
data-dropdown-toggle={"#{@row_id.(item)}-dropdown"}
class={~w[
inline-flex items-center p-0.5 text-sm font-medium text-center
text-gray-500 hover:text-gray-800 rounded-lg focus:outline-none
dark:text-gray-400 dark:hover:text-gray-100
]}
type="button"
>
<.icon name="hero-ellipsis-horizontal" class="w-5 h-5" />
</button>
<div id={"#{@row_id.(item)}-dropdown" } class={~w[
hidden z-10 w-44 bg-white rounded divide-y divide-gray-100
shadow border border-gray-300 dark:bg-gray-700 dark:divide-gray-600"
]}>
<ul
class="py-1 text-sm text-gray-700 dark:text-gray-200"
aria-labelledby={"#{@row_id.(item)}-dropdown-button"}
>
<li :for={action <- @action}>
<%= render_slot(action, @row_item.(item)) %>
</li>
</ul>
</div>
</td>
</tr>
<% 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}
/>
</tbody>
</table>
"""

View File

@@ -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>
<.breadcrumb path={~p"/#{@account}/gateway_groups/#{@group}"}>
<%= @group.name_prefix %>
</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/gateway_groups/#{@group}/edit"}>Edit</.breadcrumb>
</.breadcrumbs>
<.header>
<:title>
Editing Gateway Instance Group <code><%= @group.name_prefix %></code>
</:title>
</.header>
<section class="bg-white dark:bg-gray-900">
<div class="py-8 px-4 mx-auto max-w-2xl lg:py-16">
<.form for={@form} phx-change={:change} phx-submit={:submit}>
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
<div>
<.input
label="Name Prefix"
field={@form[:name_prefix]}
placeholder="Name of this Gateway Instance Group"
required
/>
</div>
<div>
<.input label="Tags" type="taglist" field={@form[:tags]} placeholder="Tag" />
</div>
</div>
<.submit_button>
Save
</.submit_button>
</.form>
</div>
</section>
"""
end
end

View File

@@ -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>
<.breadcrumb path={~p"/#{@account}/gateway_groups"}>Gateway Instance Groups</.breadcrumb>
</.breadcrumbs>
<.header>
<:title>
All gateways
</:title>
<:actions>
<.add_button navigate={~p"/#{@account}/gateways/new"}>
<.add_button navigate={~p"/#{@account}/gateway_groups/new"}>
Add Instance Group
</.add_button>
</:actions>
</.header>
<!-- Gateways Table -->
<div class="bg-white dark:bg-gray-800 overflow-hidden">
<.resource_filter />
<.table_with_groups id="grouped-gateways" rows={@grouped_gateways} row_id={&"gateway-#{&1.id}"}>
<:col label="INSTANCE GROUP"></:col>
<!--<.resource_filter />-->
<.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 %>
</.link>
<%= if not Enum.empty?(group.tags), do: "(" <> Enum.join(group.tags, ", ") <> ")" %>
<div class="font-light flex">
<span class="pr-1 inline-block">Resources:</span>
<.intersperse_blocks>
<:separator><span class="pr-1">,</span></: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 %></.link>
</:item>
</.intersperse_blocks>
</div>
</:group>
<:col :let={gateway} label="INSTANCE">
<.link
navigate={~p"/#{@account}/gateways/#{gateway.id}"}
@@ -61,39 +83,17 @@ defmodule Web.Gateways.Index do
<%= gateway.ipv6 %>
</code>
</:col>
<:col :let={gateway} label="RESOURCES">
<.badge>
<%= @resources[gateway.id] || "0" %>
</.badge>
<:col :let={gateway} label="STATUS">
<.connection_status schema={gateway} />
</:col>
<:col :let={_gateway} label="STATUS">
<.badge type="success">
TODO: Online
</.badge>
</:col>
<: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
</.link>
</:action>
<:action :let={_gateway}>
<a
href="#"
class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Delete
</a>
</:action>
</.table_with_groups>
<.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"} />-->
</div>
"""
end
defp resource_filter(assigns) do
def resource_filter(assigns) do
~H"""
<div class="flex flex-col md:flex-row items-center justify-between space-y-3 md:space-y-0 md:space-x-4 p-4">
<div class="w-full md:w-1/2">

View File

@@ -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>
<.breadcrumb path={~p"/#{@account}/gateway_groups/new"}>Add</.breadcrumb>
</.breadcrumbs>
<.header>
<:title :if={is_nil(@group)}>
Add a new Gateway Instance Group
</:title>
<:title :if={not is_nil(@group)}>
Deploy your Gateway Instance
</:title>
</.header>
<section class="bg-white dark:bg-gray-900">
<div class="py-8 px-4 mx-auto max-w-2xl lg:py-16">
<.form :if={is_nil(@group)} for={@form} phx-change={:change} phx-submit={:submit}>
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
<div>
<.input
label="Name Prefix"
field={@form[:name_prefix]}
placeholder="Name of this Gateway Instance Group"
required
/>
</div>
<div>
<.input label="Tags" type="taglist" field={@form[:tags]} placeholder="Tag" />
</div>
</div>
<.submit_button>
Save
</.submit_button>
</.form>
<div :if={not is_nil(@group)}>
<div class="text-xl mb-2">
Select deployment method:
</div>
<.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 \<br />
&nbsp; --name=firezone-gateway-0 \<br />
&nbsp; --restart=always \<br />
&nbsp; -v /dev/net/tun:/dev/net/tun \<br />
&nbsp; -e FZ_SECRET=<%= Gateways.encode_token!(hd(@group.tokens)) %> \<br />
&nbsp; us-east1-docker.pkg.dev/firezone/firezone/gateway:stable
</.code_block>
</:tab>
<:tab id="systemd-instructions" label="Systemd">
<.code_block id="code-sample-systemd" class="w-full rounded-b-lg" phx-no-format>
[Unit]<br />
Description=zigbee2mqtt<br />
After=network.target<br />
<br />
[Service]<br />
ExecStart=/usr/bin/npm start<br />
WorkingDirectory=/opt/zigbee2mqtt<br />
StandardOutput=inherit<br />
StandardError=inherit<br />
Restart=always<br />
User=pi
</.code_block>
</:tab>
</.tabs>
<div class="mt-4 animate-pulse">
Waiting for gateway connection...
</div>
</div>
</div>
</section>
"""
end
end

View File

@@ -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>
<.breadcrumb path={~p"/#{@account}/gateway_groups/#{@group}"}>
<%= @group.name_prefix %>
</.breadcrumb>
</.breadcrumbs>
<.header>
<:title>
Gateway Instance Group: <code><%= @group.name_prefix %></code>
</:title>
<:actions>
<.edit_button navigate={~p"/#{@account}/gateway_groups/#{@group}/edit"}>
Edit Instance Group
</.edit_button>
</:actions>
</.header>
<div class="bg-white dark:bg-gray-800 overflow-hidden">
<.vertical_table>
<.vertical_table_row>
<:label>Instance Group Name</:label>
<:value><%= @group.name_prefix %></:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Tags</:label>
<:value>
<.badge :for={tag <- @group.tags} class="ml-2">
<%= tag %>
</.badge>
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Created</:label>
<:value>
<.datetime datetime={@group.inserted_at} /> by <.owner schema={@group} />
</:value>
</.vertical_table_row>
</.vertical_table>
<!-- Gateways table -->
<div class="grid grid-cols-1 p-4 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
<div class="col-span-full mb-4 xl:mb-2">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">
Gateway Instances
</h1>
</div>
</div>
<div class="relative overflow-x-auto">
<.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 %>
</.link>
</:col>
<:col :let={gateway} label="REMOTE IP">
<code class="block text-xs">
<%= gateway.ipv4 %>
</code>
<code class="block text-xs">
<%= gateway.ipv6 %>
</code>
</:col>
<:col :let={gateway} label="TOKEN CREATED AT">
<.datetime datetime={gateway.token.inserted_at} /> by <.owner schema={gateway.token} />
</:col>
<:col :let={gateway} label="STATUS">
<.connection_status schema={gateway} />
</:col>
</.table>
</div>
<!-- Linked Resources table -->
<div class="grid grid-cols-1 p-4 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
<div class="col-span-full mb-4 xl:mb-2">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">
Linked Resources
</h1>
</div>
</div>
<div class="relative overflow-x-auto">
<.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 %>
</.link>
</:col>
<:col :let={resource} label="ADDRESS">
<%= resource.address %>
</:col>
</.table>
</div>
</div>
<.header>
<:title>
Danger zone
</:title>
<: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
</.delete_button>
</:actions>
</.header>
"""
end
end

View File

@@ -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>
<.breadcrumb path={~p"/#{@account}/gateways/#{@gateway}"}>
<%= @gateway.name_suffix %>
</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/gateways/#{@gateway}/edit"}>Edit</.breadcrumb>
</.breadcrumbs>
<.header>
<:title>
Editing Gateway <code><%= @gateway.name_suffix %></code>
</:title>
</.header>
<section class="bg-white dark:bg-gray-900">
<div class="py-8 px-4 mx-auto max-w-2xl lg:py-16">
<h2 class="mb-4 text-xl font-bold text-gray-900 dark:text-white">Gateway details</h2>
<form action="#">
<div class="grid gap-4 sm:grid-cols-1 sm:gap-6">
<div>
<.label for="gateway-name">
Name
</.label>
<input
type="text"
name="gateway-name"
id="gateway-name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
required=""
value={@gateway.name_suffix}
/>
</div>
</div>
<.submit_button>
Save
</.submit_button>
</form>
</div>
</section>
"""
end
end

View File

@@ -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>
<.breadcrumb path={~p"/#{@account}/gateways/new"}>Add Gateway</.breadcrumb>
</.breadcrumbs>
<.header>
<:title>
Add a new Gateway
</:title>
</.header>
<section class="bg-white dark:bg-gray-900">
<div class="py-8 px-4 mx-auto max-w-2xl lg:py-16">
<h2 class="mb-4 text-xl font-bold text-gray-900 dark:text-white">Gateway details</h2>
<form action="#">
<div class="grid gap-4 sm:grid-cols-1 sm:gap-6">
<div>
<.label for="gateway-name">
Name
</.label>
<input
type="text"
name="gateway-name"
id="gateway-name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
required=""
/>
</div>
<div>
<.label>
Select a deployment method
</.label>
</div>
<.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
</.code_block>
</:tab>
<: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
</.code_block>
</:tab>
</.tabs>
</div>
<div id="gateway-submit-button" class="hidden">
<!-- TODO: Display submit button when Gateway connection is detected -->
<.submit_button>
Create
</.submit_button>
</div>
<div class="mt-4">
<.p>
Waiting for gateway connection...
</.p>
</div>
</form>
</div>
</section>
"""
end
end

View File

@@ -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>
<.breadcrumb path={~p"/#{@account}/gateway_groups"}>Gateway Instance Groups</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/gateway_groups/#{@gateway.group}"}>
<%= @gateway.group.name_prefix %>
</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/gateways/#{@gateway}"}>
<%= @gateway.name_suffix %>
</.breadcrumb>
@@ -41,7 +74,7 @@ defmodule Web.Gateways.Show do
<.vertical_table_row>
<:label>Status</:label>
<:value>
<.badge type="success">TODO: Online</.badge>
<.connection_status schema={@gateway} />
</:value>
</.vertical_table_row>
<.vertical_table_row>
@@ -58,8 +91,6 @@ defmodule Web.Gateways.Show do
</:label>
<:value>
<.relative_datetime datetime={@gateway.last_seen_at} />
<br />
<%= @gateway.last_seen_at %>
</:value>
</.vertical_table_row>
<.vertical_table_row>
@@ -79,11 +110,15 @@ defmodule Web.Gateways.Show do
<:value>TODO: 4.43 GB up, 1.23 GB down</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Gateway Version</:label>
<:label>Version</:label>
<:value>
<%= "Gateway Version: #{@gateway.last_seen_version}" %>
<br />
<%= "User Agent: #{@gateway.last_seen_user_agent}" %>
<%= @gateway.last_seen_version %>
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>User Agent</:label>
<:value>
<%= @gateway.last_seen_user_agent %>
</:value>
</.vertical_table_row>
<.vertical_table_row>
@@ -92,36 +127,13 @@ defmodule Web.Gateways.Show do
</.vertical_table_row>
</.vertical_table>
</div>
<!-- Linked Resources table -->
<div class="grid grid-cols-1 p-4 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
<div class="col-span-full mb-4 xl:mb-2">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">
Linked Resources
</h1>
</div>
</div>
<div class="relative overflow-x-auto">
<.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 %>
</.link>
</:col>
<:col :let={resource} label="ADDRESS">
<%= resource.address %>
</:col>
</.table>
</div>
<.header>
<:title>
Danger zone
</:title>
<:actions>
<.delete_button>
<.delete_button phx-click="delete">
Delete Gateway
</.delete_button>
</:actions>

View File

@@ -60,7 +60,7 @@ defmodule Web.Policies.Index do
</a>
</:action>
</.table>
<.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"} />
</div>
"""
end

View File

@@ -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>
<.breadcrumb path={~p"/#{@account}/relay_groups/#{@group}"}>
<%= @group.name %>
</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/relay_groups/#{@group}/edit"}>Edit</.breadcrumb>
</.breadcrumbs>
<.header>
<:title>
Editing Relay Instance Group <code><%= @group.name %></code>
</:title>
</.header>
<section class="bg-white dark:bg-gray-900">
<div class="py-8 px-4 mx-auto max-w-2xl lg:py-16">
<.form for={@form} phx-change={:change} phx-submit={:submit}>
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
<div>
<.input
label="Name Prefix"
field={@form[:name]}
placeholder="Name of this Relay Instance Group"
required
/>
</div>
</div>
<.submit_button>
Save
</.submit_button>
</.form>
</div>
</section>
"""
end
end

View File

@@ -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</.breadcrumb>
</.breadcrumbs>
<.header>
<:title>
All relays
</:title>
<:actions>
<.add_button navigate={~p"/#{@account}/relay_groups/new"}>
Add Instance Group
</.add_button>
</:actions>
</.header>
<!-- Relays Table -->
<div class="bg-white dark:bg-gray-800 overflow-hidden">
<!--<.resource_filter />-->
<.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 %>
</.link>
<span :if={is_nil(group.account_id)}>
<%= group.name %>
</span>
</:group>
<: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"
>
<code :if={relay.ipv4} class="block text-xs">
<%= relay.ipv4 %>
</code>
<code :if={relay.ipv6} class="block text-xs">
<%= relay.ipv6 %>
</code>
</.link>
<div :if={is_nil(relay.account_id)}>
<code :if={relay.ipv4} class="block text-xs">
<%= relay.ipv4 %>
</code>
<code :if={relay.ipv6} class="block text-xs">
<%= relay.ipv6 %>
</code>
</div>
</:col>
<:col :let={relay} label="TYPE">
<%= if relay.account_id, do: "self-hosted", else: "firezone-owned" %>
</:col>
<:col :let={relay} label="STATUS">
<.connection_status schema={relay} />
</:col>
</.table_with_groups>
<!--<.paginator page={3} total_pages={100} collection_base_path={~p"/#{@account}/relay_groups"} />-->
</div>
"""
end
def resource_filter(assigns) do
~H"""
<div class="flex flex-col md:flex-row items-center justify-between space-y-3 md:space-y-0 md:space-x-4 p-4">
<div class="w-full md:w-1/2">
<form class="flex items-center">
<label for="simple-search" class="sr-only">Search</label>
<div class="relative w-full">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<.icon name="hero-magnifying-glass" class="w-5 h-5 text-gray-500 dark:text-gray-400" />
</div>
<input
type="text"
id="simple-search"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full pl-10 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Search"
required=""
/>
</div>
</form>
</div>
<.button_group>
<:first>
All
</:first>
<:middle>
Online
</:middle>
<:last>
Deleted
</:last>
</.button_group>
</div>
"""
end
end

View File

@@ -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>
<.breadcrumb path={~p"/#{@account}/relay_groups/new"}>Add</.breadcrumb>
</.breadcrumbs>
<.header>
<:title :if={is_nil(@group)}>
Add a new Relay Instance Group
</:title>
<:title :if={not is_nil(@group)}>
Deploy your Relay Instance
</:title>
</.header>
<section class="bg-white dark:bg-gray-900">
<div class="py-8 px-4 mx-auto max-w-2xl lg:py-16">
<.form :if={is_nil(@group)} for={@form} phx-change={:change} phx-submit={:submit}>
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
<div>
<.input
label="Name Prefix"
field={@form[:name]}
placeholder="Name of this Relay Instance Group"
required
/>
</div>
</div>
<.submit_button>
Save
</.submit_button>
</.form>
<div :if={not is_nil(@group)}>
<div class="text-xl mb-2">
Select deployment method:
</div>
<.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 \<br />
&nbsp; --name=firezone-relay-0 \<br />
&nbsp; --restart=always \<br />
&nbsp; -v /dev/net/tun:/dev/net/tun \<br />
&nbsp; -e PORTAL_TOKEN=<%= Relays.encode_token!(hd(@group.tokens)) %> \<br />
&nbsp; us-east1-docker.pkg.dev/firezone/firezone/relay:stable
</.code_block>
</:tab>
<:tab id="systemd-instructions" label="Systemd">
<.code_block id="code-sample-systemd" class="w-full rounded-b-lg" phx-no-format>
[Unit]<br />
Description=zigbee2mqtt<br />
After=network.target<br />
<br />
[Service]<br />
ExecStart=/usr/bin/npm start<br />
WorkingDirectory=/opt/zigbee2mqtt<br />
StandardOutput=inherit<br />
StandardError=inherit<br />
Restart=always<br />
User=pi
</.code_block>
</:tab>
</.tabs>
<div class="mt-4 animate-pulse">
Waiting for relay connection...
</div>
</div>
</div>
</section>
"""
end
end

View File

@@ -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>
<.breadcrumb path={~p"/#{@account}/relay_groups/#{@group}"}>
<%= @group.name %>
</.breadcrumb>
</.breadcrumbs>
<.header>
<:title>
Relay Instance Group: <code><%= @group.name %></code>
</:title>
<:actions :if={@group.account_id}>
<.edit_button navigate={~p"/#{@account}/relay_groups/#{@group}/edit"}>
Edit Instance Group
</.edit_button>
</:actions>
</.header>
<div class="bg-white dark:bg-gray-800 overflow-hidden">
<.vertical_table>
<.vertical_table_row>
<:label>Instance Group Name</:label>
<:value><%= @group.name %></:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Created</:label>
<:value>
<.datetime datetime={@group.inserted_at} /> by <.owner schema={@group} />
</:value>
</.vertical_table_row>
</.vertical_table>
<!-- Relays table -->
<div class="grid grid-cols-1 p-4 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
<div class="col-span-full mb-4 xl:mb-2">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">
Relay Instances
</h1>
</div>
</div>
<div class="relative overflow-x-auto">
<.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"
>
<code :if={relay.ipv4} class="block text-xs">
<%= relay.ipv4 %>
</code>
<code :if={relay.ipv6} class="block text-xs">
<%= relay.ipv6 %>
</code>
</.link>
</:col>
<:col :let={relay} label="TOKEN CREATED AT">
<.datetime datetime={relay.token.inserted_at} /> by <.owner schema={relay.token} />
</:col>
<:col :let={relay} label="STATUS">
<.connection_status schema={relay} />
</:col>
</.table>
</div>
</div>
<.header>
<:title>
Danger zone
</:title>
<: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
</.delete_button>
</:actions>
</.header>
"""
end
end

View File

@@ -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>
<.breadcrumb path={~p"/#{@account}/relay_groups/#{@relay.group}"}>
<%= @relay.group.name %>
</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/relays/#{@relay}"}>
<%= @relay.ipv4 || @relay.ipv6 %>
</.breadcrumb>
</.breadcrumbs>
<.header>
<:title>
Relay:
<.intersperse_blocks>
<:separator>,&nbsp;</:separator>
<:item :for={ip <- [@relay.ipv4, @relay.ipv6]} :if={not is_nil(ip)}>
<code><%= @relay.ipv4 %></code>
</:item>
</.intersperse_blocks>
</:title>
</.header>
<!-- Relay details -->
<div class="bg-white dark:bg-gray-800 overflow-hidden">
<.vertical_table>
<.vertical_table_row>
<:label>Instance Group Name</:label>
<:value><%= @relay.group.name %></:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Status</:label>
<:value>
<.connection_status schema={@relay} />
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Location</:label>
<:value>
<code>
<%= @relay.last_seen_remote_ip %>
</code>
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>
Last seen
</:label>
<:value>
<.relative_datetime datetime={@relay.last_seen_at} />
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Remote IPv4</:label>
<:value>
<code><%= @relay.ipv4 %></code>
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Remote IPv6</:label>
<:value>
<code><%= @relay.ipv6 %></code>
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Version</:label>
<:value>
<%= @relay.last_seen_version %>
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>User Agent</:label>
<:value>
<%= @relay.last_seen_user_agent %>
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Deployment Method</:label>
<:value>TODO: Docker</:value>
</.vertical_table_row>
</.vertical_table>
</div>
<.header>
<:title>
Danger zone
</:title>
<:actions :if={@relay.account_id}>
<.delete_button phx-click="delete">
Delete Relay
</.delete_button>
</:actions>
</.header>
"""
end
end

View File

@@ -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
</.badge>
</.link>
</:col>
<:col :let={_resource} label="GROUPS">
<:col :let={_resource} label="AUTHORIZED GROUPS">
TODO
<.link navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}>
<.badge>Engineering</.badge>

View File

@@ -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>
<.vertical_table_row>
<:label>
Traffic restriction
Traffic Filtering Rules
</:label>
<:value>
<%= for filter <- @resource.filters do %>
<div :if={@resource.filters == []} %>
No traffic filtering rules
</div>
<div :for={filter <- @resource.filters} :if={@resource.filters != []} %>
<code>
<%= pretty_print_filter(filter) %>
</code>
<br />
<% end %>
</div>
</:value>
</.vertical_table_row>
<.vertical_table_row>
@@ -85,15 +85,7 @@ defmodule Web.Resources.Show do
Created
</:label>
<: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
</.link>
)
<.datetime datetime={@resource.inserted_at} /> by <.owner schema={@resource} />
</:value>
</.vertical_table_row>
</.vertical_table>
@@ -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 %>
</.link>
</:col>
<:col :let={_gateway_group} label="Status">
<.badge type="success">TODO: Online</.badge>
</:col>
</.table>
</div>

View File

@@ -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 %>
</.code_block>
@@ -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 %>
</.code_block>

View File

@@ -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 %>
</.code_block>
@@ -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 %>
</.code_block>

View File

@@ -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

View File

@@ -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<Id>,
) -> 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<CB: Callbacks + 'static> ControlPlane<CB> {
}
#[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<CB: Callbacks + 'static> ControlPlane<CB> {
)
.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<CB: Callbacks + 'static> ControlPlane<CB> {
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),

View File

@@ -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<Relay>,
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<Relay>,
}
// 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<IngressMessages> for Messages {
impl From<ReplyMessages> 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<ReplyMessages> 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<Id>,
},
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::<EgressMessages, ()>::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::<IngressMessages, ReplyMessages>::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"
}

View File

@@ -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 {

View File

@@ -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<Id>,
) -> 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<CB: Callbacks + 'static> ControlPlane<CB> {
}
#[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<CB: Callbacks + 'static> ControlPlane<CB> {
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(())
}

View File

@@ -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<Utc>,
}
// 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

View File

@@ -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<C, CB> Tunnel<C, CB>
where
C: ControlSignal + Send + Sync + 'static,
@@ -35,9 +42,8 @@ where
data_channel: Arc<RTCDataChannel>,
index: u32,
peer_config: PeerConfig,
expires_at: Option<DateTime<Utc>>,
conn_id: Id,
resources: Option<ResourceDescription>,
resources: Option<(ResourceDescription, DateTime<Utc>)>,
) -> 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<Self>,
resource_id: Id,
gateway_id: Id,
relays: Vec<Relay>,
) -> Result<RequestConnection> {
) -> Result<Request> {
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<Utc>,
) {
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);

View File

@@ -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()),

View File

@@ -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<Id>,
) -> 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<C: ControlSignal, CB: Callbacks> {
@@ -142,8 +148,10 @@ pub struct Tunnel<C: ControlSignal, CB: Callbacks> {
peers_by_ip: RwLock<IpNetworkTable<Arc<Peer>>>,
peer_connections: Mutex<HashMap<Id, Arc<RTCPeerConnection>>>,
awaiting_connection: Mutex<HashSet<Id>>,
gateway_awaiting_connection: Mutex<HashMap<Id, Vec<IpNetwork>>>,
resources_gateways: Mutex<HashMap<Id, Id>>,
webrtc_api: API,
resources: RwLock<ResourceTable>,
resources: RwLock<ResourceTable<ResourceDescription>>,
control_signaler: C,
gateway_public_keys: Mutex<HashMap<Id, PublicKey>>,
callbacks: CallbackErrorFacade<CB>,
@@ -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<Self>) {
@@ -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<ResourceDescription> {
// 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::<Vec<_>>(),
);
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"),

View File

@@ -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<Utc>);
pub(crate) struct Peer {
pub tunnel: Mutex<Tunn>,
pub index: u32,
pub allowed_ips: IpNetworkTable<()>,
pub allowed_ips: RwLock<IpNetworkTable<()>>,
pub channel: Arc<DataChannel>,
pub expires_at: Option<DateTime<Utc>>,
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<ResourceDescription>,
pub resources: Option<RwLock<ResourceTable<ExpiryingResource>>>,
// 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<Option<IpAddr>>,
pub translated_resource_addresses: RwLock<HashMap<IpAddr, Id>>,
}
impl Peer {
@@ -49,17 +50,15 @@ impl Peer {
index: u32,
config: &PeerConfig,
channel: Arc<DataChannel>,
expires_at: Option<DateTime<Utc>>,
conn_id: Id,
resource: Option<ResourceDescription>,
gateway_id: Id,
resource: Option<(ResourceDescription, DateTime<Utc>)>,
) -> 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<IpNetwork>,
channel: Arc<DataChannel>,
expires_at: Option<DateTime<Utc>>,
conn_id: Id,
resource: Option<ResourceDescription>,
gateway_id: Id,
resource: Option<(ResourceDescription, DateTime<Utc>)>,
) -> 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<ResourceDescription> {
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<Utc>) {
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);
}
}

View File

@@ -36,7 +36,7 @@ where
pub(crate) async fn send_to_resource(&self, peer: &Arc<Peer>, 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::<u16>)
.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::<u16>)
.and_then(std::result::Result::ok),
)
}
ResourceDescription::Cidr(r) => {
if r.address.contains(dst) {

View File

@@ -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<Utc>) {
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<Id, ResourceDescription>,
network_table: IpNetworkTable<NonNull<ResourceDescription>>,
dns_name: HashMap<String, NonNull<ResourceDescription>>,
pub(crate) struct ResourceTable<T> {
id_table: HashMap<Id, T>,
network_table: IpNetworkTable<NonNull<T>>,
dns_name: HashMap<String, NonNull<T>>,
}
// SAFETY: We actually hold a hashmap internally that the pointers points to
unsafe impl Send for ResourceTable {}
unsafe impl<T> Send for ResourceTable<T> {}
// 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<T> Sync for ResourceTable<T> {}
impl Default for ResourceTable {
fn default() -> ResourceTable {
impl<T> Default for ResourceTable<T> {
fn default() -> ResourceTable<T> {
ResourceTable::new()
}
}
impl ResourceTable {
impl<T> ResourceTable<T> {
/// Creates a new `ResourceTable`
pub fn new() -> ResourceTable {
pub fn new() -> ResourceTable<T> {
ResourceTable {
network_table: IpNetworkTable::new(),
id_table: HashMap::new(),
dns_name: HashMap::new(),
}
}
}
pub fn values(&self) -> impl Iterator<Item = &ResourceDescription> {
impl<T> ResourceTable<T>
where
T: Resource + Clone,
{
pub fn values(&self) -> impl Iterator<Item = &T> {
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<IpAddr>) -> Option<&ResourceDescription> {
pub fn get_by_ip(&self, ip: impl Into<IpAddr>) -> 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<str>) -> Option<&ResourceDescription> {
pub fn get_by_name(&self, name: impl AsRef<str>) -> 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<ResourceDescription>) {
unsafe fn remove_resource(&mut self, resource_description: NonNull<T>) {
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<ResourceDescription> {
self.id_table.values().cloned().collect()
self.id_table
.values()
.map(|r| r.description())
.cloned()
.collect()
}
}