diff --git a/elixir/apps/api/lib/api/client/channel.ex b/elixir/apps/api/lib/api/client/channel.ex index 5b5b5ac04..1b9819135 100644 --- a/elixir/apps/api/lib/api/client/channel.ex +++ b/elixir/apps/api/lib/api/client/channel.ex @@ -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 diff --git a/elixir/apps/api/lib/api/client/views/resource.ex b/elixir/apps/api/lib/api/client/views/resource.ex index 88d73175e..bcbd91481 100644 --- a/elixir/apps/api/lib/api/client/views/resource.ex +++ b/elixir/apps/api/lib/api/client/views/resource.ex @@ -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) diff --git a/elixir/apps/api/test/api/client/channel_test.exs b/elixir/apps/api/test/api/client/channel_test.exs index d68a72d35..14b8b8040 100644 --- a/elixir/apps/api/test/api/client/channel_test.exs +++ b/elixir/apps/api/test/api/client/channel_test.exs @@ -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 diff --git a/elixir/apps/domain/lib/domain/resources.ex b/elixir/apps/domain/lib/domain/resources.ex index 3d891fc46..318e08061 100644 --- a/elixir/apps/domain/lib/domain/resources.ex +++ b/elixir/apps/domain/lib/domain/resources.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/resources/resource/changeset.ex b/elixir/apps/domain/lib/domain/resources/resource/changeset.ex index b01aec86b..676a46874 100644 --- a/elixir/apps/domain/lib/domain/resources/resource/changeset.ex +++ b/elixir/apps/domain/lib/domain/resources/resource/changeset.ex @@ -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 diff --git a/elixir/apps/domain/priv/repo/migrations/20240725202649_migrate_dns_resource_patterns.exs b/elixir/apps/domain/priv/repo/migrations/20240725202649_migrate_dns_resource_patterns.exs new file mode 100644 index 000000000..550b093c4 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20240725202649_migrate_dns_resource_patterns.exs @@ -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 diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index 5014f81c5..93288d754 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -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: [ diff --git a/elixir/apps/domain/test/domain/actors_test.exs b/elixir/apps/domain/test/domain/actors_test.exs index 80d0180c2..46f2a2f78 100644 --- a/elixir/apps/domain/test/domain/actors_test.exs +++ b/elixir/apps/domain/test/domain/actors_test.exs @@ -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", %{ diff --git a/elixir/apps/domain/test/domain/auth_test.exs b/elixir/apps/domain/test/domain/auth_test.exs index db76bb9ab..1b2c58c47 100644 --- a/elixir/apps/domain/test/domain/auth_test.exs +++ b/elixir/apps/domain/test/domain/auth_test.exs @@ -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", %{ diff --git a/elixir/apps/domain/test/domain/flows_test.exs b/elixir/apps/domain/test/domain/flows_test.exs index c900ee60f..1b96f27c7 100644 --- a/elixir/apps/domain/test/domain/flows_test.exs +++ b/elixir/apps/domain/test/domain/flows_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/policies_test.exs b/elixir/apps/domain/test/domain/policies_test.exs index 63780e9e6..15ed8981b 100644 --- a/elixir/apps/domain/test/domain/policies_test.exs +++ b/elixir/apps/domain/test/domain/policies_test.exs @@ -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", %{ diff --git a/elixir/apps/domain/test/domain/resources_test.exs b/elixir/apps/domain/test/domain/resources_test.exs index b54049dc0..d89300380 100644 --- a/elixir/apps/domain/test/domain/resources_test.exs +++ b/elixir/apps/domain/test/domain/resources_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/tokens_test.exs b/elixir/apps/domain/test/domain/tokens_test.exs index be36be4aa..ffcfef1c1 100644 --- a/elixir/apps/domain/test/domain/tokens_test.exs +++ b/elixir/apps/domain/test/domain/tokens_test.exs @@ -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 diff --git a/elixir/apps/web/lib/web/live/resources/new.ex b/elixir/apps/web/lib/web/live/resources/new.ex index 9662472d5..6cd95ca75 100644 --- a/elixir/apps/web/lib/web/live/resources/new.ex +++ b/elixir/apps/web/lib/web/live/resources/new.ex @@ -134,12 +134,32 @@ defmodule Web.Resources.New do disabled={is_nil(@form[:type].value)} required /> +

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. +

Wildcards are supported:
+ **.c.com + matches any level of subdomains (e.g., foo.c.com, + bar.foo.c.com + and c.com).
*.c.com - will match recursively (b.c.com and a.b.c.com).
- ?.c.com - will match top-level subdomains only (b.c.com). + matches a zero and single level subdomains (e.g., + foo.c.com + and c.com + but not bar.foo.c.com).
+ us-east?.c.com + matches a single character (e.g., us-east1.c.com).

IPv4 and IPv6 addresses are supported. diff --git a/website/src/app/kb/deploy/resources/readme.mdx b/website/src/app/kb/deploy/resources/readme.mdx index e8599b7b1..fa63b331c 100644 --- a/website/src/app/kb/deploy/resources/readme.mdx +++ b/website/src/app/kb/deploy/resources/readme.mdx @@ -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`