mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]"
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user