From 6fc7d2e4e05d22b90c6ac309220534c2b7de5696 Mon Sep 17 00:00:00 2001 From: Jamil Date: Mon, 2 Jun 2025 19:24:41 -0700 Subject: [PATCH] feat(portal): configurable ip stack for DNS resources (#9303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some poorly-behaved applications (e.g. mongo) will fail to connect if they see both IPv4 and IPv6 addresses for a DNS resource, because they will try to connect to both of them and fail the whole connection setup if either one is not routable. To fix this, we need to introduce a knob to allow admins to restrict DNS resources to only A or AAAA records. Screenshot 2025-06-02 at 10 48 39 AM Screenshot 2025-06-02 at 11 05 53 AM --- Related: https://firezonehq.slack.com/archives/C08KPQKJZKM/p1746720923535349 Related: #9300 Fixes: #9042 --- .../apps/api/lib/api/client/views/resource.ex | 9 +- .../api/lib/api/controllers/resource_json.ex | 3 +- .../api/lib/api/schemas/resource_schema.ex | 13 +- .../apps/api/test/api/client/channel_test.exs | 9 + .../controllers/resource_controller_test.exs | 13 +- .../apps/domain/lib/domain/repo/changeset.ex | 4 +- .../domain/lib/domain/resources/resource.ex | 1 + .../domain/resources/resource/changeset.ex | 28 ++- ...250530043656_add_ip_stack_to_resources.exs | 15 ++ ...0602214139_populate_resources_ip_stack.exs | 19 ++ ..._null_constraint_to_resources_ip_stack.exs | 29 ++++ .../domain/test/domain/resources_test.exs | 164 ++++++++++++++++++ .../web/lib/web/components/core_components.ex | 28 +++ .../web/components/navigation_components.ex | 10 +- .../web/lib/web/live/resources/components.ex | 84 +++++++++ .../apps/web/lib/web/live/resources/edit.ex | 2 + elixir/apps/web/lib/web/live/resources/new.ex | 2 + .../apps/web/lib/web/live/resources/show.ex | 14 ++ .../web/test/web/live/resources/edit_test.exs | 59 +++++++ .../web/test/web/live/resources/new_test.exs | 55 ++++++ .../web/test/web/live/resources/show_test.exs | 2 + .../src/app/kb/deploy/resources/readme.mdx | 33 ++++ 22 files changed, 574 insertions(+), 22 deletions(-) create mode 100644 elixir/apps/domain/priv/repo/migrations/20250530043656_add_ip_stack_to_resources.exs create mode 100644 elixir/apps/domain/priv/repo/migrations/20250602214139_populate_resources_ip_stack.exs create mode 100644 elixir/apps/domain/priv/repo/migrations/20250602214208_add_not_null_constraint_to_resources_ip_stack.exs diff --git a/elixir/apps/api/lib/api/client/views/resource.ex b/elixir/apps/api/lib/api/client/views/resource.ex index 8d51f9797..c6ed49a55 100644 --- a/elixir/apps/api/lib/api/client/views/resource.ex +++ b/elixir/apps/api/lib/api/client/views/resource.ex @@ -11,7 +11,8 @@ defmodule API.Client.Views.Resource do id: resource.id, type: :internet, gateway_groups: Views.GatewayGroup.render_many(resource.gateway_groups), - can_be_disabled: true + can_be_disabled: true, + ip_stack: resource.ip_stack } end @@ -27,7 +28,8 @@ defmodule API.Client.Views.Resource do address_description: resource.address_description, name: resource.name, gateway_groups: Views.GatewayGroup.render_many(resource.gateway_groups), - filters: Enum.flat_map(resource.filters, &render_filter/1) + filters: Enum.flat_map(resource.filters, &render_filter/1), + ip_stack: resource.ip_stack } end @@ -39,7 +41,8 @@ defmodule API.Client.Views.Resource do address_description: resource.address_description, name: resource.name, gateway_groups: Views.GatewayGroup.render_many(resource.gateway_groups), - filters: Enum.flat_map(resource.filters, &render_filter/1) + filters: Enum.flat_map(resource.filters, &render_filter/1), + ip_stack: resource.ip_stack } end diff --git a/elixir/apps/api/lib/api/controllers/resource_json.ex b/elixir/apps/api/lib/api/controllers/resource_json.ex index ec76eb44d..2b4c3b082 100644 --- a/elixir/apps/api/lib/api/controllers/resource_json.ex +++ b/elixir/apps/api/lib/api/controllers/resource_json.ex @@ -25,7 +25,8 @@ defmodule API.ResourceJSON do name: resource.name, address: resource.address, address_description: resource.address_description, - type: resource.type + type: resource.type, + ip_stack: resource.ip_stack } end end diff --git a/elixir/apps/api/lib/api/schemas/resource_schema.ex b/elixir/apps/api/lib/api/schemas/resource_schema.ex index 88b2a520f..93829e0ad 100644 --- a/elixir/apps/api/lib/api/schemas/resource_schema.ex +++ b/elixir/apps/api/lib/api/schemas/resource_schema.ex @@ -14,7 +14,12 @@ defmodule API.Schemas.Resource do name: %Schema{type: :string, description: "Resource name"}, address: %Schema{type: :string, description: "Resource address"}, address_description: %Schema{type: :string, description: "Resource address description"}, - type: %Schema{type: :string, description: "Resource type"} + type: %Schema{type: :string, description: "Resource type"}, + ip_stack: %Schema{ + type: :string, + description: "IP stack type. Only supported for DNS resources.", + enum: ["ipv4_only", "ipv6_only", "dual"] + } }, required: [:name, :type], example: %{ @@ -22,7 +27,8 @@ defmodule API.Schemas.Resource do "name" => "Prod DB", "address" => "10.0.0.10", "address_description" => "Production Database", - "type" => "ip" + "type" => "ip", + "ip_stack" => "ipv4_only" } }) end @@ -90,7 +96,8 @@ defmodule API.Schemas.Resource do "name" => "Prod DB", "address" => "10.0.0.10", "address_description" => "Production Database", - "type" => "ip" + "type" => "ip", + "ip_stack" => "ipv4_only" } } }) diff --git a/elixir/apps/api/test/api/client/channel_test.exs b/elixir/apps/api/test/api/client/channel_test.exs index 8e53add3d..39f3b22bb 100644 --- a/elixir/apps/api/test/api/client/channel_test.exs +++ b/elixir/apps/api/test/api/client/channel_test.exs @@ -40,6 +40,7 @@ defmodule API.Client.ChannelTest do dns_resource = Fixtures.Resources.create_resource( account: account, + ip_stack: :ipv4_only, connections: [%{gateway_group_id: gateway_group.id}] ) @@ -287,6 +288,7 @@ defmodule API.Client.ChannelTest do assert %{ id: dns_resource.id, type: :dns, + ip_stack: :ipv4_only, name: dns_resource.name, address: dns_resource.address, address_description: dns_resource.address_description, @@ -307,6 +309,7 @@ defmodule API.Client.ChannelTest do assert %{ id: cidr_resource.id, type: :cidr, + ip_stack: nil, name: cidr_resource.name, address: cidr_resource.address, address_description: cidr_resource.address_description, @@ -327,6 +330,7 @@ defmodule API.Client.ChannelTest do assert %{ id: ip_resource.id, type: :cidr, + ip_stack: nil, name: ip_resource.name, address: "#{ip_resource.address}/32", address_description: ip_resource.address_description, @@ -347,6 +351,7 @@ defmodule API.Client.ChannelTest do assert %{ id: internet_resource.id, type: :internet, + ip_stack: nil, gateway_groups: [ %{ id: internet_gateway_group.id, @@ -830,6 +835,7 @@ defmodule API.Client.ChannelTest do assert payload == %{ id: resource.id, type: :dns, + ip_stack: :ipv4_only, name: resource.name, address: resource.address, address_description: resource.address_description, @@ -867,6 +873,7 @@ defmodule API.Client.ChannelTest do assert payload == %{ id: resource.id, type: :dns, + ip_stack: :ipv4_only, name: resource.name, address: resource.address, address_description: resource.address_description, @@ -959,6 +966,7 @@ defmodule API.Client.ChannelTest do assert payload == %{ id: resource.id, type: :dns, + ip_stack: :ipv4_only, name: resource.name, address: resource.address, address_description: resource.address_description, @@ -1031,6 +1039,7 @@ defmodule API.Client.ChannelTest do assert payload == %{ id: resource.id, type: :dns, + ip_stack: :ipv4_only, name: resource.name, address: resource.address, address_description: resource.address_description, diff --git a/elixir/apps/api/test/api/controllers/resource_controller_test.exs b/elixir/apps/api/test/api/controllers/resource_controller_test.exs index c1e2ce028..d9ec81caf 100644 --- a/elixir/apps/api/test/api/controllers/resource_controller_test.exs +++ b/elixir/apps/api/test/api/controllers/resource_controller_test.exs @@ -91,7 +91,7 @@ defmodule API.ResourceControllerTest do end test "returns a single resource", %{conn: conn, account: account, actor: actor} do - resource = Fixtures.Resources.create_resource(%{account: account}) + resource = Fixtures.Resources.create_resource(%{account: account, ip_stack: :ipv4_only}) conn = conn @@ -105,7 +105,8 @@ defmodule API.ResourceControllerTest do "address_description" => resource.address_description, "id" => resource.id, "name" => resource.name, - "type" => Atom.to_string(resource.type) + "type" => Atom.to_string(resource.type), + "ip_stack" => "ipv4_only" } } end @@ -159,6 +160,7 @@ defmodule API.ResourceControllerTest do "address" => "google.com", "name" => "Google", "type" => "dns", + "ip_stack" => "ipv6_only", "connections" => [ %{"gateway_group_id" => gateway_group.id} ] @@ -176,6 +178,7 @@ defmodule API.ResourceControllerTest do assert resp["data"]["address_description"] == nil assert resp["data"]["name"] == attrs["name"] assert resp["data"]["type"] == attrs["type"] + assert resp["data"]["ip_stack"] == attrs["ip_stack"] end end @@ -223,7 +226,7 @@ defmodule API.ResourceControllerTest do test "updates a resource", %{conn: conn, account: account, actor: actor} do resource = Fixtures.Resources.create_resource(%{account: account}) - attrs = %{"name" => "Google"} + attrs = %{"name" => "Google", "ip_stack" => "ipv6_only"} conn = conn @@ -236,6 +239,7 @@ defmodule API.ResourceControllerTest do assert resp["data"]["address"] == resource.address assert resp["data"]["address_description"] == resource.address_description assert resp["data"]["name"] == attrs["name"] + assert resp["data"]["ip_stack"] == attrs["ip_stack"] end end @@ -261,7 +265,8 @@ defmodule API.ResourceControllerTest do "address_description" => resource.address_description, "id" => resource.id, "name" => resource.name, - "type" => Atom.to_string(resource.type) + "type" => Atom.to_string(resource.type), + "ip_stack" => Atom.to_string(resource.ip_stack) } } diff --git a/elixir/apps/domain/lib/domain/repo/changeset.ex b/elixir/apps/domain/lib/domain/repo/changeset.ex index 38651053d..9caa9d36e 100644 --- a/elixir/apps/domain/lib/domain/repo/changeset.ex +++ b/elixir/apps/domain/lib/domain/repo/changeset.ex @@ -56,7 +56,7 @@ defmodule Domain.Repo.Changeset do end @doc """ - Takes value from `value_field` and puts it's hash of a given type to `hash_field`. + Takes value from `value_field` and puts its hash of a given type to `hash_field`. """ def put_hash(%Ecto.Changeset{} = changeset, value_field, type, opts) do hash_field = Keyword.fetch!(opts, :to) @@ -106,7 +106,7 @@ defmodule Domain.Repo.Changeset do defp field_variations(field) when is_atom(field), do: [field, Atom.to_string(field)] @doc """ - Puts the change if field is not changed or it's value is set to `nil`. + Puts the change if field is not changed or its value is set to `nil`. """ def put_default_value(%Ecto.Changeset{} = changeset, _field, nil) do changeset diff --git a/elixir/apps/domain/lib/domain/resources/resource.ex b/elixir/apps/domain/lib/domain/resources/resource.ex index 44e743b06..7bc9038b9 100644 --- a/elixir/apps/domain/lib/domain/resources/resource.ex +++ b/elixir/apps/domain/lib/domain/resources/resource.ex @@ -9,6 +9,7 @@ defmodule Domain.Resources.Resource do field :name, :string field :type, Ecto.Enum, values: [:cidr, :ip, :dns, :internet] + field :ip_stack, Ecto.Enum, values: [:ipv4_only, :ipv6_only, :dual] embeds_many :filters, Filter, on_replace: :delete, primary_key: false do field :protocol, Ecto.Enum, values: [tcp: 6, udp: 17, icmp: 1] diff --git a/elixir/apps/domain/lib/domain/resources/resource/changeset.ex b/elixir/apps/domain/lib/domain/resources/resource/changeset.ex index e7a50c7e7..f0addda09 100644 --- a/elixir/apps/domain/lib/domain/resources/resource/changeset.ex +++ b/elixir/apps/domain/lib/domain/resources/resource/changeset.ex @@ -3,9 +3,9 @@ defmodule Domain.Resources.Resource.Changeset do alias Domain.{Auth, Accounts, Network} alias Domain.Resources.{Resource, Connection} - @fields ~w[address address_description name type]a - @update_fields ~w[address address_description name type]a - @replace_fields ~w[type address filters]a + @fields ~w[address address_description name type ip_stack]a + @update_fields ~w[address address_description name type ip_stack]a + @replace_fields ~w[type address filters ip_stack]a @required_fields ~w[name type]a # Reference list of common TLDs from IANA @@ -260,6 +260,12 @@ defmodule Domain.Resources.Resource.Changeset do changeset |> validate_length(:name, min: 1, max: 255) |> validate_length(:address_description, min: 1, max: 512) + |> maybe_put_default_ip_stack() + |> check_constraint(:ip_stack, + name: :resources_ip_stack_not_null, + message: + "IP stack must be one of 'dual', 'ipv4_only', 'ipv6_only' for DNS resources or NULL for others" + ) |> cast_embed(:filters, with: &cast_filter/2) |> unique_constraint(:ipv4, name: :resources_account_id_ipv4_index) |> unique_constraint(:ipv6, name: :resources_account_id_ipv6_index) @@ -271,4 +277,20 @@ defmodule Domain.Resources.Resource.Changeset do |> cast(attrs, [:protocol, :ports]) |> validate_required([:protocol]) end + + defp maybe_put_default_ip_stack(changeset) do + current_type = get_field(changeset, :type) + original_type = Map.get(changeset.data, :type, nil) + + cond do + current_type == :dns -> + put_default_value(changeset, :ip_stack, :dual) + + original_type == :dns and current_type != :dns -> + put_change(changeset, :ip_stack, nil) + + true -> + changeset + end + end end diff --git a/elixir/apps/domain/priv/repo/migrations/20250530043656_add_ip_stack_to_resources.exs b/elixir/apps/domain/priv/repo/migrations/20250530043656_add_ip_stack_to_resources.exs new file mode 100644 index 000000000..282bfe5ed --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20250530043656_add_ip_stack_to_resources.exs @@ -0,0 +1,15 @@ +defmodule Domain.Repo.Migrations.AddIpStackToResources do + use Ecto.Migration + + def up do + alter table(:resources) do + add(:ip_stack, :string) + end + end + + def down do + alter table(:resources) do + remove(:ip_stack) + end + end +end diff --git a/elixir/apps/domain/priv/repo/migrations/20250602214139_populate_resources_ip_stack.exs b/elixir/apps/domain/priv/repo/migrations/20250602214139_populate_resources_ip_stack.exs new file mode 100644 index 000000000..c68e80c64 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20250602214139_populate_resources_ip_stack.exs @@ -0,0 +1,19 @@ +defmodule Domain.Repo.Migrations.PopulateResourcesIpStack do + use Ecto.Migration + + def up do + execute(""" + UPDATE resources + SET ip_stack = 'dual' + WHERE type = 'dns' + """) + end + + def down do + execute(""" + UPDATE resources + SET ip_stack = NULL + WHERE type = 'dns' + """) + end +end diff --git a/elixir/apps/domain/priv/repo/migrations/20250602214208_add_not_null_constraint_to_resources_ip_stack.exs b/elixir/apps/domain/priv/repo/migrations/20250602214208_add_not_null_constraint_to_resources_ip_stack.exs new file mode 100644 index 000000000..58e1d4bfc --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20250602214208_add_not_null_constraint_to_resources_ip_stack.exs @@ -0,0 +1,29 @@ +defmodule Domain.Repo.Migrations.AddNotNullConstraintToResourcesIpStack do + use Ecto.Migration + + def up do + # Add the CHECK constraint with NOT VALID to avoid locking + execute(""" + ALTER TABLE resources + ADD CONSTRAINT resources_ip_stack_not_null + CHECK ( + (type = 'dns' AND ip_stack IN ('dual', 'ipv4_only', 'ipv6_only')) OR + (type != 'dns' AND ip_stack IS NULL) + ) NOT VALID + """) + + # Validate the constraint separately + execute(""" + ALTER TABLE resources + VALIDATE CONSTRAINT resources_ip_stack_not_null + """) + end + + def down do + # Remove the constraint + execute(""" + ALTER TABLE resources + DROP CONSTRAINT resources_ip_stack_not_null + """) + end +end diff --git a/elixir/apps/domain/test/domain/resources_test.exs b/elixir/apps/domain/test/domain/resources_test.exs index 0d4e5b862..e35d5b518 100644 --- a/elixir/apps/domain/test/domain/resources_test.exs +++ b/elixir/apps/domain/test/domain/resources_test.exs @@ -1003,6 +1003,170 @@ defmodule Domain.ResourcesTest do end describe "create_resource/2" do + setup context do + gateway_group = + Fixtures.Gateways.create_group(account: context.account, subject: context.subject) + + Map.put(context, :gateway_group, gateway_group) + end + + test "prevents setting ip_stack for ipv4 resource", %{ + subject: subject, + gateway_group: gateway_group + } do + attrs = + Fixtures.Resources.resource_attrs( + type: :ip, + address: "1.1.1.1", + ip_stack: :dual, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + assert {:error, changeset} = create_resource(attrs, subject) + + assert %{ + ip_stack: [ + "IP stack must be one of 'dual', 'ipv4_only', 'ipv6_only' for DNS resources or NULL for others" + ] + } = errors_on(changeset) + end + + test "prevents setting ip_stack for cidr4 resource", %{ + subject: subject, + gateway_group: gateway_group + } do + attrs = + Fixtures.Resources.resource_attrs( + type: :cidr, + address: "10.0.0.0/24", + ip_stack: :dual, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + assert {:error, changeset} = create_resource(attrs, subject) + + assert %{ + ip_stack: [ + "IP stack must be one of 'dual', 'ipv4_only', 'ipv6_only' for DNS resources or NULL for others" + ] + } = errors_on(changeset) + end + + test "prevents setting ip_stack for ipv6 resource", %{ + subject: subject, + gateway_group: gateway_group + } do + attrs = + Fixtures.Resources.resource_attrs( + type: :ip, + address: "2001:db8::1", + ip_stack: :dual, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + assert {:error, changeset} = create_resource(attrs, subject) + + assert %{ + ip_stack: [ + "IP stack must be one of 'dual', 'ipv4_only', 'ipv6_only' for DNS resources or NULL for others" + ] + } = errors_on(changeset) + end + + test "prevents setting ip_stack for cidr6 resource", %{ + subject: subject, + gateway_group: gateway_group + } do + attrs = + Fixtures.Resources.resource_attrs( + type: :cidr, + address: "2001:db8::/32", + ip_stack: :dual, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + assert {:error, changeset} = create_resource(attrs, subject) + + assert %{ + ip_stack: [ + "IP stack must be one of 'dual', 'ipv4_only', 'ipv6_only' for DNS resources or NULL for others" + ] + } = errors_on(changeset) + end + + test "prevents setting ip_stack for internet resource", %{account: account, subject: subject} do + {:ok, gateway_group} = Domain.Gateways.create_internet_group(account) + + attrs = + Fixtures.Resources.resource_attrs( + type: :internet, + ip_stack: :dual, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + assert {:error, changeset} = create_resource(attrs, subject) + + assert %{ + ip_stack: [ + "IP stack must be one of 'dual', 'ipv4_only', 'ipv6_only' for DNS resources or NULL for others" + ] + } = errors_on(changeset) + end + + test "allows setting ip_stack for dns resources", %{ + subject: subject, + gateway_group: gateway_group + } do + attrs = + Fixtures.Resources.resource_attrs( + type: :dns, + address: "example.com", + ip_stack: :dual, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + assert {:ok, resource} = create_resource(attrs, subject) + assert resource.ip_stack == :dual + + attrs = + Fixtures.Resources.resource_attrs( + type: :dns, + address: "example.com", + ip_stack: :ipv4_only, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + assert {:ok, resource} = create_resource(attrs, subject) + assert resource.ip_stack == :ipv4_only + + attrs = + Fixtures.Resources.resource_attrs( + type: :dns, + address: "example.com", + ip_stack: :ipv6_only, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + assert {:ok, resource} = create_resource(attrs, subject) + assert resource.ip_stack == :ipv6_only + end + + test "populates ip_stack for dns resources with 'dual' by default", %{ + subject: subject, + gateway_group: gateway_group + } do + attrs = + Fixtures.Resources.resource_attrs( + type: :dns, + address: "example.com", + ip_stack: nil, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + assert {:ok, resource} = create_resource(attrs, subject) + assert resource.ip_stack == :dual + end + test "prevents adding other resources to the internet site", %{ account: account, subject: subject diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index ab2c58df4..1b5d19559 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -53,6 +53,34 @@ defmodule Web.CoreComponents do """ end + @doc """ + Renders an inline code tag with formatting. + + ## Examples + + <.code>def foo: do :bar + """ + attr :id, :string, default: nil + attr :class, :string, default: "" + slot :inner_block, required: true + attr :rest, :global + + def code(assigns) do + assigns = + assign( + assigns, + :class, + "#{assigns.class} font-semibold p-[0.15rem] bg-neutral-100 rounded" + ) + + # Important: leave the on the same line as the render_slot call, otherwise there will be + # an undesired trailing space in the output. + ~H""" + + {render_slot(@inner_block)} + """ + end + @doc """ Render a monospace code block suitable for copying and pasting content. diff --git a/elixir/apps/web/lib/web/components/navigation_components.ex b/elixir/apps/web/lib/web/components/navigation_components.ex index d2612f8a8..e9e3bcad9 100644 --- a/elixir/apps/web/lib/web/components/navigation_components.ex +++ b/elixir/apps/web/lib/web/components/navigation_components.ex @@ -330,9 +330,9 @@ defmodule Web.NavigationComponents do ## Examples - <.website_link href="/pricing>Pricing - <.website_link href="/kb/deploy/gateways" class="text-neutral-900">Deploy Gateway(s) - <.website_link href={~p"/contact/sales"}>Contact Sales + <.website_link path="/pricing>Pricing + <.website_link path="/kb/deploy/gateways" class="text-neutral-900">Deploy Gateway(s) + <.website_link path="/contact/sales">Contact Sales """ attr :path, :string, required: true attr :fragment, :string, required: false, default: "" @@ -357,9 +357,7 @@ defmodule Web.NavigationComponents do ## Examples - <.website_link href="/pricing>Pricing - <.website_link href="/kb/deploy/gateways" class="text-neutral-900">Deploy Gateway(s) - <.website_link href={~p"/contact/sales"}>Contact Sales + <.docs_action path="/kb/deploy/gateways" class="text-neutral-900">Deploy Gateway(s) """ attr :path, :string, required: true attr :fragment, :string, required: false, default: "" diff --git a/elixir/apps/web/lib/web/live/resources/components.ex b/elixir/apps/web/lib/web/live/resources/components.ex index f23dd1dcf..310d96286 100644 --- a/elixir/apps/web/lib/web/live/resources/components.ex +++ b/elixir/apps/web/lib/web/live/resources/components.ex @@ -222,6 +222,77 @@ defmodule Web.Resources.Components do """ end + attr :form, :any, required: true + + def ip_stack_form(assigns) do + ~H""" +
+ IP Stack +

+ Determines what + <.website_link path="/kb/deploy/resources" fragment="ip-stack">record types + are generated by the stub resolver. If unsure, leave this unchanged. +

+
+ <.input + id="resource-ip-stack--dual" + type="radio" + field={@form[:ip_stack]} + value="dual" + checked={"#{@form[:ip_stack].value}" == "" or "#{@form[:ip_stack].value}" == "dual"} + > + + +
+
+ <.input + id="resource-ip-stack--ipv4-only" + type="radio" + field={@form[:ip_stack]} + value="ipv4_only" + checked={"#{@form[:ip_stack].value}" == "ipv4_only"} + > + + +
+
+ <.input + id="resource-ip-stack--ipv6-only" + type="radio" + field={@form[:ip_stack]} + value="ipv6_only" + checked={"#{@form[:ip_stack].value}" == "ipv6_only"} + > + + +
+
+ """ + end + attr :filter, :any, required: true def filter_description(assigns) do @@ -354,4 +425,17 @@ defmodule Web.Resources.Components do [id] end) end + + @known_recommendations %{ + "mongodb.net" => "ipv4_only" + } + + defp ip_stack_recommendation(form) do + if address = form[:address].value do + @known_recommendations + |> Enum.find_value(fn {key, value} -> + if String.ends_with?(String.trim(address), key), do: value + end) + end + end end diff --git a/elixir/apps/web/lib/web/live/resources/edit.ex b/elixir/apps/web/lib/web/live/resources/edit.ex index d7b65ec57..f0b3ef085 100644 --- a/elixir/apps/web/lib/web/live/resources/edit.ex +++ b/elixir/apps/web/lib/web/live/resources/edit.ex @@ -203,6 +203,8 @@ defmodule Web.Resources.Edit do required /> + <.ip_stack_form :if={"#{@form[:type].value}" == "dns"} form={@form} /> + <.filters_form :if={@resource.type != :internet} account={@account} diff --git a/elixir/apps/web/lib/web/live/resources/new.ex b/elixir/apps/web/lib/web/live/resources/new.ex index e31dd91b7..9ba09815c 100644 --- a/elixir/apps/web/lib/web/live/resources/new.ex +++ b/elixir/apps/web/lib/web/live/resources/new.ex @@ -187,6 +187,8 @@ defmodule Web.Resources.New do required /> + <.ip_stack_form :if={"#{@form[:type].value}" == "dns"} form={@form} /> + <.filters_form account={@account} form={@form[:filters]} /> <.connections_form diff --git a/elixir/apps/web/lib/web/live/resources/show.ex b/elixir/apps/web/lib/web/live/resources/show.ex index c8812d618..0cf0ea3e2 100644 --- a/elixir/apps/web/lib/web/live/resources/show.ex +++ b/elixir/apps/web/lib/web/live/resources/show.ex @@ -170,6 +170,16 @@ defmodule Web.Resources.Show do + <.vertical_table_row :if={@resource.ip_stack}> + <:label> + IP Stack + + <:value> + + {format_ip_stack(@resource.ip_stack)} + + + <.vertical_table_row> <:label> Connected Sites @@ -453,4 +463,8 @@ defmodule Web.Resources.Show do ] ) end + + defp format_ip_stack(:dual), do: "Dual-stack (IPv4 and IPv6)" + defp format_ip_stack(:ipv4_only), do: "IPv4 only" + defp format_ip_stack(:ipv6_only), do: "IPv6 only" end diff --git a/elixir/apps/web/test/web/live/resources/edit_test.exs b/elixir/apps/web/test/web/live/resources/edit_test.exs index ef52c0c7b..1f727e39c 100644 --- a/elixir/apps/web/test/web/live/resources/edit_test.exs +++ b/elixir/apps/web/test/web/live/resources/edit_test.exs @@ -110,6 +110,7 @@ defmodule Web.Live.Resources.EditTest do "resource[filters][udp][enabled]", "resource[filters][udp][ports]", "resource[filters][udp][protocol]", + "resource[ip_stack]", "resource[name]", "resource[type]" ]) @@ -143,6 +144,7 @@ defmodule Web.Live.Resources.EditTest do "resource[filters][udp][enabled]", "resource[filters][udp][ports]", "resource[filters][udp][protocol]", + "resource[ip_stack]", "resource[name]", "resource[type]" ] @@ -256,6 +258,62 @@ defmodule Web.Live.Resources.EditTest do assert updated_filters.udp == attrs.filters.udp end + test "updates ip_stack when resource type is dns", %{ + account: account, + identity: identity, + resource: resource, + conn: conn + } do + conn = authorize_conn(conn, identity) + + attrs = %{ + name: "foobar.com", + type: "dns", + address: "foobar.com", + ip_stack: "ipv4_only" + } + + {:ok, lv, html} = + conn + |> live(~p"/#{account}/resources/#{resource}/edit") + + refute html =~ "Recommended for this Resource" + + {:ok, _lv, html} = + lv + |> form("form", resource: attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/#{account}/resources") + + assert updated_resource = Repo.get_by(Domain.Resources.Resource, id: resource.id) + assert updated_resource.name == attrs.name + assert updated_resource.type == :dns + assert updated_resource.address == attrs.address + assert updated_resource.ip_stack == :ipv4_only + assert html =~ "Resource #{updated_resource.name} updated successfully" + end + + test "renders ip stack recommendation", %{ + account: account, + identity: identity, + conn: conn + } do + conn = authorize_conn(conn, identity) + + resource = + Fixtures.Resources.create_resource( + account: account, + type: :dns, + address: "**.mongodb.net" + ) + + {:ok, _lv, html} = + conn + |> live(~p"/#{account}/resources/#{resource}/edit") + + assert html =~ "Recommended for this Resource" + end + test "redirects to a site when site_id query param is set", %{ account: account, identity: identity, @@ -314,6 +372,7 @@ defmodule Web.Live.Resources.EditTest do "resource[filters][udp][enabled]", "resource[filters][udp][ports]", "resource[filters][udp][protocol]", + "resource[ip_stack]", "resource[name]", "resource[type]" ] diff --git a/elixir/apps/web/test/web/live/resources/new_test.exs b/elixir/apps/web/test/web/live/resources/new_test.exs index 6c9b4f6a3..862e533d2 100644 --- a/elixir/apps/web/test/web/live/resources/new_test.exs +++ b/elixir/apps/web/test/web/live/resources/new_test.exs @@ -490,6 +490,61 @@ defmodule Web.Live.Resources.NewTest do assert html =~ "UPGRADE TO UNLOCK" end + test "sets ip_stack when resource type is dns", %{ + account: account, + identity: identity, + group: group, + conn: conn + } do + attrs = %{ + name: "foobar.com", + address: "foobar.com", + ip_stack: "ipv4_only" + } + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/resources/new?site_id=#{group}") + + lv + |> form("form") + |> render_change(resource: %{type: :dns}) + + lv + |> form("form", resource: attrs) + |> render_submit() + + resource = Repo.get_by(Domain.Resources.Resource, %{name: attrs.name, address: attrs.address}) + assert resource.ip_stack == :ipv4_only + end + + test "renders ip stack recommendation", %{ + account: account, + identity: identity, + group: group, + conn: conn + } do + attrs = %{ + name: "Mongo DB", + address: "**.mongodb.net", + ip_stack: :ipv6_only, + type: :dns + } + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/resources/new?site_id=#{group}") + + html = + lv + |> form("form") + |> render_change(resource: attrs) + + assert html =~ "Recommended for this Resource" + end + test "creates a resource on valid attrs when traffic filter form disabled", %{ account: account, group: group, diff --git a/elixir/apps/web/test/web/live/resources/show_test.exs b/elixir/apps/web/test/web/live/resources/show_test.exs index b83bb9f9b..6761d1013 100644 --- a/elixir/apps/web/test/web/live/resources/show_test.exs +++ b/elixir/apps/web/test/web/live/resources/show_test.exs @@ -15,6 +15,7 @@ defmodule Web.Live.Resources.ShowTest do Fixtures.Resources.create_resource( account: account, subject: subject, + ip_stack: :ipv4_only, connections: [%{gateway_group_id: group.id}] ) @@ -140,6 +141,7 @@ defmodule Web.Live.Resources.ShowTest do assert table["address"] =~ resource.address assert table["created"] =~ actor.name assert table["address description"] =~ resource.address_description + assert table["ip stack"] =~ "IPv4 only" for filter <- resource.filters do assert String.downcase(table["traffic restriction"]) =~ Atom.to_string(filter.protocol) diff --git a/website/src/app/kb/deploy/resources/readme.mdx b/website/src/app/kb/deploy/resources/readme.mdx index 5bf1bfaab..29f8e186a 100644 --- a/website/src/app/kb/deploy/resources/readme.mdx +++ b/website/src/app/kb/deploy/resources/readme.mdx @@ -91,6 +91,39 @@ for routing, where field validations are more restrictive. This can be useful to provide a bookmark to a service like `https://gitlab.company.com`, or give hints for accessing the service, like `10.0.0.1:2222`. + + +### IP stack + + + + + +Introduced in macOS 1.5.2, iOS 1.5.2, Android 1.5.0, Windows 1.5.0, and Linux +1.5.0. + + + +The **IP stack** setting for DNS Resources controls the types of DNS records +(`A` for IPv4, `AAAA` for IPv6) generated by the +[stub resolver](/kb/architecture/critical-sequences#dns-resolution). + +- **Dual-stack (Default):** Generates both `A` and `AAAA` records. +- **IPv4 only:** Generates only `A` records. +- **IPv6 only:** Generates only `AAAA` records. + +This setting primarily enhances compatibility with applications that might not +properly handle **ICMP unreachable errors**. These errors are typically sent by +the Gateway to indicate that the requested IP stack (e.g., IPv6) does not have +corresponding `A` or `AAAA` records for a connection attempt. + +Since some applications don't gracefully handle these errors, configuring the IP +stack to `IPv4 only` or `IPv6 only` can mitigate such issues by ensuring only +available records are returned. + +If you're unsure, it's generally recommended to leave this setting at +**Dual-stack**. + ### Traffic restrictions