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