mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 18:18:55 +00:00
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:
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, _} =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
"""
|
||||
|
||||
87
elixir/apps/web/lib/web/live/gateway_groups/edit.ex
Normal file
87
elixir/apps/web/lib/web/live/gateway_groups/edit.ex
Normal 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
|
||||
@@ -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">
|
||||
132
elixir/apps/web/lib/web/live/gateway_groups/new.ex
Normal file
132
elixir/apps/web/lib/web/live/gateway_groups/new.ex
Normal 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 />
|
||||
--name=firezone-gateway-0 \<br />
|
||||
--restart=always \<br />
|
||||
-v /dev/net/tun:/dev/net/tun \<br />
|
||||
-e FZ_SECRET=<%= Gateways.encode_token!(hd(@group.tokens)) %> \<br />
|
||||
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
|
||||
148
elixir/apps/web/lib/web/live/gateway_groups/show.ex
Normal file
148
elixir/apps/web/lib/web/live/gateway_groups/show.ex
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
69
elixir/apps/web/lib/web/live/relay_groups/edit.ex
Normal file
69
elixir/apps/web/lib/web/live/relay_groups/edit.ex
Normal 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
|
||||
128
elixir/apps/web/lib/web/live/relay_groups/index.ex
Normal file
128
elixir/apps/web/lib/web/live/relay_groups/index.ex
Normal 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
|
||||
113
elixir/apps/web/lib/web/live/relay_groups/new.ex
Normal file
113
elixir/apps/web/lib/web/live/relay_groups/new.ex
Normal 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 />
|
||||
--name=firezone-relay-0 \<br />
|
||||
--restart=always \<br />
|
||||
-v /dev/net/tun:/dev/net/tun \<br />
|
||||
-e PORTAL_TOKEN=<%= Relays.encode_token!(hd(@group.tokens)) %> \<br />
|
||||
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
|
||||
113
elixir/apps/web/lib/web/live/relay_groups/show.ex
Normal file
113
elixir/apps/web/lib/web/live/relay_groups/show.ex
Normal 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
|
||||
137
elixir/apps/web/lib/web/live/relays/show.ex
Normal file
137
elixir/apps/web/lib/web/live/relays/show.ex
Normal 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>, </: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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user