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

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