Add routing option for sites (#2610)

Why:

* As sites are created, the default behavior right now is to route
traffic through whichever path is easiest/fastest. This commit adds the
ability to allow the admin to choose a routing policy for a given site.
This commit is contained in:
bmanifold
2023-11-22 14:59:54 -05:00
committed by GitHub
parent 48722d609f
commit ef480e1acd
24 changed files with 767 additions and 66 deletions

View File

@@ -205,7 +205,10 @@ services:
PUBLIC_IP6_ADDR: fcff:3990:3990::101
LOWEST_PORT: 55555
HIGHEST_PORT: 55666
FIREZONE_TOKEN: "SFMyNTY.g2gDaAJtAAAAJDcyODZiNTNkLTA3M2UtNGM0MS05ZmYxLWNjODQ1MWRhZDI5OW0AAABARVg3N0dhMEhLSlVWTGdjcE1yTjZIYXRkR25mdkFEWVFyUmpVV1d5VHFxdDdCYVVkRVUzbzktRmJCbFJkSU5JS24GAFSzb0KJAWIAAVGA.waeGE26tbgkgIcMrWyck0ysv9SHIoHr0zqoM3wao84M"
# Token for self-hosted Relay
#FIREZONE_TOKEN: "SFMyNTY.g2gDaAJtAAAAJDcyODZiNTNkLTA3M2UtNGM0MS05ZmYxLWNjODQ1MWRhZDI5OW0AAABARVg3N0dhMEhLSlVWTGdjcE1yTjZIYXRkR25mdkFEWVFyUmpVV1d5VHFxdDdCYVVkRVUzbzktRmJCbFJkSU5JS24GAFSzb0KJAWIAAVGA.waeGE26tbgkgIcMrWyck0ysv9SHIoHr0zqoM3wao84M"
# Token for global Relay
FIREZONE_TOKEN: "SFMyNTY.g2gDaAJtAAAAJGMxMDM4ZTIyLTAyMTUtNDk3Ny05ZjZjLWY2NTYyMWUwMDA4Zm0AAABWT2JubmIzN2RCdE5RY2NDVS1mQll1MWg4TmFmQXAwS3lvT3dsbzJUVEl5NjBvZm9rSWxWNjBzcGExMkc1cElHLVJWS2o1cXdIVkVoMWs5bjh4QmNmOUFuBgAqiFHuiwFiAAFRgA.VyV9cW06PCZyxTefBwIlSCFTDBFEOSRQ2gfJXtMplVE"
RUST_LOG: "debug"
RUST_BACKTRACE: 1
FIREZONE_API_URL: ws://api:8081

View File

@@ -180,8 +180,12 @@ defmodule API.Client.Channel do
with {:ok, resource} <-
Resources.fetch_and_authorize_resource_by_id(resource_id, socket.assigns.subject),
{:ok, [_ | _] = gateways} <-
Gateways.list_connected_gateways_for_resource(resource),
{:ok, [_ | _] = relays} <- Relays.list_connected_relays_for_resource(resource) do
Gateways.list_connected_gateways_for_resource(resource, preload: :group),
gateway_groups = Enum.map(gateways, & &1.group),
{relay_hosting_type, relay_connection_type} =
Gateways.relay_strategy(gateway_groups),
{:ok, [_ | _] = relays} <-
Relays.list_connected_relays_for_resource(resource, relay_hosting_type) do
location = {
socket.assigns.client.last_seen_remote_ip_location_lat,
socket.assigns.client.last_seen_remote_ip_location_lon
@@ -193,7 +197,12 @@ defmodule API.Client.Channel do
reply =
{:ok,
%{
relays: Views.Relay.render_many(relays, socket.assigns.subject.expires_at),
relays:
Views.Relay.render_many(
relays,
socket.assigns.subject.expires_at,
relay_connection_type
),
resource_id: resource_id,
gateway_id: gateway.id,
gateway_remote_ip: gateway.last_seen_remote_ip

View File

@@ -1,21 +1,35 @@
defmodule API.Client.Views.Relay do
alias Domain.Relays
def render_many(relays, expires_at) do
Enum.flat_map(relays, &render(&1, expires_at))
def render_many(relays, expires_at, stun_or_turn) do
Enum.flat_map(relays, &render(&1, expires_at, stun_or_turn))
end
def render(%Relays.Relay{} = relay, expires_at) do
def render(%Relays.Relay{} = relay, expires_at, stun_or_turn) do
[
maybe_render(relay, expires_at, relay.ipv4),
maybe_render(relay, expires_at, relay.ipv6)
maybe_render(relay, expires_at, relay.ipv4, stun_or_turn),
maybe_render(relay, expires_at, relay.ipv6, stun_or_turn)
]
|> List.flatten()
end
defp maybe_render(%Relays.Relay{}, _expires_at, nil), do: []
defp maybe_render(%Relays.Relay{}, _expires_at, nil, _stun_or_turn), do: []
defp maybe_render(%Relays.Relay{} = relay, expires_at, address) do
# STUN returns the reflective candidates to the peer and is used for hole-punching;
# TURN is used to real actual traffic if hole-punching fails. It requires authentication.
# WebRTC will automatically fail back to STUN if TURN fails,
# so there is no need to send both of them along with each other.
defp maybe_render(%Relays.Relay{} = relay, _expires_at, address, :stun) do
[
%{
type: :stun,
uri: "stun:#{format_address(address)}:#{relay.port}"
}
]
end
defp maybe_render(%Relays.Relay{} = relay, expires_at, address, :turn) do
%{
username: username,
password: password,
@@ -23,12 +37,6 @@ defmodule API.Client.Views.Relay do
} = Relays.generate_username_and_password(relay, expires_at)
[
# WebRTC automatically falls back to STUN if TURN fails,
# so no need to send it explicitly
# %{
# type: :stun,
# uri: "stun:#{format_address(address)}:#{relay.port}"
# },
%{
type: :turn,
uri: "turn:#{format_address(address)}:#{relay.port}",

View File

@@ -145,7 +145,12 @@ defmodule API.Gateway.Channel do
client = Clients.fetch_client_by_id!(client_id, preload: [:actor])
resource = Resources.fetch_resource_by_id!(resource_id)
{:ok, relays} = Relays.list_connected_relays_for_resource(resource)
{relay_hosting_type, relay_connection_type} =
Gateways.relay_strategy([socket.assigns.gateway_group])
{:ok, relays} =
Relays.list_connected_relays_for_resource(resource, relay_hosting_type)
ref = Ecto.UUID.generate()
@@ -153,7 +158,7 @@ defmodule API.Gateway.Channel do
ref: ref,
flow_id: flow_id,
actor: Views.Actor.render(client.actor),
relays: Views.Relay.render_many(relays, authorization_expires_at),
relays: Views.Relay.render_many(relays, authorization_expires_at, relay_connection_type),
resource: Views.Resource.render(resource),
client: Views.Client.render(client, rtc_session_description, preshared_key),
expires_at: DateTime.to_unix(authorization_expires_at, :second)

View File

@@ -37,7 +37,8 @@ defmodule API.Gateway.Socket do
|> Map.put("last_seen_remote_ip_location_lon", location_lon)
with {:ok, token} <- Gateways.authorize_gateway(encrypted_secret),
{:ok, gateway} <- Gateways.upsert_gateway(token, attrs) do
{:ok, gateway} <- Gateways.upsert_gateway(token, attrs),
{:ok, gateway_group} <- Gateways.fetch_group_by_id(gateway.group_id) do
OpenTelemetry.Tracer.set_attributes(%{
gateway_id: gateway.id,
account_id: gateway.account_id
@@ -46,6 +47,7 @@ defmodule API.Gateway.Socket do
socket =
socket
|> assign(:gateway, gateway)
|> assign(:gateway_group, gateway_group)
|> assign(:opentelemetry_span_ctx, OpenTelemetry.Tracer.current_span_ctx())
|> assign(:opentelemetry_ctx, OpenTelemetry.Ctx.get_current())

View File

@@ -1,5 +1,5 @@
defmodule API.Gateway.Views.Relay do
def render_many(relays, expires_at) do
Enum.flat_map(relays, &API.Client.Views.Relay.render(&1, expires_at))
def render_many(relays, expires_at, conn_type) do
Enum.flat_map(relays, &API.Client.Views.Relay.render(&1, expires_at, conn_type))
end
end

View File

@@ -73,6 +73,7 @@ defmodule API.Client.ChannelTest do
%{
account: account,
actor: actor,
actor_group: actor_group,
identity: identity,
subject: subject,
client: client,
@@ -262,15 +263,18 @@ defmodule API.Client.ChannelTest do
global_relay =
Fixtures.Relays.create_relay(
group: global_relay_group,
ipv6: nil,
last_seen_remote_ip_location_lat: 37,
last_seen_remote_ip_location_lon: -120
)
# Creating this Relay to verify it doesn't get returned when :managed routing option is selected
relay = Fixtures.Relays.create_relay(account: account)
stamp_secret = Ecto.UUID.generate()
:ok = Domain.Relays.connect_relay(relay, stamp_secret)
stamp_secret_global = Ecto.UUID.generate()
:ok = Domain.Relays.connect_relay(global_relay, stamp_secret_global)
# Online Gateway
:ok = Domain.Gateways.connect_gateway(gateway)
@@ -287,6 +291,93 @@ defmodule API.Client.ChannelTest do
assert gateway_id == gateway.id
assert gateway_last_seen_remote_ip == gateway.last_seen_remote_ip
ipv4_turn_uri = "turn:#{global_relay.ipv4}:#{global_relay.port}"
ipv6_turn_uri = "turn:[#{global_relay.ipv6}]:#{global_relay.port}"
assert [
%{
type: :turn,
expires_at: expires_at_unix,
password: password1,
username: username1,
uri: ^ipv4_turn_uri
},
%{
type: :turn,
expires_at: expires_at_unix,
password: password2,
username: username2,
uri: ^ipv6_turn_uri
}
] = relays
assert username1 != username2
assert password1 != password2
assert [expires_at, salt] = String.split(username1, ":", parts: 2)
expires_at = expires_at |> String.to_integer() |> DateTime.from_unix!()
socket_expires_at = DateTime.truncate(socket.assigns.subject.expires_at, :second)
assert expires_at == socket_expires_at
assert is_binary(salt)
end
test "returns online gateway and self-hosted relays connected to the resource", %{
account: account,
socket: socket,
actor_group: actor_group
} do
# Gateway setup
gateway_group = Fixtures.Gateways.create_group(account: account, routing: :self_hosted)
gateway = Fixtures.Gateways.create_gateway(account: account, group: gateway_group)
:ok = Domain.Gateways.connect_gateway(gateway)
# Resource setup
resource =
Fixtures.Resources.create_resource(
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
Fixtures.Policies.create_policy(
account: account,
actor_group: actor_group,
resource: resource
)
# Global Relay setup
global_relay_group = Fixtures.Relays.create_global_group()
global_relay =
Fixtures.Relays.create_relay(
group: global_relay_group,
last_seen_remote_ip_location_lat: 37,
last_seen_remote_ip_location_lon: -120
)
stamp_secret_global = Ecto.UUID.generate()
:ok = Domain.Relays.connect_relay(global_relay, stamp_secret_global)
# Self-hosted Relay setup
relay = Fixtures.Relays.create_relay(account: account)
stamp_secret = Ecto.UUID.generate()
:ok = Domain.Relays.connect_relay(relay, stamp_secret)
ref = push(socket, "prepare_connection", %{"resource_id" => resource.id})
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 length(relays) == 2
assert gateway_id == gateway.id
assert gateway_last_seen_remote_ip == gateway.last_seen_remote_ip
ipv4_turn_uri = "turn:#{relay.ipv4}:#{relay.port}"
ipv6_turn_uri = "turn:[#{relay.ipv6}]:#{relay.port}"
@@ -316,12 +407,77 @@ defmodule API.Client.ChannelTest do
assert expires_at == socket_expires_at
assert is_binary(salt)
end
:ok = Domain.Relays.connect_relay(global_relay, stamp_secret)
test "returns online gateway and stun-only relay URLs connected to the resource", %{
account: account,
socket: socket,
actor_group: actor_group
} do
# Gateway setup
gateway_group = Fixtures.Gateways.create_group(account: account, routing: :stun_only)
gateway = Fixtures.Gateways.create_gateway(account: account, group: gateway_group)
:ok = Domain.Gateways.connect_gateway(gateway)
# Resource setup
resource =
Fixtures.Resources.create_resource(
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
Fixtures.Policies.create_policy(
account: account,
actor_group: actor_group,
resource: resource
)
# Global Relay setup
global_relay_group = Fixtures.Relays.create_global_group()
global_relay =
Fixtures.Relays.create_relay(
group: global_relay_group,
last_seen_remote_ip_location_lat: 37,
last_seen_remote_ip_location_lon: -120
)
stamp_secret_global = Ecto.UUID.generate()
:ok = Domain.Relays.connect_relay(global_relay, stamp_secret_global)
# Self-hosted Relay setup
relay = Fixtures.Relays.create_relay(account: account)
stamp_secret = Ecto.UUID.generate()
:ok = Domain.Relays.connect_relay(relay, stamp_secret)
ref = push(socket, "prepare_connection", %{"resource_id" => resource.id})
assert_reply ref, :ok, %{relays: relays}
assert length(relays) == 3
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 length(relays) == 2
assert gateway_id == gateway.id
assert gateway_last_seen_remote_ip == gateway.last_seen_remote_ip
ipv4_turn_uri = "stun:#{global_relay.ipv4}:#{global_relay.port}"
ipv6_turn_uri = "stun:[#{global_relay.ipv6}]:#{global_relay.port}"
assert [
%{
type: :stun,
uri: ^ipv4_turn_uri
},
%{
type: :stun,
uri: ^ipv6_turn_uri
}
] = relays
end
end

View File

@@ -8,6 +8,7 @@ defmodule API.Gateway.ChannelTest do
subject = Fixtures.Auth.create_subject(identity: identity)
client = Fixtures.Clients.create_client(subject: subject)
gateway = Fixtures.Gateways.create_gateway(account: account)
{:ok, gateway_group} = Domain.Gateways.fetch_group_by_id(gateway.group_id, subject)
resource =
Fixtures.Resources.create_resource(
@@ -19,12 +20,15 @@ defmodule API.Gateway.ChannelTest do
API.Gateway.Socket
|> socket("gateway:#{gateway.id}", %{
gateway: gateway,
gateway_group: gateway_group,
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test")
})
|> subscribe_and_join(API.Gateway.Channel, "gateway")
relay = Fixtures.Relays.create_relay(account: account)
global_relay_group = Fixtures.Relays.create_global_group()
global_relay = Fixtures.Relays.create_relay(group: global_relay_group)
%{
account: account,
@@ -35,6 +39,7 @@ defmodule API.Gateway.ChannelTest do
gateway: gateway,
resource: resource,
relay: relay,
global_relay: global_relay,
socket: socket
}
end
@@ -134,10 +139,10 @@ defmodule API.Gateway.ChannelTest do
end
describe "handle_info/2 :request_connection" do
test "pushes request_connection message", %{
test "pushes request_connection message with managed relays", %{
client: client,
resource: resource,
relay: relay,
global_relay: relay,
socket: socket
} do
channel_pid = self()
@@ -226,6 +231,215 @@ defmodule API.Gateway.ChannelTest do
assert DateTime.from_unix!(payload.expires_at) == DateTime.truncate(expires_at, :second)
end
test "pushes request_connection message with self-hosted relays", %{
account: account,
client: client,
relay: relay
} do
gateway_group = Fixtures.Gateways.create_group(%{account: account, routing: "self_hosted"})
gateway = Fixtures.Gateways.create_gateway(account: account, group: gateway_group)
resource =
Fixtures.Resources.create_resource(
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
{:ok, _, socket} =
API.Gateway.Socket
|> socket("gateway:#{gateway.id}", %{
gateway: gateway,
gateway_group: gateway_group,
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test")
})
|> subscribe_and_join(API.Gateway.Channel, "gateway")
channel_pid = self()
socket_ref = make_ref()
expires_at = DateTime.utc_now() |> DateTime.add(30, :second)
preshared_key = "PSK"
rtc_session_description = "RTC_SD"
flow_id = Ecto.UUID.generate()
otel_ctx = {OpenTelemetry.Ctx.new(), OpenTelemetry.Tracer.start_span("connect")}
stamp_secret = Ecto.UUID.generate()
:ok = Domain.Relays.connect_relay(relay, stamp_secret)
send(
socket.channel_pid,
{:request_connection, {channel_pid, socket_ref},
%{
client_id: client.id,
resource_id: resource.id,
flow_id: flow_id,
authorization_expires_at: expires_at,
client_rtc_session_description: rtc_session_description,
client_preshared_key: preshared_key
}, otel_ctx}
)
assert_push "request_connection", payload
assert is_binary(payload.ref)
assert payload.flow_id == flow_id
assert payload.actor == %{id: client.actor_id}
ipv4_turn_uri = "turn:#{relay.ipv4}:#{relay.port}"
ipv6_turn_uri = "turn:[#{relay.ipv6}]:#{relay.port}"
assert [
%{
type: :turn,
expires_at: expires_at_unix,
password: password1,
username: username1,
uri: ^ipv4_turn_uri
},
%{
type: :turn,
expires_at: expires_at_unix,
password: password2,
username: username2,
uri: ^ipv6_turn_uri
}
] = payload.relays
assert username1 != username2
assert password1 != password2
assert [username_expires_at_unix, username_salt] = String.split(username1, ":", parts: 2)
assert username_expires_at_unix == to_string(DateTime.to_unix(expires_at, :second))
assert DateTime.from_unix!(expires_at_unix) == DateTime.truncate(expires_at, :second)
assert is_binary(username_salt)
assert payload.resource == %{
address: resource.address,
id: resource.id,
name: resource.name,
type: :dns,
ipv4: resource.ipv4,
ipv6: resource.ipv6,
filters: [
%{protocol: :tcp, port_range_end: 80, port_range_start: 80},
%{protocol: :tcp, port_range_end: 433, port_range_start: 433},
%{protocol: :udp, port_range_start: 100, port_range_end: 200}
]
}
assert payload.client == %{
id: client.id,
peer: %{
ipv4: client.ipv4,
ipv6: client.ipv6,
persistent_keepalive: 25,
preshared_key: preshared_key,
public_key: client.public_key
},
rtc_session_description: rtc_session_description
}
assert DateTime.from_unix!(payload.expires_at) == DateTime.truncate(expires_at, :second)
end
test "pushes request_connection message with stun-only relay URLs", %{
account: account,
client: client,
global_relay: relay
} do
gateway_group = Fixtures.Gateways.create_group(%{account: account, routing: "stun_only"})
gateway = Fixtures.Gateways.create_gateway(account: account, group: gateway_group)
resource =
Fixtures.Resources.create_resource(
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
{:ok, _, socket} =
API.Gateway.Socket
|> socket("gateway:#{gateway.id}", %{
gateway: gateway,
gateway_group: gateway_group,
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test")
})
|> subscribe_and_join(API.Gateway.Channel, "gateway")
channel_pid = self()
socket_ref = make_ref()
expires_at = DateTime.utc_now() |> DateTime.add(30, :second)
preshared_key = "PSK"
rtc_session_description = "RTC_SD"
flow_id = Ecto.UUID.generate()
otel_ctx = {OpenTelemetry.Ctx.new(), OpenTelemetry.Tracer.start_span("connect")}
stamp_secret = Ecto.UUID.generate()
:ok = Domain.Relays.connect_relay(relay, stamp_secret)
send(
socket.channel_pid,
{:request_connection, {channel_pid, socket_ref},
%{
client_id: client.id,
resource_id: resource.id,
flow_id: flow_id,
authorization_expires_at: expires_at,
client_rtc_session_description: rtc_session_description,
client_preshared_key: preshared_key
}, otel_ctx}
)
assert_push "request_connection", payload
assert is_binary(payload.ref)
assert payload.flow_id == flow_id
assert payload.actor == %{id: client.actor_id}
ipv4_turn_uri = "stun:#{relay.ipv4}:#{relay.port}"
ipv6_turn_uri = "stun:[#{relay.ipv6}]:#{relay.port}"
assert [
%{
type: :stun,
uri: ^ipv4_turn_uri
},
%{
type: :stun,
uri: ^ipv6_turn_uri
}
] = payload.relays
assert payload.resource == %{
address: resource.address,
id: resource.id,
name: resource.name,
type: :dns,
ipv4: resource.ipv4,
ipv6: resource.ipv6,
filters: [
%{protocol: :tcp, port_range_end: 80, port_range_start: 80},
%{protocol: :tcp, port_range_end: 433, port_range_start: 433},
%{protocol: :udp, port_range_start: 100, port_range_end: 200}
]
}
assert payload.client == %{
id: client.id,
peer: %{
ipv4: client.ipv4,
ipv6: client.ipv6,
persistent_keepalive: 25,
preshared_key: preshared_key,
public_key: client.public_key
},
rtc_session_description: rtc_session_description
}
assert DateTime.from_unix!(payload.expires_at) == DateTime.truncate(expires_at, :second)
end
end
describe "handle_in/3 connection_ready" do

View File

@@ -16,6 +16,27 @@ defmodule Domain.Gateways do
Supervisor.init(children, strategy: :one_for_one)
end
def fetch_group_by_id(id) do
with true <- Validator.valid_uuid?(id) do
Group.Query.by_id(id)
|> Repo.fetch()
|> case do
{:ok, group} ->
group =
group
|> maybe_preload_online_status()
{:ok, group}
{:error, reason} ->
{:error, reason}
end
else
false -> {:error, :not_found}
other -> other
end
end
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
@@ -279,7 +300,8 @@ defmodule Domain.Gateways do
end
end
def list_connected_gateways_for_resource(%Resources.Resource{} = resource) do
def list_connected_gateways_for_resource(%Resources.Resource{} = resource, opts \\ []) do
{preload, _opts} = Keyword.pop(opts, :preload, [])
connected_gateways = Presence.list("gateways:#{resource.account_id}")
gateways =
@@ -293,6 +315,7 @@ defmodule Domain.Gateways do
|> Gateway.Query.by_account_id(resource.account_id)
|> Gateway.Query.by_resource_id(resource.id)
|> Repo.all()
|> Repo.preload(preload)
{:ok, gateways}
end
@@ -462,4 +485,27 @@ defmodule Domain.Gateways do
defp fetch_config! do
Domain.Config.fetch_env!(:domain, __MODULE__)
end
# Finds the most strict routing strategy for a given list of gateway groups.
def relay_strategy(gateway_groups) when is_list(gateway_groups) do
strictness = [
stun_only: 3,
self_hosted: 2,
managed: 1
]
gateway_groups
|> Enum.max_by(fn %{routing: routing} ->
Keyword.fetch!(strictness, routing)
end)
|> relay_strategy_mapping()
end
defp relay_strategy_mapping(%Group{} = group) do
case group.routing do
:stun_only -> {:managed, :stun}
:self_hosted -> {:self_hosted, :turn}
:managed -> {:managed, :turn}
end
end
end

View File

@@ -3,6 +3,7 @@ defmodule Domain.Gateways.Group do
schema "gateway_groups" do
field :name, :string
field :routing, Ecto.Enum, values: ~w[managed self_hosted stun_only]a
belongs_to :account, Domain.Accounts.Account
has_many :gateways, Domain.Gateways.Gateway, foreign_key: :group_id, where: [deleted_at: nil]

View File

@@ -3,7 +3,7 @@ defmodule Domain.Gateways.Group.Changeset do
alias Domain.{Auth, Accounts}
alias Domain.Gateways
@fields ~w[name]a
@fields ~w[name routing]a
def create(%Accounts.Account{} = account, attrs, %Auth.Subject{} = subject) do
%Gateways.Group{account: account}

View File

@@ -255,18 +255,24 @@ defmodule Domain.Relays do
end)
end
def list_connected_relays_for_resource(%Resources.Resource{} = resource) do
connected_relays =
Map.merge(
Presence.list("relays"),
Presence.list("relays:#{resource.account_id}")
)
def list_connected_relays_for_resource(%Resources.Resource{} = _resource, :managed) do
connected_relays = Presence.list("relays")
filter = &Relay.Query.public(&1)
list_relays_for_resource(connected_relays, filter)
end
def list_connected_relays_for_resource(%Resources.Resource{} = resource, :self_hosted) do
connected_relays = Presence.list("relays:#{resource.account_id}")
filter = &Relay.Query.by_account_id(&1, resource.account_id)
list_relays_for_resource(connected_relays, filter)
end
defp list_relays_for_resource(connected_relays, filter) do
relays =
connected_relays
|> Map.keys()
|> Relay.Query.by_ids()
|> Relay.Query.public_or_by_account_id(resource.account_id)
|> filter.()
|> Repo.all()
|> Enum.map(fn relay ->
%{metas: [%{secret: stamp_secret}]} = Map.get(connected_relays, relay.id)

View File

@@ -22,6 +22,14 @@ defmodule Domain.Relays.Relay.Query do
where(queryable, [relays: relays], relays.account_id == ^account_id)
end
def public(queryable \\ all()) do
where(
queryable,
[relays: relays],
is_nil(relays.account_id)
)
end
def public_or_by_account_id(queryable \\ all(), account_id) do
where(
queryable,

View File

@@ -0,0 +1,13 @@
defmodule Domain.Repo.Migrations.AddRoutingToGatewayGroup do
use Ecto.Migration
def change do
alter table(:gateway_groups) do
add(:routing, :string)
end
execute("UPDATE gateway_groups SET routing = 'managed'")
execute("ALTER TABLE gateway_groups ALTER COLUMN routing SET NOT NULL")
end
end

View File

@@ -294,6 +294,51 @@ all_group
IO.puts("")
{:ok, global_relay_group} =
Relays.create_global_group(%{
name: "fz-global-relays",
tokens: [%{}]
})
global_relay_group_token = hd(global_relay_group.tokens)
global_relay_group_token =
maybe_repo_update.(global_relay_group_token,
id: "c1038e22-0215-4977-9f6c-f65621e0008f",
hash:
"$argon2id$v=19$m=65536,t=3,p=4$XBzQrgdRFH5XhiTfWFcGWA$PTTy4D7xtahPbvGTgZLgGS8qHnfd8LJKWAnTdhB4yww",
value:
"Obnnb37dBtNQccCU-fBYu1h8NafAp0KyoOwlo2TTIy60ofokIlV60spa12G5pIG-RVKj5qwHVEh1k9n8xBcf9A"
)
IO.puts("Created global relay groups:")
IO.puts(" #{global_relay_group.name} token: #{Relays.encode_token!(global_relay_group_token)}")
IO.puts("")
{:ok, global_relay} =
Relays.upsert_relay(global_relay_group_token, %{
ipv4: {189, 172, 72, 111},
ipv6: {0, 0, 0, 0, 0, 0, 0, 1},
last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412",
last_seen_remote_ip: %Postgrex.INET{address: {189, 172, 72, 111}}
})
for i <- 1..5 do
{:ok, _global_relay} =
Relays.upsert_relay(global_relay_group_token, %{
ipv4: {189, 172, 72, 111 + i},
ipv6: {0, 0, 0, 0, 0, 0, 0, i},
last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412",
last_seen_remote_ip: %Postgrex.INET{address: {189, 172, 72, 111 + i}}
})
end
IO.puts("Created global relays:")
IO.puts(" Group #{global_relay_group.name}:")
IO.puts(" IPv4: #{global_relay.ipv4} IPv6: #{global_relay.ipv6}")
IO.puts("")
relay_group =
account
|> Relays.Group.Changeset.create(
@@ -342,7 +387,7 @@ IO.puts("")
gateway_group =
account
|> Gateways.Group.Changeset.create(
%{name: "mycro-aws-gws", tokens: [%{}]},
%{name: "mycro-aws-gws", routing: "managed", tokens: [%{}]},
admin_subject
)
|> Repo.insert!()

View File

@@ -72,6 +72,30 @@ defmodule Domain.GatewaysTest do
end
end
describe "fetch_group_by_id/1" do
test "returns error when UUID is invalid" do
assert fetch_group_by_id("foo") == {:error, :not_found}
end
test "does not return deleted groups", %{account: account} do
group =
Fixtures.Gateways.create_group(account: account)
|> Fixtures.Gateways.delete_group()
assert fetch_group_by_id(group.id) == {:error, :not_found}
end
test "returns group by id", %{account: account} do
group = Fixtures.Gateways.create_group(account: account)
assert {:ok, fetched_group} = fetch_group_by_id(group.id)
assert fetched_group.id == group.id
end
test "returns error when group does not exist" do
assert fetch_group_by_id(Ecto.UUID.generate()) == {:error, :not_found}
end
end
describe "list_groups/1" do
test "returns empty list when there are no groups", %{subject: subject} do
assert list_groups(subject) == {:ok, []}
@@ -129,7 +153,7 @@ defmodule Domain.GatewaysTest do
describe "create_group/2" do
test "returns error on empty attrs", %{subject: subject} do
assert {:error, changeset} = create_group(%{}, subject)
assert errors_on(changeset) == %{tokens: ["can't be blank"]}
assert errors_on(changeset) == %{tokens: ["can't be blank"], routing: ["can't be blank"]}
end
test "returns error on invalid attrs", %{account: account, subject: subject} do
@@ -141,18 +165,34 @@ defmodule Domain.GatewaysTest do
assert errors_on(changeset) == %{
tokens: ["can't be blank"],
name: ["should be at most 64 character(s)"]
name: ["should be at most 64 character(s)"],
routing: ["can't be blank"]
}
Fixtures.Gateways.create_group(account: account, name: "foo")
attrs = %{name: "foo", tokens: [%{}]}
attrs = %{name: "foo", tokens: [%{}], routing: "managed"}
assert {:error, changeset} = create_group(attrs, subject)
assert "has already been taken" in errors_on(changeset).name
end
test "returns error on invalid routing value", %{subject: subject} do
attrs = %{
name_prefix: "foo",
routing: "foo",
tokens: [%{}]
}
assert {:error, changeset} = create_group(attrs, subject)
assert errors_on(changeset) == %{
routing: ["is invalid"]
}
end
test "creates a group", %{subject: subject} do
attrs = %{
name: "foo",
routing: "managed",
tokens: [%{}]
}
@@ -163,6 +203,8 @@ defmodule Domain.GatewaysTest do
assert group.created_by == :identity
assert group.created_by_identity_id == subject.identity.id
assert group.routing == :managed
assert [%Gateways.Token{} = token] = group.tokens
assert token.created_by == :identity
assert token.created_by_identity_id == subject.identity.id
@@ -210,13 +252,15 @@ defmodule Domain.GatewaysTest do
group = Fixtures.Gateways.create_group(account: account)
attrs = %{
name: String.duplicate("A", 65)
name: String.duplicate("A", 65),
routing: "foo"
}
assert {:error, changeset} = update_group(group, attrs, subject)
assert errors_on(changeset) == %{
name: ["should be at most 64 character(s)"]
name: ["should be at most 64 character(s)"],
routing: ["is invalid"]
}
Fixtures.Gateways.create_group(account: account, name: "foo")
@@ -229,11 +273,13 @@ defmodule Domain.GatewaysTest do
group = Fixtures.Gateways.create_group(account: account)
attrs = %{
name: "foo"
name: "foo",
routing: "stun_only"
}
assert {:ok, group} = update_group(group, attrs, subject)
assert group.name == "foo"
assert group.routing == :stun_only
end
test "returns error when subject has no permission to manage groups", %{
@@ -962,4 +1008,35 @@ defmodule Domain.GatewaysTest do
assert authorize_gateway(Ecto.UUID.generate()) == {:error, :invalid_token}
end
end
describe "relay_strategy/1" do
test "managed strategy" do
group = Fixtures.Gateways.create_group(routing: :managed)
assert {:managed, :turn} == relay_strategy([group])
end
test "self-hosted strategy" do
group = Fixtures.Gateways.create_group(routing: :self_hosted)
assert {:self_hosted, :turn} == relay_strategy([group])
end
test "stun_only strategy" do
group = Fixtures.Gateways.create_group(routing: :stun_only)
assert {:managed, :stun} == relay_strategy([group])
end
test "strictest strategy is returned" do
managed_group = Fixtures.Gateways.create_group(routing: :managed)
self_hosted_group = Fixtures.Gateways.create_group(routing: :self_hosted)
stun_only_group = Fixtures.Gateways.create_group(routing: :stun_only)
assert {:managed, :stun} ==
relay_strategy([managed_group, self_hosted_group, stun_only_group])
assert {:self_hosted, :turn} == relay_strategy([managed_group, self_hosted_group])
assert {:managed, :stun} == relay_strategy([managed_group, stun_only_group])
assert {:managed, :stun} == relay_strategy([self_hosted_group, stun_only_group])
assert {:managed, :turn} == relay_strategy([managed_group])
end
end
end

View File

@@ -497,8 +497,17 @@ defmodule Domain.RelaysTest do
end
end
describe "list_connected_relays_for_resource/1" do
test "returns empty list when there are no online relays", %{account: account} do
describe "list_connected_relays_for_resource/2" do
test "returns empty list when there are no managed relays online", %{account: account} do
resource = Fixtures.Resources.create_resource(account: account)
group = Fixtures.Relays.create_global_group()
Fixtures.Relays.create_relay(group: group)
assert list_connected_relays_for_resource(resource, :managed) == {:ok, []}
end
test "returns empty list when there are no self-hosted relays online", %{account: account} do
resource = Fixtures.Resources.create_resource(account: account)
Fixtures.Relays.create_relay(account: account)
@@ -506,7 +515,7 @@ defmodule Domain.RelaysTest do
Fixtures.Relays.create_relay(account: account)
|> Fixtures.Relays.delete_relay()
assert list_connected_relays_for_resource(resource) == {:ok, []}
assert list_connected_relays_for_resource(resource, :self_hosted) == {:ok, []}
end
test "returns list of connected account relays", %{account: account} do
@@ -516,7 +525,7 @@ defmodule Domain.RelaysTest do
assert connect_relay(relay, stamp_secret) == :ok
assert {:ok, [connected_relay]} = list_connected_relays_for_resource(resource)
assert {:ok, [connected_relay]} = list_connected_relays_for_resource(resource, :self_hosted)
assert connected_relay.id == relay.id
assert connected_relay.stamp_secret == stamp_secret
@@ -530,7 +539,7 @@ defmodule Domain.RelaysTest do
assert connect_relay(relay, stamp_secret) == :ok
assert {:ok, [connected_relay]} = list_connected_relays_for_resource(resource)
assert {:ok, [connected_relay]} = list_connected_relays_for_resource(resource, :managed)
assert connected_relay.id == relay.id
assert connected_relay.stamp_secret == stamp_secret

View File

@@ -5,6 +5,7 @@ defmodule Domain.Fixtures.Gateways do
def group_attrs(attrs \\ %{}) do
Enum.into(attrs, %{
name: "group-#{unique_integer()}",
routing: "managed",
tokens: [%{}]
})
end

View File

@@ -0,0 +1,12 @@
defmodule Web.Sites.Components do
use Web, :component_library
def pretty_print_routing(routing) do
case routing do
:managed -> "Firezone Managed Relays"
:self_hosted -> "Self Hosted Relays"
:stun_only -> "Direct Only"
routing -> to_string(routing)
end
end
end

View File

@@ -1,5 +1,6 @@
defmodule Web.Sites.Edit do
use Web, :live_view
import Web.Sites.Components
alias Domain.Gateways
def mount(%{"id" => id}, _session, socket) do
@@ -28,12 +29,50 @@ defmodule Web.Sites.Edit do
<.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 Site"
required
/>
<.input label="Name" field={@form[:name]} placeholder="Name of this Site" required />
</div>
<div>
<p class="text-lg text-slate-900 mb-2">
Data Routing -
<a
class={[link_style(), "text-sm"]}
href="https://www.firezone.dev/kb?utm_source=product"
target="_blank"
>
Read about routing in Firezone
</a>
</p>
<div>
<div>
<.input
id="routing-option-managed"
type="radio"
field={@form[:routing]}
value="managed"
label={pretty_print_routing(:managed)}
checked={@form[:routing].value == :managed}
required
/>
<p class="ml-6 mb-4 text-sm text-slate-500 dark:text-slate-400">
Firezone will route connections through our managed Relays only if a direct connection to a Gateway is not possible.
Firezone can never decrypt the contents of your traffic.
</p>
</div>
<div>
<.input
id="routing-option-stun-only"
type="radio"
field={@form[:routing]}
value="stun_only"
label={pretty_print_routing(:stun_only)}
checked={@form[:routing].value == :stun_only}
required
/>
<p class="ml-6 mb-4 text-sm text-gray-500 dark:text-gray-400">
Firezone will enforce direct connections to all Gateways in this Site. This could cause connectivity issues in rare cases.
</p>
</div>
</div>
</div>
</div>
<.submit_button>

View File

@@ -1,5 +1,6 @@
defmodule Web.Sites.New do
use Web, :live_view
import Web.Sites.Components
alias Domain.Gateways
def mount(_params, _session, socket) do
@@ -23,12 +24,50 @@ defmodule Web.Sites.New do
<.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 Site"
required
/>
<.input label="Name" field={@form[:name]} placeholder="Name of this Site" required />
</div>
<div>
<p class="text-lg text-slate-900 mb-2">
Data Routing -
<a
class={[link_style(), "text-sm"]}
href="https://www.firezone.dev/kb?utm_source=product"
target="_blank"
>
Read about routing in Firezone
</a>
</p>
<div>
<div>
<.input
id="routing-option-managed"
type="radio"
field={@form[:routing]}
value="managed"
label={pretty_print_routing(:managed)}
checked={@form[:routing].value == :managed}
required
/>
<p class="ml-6 mb-4 text-sm text-slate-500 dark:text-slate-400">
Firezone will route connections through our managed Relays only if a direct connection to a Gateway is not possible.
Firezone can never decrypt the contents of your traffic.
</p>
</div>
<div>
<.input
id="routing-option-stun-only"
type="radio"
field={@form[:routing]}
value="stun_only"
label={pretty_print_routing(:stun_only)}
checked={@form[:routing].value == :stun_only}
required
/>
<p class="ml-6 mb-4 text-sm text-gray-500 dark:text-gray-400">
Firezone will enforce direct connections to all Gateways in this Site. This could cause connectivity issues in rare cases.
</p>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
defmodule Web.Sites.Show do
use Web, :live_view
import Web.Sites.Components
alias Domain.{Gateways, Resources}
def mount(%{"id" => id}, _session, socket) do
@@ -58,6 +59,10 @@ defmodule Web.Sites.Show do
<:label>Name</:label>
<:value><%= @group.name %></:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Data Routing</:label>
<:value><%= pretty_print_routing(@group.routing) %></:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Created</:label>
<:value>

View File

@@ -77,7 +77,8 @@ defmodule Web.Live.Sites.EditTest do
form = form(lv, "form")
assert find_inputs(form) == [
"group[name]"
"group[name]",
"group[routing]"
]
end
@@ -136,7 +137,8 @@ defmodule Web.Live.Sites.EditTest do
group: group,
conn: conn
} do
attrs = Fixtures.Gateways.group_attrs() |> Map.take([:name])
attrs = Fixtures.Gateways.group_attrs() |> Map.take([:name, :routing])
attrs = %{attrs | routing: "stun_only"}
{:ok, lv, _html} =
conn

View File

@@ -55,7 +55,8 @@ defmodule Web.Live.Sites.NewTest do
form = form(lv, "form")
assert find_inputs(form) == [
"group[name]"
"group[name]",
"group[routing]"
]
end
@@ -86,7 +87,7 @@ defmodule Web.Live.Sites.NewTest do
conn: conn
} do
other_gateway = Fixtures.Gateways.create_group(account: account)
attrs = %{name: other_gateway.name}
attrs = %{name: other_gateway.name, routing: "managed"}
{:ok, lv, _html} =
conn
@@ -106,7 +107,7 @@ defmodule Web.Live.Sites.NewTest do
identity: identity,
conn: conn
} do
attrs = Fixtures.Gateways.group_attrs() |> Map.take([:name])
attrs = Fixtures.Gateways.group_attrs() |> Map.take([:name, :routing])
{:ok, lv, _html} =
conn