feat(portal): configurable ip stack for DNS resources (#9303)

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.


<img width="750" alt="Screenshot 2025-06-02 at 10 48 39 AM"
src="https://github.com/user-attachments/assets/4dbcb6ae-685f-43ee-b9e8-1502b365a294"
/>

<img width="1174" alt="Screenshot 2025-06-02 at 11 05 53 AM"
src="https://github.com/user-attachments/assets/02d0a4b3-e6e8-4b6d-89fa-d3d999b5811e"
/>

---

Related:
https://firezonehq.slack.com/archives/C08KPQKJZKM/p1746720923535349
Related: #9300
Fixes: #9042
This commit is contained in:
Jamil
2025-06-02 19:24:41 -07:00
committed by GitHub
parent 0ca31307f4
commit 6fc7d2e4e0
22 changed files with 574 additions and 22 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
}
})

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -53,6 +53,34 @@ defmodule Web.CoreComponents do
"""
end
@doc """
Renders an inline code tag with formatting.
## Examples
<.code>def foo: do :bar</.code>
"""
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 </code> on the same line as the render_slot call, otherwise there will be
# an undesired trailing space in the output.
~H"""
<code id={@id} class={@class} {@rest} phx-no-format>
{render_slot(@inner_block)}</code>
"""
end
@doc """
Render a monospace code block suitable for copying and pasting content.

View File

@@ -330,9 +330,9 @@ defmodule Web.NavigationComponents do
## Examples
<.website_link href="/pricing>Pricing</.website_link>
<.website_link href="/kb/deploy/gateways" class="text-neutral-900">Deploy Gateway(s)</.website_link>
<.website_link href={~p"/contact/sales"}>Contact Sales</.website_link>
<.website_link path="/pricing>Pricing</.website_link>
<.website_link path="/kb/deploy/gateways" class="text-neutral-900">Deploy Gateway(s)</.website_link>
<.website_link path="/contact/sales">Contact Sales</.website_link>
"""
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>
<.website_link href="/kb/deploy/gateways" class="text-neutral-900">Deploy Gateway(s)</.website_link>
<.website_link href={~p"/contact/sales"}>Contact Sales</.website_link>
<.docs_action path="/kb/deploy/gateways" class="text-neutral-900">Deploy Gateway(s)</.docs_action>
"""
attr :path, :string, required: true
attr :fragment, :string, required: false, default: ""

View File

@@ -222,6 +222,77 @@ defmodule Web.Resources.Components do
"""
end
attr :form, :any, required: true
def ip_stack_form(assigns) do
~H"""
<div>
<legend class="text-xl mb-4">IP Stack</legend>
<p class="text-sm text-neutral-500 mb-4">
Determines what
<.website_link path="/kb/deploy/resources" fragment="ip-stack">record types</.website_link>
are generated by the stub resolver. If unsure, leave this unchanged.
</p>
<div class="mb-2">
<.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"}
>
<label>
<span class="font-medium">Dual-stack:</span>
<.code class="text-xs">A</.code>
and
<.code class="text-xs">AAAA</.code>
records
<span :if={ip_stack_recommendation(@form) == "dual"}>
<.badge type="info">Recommended for this Resource</.badge>
</span>
</label>
</.input>
</div>
<div class="mb-2">
<.input
id="resource-ip-stack--ipv4-only"
type="radio"
field={@form[:ip_stack]}
value="ipv4_only"
checked={"#{@form[:ip_stack].value}" == "ipv4_only"}
>
<label>
<span class="font-medium">IPv4:</span>
<.code class="text-xs">A</.code>
records only
<span :if={ip_stack_recommendation(@form) == "ipv4_only"}>
<.badge type="info">Recommended for this Resource</.badge>
</span>
</label>
</.input>
</div>
<div class="mb-2">
<.input
id="resource-ip-stack--ipv6-only"
type="radio"
field={@form[:ip_stack]}
value="ipv6_only"
checked={"#{@form[:ip_stack].value}" == "ipv6_only"}
>
<label>
<span class="font-medium">IPv6:</span>
<.code class="text-xs">AAAA</.code>
records only
<span :if={ip_stack_recommendation(@form) == "ipv6_only"}>
<.badge type="info">Recommended for this Resource</.badge>
</span>
</label>
</.input>
</div>
</div>
"""
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

View File

@@ -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}

View File

@@ -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

View File

@@ -170,6 +170,16 @@ defmodule Web.Resources.Show do
</span>
</:value>
</.vertical_table_row>
<.vertical_table_row :if={@resource.ip_stack}>
<:label>
IP Stack
</:label>
<:value>
<span>
{format_ip_stack(@resource.ip_stack)}
</span>
</:value>
</.vertical_table_row>
<.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

View File

@@ -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]"
]

View File

@@ -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,

View File

@@ -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)

View File

@@ -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`.
<PlanBadge plans={["starter", "team", "enterprise"]}>
### IP stack
</PlanBadge>
<Alert color="info">
Introduced in macOS 1.5.2, iOS 1.5.2, Android 1.5.0, Windows 1.5.0, and Linux
1.5.0.
</Alert>
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**.
<PlanBadge plans={["team", "enterprise"]}>
### Traffic restrictions