From 306c1983a2257f376a1c1e7c809264bb1d10af77 Mon Sep 17 00:00:00 2001
From: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Wed, 22 Mar 2023 13:42:20 +0300
Subject: [PATCH] Pull request 1758: 1472-edns-custom-ip-api

Merge in DNS/adguard-home from 1472-edns-custom-ip-api to master

Updates #1472

Squashed commit of the following:

commit 7605ec5bd5467ddd28a650385193eb2332653bb6
Merge: 8b2ac227 194ead34
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Wed Mar 22 13:39:25 2023 +0300

    Merge branch 'master' into 1472-edns-custom-ip-api

commit 8b2ac22793a51d2555d32c1f5b5c118118807d1f
Merge: d5ca8b6e c3edab43
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Mar 21 18:26:55 2023 +0300

    Merge branch 'master' into 1472-edns-custom-ip-api

commit d5ca8b6e1d87480d4ee4afd346e6bf04907fca95
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Mar 21 18:26:24 2023 +0300

    dnsforward: imp tests

commit 1302586d22812c22755ea1c7e7fcd32330d707c5
Author: Vladislav Abdulmyanov <v.abdulmyanov@adguard.com>
Date:   Tue Mar 21 16:47:56 2023 +0200

    client: change validation for custom edns ip

commit 44e4dc6d1e47de6597ed9ee328db9639e38b4868
Author: Vladislav Abdulmyanov <v.abdulmyanov@adguard.com>
Date:   Tue Mar 21 16:31:42 2023 +0200

    client: implement edns custom ip

commit 8a3e7ad8ebf16262818821340da23baa5f004bff
Merge: 04ac1112 f736d85e
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Mar 21 15:04:40 2023 +0300

    Merge branch 'master' into 1472-edns-custom-ip-api

commit 04ac1112dda2e778243555b2f54819e5ba586e05
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Mar 21 15:03:39 2023 +0300

    dnsforward: imp tests

commit b44f6d0ccb3ba7b7a5be07c9cf293dad9c83c794
Merge: 19c6851e 48431f8b
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Mar 20 17:55:49 2023 +0300

    Merge branch 'master' into 1472-edns-custom-ip-api

commit 19c6851e30f30b0572334822e8639e03760de986
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Fri Mar 10 10:40:15 2023 +0300

    all: fix chlog

commit 6dcdcbd666ebf3a56a38251e8ead09c605068ce3
Merge: a7f1bf71 a2053526
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Fri Mar 10 10:23:37 2023 +0300

    Merge branch 'master' into 1472-edns-custom-ip-api

commit a7f1bf715e3557c710b700b9bd923868eb07715b
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Mar 7 19:24:18 2023 +0300

    home: fix default value

commit 0311a9bb6571975963d747ef4ef427b59dca03bc
Merge: 7e0bb3df 1011b8f9
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Mar 7 19:04:18 2023 +0300

    Merge branch 'master' into 1472-edns-custom-ip-api

commit 7e0bb3df78f10f4b4ae0fd49681d1aa0040521c5
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Mar 7 19:03:24 2023 +0300

    all: fix chlog

commit 202d7ccf4721ccf39726da01d237e07317bfaa58
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Mar 7 11:35:41 2023 +0300

    dnsforward: fix typo

commit fe95e003a0c3b316a44b5ec0b848a60ddd4c85cf
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Mar 7 11:28:21 2023 +0300

    all: fix docs

commit 66835a9aa22b3015f9238c1d6f5aa9bd6067db8a
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Mar 7 10:48:08 2023 +0300

    dnsforward: add todo

commit b58255e1e6660a8229bb9c40f2acddebb3dbdc66
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Mar 6 15:40:02 2023 +0300

    all: upd chlog

commit 9b2be7facba30c815144e08a7835353cad14c405
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Fri Mar 3 11:22:19 2023 +0300

    dnsforward: edns custom ip api
---
 CHANGELOG.md                                  |   3 +
 client/src/__locales/en.json                  |   2 +
 .../components/Settings/Dns/Config/Form.js    |  41 ++++-
 .../components/Settings/Dns/Config/index.js   |   4 +
 internal/dnsforward/config.go                 |  15 +-
 internal/dnsforward/http.go                   | 164 ++++++++++++------
 internal/dnsforward/http_test.go              |  16 +-
 .../TestDNSForwardHTTP_handleGetConfig.json   |  12 +-
 .../TestDNSForwardHTTP_handleSetConfig.json   | 144 +++++++++++++--
 internal/home/config.go                       |   2 +-
 openapi/CHANGELOG.md                          |  12 ++
 openapi/openapi.yaml                          |   8 +-
 12 files changed, 326 insertions(+), 97 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b060ada..61f8706a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,8 @@ NOTE: Add new changes BELOW THIS COMMENT.
 
 ### Added
 
+- The ability to set custom IP for EDNS Client Subnet by using the DNS-server
+  configuration section on the DNS settings page in the UI ([#1472]).
 - The ability to manage safesearch for each service by using the new
   `safe_search` field ([#1163]).
 
@@ -68,6 +70,7 @@ In this release, the schema version has changed from 17 to 19.
   ([#5584]).
 
 [#1163]: https://github.com/AdguardTeam/AdGuardHome/issues/1163
+[#1472]: https://github.com/AdguardTeam/AdGuardHome/issues/1472
 [#5567]: https://github.com/AdguardTeam/AdGuardHome/issues/5567
 [#5584]: https://github.com/AdguardTeam/AdGuardHome/issues/5584
 
diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json
index ee0345d7..5ccd771b 100644
--- a/client/src/__locales/en.json
+++ b/client/src/__locales/en.json
@@ -290,6 +290,8 @@
     "rate_limit": "Rate limit",
     "edns_enable": "Enable EDNS client subnet",
     "edns_cs_desc": "Add the EDNS Client Subnet option (ECS) to upstream requests and log the values sent by the clients in the query log.",
+    "edns_use_custom_ip": "Use custom IP for EDNS",
+    "edns_use_custom_ip_desc": "Allow to use custom IP for EDNS",
     "rate_limit_desc": "The number of requests per second allowed per client. Setting it to 0 means no limit.",
     "blocking_ipv4_desc": "IP address to be returned for a blocked A request",
     "blocking_ipv6_desc": "IP address to be returned for a blocked AAAA request",
diff --git a/client/src/components/Settings/Dns/Config/Form.js b/client/src/components/Settings/Dns/Config/Form.js
index a2dd2bf9..52d94741 100644
--- a/client/src/components/Settings/Dns/Config/Form.js
+++ b/client/src/components/Settings/Dns/Config/Form.js
@@ -13,15 +13,11 @@ import {
     validateIpv4,
     validateIpv6,
     validateRequiredValue,
+    validateIp,
 } from '../../../../helpers/validators';
 import { BLOCKING_MODES, FORM_NAME, UINT32_RANGE } from '../../../../helpers/constants';
 
 const checkboxes = [
-    {
-        name: 'edns_cs_enabled',
-        placeholder: 'edns_enable',
-        subtitle: 'edns_cs_desc',
-    },
     {
         name: 'dnssec_enabled',
         placeholder: 'dnssec_enable',
@@ -66,6 +62,8 @@ const Form = ({
     const { t } = useTranslation();
     const {
         blocking_mode,
+        edns_cs_enabled,
+        edns_cs_use_custom,
     } = useSelector((state) => state.form[FORM_NAME.BLOCKING_MODE].values ?? {}, shallowEqual);
 
     return <form onSubmit={handleSubmit}>
@@ -92,6 +90,39 @@ const Form = ({
                     />
                 </div>
             </div>
+            <div className="col-12">
+                <div className="form__group form__group--settings">
+                    <Field
+                        name="edns_cs_enabled"
+                        type="checkbox"
+                        component={CheckboxField}
+                        placeholder={t('edns_enable')}
+                        disabled={processing}
+                        subtitle={t('edns_cs_desc')}
+                    />
+                </div>
+            </div>
+            <div className="col-12 form__group form__group--inner">
+                <div className="form__group ">
+                    <Field
+                        name="edns_cs_use_custom"
+                        type="checkbox"
+                        component={CheckboxField}
+                        placeholder={t('edns_use_custom_ip')}
+                        disabled={processing || !edns_cs_enabled}
+                        subtitle={t('edns_use_custom_ip_desc')}
+                    />
+                </div>
+
+                {edns_cs_use_custom && (<Field
+                    name="edns_cs_custom_ip"
+                    component={renderInputField}
+                    className="form-control"
+                    placeholder={t('form_enter_ip')}
+                    validate={[validateIp, validateRequiredValue]}
+                />)}
+
+            </div>
             {checkboxes.map(({ name, placeholder, subtitle }) => <div className="col-12" key={name}>
                 <div className="form__group form__group--settings">
                     <Field
diff --git a/client/src/components/Settings/Dns/Config/index.js b/client/src/components/Settings/Dns/Config/index.js
index a472ff94..59f5a4bc 100644
--- a/client/src/components/Settings/Dns/Config/index.js
+++ b/client/src/components/Settings/Dns/Config/index.js
@@ -14,6 +14,8 @@ const Config = () => {
         blocking_ipv4,
         blocking_ipv6,
         edns_cs_enabled,
+        edns_cs_use_custom,
+        edns_cs_custom_ip,
         dnssec_enabled,
         disable_ipv6,
         processingSetConfig,
@@ -39,6 +41,8 @@ const Config = () => {
                         edns_cs_enabled,
                         disable_ipv6,
                         dnssec_enabled,
+                        edns_cs_use_custom,
+                        edns_cs_custom_ip,
                     }}
                     onSubmit={handleFormSubmit}
                     processing={processingSetConfig}
diff --git a/internal/dnsforward/config.go b/internal/dnsforward/config.go
index f18e7513..008b2539 100644
--- a/internal/dnsforward/config.go
+++ b/internal/dnsforward/config.go
@@ -200,7 +200,7 @@ type FilteringConfig struct {
 // EDNSClientSubnet is the settings list for EDNS Client Subnet.
 type EDNSClientSubnet struct {
 	// CustomIP for EDNS Client Subnet.
-	CustomIP string `yaml:"custom_ip"`
+	CustomIP netip.Addr `yaml:"custom_ip"`
 
 	// Enabled defines if EDNS Client Subnet is enabled.
 	Enabled bool `yaml:"enabled"`
@@ -340,15 +340,8 @@ func (s *Server) createProxyConfig() (conf proxy.Config, err error) {
 	}
 
 	if srvConf.EDNSClientSubnet.UseCustom {
-		// TODO(s.chzhen):  Add wrapper around netip.Addr.
-		var ip net.IP
-		ip, err = netutil.ParseIP(srvConf.EDNSClientSubnet.CustomIP)
-		if err != nil {
-			return conf, fmt.Errorf("edns: %w", err)
-		}
-
 		// TODO(s.chzhen):  Use netip.Addr instead of net.IP inside dnsproxy.
-		conf.EDNSAddr = ip
+		conf.EDNSAddr = net.IP(srvConf.EDNSClientSubnet.CustomIP.AsSlice())
 	}
 
 	if srvConf.CacheSize != 0 {
@@ -377,7 +370,7 @@ func (s *Server) createProxyConfig() (conf proxy.Config, err error) {
 
 	err = s.prepareTLS(&conf)
 	if err != nil {
-		return conf, fmt.Errorf("validating tls: %w", err)
+		return proxy.Config{}, fmt.Errorf("validating tls: %w", err)
 	}
 
 	if c := srvConf.DNSCryptConfig; c.Enabled {
@@ -388,7 +381,7 @@ func (s *Server) createProxyConfig() (conf proxy.Config, err error) {
 	}
 
 	if conf.UpstreamConfig == nil || len(conf.UpstreamConfig.Upstreams) == 0 {
-		return conf, errors.Error("no default upstream servers configured")
+		return proxy.Config{}, errors.Error("no default upstream servers configured")
 	}
 
 	return conf, nil
diff --git a/internal/dnsforward/http.go b/internal/dnsforward/http.go
index 0c8b6726..924a3675 100644
--- a/internal/dnsforward/http.go
+++ b/internal/dnsforward/http.go
@@ -23,26 +23,78 @@ import (
 )
 
 // jsonDNSConfig is the JSON representation of the DNS server configuration.
+//
+// TODO(s.chzhen):  Split it into smaller pieces.  Use aghalg.NullBool instead
+// of *bool.
 type jsonDNSConfig struct {
-	Upstreams         *[]string     `json:"upstream_dns"`
-	UpstreamsFile     *string       `json:"upstream_dns_file"`
-	Bootstraps        *[]string     `json:"bootstrap_dns"`
-	ProtectionEnabled *bool         `json:"protection_enabled"`
-	RateLimit         *uint32       `json:"ratelimit"`
-	BlockingMode      *BlockingMode `json:"blocking_mode"`
-	EDNSCSEnabled     *bool         `json:"edns_cs_enabled"`
-	DNSSECEnabled     *bool         `json:"dnssec_enabled"`
-	DisableIPv6       *bool         `json:"disable_ipv6"`
-	UpstreamMode      *string       `json:"upstream_mode"`
-	CacheSize         *uint32       `json:"cache_size"`
-	CacheMinTTL       *uint32       `json:"cache_ttl_min"`
-	CacheMaxTTL       *uint32       `json:"cache_ttl_max"`
-	CacheOptimistic   *bool         `json:"cache_optimistic"`
-	ResolveClients    *bool         `json:"resolve_clients"`
-	UsePrivateRDNS    *bool         `json:"use_private_ptr_resolvers"`
-	LocalPTRUpstreams *[]string     `json:"local_ptr_upstreams"`
-	BlockingIPv4      net.IP        `json:"blocking_ipv4"`
-	BlockingIPv6      net.IP        `json:"blocking_ipv6"`
+	// Upstreams is the list of upstream DNS servers.
+	Upstreams *[]string `json:"upstream_dns"`
+
+	// UpstreamsFile is the file containing upstream DNS servers.
+	UpstreamsFile *string `json:"upstream_dns_file"`
+
+	// Bootstraps is the list of DNS servers resolving IP addresses of the
+	// upstream DoH/DoT resolvers.
+	Bootstraps *[]string `json:"bootstrap_dns"`
+
+	// ProtectionEnabled defines if protection is enabled.
+	ProtectionEnabled *bool `json:"protection_enabled"`
+
+	// RateLimit is the number of requests per second allowed per client.
+	RateLimit *uint32 `json:"ratelimit"`
+
+	// BlockingMode defines the way blocked responses are constructed.
+	BlockingMode *BlockingMode `json:"blocking_mode"`
+
+	// EDNSCSEnabled defines if EDNS Client Subnet is enabled.
+	EDNSCSEnabled *bool `json:"edns_cs_enabled"`
+
+	// EDNSCSUseCustom defines if EDNSCSCustomIP should be used.
+	EDNSCSUseCustom *bool `json:"edns_cs_use_custom"`
+
+	// DNSSECEnabled defines if DNSSEC is enabled.
+	DNSSECEnabled *bool `json:"dnssec_enabled"`
+
+	// DisableIPv6 defines if IPv6 addresses should be dropped.
+	DisableIPv6 *bool `json:"disable_ipv6"`
+
+	// UpstreamMode defines the way DNS requests are constructed.
+	UpstreamMode *string `json:"upstream_mode"`
+
+	// CacheSize in bytes.
+	CacheSize *uint32 `json:"cache_size"`
+
+	// CacheMinTTL is custom minimum TTL for cached DNS responses.
+	CacheMinTTL *uint32 `json:"cache_ttl_min"`
+
+	// CacheMaxTTL is custom maximum TTL for cached DNS responses.
+	CacheMaxTTL *uint32 `json:"cache_ttl_max"`
+
+	// CacheOptimistic defines if expired entries should be served.
+	CacheOptimistic *bool `json:"cache_optimistic"`
+
+	// ResolveClients defines if clients IPs should be resolved into hostnames.
+	ResolveClients *bool `json:"resolve_clients"`
+
+	// UsePrivateRDNS defines if privates DNS resolvers should be used.
+	UsePrivateRDNS *bool `json:"use_private_ptr_resolvers"`
+
+	// LocalPTRUpstreams is the list of local private DNS resolvers.
+	LocalPTRUpstreams *[]string `json:"local_ptr_upstreams"`
+
+	// BlockingIPv4 is custom IPv4 address for blocked A requests.
+	BlockingIPv4 net.IP `json:"blocking_ipv4"`
+
+	// BlockingIPv6 is custom IPv6 address for blocked AAAA requests.
+	BlockingIPv6 net.IP `json:"blocking_ipv6"`
+
+	// EDNSCSCustomIP is custom IP for EDNS Client Subnet.
+	EDNSCSCustomIP netip.Addr `json:"edns_cs_custom_ip"`
+
+	// DefaultLocalPTRUpstreams is used to pass the addresses from
+	// systemResolvers to the front-end.  It's not a pointer to the slice since
+	// there is no need to omit it while decoding from JSON.
+	DefaultLocalPTRUpstreams []string `json:"default_local_ptr_upstreams,omitempty"`
 }
 
 func (s *Server) getDNSConfig() (c *jsonDNSConfig) {
@@ -57,7 +109,11 @@ func (s *Server) getDNSConfig() (c *jsonDNSConfig) {
 	blockingIPv4 := s.conf.BlockingIPv4
 	blockingIPv6 := s.conf.BlockingIPv6
 	ratelimit := s.conf.Ratelimit
+
+	customIP := s.conf.EDNSClientSubnet.CustomIP
 	enableEDNSClientSubnet := s.conf.EDNSClientSubnet.Enabled
+	useCustom := s.conf.EDNSClientSubnet.UseCustom
+
 	enableDNSSEC := s.conf.EnableDNSSEC
 	aaaaDisabled := s.conf.AAAADisabled
 	cacheSize := s.conf.CacheSize
@@ -74,46 +130,40 @@ func (s *Server) getDNSConfig() (c *jsonDNSConfig) {
 		upstreamMode = "parallel"
 	}
 
-	return &jsonDNSConfig{
-		Upstreams:         &upstreams,
-		UpstreamsFile:     &upstreamFile,
-		Bootstraps:        &bootstraps,
-		ProtectionEnabled: &protectionEnabled,
-		BlockingMode:      &blockingMode,
-		BlockingIPv4:      blockingIPv4,
-		BlockingIPv6:      blockingIPv6,
-		RateLimit:         &ratelimit,
-		EDNSCSEnabled:     &enableEDNSClientSubnet,
-		DNSSECEnabled:     &enableDNSSEC,
-		DisableIPv6:       &aaaaDisabled,
-		CacheSize:         &cacheSize,
-		CacheMinTTL:       &cacheMinTTL,
-		CacheMaxTTL:       &cacheMaxTTL,
-		CacheOptimistic:   &cacheOptimistic,
-		UpstreamMode:      &upstreamMode,
-		ResolveClients:    &resolveClients,
-		UsePrivateRDNS:    &usePrivateRDNS,
-		LocalPTRUpstreams: &localPTRUpstreams,
-	}
-}
-
-func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
 	defLocalPTRUps, err := s.filterOurDNSAddrs(s.sysResolvers.Get())
 	if err != nil {
 		log.Debug("getting dns configuration: %s", err)
 	}
 
-	resp := struct {
-		jsonDNSConfig
-		// DefautLocalPTRUpstreams is used to pass the addresses from
-		// systemResolvers to the front-end.  It's not a pointer to the slice
-		// since there is no need to omit it while decoding from JSON.
-		DefautLocalPTRUpstreams []string `json:"default_local_ptr_upstreams,omitempty"`
-	}{
-		jsonDNSConfig:           *s.getDNSConfig(),
-		DefautLocalPTRUpstreams: defLocalPTRUps,
+	return &jsonDNSConfig{
+		Upstreams:                &upstreams,
+		UpstreamsFile:            &upstreamFile,
+		Bootstraps:               &bootstraps,
+		ProtectionEnabled:        &protectionEnabled,
+		BlockingMode:             &blockingMode,
+		BlockingIPv4:             blockingIPv4,
+		BlockingIPv6:             blockingIPv6,
+		RateLimit:                &ratelimit,
+		EDNSCSCustomIP:           customIP,
+		EDNSCSEnabled:            &enableEDNSClientSubnet,
+		EDNSCSUseCustom:          &useCustom,
+		DNSSECEnabled:            &enableDNSSEC,
+		DisableIPv6:              &aaaaDisabled,
+		CacheSize:                &cacheSize,
+		CacheMinTTL:              &cacheMinTTL,
+		CacheMaxTTL:              &cacheMaxTTL,
+		CacheOptimistic:          &cacheOptimistic,
+		UpstreamMode:             &upstreamMode,
+		ResolveClients:           &resolveClients,
+		UsePrivateRDNS:           &usePrivateRDNS,
+		LocalPTRUpstreams:        &localPTRUpstreams,
+		DefaultLocalPTRUpstreams: defLocalPTRUps,
 	}
+}
 
+// handleGetConfig handles requests to the GET /control/dns_info endpoint.
+func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
+	resp := s.getDNSConfig()
 	_ = aghhttp.WriteJSONResponse(w, r, resp)
 }
 
@@ -204,6 +254,7 @@ func (req *jsonDNSConfig) checkCacheTTL() bool {
 	return min <= max
 }
 
+// handleSetConfig handles requests to the POST /control/dns_config endpoint.
 func (s *Server) handleSetConfig(w http.ResponseWriter, r *http.Request) {
 	req := &jsonDNSConfig{}
 	err := json.NewDecoder(r.Body).Decode(req)
@@ -231,8 +282,8 @@ func (s *Server) handleSetConfig(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// setConfigRestartable sets the server parameters.  shouldRestart is true if
-// the server should be restarted to apply changes.
+// setConfig sets the server parameters.  shouldRestart is true if the server
+// should be restarted to apply changes.
 func (s *Server) setConfig(dc *jsonDNSConfig) (shouldRestart bool) {
 	s.serverLock.Lock()
 	defer s.serverLock.Unlock()
@@ -250,6 +301,10 @@ func (s *Server) setConfig(dc *jsonDNSConfig) (shouldRestart bool) {
 		s.conf.FastestAddr = *dc.UpstreamMode == "fastest_addr"
 	}
 
+	if dc.EDNSCSUseCustom != nil && *dc.EDNSCSUseCustom {
+		s.conf.EDNSClientSubnet.CustomIP = dc.EDNSCSCustomIP
+	}
+
 	setIfNotNil(&s.conf.ProtectionEnabled, dc.ProtectionEnabled)
 	setIfNotNil(&s.conf.EnableDNSSEC, dc.DNSSECEnabled)
 	setIfNotNil(&s.conf.AAAADisabled, dc.DisableIPv6)
@@ -281,6 +336,7 @@ func (s *Server) setConfigRestartable(dc *jsonDNSConfig) (shouldRestart bool) {
 		setIfNotNil(&s.conf.UpstreamDNSFileName, dc.UpstreamsFile),
 		setIfNotNil(&s.conf.BootstrapDNS, dc.Bootstraps),
 		setIfNotNil(&s.conf.EDNSClientSubnet.Enabled, dc.EDNSCSEnabled),
+		setIfNotNil(&s.conf.EDNSClientSubnet.UseCustom, dc.EDNSCSUseCustom),
 		setIfNotNil(&s.conf.CacheSize, dc.CacheSize),
 		setIfNotNil(&s.conf.CacheMinTTL, dc.CacheMinTTL),
 		setIfNotNil(&s.conf.CacheMaxTTL, dc.CacheMaxTTL),
diff --git a/internal/dnsforward/http_test.go b/internal/dnsforward/http_test.go
index ef2228c1..144568d3 100644
--- a/internal/dnsforward/http_test.go
+++ b/internal/dnsforward/http_test.go
@@ -181,6 +181,12 @@ func TestDNSForwardHTTP_handleSetConfig(t *testing.T) {
 	}, {
 		name:    "edns_cs_enabled",
 		wantSet: "",
+	}, {
+		name:    "edns_cs_use_custom",
+		wantSet: "",
+	}, {
+		name:    "edns_cs_use_custom_bad_ip",
+		wantSet: "decoding request: ParseAddr(\"bad.ip\"): unexpected character (at \"bad.ip\")",
 	}, {
 		name:    "dnssec_enabled",
 		wantSet: "",
@@ -222,16 +228,20 @@ func TestDNSForwardHTTP_handleSetConfig(t *testing.T) {
 		Req  json.RawMessage `json:"req"`
 		Want json.RawMessage `json:"want"`
 	}
-	loadTestData(t, t.Name()+jsonExt, &data)
+
+	testData := t.Name() + jsonExt
+	loadTestData(t, testData, &data)
 
 	for _, tc := range testCases {
+		// NOTE:  Do not use require.Contains, because the size of the data
+		// prevents it from printing a meaningful error message.
 		caseData, ok := data[tc.name]
-		require.True(t, ok)
+		require.Truef(t, ok, "%q does not contain test data for test case %s", testData, tc.name)
 
 		t.Run(tc.name, func(t *testing.T) {
 			t.Cleanup(func() {
 				s.conf = defaultConf
-				s.conf.FilteringConfig.EDNSClientSubnet.Enabled = false
+				s.conf.FilteringConfig.EDNSClientSubnet = &EDNSClientSubnet{}
 			})
 
 			rBody := io.NopCloser(bytes.NewReader(caseData.Req))
diff --git a/internal/dnsforward/testdata/TestDNSForwardHTTP_handleGetConfig.json b/internal/dnsforward/testdata/TestDNSForwardHTTP_handleGetConfig.json
index 3ac6f2f5..fe2c5666 100644
--- a/internal/dnsforward/testdata/TestDNSForwardHTTP_handleGetConfig.json
+++ b/internal/dnsforward/testdata/TestDNSForwardHTTP_handleGetConfig.json
@@ -26,7 +26,9 @@
     "cache_optimistic": false,
     "resolve_clients": false,
     "use_private_ptr_resolvers": false,
-    "local_ptr_upstreams": []
+    "local_ptr_upstreams": [],
+    "edns_cs_use_custom": false,
+    "edns_cs_custom_ip": ""
   },
   "fastest_addr": {
     "upstream_dns": [
@@ -55,7 +57,9 @@
     "cache_optimistic": false,
     "resolve_clients": false,
     "use_private_ptr_resolvers": false,
-    "local_ptr_upstreams": []
+    "local_ptr_upstreams": [],
+    "edns_cs_use_custom": false,
+    "edns_cs_custom_ip": ""
   },
   "parallel": {
     "upstream_dns": [
@@ -84,6 +88,8 @@
     "cache_optimistic": false,
     "resolve_clients": false,
     "use_private_ptr_resolvers": false,
-    "local_ptr_upstreams": []
+    "local_ptr_upstreams": [],
+    "edns_cs_use_custom": false,
+    "edns_cs_custom_ip": ""
   }
 }
diff --git a/internal/dnsforward/testdata/TestDNSForwardHTTP_handleSetConfig.json b/internal/dnsforward/testdata/TestDNSForwardHTTP_handleSetConfig.json
index f55359a9..ca8c963a 100644
--- a/internal/dnsforward/testdata/TestDNSForwardHTTP_handleSetConfig.json
+++ b/internal/dnsforward/testdata/TestDNSForwardHTTP_handleSetConfig.json
@@ -33,7 +33,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "bootstraps": {
@@ -66,7 +68,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "blocking_mode_good": {
@@ -100,7 +104,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "blocking_mode_bad": {
@@ -134,7 +140,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "ratelimit": {
@@ -168,7 +176,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "edns_cs_enabled": {
@@ -202,7 +212,85 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
+    }
+  },
+  "edns_cs_use_custom": {
+    "req": {
+      "edns_cs_enabled": true,
+      "edns_cs_use_custom": true,
+      "edns_cs_custom_ip": "1.2.3.4"
+    },
+    "want": {
+      "upstream_dns": [
+        "8.8.8.8:53",
+        "8.8.4.4:53"
+      ],
+      "upstream_dns_file": "",
+      "bootstrap_dns": [
+        "9.9.9.10",
+        "149.112.112.10",
+        "2620:fe::10",
+        "2620:fe::fe:10"
+      ],
+      "protection_enabled": true,
+      "ratelimit": 0,
+      "blocking_mode": "default",
+      "blocking_ipv4": "",
+      "blocking_ipv6": "",
+      "edns_cs_enabled": true,
+      "dnssec_enabled": false,
+      "disable_ipv6": false,
+      "upstream_mode": "",
+      "cache_size": 0,
+      "cache_ttl_min": 0,
+      "cache_ttl_max": 0,
+      "cache_optimistic": false,
+      "resolve_clients": false,
+      "use_private_ptr_resolvers": false,
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": true,
+      "edns_cs_custom_ip": "1.2.3.4"
+    }
+  },
+  "edns_cs_use_custom_bad_ip": {
+    "req": {
+      "edns_cs_enabled": true,
+      "edns_cs_use_custom": true,
+      "edns_cs_custom_ip": "bad.ip"
+    },
+    "want": {
+      "upstream_dns": [
+        "8.8.8.8:53",
+        "8.8.4.4:53"
+      ],
+      "upstream_dns_file": "",
+      "bootstrap_dns": [
+        "9.9.9.10",
+        "149.112.112.10",
+        "2620:fe::10",
+        "2620:fe::fe:10"
+      ],
+      "protection_enabled": true,
+      "ratelimit": 0,
+      "blocking_mode": "default",
+      "blocking_ipv4": "",
+      "blocking_ipv6": "",
+      "edns_cs_enabled": false,
+      "dnssec_enabled": false,
+      "disable_ipv6": false,
+      "upstream_mode": "",
+      "cache_size": 0,
+      "cache_ttl_min": 0,
+      "cache_ttl_max": 0,
+      "cache_optimistic": false,
+      "resolve_clients": false,
+      "use_private_ptr_resolvers": false,
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "dnssec_enabled": {
@@ -236,7 +324,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "cache_size": {
@@ -270,7 +360,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "upstream_mode_parallel": {
@@ -304,7 +396,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "upstream_mode_fastest_addr": {
@@ -338,7 +432,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "upstream_dns_bad": {
@@ -374,7 +470,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "bootstraps_bad": {
@@ -410,7 +508,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "cache_bad_ttl": {
@@ -445,7 +545,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "upstream_mode_bad": {
@@ -479,7 +581,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "local_ptr_upstreams_good": {
@@ -517,7 +621,9 @@
       "use_private_ptr_resolvers": false,
       "local_ptr_upstreams": [
         "123.123.123.123"
-      ]
+      ],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "local_ptr_upstreams_bad": {
@@ -554,7 +660,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   },
   "local_ptr_upstreams_null": {
@@ -588,7 +696,9 @@
       "cache_optimistic": false,
       "resolve_clients": false,
       "use_private_ptr_resolvers": false,
-      "local_ptr_upstreams": []
+      "local_ptr_upstreams": [],
+      "edns_cs_use_custom": false,
+      "edns_cs_custom_ip": ""
     }
   }
 }
diff --git a/internal/home/config.go b/internal/home/config.go
index 6aad27b6..30dcb69d 100644
--- a/internal/home/config.go
+++ b/internal/home/config.go
@@ -286,7 +286,7 @@ var config = &configuration{
 			CacheSize:      4 * 1024 * 1024,
 
 			EDNSClientSubnet: &dnsforward.EDNSClientSubnet{
-				CustomIP:  "",
+				CustomIP:  netip.Addr{},
 				Enabled:   false,
 				UseCustom: false,
 			},
diff --git a/openapi/CHANGELOG.md b/openapi/CHANGELOG.md
index 20dde662..3b43140e 100644
--- a/openapi/CHANGELOG.md
+++ b/openapi/CHANGELOG.md
@@ -4,6 +4,18 @@
 
 ## v0.108.0: API changes
 
+## v0.107.27: API changes
+
+### The new optional fields `"edns_cs_use_custom"` and `"edns_cs_custom_ip"` in `DNSConfig`
+
+* The new optional fields `"edns_cs_use_custom"` and `"edns_cs_custom_ip"` in
+  `POST /control/dns_config` method makes AdGuard Home use or not use the
+  custom IP for EDNS Client Subnet.
+
+* The new optional fields `"edns_cs_use_custom"` and `"edns_cs_custom_ip"` in
+  `GET /control/dns_info` method are set if AdGuard Home uses custom IP for
+  EDNS Client Subnet.
+
 
 
 ## v0.107.23: API changes
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 0bbac1e0..2ec4d858 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -1254,7 +1254,7 @@
           'example': 'en'
     'DNSConfig':
       'type': 'object'
-      'description': 'Query log configuration'
+      'description': 'DNS server configuration'
       'properties':
         'bootstrap_dns':
           'type': 'array'
@@ -1280,8 +1280,6 @@
           'type': 'string'
         'protection_enabled':
           'type': 'boolean'
-        'dhcp_available':
-          'type': 'boolean'
         'ratelimit':
           'type': 'integer'
         'blocking_mode':
@@ -1298,6 +1296,10 @@
           'type': 'string'
         'edns_cs_enabled':
           'type': 'boolean'
+        'edns_cs_use_custom':
+          'type': 'boolean'
+        'edns_cs_custom_ip':
+          'type': 'string'
         'disable_ipv6':
           'type': 'boolean'
         'dnssec_enabled':