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]}> -

Search Domain

+

Search Domain

-

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

-
- <.input field={config[:search_domain]} placeholder="E.g. example.com" /> +
+ <.inputs_for :let={config_form} field={@form[:config]}> + <.input field={config_form[:search_domain]} placeholder="E.g. example.com" />

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.

-
+ +
-

Upstream Resolvers

+

Upstream Resolvers

-

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

+
+ + <.input + type="select" + field={dns_form[:doh_provider]} + options={[ + {"Google Public DNS", :google}, + {"Cloudflare DNS", :cloudflare}, + {"Quad9 DNS", :quad9}, + {"OpenDNS", :opendns} + ]} + /> +

+ Note: + Secure DNS is only supported on very recent Clients. Ensure your users are using the latest version to benefit from Secure DNS. +

+
-
-
- <.inputs_for :let={dns} field={config[:clients_upstream_dns]}> - +
+
+

+ Upstream resolvers will be used by Client devices in the order they are listed below. +

-
-
- <.input - type="select" - label="Protocol" - field={dns[:protocol]} - placeholder="Protocol" - options={dns_options()} - value={dns[:protocol].value} - /> -
-
- <.input label="Address" field={dns[:address]} placeholder="E.g. 1.1.1.1" /> -
-
-
- +

+ No upstream resolvers have been configured. Click New Resolver + to add one. +

+ + <.inputs_for :let={address_form} field={dns_form[:addresses]}> + + +
+
+ <.input + label="IP Address" + field={address_form[:address]} + placeholder="E.g. 1.1.1.1" + /> +
+
+
+ +
-
- + - - <.button - class="mt-6 w-full" - type="button" - style="info" - name={"#{config.name}[clients_upstream_dns_sort][]"} - value="new" - phx-click={JS.dispatch("change")} - > - New Resolver - - <.error - :for={error <- dns_config_errors(@form.source.changes)} - data-validation-error-for="clients_upstream_dns" - > - {error} - + <.error :for={{msg, _opts} <- dns_form[:addresses].errors}> + {msg} + + + + <.button + class="mt-6 w-full" + type="button" + style="info" + name="account[config][clients_upstream_dns][addresses_sort][]" + value="new" + phx-click={JS.dispatch("change")} + > + New Resolver + + +

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

+ +
<.submit_button> Save DNS Settings @@ -163,66 +284,27 @@ defmodule Web.Settings.DNS do """ end - def handle_event("change", %{"account" => attrs}, socket) do + def handle_event("change", %{"account" => params}, socket) do form = - Accounts.change_account(socket.assigns.account, attrs) - |> Map.put(:action, :validate) - |> to_form() + socket.assigns.form.data + |> Accounts.change_account(params) + |> to_form(action: :validate) {:noreply, assign(socket, form: form)} end - def handle_event("submit", %{"account" => attrs}, socket) do - with {:ok, account} <- - Accounts.update_account(socket.assigns.account, attrs, socket.assigns.subject) do - form = - Accounts.change_account(account, %{}) - |> to_form() + def handle_event("submit", %{"account" => params}, socket) do + case Accounts.update_account(socket.assigns.form.data, params, socket.assigns.subject) do + {:ok, account} -> + socket = + socket + |> put_flash(:success, "DNS settings saved successfully") + |> init(account) - socket = put_flash(socket, :success, "Save successful!") + {:noreply, socket} - {:noreply, assign(socket, account: account, form: form)} - else {:error, changeset} -> - form = - changeset - |> Map.put(:action, :validate) - |> to_form() - - {:noreply, assign(socket, form: form)} + {:noreply, assign(socket, form: to_form(changeset))} end end - - defp upstream_dns_empty?(account, form) do - upstream_dns_changes = - Map.get(form.source.changes, :config, %{}) - |> Map.get(:changes, %{}) - |> Map.get(:clients_upstream_dns, %{}) - - Enum.empty?(account.config.clients_upstream_dns) and Enum.empty?(upstream_dns_changes) - end - - defp dns_options do - supported_dns_protocols = Enum.map(Accounts.Config.supported_dns_protocols(), &to_string/1) - - [ - [key: "IP", value: "ip_port"], - [key: "DNS over TLS", value: "dns_over_tls"], - [key: "DNS over HTTPS", value: "dns_over_https"] - ] - |> Enum.map(fn option -> - case option[:value] in supported_dns_protocols do - true -> option - false -> option ++ [disabled: true] - end - end) - end - - defp dns_config_errors(changes) when changes == %{} do - [] - end - - defp dns_config_errors(changes) do - translate_errors(changes.config.errors, :clients_upstream_dns) - end end diff --git a/elixir/apps/web/test/web/live/settings/dns_test.exs b/elixir/apps/web/test/web/live/settings/dns_test.exs index 9f1c1bc63..ffb6015fd 100644 --- a/elixir/apps/web/test/web/live/settings/dns_test.exs +++ b/elixir/apps/web/test/web/live/settings/dns_test.exs @@ -40,64 +40,68 @@ defmodule Web.Live.Settings.DNSTest do assert breadcrumbs =~ "DNS Settings" end - test "renders form with no input fields", %{ + test "renders form with DNS type options", %{ account: account, identity: identity, conn: conn } do - Fixtures.Accounts.update_account(account, %{config: %{clients_upstream_dns: []}}) + Fixtures.Accounts.update_account(account, %{ + config: %{clients_upstream_dns: %{type: :system, addresses: []}} + }) - {:ok, lv, _html} = + {:ok, _lv, html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/settings/dns") - form = lv |> form("form") - - assert find_inputs(form) == [ - "account[config][_persistent_id]", - "account[config][clients_upstream_dns_drop][]", - "account[config][search_domain]" - ] + assert html =~ "System DNS" + assert html =~ "Secure DNS" + assert html =~ "Custom DNS" end - test "renders input field on button click", %{ + test "shows DoH provider dropdown when secure DNS selected", %{ account: account, identity: identity, conn: conn } do - Fixtures.Accounts.update_account(account, %{config: %{clients_upstream_dns: []}}) + Fixtures.Accounts.update_account(account, %{ + config: %{clients_upstream_dns: %{type: :secure, doh_provider: :google, addresses: []}} + }) - {:ok, lv, _html} = + {:ok, _lv, html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/settings/dns") - attrs = %{ - "_target" => ["account", "config", "clients_upstream_dns_sort"], - "account" => %{ - "config" => %{ - "_persistent_id" => "0", - "clients_upstream_dns_drop" => [""], - "clients_upstream_dns_sort" => ["new"] + assert html =~ "DNS-over-HTTPS Provider" + assert html =~ "Google Public DNS" + assert html =~ "Cloudflare DNS" + assert html =~ "Quad9 DNS" + assert html =~ "OpenDNS" + end + + test "shows custom DNS fields when custom type selected", %{ + account: account, + identity: identity, + conn: conn + } do + Fixtures.Accounts.update_account(account, %{ + config: %{ + clients_upstream_dns: %{ + type: :custom, + addresses: [%{address: "8.8.8.8"}] } } - } + }) - lv - |> render_click(:change, attrs) + {:ok, _lv, html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/dns") - form = lv |> form("form") - - assert find_inputs(form) == [ - "account[config][_persistent_id]", - "account[config][clients_upstream_dns][0][_persistent_id]", - "account[config][clients_upstream_dns][0][address]", - "account[config][clients_upstream_dns][0][protocol]", - "account[config][clients_upstream_dns_drop][]", - "account[config][clients_upstream_dns_sort][]", - "account[config][search_domain]" - ] + assert html =~ "IP Address" + assert html =~ "8.8.8.8" + assert html =~ "New Resolver" end test "saves search domain", %{ @@ -105,7 +109,10 @@ defmodule Web.Live.Settings.DNSTest do identity: identity, conn: conn } do - account = Fixtures.Accounts.update_account(account, %{config: %{clients_upstream_dns: []}}) + account = + Fixtures.Accounts.update_account(account, %{ + config: %{clients_upstream_dns: %{type: :system, addresses: []}} + }) attrs = %{ account: %{ @@ -124,16 +131,7 @@ defmodule Web.Live.Settings.DNSTest do |> form("form", attrs) |> render_submit() - assert lv - |> form("form") - |> find_inputs() == [ - "account[config][_persistent_id]", - "account[config][clients_upstream_dns_drop][]", - "account[config][search_domain]" - ] - account = Domain.Accounts.fetch_account_by_id!(account.id) - assert account.config.search_domain == "example.com" end @@ -142,7 +140,10 @@ defmodule Web.Live.Settings.DNSTest do identity: identity, conn: conn } do - account = Fixtures.Accounts.update_account(account, %{config: %{clients_upstream_dns: []}}) + account = + Fixtures.Accounts.update_account(account, %{ + config: %{clients_upstream_dns: %{type: :system, addresses: []}} + }) attrs = %{ account: %{ @@ -167,173 +168,375 @@ defmodule Web.Live.Settings.DNSTest do identity: identity, conn: conn } do - Fixtures.Accounts.update_account(account, %{config: %{clients_upstream_dns: []}}) - - attrs = %{ - account: %{ - config: %{ - clients_upstream_dns: %{"0" => %{address: "8.8.8.8"}} + # Start with custom type and one address already configured + Fixtures.Accounts.update_account(account, %{ + config: %{ + clients_upstream_dns: %{ + type: :custom, + addresses: [%{address: "1.1.1.1"}] } } - } + }) {:ok, lv, _html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/settings/dns") - lv - |> element("form") - |> render_change(%{ - "account" => %{ - "config" => %{ - "clients_upstream_dns_drop" => [""], - "clients_upstream_dns_sort" => ["new"] - } - } - }) - - lv - |> form("form", attrs) - |> render_submit() - - assert lv - |> form("form") - |> find_inputs() == [ - "account[config][_persistent_id]", - "account[config][clients_upstream_dns][0][_persistent_id]", - "account[config][clients_upstream_dns][0][address]", - "account[config][clients_upstream_dns][0][protocol]", - "account[config][clients_upstream_dns_drop][]", - "account[config][clients_upstream_dns_sort][]", - "account[config][search_domain]" - ] - end - - test "removes blank entries upon save", %{ - account: account, - identity: identity, - conn: conn - } do attrs = %{ account: %{ config: %{ clients_upstream_dns: %{ - "0" => %{address: ""} + type: "custom", + addresses: %{"0" => %{"address" => "8.8.8.8"}} } } } } - {:ok, lv, _html} = - conn - |> authorize_conn(identity) - |> live(~p"/#{account}/settings/dns") - lv |> form("form", attrs) |> render_submit() - assert lv - |> form("form") - |> find_inputs() == [ - "account[config][_persistent_id]", - "account[config][clients_upstream_dns][0][_persistent_id]", - "account[config][clients_upstream_dns][0][address]", - "account[config][clients_upstream_dns][0][protocol]", - "account[config][clients_upstream_dns][1][_persistent_id]", - "account[config][clients_upstream_dns][1][address]", - "account[config][clients_upstream_dns][1][protocol]", - "account[config][clients_upstream_dns][2][_persistent_id]", - "account[config][clients_upstream_dns][2][address]", - "account[config][clients_upstream_dns][2][protocol]", - "account[config][clients_upstream_dns_drop][]", - "account[config][clients_upstream_dns_sort][]", - "account[config][search_domain]" - ] + account = Domain.Accounts.fetch_account_by_id!(account.id) + assert account.config.clients_upstream_dns.type == :custom + assert length(account.config.clients_upstream_dns.addresses) == 1 + assert hd(account.config.clients_upstream_dns.addresses).address == "8.8.8.8" end - test "warns when duplicate IPv4 addresses found", %{ + test "returns error when custom type has no addresses", %{ account: account, identity: identity, conn: conn } do - addr1 = %{address: "8.8.8.8"} - addr1_dup = %{address: "8.8.8.8"} - addr2 = %{address: "1.1.1.1"} - - attrs = %{ - account: %{ - config: %{ - clients_upstream_dns: %{"0" => addr1} - } - } - } + # Start with custom type but no addresses + Fixtures.Accounts.update_account(account, %{ + config: %{clients_upstream_dns: %{type: :custom, addresses: []}} + }) {:ok, lv, _html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/settings/dns") - lv - |> form("form", attrs) - |> render_submit() + # Try to submit without any addresses + html = + lv + |> form("form", %{ + account: %{ + config: %{ + clients_upstream_dns: %{ + type: "custom" + } + } + } + }) + |> render_submit() - assert lv - |> form("form", %{ - account: %{ - config: %{clients_upstream_dns: %{"1" => addr1}} - } - }) - |> render_change() =~ "all addresses must be unique" - - refute lv - |> form("form", %{ - account: %{ - config: %{clients_upstream_dns: %{"1" => addr2}} - } - }) - |> render_change() =~ "all addresses must be unique" - - assert lv - |> form("form", %{ - account: %{ - config: %{clients_upstream_dns: %{"1" => addr1_dup}} - } - }) - |> render_change() =~ "all addresses must be unique" + assert html =~ "must have at least one custom resolver" end - test "displays 'cannot be empty' error message", %{ + test "validates duplicate addresses", %{ account: account, identity: identity, conn: conn } do - attrs = %{ - account: %{ - config: %{ - clients_upstream_dns: %{"0" => %{address: "8.8.8.8"}} - } - } - } - {:ok, lv, _html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/settings/dns") - lv - |> form("form", attrs) - |> render_submit() - assert lv |> form("form", %{ account: %{ config: %{ - clients_upstream_dns: %{"0" => %{address: ""}} + clients_upstream_dns: %{ + type: "custom", + addresses: %{ + "0" => %{"address" => "8.8.8.8"}, + "1" => %{"address" => "8.8.8.8"} + } + } } } }) - |> render_change() =~ "can't be blank" + |> render_change() =~ "all addresses must be unique" + end + + test "validates IP addresses", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/dns") + + assert lv + |> form("form", %{ + account: %{ + config: %{ + clients_upstream_dns: %{ + type: "custom", + addresses: %{"0" => %{"address" => "invalid"}} + } + } + } + }) + |> render_change() =~ "must be a valid IP address" + + refute lv + |> form("form", %{ + account: %{ + config: %{ + clients_upstream_dns: %{ + type: "custom", + addresses: %{"0" => %{"address" => "8.8.8.8"}} + } + } + } + }) + |> render_change() =~ "must be a valid IP address" + end + + test "saves secure DNS with DoH provider", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/dns") + + # First change just the type to make doh_provider field appear + lv + |> form("form", %{ + account: %{ + config: %{ + clients_upstream_dns: %{ + type: "secure" + } + } + } + }) + |> render_change() + + # Now submit with both type and doh_provider + lv + |> form("form", %{ + account: %{ + config: %{ + clients_upstream_dns: %{ + type: "secure", + doh_provider: "google" + } + } + } + }) + |> render_submit() + + account = Repo.reload!(account) + assert account.config.clients_upstream_dns.type == :secure + assert account.config.clients_upstream_dns.doh_provider == :google + end + + test "saves system resolver selection", %{ + account: account, + identity: identity, + conn: conn + } do + # Start with secure DNS set + Fixtures.Accounts.update_account(account, %{ + config: %{clients_upstream_dns: %{type: :secure, doh_provider: :cloudflare, addresses: []}} + }) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/dns") + + attrs = %{ + account: %{ + config: %{ + clients_upstream_dns: %{ + type: "system" + } + } + } + } + + lv + |> form("form", attrs) + |> render_submit() + + account = Domain.Accounts.fetch_account_by_id!(account.id) + assert account.config.clients_upstream_dns.type == :system + assert Enum.empty?(account.config.clients_upstream_dns.addresses) + end + + test "retains DoH provider when switching from secure to system and back", %{ + account: account, + identity: identity, + conn: conn + } do + # Start with secure DNS + Fixtures.Accounts.update_account(account, %{ + config: %{clients_upstream_dns: %{type: :secure, doh_provider: :quad9, addresses: []}} + }) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/dns") + + # Switch to system + render_change(lv, :change, %{ + "account" => %{ + "config" => %{ + "clients_upstream_dns" => %{ + "type" => "system" + } + } + } + }) + + # Switch back to secure - DoH provider should still be there + html = + render_change(lv, :change, %{ + "account" => %{ + "config" => %{ + "clients_upstream_dns" => %{ + "type" => "secure", + "doh_provider" => "quad9" + } + } + } + }) + + assert html =~ "Quad9 DNS" + end + + test "retains custom addresses when switching from custom to system and back", %{ + account: account, + identity: identity, + conn: conn + } do + # Start with custom addresses + Fixtures.Accounts.update_account(account, %{ + config: %{ + clients_upstream_dns: %{ + type: :custom, + addresses: [%{address: "8.8.8.8"}] + } + } + }) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/dns") + + # Switch to system + render_change(lv, :change, %{ + "account" => %{ + "config" => %{ + "clients_upstream_dns" => %{ + "type" => "system" + } + } + } + }) + + # Switch back to custom - addresses should still be there + html = + render_change(lv, :change, %{ + "account" => %{ + "config" => %{ + "clients_upstream_dns" => %{ + "type" => "custom", + "addresses" => %{"0" => %{"address" => "8.8.8.8"}} + } + } + } + }) + + assert html =~ "8.8.8.8" + end + + test "can add multiple custom DNS addresses", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/dns") + + attrs = %{ + account: %{ + config: %{ + clients_upstream_dns: %{ + type: "custom", + addresses: %{ + "0" => %{"address" => "8.8.8.8"}, + "1" => %{"address" => "1.1.1.1"}, + "2" => %{"address" => "2001:4860:4860::8888"} + } + } + } + } + } + + lv + |> form("form", attrs) + |> render_submit() + + account = Domain.Accounts.fetch_account_by_id!(account.id) + assert account.config.clients_upstream_dns.type == :custom + assert length(account.config.clients_upstream_dns.addresses) == 3 + + addresses = Enum.map(account.config.clients_upstream_dns.addresses, & &1.address) + assert "8.8.8.8" in addresses + assert "1.1.1.1" in addresses + assert "2001:4860:4860::8888" in addresses + end + + test "can change DoH provider", %{ + account: account, + identity: identity, + conn: conn + } do + # Start with Google + Fixtures.Accounts.update_account(account, %{ + config: %{clients_upstream_dns: %{type: :secure, doh_provider: :google, addresses: []}} + }) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/dns") + + # Change to Cloudflare + attrs = %{ + account: %{ + config: %{ + clients_upstream_dns: %{ + type: "secure", + doh_provider: "cloudflare" + } + } + } + } + + lv + |> form("form", attrs) + |> render_submit() + + account = Domain.Accounts.fetch_account_by_id!(account.id) + assert account.config.clients_upstream_dns.type == :secure + assert account.config.clients_upstream_dns.doh_provider == :cloudflare end end diff --git a/website/src/app/kb/deploy/dns/readme.mdx b/website/src/app/kb/deploy/dns/readme.mdx index 06cbe8f70..617820ac9 100644 --- a/website/src/app/kb/deploy/dns/readme.mdx +++ b/website/src/app/kb/deploy/dns/readme.mdx @@ -102,38 +102,39 @@ feedback on -## Configuring Client DNS upstream resolvers +## Configuring Client DNS + +Go to `Settings -> DNS` to configure how Firezone Clients should resolve DNS. + +### Default system resolvers + +By default, Firezone Clients will use the system resolvers, typically set by the +DHCP server of their local network. + +### Secure DNS + + + Secure DNS was added in macOS 1.5.10, iOS 1.5.10, Android 1.5.7, Windows + 1.5.9, and Linux 1.5.9. + + +Firezone Clients can use the DNS-over-HTTPS protocol for all non-Firezone +resources. Secure DNS encrypts all DNS traffic, preventing middleboxes +(including ISPs) from seeing or manipulating DNS traffic. This is especially +useful on insecure or untrusted networks. + +### Custom resolvers Upstream DNS in all Clients can be configured with the servers of your choosing so that all queries on Client devices will be forwarded to the servers you specify for all non-Firezone resources. -Go to `Settings -> DNS` and enter IPv4 and/or IPv6 servers to use as fallback -resolvers. Firezone Clients will use these servers in the order they are defined -for any query that doesn't match a Resource the user has access to. - When setting custom upstream resolvers, it is **highly** recommended to configure **both** an IPv4 and IPv6 option. Otherwise, a Client that has only IPv4 or IPv6 connectivity may not be able to resolve DNS queries. - - Firezone Clients support only DNS over UDP/53 at this time. DNS-over-TLS and - DNS-over-HTTPS upstream servers are not yet supported. - - -If no custom resolvers are configured, Firezone Clients will fall back to the -default system resolvers, typically set by the DHCP server of their local -network. - - - Custom resolvers such as - [Cloudflare](https://developers.cloudflare.com/1.1.1.1/setup/#1111-for-families) - or [NextDNS](https://nextdns.io) can be used to block malware, ads, adult - material and other content for all users in your Firezone account. - - ## Configuring Gateway resolvers Firezone makes no assumptions about the DNS environment in which the Gateway