From ef480e1acdb8d428de34418eb2f6ee3f4a8d5c5d Mon Sep 17 00:00:00 2001 From: bmanifold Date: Wed, 22 Nov 2023 14:59:54 -0500 Subject: [PATCH] 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. --- docker-compose.yml | 5 +- elixir/apps/api/lib/api/client/channel.ex | 15 +- elixir/apps/api/lib/api/client/views/relay.ex | 34 +-- elixir/apps/api/lib/api/gateway/channel.ex | 9 +- elixir/apps/api/lib/api/gateway/socket.ex | 4 +- .../apps/api/lib/api/gateway/views/relay.ex | 4 +- .../apps/api/test/api/client/channel_test.exs | 164 ++++++++++++- .../api/test/api/gateway/channel_test.exs | 218 +++++++++++++++++- elixir/apps/domain/lib/domain/gateways.ex | 48 +++- .../apps/domain/lib/domain/gateways/group.ex | 1 + .../lib/domain/gateways/group/changeset.ex | 2 +- elixir/apps/domain/lib/domain/relays.ex | 20 +- .../domain/lib/domain/relays/relay/query.ex | 8 + ...120042428_add_routing_to_gateway_group.exs | 13 ++ elixir/apps/domain/priv/repo/seeds.exs | 47 +++- .../apps/domain/test/domain/gateways_test.exs | 89 ++++++- .../apps/domain/test/domain/relays_test.exs | 19 +- .../domain/test/support/fixtures/gateways.ex | 1 + .../apps/web/lib/web/live/sites/components.ex | 12 + elixir/apps/web/lib/web/live/sites/edit.ex | 51 +++- elixir/apps/web/lib/web/live/sites/new.ex | 51 +++- elixir/apps/web/lib/web/live/sites/show.ex | 5 + .../web/test/web/live/sites/edit_test.exs | 6 +- .../apps/web/test/web/live/sites/new_test.exs | 7 +- 24 files changed, 767 insertions(+), 66 deletions(-) create mode 100644 elixir/apps/domain/priv/repo/migrations/20231120042428_add_routing_to_gateway_group.exs create mode 100644 elixir/apps/web/lib/web/live/sites/components.ex diff --git a/docker-compose.yml b/docker-compose.yml index 77d6feeda..9846f3b90 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/elixir/apps/api/lib/api/client/channel.ex b/elixir/apps/api/lib/api/client/channel.ex index f7f506d9c..bed53bcba 100644 --- a/elixir/apps/api/lib/api/client/channel.ex +++ b/elixir/apps/api/lib/api/client/channel.ex @@ -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 diff --git a/elixir/apps/api/lib/api/client/views/relay.ex b/elixir/apps/api/lib/api/client/views/relay.ex index 4aa516e04..6bc75a1aa 100644 --- a/elixir/apps/api/lib/api/client/views/relay.ex +++ b/elixir/apps/api/lib/api/client/views/relay.ex @@ -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}", diff --git a/elixir/apps/api/lib/api/gateway/channel.ex b/elixir/apps/api/lib/api/gateway/channel.ex index 2bbf3c63d..ccaee4ac9 100644 --- a/elixir/apps/api/lib/api/gateway/channel.ex +++ b/elixir/apps/api/lib/api/gateway/channel.ex @@ -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) diff --git a/elixir/apps/api/lib/api/gateway/socket.ex b/elixir/apps/api/lib/api/gateway/socket.ex index 4086d98e0..8a1c599b5 100644 --- a/elixir/apps/api/lib/api/gateway/socket.ex +++ b/elixir/apps/api/lib/api/gateway/socket.ex @@ -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()) diff --git a/elixir/apps/api/lib/api/gateway/views/relay.ex b/elixir/apps/api/lib/api/gateway/views/relay.ex index 49e4ab650..a27302512 100644 --- a/elixir/apps/api/lib/api/gateway/views/relay.ex +++ b/elixir/apps/api/lib/api/gateway/views/relay.ex @@ -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 diff --git a/elixir/apps/api/test/api/client/channel_test.exs b/elixir/apps/api/test/api/client/channel_test.exs index 200185c7f..5bbd8b403 100644 --- a/elixir/apps/api/test/api/client/channel_test.exs +++ b/elixir/apps/api/test/api/client/channel_test.exs @@ -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 diff --git a/elixir/apps/api/test/api/gateway/channel_test.exs b/elixir/apps/api/test/api/gateway/channel_test.exs index 9d7edd2ee..73c7c8b72 100644 --- a/elixir/apps/api/test/api/gateway/channel_test.exs +++ b/elixir/apps/api/test/api/gateway/channel_test.exs @@ -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 diff --git a/elixir/apps/domain/lib/domain/gateways.ex b/elixir/apps/domain/lib/domain/gateways.ex index 897d9f9c4..b2a2cafe8 100644 --- a/elixir/apps/domain/lib/domain/gateways.ex +++ b/elixir/apps/domain/lib/domain/gateways.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/gateways/group.ex b/elixir/apps/domain/lib/domain/gateways/group.ex index 901f6bd17..c601f0613 100644 --- a/elixir/apps/domain/lib/domain/gateways/group.ex +++ b/elixir/apps/domain/lib/domain/gateways/group.ex @@ -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] diff --git a/elixir/apps/domain/lib/domain/gateways/group/changeset.ex b/elixir/apps/domain/lib/domain/gateways/group/changeset.ex index 518b71ed8..e6ca44b54 100644 --- a/elixir/apps/domain/lib/domain/gateways/group/changeset.ex +++ b/elixir/apps/domain/lib/domain/gateways/group/changeset.ex @@ -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} diff --git a/elixir/apps/domain/lib/domain/relays.ex b/elixir/apps/domain/lib/domain/relays.ex index 64740e339..b4ac95395 100644 --- a/elixir/apps/domain/lib/domain/relays.ex +++ b/elixir/apps/domain/lib/domain/relays.ex @@ -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) diff --git a/elixir/apps/domain/lib/domain/relays/relay/query.ex b/elixir/apps/domain/lib/domain/relays/relay/query.ex index 492ee9c5a..f0dcc1c56 100644 --- a/elixir/apps/domain/lib/domain/relays/relay/query.ex +++ b/elixir/apps/domain/lib/domain/relays/relay/query.ex @@ -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, diff --git a/elixir/apps/domain/priv/repo/migrations/20231120042428_add_routing_to_gateway_group.exs b/elixir/apps/domain/priv/repo/migrations/20231120042428_add_routing_to_gateway_group.exs new file mode 100644 index 000000000..bc1d714ad --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20231120042428_add_routing_to_gateway_group.exs @@ -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 diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index e2a6eb57b..23e8abd5b 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -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!() diff --git a/elixir/apps/domain/test/domain/gateways_test.exs b/elixir/apps/domain/test/domain/gateways_test.exs index 7cd7eb352..89c57205c 100644 --- a/elixir/apps/domain/test/domain/gateways_test.exs +++ b/elixir/apps/domain/test/domain/gateways_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/relays_test.exs b/elixir/apps/domain/test/domain/relays_test.exs index 28967d660..09e0e77a2 100644 --- a/elixir/apps/domain/test/domain/relays_test.exs +++ b/elixir/apps/domain/test/domain/relays_test.exs @@ -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 diff --git a/elixir/apps/domain/test/support/fixtures/gateways.ex b/elixir/apps/domain/test/support/fixtures/gateways.ex index c0acca920..54882f34a 100644 --- a/elixir/apps/domain/test/support/fixtures/gateways.ex +++ b/elixir/apps/domain/test/support/fixtures/gateways.ex @@ -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 diff --git a/elixir/apps/web/lib/web/live/sites/components.ex b/elixir/apps/web/lib/web/live/sites/components.ex new file mode 100644 index 000000000..16d79fe4c --- /dev/null +++ b/elixir/apps/web/lib/web/live/sites/components.ex @@ -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 diff --git a/elixir/apps/web/lib/web/live/sites/edit.ex b/elixir/apps/web/lib/web/live/sites/edit.ex index 42cc73f8e..9ce7615d4 100644 --- a/elixir/apps/web/lib/web/live/sites/edit.ex +++ b/elixir/apps/web/lib/web/live/sites/edit.ex @@ -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}>
- <.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 /> +
+
+

+ Data Routing - + + Read about routing in Firezone + +

+
+
+ <.input + id="routing-option-managed" + type="radio" + field={@form[:routing]} + value="managed" + label={pretty_print_routing(:managed)} + checked={@form[:routing].value == :managed} + required + /> +

+ 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. +

+
+
+ <.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 + /> +

+ Firezone will enforce direct connections to all Gateways in this Site. This could cause connectivity issues in rare cases. +

+
+
<.submit_button> diff --git a/elixir/apps/web/lib/web/live/sites/new.ex b/elixir/apps/web/lib/web/live/sites/new.ex index 742d0d511..dd6a21b3b 100644 --- a/elixir/apps/web/lib/web/live/sites/new.ex +++ b/elixir/apps/web/lib/web/live/sites/new.ex @@ -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}>
- <.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 /> +
+
+

+ Data Routing - + + Read about routing in Firezone + +

+
+
+ <.input + id="routing-option-managed" + type="radio" + field={@form[:routing]} + value="managed" + label={pretty_print_routing(:managed)} + checked={@form[:routing].value == :managed} + required + /> +

+ 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. +

+
+
+ <.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 + /> +

+ Firezone will enforce direct connections to all Gateways in this Site. This could cause connectivity issues in rare cases. +

+
+
diff --git a/elixir/apps/web/lib/web/live/sites/show.ex b/elixir/apps/web/lib/web/live/sites/show.ex index f5b00312f..7afb90a20 100644 --- a/elixir/apps/web/lib/web/live/sites/show.ex +++ b/elixir/apps/web/lib/web/live/sites/show.ex @@ -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 <:value><%= @group.name %> + <.vertical_table_row> + <:label>Data Routing + <:value><%= pretty_print_routing(@group.routing) %> + <.vertical_table_row> <:label>Created <:value> diff --git a/elixir/apps/web/test/web/live/sites/edit_test.exs b/elixir/apps/web/test/web/live/sites/edit_test.exs index 600b4cfec..6f6ffdc8e 100644 --- a/elixir/apps/web/test/web/live/sites/edit_test.exs +++ b/elixir/apps/web/test/web/live/sites/edit_test.exs @@ -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 diff --git a/elixir/apps/web/test/web/live/sites/new_test.exs b/elixir/apps/web/test/web/live/sites/new_test.exs index 88d218b4e..c83d864df 100644 --- a/elixir/apps/web/test/web/live/sites/new_test.exs +++ b/elixir/apps/web/test/web/live/sites/new_test.exs @@ -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