diff --git a/elixir/apps/api/lib/api/client/views/interface.ex b/elixir/apps/api/lib/api/client/views/interface.ex index 3117a9174..b262c989f 100644 --- a/elixir/apps/api/lib/api/client/views/interface.ex +++ b/elixir/apps/api/lib/api/client/views/interface.ex @@ -1,29 +1,43 @@ defmodule API.Client.Views.Interface do alias Domain.Clients + @doh_providers %{ + google: [%{url: "https://dns.google/dns-query"}], + quad9: [%{url: "https://dns.quad9.net/dns-query"}], + cloudflare: [%{url: "https://cloudflare-dns.com/dns-query"}], + opendns: [%{url: "https://doh.opendns.com/dns-query"}] + } + def render(%Clients.Client{} = client) do - clients_upstream_dns = Map.get(client.account.config, :clients_upstream_dns, []) + dns_config = Map.get(client.account.config, :clients_upstream_dns) - # TODO: DOH RESOLVERS - # Remove this old field once clients are upgraded. - # old field - append normalized port - upstream_dns = - clients_upstream_dns - |> Enum.map(fn %{address: address} = dns_config -> - ip = if String.contains?(address, ":"), do: "[#{address}]", else: address - Map.from_struct(%{dns_config | address: "#{ip}:53"}) - end) + {upstream_do53, upstream_doh, upstream_dns} = + case dns_config do + %{type: :custom, addresses: addresses} when is_list(addresses) and addresses != [] -> + do53 = Enum.map(addresses, fn %{address: address} -> %{ip: address} end) + # Legacy field - append normalized port for backwards compatibility + legacy_dns = + Enum.map(addresses, fn %{address: address} -> + ip = if String.contains?(address, ":"), do: "[#{address}]", else: address + %{protocol: :ip_port, address: "#{ip}:53"} + end) - # new field - no port - upstream_do53 = - clients_upstream_dns - |> Enum.map(fn %{address: address} -> %{ip: address} end) + {do53, [], legacy_dns} + + %{type: :secure, doh_provider: provider} + when provider in [:google, :quad9, :cloudflare, :opendns] -> + doh = @doh_providers[provider] || [] + {[], doh, []} + + _ -> + # :system or nil or :custom with no addresses - use system resolvers + {[], [], []} + end %{ search_domain: client.account.config.search_domain, upstream_do53: upstream_do53, - # Populate from DB once present. - upstream_doh: [], + upstream_doh: upstream_doh, ipv4: client.ipv4, ipv6: client.ipv6, diff --git a/elixir/apps/api/test/api/client/channel_test.exs b/elixir/apps/api/test/api/client/channel_test.exs index 768eedff1..cd825f073 100644 --- a/elixir/apps/api/test/api/client/channel_test.exs +++ b/elixir/apps/api/test/api/client/channel_test.exs @@ -7,11 +7,14 @@ defmodule API.Client.ChannelTest do account = Fixtures.Accounts.create_account( config: %{ - clients_upstream_dns: [ - %{protocol: "ip_port", address: "1:2:3:4:5:6:7:8"}, - %{protocol: "ip_port", address: "1.1.1.1"}, - %{protocol: "ip_port", address: "8.8.8.8"} - ], + clients_upstream_dns: %{ + type: :custom, + addresses: [ + %{address: "1:2:3:4:5:6:7:8"}, + %{address: "1.1.1.1"}, + %{address: "8.8.8.8"} + ] + }, search_domain: "example.com" }, features: %{ diff --git a/elixir/apps/api/test/api/client/views/interface_test.exs b/elixir/apps/api/test/api/client/views/interface_test.exs new file mode 100644 index 000000000..1dceefe28 --- /dev/null +++ b/elixir/apps/api/test/api/client/views/interface_test.exs @@ -0,0 +1,71 @@ +defmodule API.Client.Views.InterfaceTest do + use API.ChannelCase, async: true + alias API.Client.Views.Interface + + describe "render/1" do + test "renders interface config with Do53 resolvers" do + account = + Fixtures.Accounts.create_account( + config: %{ + clients_upstream_dns: %{ + type: :custom, + addresses: [ + %{address: "1.1.1.1"}, + %{address: "8.8.8.8"} + ] + }, + search_domain: "example.com" + } + ) + + client = Fixtures.Clients.create_client(account: account) |> Domain.Repo.preload(:account) + + result = Interface.render(client) + + assert result.search_domain == "example.com" + assert result.ipv4 == client.ipv4 + assert result.ipv6 == client.ipv6 + + # New Do53 format + assert result.upstream_do53 == [ + %{ip: "1.1.1.1"}, + %{ip: "8.8.8.8"} + ] + + # Legacy format + assert result.upstream_dns == [ + %{protocol: :ip_port, address: "1.1.1.1:53"}, + %{protocol: :ip_port, address: "8.8.8.8:53"} + ] + + # No DoH provider set + assert result.upstream_doh == [] + end + + test "renders interface config with DoH provider despite addresses if type is :secure" do + account = + Fixtures.Accounts.create_account( + config: %{ + clients_upstream_dns: %{ + type: :secure, + doh_provider: :opendns, + addresses: [ + %{address: "1.1.1.1"} + ] + } + } + ) + + client = Fixtures.Clients.create_client(account: account) |> Domain.Repo.preload(:account) + + result = Interface.render(client) + + assert result.upstream_doh == [ + %{url: "https://doh.opendns.com/dns-query"} + ] + + assert result.upstream_do53 == [] + assert result.upstream_dns == [] + end + end +end diff --git a/elixir/apps/domain/lib/domain/accounts/config.ex b/elixir/apps/domain/lib/domain/accounts/config.ex index edd45c424..708e6d58c 100644 --- a/elixir/apps/domain/lib/domain/accounts/config.ex +++ b/elixir/apps/domain/lib/domain/accounts/config.ex @@ -5,11 +5,20 @@ defmodule Domain.Accounts.Config do embedded_schema do field :search_domain, :string - embeds_many :clients_upstream_dns, ClientsUpstreamDNS, + embeds_one :clients_upstream_dns, ClientsUpstreamDns, primary_key: false, - on_replace: :delete do - field :protocol, Ecto.Enum, values: [:ip_port, :dns_over_tls, :dns_over_http] - field :address, :string + on_replace: :update do + field :type, Ecto.Enum, values: [:system, :secure, :custom], default: :system + + field :doh_provider, Ecto.Enum, + values: [:google, :opendns, :cloudflare, :quad9], + default: :google + + embeds_many :addresses, Address, + primary_key: false, + on_replace: :delete do + field :address, :string + end end embeds_one :notifications, Notifications, @@ -22,13 +31,14 @@ defmodule Domain.Accounts.Config do end end - def supported_dns_protocols, do: ~w[ip_port]a - @doc """ Returns a default config with defaults set """ def default_config do %__MODULE__{ + clients_upstream_dns: %__MODULE__.ClientsUpstreamDns{ + type: :system + }, notifications: %__MODULE__.Notifications{ outdated_gateway: %Domain.Accounts.Config.Notifications.Email{enabled: true} } @@ -39,6 +49,7 @@ defmodule Domain.Accounts.Config do Ensures a config has proper defaults """ def ensure_defaults(%__MODULE__{} = config) do + # Ensure notifications defaults notifications = config.notifications || %__MODULE__.Notifications{} outdated_gateway = diff --git a/elixir/apps/domain/lib/domain/accounts/config/changeset.ex b/elixir/apps/domain/lib/domain/accounts/config/changeset.ex index cbd0700ec..0db640179 100644 --- a/elixir/apps/domain/lib/domain/accounts/config/changeset.ex +++ b/elixir/apps/domain/lib/domain/accounts/config/changeset.ex @@ -3,19 +3,35 @@ defmodule Domain.Accounts.Config.Changeset do alias Domain.Types.IPPort alias Domain.Accounts.Config - @default_dns_port 53 - def changeset(config \\ %Config{}, attrs) do config |> cast(attrs, [:search_domain]) - |> cast_embed(:clients_upstream_dns, - with: &client_upstream_dns_changeset/2, - sort_param: :clients_upstream_dns_sort, - drop_param: :clients_upstream_dns_drop - ) + |> cast_embed(:clients_upstream_dns, with: &clients_upstream_dns_changeset/2) |> cast_embed(:notifications, with: ¬ifications_changeset/2) |> validate_search_domain() - |> validate_unique_clients_upstream_dns() + end + + def clients_upstream_dns_changeset(clients_upstream_dns \\ %Config.ClientsUpstreamDns{}, attrs) do + clients_upstream_dns + |> cast(attrs, [:type, :doh_provider]) + |> validate_required([:type]) + |> cast_embed(:addresses, + with: &address_changeset/2, + sort_param: :addresses_sort, + drop_param: :addresses_drop + ) + |> validate_doh_provider_for_secure() + |> validate_addresses_for_type() + |> validate_custom_has_addresses() + end + + def address_changeset(address \\ %Config.ClientsUpstreamDns.Address{}, attrs) do + address + |> cast(attrs, [:address]) + |> validate_required([:address]) + |> trim_change(:address) + |> validate_ip_address() + |> validate_reserved_ip_exclusion() end defp validate_search_domain(changeset) do @@ -46,48 +62,67 @@ defmodule Domain.Accounts.Config.Changeset do end) end - defp validate_unique_clients_upstream_dns(changeset) do - with false <- has_errors?(changeset, :clients_upstream_dns), - {_data_or_changes, client_upstream_dns} <- fetch_field(changeset, :clients_upstream_dns) do - addresses = - client_upstream_dns - |> Enum.map(&normalize_dns_address/1) - |> Enum.reject(&is_nil/1) - - if addresses -- Enum.uniq(addresses) == [] do - changeset + defp validate_doh_provider_for_secure(changeset) do + with true <- changeset.valid?, + {_data_or_changes, type} <- fetch_field(changeset, :type), + {_data_or_changes, doh_provider} <- fetch_field(changeset, :doh_provider) do + if type == :secure and is_nil(doh_provider) do + add_error(changeset, :doh_provider, "must be selected when using secure DNS") else - add_error(changeset, :clients_upstream_dns, "all addresses must be unique") + changeset end else _ -> changeset end end - def client_upstream_dns_changeset(client_upstream_dns \\ %Config.ClientsUpstreamDNS{}, attrs) do - client_upstream_dns - |> cast(attrs, [:protocol, :address]) - |> validate_required([:protocol, :address]) - |> trim_change(:address) - |> validate_inclusion(:protocol, Config.supported_dns_protocols(), - message: "this type of DNS provider is not supported yet" - ) - |> validate_address() - |> validate_reserved_ip_exclusion() - end + defp validate_addresses_for_type(changeset) do + with true <- changeset.valid?, + {_data_or_changes, type} <- fetch_field(changeset, :type), + {_data_or_changes, addresses} <- fetch_field(changeset, :addresses) do + case type do + :custom -> + validate_custom_addresses(changeset, addresses) - defp validate_address(changeset) do - if has_errors?(changeset, :protocol) do - changeset - else - case fetch_field(changeset, :protocol) do - {_changes_or_data, :ip_port} -> validate_ip_port(changeset) - :error -> changeset + _ -> + # For system and secure DNS, addresses are ignored but not cleared + # This allows users to switch between types without losing their custom addresses + changeset end + else + _ -> changeset end end - defp validate_ip_port(changeset) do + defp validate_custom_addresses(changeset, addresses) do + # Check for unique addresses + normalized_addresses = + addresses + |> Enum.map(&normalize_dns_address/1) + |> Enum.reject(&is_nil/1) + + if normalized_addresses -- Enum.uniq(normalized_addresses) == [] do + changeset + else + add_error(changeset, :addresses, "all addresses must be unique") + end + end + + defp validate_custom_has_addresses(changeset) do + with true <- changeset.valid?, + {_data_or_changes, type} <- fetch_field(changeset, :type), + {_data_or_changes, addresses} <- fetch_field(changeset, :addresses) do + if type == :custom and Enum.empty?(addresses) do + add_error(changeset, :addresses, "must have at least one custom resolver") + else + changeset + end + else + _ -> changeset + end + end + + defp validate_ip_address(changeset) do validate_change(changeset, :address, fn :address, address -> case IPPort.cast(address) do {:ok, %IPPort{port: nil}} -> [] @@ -115,19 +150,9 @@ defmodule Domain.Accounts.Config.Changeset do |> cast_embed(:idp_sync_error, with: &Config.Notifications.Email.Changeset.changeset/2) end - defp normalize_dns_address(%Config.ClientsUpstreamDNS{protocol: :ip_port, address: address}) do - case IPPort.cast(address) do - {:ok, address} -> - address - |> IPPort.put_default_port(@default_dns_port) - |> to_string() - - _ -> - address - end - end - - defp normalize_dns_address(%Config.ClientsUpstreamDNS{address: address}) do + defp normalize_dns_address(%Config.ClientsUpstreamDns.Address{address: address}) do address end + + defp normalize_dns_address(_), do: nil end diff --git a/elixir/apps/domain/priv/repo/migrations/20251114111414_simplify_account_client_dns.exs b/elixir/apps/domain/priv/repo/migrations/20251114111414_simplify_account_client_dns.exs new file mode 100644 index 000000000..b90af5260 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20251114111414_simplify_account_client_dns.exs @@ -0,0 +1,41 @@ +defmodule Domain.Repo.Migrations.SimplifyAccountClientDNS do + use Ecto.Migration + + def up do + execute(""" + UPDATE accounts + SET config = jsonb_set( + config, + '{clients_upstream_dns}', + COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'address', + CASE + -- Strip port from IPv4 address (e.g., "1.1.1.1:53" -> "1.1.1.1") + WHEN dns->>'address' ~ '^[0-9.]+:[0-9]+$' THEN + substring(dns->>'address' from '^([0-9.]+):[0-9]+$') + -- Strip port from IPv6 address (e.g., "[::1]:53" -> "::1") + WHEN dns->>'address' ~ '^\\\[.*\\\]:[0-9]+$' THEN + substring(dns->>'address' from '^\\\[(.+)\\\]:[0-9]+$') + -- Address without port, use as-is + ELSE + dns->>'address' + END + ) + ) + FROM jsonb_array_elements(config->'clients_upstream_dns') AS dns + WHERE dns->>'address' IS NOT NULL AND dns->>'address' != '' + ), + '[]'::jsonb + ) + ) + WHERE config->'clients_upstream_dns' IS NOT NULL + AND jsonb_typeof(config->'clients_upstream_dns') = 'array' + """) + end + + def down do + end +end diff --git a/elixir/apps/domain/priv/repo/migrations/20251118053209_refactor_clients_upstream_dns.exs b/elixir/apps/domain/priv/repo/migrations/20251118053209_refactor_clients_upstream_dns.exs new file mode 100644 index 000000000..d532281b6 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20251118053209_refactor_clients_upstream_dns.exs @@ -0,0 +1,42 @@ +defmodule Domain.Repo.Migrations.RefactorClientsUpstreamDns do + use Ecto.Migration + + def up do + execute(""" + UPDATE accounts + SET config = jsonb_set( + config, + '{clients_upstream_dns}', + CASE + WHEN jsonb_array_length(config->'clients_upstream_dns') = 0 THEN + '{"type": "system", "addresses": []}'::jsonb + ELSE + jsonb_build_object( + 'type', 'custom', + 'addresses', config->'clients_upstream_dns' + ) + END + ) + WHERE config->'clients_upstream_dns' IS NOT NULL + AND jsonb_typeof(config->'clients_upstream_dns') = 'array' + """) + end + + def down do + execute(""" + UPDATE accounts + SET config = jsonb_set( + config, + '{clients_upstream_dns}', + CASE + WHEN config->'clients_upstream_dns'->>'type' = 'custom' THEN + COALESCE(config->'clients_upstream_dns'->'addresses', '[]'::jsonb) + ELSE + '[]'::jsonb + END + ) + WHERE config->'clients_upstream_dns' IS NOT NULL + AND jsonb_typeof(config->'clients_upstream_dns') = 'object' + """) + end +end diff --git a/elixir/apps/domain/test/domain/accounts_test.exs b/elixir/apps/domain/test/domain/accounts_test.exs index b94fe5ec8..8527a95c1 100644 --- a/elixir/apps/domain/test/domain/accounts_test.exs +++ b/elixir/apps/domain/test/domain/accounts_test.exs @@ -360,7 +360,10 @@ defmodule Domain.AccountsTest do monthly_active_users_count: -1 }, config: %{ - clients_upstream_dns: [%{protocol: "ip_port", address: "!!!"}] + clients_upstream_dns: %{ + type: :custom, + addresses: [%{address: "!!!"}] + } } } @@ -369,9 +372,11 @@ defmodule Domain.AccountsTest do assert errors_on(changeset) == %{ name: ["should be at most 64 character(s)"], config: %{ - clients_upstream_dns: [ - %{address: ["must be a valid IP address"]} - ] + clients_upstream_dns: %{ + addresses: [ + %{address: ["must be a valid IP address"]} + ] + } } } end @@ -393,10 +398,13 @@ defmodule Domain.AccountsTest do } }, config: %{ - clients_upstream_dns: [ - %{protocol: "ip_port", address: "1.1.1.1"}, - %{protocol: "ip_port", address: "8.8.8.8"} - ] + clients_upstream_dns: %{ + type: :custom, + addresses: [ + %{address: "1.1.1.1"}, + %{address: "8.8.8.8"} + ] + } } } @@ -412,15 +420,11 @@ defmodule Domain.AccountsTest do assert is_nil(account.metadata.stripe.customer_id) - assert account.config.clients_upstream_dns == [ - %Domain.Accounts.Config.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "1.1.1.1" - }, - %Domain.Accounts.Config.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "8.8.8.8" - } + assert account.config.clients_upstream_dns.type == :custom + + assert account.config.clients_upstream_dns.addresses == [ + %Domain.Accounts.Config.ClientsUpstreamDns.Address{address: "1.1.1.1"}, + %Domain.Accounts.Config.ClientsUpstreamDns.Address{address: "8.8.8.8"} ] end @@ -474,7 +478,10 @@ defmodule Domain.AccountsTest do monthly_active_users_count: -1 }, config: %{ - clients_upstream_dns: [%{protocol: "ip_port", address: "!!!"}] + clients_upstream_dns: %{ + type: :custom, + addresses: [%{address: "!!!"}] + } } } @@ -489,9 +496,11 @@ defmodule Domain.AccountsTest do monthly_active_users_count: ["must be greater than or equal to 0"] }, config: %{ - clients_upstream_dns: [ - %{address: ["must be a valid IP address"]} - ] + clients_upstream_dns: %{ + addresses: [ + %{address: ["must be a valid IP address"]} + ] + } } } end @@ -499,34 +508,36 @@ defmodule Domain.AccountsTest do test "trims client upstream dns config address fields", %{account: account} do attrs = %{ config: %{ - clients_upstream_dns: [ - %{protocol: "ip_port", address: " 1.1.1.1"}, - %{protocol: "ip_port", address: "8.8.8.8 "} - ] + clients_upstream_dns: %{ + type: :custom, + addresses: [ + %{address: " 1.1.1.1"}, + %{address: "8.8.8.8 "} + ] + } } } assert {:ok, account} = update_account_by_id(account.id, attrs) - assert account.config.clients_upstream_dns == [ - %Domain.Accounts.Config.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "1.1.1.1" - }, - %Domain.Accounts.Config.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "8.8.8.8" - } + assert account.config.clients_upstream_dns.type == :custom + + assert account.config.clients_upstream_dns.addresses == [ + %Domain.Accounts.Config.ClientsUpstreamDns.Address{address: "1.1.1.1"}, + %Domain.Accounts.Config.ClientsUpstreamDns.Address{address: "8.8.8.8"} ] end test "returns error on duplicate upstream dns config addresses", %{account: account} do attrs = %{ config: %{ - clients_upstream_dns: [ - %{protocol: "ip_port", address: "1.1.1.1"}, - %{protocol: "ip_port", address: "1.1.1.1 "} - ] + clients_upstream_dns: %{ + type: :custom, + addresses: [ + %{address: "1.1.1.1"}, + %{address: "1.1.1.1 "} + ] + } } } @@ -534,7 +545,30 @@ defmodule Domain.AccountsTest do assert errors_on(changeset) == %{ config: %{ - clients_upstream_dns: ["all addresses must be unique"] + clients_upstream_dns: %{ + addresses: ["all addresses must be unique"] + } + } + } + end + + test "returns error when custom type has no addresses", %{account: account} do + attrs = %{ + config: %{ + clients_upstream_dns: %{ + type: :custom, + addresses: [] + } + } + } + + assert {:error, changeset} = update_account_by_id(account.id, attrs) + + assert errors_on(changeset) == %{ + config: %{ + clients_upstream_dns: %{ + addresses: ["must have at least one custom resolver"] + } } } end @@ -542,9 +576,12 @@ defmodule Domain.AccountsTest do test "does not allow ports", %{account: account} do attrs = %{ config: %{ - clients_upstream_dns: [ - %{protocol: "ip_port", address: "1.1.1.1:53"} - ] + clients_upstream_dns: %{ + type: :custom, + addresses: [ + %{address: "1.1.1.1:53"} + ] + } } } @@ -552,9 +589,11 @@ defmodule Domain.AccountsTest do assert errors_on(changeset) == %{ config: %{ - clients_upstream_dns: [ - %{address: ["must not include a port"]} - ] + clients_upstream_dns: %{ + addresses: [ + %{address: ["must not include a port"]} + ] + } } } end @@ -562,9 +601,12 @@ defmodule Domain.AccountsTest do test "returns error on dns config address in IPv4 sentinel range", %{account: account} do attrs = %{ config: %{ - clients_upstream_dns: [ - %{protocol: "ip_port", address: "100.64.10.1"} - ] + clients_upstream_dns: %{ + type: :custom, + addresses: [ + %{address: "100.64.10.1"} + ] + } } } @@ -572,9 +614,11 @@ defmodule Domain.AccountsTest do assert errors_on(changeset) == %{ config: %{ - clients_upstream_dns: [ - %{address: ["cannot be in the CIDR 100.64.0.0/10"]} - ] + clients_upstream_dns: %{ + addresses: [ + %{address: ["cannot be in the CIDR 100.64.0.0/10"]} + ] + } } } end @@ -582,9 +626,12 @@ defmodule Domain.AccountsTest do test "returns error on dns config address in IPv6 sentinel range", %{account: account} do attrs = %{ config: %{ - clients_upstream_dns: [ - %{protocol: "ip_port", address: "fd00:2021:1111:10::"} - ] + clients_upstream_dns: %{ + type: :custom, + addresses: [ + %{address: "fd00:2021:1111:10::"} + ] + } } } @@ -592,9 +639,11 @@ defmodule Domain.AccountsTest do assert errors_on(changeset) == %{ config: %{ - clients_upstream_dns: [ - %{address: ["cannot be in the CIDR fd00:2021:1111::/48"]} - ] + clients_upstream_dns: %{ + addresses: [ + %{address: ["cannot be in the CIDR fd00:2021:1111::/48"]} + ] + } } } end @@ -707,6 +756,60 @@ defmodule Domain.AccountsTest do } end + test "accepts all valid DoH providers", %{account: account} do + for provider <- [:google, :quad9, :cloudflare, :opendns] do + attrs = %{ + config: %{ + clients_upstream_dns: %{ + type: :secure, + doh_provider: provider, + addresses: [] + } + } + } + + assert {:ok, account} = update_account_by_id(account.id, attrs) + assert account.config.clients_upstream_dns.doh_provider == provider + assert account.config.clients_upstream_dns.type == :secure + end + end + + test "retains addresses when switching to secure DNS", %{account: account} do + # First set custom DNS + attrs = %{ + config: %{ + clients_upstream_dns: %{ + type: :custom, + addresses: [ + %{address: "1.1.1.1"} + ] + } + } + } + + assert {:ok, account} = update_account_by_id(account.id, attrs) + assert account.config.clients_upstream_dns.type == :custom + assert length(account.config.clients_upstream_dns.addresses) == 1 + + # Now switch to DoH provider + attrs = %{ + config: %{ + clients_upstream_dns: %{ + type: :secure, + doh_provider: :cloudflare, + addresses: [ + %{address: "1.1.1.1"} + ] + } + } + } + + assert {:ok, account} = update_account_by_id(account.id, attrs) + assert account.config.clients_upstream_dns.type == :secure + assert account.config.clients_upstream_dns.doh_provider == :cloudflare + assert length(account.config.clients_upstream_dns.addresses) == 1 + end + test "updates account and broadcasts a message", %{account: account} do Bypass.open() |> Domain.Mocks.Stripe.mock_update_customer_endpoint(account) @@ -728,10 +831,13 @@ defmodule Domain.AccountsTest do } }, config: %{ - clients_upstream_dns: [ - %{protocol: "ip_port", address: "1.1.1.1"}, - %{protocol: "ip_port", address: "8.8.8.8"} - ] + clients_upstream_dns: %{ + type: :custom, + addresses: [ + %{address: "1.1.1.1"}, + %{address: "8.8.8.8"} + ] + } } } @@ -754,15 +860,11 @@ defmodule Domain.AccountsTest do assert account.metadata.stripe.billing_email == attrs.metadata.stripe.billing_email - assert account.config.clients_upstream_dns == [ - %Domain.Accounts.Config.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "1.1.1.1" - }, - %Domain.Accounts.Config.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "8.8.8.8" - } + assert account.config.clients_upstream_dns.type == :custom + + assert account.config.clients_upstream_dns.addresses == [ + %Domain.Accounts.Config.ClientsUpstreamDns.Address{address: "1.1.1.1"}, + %Domain.Accounts.Config.ClientsUpstreamDns.Address{address: "8.8.8.8"} ] end diff --git a/elixir/apps/domain/test/support/fixtures/accounts.ex b/elixir/apps/domain/test/support/fixtures/accounts.ex index 6cc41f5a3..3aaf90514 100644 --- a/elixir/apps/domain/test/support/fixtures/accounts.ex +++ b/elixir/apps/domain/test/support/fixtures/accounts.ex @@ -11,11 +11,14 @@ defmodule Domain.Fixtures.Accounts do legal_name: "l-acc-#{unique_num}", slug: "acc_#{unique_num}", config: %{ - clients_upstream_dns: [ - %{protocol: "ip_port", address: "1.1.1.1"}, - %{protocol: "ip_port", address: "2606:4700:4700::1111"}, - %{protocol: "ip_port", address: "9.9.9.9"} - ] + clients_upstream_dns: %{ + type: :custom, + addresses: [ + %{address: "1.1.1.1"}, + %{address: "2606:4700:4700::1111"}, + %{address: "9.9.9.9"} + ] + } }, features: %{ policy_conditions: true, diff --git a/elixir/apps/web/lib/web/live/settings/dns.ex b/elixir/apps/web/lib/web/live/settings/dns.ex index 6bba87e8c..98a9a81b3 100644 --- a/elixir/apps/web/lib/web/live/settings/dns.ex +++ b/elixir/apps/web/lib/web/live/settings/dns.ex @@ -5,16 +5,16 @@ defmodule Web.Settings.DNS do def mount(_params, _session, socket) do with {:ok, account} <- Accounts.fetch_account_by_id(socket.assigns.account.id, socket.assigns.subject) do - form = - Accounts.change_account(account, %{}) - |> to_form() + # Ensure config has proper defaults + account = %{account | config: Domain.Accounts.Config.ensure_defaults(account.config)} + + doh_disabled = System.get_env("DISABLE_DOH_RESOLVERS") == "true" socket = - assign(socket, - page_title: "DNS", - account: account, - form: form - ) + socket + |> assign(page_title: "DNS") + |> assign(doh_disabled: doh_disabled) + |> init(account) {:ok, socket} else @@ -22,6 +22,12 @@ defmodule Web.Settings.DNS do end end + defp init(socket, account) do + changeset = Accounts.change_account(account) + + assign(socket, form: to_form(changeset)) + end + def render(assigns) do ~H""" <.breadcrumbs account={@account}> @@ -48,109 +54,224 @@ defmodule Web.Settings.DNS do <.form for={@form} phx-submit={:submit} phx-change={:change}> <.flash kind={:success} flash={@flash} phx-click="lv:clear-flash" /> - <.inputs_for :let={config} field={@form[:config]}> -
- The search domain, or default DNS suffix, will be appended to all single-label DNS queries made by Client devices - while connected to Firezone. -
++ The search domain, or default DNS suffix, will be appended to all single-label DNS queries made by Client devices + while connected to Firezone. +
-Enter a valid FQDN to append to single-label DNS queries. The resulting FQDN will be used to match against DNS Resources in your account, or forwarded to the upstream resolvers if no match is found.
-- Queries for Resources will always use Firezone's internal DNS. - All other queries will use the resolvers configured here or the device's - system resolvers if none are configured. -
++ Queries for Resources will always use Firezone's internal DNS. + All other queries will use the resolvers configured here. +
-- Upstream resolvers will be used by Client devices in the order they are listed below. -
+ <.inputs_for :let={config_form} field={@form[:config]}> + <.inputs_for :let={dns_form} field={config_form[:clients_upstream_dns]}> +- No upstream resolvers have been configured. Click New Resolver - to add one. -
++ Note: + Secure DNS is only supported on very recent Clients. Ensure your users are using the latest version to benefit from Secure DNS. +
++ Upstream resolvers will be used by Client devices in the order they are listed below. +
-+ No upstream resolvers have been configured. Click New Resolver + to add one. +
+ + <.inputs_for :let={address_form} field={dns_form[:addresses]}> + + ++ Note: + It is highly recommended to specify both + IPv4 and IPv6 addresses when adding upstream resolvers. Otherwise, Clients without IPv4 + or IPv6 connectivity may not be able to resolve DNS queries. +
+- Note: - It is highly recommended to specify both - IPv4 and IPv6 addresses when adding upstream resolvers. Otherwise, Clients without IPv4 - or IPv6 connectivity may not be able to resolve DNS queries. -
+ +