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