From ad26e508ff7dccc1353d358ca3307c9e900e7e02 Mon Sep 17 00:00:00 2001 From: Andrew Dryga Date: Tue, 31 Oct 2023 15:01:37 -0600 Subject: [PATCH] GeoIP routing and load-balancing for traffic (#2517) --- elixir/apps/api/lib/api/client/channel.ex | 8 +- elixir/apps/api/lib/api/client/socket.ex | 14 +- elixir/apps/api/lib/api/client/views/relay.ex | 10 +- elixir/apps/api/lib/api/gateway/socket.ex | 7 + elixir/apps/api/lib/api/relay/socket.ex | 7 + elixir/apps/api/lib/api/sockets.ex | 43 ++++-- .../apps/api/test/api/client/channel_test.exs | 23 ++- .../apps/api/test/api/client/socket_test.exs | 17 ++- .../api/test/api/gateway/channel_test.exs | 10 -- .../apps/api/test/api/gateway/socket_test.exs | 11 +- .../apps/api/test/api/relay/socket_test.exs | 11 +- elixir/apps/domain/lib/domain/auth.ex | 19 +-- elixir/apps/domain/lib/domain/auth/context.ex | 8 + .../apps/domain/lib/domain/auth/identity.ex | 4 + .../lib/domain/auth/identity/changeset.ex | 11 +- .../apps/domain/lib/domain/clients/client.ex | 4 + .../lib/domain/clients/client/changeset.ex | 14 +- elixir/apps/domain/lib/domain/gateways.ex | 31 +++- .../domain/lib/domain/gateways/gateway.ex | 4 + .../lib/domain/gateways/gateway/changeset.ex | 20 ++- elixir/apps/domain/lib/domain/geo.ex | 21 +++ elixir/apps/domain/lib/domain/relays.ex | 27 +++- elixir/apps/domain/lib/domain/relays/relay.ex | 4 + .../lib/domain/relays/relay/changeset.ex | 17 ++- ...231027215407_add_geoip_location_fields.exs | 33 ++++ elixir/apps/domain/priv/repo/seeds.exs | 6 +- elixir/apps/domain/test/domain/auth_test.exs | 60 ++++---- .../apps/domain/test/domain/clients_test.exs | 24 +++ .../apps/domain/test/domain/gateways_test.exs | 129 ++++++++++++++-- elixir/apps/domain/test/domain/geo_test.exs | 20 +++ .../domain/google_cloud_platform_test.exs | 4 +- .../domain/jobs/executors/global_test.exs | 2 +- .../apps/domain/test/domain/relays_test.exs | 142 +++++++++++++++++- .../apps/domain/test/support/fixtures/auth.ex | 33 +++- .../domain/test/support/fixtures/clients.ex | 8 +- .../domain/test/support/fixtures/gateways.ex | 6 +- .../domain/test/support/fixtures/relays.ex | 6 +- elixir/apps/web/lib/web/auth.ex | 89 ++++++++++- .../web/test/support/acceptance_case/auth.ex | 24 ++- elixir/apps/web/test/support/conn_case.ex | 15 +- elixir/apps/web/test/web/auth_test.exs | 53 ++++++- .../web/controllers/auth_controller_test.exs | 23 ++- .../service_accounts/new_identity_test.exs | 11 +- .../google_workspace/connect_test.exs | 11 +- .../openid_connect/connect_test.exs | 11 +- terraform/modules/elixir-app/main.tf | 12 +- 46 files changed, 921 insertions(+), 146 deletions(-) create mode 100644 elixir/apps/domain/lib/domain/geo.ex create mode 100644 elixir/apps/domain/priv/repo/migrations/20231027215407_add_geoip_location_fields.exs create mode 100644 elixir/apps/domain/test/domain/geo_test.exs diff --git a/elixir/apps/api/lib/api/client/channel.ex b/elixir/apps/api/lib/api/client/channel.ex index ece36c7dc..f7f506d9c 100644 --- a/elixir/apps/api/lib/api/client/channel.ex +++ b/elixir/apps/api/lib/api/client/channel.ex @@ -182,7 +182,13 @@ defmodule API.Client.Channel do {: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) + location = { + socket.assigns.client.last_seen_remote_ip_location_lat, + socket.assigns.client.last_seen_remote_ip_location_lon + } + + relays = Relays.load_balance_relays(location, relays) + gateway = Gateways.load_balance_gateways(location, gateways, connected_gateway_ids) reply = {:ok, diff --git a/elixir/apps/api/lib/api/client/socket.ex b/elixir/apps/api/lib/api/client/socket.ex index 9c333a157..93954ef1f 100644 --- a/elixir/apps/api/lib/api/client/socket.ex +++ b/elixir/apps/api/lib/api/client/socket.ex @@ -23,7 +23,19 @@ defmodule API.Client.Socket do real_ip = API.Sockets.real_ip(x_headers, peer_data) - with {:ok, subject} <- Auth.sign_in(token, user_agent, real_ip), + {location_region, location_city, {location_lat, location_lon}} = + API.Sockets.load_balancer_ip_location(x_headers) + + context = %Auth.Context{ + user_agent: user_agent, + remote_ip: real_ip, + remote_ip_location_region: location_region, + remote_ip_location_city: location_city, + remote_ip_location_lat: location_lat, + remote_ip_location_lon: location_lon + } + + with {:ok, subject} <- Auth.sign_in(token, context), {:ok, client} <- Clients.upsert_client(attrs, subject) do OpenTelemetry.Tracer.set_attributes(%{ client_id: client.id, diff --git a/elixir/apps/api/lib/api/client/views/relay.ex b/elixir/apps/api/lib/api/client/views/relay.ex index aab1cbd18..4aa516e04 100644 --- a/elixir/apps/api/lib/api/client/views/relay.ex +++ b/elixir/apps/api/lib/api/client/views/relay.ex @@ -23,10 +23,12 @@ defmodule API.Client.Views.Relay do } = Relays.generate_username_and_password(relay, expires_at) [ - %{ - type: :stun, - uri: "stun:#{format_address(address)}:#{relay.port}" - }, + # 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/socket.ex b/elixir/apps/api/lib/api/gateway/socket.ex index b85bd7569..dc820c1bc 100644 --- a/elixir/apps/api/lib/api/gateway/socket.ex +++ b/elixir/apps/api/lib/api/gateway/socket.ex @@ -23,11 +23,18 @@ defmodule API.Gateway.Socket do real_ip = API.Sockets.real_ip(x_headers, peer_data) + {location_region, location_city, {location_lat, location_lon}} = + API.Sockets.load_balancer_ip_location(x_headers) + attrs = attrs |> Map.take(~w[external_id name_suffix public_key]) |> Map.put("last_seen_user_agent", user_agent) |> Map.put("last_seen_remote_ip", real_ip) + |> Map.put("last_seen_remote_ip_location_region", location_region) + |> Map.put("last_seen_remote_ip_location_city", location_city) + |> Map.put("last_seen_remote_ip_location_lat", location_lat) + |> 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 diff --git a/elixir/apps/api/lib/api/relay/socket.ex b/elixir/apps/api/lib/api/relay/socket.ex index 0b131d7f8..66a9a7050 100644 --- a/elixir/apps/api/lib/api/relay/socket.ex +++ b/elixir/apps/api/lib/api/relay/socket.ex @@ -23,11 +23,18 @@ defmodule API.Relay.Socket do real_ip = API.Sockets.real_ip(x_headers, peer_data) + {location_region, location_city, {location_lat, location_lon}} = + API.Sockets.load_balancer_ip_location(x_headers) + attrs = attrs |> Map.take(~w[ipv4 ipv6]) |> Map.put("last_seen_user_agent", user_agent) |> Map.put("last_seen_remote_ip", real_ip) + |> Map.put("last_seen_remote_ip_location_region", location_region) + |> Map.put("last_seen_remote_ip_location_city", location_city) + |> Map.put("last_seen_remote_ip_location_lat", location_lat) + |> Map.put("last_seen_remote_ip_location_lon", location_lon) with {:ok, token} <- Relays.authorize_relay(encrypted_secret), {:ok, relay} <- Relays.upsert_relay(token, attrs) do diff --git a/elixir/apps/api/lib/api/sockets.ex b/elixir/apps/api/lib/api/sockets.ex index 0c08d2ad2..5b3f7dd80 100644 --- a/elixir/apps/api/lib/api/sockets.ex +++ b/elixir/apps/api/lib/api/sockets.ex @@ -40,22 +40,35 @@ defmodule API.Sockets do real_ip || peer_data.address end - # if Mix.env() == :test do - # defp maybe_allow_sandbox_access(%{user_agent: user_agent}) do - # %{owner: owner_pid, repo: repos} = - # metadata = Phoenix.Ecto.SQL.Sandbox.decode_metadata(user_agent) + def load_balancer_ip_location(x_headers) do + location_region = + case API.Sockets.get_header(x_headers, "x-geo-location-region") do + {"x-geo-location-region", location_region} -> location_region + _other -> nil + end - # repos - # |> List.wrap() - # |> Enum.each(fn repo -> - # Ecto.Adapters.SQL.Sandbox.allow(repo, owner_pid, self()) - # end) + location_city = + case API.Sockets.get_header(x_headers, "x-geo-location-city") do + {"x-geo-location-city", location_city} -> location_city + _other -> nil + end - # {:ok, metadata} - # end + {location_lat, location_lon} = + case API.Sockets.get_header(x_headers, "x-geo-location-coordinates") do + {"x-geo-location-coordinates", coordinates} -> + [lat, lon] = String.split(coordinates, ",", parts: 2) + lat = String.to_float(lat) + lon = String.to_float(lon) + {lat, lon} - # defp maybe_allow_sandbox_access(_), do: {:ok, %{}} - # else - # defp maybe_allow_sandbox_access(_), do: {:ok, %{}} - # end + _other -> + {nil, nil} + end + + {location_region, location_city, {location_lat, location_lon}} + end + + def get_header(x_headers, key) do + List.keyfind(x_headers, key, 0) + 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 7cfe36862..200185c7f 100644 --- a/elixir/apps/api/test/api/client/channel_test.exs +++ b/elixir/apps/api/test/api/client/channel_test.exs @@ -258,7 +258,15 @@ defmodule API.Client.ChannelTest do } do # Online Relay global_relay_group = Fixtures.Relays.create_global_group() - global_relay = Fixtures.Relays.create_relay(group: global_relay_group, ipv6: nil) + + 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 + ) + relay = Fixtures.Relays.create_relay(account: account) stamp_secret = Ecto.UUID.generate() :ok = Domain.Relays.connect_relay(relay, stamp_secret) @@ -279,16 +287,10 @@ defmodule API.Client.ChannelTest do 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}" - ipv6_stun_uri = "stun:[#{relay.ipv6}]:#{relay.port}" ipv6_turn_uri = "turn:[#{relay.ipv6}]:#{relay.port}" assert [ - %{ - type: :stun, - uri: ^ipv4_stun_uri - }, %{ type: :turn, expires_at: expires_at_unix, @@ -296,10 +298,6 @@ defmodule API.Client.ChannelTest do username: username1, uri: ^ipv4_turn_uri }, - %{ - type: :stun, - uri: ^ipv6_stun_uri - }, %{ type: :turn, expires_at: expires_at_unix, @@ -320,9 +318,10 @@ defmodule API.Client.ChannelTest do assert is_binary(salt) :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) assert_reply ref, :ok, %{relays: relays} - assert length(relays) == 6 + assert length(relays) == 3 end end diff --git a/elixir/apps/api/test/api/client/socket_test.exs b/elixir/apps/api/test/api/client/socket_test.exs index 6df42d1b7..20c1c1898 100644 --- a/elixir/apps/api/test/api/client/socket_test.exs +++ b/elixir/apps/api/test/api/client/socket_test.exs @@ -4,10 +4,19 @@ defmodule API.Client.SocketTest do alias API.Client.Socket alias Domain.Auth + @geo_headers [ + {"x-geo-location-region", "Ukraine"}, + {"x-geo-location-city", "Kyiv"}, + {"x-geo-location-coordinates", "50.4333,30.5167"} + ] + @connect_info %{ user_agent: "iOS/12.7 (iPhone) connlib/0.1.1", peer_data: %{address: {189, 172, 73, 001}}, - x_headers: [{"x-forwarded-for", "189.172.73.153"}], + x_headers: + [ + {"x-forwarded-for", "189.172.73.153"} + ] ++ @geo_headers, trace_context_headers: [] } @@ -34,6 +43,10 @@ defmodule API.Client.SocketTest do assert client.public_key == attrs["public_key"] assert client.last_seen_user_agent == subject.context.user_agent assert client.last_seen_remote_ip.address == subject.context.remote_ip + assert client.last_seen_remote_ip_location_region == "Ukraine" + assert client.last_seen_remote_ip_location_city == "Kyiv" + assert client.last_seen_remote_ip_location_lat == 50.4333 + assert client.last_seen_remote_ip_location_lon == 30.5167 assert client.last_seen_version == "0.7.412" end @@ -82,7 +95,7 @@ defmodule API.Client.SocketTest do %{ user_agent: subject.context.user_agent, peer_data: %{address: subject.context.remote_ip}, - x_headers: [], + x_headers: @geo_headers, trace_context_headers: [] } end diff --git a/elixir/apps/api/test/api/gateway/channel_test.exs b/elixir/apps/api/test/api/gateway/channel_test.exs index 960480acf..9d7edd2ee 100644 --- a/elixir/apps/api/test/api/gateway/channel_test.exs +++ b/elixir/apps/api/test/api/gateway/channel_test.exs @@ -171,16 +171,10 @@ defmodule API.Gateway.ChannelTest do assert payload.flow_id == flow_id assert payload.actor == %{id: client.actor_id} - ipv4_stun_uri = "stun:#{relay.ipv4}:#{relay.port}" ipv4_turn_uri = "turn:#{relay.ipv4}:#{relay.port}" - ipv6_stun_uri = "stun:[#{relay.ipv6}]:#{relay.port}" ipv6_turn_uri = "turn:[#{relay.ipv6}]:#{relay.port}" assert [ - %{ - type: :stun, - uri: ^ipv4_stun_uri - }, %{ type: :turn, expires_at: expires_at_unix, @@ -188,10 +182,6 @@ defmodule API.Gateway.ChannelTest do username: username1, uri: ^ipv4_turn_uri }, - %{ - type: :stun, - uri: ^ipv6_stun_uri - }, %{ type: :turn, expires_at: expires_at_unix, diff --git a/elixir/apps/api/test/api/gateway/socket_test.exs b/elixir/apps/api/test/api/gateway/socket_test.exs index 4f924fe09..bcedaccc7 100644 --- a/elixir/apps/api/test/api/gateway/socket_test.exs +++ b/elixir/apps/api/test/api/gateway/socket_test.exs @@ -9,7 +9,12 @@ defmodule API.Gateway.SocketTest do @connect_info %{ user_agent: "iOS/12.7 (iPhone) connlib/#{@connlib_version}", peer_data: %{address: {189, 172, 73, 001}}, - x_headers: [{"x-forwarded-for", "189.172.73.153"}], + x_headers: [ + {"x-forwarded-for", "189.172.73.153"}, + {"x-geo-location-region", "Ukraine"}, + {"x-geo-location-city", "Kyiv"}, + {"x-geo-location-coordinates", "50.4333,30.5167"} + ], trace_context_headers: [] } @@ -31,6 +36,10 @@ defmodule API.Gateway.SocketTest do assert gateway.public_key == attrs["public_key"] assert gateway.last_seen_user_agent == @connect_info.user_agent assert gateway.last_seen_remote_ip.address == {189, 172, 73, 153} + assert gateway.last_seen_remote_ip_location_region == "Ukraine" + assert gateway.last_seen_remote_ip_location_city == "Kyiv" + assert gateway.last_seen_remote_ip_location_lat == 50.4333 + assert gateway.last_seen_remote_ip_location_lon == 30.5167 assert gateway.last_seen_version == @connlib_version end diff --git a/elixir/apps/api/test/api/relay/socket_test.exs b/elixir/apps/api/test/api/relay/socket_test.exs index cc556eb46..5a597cc81 100644 --- a/elixir/apps/api/test/api/relay/socket_test.exs +++ b/elixir/apps/api/test/api/relay/socket_test.exs @@ -9,7 +9,12 @@ defmodule API.Relay.SocketTest do @connect_info %{ user_agent: "iOS/12.7 (iPhone) connlib/#{@connlib_version}", peer_data: %{address: {189, 172, 73, 001}}, - x_headers: [{"x-forwarded-for", "189.172.73.153"}], + x_headers: [ + {"x-forwarded-for", "189.172.73.153"}, + {"x-geo-location-region", "Ukraine"}, + {"x-geo-location-city", "Kyiv"}, + {"x-geo-location-coordinates", "50.4333,30.5167"} + ], trace_context_headers: [] } @@ -31,6 +36,10 @@ defmodule API.Relay.SocketTest do assert relay.ipv6.address == attrs["ipv6"] assert relay.last_seen_user_agent == @connect_info.user_agent assert relay.last_seen_remote_ip.address == {189, 172, 73, 153} + assert relay.last_seen_remote_ip_location_region == "Ukraine" + assert relay.last_seen_remote_ip_location_city == "Kyiv" + assert relay.last_seen_remote_ip_location_lat == 50.4333 + assert relay.last_seen_remote_ip_location_lon == 30.5167 assert relay.last_seen_version == @connlib_version end diff --git a/elixir/apps/domain/lib/domain/auth.ex b/elixir/apps/domain/lib/domain/auth.ex index d799abc46..95a2ef0e0 100644 --- a/elixir/apps/domain/lib/domain/auth.ex +++ b/elixir/apps/domain/lib/domain/auth.ex @@ -419,7 +419,8 @@ defmodule Domain.Auth do with {:ok, identity} <- Repo.fetch(identity_queryable), {:ok, identity, expires_at} <- Adapters.verify_secret(provider, identity, secret) do - {:ok, build_subject(identity, expires_at, user_agent, remote_ip)} + context = %Context{remote_ip: remote_ip, user_agent: user_agent} + {:ok, build_subject(identity, expires_at, context)} else {:error, :not_found} -> {:error, :unauthorized} {:error, :invalid_secret} -> {:error, :unauthorized} @@ -429,7 +430,8 @@ defmodule Domain.Auth do def sign_in(%Provider{} = provider, payload, user_agent, remote_ip) do with {:ok, identity, expires_at} <- Adapters.verify_and_update_identity(provider, payload) do - {:ok, build_subject(identity, expires_at, user_agent, remote_ip)} + context = %Context{remote_ip: remote_ip, user_agent: user_agent} + {:ok, build_subject(identity, expires_at, context)} else {:error, :not_found} -> {:error, :unauthorized} {:error, :invalid} -> {:error, :unauthorized} @@ -437,9 +439,9 @@ defmodule Domain.Auth do end end - def sign_in(token, user_agent, remote_ip) when is_binary(token) do - with {:ok, identity, expires_at} <- verify_token(token, user_agent, remote_ip) do - {:ok, build_subject(identity, expires_at, user_agent, remote_ip)} + def sign_in(token, %Context{} = context) when is_binary(token) do + with {:ok, identity, expires_at} <- verify_token(token, context.user_agent, context.remote_ip) do + {:ok, build_subject(identity, expires_at, context)} else {:error, :not_found} -> {:error, :unauthorized} {:error, :invalid_token} -> {:error, :unauthorized} @@ -468,11 +470,10 @@ defmodule Domain.Auth do end @doc false - def build_subject(%Identity{} = identity, expires_at, user_agent, remote_ip) - when is_binary(user_agent) and is_tuple(remote_ip) do + def build_subject(%Identity{} = identity, expires_at, context) do identity = identity - |> Identity.Changeset.sign_in_identity(user_agent, remote_ip) + |> Identity.Changeset.sign_in_identity(context) |> Repo.update!() identity_with_preloads = Repo.preload(identity, [:account, :actor]) @@ -484,7 +485,7 @@ defmodule Domain.Auth do permissions: permissions, account: identity_with_preloads.account, expires_at: build_subject_expires_at(identity_with_preloads.actor, expires_at), - context: %Context{remote_ip: remote_ip, user_agent: user_agent} + context: context } end diff --git a/elixir/apps/domain/lib/domain/auth/context.ex b/elixir/apps/domain/lib/domain/auth/context.ex index bc848c75b..c9cf56aa7 100644 --- a/elixir/apps/domain/lib/domain/auth/context.ex +++ b/elixir/apps/domain/lib/domain/auth/context.ex @@ -7,9 +7,17 @@ defmodule Domain.Auth.Context do """ @type t :: %__MODULE__{ remote_ip: :inet.ip_address(), + remote_ip_location_region: String.t(), + remote_ip_location_city: String.t(), + remote_ip_location_lat: float(), + remote_ip_location_lon: float(), user_agent: String.t() } defstruct remote_ip: nil, + remote_ip_location_region: nil, + remote_ip_location_city: nil, + remote_ip_location_lat: nil, + remote_ip_location_lon: nil, user_agent: nil end diff --git a/elixir/apps/domain/lib/domain/auth/identity.ex b/elixir/apps/domain/lib/domain/auth/identity.ex index 20153ab4c..02d474520 100644 --- a/elixir/apps/domain/lib/domain/auth/identity.ex +++ b/elixir/apps/domain/lib/domain/auth/identity.ex @@ -11,6 +11,10 @@ defmodule Domain.Auth.Identity do field :last_seen_user_agent, :string field :last_seen_remote_ip, Domain.Types.IP + field :last_seen_remote_ip_location_region, :string + field :last_seen_remote_ip_location_city, :string + field :last_seen_remote_ip_location_lat, :float + field :last_seen_remote_ip_location_lon, :float field :last_seen_at, :utc_datetime_usec belongs_to :account, Domain.Accounts.Account diff --git a/elixir/apps/domain/lib/domain/auth/identity/changeset.ex b/elixir/apps/domain/lib/domain/auth/identity/changeset.ex index b57e714ca..f84bce443 100644 --- a/elixir/apps/domain/lib/domain/auth/identity/changeset.ex +++ b/elixir/apps/domain/lib/domain/auth/identity/changeset.ex @@ -74,12 +74,17 @@ defmodule Domain.Auth.Identity.Changeset do |> put_change(:provider_virtual_state, virtual_state) end - def sign_in_identity(identity_or_changeset, user_agent, remote_ip) do + def sign_in_identity(identity_or_changeset, context) do identity_or_changeset |> change() - |> put_change(:last_seen_user_agent, user_agent) - |> put_change(:last_seen_remote_ip, %Postgrex.INET{address: remote_ip}) + |> put_change(:last_seen_user_agent, context.user_agent) + |> put_change(:last_seen_remote_ip, %Postgrex.INET{address: context.remote_ip}) + |> put_change(:last_seen_remote_ip_location_region, context.remote_ip_location_region) + |> put_change(:last_seen_remote_ip_location_city, context.remote_ip_location_city) + |> put_change(:last_seen_remote_ip_location_lat, context.remote_ip_location_lat) + |> put_change(:last_seen_remote_ip_location_lon, context.remote_ip_location_lon) |> put_change(:last_seen_at, DateTime.utc_now()) + |> validate_required(~w[last_seen_user_agent last_seen_remote_ip]a) end def delete_identity(%Identity{} = identity) do diff --git a/elixir/apps/domain/lib/domain/clients/client.ex b/elixir/apps/domain/lib/domain/clients/client.ex index 102eb1d85..180487d1b 100644 --- a/elixir/apps/domain/lib/domain/clients/client.ex +++ b/elixir/apps/domain/lib/domain/clients/client.ex @@ -13,6 +13,10 @@ defmodule Domain.Clients.Client do field :last_seen_user_agent, :string field :last_seen_remote_ip, Domain.Types.IP + field :last_seen_remote_ip_location_region, :string + field :last_seen_remote_ip_location_city, :string + field :last_seen_remote_ip_location_lat, :float + field :last_seen_remote_ip_location_lon, :float field :last_seen_version, :string field :last_seen_at, :utc_datetime_usec diff --git a/elixir/apps/domain/lib/domain/clients/client/changeset.ex b/elixir/apps/domain/lib/domain/clients/client/changeset.ex index 00742850b..48a9e3a22 100644 --- a/elixir/apps/domain/lib/domain/clients/client/changeset.ex +++ b/elixir/apps/domain/lib/domain/clients/client/changeset.ex @@ -5,8 +5,14 @@ defmodule Domain.Clients.Client.Changeset do @upsert_fields ~w[external_id name public_key]a @conflict_replace_fields ~w[public_key - last_seen_user_agent last_seen_remote_ip - last_seen_version last_seen_at + last_seen_user_agent + last_seen_remote_ip + last_seen_remote_ip_location_region + last_seen_remote_ip_location_city + last_seen_remote_ip_location_lat + last_seen_remote_ip_location_lon + last_seen_version + last_seen_at updated_at]a @update_fields ~w[name]a @required_fields @upsert_fields @@ -28,6 +34,10 @@ defmodule Domain.Clients.Client.Changeset do |> put_change(:account_id, identity.account_id) |> put_change(:last_seen_user_agent, context.user_agent) |> put_change(:last_seen_remote_ip, %Postgrex.INET{address: context.remote_ip}) + |> put_change(:last_seen_remote_ip_location_region, context.remote_ip_location_region) + |> put_change(:last_seen_remote_ip_location_city, context.remote_ip_location_city) + |> put_change(:last_seen_remote_ip_location_lat, context.remote_ip_location_lat) + |> put_change(:last_seen_remote_ip_location_lon, context.remote_ip_location_lon) |> changeset() |> validate_required(@required_fields) |> validate_base64(:public_key) diff --git a/elixir/apps/domain/lib/domain/gateways.ex b/elixir/apps/domain/lib/domain/gateways.ex index 056f62cb9..414e870ef 100644 --- a/elixir/apps/domain/lib/domain/gateways.ex +++ b/elixir/apps/domain/lib/domain/gateways.ex @@ -1,6 +1,6 @@ defmodule Domain.Gateways do use Supervisor - alias Domain.{Repo, Auth, Validator} + alias Domain.{Repo, Auth, Validator, Geo} alias Domain.{Accounts, Resources} alias Domain.Gateways.{Authorizer, Gateway, Group, Token, Presence} @@ -335,16 +335,37 @@ defmodule Domain.Gateways do end end - def load_balance_gateways(gateways) do + def load_balance_gateways({_lat, _lon}, []) do + nil + end + + def load_balance_gateways({lat, lon}, gateways) when is_nil(lat) or is_nil(lon) do Enum.random(gateways) end - def load_balance_gateways(gateways, preferred_gateway_ids) do + def load_balance_gateways({lat, lon}, gateways) do + gateways + # This allows to group gateways that are running at the same location so + # we are using at least 2 locations to build ICE candidates + |> Enum.group_by(fn gateway -> + {gateway.last_seen_remote_ip_location_lat, gateway.last_seen_remote_ip_location_lon} + end) + |> Enum.map(fn {{gateway_lat, gateway_lon}, gateway} -> + distance = Geo.distance({lat, lon}, {gateway_lat, gateway_lon}) + {distance, gateway} + end) + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.at(0) + |> elem(1) + |> Enum.random() + end + + def load_balance_gateways({lat, lon}, 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) + [] -> load_balance_gateways({lat, lon}, gateways) + preferred_gateways -> load_balance_gateways({lat, lon}, preferred_gateways) end end diff --git a/elixir/apps/domain/lib/domain/gateways/gateway.ex b/elixir/apps/domain/lib/domain/gateways/gateway.ex index 4471d8fdf..2831b3f28 100644 --- a/elixir/apps/domain/lib/domain/gateways/gateway.ex +++ b/elixir/apps/domain/lib/domain/gateways/gateway.ex @@ -14,6 +14,10 @@ defmodule Domain.Gateways.Gateway do field :last_seen_user_agent, :string field :last_seen_remote_ip, Domain.Types.IP + field :last_seen_remote_ip_location_region, :string + field :last_seen_remote_ip_location_city, :string + field :last_seen_remote_ip_location_lat, :float + field :last_seen_remote_ip_location_lon, :float field :last_seen_version, :string field :last_seen_at, :utc_datetime_usec diff --git a/elixir/apps/domain/lib/domain/gateways/gateway/changeset.ex b/elixir/apps/domain/lib/domain/gateways/gateway/changeset.ex index b6b7d1b31..029adf9e1 100644 --- a/elixir/apps/domain/lib/domain/gateways/gateway/changeset.ex +++ b/elixir/apps/domain/lib/domain/gateways/gateway/changeset.ex @@ -4,13 +4,25 @@ defmodule Domain.Gateways.Gateway.Changeset do alias Domain.Gateways @upsert_fields ~w[external_id name_suffix public_key - last_seen_user_agent last_seen_remote_ip]a + last_seen_user_agent + last_seen_remote_ip + last_seen_remote_ip_location_region + last_seen_remote_ip_location_city + last_seen_remote_ip_location_lat + last_seen_remote_ip_location_lon]a @conflict_replace_fields ~w[public_key - last_seen_user_agent last_seen_remote_ip - last_seen_version last_seen_at + last_seen_user_agent + last_seen_remote_ip + last_seen_remote_ip_location_region + last_seen_remote_ip_location_city + last_seen_remote_ip_location_lat + last_seen_remote_ip_location_lon + last_seen_version + last_seen_at updated_at]a @update_fields ~w[name_suffix]a - @required_fields @upsert_fields + @required_fields ~w[external_id name_suffix public_key + last_seen_user_agent last_seen_remote_ip]a # WireGuard base64-encoded string length @key_length 44 diff --git a/elixir/apps/domain/lib/domain/geo.ex b/elixir/apps/domain/lib/domain/geo.ex new file mode 100644 index 000000000..f65ab7d1e --- /dev/null +++ b/elixir/apps/domain/lib/domain/geo.ex @@ -0,0 +1,21 @@ +defmodule Domain.Geo do + @radius_of_earth_km 6371.0 + + def distance({lat1, lon1}, {lat2, lon2}) do + d_lat = degrees_to_radians(lat2 - lat1) + d_lon = degrees_to_radians(lon2 - lon1) + + a = + :math.sin(d_lat / 2) * :math.sin(d_lat / 2) + + :math.cos(degrees_to_radians(lat1)) * :math.cos(degrees_to_radians(lat2)) * + :math.sin(d_lon / 2) * :math.sin(d_lon / 2) + + c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a)) + + @radius_of_earth_km * c + end + + defp degrees_to_radians(deg) do + deg * :math.pi() / 180 + end +end diff --git a/elixir/apps/domain/lib/domain/relays.ex b/elixir/apps/domain/lib/domain/relays.ex index 24630d158..635f65560 100644 --- a/elixir/apps/domain/lib/domain/relays.ex +++ b/elixir/apps/domain/lib/domain/relays.ex @@ -1,6 +1,6 @@ defmodule Domain.Relays do use Supervisor - alias Domain.{Repo, Auth, Validator} + alias Domain.{Repo, Auth, Validator, Geo} alias Domain.{Accounts, Resources} alias Domain.Relays.{Authorizer, Relay, Group, Token, Presence} @@ -313,6 +313,31 @@ defmodule Domain.Relays do end end + @doc """ + Selects 3 nearest relays to the given location and then picks one of them randomly. + """ + def load_balance_relays({lat, lon}, relays) when is_nil(lat) or is_nil(lon) do + relays + |> Enum.shuffle() + |> Enum.take(2) + end + + def load_balance_relays({lat, lon}, relays) do + relays + # This allows to group relays that are running at the same location so + # we are using at least 2 locations to build ICE candidates + |> Enum.group_by(fn relay -> + {relay.last_seen_remote_ip_location_lat, relay.last_seen_remote_ip_location_lon} + end) + |> Enum.map(fn {{relay_lat, relay_lon}, relay} -> + distance = Geo.distance({lat, lon}, {relay_lat, relay_lon}) + {distance, relay} + end) + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.take(2) + |> Enum.map(&Enum.random(elem(&1, 1))) + end + def encode_token!(%Token{value: value} = token) when not is_nil(value) do body = {token.id, token.value} config = fetch_config!() diff --git a/elixir/apps/domain/lib/domain/relays/relay.ex b/elixir/apps/domain/lib/domain/relays/relay.ex index cd436315f..8cc4ac228 100644 --- a/elixir/apps/domain/lib/domain/relays/relay.ex +++ b/elixir/apps/domain/lib/domain/relays/relay.ex @@ -9,6 +9,10 @@ defmodule Domain.Relays.Relay do field :last_seen_user_agent, :string field :last_seen_remote_ip, Domain.Types.IP + field :last_seen_remote_ip_location_region, :string + field :last_seen_remote_ip_location_city, :string + field :last_seen_remote_ip_location_lat, :float + field :last_seen_remote_ip_location_lon, :float field :last_seen_version, :string field :last_seen_at, :utc_datetime_usec diff --git a/elixir/apps/domain/lib/domain/relays/relay/changeset.ex b/elixir/apps/domain/lib/domain/relays/relay/changeset.ex index b7388c741..34a456f0e 100644 --- a/elixir/apps/domain/lib/domain/relays/relay/changeset.ex +++ b/elixir/apps/domain/lib/domain/relays/relay/changeset.ex @@ -4,10 +4,21 @@ defmodule Domain.Relays.Relay.Changeset do alias Domain.Relays @upsert_fields ~w[ipv4 ipv6 port - last_seen_user_agent last_seen_remote_ip]a + last_seen_user_agent + last_seen_remote_ip + last_seen_remote_ip_location_region + last_seen_remote_ip_location_city + last_seen_remote_ip_location_lat + last_seen_remote_ip_location_lon]a @conflict_replace_fields ~w[ipv4 ipv6 port - last_seen_user_agent last_seen_remote_ip - last_seen_version last_seen_at + last_seen_user_agent + last_seen_remote_ip + last_seen_remote_ip_location_region + last_seen_remote_ip_location_city + last_seen_remote_ip_location_lat + last_seen_remote_ip_location_lon + last_seen_version + last_seen_at updated_at]a def upsert_conflict_target(%{account_id: nil}) do diff --git a/elixir/apps/domain/priv/repo/migrations/20231027215407_add_geoip_location_fields.exs b/elixir/apps/domain/priv/repo/migrations/20231027215407_add_geoip_location_fields.exs new file mode 100644 index 000000000..be9a69eed --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20231027215407_add_geoip_location_fields.exs @@ -0,0 +1,33 @@ +defmodule Domain.Repo.Migrations.AddGeoipLocationFields do + use Ecto.Migration + + def change do + alter table(:clients) do + add(:last_seen_remote_ip_location_region, :text) + add(:last_seen_remote_ip_location_city, :text) + add(:last_seen_remote_ip_location_lat, :float) + add(:last_seen_remote_ip_location_lon, :float) + end + + alter table(:relays) do + add(:last_seen_remote_ip_location_region, :text) + add(:last_seen_remote_ip_location_city, :text) + add(:last_seen_remote_ip_location_lat, :float) + add(:last_seen_remote_ip_location_lon, :float) + end + + alter table(:gateways) do + add(:last_seen_remote_ip_location_region, :text) + add(:last_seen_remote_ip_location_city, :text) + add(:last_seen_remote_ip_location_lat, :float) + add(:last_seen_remote_ip_location_lon, :float) + end + + alter table(:auth_identities) do + add(:last_seen_remote_ip_location_region, :text) + add(:last_seen_remote_ip_location_city, :text) + add(:last_seen_remote_ip_location_lat, :float) + add(:last_seen_remote_ip_location_lon, :float) + end + end +end diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index 8b2ab74d9..9d686a6bc 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -190,16 +190,14 @@ unprivileged_subject = Auth.build_subject( unprivileged_actor_userpass_identity, DateTime.utc_now() |> DateTime.add(365, :day), - "Debian/11.0.0 connlib/0.1.0", - {172, 28, 0, 100} + %Auth.Context{user_agent: "Debian/11.0.0 connlib/0.1.0", remote_ip: {172, 28, 0, 100}} ) admin_subject = Auth.build_subject( admin_actor_email_identity, nil, - "iOS/12.5 (iPhone) connlib/0.7.412", - {100, 64, 100, 58} + %Auth.Context{user_agent: "iOS/12.5 (iPhone) connlib/0.7.412", remote_ip: {100, 64, 100, 58}} ) IO.puts("Created users: ") diff --git a/elixir/apps/domain/test/domain/auth_test.exs b/elixir/apps/domain/test/domain/auth_test.exs index 0d6b23a3a..992dbeda1 100644 --- a/elixir/apps/domain/test/domain/auth_test.exs +++ b/elixir/apps/domain/test/domain/auth_test.exs @@ -2326,7 +2326,7 @@ defmodule Domain.AuthTest do end end - describe "sign_in/3" do + describe "sign_in/2" do setup do account = Fixtures.Accounts.create_account() Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark) @@ -2350,45 +2350,51 @@ defmodule Domain.AuthTest do identity: identity, subject: subject, user_agent: user_agent, - remote_ip: remote_ip + remote_ip: remote_ip, + context: %Auth.Context{ + remote_ip: remote_ip, + remote_ip_location_region: "UA", + remote_ip_location_city: "Kyiv", + remote_ip_location_lat: 50.4501, + remote_ip_location_lon: 30.5234, + user_agent: user_agent + } } end - test "returns error when token is invalid", %{user_agent: user_agent, remote_ip: remote_ip} do - assert sign_in(Ecto.UUID.generate(), user_agent, remote_ip) == + test "returns error when token is invalid", %{context: context} do + assert sign_in(Ecto.UUID.generate(), context) == {:error, :unauthorized} end test "returns subject on success for session token", %{ subject: subject, - user_agent: user_agent, - remote_ip: remote_ip + context: context } do {:ok, token} = create_session_token_from_subject(subject) - assert {:ok, reconstructed_subject} = sign_in(token, user_agent, remote_ip) + assert {:ok, reconstructed_subject} = sign_in(token, context) assert reconstructed_subject.identity.id == subject.identity.id assert reconstructed_subject.actor.id == subject.actor.id assert reconstructed_subject.account.id == subject.account.id assert reconstructed_subject.permissions == subject.permissions - assert reconstructed_subject.context == subject.context + assert reconstructed_subject.context.remote_ip == subject.context.remote_ip + assert reconstructed_subject.context.user_agent == subject.context.user_agent assert DateTime.diff(reconstructed_subject.expires_at, subject.expires_at) <= 1 end test "returns subject on success for client token", %{ subject: subject, - user_agent: user_agent + context: context } do {:ok, token} = create_client_token_from_subject(subject) - <> = - <> - # Client sessions are not binded to a specific user agent or remote ip - remote_ip = {a, b, c, d} - user_agent = user_agent <> "+b1" + remote_ip = Domain.Fixture.unique_ipv4() + user_agent = context.user_agent <> "+b1" + context = %{context | remote_ip: remote_ip, user_agent: user_agent} - assert {:ok, reconstructed_subject} = sign_in(token, user_agent, remote_ip) + assert {:ok, reconstructed_subject} = sign_in(token, context) assert reconstructed_subject.identity.id == subject.identity.id assert reconstructed_subject.actor.id == subject.actor.id @@ -2402,8 +2408,7 @@ defmodule Domain.AuthTest do test "returns subject on success for service account token", %{ account: account, - user_agent: user_agent, - remote_ip: remote_ip, + context: context, subject: subject } do one_day = DateTime.utc_now() |> DateTime.add(1, :day) @@ -2413,8 +2418,8 @@ defmodule Domain.AuthTest do Fixtures.Auth.create_identity( account: account, provider: provider, - user_agent: user_agent, - remote_ip: remote_ip, + user_agent: context.user_agent, + remote_ip: context.remote_ip, provider_virtual_state: %{ "expires_at" => one_day } @@ -2422,24 +2427,24 @@ defmodule Domain.AuthTest do {:ok, token} = create_access_token_for_identity(identity) - assert {:ok, reconstructed_subject} = sign_in(token, user_agent, remote_ip) + assert {:ok, reconstructed_subject} = sign_in(token, context) assert reconstructed_subject.identity.id == identity.id assert reconstructed_subject.actor.id == identity.actor_id assert reconstructed_subject.account.id == identity.account_id assert reconstructed_subject.permissions == subject.permissions - assert reconstructed_subject.context == subject.context + assert reconstructed_subject.context.remote_ip == subject.context.remote_ip + assert reconstructed_subject.context.user_agent == subject.context.user_agent assert DateTime.diff(reconstructed_subject.expires_at, one_day) <= 1 end test "updates last signed in fields for identity on success", %{ identity: identity, subject: subject, - user_agent: user_agent, - remote_ip: remote_ip + context: context } do {:ok, token} = create_session_token_from_subject(subject) - assert {:ok, _subject} = sign_in(token, user_agent, remote_ip) + assert {:ok, _subject} = sign_in(token, context) assert updated_identity = Repo.one(Auth.Identity) assert updated_identity.last_seen_at != identity.last_seen_at @@ -2463,19 +2468,18 @@ defmodule Domain.AuthTest do # } do # user_agent = "iOS/12.6 (iPhone) connlib/0.7.412" # {:ok, token} = create_session_token_from_subject(subject) - # assert sign_in(token, user_agent, remote_ip) == {:error, :unauthorized} + # assert sign_in(token, context) == {:error, :unauthorized} # end test "returns error when token is created for a deleted identity", %{ identity: identity, subject: subject, - user_agent: user_agent, - remote_ip: remote_ip + context: context } do {:ok, _identity} = delete_identity(identity, subject) {:ok, token} = create_session_token_from_subject(subject) - assert sign_in(token, user_agent, remote_ip) == {:error, :unauthorized} + assert sign_in(token, context) == {:error, :unauthorized} end end diff --git a/elixir/apps/domain/test/domain/clients_test.exs b/elixir/apps/domain/test/domain/clients_test.exs index 2f8d3dd6d..04a55ee8f 100644 --- a/elixir/apps/domain/test/domain/clients_test.exs +++ b/elixir/apps/domain/test/domain/clients_test.exs @@ -337,6 +337,14 @@ defmodule Domain.ClientsTest do refute is_nil(client.ipv6) assert client.last_seen_remote_ip == %Postgrex.INET{address: subject.context.remote_ip} + + assert client.last_seen_remote_ip_location_region == + subject.context.remote_ip_location_region + + assert client.last_seen_remote_ip_location_city == subject.context.remote_ip_location_city + assert client.last_seen_remote_ip_location_lat == subject.context.remote_ip_location_lat + assert client.last_seen_remote_ip_location_lon == subject.context.remote_ip_location_lon + assert client.last_seen_user_agent == subject.context.user_agent assert client.last_seen_version == "0.7.412" assert client.last_seen_at @@ -353,6 +361,10 @@ defmodule Domain.ClientsTest do | context: %Domain.Auth.Context{ subject.context | remote_ip: {100, 64, 100, 101}, + remote_ip_location_region: "Mexico", + remote_ip_location_city: "Merida", + remote_ip_location_lat: 7.7758, + remote_ip_location_lon: -2.4128, user_agent: "iOS/12.5 (iPhone) connlib/0.7.411" } } @@ -376,6 +388,18 @@ defmodule Domain.ClientsTest do assert updated_client.ipv6 == client.ipv6 assert updated_client.last_seen_at assert updated_client.last_seen_at != client.last_seen_at + + assert updated_client.last_seen_remote_ip_location_region == + subject.context.remote_ip_location_region + + assert updated_client.last_seen_remote_ip_location_city == + subject.context.remote_ip_location_city + + assert updated_client.last_seen_remote_ip_location_lat == + subject.context.remote_ip_location_lat + + assert updated_client.last_seen_remote_ip_location_lon == + subject.context.remote_ip_location_lon end test "does not reserve additional addresses on update", %{ diff --git a/elixir/apps/domain/test/domain/gateways_test.exs b/elixir/apps/domain/test/domain/gateways_test.exs index 14b129ea5..dc271d082 100644 --- a/elixir/apps/domain/test/domain/gateways_test.exs +++ b/elixir/apps/domain/test/domain/gateways_test.exs @@ -556,7 +556,11 @@ defmodule Domain.GatewaysTest do external_id: nil, public_key: "x", last_seen_user_agent: "foo", - last_seen_remote_ip: {256, 0, 0, 0} + last_seen_remote_ip: {256, 0, 0, 0}, + last_seen_remote_ip_location_region: -1, + last_seen_remote_ip_location_city: -1, + last_seen_remote_ip_location_lat: :x, + last_seen_remote_ip_location_lon: :x } assert {:error, changeset} = upsert_gateway(token, attrs) @@ -564,7 +568,11 @@ defmodule Domain.GatewaysTest do assert errors_on(changeset) == %{ public_key: ["should be 44 character(s)", "must be a base64-encoded string"], external_id: ["can't be blank"], - last_seen_user_agent: ["is invalid"] + last_seen_user_agent: ["is invalid"], + last_seen_remote_ip_location_region: ["is invalid"], + last_seen_remote_ip_location_city: ["is invalid"], + last_seen_remote_ip_location_lat: ["is invalid"], + last_seen_remote_ip_location_lon: ["is invalid"] } end @@ -590,6 +598,13 @@ defmodule Domain.GatewaysTest do assert gateway.last_seen_user_agent == attrs.last_seen_user_agent assert gateway.last_seen_version == "0.7.412" assert gateway.last_seen_at + + assert gateway.last_seen_remote_ip_location_region == + attrs.last_seen_remote_ip_location_region + + assert gateway.last_seen_remote_ip_location_city == attrs.last_seen_remote_ip_location_city + assert gateway.last_seen_remote_ip_location_lat == attrs.last_seen_remote_ip_location_lat + assert gateway.last_seen_remote_ip_location_lon == attrs.last_seen_remote_ip_location_lon end test "updates gateway when it already exists", %{ @@ -601,7 +616,11 @@ defmodule Domain.GatewaysTest do Fixtures.Gateways.gateway_attrs( external_id: gateway.external_id, last_seen_remote_ip: {100, 64, 100, 101}, - last_seen_user_agent: "iOS/12.5 (iPhone) connlib/0.7.411" + last_seen_user_agent: "iOS/12.5 (iPhone) connlib/0.7.411", + last_seen_remote_ip_location_region: "Mexico", + last_seen_remote_ip_location_city: "Merida", + last_seen_remote_ip_location_lat: 7.7758, + last_seen_remote_ip_location_lon: -2.4128 ) assert {:ok, updated_gateway} = upsert_gateway(token, attrs) @@ -624,6 +643,18 @@ defmodule Domain.GatewaysTest do assert updated_gateway.ipv4 == gateway.ipv4 assert updated_gateway.ipv6 == gateway.ipv6 + + assert updated_gateway.last_seen_remote_ip_location_region == + attrs.last_seen_remote_ip_location_region + + assert updated_gateway.last_seen_remote_ip_location_city == + attrs.last_seen_remote_ip_location_city + + assert updated_gateway.last_seen_remote_ip_location_lat == + attrs.last_seen_remote_ip_location_lat + + assert updated_gateway.last_seen_remote_ip_location_lon == + attrs.last_seen_remote_ip_location_lon end test "does not reserve additional addresses on update", %{ @@ -771,31 +802,111 @@ defmodule Domain.GatewaysTest do end end - describe "load_balance_gateways/1" do + describe "load_balance_gateways/2" do + test "returns nil when there are no gateways" do + assert load_balance_gateways({0, 0}, []) == nil + end + test "returns random gateway" do gateways = Enum.map(1..10, fn _ -> Fixtures.Gateways.create_gateway() end) - assert Enum.member?(gateways, load_balance_gateways(gateways)) + assert Enum.member?(gateways, load_balance_gateways({0, 0}, gateways)) + end + + test "returns random gateways when there are no coordinates" do + gateway_1 = Fixtures.Gateways.create_gateway() + gateway_2 = Fixtures.Gateways.create_gateway() + gateway_3 = Fixtures.Gateways.create_gateway() + + assert gateway = load_balance_gateways({nil, nil}, [gateway_1, gateway_2, gateway_3]) + assert gateway.id in [gateway_1.id, gateway_2.id, gateway_3.id] + end + + test "returns gateways in two closest regions to a given location" do + # Moncks Corner, South Carolina + gateway_us_east_1 = + Fixtures.Gateways.create_gateway( + last_seen_remote_ip_location_lat: 33.2029, + last_seen_remote_ip_location_lon: -80.0131 + ) + + gateway_us_east_2 = + Fixtures.Gateways.create_gateway( + last_seen_remote_ip_location_lat: 33.2029, + last_seen_remote_ip_location_lon: -80.0131 + ) + + gateway_us_east_3 = + Fixtures.Gateways.create_gateway( + last_seen_remote_ip_location_lat: 33.2029, + last_seen_remote_ip_location_lon: -80.0131 + ) + + # The Dalles, Oregon + gateway_us_west_1 = + Fixtures.Gateways.create_gateway( + last_seen_remote_ip_location_lat: 45.5946, + last_seen_remote_ip_location_lon: -121.1787 + ) + + gateway_us_west_2 = + Fixtures.Gateways.create_gateway( + last_seen_remote_ip_location_lat: 45.5946, + last_seen_remote_ip_location_lon: -121.1787 + ) + + # Council Bluffs, Iowa + gateway_us_central_1 = + Fixtures.Gateways.create_gateway( + last_seen_remote_ip_location_lat: 41.2619, + last_seen_remote_ip_location_lon: -95.8608 + ) + + gateways = [ + gateway_us_east_1, + gateway_us_east_2, + gateway_us_east_3, + gateway_us_west_1, + gateway_us_west_2, + gateway_us_central_1 + ] + + # multiple attempts are used to increase chances that all gateways in a group are randomly selected + for _ <- 0..3 do + assert gateway = load_balance_gateways({32.2029, -80.0131}, gateways) + assert gateway.id in [gateway_us_east_1.id, gateway_us_east_2.id, gateway_us_east_3.id] + end + + for _ <- 0..2 do + assert gateway = load_balance_gateways({45.5946, -121.1787}, gateways) + assert gateway.id in [gateway_us_west_1.id, gateway_us_west_2.id] + end + + assert gateway = load_balance_gateways({42.2619, -96.8608}, gateways) + assert gateway.id == gateway_us_central_1.id end end - describe "load_balance_gateways/2" do + describe "load_balance_gateways/3" do test "returns random gateway if no gateways are already connected" do gateways = Enum.map(1..10, fn _ -> Fixtures.Gateways.create_gateway() end) - assert Enum.member?(gateways, load_balance_gateways(gateways, [])) + assert Enum.member?(gateways, load_balance_gateways({0, 0}, gateways, [])) end test "reuses gateway that is already connected to reduce the latency" do gateways = Enum.map(1..10, fn _ -> Fixtures.Gateways.create_gateway() end) [connected_gateway | _] = gateways - assert load_balance_gateways(gateways, [connected_gateway.id]) == connected_gateway + assert load_balance_gateways({0, 0}, gateways, [connected_gateway.id]) == connected_gateway end test "returns random gateway from the connected ones" do gateways = Enum.map(1..10, fn _ -> Fixtures.Gateways.create_gateway() end) [connected_gateway1, connected_gateway2 | _] = gateways - assert load_balance_gateways(gateways, [connected_gateway1.id, connected_gateway2.id]) in [ + assert load_balance_gateways({0, 0}, gateways, [ + connected_gateway1.id, + connected_gateway2.id + ]) in [ connected_gateway1, connected_gateway2 ] diff --git a/elixir/apps/domain/test/domain/geo_test.exs b/elixir/apps/domain/test/domain/geo_test.exs new file mode 100644 index 000000000..f30aa8ee3 --- /dev/null +++ b/elixir/apps/domain/test/domain/geo_test.exs @@ -0,0 +1,20 @@ +defmodule Domain.GeoTest do + use Domain.DataCase, async: true + import Domain.Geo + + describe "distance/2" do + test "calculates distance between two identical points" do + assert distance({34.0522, -118.2437}, {34.0522, -118.2437}) == 0 + end + + test "calculates distance between Los Angeles and San Francisco" do + distance = distance({34.0522, -118.2437}, {37.7749, -122.4194}) + assert 558 < distance and distance < 560 + end + + test "calculates distance between New York and London" do + distance = distance({40.7128, -74.0060}, {51.5074, -0.1278}) + assert 5569 < distance and distance < 5571 + end + end +end diff --git a/elixir/apps/domain/test/domain/google_cloud_platform_test.exs b/elixir/apps/domain/test/domain/google_cloud_platform_test.exs index c72d14f94..d2f8956a7 100644 --- a/elixir/apps/domain/test/domain/google_cloud_platform_test.exs +++ b/elixir/apps/domain/test/domain/google_cloud_platform_test.exs @@ -74,9 +74,7 @@ defmodule Domain.GoogleCloudPlatformTest do } ] = nodes - assert_receive {:bypass_request, conn} - - filters = conn.params["filter"] + assert_receive {:bypass_request, %{params: %{"filter" => filters}}} assert Enum.sort(String.split(filters, " AND ")) == [ "labels.cluster_name=firezone", diff --git a/elixir/apps/domain/test/domain/jobs/executors/global_test.exs b/elixir/apps/domain/test/domain/jobs/executors/global_test.exs index 3add3c3ee..cb2d0a1e7 100644 --- a/elixir/apps/domain/test/domain/jobs/executors/global_test.exs +++ b/elixir/apps/domain/test/domain/jobs/executors/global_test.exs @@ -25,7 +25,7 @@ defmodule Domain.Jobs.Executors.GlobalTest do }) refute_receive {:executed, _pid, _time}, 50 - assert_receive {:executed, _pid, _time}, 400 + assert_receive {:executed, _pid, _time}, 1000 end test "registers itself as a leader if there is no global name registered" do diff --git a/elixir/apps/domain/test/domain/relays_test.exs b/elixir/apps/domain/test/domain/relays_test.exs index f7cb7e51d..c24594398 100644 --- a/elixir/apps/domain/test/domain/relays_test.exs +++ b/elixir/apps/domain/test/domain/relays_test.exs @@ -575,7 +575,11 @@ defmodule Domain.RelaysTest do ipv4: "1.1.1.256", ipv6: "fd01::10000", last_seen_user_agent: "foo", - last_seen_remote_ip: {256, 0, 0, 0}, + last_seen_remote_ip: -1, + last_seen_remote_ip_location_region: -1, + last_seen_remote_ip_location_city: -1, + last_seen_remote_ip_location_lat: :x, + last_seen_remote_ip_location_lon: :x, port: -1 } @@ -585,6 +589,11 @@ defmodule Domain.RelaysTest do ipv4: ["one of these fields must be present: ipv4, ipv6", "is invalid"], ipv6: ["one of these fields must be present: ipv4, ipv6", "is invalid"], last_seen_user_agent: ["is invalid"], + last_seen_remote_ip: ["is invalid"], + last_seen_remote_ip_location_region: ["is invalid"], + last_seen_remote_ip_location_city: ["is invalid"], + last_seen_remote_ip_location_lat: ["is invalid"], + last_seen_remote_ip_location_lon: ["is invalid"], port: ["must be greater than or equal to 1"] } @@ -609,6 +618,14 @@ defmodule Domain.RelaysTest do assert relay.ipv6.address == attrs.ipv6 assert relay.last_seen_remote_ip.address == attrs.last_seen_remote_ip + + assert relay.last_seen_remote_ip_location_region == + attrs.last_seen_remote_ip_location_region + + assert relay.last_seen_remote_ip_location_city == attrs.last_seen_remote_ip_location_city + assert relay.last_seen_remote_ip_location_lat == attrs.last_seen_remote_ip_location_lat + assert relay.last_seen_remote_ip_location_lon == attrs.last_seen_remote_ip_location_lon + assert relay.last_seen_user_agent == attrs.last_seen_user_agent assert relay.last_seen_version == "0.7.412" assert relay.last_seen_at @@ -639,7 +656,11 @@ defmodule Domain.RelaysTest do Fixtures.Relays.relay_attrs( ipv4: relay.ipv4, last_seen_remote_ip: relay.ipv4, - last_seen_user_agent: "iOS/12.5 (iPhone) connlib/0.7.411" + last_seen_user_agent: "iOS/12.5 (iPhone) connlib/0.7.411", + last_seen_remote_ip_location_region: "Mexico", + last_seen_remote_ip_location_city: "Merida", + last_seen_remote_ip_location_lat: 7.7758, + last_seen_remote_ip_location_lon: -2.4128 ) assert {:ok, updated_relay} = upsert_relay(token, attrs) @@ -661,6 +682,18 @@ defmodule Domain.RelaysTest do assert updated_relay.ipv6 != relay.ipv6 assert updated_relay.port == 3478 + assert updated_relay.last_seen_remote_ip_location_region == + attrs.last_seen_remote_ip_location_region + + assert updated_relay.last_seen_remote_ip_location_city == + attrs.last_seen_remote_ip_location_city + + assert updated_relay.last_seen_remote_ip_location_lat == + attrs.last_seen_remote_ip_location_lat + + assert updated_relay.last_seen_remote_ip_location_lon == + attrs.last_seen_remote_ip_location_lon + assert Repo.aggregate(Domain.Network.Address, :count) == 0 end @@ -763,6 +796,111 @@ defmodule Domain.RelaysTest do end end + describe "load_balance_relays/2" do + test "returns empty list when there are no relays" do + assert load_balance_relays({0, 0}, []) == [] + end + + test "returns random relays when there are no coordinates" do + relay_1 = Fixtures.Relays.create_relay() + relay_2 = Fixtures.Relays.create_relay() + relay_3 = Fixtures.Relays.create_relay() + + assert relays = load_balance_relays({nil, nil}, [relay_1, relay_2, relay_3]) + assert length(relays) == 2 + assert Enum.all?(relays, &(&1.id in [relay_1.id, relay_2.id, relay_3.id])) + end + + test "returns at least two relays even if they are at the same location" do + # Moncks Corner, South Carolina + relay_us_east_1 = + Fixtures.Relays.create_relay( + last_seen_remote_ip_location_lat: 33.2029, + last_seen_remote_ip_location_lon: -80.0131 + ) + + relay_us_east_2 = + Fixtures.Relays.create_relay( + last_seen_remote_ip_location_lat: 33.2029, + last_seen_remote_ip_location_lon: -80.0131 + ) + + relays = [ + relay_us_east_1, + relay_us_east_2 + ] + + assert [relay1] = load_balance_relays({32.2029, -80.0131}, relays) + assert relay1.id in [relay_us_east_1.id, relay_us_east_2.id] + end + + test "selects relays in two closest regions to a given location" do + # Moncks Corner, South Carolina + relay_us_east_1 = + Fixtures.Relays.create_relay( + last_seen_remote_ip_location_lat: 33.2029, + last_seen_remote_ip_location_lon: -80.0131 + ) + + relay_us_east_2 = + Fixtures.Relays.create_relay( + last_seen_remote_ip_location_lat: 33.2029, + last_seen_remote_ip_location_lon: -80.0131 + ) + + relay_us_east_3 = + Fixtures.Relays.create_relay( + last_seen_remote_ip_location_lat: 33.2029, + last_seen_remote_ip_location_lon: -80.0131 + ) + + # The Dalles, Oregon + relay_us_west_1 = + Fixtures.Relays.create_relay( + last_seen_remote_ip_location_lat: 45.5946, + last_seen_remote_ip_location_lon: -121.1787 + ) + + relay_us_west_2 = + Fixtures.Relays.create_relay( + last_seen_remote_ip_location_lat: 45.5946, + last_seen_remote_ip_location_lon: -121.1787 + ) + + # Council Bluffs, Iowa + relay_us_central_1 = + Fixtures.Relays.create_relay( + last_seen_remote_ip_location_lat: 41.2619, + last_seen_remote_ip_location_lon: -95.8608 + ) + + relays = [ + relay_us_east_1, + relay_us_east_2, + relay_us_east_3, + relay_us_west_1, + relay_us_west_2, + relay_us_central_1 + ] + + # multiple attempts are used to increase chances that all relays in a group are randomly selected + for _ <- 0..3 do + assert [relay1, relay2] = load_balance_relays({32.2029, -80.0131}, relays) + assert relay1.id in [relay_us_east_1.id, relay_us_east_2.id, relay_us_east_3.id] + assert relay2.id == relay_us_central_1.id + end + + for _ <- 0..2 do + assert [relay1, relay2] = load_balance_relays({45.5946, -121.1787}, relays) + assert relay1.id in [relay_us_west_1.id, relay_us_west_2.id] + assert relay2.id == relay_us_central_1.id + end + + assert [relay1, _relay2] = load_balance_relays({42.2619, -96.8608}, relays) + assert relay1.id == relay_us_central_1.id + end + end + describe "encode_token!/1" do test "returns encoded token" do token = Fixtures.Relays.create_token() diff --git a/elixir/apps/domain/test/support/fixtures/auth.ex b/elixir/apps/domain/test/support/fixtures/auth.ex index 6fa0b1b29..84715296e 100644 --- a/elixir/apps/domain/test/support/fixtures/auth.ex +++ b/elixir/apps/domain/test/support/fixtures/auth.ex @@ -333,12 +333,41 @@ defmodule Domain.Fixtures.Auth do user_agent() end) - {remote_ip, _attrs} = + {remote_ip, attrs} = Map.pop_lazy(attrs, :remote_ip, fn -> remote_ip() end) - Auth.build_subject(identity, expires_at, user_agent, remote_ip) + {remote_ip_location_region, attrs} = + Map.pop_lazy(attrs, :remote_ip_location_region, fn -> + Enum.random(["US", "UA"]) + end) + + {remote_ip_location_city, attrs} = + Map.pop_lazy(attrs, :remote_ip_location_city, fn -> + Enum.random(["Odessa", "New York"]) + end) + + {remote_ip_location_lat, attrs} = + Map.pop_lazy(attrs, :remote_ip_location_city, fn -> + Enum.random([37.7758, 40.7128]) + end) + + {remote_ip_location_lon, _attrs} = + Map.pop_lazy(attrs, :remote_ip_location_city, fn -> + Enum.random([-122.4128, -74.0060]) + end) + + context = %Auth.Context{ + remote_ip: remote_ip, + remote_ip_location_region: remote_ip_location_region, + remote_ip_location_city: remote_ip_location_city, + remote_ip_location_lat: remote_ip_location_lat, + remote_ip_location_lon: remote_ip_location_lon, + user_agent: user_agent + } + + Auth.build_subject(identity, expires_at, context) end def remove_permissions(%Auth.Subject{} = subject) do diff --git a/elixir/apps/domain/test/support/fixtures/clients.ex b/elixir/apps/domain/test/support/fixtures/clients.ex index fb5f66521..23160e6b4 100644 --- a/elixir/apps/domain/test/support/fixtures/clients.ex +++ b/elixir/apps/domain/test/support/fixtures/clients.ex @@ -6,7 +6,13 @@ defmodule Domain.Fixtures.Clients do Enum.into(attrs, %{ external_id: Ecto.UUID.generate(), name: "client-#{unique_integer()}", - public_key: unique_public_key() + public_key: unique_public_key(), + last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412", + last_seen_remote_ip: Enum.random([unique_ipv4(), unique_ipv6()]), + last_seen_remote_ip_location_region: "US", + last_seen_remote_ip_location_city: "San Francisco", + last_seen_remote_ip_location_lat: 37.7758, + last_seen_remote_ip_location_lon: -122.4128 }) end diff --git a/elixir/apps/domain/test/support/fixtures/gateways.ex b/elixir/apps/domain/test/support/fixtures/gateways.ex index 22c358c04..388f1153a 100644 --- a/elixir/apps/domain/test/support/fixtures/gateways.ex +++ b/elixir/apps/domain/test/support/fixtures/gateways.ex @@ -74,7 +74,11 @@ defmodule Domain.Fixtures.Gateways do name_suffix: "gw-#{Domain.Crypto.random_token(5, encoder: :user_friendly)}", public_key: unique_public_key(), last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412", - last_seen_remote_ip: %Postgrex.INET{address: {189, 172, 73, 153}} + last_seen_remote_ip: %Postgrex.INET{address: {189, 172, 73, 153}}, + last_seen_remote_ip_location_region: "US", + last_seen_remote_ip_location_city: "San Francisco", + last_seen_remote_ip_location_lat: 37.7758, + last_seen_remote_ip_location_lon: -122.4128 }) end diff --git a/elixir/apps/domain/test/support/fixtures/relays.ex b/elixir/apps/domain/test/support/fixtures/relays.ex index 8d2214b2a..0c38569db 100644 --- a/elixir/apps/domain/test/support/fixtures/relays.ex +++ b/elixir/apps/domain/test/support/fixtures/relays.ex @@ -81,7 +81,11 @@ defmodule Domain.Fixtures.Relays do ipv4: ipv4, ipv6: unique_ipv6(), last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412", - last_seen_remote_ip: ipv4 + last_seen_remote_ip: ipv4, + last_seen_remote_ip_location_region: "Mexico", + last_seen_remote_ip_location_city: "Merida", + last_seen_remote_ip_location_lat: 37.7749, + last_seen_remote_ip_location_lon: -120.4194 }) end diff --git a/elixir/apps/web/lib/web/auth.ex b/elixir/apps/web/lib/web/auth.ex index 3dafbe125..2363842b5 100644 --- a/elixir/apps/web/lib/web/auth.ex +++ b/elixir/apps/web/lib/web/auth.ex @@ -135,9 +135,21 @@ defmodule Web.Auth do Fetches the session token from the session and assigns the subject to the connection. """ def fetch_subject_and_account(%Plug.Conn{} = conn, _opts) do + {location_region, location_city, {location_lat, location_lon}} = + get_load_balancer_ip_location(conn) + + context = %Auth.Context{ + user_agent: conn.assigns.user_agent, + remote_ip: conn.remote_ip, + remote_ip_location_region: location_region, + remote_ip_location_city: location_city, + remote_ip_location_lat: location_lat, + remote_ip_location_lon: location_lon + } + with token when not is_nil(token) <- Plug.Conn.get_session(conn, :session_token), {:ok, subject} <- - Domain.Auth.sign_in(token, conn.assigns.user_agent, conn.remote_ip), + Domain.Auth.sign_in(token, context), {:ok, account} <- Domain.Accounts.fetch_account_by_id_or_slug( conn.path_params["account_id_or_slug"], @@ -151,6 +163,66 @@ defmodule Web.Auth do end end + defp get_load_balancer_ip_location(%Plug.Conn{} = conn) do + location_region = + case Plug.Conn.get_req_header(conn, "x-geo-location-region") do + [location_region | _] -> location_region + [] -> nil + end + + location_city = + case Plug.Conn.get_req_header(conn, "x-geo-location-city") do + [location_city | _] -> location_city + [] -> nil + end + + {location_lat, location_lon} = + case Plug.Conn.get_req_header(conn, "x-geo-location-coordinates") do + [coordinates | _] -> + [lat, lon] = String.split(coordinates, ",", parts: 2) + lat = String.to_float(lat) + lon = String.to_float(lon) + {lat, lon} + + [] -> + {nil, nil} + end + + {location_region, location_city, {location_lat, location_lon}} + end + + defp get_load_balancer_ip_location(x_headers) do + location_region = + case get_socket_header(x_headers, "x-geo-location-region") do + {"x-geo-location-region", location_region} -> location_region + _other -> nil + end + + location_city = + case get_socket_header(x_headers, "x-geo-location-city") do + {"x-geo-location-city", location_city} -> location_city + _other -> nil + end + + {location_lat, location_lon} = + case get_socket_header(x_headers, "x-geo-location-coordinates") do + {"x-geo-location-coordinates", coordinates} -> + [lat, lon] = String.split(coordinates, ",", parts: 2) + lat = String.to_float(lat) + lon = String.to_float(lon) + {lat, lon} + + _other -> + {nil, nil} + end + + {location_region, location_city, {location_lat, location_lon}} + end + + defp get_socket_header(x_headers, key) do + List.keyfind(x_headers, key, 0) + end + @doc """ Used for routes that require the user to not be authenticated. """ @@ -293,9 +365,22 @@ defmodule Web.Auth do Phoenix.Component.assign_new(socket, :subject, fn -> user_agent = Phoenix.LiveView.get_connect_info(socket, :user_agent) real_ip = real_ip(socket) + x_headers = Phoenix.LiveView.get_connect_info(socket, :x_headers) || [] + + {location_region, location_city, {location_lat, location_lon}} = + get_load_balancer_ip_location(x_headers) + + context = %Domain.Auth.Context{ + user_agent: user_agent, + remote_ip: real_ip, + remote_ip_location_region: location_region, + remote_ip_location_city: location_city, + remote_ip_location_lat: location_lat, + remote_ip_location_lon: location_lon + } with token when not is_nil(token) <- session["session_token"], - {:ok, subject} <- Domain.Auth.sign_in(token, user_agent, real_ip) do + {:ok, subject} <- Domain.Auth.sign_in(token, context) do subject else _ -> nil diff --git a/elixir/apps/web/test/support/acceptance_case/auth.ex b/elixir/apps/web/test/support/acceptance_case/auth.ex index 53d5c2fab..29a5c3ddd 100644 --- a/elixir/apps/web/test/support/acceptance_case/auth.ex +++ b/elixir/apps/web/test/support/acceptance_case/auth.ex @@ -35,7 +35,17 @@ defmodule Web.AcceptanceCase.Auth do def authenticate(session, %Domain.Auth.Identity{} = identity) do user_agent = fetch_session_user_agent!(session) remote_ip = {127, 0, 0, 1} - subject = Domain.Auth.build_subject(identity, nil, user_agent, remote_ip) + + context = %Domain.Auth.Context{ + user_agent: user_agent, + remote_ip_location_region: "UA", + remote_ip_location_city: "Kyiv", + remote_ip_location_lat: 50.4501, + remote_ip_location_lon: 30.5234, + remote_ip: remote_ip + } + + subject = Domain.Auth.build_subject(identity, nil, context) authenticate(session, subject) end @@ -77,8 +87,8 @@ defmodule Web.AcceptanceCase.Auth do if token = cookie["session_token"] do user_agent = fetch_session_user_agent!(session) remote_ip = {127, 0, 0, 1} - - assert {:ok, subject} = Domain.Auth.sign_in(token, user_agent, remote_ip) + context = %Domain.Auth.Context{user_agent: user_agent, remote_ip: remote_ip} + assert {:ok, subject} = Domain.Auth.sign_in(token, context) flunk("User is authenticated, identity: #{inspect(subject.identity)}") :ok else @@ -91,9 +101,11 @@ defmodule Web.AcceptanceCase.Auth do def assert_authenticated(session, identity) do with {:ok, cookie} <- fetch_session_cookie(session), - user_agent = fetch_session_user_agent!(session), - remote_ip = {127, 0, 0, 1}, - {:ok, subject} <- Domain.Auth.sign_in(cookie["session_token"], user_agent, remote_ip) do + context = %Domain.Auth.Context{ + user_agent: fetch_session_user_agent!(session), + remote_ip: {127, 0, 0, 1} + }, + {:ok, subject} <- Domain.Auth.sign_in(cookie["session_token"], context) do assert subject.identity.id == identity.id, "Expected #{inspect(identity)}, got #{inspect(subject.identity)}" diff --git a/elixir/apps/web/test/support/conn_case.ex b/elixir/apps/web/test/support/conn_case.ex index 26e599cc0..87aceac34 100644 --- a/elixir/apps/web/test/support/conn_case.ex +++ b/elixir/apps/web/test/support/conn_case.ex @@ -31,6 +31,9 @@ defmodule Web.ConnCase do Phoenix.ConnTest.build_conn() |> Plug.Conn.put_req_header("user-agent", user_agent) |> Plug.Test.init_test_session(%{}) + |> Plug.Conn.put_req_header("x-geo-location-region", "UA") + |> Plug.Conn.put_req_header("x-geo-location-city", "Kyiv") + |> Plug.Conn.put_req_header("x-geo-location-coordinates", "50.4333,30.5167") {:ok, conn: conn, user_agent: user_agent} end @@ -46,7 +49,17 @@ defmodule Web.ConnCase do def authorize_conn(conn, %Domain.Auth.Identity{} = identity) do expires_in = DateTime.utc_now() |> DateTime.add(300, :second) {"user-agent", user_agent} = List.keyfind(conn.req_headers, "user-agent", 0, "FooBar 1.1") - subject = Domain.Auth.build_subject(identity, expires_in, user_agent, conn.remote_ip) + + context = %Domain.Auth.Context{ + user_agent: user_agent, + remote_ip_location_region: "UA", + remote_ip_location_city: "Kyiv", + remote_ip_location_lat: 50.4501, + remote_ip_location_lon: 30.5234, + remote_ip: conn.remote_ip + } + + subject = Domain.Auth.build_subject(identity, expires_in, context) conn |> Web.Auth.put_subject_in_session(subject) diff --git a/elixir/apps/web/test/web/auth_test.exs b/elixir/apps/web/test/web/auth_test.exs index 88fc5899a..52857cef6 100644 --- a/elixir/apps/web/test/web/auth_test.exs +++ b/elixir/apps/web/test/web/auth_test.exs @@ -35,8 +35,7 @@ defmodule Web.AuthTest do conn = put_subject_in_session(conn, subject) assert token = get_session(conn, "session_token") - assert {:ok, _subject} = - Domain.Auth.sign_in(token, subject.context.user_agent, subject.context.remote_ip) + assert {:ok, _subject} = Domain.Auth.sign_in(token, subject.context) end test "persists sign in time in session", %{conn: conn, admin_subject: subject} do @@ -125,6 +124,30 @@ defmodule Web.AuthTest do assert conn.assigns.account.id == subject.account.id end + test "puts load balancer GeoIP headers to subject context", %{ + conn: conn, + admin_subject: subject + } do + {:ok, session_token} = Domain.Auth.create_session_token_from_subject(subject) + + conn = + %{ + conn + | path_params: %{"account_id_or_slug" => subject.account.id}, + remote_ip: {100, 64, 100, 58} + } + |> put_req_header("x-geo-location-region", "Ukraine") + |> put_req_header("x-geo-location-city", "Kyiv") + |> put_req_header("x-geo-location-coordinates", "50.4333,30.5167") + |> put_session(:session_token, session_token) + |> fetch_subject_and_account([]) + + assert conn.assigns.subject.context.remote_ip_location_region == "Ukraine" + assert conn.assigns.subject.context.remote_ip_location_city == "Kyiv" + assert conn.assigns.subject.context.remote_ip_location_lat == 50.4333 + assert conn.assigns.subject.context.remote_ip_location_lon == 30.5167 + end + test "does not authenticate to an incorrect account", %{conn: conn, admin_subject: subject} do {:ok, session_token} = Domain.Auth.create_session_token_from_subject(subject) @@ -228,7 +251,12 @@ defmodule Web.AuthTest do private: %{ connect_info: %{ user_agent: context.admin_subject.context.user_agent, - peer_data: %{address: {100, 64, 100, 58}} + peer_data: %{address: {100, 64, 100, 58}}, + x_headers: [ + {"x-geo-location-region", "UA"}, + {"x-geo-location-city", "Kyiv"}, + {"x-geo-location-coordinates", "50.4333,30.5167"} + ] } } } @@ -247,6 +275,25 @@ defmodule Web.AuthTest do assert {:cont, updated_socket} = on_mount(:mount_subject, params, session, socket) assert updated_socket.assigns.subject.identity.id == subject.identity.id + assert updated_socket.assigns.subject.context.user_agent == subject.context.user_agent + assert updated_socket.assigns.subject.context.remote_ip == subject.context.remote_ip + end + + test "puts load balancer GeoIP information to subject context", %{ + conn: conn, + socket: socket, + admin_subject: subject + } do + {:ok, session_token} = Domain.Auth.create_session_token_from_subject(subject) + session = conn |> put_session(:session_token, session_token) |> get_session() + params = %{"account_id_or_slug" => subject.account.id} + + assert {:cont, socket} = on_mount(:mount_subject, params, session, socket) + + assert socket.assigns.subject.context.remote_ip_location_region == "UA" + assert socket.assigns.subject.context.remote_ip_location_city == "Kyiv" + assert socket.assigns.subject.context.remote_ip_location_lat == 50.4333 + assert socket.assigns.subject.context.remote_ip_location_lon == 30.5167 end test "assigns nil to subject assign if there isn't a valid session_token", %{ diff --git a/elixir/apps/web/test/web/controllers/auth_controller_test.exs b/elixir/apps/web/test/web/controllers/auth_controller_test.exs index ba59f5bff..37ac75970 100644 --- a/elixir/apps/web/test/web/controllers/auth_controller_test.exs +++ b/elixir/apps/web/test/web/controllers/auth_controller_test.exs @@ -206,11 +206,24 @@ defmodule Web.AuthControllerTest do "session_token" => session_token } = conn.private.plug_session + context = %Domain.Auth.Context{ + remote_ip: conn.remote_ip, + user_agent: "testing", + remote_ip_location_region: "Mexico", + remote_ip_location_city: "Merida", + remote_ip_location_lat: 37.7749, + remote_ip_location_lon: -120.4194 + } + assert socket_id == identity.actor_id - assert {:ok, subject} = Domain.Auth.sign_in(session_token, "testing", conn.remote_ip) + assert {:ok, subject} = Domain.Auth.sign_in(session_token, context) assert subject.identity.id == identity.id assert subject.identity.last_seen_user_agent == "testing" assert subject.identity.last_seen_remote_ip.address == {127, 0, 0, 1} + assert subject.identity.last_seen_remote_ip_location_region == "Mexico" + assert subject.identity.last_seen_remote_ip_location_city == "Merida" + assert subject.identity.last_seen_remote_ip_location_lat == 37.7749 + assert subject.identity.last_seen_remote_ip_location_lon == -120.4194 assert subject.identity.last_seen_at end @@ -835,8 +848,10 @@ defmodule Web.AuthControllerTest do "session_token" => session_token } = conn.private.plug_session + context = %Domain.Auth.Context{remote_ip: conn.remote_ip, user_agent: "testing"} + assert socket_id == identity.actor_id - assert {:ok, subject} = Domain.Auth.sign_in(session_token, "testing", conn.remote_ip) + assert {:ok, subject} = Domain.Auth.sign_in(session_token, context) assert subject.identity.id == identity.id assert subject.identity.last_seen_user_agent == "testing" assert subject.identity.last_seen_remote_ip.address == {127, 0, 0, 1} @@ -1056,8 +1071,10 @@ defmodule Web.AuthControllerTest do "session_token" => session_token } = conn.private.plug_session + context = %Domain.Auth.Context{remote_ip: conn.remote_ip, user_agent: "testing"} + assert socket_id == identity.actor_id - assert {:ok, subject} = Domain.Auth.sign_in(session_token, "testing", conn.remote_ip) + assert {:ok, subject} = Domain.Auth.sign_in(session_token, context) assert subject.identity.id == identity.id assert subject.identity.last_seen_user_agent == "testing" assert subject.identity.last_seen_remote_ip.address == {127, 0, 0, 1} diff --git a/elixir/apps/web/test/web/live/actors/service_accounts/new_identity_test.exs b/elixir/apps/web/test/web/live/actors/service_accounts/new_identity_test.exs index 06b1df2d7..aa15b36ec 100644 --- a/elixir/apps/web/test/web/live/actors/service_accounts/new_identity_test.exs +++ b/elixir/apps/web/test/web/live/actors/service_accounts/new_identity_test.exs @@ -144,9 +144,18 @@ defmodule Web.Live.Actors.ServiceAccounts.NewIdentityTest do |> form("form", identity: attrs) |> render_submit() + context = %Domain.Auth.Context{ + remote_ip: Fixtures.Auth.remote_ip(), + user_agent: Fixtures.Auth.user_agent(), + remote_ip_location_region: "Mexico", + remote_ip_location_city: "Merida", + remote_ip_location_lat: 37.7749, + remote_ip_location_lon: -120.4194 + } + # TODO: assert {:ok, _token} = Floki.find(html, "code") |> element_to_text() - |> Domain.Auth.sign_in(Fixtures.Auth.user_agent(), Fixtures.Auth.remote_ip()) + |> Domain.Auth.sign_in(context) end end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs index a02793448..86bbad84e 100644 --- a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs +++ b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs @@ -200,8 +200,17 @@ defmodule Web.Live.Settings.IdentityProviders.GoogleWorkspace.Connect do "session_token" => session_token } = conn.private.plug_session + context = %Domain.Auth.Context{ + remote_ip: conn.remote_ip, + user_agent: "testing", + remote_ip_location_region: "Mexico", + remote_ip_location_city: "Merida", + remote_ip_location_lat: 37.7749, + remote_ip_location_lon: -120.4194 + } + assert socket_id == identity.actor_id - assert {:ok, subject} = Domain.Auth.sign_in(session_token, "testing", conn.remote_ip) + assert {:ok, subject} = Domain.Auth.sign_in(session_token, context) assert subject.identity.id == identity.id assert subject.identity.last_seen_user_agent == "testing" assert subject.identity.last_seen_remote_ip.address == {127, 0, 0, 1} diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/connect_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/connect_test.exs index 0919291ed..a541fc883 100644 --- a/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/connect_test.exs +++ b/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/connect_test.exs @@ -192,8 +192,17 @@ defmodule Web.Live.Settings.IdentityProviders.OpenIDConnect.Connect do "session_token" => session_token } = conn.private.plug_session + context = %Domain.Auth.Context{ + remote_ip: conn.remote_ip, + user_agent: "testing", + remote_ip_location_region: "Mexico", + remote_ip_location_city: "Merida", + remote_ip_location_lat: 37.7749, + remote_ip_location_lon: -120.4194 + } + assert socket_id == identity.actor_id - assert {:ok, subject} = Domain.Auth.sign_in(session_token, "testing", conn.remote_ip) + assert {:ok, subject} = Domain.Auth.sign_in(session_token, context) assert subject.identity.id == identity.id assert subject.identity.last_seen_user_agent == "testing" assert subject.identity.last_seen_remote_ip.address == {127, 0, 0, 1} diff --git a/terraform/modules/elixir-app/main.tf b/terraform/modules/elixir-app/main.tf index fae0c7383..e5b00dbce 100644 --- a/terraform/modules/elixir-app/main.tf +++ b/terraform/modules/elixir-app/main.tf @@ -418,8 +418,16 @@ resource "google_compute_backend_service" "default" { enable_cdn = false compression_mode = "DISABLED" - custom_request_headers = [] - custom_response_headers = [] + custom_request_headers = [ + "X-Geo-Location-Region:{client_region}", + "X-Geo-Location-City:{client_city}", + "X-Geo-Location-Coordinates:{client_city_lat_long}", + "X-Client-IP:{client_ip}", + ] + + custom_response_headers = [ + "X-Cache-Hit: {cdn_cache_status}" + ] session_affinity = "CLIENT_IP"