feat(portal): Wildcard dns with backwards compatibility (#6214)

If a new resource is created that will use format not supported by
previous client versions we temporarily show a warning:
<img width="683" alt="Screenshot 2024-08-07 at 2 28 57 PM"
src="https://github.com/user-attachments/assets/bbfdfc96-0c4b-4226-93c5-bc2b5fdb9d30">

It will also be excluded from `resources` list for older clients (below
1.2).

---------

Co-authored-by: Thomas Eizinger <thomas@eizinger.io>
This commit is contained in:
Andrew Dryga
2024-08-10 12:25:24 -06:00
committed by GitHub
parent 93d678aaea
commit 00b93f6b82
15 changed files with 298 additions and 39 deletions

View File

@@ -91,6 +91,9 @@ defmodule API.Client.Channel do
:ok = Enum.each(relays, &Relays.subscribe_to_relay_presence/1)
:ok = maybe_subscribe_for_relays_presence(relays, socket)
resources =
map_and_filter_compatible_resources(resources, socket.assigns.client.last_seen_version)
:ok =
push(socket, "init", %{
resources: Views.Resource.render_many(resources),
@@ -223,7 +226,20 @@ defmodule API.Client.Channel do
preload: [:gateway_groups]
) do
{:ok, resource} ->
push(socket, "resource_created_or_updated", Views.Resource.render(resource))
case map_and_filter_compatible_resource(
resource,
socket.assigns.client.last_seen_version
) do
{:cont, resource} ->
push(
socket,
"resource_created_or_updated",
Views.Resource.render(resource)
)
:drop ->
:ok
end
{:error, _reason} ->
:ok
@@ -269,7 +285,20 @@ defmodule API.Client.Channel do
preload: [:gateway_groups]
) do
{:ok, resource} ->
push(socket, "resource_created_or_updated", Views.Resource.render(resource))
case map_and_filter_compatible_resource(
resource,
socket.assigns.client.last_seen_version
) do
{:cont, resource} ->
push(
socket,
"resource_created_or_updated",
Views.Resource.render(resource)
)
:drop ->
:ok
end
{:error, _reason} ->
:ok
@@ -302,7 +331,20 @@ defmodule API.Client.Channel do
preload: [:gateway_groups]
) do
{:ok, resource} ->
push(socket, "resource_created_or_updated", Views.Resource.render(resource))
case map_and_filter_compatible_resource(
resource,
socket.assigns.client.last_seen_version
) do
{:cont, resource} ->
push(
socket,
"resource_created_or_updated",
Views.Resource.render(resource)
)
:drop ->
:ok
end
{:error, _reason} ->
:ok
@@ -699,4 +741,27 @@ defmodule API.Client.Channel do
gateways -> {:ok, gateways}
end
end
defp map_and_filter_compatible_resources(resources, client_version) do
Enum.flat_map(resources, fn resource ->
case map_and_filter_compatible_resource(resource, client_version) do
{:cont, resource} -> [resource]
:drop -> []
end
end)
end
defp map_and_filter_compatible_resource(resource, client_version) do
if Version.match?(client_version, ">= 1.2.0") do
{:cont, resource}
else
resource.address
|> String.codepoints()
|> Resources.map_resource_address()
|> case do
{:cont, address} -> {:cont, %{resource | address: address}}
:drop -> :drop
end
end
end
end

View File

@@ -15,10 +15,7 @@ defmodule API.Client.Views.Resource do
id: resource.id,
type: :cidr,
address: address,
# TODO: This is a workaround due to clients expecting address_description not
# to be null. Remove this to send null address_description on or after 8/13/24
# once we can reasonably expect clients to have upgraded.
address_description: resource.address_description || address,
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)
@@ -30,10 +27,7 @@ defmodule API.Client.Views.Resource do
id: resource.id,
type: resource.type,
address: resource.address,
# TODO: This is a workaround due to clients expecting address_description not
# to be null. Remove this to send null address_description on or after 8/13/24
# once we can reasonably expect clients to have upgraded.
address_description: resource.address_description || resource.address,
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)

View File

@@ -289,6 +289,92 @@ defmodule API.Client.ChannelTest do
}
end
test "sends backwards compatible list of resources if client version is below 1.2", %{
account: account,
subject: subject,
client: client,
gateway_group: gateway_group,
actor_group: actor_group
} do
assert_push "init", %{}
star_mapped_resource =
Fixtures.Resources.create_resource(
address: "**.glob-example.com",
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
question_mark_mapped_resource =
Fixtures.Resources.create_resource(
address: "*.question-example.com",
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
mid_question_mark_mapped_resource =
Fixtures.Resources.create_resource(
address: "foo.*.example.com",
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
mid_star_mapped_resource =
Fixtures.Resources.create_resource(
address: "foo.**.glob-example.com",
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
mid_single_char_mapped_resource =
Fixtures.Resources.create_resource(
address: "us-east?-d.glob-example.com",
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
for resource <- [
star_mapped_resource,
question_mark_mapped_resource,
mid_question_mark_mapped_resource,
mid_star_mapped_resource,
mid_single_char_mapped_resource
] do
Fixtures.Policies.create_policy(
account: account,
actor_group: actor_group,
resource: resource
)
end
API.Client.Socket
|> socket("client:#{client.id}", %{
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
})
|> subscribe_and_join(API.Client.Channel, "client")
assert_push "init", %{
resources: resources
}
resource_addresses = Enum.map(resources, & &1.address)
assert "*.glob-example.com" in resource_addresses
assert "?.question-example.com" in resource_addresses
assert "foo.*.example.com" not in resource_addresses
assert "foo.?.example.com" not in resource_addresses
assert "foo.**.glob-example.com" not in resource_addresses
assert "foo.*.glob-example.com" not in resource_addresses
assert "us-east?-d.glob-example.com" not in resource_addresses
assert "us-east*-d.glob-example.com" not in resource_addresses
end
test "subscribes for client events", %{
client: client
} do

View File

@@ -283,4 +283,30 @@ defmodule Domain.Resources do
|> account_topic()
|> PubSub.broadcast(payload)
end
@doc false
# This is the code that will be removed in future version of Firezone (in 1.3-1.4)
# and is reused to prevent breaking changes
def map_resource_address(address, acc \\ "")
def map_resource_address(["*", "*" | rest], ""),
do: map_resource_address(rest, "*")
def map_resource_address(["*", "*" | _rest], _acc),
do: :drop
def map_resource_address(["*" | rest], ""),
do: map_resource_address(rest, "?")
def map_resource_address(["*" | _rest], _acc),
do: :drop
def map_resource_address(["?" | _rest], _acc),
do: :drop
def map_resource_address([char | rest], acc),
do: map_resource_address(rest, acc <> char)
def map_resource_address([], acc),
do: {:cont, acc}
end

View File

@@ -59,7 +59,7 @@ defmodule Domain.Resources.Resource.Changeset do
|> validate_does_not_end_with(:address, "localhost",
message: "localhost cannot be used, please add a DNS alias to /etc/hosts instead"
)
|> validate_format(:address, ~r/^([*?]\.)?[\p{L}0-9-]{1,63}(\.[\p{L}0-9-]{1,63})*$/iu)
|> validate_format(:address, ~r/^[\p{L}\*\?0-9-]{1,63}(\.[\p{L}\*\?0-9-]{1,63})*$/iu)
end
defp validate_cidr_address(changeset) do

View File

@@ -0,0 +1,11 @@
defmodule Domain.Repo.Migrations.MigrateDnsResourcePatterns do
use Ecto.Migration
def change do
execute("""
UPDATE resources
SET address = replace(replace(address, '*', '**'), '?', '*')
WHERE type = 'dns'
""")
end
end

View File

@@ -640,8 +640,8 @@ IO.puts("")
Resources.create_resource(
%{
type: :dns,
name: "*.firez.one",
address: "*.firez.one",
name: "**.firez.one",
address: "**.firez.one",
address_description: "https://firez.one/",
connections: [%{gateway_group_id: gateway_group.id}],
filters: []
@@ -653,8 +653,8 @@ IO.puts("")
Resources.create_resource(
%{
type: :dns,
name: "?.firezone.dev",
address: "?.firezone.dev",
name: "*.firezone.dev",
address: "*.firezone.dev",
address_description: "https://firezone.dev/",
connections: [%{gateway_group_id: gateway_group.id}],
filters: []
@@ -751,8 +751,8 @@ IO.puts("")
Resources.create_resource(
%{
type: :dns,
name: "?.httpbin",
address: "?.httpbin",
name: "*.httpbin",
address: "*.httpbin",
address_description: "http://httpbin/",
connections: [%{gateway_group_id: gateway_group.id}],
filters: [

View File

@@ -2752,7 +2752,7 @@ defmodule Domain.ActorsTest do
assert {:ok, _actor} = disable_actor(actor, subject)
expires_at = Repo.one(Domain.Flows.Flow).expires_at
assert DateTime.diff(expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expires_at, DateTime.utc_now()) <= 1
end
test "returns error when trying to disable the last admin actor" do
@@ -3003,7 +3003,7 @@ defmodule Domain.ActorsTest do
assert {:ok, _actor} = delete_actor(actor, subject)
expires_at = Repo.one(Domain.Flows.Flow).expires_at
assert DateTime.diff(expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expires_at, DateTime.utc_now()) <= 1
end
test "returns error when trying to delete the last admin actor", %{

View File

@@ -933,7 +933,7 @@ defmodule Domain.AuthTest do
assert {:ok, _provider} = disable_provider(provider, subject)
expires_at = Repo.one(Domain.Flows.Flow).expires_at
assert DateTime.diff(expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expires_at, DateTime.utc_now()) <= 1
end
test "returns error when trying to disable the last provider", %{
@@ -1153,7 +1153,7 @@ defmodule Domain.AuthTest do
assert {:ok, _provider} = delete_provider(provider, subject)
expires_at = Repo.one(Domain.Flows.Flow).expires_at
assert DateTime.diff(expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expires_at, DateTime.utc_now()) <= 1
end
test "returns error when trying to delete the last provider", %{
@@ -2876,7 +2876,7 @@ defmodule Domain.AuthTest do
assert delete_identities_for(actor, subject) == :ok
expires_at = Repo.one(Domain.Flows.Flow).expires_at
assert DateTime.diff(expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expires_at, DateTime.utc_now()) <= 1
end
test "updates dynamic group memberships", %{

View File

@@ -930,7 +930,7 @@ defmodule Domain.FlowsTest do
actor_group: actor_group
} do
assert {:ok, [expired_flow]} = expire_flows_for(actor_group)
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) <= 1
assert expired_flow.id == flow.id
end
@@ -939,7 +939,7 @@ defmodule Domain.FlowsTest do
identity: identity
} do
assert {:ok, [expired_flow]} = expire_flows_for(identity)
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) <= 1
assert expired_flow.id == flow.id
end
end
@@ -974,7 +974,7 @@ defmodule Domain.FlowsTest do
policy: policy
} do
assert {:ok, [expired_flow]} = expire_flows_for(actor.id, policy.actor_group_id)
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) <= 1
assert expired_flow.id == flow.id
end
@@ -984,7 +984,7 @@ defmodule Domain.FlowsTest do
subject: subject
} do
assert {:ok, [expired_flow]} = expire_flows_for(actor, subject)
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) <= 1
assert expired_flow.id == flow.id
end
@@ -994,7 +994,7 @@ defmodule Domain.FlowsTest do
subject: subject
} do
assert {:ok, [expired_flow]} = expire_flows_for(policy, subject)
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) <= 1
assert expired_flow.id == flow.id
end
@@ -1004,7 +1004,7 @@ defmodule Domain.FlowsTest do
subject: subject
} do
assert {:ok, [expired_flow]} = expire_flows_for(resource, subject)
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) <= 1
assert expired_flow.id == flow.id
end
@@ -1014,7 +1014,7 @@ defmodule Domain.FlowsTest do
subject: subject
} do
assert {:ok, [expired_flow]} = expire_flows_for(actor_group, subject)
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) <= 1
assert expired_flow.id == flow.id
end
@@ -1024,7 +1024,7 @@ defmodule Domain.FlowsTest do
subject: subject
} do
assert {:ok, [expired_flow]} = expire_flows_for(identity, subject)
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) <= 1
assert expired_flow.id == flow.id
end
@@ -1034,7 +1034,7 @@ defmodule Domain.FlowsTest do
subject: subject
} do
assert {:ok, [expired_flow]} = expire_flows_for(provider, subject)
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expired_flow.expires_at, DateTime.utc_now()) <= 1
assert expired_flow.id == flow.id
end

View File

@@ -511,7 +511,7 @@ defmodule Domain.PoliciesTest do
assert {:ok, _policy} = disable_policy(policy, subject)
expires_at = Repo.one(Domain.Flows.Flow).expires_at
assert DateTime.diff(expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expires_at, DateTime.utc_now()) <= 1
end
test "broadcasts an account message when policy is disabled", %{
@@ -888,7 +888,7 @@ defmodule Domain.PoliciesTest do
assert {:ok, [_deleted_policy]} = delete_policies_for(resource, subject)
expires_at = Repo.one(Domain.Flows.Flow).expires_at
assert DateTime.diff(expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expires_at, DateTime.utc_now()) <= 1
end
test "broadcasts an account message when policy is deleted", %{

View File

@@ -917,7 +917,7 @@ defmodule Domain.ResourcesTest do
}
end
test "validates dns address", %{subject: subject} do
test "validates dns address", %{account: account, subject: subject} do
attrs = %{"address" => String.duplicate("a", 256), "type" => "dns"}
assert {:error, changeset} = create_resource(attrs, subject)
assert "should be at most 253 character(s)" in errors_on(changeset).address
@@ -925,6 +925,50 @@ defmodule Domain.ResourcesTest do
attrs = %{"address" => "a", "type" => "dns"}
assert {:error, changeset} = create_resource(attrs, subject)
refute Map.has_key?(errors_on(changeset), :address)
for dns <- [
"**.example.com",
"example.com",
"app.**.example.com",
"app.bar.foo.example.com",
"**.example.com",
"foo.example.com",
"**.example.com",
"foo.bar.example.com",
"*.example.com",
"foo.example.com",
"*.example.com",
"example.com",
"foo.*.example.com",
"foo.bar.example.com",
"app.*.*.example.com",
"app.foo.bar.example.com",
"app.f??.example.com",
"app.foo.example.com",
"app.example.com",
"app.example.com",
"*?*.example.com",
"app.example.com",
"app.**.web.**.example.com",
"app.web.example.com",
"app.*.example.com",
"app.*com",
"app?com",
"google.com",
"myhost"
] do
gateway = Fixtures.Gateways.create_gateway(account: account)
attrs =
Fixtures.Resources.resource_attrs(
address: dns,
connections: [
%{gateway_group_id: gateway.group_id}
]
)
assert {:ok, _resource} = create_resource(attrs, subject)
end
end
test "validates cidr address", %{subject: subject} do

View File

@@ -524,7 +524,7 @@ defmodule Domain.TokensTest do
assert {:ok, _token} = delete_token_for(subject)
expires_at = Repo.one(Domain.Flows.Flow).expires_at
assert DateTime.diff(expires_at, DateTime.utc_now()) < 1
assert DateTime.diff(expires_at, DateTime.utc_now()) <= 1
end
test "does not delete tokens for other actors", %{account: account, subject: subject} do

View File

@@ -134,12 +134,32 @@ defmodule Web.Resources.New do
disabled={is_nil(@form[:type].value)}
required
/>
<p
:if={
@form[:type].value == :dns and
is_binary(@form[:address].value) and
@form[:address].value
|> String.codepoints()
|> Resources.map_resource_address() == :drop
}
class="flex items-center gap-2 text-sm leading-6 text-accent-600 mt-2 w-full"
>
<.icon name="hero-exclamation-triangle" class="w-4 h-4" />
This is an advanced address format. This Resource will be available to Clients v1.2.0 and higher only.
</p>
<p :if={@form[:type].value == :dns} class="mt-2 text-xs text-neutral-500">
Wildcards are supported:<br />
<code class="ml-2 px-0.5 font-semibold">**.c.com</code>
matches any level of subdomains (e.g., <code class="px-0.5 font-semibold">foo.c.com</code>,
<code class="px-0.5 font-semibold">bar.foo.c.com</code>
and <code class="px-0.5 font-semibold">c.com</code>).<br />
<code class="ml-2 px-0.5 font-semibold">*.c.com</code>
will match recursively (<code class="px-0.5 font-semibold">b.c.com</code> and <code class="px-0.5 font-semibold">a.b.c.com</code>).<br />
<code class="ml-2 px-0.5 font-semibold">?.c.com</code>
will match top-level subdomains only (<code class="px-0.5 font-semibold">b.c.com</code>).
matches a zero and single level subdomains (e.g.,
<code class="px-0.5 font-semibold">foo.c.com</code>
and <code class="px-0.5 font-semibold">c.com</code>
but not <code class="px-0.5 font-semibold">bar.foo.c.com</code>). <br />
<code class="ml-2 px-0.5 font-semibold">us-east?.c.com</code>
matches a single character (e.g., <code class="px-0.5 font-semibold">us-east1.c.com</code>).
</p>
<p :if={@form[:type].value == :ip} class="mt-2 text-xs text-neutral-500">
IPv4 and IPv6 addresses are supported.

View File

@@ -30,6 +30,19 @@ From there, you can select the type of Resource you want to create:
- To non-recursively match all subdomains, use a question mark, such as
`?.example.com`. This will match `example.com`, `sub1.example.com`, and
`sub2.example.com` **but not** `sub1.sub2.example.com`.
{/* - By default, the pattern will only match the exact name you enter. */}
{/* - To match all subdomains recursively, use a double-wildcard, such as */}
{/* `**.example.com`. This will match `example.com`, `sub.example.com`, and */}
{/* `sub.sub.example.com`. */}
{/* - To match all subdomains non-recursively, use a single wildcard, such as */}
{/* `*.example.com`. This will match `sub.example.com` but not */}
{/* `sub.sub.example.com`. */}
{/* - To match a single character, use a question mark, such as */}
{/* `us-east?.example.com`. This will match `us-east1.example.com` but not */}
{/* `us-eastXY.example.com`. */}
{/* - Wildcards can be placed between domain components, e.g., `foo.*.example.com` */}
{/* will match `foo.bar.example.com` or `foo.**.example.com` will match */}
{/* `foo.bar.baz.example.com`. */}
- **IP**: A single IPv4 or IPv6 address
- **CIDR**: A range of IPv4 or IPv6 addresses in CIDR notation, such as
`10.1.2.0/24` or `2001:db8::/48`