feat(portal): extend DNS settings to allow for DoH providers (#10882)

In order to allow customers to make use of connlib's DoH functionality,
we need a configuration UI for it. We take inspiration from the "New
Resource" page and implement a 3-choice UI component for configuring how
Clients should resolve DNS queries:

- System
- Secure DNS
- Custom

The secure and custom DNS options show an additional form when selected
for either picking a DoH provider or the addresses of the custom DNS
servers.

Right now, the "Secure DNS" part is disabled if the
`DISABLE_DOH_PROVIDER` env variable is set. We render a "Coming soon"
tooltip on hover:

<img width="1534" height="1100" alt="image"
src="https://github.com/user-attachments/assets/a12a6ba4-806f-4d19-8aea-5c1cd981d609"
/>

This allows us to test this in staging and still ship to production if
needed prior to enabling it.

Resolves: #10792
Resolves: #10786

---------

Co-authored-by: Jamil Bou Kheir <jamilbk@users.noreply.github.com>
This commit is contained in:
Thomas Eizinger
2025-11-22 17:48:07 +11:00
committed by GitHub
parent aab779e68b
commit bce2aa30b5
12 changed files with 1077 additions and 479 deletions

View File

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

View File

@@ -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: %{

View File

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

View File

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

View File

@@ -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: &notifications_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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]}>
<h2 class="mb-4 text-xl text-neutral-900">Search Domain</h2>
<h2 class="mb-4 text-xl text-neutral-900">Search Domain</h2>
<p class="mb-4 text-neutral-500">
The search domain, or default DNS suffix, will be appended to all single-label DNS queries made by Client devices
while connected to Firezone.
</p>
<p class="mb-4 text-neutral-500">
The search domain, or default DNS suffix, will be appended to all single-label DNS queries made by Client devices
while connected to Firezone.
</p>
<div class="mb-8">
<.input field={config[:search_domain]} placeholder="E.g. example.com" />
<div class="mb-8">
<.inputs_for :let={config_form} field={@form[:config]}>
<.input field={config_form[:search_domain]} placeholder="E.g. example.com" />
<p class="mt-2 text-sm text-neutral-500">
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.
</p>
</div>
</.inputs_for>
</div>
<h2 class="mb-4 text-xl text-neutral-900">Upstream Resolvers</h2>
<h2 class="mb-4 text-xl text-neutral-900">Upstream Resolvers</h2>
<p class="mb-4 text-neutral-500">
Queries for Resources will <strong>always</strong> use Firezone's internal DNS.
All other queries will use the resolvers configured here or the device's
system resolvers if none are configured.
</p>
<p class="mb-4 text-neutral-500">
Queries for Resources will <strong>always</strong> use Firezone's internal DNS.
All other queries will use the resolvers configured here.
</p>
<p :if={not upstream_dns_empty?(@account, @form)} class="mb-4 text-neutral-500">
Upstream resolvers will be used by Client devices in the order they are listed below.
</p>
<.inputs_for :let={config_form} field={@form[:config]}>
<.inputs_for :let={dns_form} field={config_form[:clients_upstream_dns]}>
<div class="mb-6">
<ul class="grid w-full gap-6 md:grid-cols-3">
<li>
<.input
id="dns-type--system"
type="radio_button_group"
field={dns_form[:type]}
value="system"
checked={"#{dns_form[:type].value}" == "system"}
required
/>
<label for="dns-type--system" class={~w[
inline-flex items-center justify-between w-full
p-5 text-gray-500 bg-white border border-gray-200
rounded cursor-pointer peer-checked:border-accent-500
peer-checked:text-accent-500 hover:text-gray-600 hover:bg-gray-100
]}>
<div class="block">
<div class="w-full font-semibold mb-3">
<.icon name="hero-computer-desktop" class="w-5 h-5 mr-1" /> System DNS
</div>
<div class="w-full text-sm">
Use the device's default DNS resolvers.
</div>
</div>
</label>
</li>
<li>
<.input
id="dns-type--secure"
type="radio_button_group"
field={dns_form[:type]}
value="secure"
checked={"#{dns_form[:type].value}" == "secure"}
disabled={@doh_disabled}
required
/>
<label
for="dns-type--secure"
class={[
"inline-flex items-center justify-between w-full",
"p-5 text-gray-500 bg-white border border-gray-200",
"rounded cursor-pointer peer-checked:border-accent-500",
"peer-checked:text-accent-500 hover:text-gray-600 hover:bg-gray-100",
@doh_disabled && "opacity-50 cursor-not-allowed",
"relative group"
]}
title={@doh_disabled && "Coming soon"}
>
<div class="block">
<div class="w-full font-semibold mb-3">
<.icon name="hero-lock-closed" class="w-5 h-5 mr-1" /> Secure DNS
</div>
<div class="w-full text-sm">
Use DNS-over-HTTPS from trusted providers.
</div>
</div>
</label>
</li>
<li>
<.input
id="dns-type--custom"
type="radio_button_group"
field={dns_form[:type]}
value="custom"
checked={"#{dns_form[:type].value}" == "custom"}
required
/>
<label for="dns-type--custom" class={~w[
inline-flex items-center justify-between w-full
p-5 text-gray-500 bg-white border border-gray-200
rounded cursor-pointer peer-checked:border-accent-500
peer-checked:text-accent-500 hover:text-gray-600 hover:bg-gray-100
]}>
<div class="block">
<div class="w-full font-semibold mb-3">
<.icon name="hero-cog-6-tooth" class="w-5 h-5 mr-1" /> Custom DNS
</div>
<div class="w-full text-sm">
Configure your own DNS server addresses.
</div>
</div>
</label>
</li>
</ul>
</div>
<p :if={upstream_dns_empty?(@account, @form)} class="text-neutral-500">
No upstream resolvers have been configured. Click <strong>New Resolver</strong>
to add one.
</p>
<div :if={"#{dns_form[:type].value}" == "secure"} class="mb-6">
<label class="block mb-2 text-sm font-medium text-neutral-900">
DNS-over-HTTPS Provider
</label>
<.input
type="select"
field={dns_form[:doh_provider]}
options={[
{"Google Public DNS", :google},
{"Cloudflare DNS", :cloudflare},
{"Quad9 DNS", :quad9},
{"OpenDNS", :opendns}
]}
/>
<p class="mt-4 text-sm text-neutral-500">
<strong>Note:</strong>
Secure DNS is only supported on very recent Clients. Ensure your users are using the latest version to benefit from Secure DNS.
</p>
</div>
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
<div>
<.inputs_for :let={dns} field={config[:clients_upstream_dns]}>
<input
type="hidden"
name={"#{config.name}[clients_upstream_dns_sort][]"}
value={dns.index}
/>
<div
:if={"#{dns_form[:type].value}" == "custom"}
class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6"
>
<div>
<p
:if={not Enum.empty?(dns_form[:addresses].value || [])}
class="mb-4 text-neutral-500"
>
Upstream resolvers will be used by Client devices in the order they are listed below.
</p>
<div class="flex gap-4 items-start mb-2">
<div class="w-3/12">
<.input
type="select"
label="Protocol"
field={dns[:protocol]}
placeholder="Protocol"
options={dns_options()}
value={dns[:protocol].value}
/>
</div>
<div class="flex-grow">
<.input label="Address" field={dns[:address]} placeholder="E.g. 1.1.1.1" />
</div>
<div class="justify-self-end">
<div class="pt-7">
<button
type="button"
name={"#{config.name}[clients_upstream_dns_drop][]"}
value={dns.index}
phx-click={JS.dispatch("change")}
>
<.icon
name="hero-trash"
class="-ml-1 text-red-500 w-5 h-5 relative top-2"
/>
</button>
<p
:if={Enum.empty?(dns_form[:addresses].value || [])}
class="mb-4 text-neutral-500"
>
No upstream resolvers have been configured. Click <strong>New Resolver</strong>
to add one.
</p>
<.inputs_for :let={address_form} field={dns_form[:addresses]}>
<input
type="hidden"
name="account[config][clients_upstream_dns][addresses_sort][]"
value={address_form.index}
/>
<div class="flex gap-4 items-start mb-2">
<div class="flex-grow">
<.input
label="IP Address"
field={address_form[:address]}
placeholder="E.g. 1.1.1.1"
/>
</div>
<div class="justify-self-end">
<div class="pt-7">
<button
type="button"
name="account[config][clients_upstream_dns][addresses_drop][]"
value={address_form.index}
phx-click={JS.dispatch("change")}
>
<.icon
name="hero-trash"
class="-ml-1 text-red-500 w-5 h-5 relative top-2"
/>
</button>
</div>
</div>
</div>
</div>
</.inputs_for>
</.inputs_for>
<input type="hidden" name={"#{config.name}[clients_upstream_dns_drop][]"} />
<.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
</.button>
<.error
:for={error <- dns_config_errors(@form.source.changes)}
data-validation-error-for="clients_upstream_dns"
>
{error}
</.error>
<.error :for={{msg, _opts} <- dns_form[:addresses].errors}>
{msg}
</.error>
<input
type="hidden"
name="account[config][clients_upstream_dns][addresses_drop][]"
/>
<.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
</.button>
<p class="mt-4 text-sm text-neutral-500">
<strong>Note:</strong>
It is highly recommended to specify <strong>both</strong>
IPv4 and IPv6 addresses when adding upstream resolvers. Otherwise, Clients without IPv4
or IPv6 connectivity may not be able to resolve DNS queries.
</p>
</div>
</div>
</div>
<p class="text-sm text-neutral-500">
<strong>Note:</strong>
It is highly recommended to specify <strong>both</strong>
IPv4 and IPv6 addresses when adding upstream resolvers. Otherwise, Clients without IPv4
or IPv6 connectivity may not be able to resolve DNS queries.
</p>
</.inputs_for>
</.inputs_for>
<div class="mt-16">
<.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

View File

@@ -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&#39;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

View File

@@ -102,38 +102,39 @@ feedback on
</Alert>
## 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
<Alert color="info">
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.
</Alert>
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.
<Alert color="warning">
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.
</Alert>
<Alert color="warning">
Firezone Clients support only DNS over UDP/53 at this time. DNS-over-TLS and
DNS-over-HTTPS upstream servers are not yet supported.
</Alert>
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.
<Alert color="info">
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.
</Alert>
## Configuring Gateway resolvers
Firezone makes no assumptions about the DNS environment in which the Gateway