From d4afd60b08a3cc6da63d3ece706906f4dc76fdb9 Mon Sep 17 00:00:00 2001
From: hellodword <46193371+hellodword@users.noreply.github.com>
Date: Fri, 9 Sep 2022 19:51:10 +0800
Subject: [PATCH 01/31] feat: add dns.ipset_file setting

---
 CHANGELOG.md                      |  6 ++++++
 internal/dnsforward/config.go     | 23 +++++++++++++++++++++++
 internal/dnsforward/dnsforward.go |  2 +-
 3 files changed, 30 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 67835936..7371ea24 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,12 @@ and this project adheres to
 See also the [v0.107.13 GitHub milestone][ms-v0.107.13].
 
 [ms-v0.107.13]:   https://github.com/AdguardTeam/AdGuardHome/milestone/49?closed=1
+
+### Added
+
+- The `dns.ipset_file` property in the configuration file now allows you to
+  load the ipset list from a separate file instead of setting all upstreams
+  in AdGuard Home settings.  ([#4686]).
 -->
 
 
diff --git a/internal/dnsforward/config.go b/internal/dnsforward/config.go
index 2b2ba2e8..5febc457 100644
--- a/internal/dnsforward/config.go
+++ b/internal/dnsforward/config.go
@@ -131,6 +131,10 @@ type FilteringConfig struct {
 	//   DOMAIN[,DOMAIN].../IPSET_NAME
 	//
 	IpsetList []string `yaml:"ipset"`
+
+	// IpsetListFileName, if set, points to the file with ipset configuration.
+	// The format is the same as in IpsetList.
+	IpsetListFileName string `yaml:"ipset_file"`
 }
 
 // TLSConfig is the TLS configuration for HTTPS, DNS-over-HTTPS, and DNS-over-TLS
@@ -501,3 +505,22 @@ func (s *Server) onGetCertificate(ch *tls.ClientHelloInfo) (*tls.Certificate, er
 	}
 	return &s.conf.cert, nil
 }
+
+// prepareIpsetListSettings - prepares ipset list settings
+func (s *Server) prepareIpsetListSettings() error {
+	var ipsets []string
+	if s.conf.IpsetListFileName != "" {
+		data, err := os.ReadFile(s.conf.IpsetListFileName)
+		if err != nil {
+			return err
+		}
+
+		ipsets = stringutil.SplitTrimmed(string(data), "\n")
+
+		log.Debug("dns: using %d ipset list from file %s", len(ipsets), s.conf.IpsetListFileName)
+	} else {
+		ipsets = s.conf.IpsetList
+	}
+
+	return s.ipset.init(ipsets)
+}
diff --git a/internal/dnsforward/dnsforward.go b/internal/dnsforward/dnsforward.go
index 33be1e83..0ed97b8a 100644
--- a/internal/dnsforward/dnsforward.go
+++ b/internal/dnsforward/dnsforward.go
@@ -446,7 +446,7 @@ func (s *Server) Prepare(conf *ServerConfig) (err error) {
 
 	s.initDefaultSettings()
 
-	err = s.ipset.init(s.conf.IpsetList)
+	err = s.prepareIpsetListSettings()
 	if err != nil {
 		// Don't wrap the error, because it's informative enough as is.
 		return err

From 782de99a0ae71a3d4b3b9ec08f3c27842013d8d9 Mon Sep 17 00:00:00 2001
From: Eugene Burkov <e.burkov@adguard.com>
Date: Fri, 9 Sep 2022 19:44:27 +0300
Subject: [PATCH 02/31] Pull request: 4904 return dhcp msg size

Merge in DNS/adguard-home from 4904-rm-padding to master

Updates #4904.
Updates #4903.

Squashed commit of the following:

commit 85337402ad64395704028534f17ac1887cff64e8
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Fri Sep 9 19:38:09 2022 +0300

    dhcpd: return dhcp msg size
---
 CHANGELOG.md         |  6 ++++++
 internal/dhcpd/v4.go | 16 ----------------
 2 files changed, 6 insertions(+), 16 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 67835936..ddad8fa8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,12 +15,18 @@ and this project adheres to
 ## [v0.108.0] - 2022-12-01 (APPROX.)
 -->
 
+### Changed
+
+- The minimum DHCP message size is reassigned back to BOOTP's constraint of 300
+  bytes ([#4904]).
+
 ### Security
 
 - Weaker cipher suites that use the CBC (cipher block chaining) mode of
   operation have been disabled ([#2993]).
 
 [#2993]: https://github.com/AdguardTeam/AdGuardHome/issues/2993
+[#4904]: https://github.com/AdguardTeam/AdGuardHome/issues/4904
 
 
 
diff --git a/internal/dhcpd/v4.go b/internal/dhcpd/v4.go
index 4d6c817b..5eec00bd 100644
--- a/internal/dhcpd/v4.go
+++ b/internal/dhcpd/v4.go
@@ -1086,12 +1086,6 @@ func (s *v4Server) packetHandler(conn net.PacketConn, peer net.Addr, req *dhcpv4
 	s.send(peer, conn, req, resp)
 }
 
-// minDHCPMsgSize is the minimum length of the encoded DHCP message in bytes
-// according to RFC-2131.
-//
-// See https://datatracker.ietf.org/doc/html/rfc2131#section-2.
-const minDHCPMsgSize = 576
-
 // send writes resp for peer to conn considering the req's parameters according
 // to RFC-2131.
 //
@@ -1133,16 +1127,6 @@ func (s *v4Server) send(peer net.Addr, conn net.PacketConn, req, resp *dhcpv4.DH
 	}
 
 	pktData := resp.ToBytes()
-	pktLen := len(pktData)
-	if pktLen < minDHCPMsgSize {
-		// Expand the packet to match the minimum DHCP message length.  Although
-		// the dhpcv4 package deals with the BOOTP's lower packet length
-		// constraint, it seems some clients expecting the length being at least
-		// 576 bytes as per RFC 2131 (and an obsolete RFC 1533).
-		//
-		// See https://github.com/AdguardTeam/AdGuardHome/issues/4337.
-		pktData = append(pktData, make([]byte, minDHCPMsgSize-pktLen)...)
-	}
 
 	log.Debug("dhcpv4: sending %d bytes to %s: %s", len(pktData), peer, resp.Summary())
 

From ccc4f1a2da6b152c09bb413b0e040ca4fee2b5ab Mon Sep 17 00:00:00 2001
From: Ainar Garipov <A.Garipov@AdGuard.COM>
Date: Mon, 12 Sep 2022 16:11:32 +0300
Subject: [PATCH 03/31] all: imp docs

---
 CHANGELOG.md                      | 13 ++++-----
 internal/dnsforward/config.go     | 44 ++++++++++++++++---------------
 internal/dnsforward/dnsforward.go |  2 +-
 3 files changed, 31 insertions(+), 28 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index db23109c..de190c29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,12 @@ and this project adheres to
 ## [v0.108.0] - 2022-12-01 (APPROX.)
 -->
 
+### Added
+
+- The new optional `dns.ipset_file` property in the configuration file allows
+  loading the `ipset` list from a file, just like `dns.upstream_dns_file` does
+  for upstream servers ([#4686]).
+
 ### Changed
 
 - The minimum DHCP message size is reassigned back to BOOTP's constraint of 300
@@ -26,6 +32,7 @@ and this project adheres to
   operation have been disabled ([#2993]).
 
 [#2993]: https://github.com/AdguardTeam/AdGuardHome/issues/2993
+[#4686]: https://github.com/AdguardTeam/AdGuardHome/issues/4686
 [#4904]: https://github.com/AdguardTeam/AdGuardHome/issues/4904
 
 
@@ -37,12 +44,6 @@ and this project adheres to
 See also the [v0.107.13 GitHub milestone][ms-v0.107.13].
 
 [ms-v0.107.13]:   https://github.com/AdguardTeam/AdGuardHome/milestone/49?closed=1
-
-### Added
-
-- The `dns.ipset_file` property in the configuration file now allows you to
-  load the ipset list from a separate file instead of setting all upstreams
-  in AdGuard Home settings.  ([#4686]).
 -->
 
 
diff --git a/internal/dnsforward/config.go b/internal/dnsforward/config.go
index 5febc457..747767c4 100644
--- a/internal/dnsforward/config.go
+++ b/internal/dnsforward/config.go
@@ -128,12 +128,13 @@ type FilteringConfig struct {
 	// IpsetList is the ipset configuration that allows AdGuard Home to add
 	// IP addresses of the specified domain names to an ipset list.  Syntax:
 	//
-	//   DOMAIN[,DOMAIN].../IPSET_NAME
+	//	DOMAIN[,DOMAIN].../IPSET_NAME
 	//
+	// This field is ignored if [IpsetListFileName] is set.
 	IpsetList []string `yaml:"ipset"`
 
 	// IpsetListFileName, if set, points to the file with ipset configuration.
-	// The format is the same as in IpsetList.
+	// The format is the same as in [IpsetList].
 	IpsetListFileName string `yaml:"ipset_file"`
 }
 
@@ -404,6 +405,26 @@ func setProxyUpstreamMode(
 	}
 }
 
+// prepareIpsetListSettings reads and prepares the ipset configuration either
+// from a file or from the data in the configuration file.
+func (s *Server) prepareIpsetListSettings() (err error) {
+	fn := s.conf.IpsetListFileName
+	if fn == "" {
+		return s.ipset.init(s.conf.IpsetList)
+	}
+
+	data, err := os.ReadFile(fn)
+	if err != nil {
+		return err
+	}
+
+	ipsets := stringutil.SplitTrimmed(string(data), "\n")
+
+	log.Debug("dns: using %d ipset rules from file %q", len(ipsets), fn)
+
+	return s.ipset.init(ipsets)
+}
+
 // prepareTLS - prepares TLS configuration for the DNS proxy
 func (s *Server) prepareTLS(proxyConfig *proxy.Config) error {
 	if len(s.conf.CertificateChainData) == 0 || len(s.conf.PrivateKeyData) == 0 {
@@ -505,22 +526,3 @@ func (s *Server) onGetCertificate(ch *tls.ClientHelloInfo) (*tls.Certificate, er
 	}
 	return &s.conf.cert, nil
 }
-
-// prepareIpsetListSettings - prepares ipset list settings
-func (s *Server) prepareIpsetListSettings() error {
-	var ipsets []string
-	if s.conf.IpsetListFileName != "" {
-		data, err := os.ReadFile(s.conf.IpsetListFileName)
-		if err != nil {
-			return err
-		}
-
-		ipsets = stringutil.SplitTrimmed(string(data), "\n")
-
-		log.Debug("dns: using %d ipset list from file %s", len(ipsets), s.conf.IpsetListFileName)
-	} else {
-		ipsets = s.conf.IpsetList
-	}
-
-	return s.ipset.init(ipsets)
-}
diff --git a/internal/dnsforward/dnsforward.go b/internal/dnsforward/dnsforward.go
index 0ed97b8a..4af874b4 100644
--- a/internal/dnsforward/dnsforward.go
+++ b/internal/dnsforward/dnsforward.go
@@ -449,7 +449,7 @@ func (s *Server) Prepare(conf *ServerConfig) (err error) {
 	err = s.prepareIpsetListSettings()
 	if err != nil {
 		// Don't wrap the error, because it's informative enough as is.
-		return err
+		return fmt.Errorf("preparing ipset settings: %w", err)
 	}
 
 	err = s.prepareUpstreamSettings()

From 10a8f7964482fd07502ac041ef92b4ea8adb3c3a Mon Sep 17 00:00:00 2001
From: Ainar Garipov <A.Garipov@AdGuard.COM>
Date: Mon, 12 Sep 2022 16:45:19 +0300
Subject: [PATCH 04/31] all: imp chlog

---
 CHANGELOG.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index de190c29..12c69286 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,9 +17,9 @@ and this project adheres to
 
 ### Added
 
-- The new optional `dns.ipset_file` property in the configuration file allows
-  loading the `ipset` list from a file, just like `dns.upstream_dns_file` does
-  for upstream servers ([#4686]).
+- The new optional `dns.ipset_file` property in the configuration file.  It
+  allows loading the `ipset` list from a file, just like `dns.upstream_dns_file`
+  does for upstream servers ([#4686]).
 
 ### Changed
 

From 53e2c1f7cd28749b79fb5d4ccee5b6c55becc1d1 Mon Sep 17 00:00:00 2001
From: hellodword <46193371+hellodword@users.noreply.github.com>
Date: Mon, 12 Sep 2022 22:36:23 +0800
Subject: [PATCH 05/31] disable notify in forked repository

---
 .github/workflows/build.yml | 3 ++-
 .github/workflows/lint.yml  | 3 ++-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 61ed05e2..7e153d0d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -112,7 +112,8 @@
     # Use always() to signal to the runner that this job must run even if the
     # previous ones failed.
     'if':
-      ${{ always() &&
+      ${{ github.repository_owner == 'AdguardTeam' &&
+        always() &&
         (
           github.event_name == 'push' ||
           github.event.pull_request.head.repo.full_name == github.repository
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 1842c2dc..56183571 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -43,7 +43,8 @@
     # Use always() to signal to the runner that this job must run even if the
     # previous ones failed.
     'if':
-      ${{ always() &&
+      ${{ github.repository_owner == 'AdguardTeam' &&
+        always() &&
         (
           github.event_name == 'push' ||
           github.event.pull_request.head.repo.full_name == github.repository

From bedfb47a9f21f225296806de0b452b9ad5262d43 Mon Sep 17 00:00:00 2001
From: Ainar Garipov <A.Garipov@AdGuard.COM>
Date: Tue, 13 Sep 2022 13:49:58 +0300
Subject: [PATCH 06/31] all: fmt workflow specs

---
 .github/workflows/build.yml | 3 ++-
 .github/workflows/lint.yml  | 3 ++-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 7e153d0d..c01f5b2c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -112,8 +112,9 @@
     # Use always() to signal to the runner that this job must run even if the
     # previous ones failed.
     'if':
-      ${{ github.repository_owner == 'AdguardTeam' &&
+      ${{
         always() &&
+        github.repository_owner == 'AdguardTeam' &&
         (
           github.event_name == 'push' ||
           github.event.pull_request.head.repo.full_name == github.repository
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 56183571..a60121fe 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -43,8 +43,9 @@
     # Use always() to signal to the runner that this job must run even if the
     # previous ones failed.
     'if':
-      ${{ github.repository_owner == 'AdguardTeam' &&
+      ${{
         always() &&
+        github.repository_owner == 'AdguardTeam' &&
         (
           github.event_name == 'push' ||
           github.event.pull_request.head.repo.full_name == github.repository

From 08799e9d0a335046f315977aefc4e468b60fddc0 Mon Sep 17 00:00:00 2001
From: Ainar Garipov <A.Garipov@AdGuard.COM>
Date: Tue, 13 Sep 2022 14:00:46 +0300
Subject: [PATCH 07/31] all: use precise go version in github workflows

---
 .github/workflows/build.yml | 6 +++---
 .github/workflows/lint.yml  | 4 ++--
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c01f5b2c..dbe2bad3 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,7 +1,7 @@
 'name': 'build'
 
 'env':
-  'GO_VERSION': '1.18'
+  'GO_VERSION': '1.18.6'
   'NODE_VERSION': '14'
 
 'on':
@@ -31,7 +31,7 @@
       'with':
         'fetch-depth': 0
     - 'name': 'Set up Go'
-      'uses': 'actions/setup-go@v2'
+      'uses': 'actions/setup-go@v3'
       'with':
         'go-version': '${{ env.GO_VERSION }}'
     - 'name': 'Set up Node'
@@ -72,7 +72,7 @@
       'with':
         'fetch-depth': 0
     - 'name': 'Set up Go'
-      'uses': 'actions/setup-go@v2'
+      'uses': 'actions/setup-go@v3'
       'with':
         'go-version': '${{ env.GO_VERSION }}'
     - 'name': 'Set up Node'
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index a60121fe..64719a3e 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1,7 +1,7 @@
 'name': 'lint'
 
 'env':
-  'GO_VERSION': '1.18'
+  'GO_VERSION': '1.18.6'
 
 'on':
   'push':
@@ -17,7 +17,7 @@
     'steps':
     - 'uses': 'actions/checkout@v2'
     - 'name': 'Set up Go'
-      'uses': 'actions/setup-go@v2'
+      'uses': 'actions/setup-go@v3'
       'with':
         'go-version': '${{ env.GO_VERSION }}'
     - 'name': 'run-lint'

From b79c08316f37ddfec94c73ee9c02405059fcd52f Mon Sep 17 00:00:00 2001
From: Ainar Garipov <A.Garipov@AdGuard.COM>
Date: Tue, 13 Sep 2022 14:39:10 +0300
Subject: [PATCH 08/31] all: temp disable govulncheck

---
 internal/tools/go.mod   |  6 +++---
 internal/tools/go.sum   | 12 ++++++------
 scripts/make/go-lint.sh |  5 ++++-
 3 files changed, 13 insertions(+), 10 deletions(-)

diff --git a/internal/tools/go.mod b/internal/tools/go.mod
index ed3ed977..c4f78914 100644
--- a/internal/tools/go.mod
+++ b/internal/tools/go.mod
@@ -10,7 +10,7 @@ require (
 	github.com/kyoh86/looppointer v0.1.7
 	github.com/securego/gosec/v2 v2.13.1
 	golang.org/x/tools v0.1.13-0.20220803210227-8b9a1fbdf5c3
-	golang.org/x/vuln v0.0.0-20220902211423-27dd78d2ca39
+	golang.org/x/vuln v0.0.0-20220912202342-0ed43f12cb05
 	honnef.co/go/tools v0.3.3
 	mvdan.cc/gofumpt v0.3.1
 	mvdan.cc/unparam v0.0.0-20220831102321-2fc90a84c7ec
@@ -25,10 +25,10 @@ require (
 	github.com/kyoh86/nolint v0.0.1 // indirect
 	github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354 // indirect
 	github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
-	golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 // indirect
+	golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 // indirect
 	golang.org/x/exp/typeparams v0.0.0-20220827204233-334a2380cb91 // indirect
 	golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
 	golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde // indirect
-	golang.org/x/sys v0.0.0-20220906135438-9e1f76180b77 // indirect
+	golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 )
diff --git a/internal/tools/go.sum b/internal/tools/go.sum
index 0f43bb9f..367d3d53 100644
--- a/internal/tools/go.sum
+++ b/internal/tools/go.sum
@@ -55,8 +55,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 h1:tnebWN09GYg9OLPss1KXj8txwZc6X6uMr6VFdcGNbHw=
-golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
 golang.org/x/exp/typeparams v0.0.0-20220827204233-334a2380cb91 h1:Ic/qN6TEifvObMGQy72k0n1LlJr7DjWWEi+MOsDOiSk=
 golang.org/x/exp/typeparams v0.0.0-20220827204233-334a2380cb91/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -86,8 +86,8 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220906135438-9e1f76180b77 h1:C1tElbkWrsSkn3IRl1GCW/gETw1TywWIPgwZtXTZbYg=
-golang.org/x/sys v0.0.0-20220906135438-9e1f76180b77/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2 h1:wM1k/lXfpc5HdkJJyW9GELpd8ERGdnh8sMGL6Gzq3Ho=
+golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -102,8 +102,8 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
 golang.org/x/tools v0.1.13-0.20220803210227-8b9a1fbdf5c3 h1:aE4T3aJwdCNz+s35ScSQYUzeGu7BOLDHZ1bBHVurqqY=
 golang.org/x/tools v0.1.13-0.20220803210227-8b9a1fbdf5c3/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/vuln v0.0.0-20220902211423-27dd78d2ca39 h1:501+NfNjDh4IT4HOzdeezTOFD7njtY49aXJN1oY3E1s=
-golang.org/x/vuln v0.0.0-20220902211423-27dd78d2ca39/go.mod h1:7tDfEDtOLlzHQRi4Yzfg5seVBSvouUIjyPzBx4q5CxQ=
+golang.org/x/vuln v0.0.0-20220912202342-0ed43f12cb05 h1:NWQHMTdThZhCArzUbnu1Bh+l3LdwUfjZws+ivBR2sxM=
+golang.org/x/vuln v0.0.0-20220912202342-0ed43f12cb05/go.mod h1:7tDfEDtOLlzHQRi4Yzfg5seVBSvouUIjyPzBx4q5CxQ=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/scripts/make/go-lint.sh b/scripts/make/go-lint.sh
index 2cdcc90d..f6f09592 100644
--- a/scripts/make/go-lint.sh
+++ b/scripts/make/go-lint.sh
@@ -219,7 +219,10 @@ exit_on_output gofumpt --extra -e -l .
 
 "$GO" vet ./...
 
-govulncheck ./...
+# TODO(a.garipov): Reenable this once https://github.com/golang/go/issues/55035
+# is fixed.
+#
+#	govulncheck ./...
 
 # Apply more lax standards to the code we haven't properly refactored yet.
 gocyclo --over 17 ./internal/querylog/

From b74b92fc271a59c00e3a20b8c16c61ec8193d55a Mon Sep 17 00:00:00 2001
From: Eugene Burkov <e.burkov@adguard.com>
Date: Tue, 13 Sep 2022 20:06:23 +0300
Subject: [PATCH 09/31] Pull request: Improve build tags

Merge in DNS/adguard-home from imp-build-tags to master

Squashed commit of the following:

commit c15793e04c08097835692568a598b8a8d15f57f4
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 13 19:25:20 2022 +0300

    home: imp build tags

commit 2b9b68e9fe6942422951f50d90c70143a3509401
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 13 19:23:56 2022 +0300

    version: imp build tags

commit c0ade3d6ae8885c596fc31312360b25fe992d1e4
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 13 19:20:48 2022 +0300

    dhcpd: imp build tags

commit 0ca2a73b7c3b721400a0cc6383cc9e60f4961f22
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 13 19:17:22 2022 +0300

    aghos: imp build tags

commit 733a685b24b56153b96d59cb97c174ad322ff841
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 13 19:13:11 2022 +0300

    aghnet: imp build tags
---
 internal/aghnet/arpdb_bsd.go                    | 1 -
 internal/aghnet/arpdb_bsd_test.go               | 1 -
 internal/aghnet/arpdb_linux.go                  | 1 -
 internal/aghnet/arpdb_linux_test.go             | 1 -
 internal/aghnet/arpdb_openbsd.go                | 1 -
 internal/aghnet/arpdb_openbsd_test.go           | 1 -
 internal/aghnet/arpdb_windows.go                | 1 -
 internal/aghnet/arpdb_windows_test.go           | 1 -
 internal/aghnet/dhcp_unix.go                    | 3 +--
 internal/aghnet/dhcp_windows.go                 | 1 -
 internal/aghnet/hostscontainer_linux.go         | 1 -
 internal/aghnet/hostscontainer_others.go        | 1 -
 internal/aghnet/hostscontainer_windows.go       | 1 -
 internal/aghnet/interfaces_linux.go             | 1 -
 internal/aghnet/interfaces_unix.go              | 3 +--
 internal/aghnet/interfaces_windows.go           | 1 -
 internal/aghnet/ipset_linux.go                  | 1 -
 internal/aghnet/ipset_linux_test.go             | 1 -
 internal/aghnet/ipset_others.go                 | 1 -
 internal/aghnet/net_bsd.go                      | 1 -
 internal/aghnet/net_darwin.go                   | 1 -
 internal/aghnet/net_freebsd.go                  | 1 -
 internal/aghnet/net_freebsd_test.go             | 1 -
 internal/aghnet/net_linux.go                    | 1 -
 internal/aghnet/net_linux_test.go               | 1 -
 internal/aghnet/net_openbsd.go                  | 1 -
 internal/aghnet/net_openbsd_test.go             | 1 -
 internal/aghnet/net_unix.go                     | 3 +--
 internal/aghnet/net_windows.go                  | 1 -
 internal/aghnet/systemresolvers_others.go       | 1 -
 internal/aghnet/systemresolvers_others_test.go  | 1 -
 internal/aghnet/systemresolvers_windows.go      | 1 -
 internal/aghnet/systemresolvers_windows_test.go | 1 -
 internal/aghos/endian_big.go                    | 1 -
 internal/aghos/endian_little.go                 | 1 -
 internal/aghos/os_bsd.go                        | 3 +--
 internal/aghos/os_freebsd.go                    | 1 -
 internal/aghos/os_linux.go                      | 1 -
 internal/aghos/os_unix.go                       | 1 -
 internal/aghos/os_windows.go                    | 1 -
 internal/aghos/syslog_others.go                 | 3 +--
 internal/aghos/syslog_windows.go                | 3 +--
 internal/aghos/user_unix.go                     | 3 +--
 internal/aghos/user_windows.go                  | 1 -
 internal/dhcpd/broadcast_bsd.go                 | 1 -
 internal/dhcpd/broadcast_bsd_test.go            | 1 -
 internal/dhcpd/broadcast_others.go              | 3 +--
 internal/dhcpd/broadcast_others_test.go         | 3 +--
 internal/dhcpd/conn_unix.go                     | 3 +--
 internal/dhcpd/conn_unix_test.go                | 3 +--
 internal/dhcpd/options_unix.go                  | 3 +--
 internal/dhcpd/options_unix_test.go             | 3 +--
 internal/dhcpd/os_windows.go                    | 1 -
 internal/dhcpd/v46_windows.go                   | 1 -
 internal/home/service_linux.go                  | 1 -
 internal/home/service_openbsd.go                | 1 -
 internal/home/service_others.go                 | 1 -
 internal/version/norace.go                      | 1 -
 internal/version/race.go                        | 1 -
 59 files changed, 13 insertions(+), 72 deletions(-)

diff --git a/internal/aghnet/arpdb_bsd.go b/internal/aghnet/arpdb_bsd.go
index 317579de..9519eeec 100644
--- a/internal/aghnet/arpdb_bsd.go
+++ b/internal/aghnet/arpdb_bsd.go
@@ -1,5 +1,4 @@
 //go:build darwin || freebsd
-// +build darwin freebsd
 
 package aghnet
 
diff --git a/internal/aghnet/arpdb_bsd_test.go b/internal/aghnet/arpdb_bsd_test.go
index 3404af69..9933c721 100644
--- a/internal/aghnet/arpdb_bsd_test.go
+++ b/internal/aghnet/arpdb_bsd_test.go
@@ -1,5 +1,4 @@
 //go:build darwin || freebsd
-// +build darwin freebsd
 
 package aghnet
 
diff --git a/internal/aghnet/arpdb_linux.go b/internal/aghnet/arpdb_linux.go
index e8b34a2f..82f83adf 100644
--- a/internal/aghnet/arpdb_linux.go
+++ b/internal/aghnet/arpdb_linux.go
@@ -1,5 +1,4 @@
 //go:build linux
-// +build linux
 
 package aghnet
 
diff --git a/internal/aghnet/arpdb_linux_test.go b/internal/aghnet/arpdb_linux_test.go
index 46d87150..22fe7135 100644
--- a/internal/aghnet/arpdb_linux_test.go
+++ b/internal/aghnet/arpdb_linux_test.go
@@ -1,5 +1,4 @@
 //go:build linux
-// +build linux
 
 package aghnet
 
diff --git a/internal/aghnet/arpdb_openbsd.go b/internal/aghnet/arpdb_openbsd.go
index e1626c0b..5590f335 100644
--- a/internal/aghnet/arpdb_openbsd.go
+++ b/internal/aghnet/arpdb_openbsd.go
@@ -1,5 +1,4 @@
 //go:build openbsd
-// +build openbsd
 
 package aghnet
 
diff --git a/internal/aghnet/arpdb_openbsd_test.go b/internal/aghnet/arpdb_openbsd_test.go
index 915c17ff..0a45514a 100644
--- a/internal/aghnet/arpdb_openbsd_test.go
+++ b/internal/aghnet/arpdb_openbsd_test.go
@@ -1,5 +1,4 @@
 //go:build openbsd
-// +build openbsd
 
 package aghnet
 
diff --git a/internal/aghnet/arpdb_windows.go b/internal/aghnet/arpdb_windows.go
index e81f6818..f6e27b5b 100644
--- a/internal/aghnet/arpdb_windows.go
+++ b/internal/aghnet/arpdb_windows.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package aghnet
 
diff --git a/internal/aghnet/arpdb_windows_test.go b/internal/aghnet/arpdb_windows_test.go
index ad88ff8e..bb75c988 100644
--- a/internal/aghnet/arpdb_windows_test.go
+++ b/internal/aghnet/arpdb_windows_test.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package aghnet
 
diff --git a/internal/aghnet/dhcp_unix.go b/internal/aghnet/dhcp_unix.go
index 4791d0e5..464e5aa9 100644
--- a/internal/aghnet/dhcp_unix.go
+++ b/internal/aghnet/dhcp_unix.go
@@ -1,5 +1,4 @@
-//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
-// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris
+//go:build darwin || freebsd || linux || openbsd
 
 package aghnet
 
diff --git a/internal/aghnet/dhcp_windows.go b/internal/aghnet/dhcp_windows.go
index 6d1ba231..f8f6dbd2 100644
--- a/internal/aghnet/dhcp_windows.go
+++ b/internal/aghnet/dhcp_windows.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package aghnet
 
diff --git a/internal/aghnet/hostscontainer_linux.go b/internal/aghnet/hostscontainer_linux.go
index b456efdd..290291e9 100644
--- a/internal/aghnet/hostscontainer_linux.go
+++ b/internal/aghnet/hostscontainer_linux.go
@@ -1,5 +1,4 @@
 //go:build linux
-// +build linux
 
 package aghnet
 
diff --git a/internal/aghnet/hostscontainer_others.go b/internal/aghnet/hostscontainer_others.go
index cd9f0fb7..61487dc4 100644
--- a/internal/aghnet/hostscontainer_others.go
+++ b/internal/aghnet/hostscontainer_others.go
@@ -1,5 +1,4 @@
 //go:build !(windows || linux)
-// +build !windows,!linux
 
 package aghnet
 
diff --git a/internal/aghnet/hostscontainer_windows.go b/internal/aghnet/hostscontainer_windows.go
index 9eb2fe6f..819ba5bb 100644
--- a/internal/aghnet/hostscontainer_windows.go
+++ b/internal/aghnet/hostscontainer_windows.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package aghnet
 
diff --git a/internal/aghnet/interfaces_linux.go b/internal/aghnet/interfaces_linux.go
index a3cda5fa..435084fb 100644
--- a/internal/aghnet/interfaces_linux.go
+++ b/internal/aghnet/interfaces_linux.go
@@ -1,5 +1,4 @@
 //go:build linux
-// +build linux
 
 package aghnet
 
diff --git a/internal/aghnet/interfaces_unix.go b/internal/aghnet/interfaces_unix.go
index 529b0d9c..476f1a1e 100644
--- a/internal/aghnet/interfaces_unix.go
+++ b/internal/aghnet/interfaces_unix.go
@@ -1,5 +1,4 @@
-//go:build aix || darwin || dragonfly || freebsd || netbsd || openbsd || solaris
-// +build aix darwin dragonfly freebsd netbsd openbsd solaris
+//go:build darwin || freebsd || openbsd
 
 package aghnet
 
diff --git a/internal/aghnet/interfaces_windows.go b/internal/aghnet/interfaces_windows.go
index e483c350..a00b7094 100644
--- a/internal/aghnet/interfaces_windows.go
+++ b/internal/aghnet/interfaces_windows.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package aghnet
 
diff --git a/internal/aghnet/ipset_linux.go b/internal/aghnet/ipset_linux.go
index 57248b41..d1376b52 100644
--- a/internal/aghnet/ipset_linux.go
+++ b/internal/aghnet/ipset_linux.go
@@ -1,5 +1,4 @@
 //go:build linux
-// +build linux
 
 package aghnet
 
diff --git a/internal/aghnet/ipset_linux_test.go b/internal/aghnet/ipset_linux_test.go
index 12c842a0..d220f87e 100644
--- a/internal/aghnet/ipset_linux_test.go
+++ b/internal/aghnet/ipset_linux_test.go
@@ -1,5 +1,4 @@
 //go:build linux
-// +build linux
 
 package aghnet
 
diff --git a/internal/aghnet/ipset_others.go b/internal/aghnet/ipset_others.go
index 814c35be..8406e0e1 100644
--- a/internal/aghnet/ipset_others.go
+++ b/internal/aghnet/ipset_others.go
@@ -1,5 +1,4 @@
 //go:build !linux
-// +build !linux
 
 package aghnet
 
diff --git a/internal/aghnet/net_bsd.go b/internal/aghnet/net_bsd.go
index bd705e92..94a27a6d 100644
--- a/internal/aghnet/net_bsd.go
+++ b/internal/aghnet/net_bsd.go
@@ -1,5 +1,4 @@
 //go:build darwin || freebsd || openbsd
-// +build darwin freebsd openbsd
 
 package aghnet
 
diff --git a/internal/aghnet/net_darwin.go b/internal/aghnet/net_darwin.go
index 296a18b0..bdf04729 100644
--- a/internal/aghnet/net_darwin.go
+++ b/internal/aghnet/net_darwin.go
@@ -1,5 +1,4 @@
 //go:build darwin
-// +build darwin
 
 package aghnet
 
diff --git a/internal/aghnet/net_freebsd.go b/internal/aghnet/net_freebsd.go
index 85d40184..94ce77c7 100644
--- a/internal/aghnet/net_freebsd.go
+++ b/internal/aghnet/net_freebsd.go
@@ -1,5 +1,4 @@
 //go:build freebsd
-// +build freebsd
 
 package aghnet
 
diff --git a/internal/aghnet/net_freebsd_test.go b/internal/aghnet/net_freebsd_test.go
index 2c758360..8e23092c 100644
--- a/internal/aghnet/net_freebsd_test.go
+++ b/internal/aghnet/net_freebsd_test.go
@@ -1,5 +1,4 @@
 //go:build freebsd
-// +build freebsd
 
 package aghnet
 
diff --git a/internal/aghnet/net_linux.go b/internal/aghnet/net_linux.go
index 4be8835c..d0c3f7fd 100644
--- a/internal/aghnet/net_linux.go
+++ b/internal/aghnet/net_linux.go
@@ -1,5 +1,4 @@
 //go:build linux
-// +build linux
 
 package aghnet
 
diff --git a/internal/aghnet/net_linux_test.go b/internal/aghnet/net_linux_test.go
index 838802ff..89058312 100644
--- a/internal/aghnet/net_linux_test.go
+++ b/internal/aghnet/net_linux_test.go
@@ -1,5 +1,4 @@
 //go:build linux
-// +build linux
 
 package aghnet
 
diff --git a/internal/aghnet/net_openbsd.go b/internal/aghnet/net_openbsd.go
index cf911105..a2650aee 100644
--- a/internal/aghnet/net_openbsd.go
+++ b/internal/aghnet/net_openbsd.go
@@ -1,5 +1,4 @@
 //go:build openbsd
-// +build openbsd
 
 package aghnet
 
diff --git a/internal/aghnet/net_openbsd_test.go b/internal/aghnet/net_openbsd_test.go
index 356799b7..7239f50f 100644
--- a/internal/aghnet/net_openbsd_test.go
+++ b/internal/aghnet/net_openbsd_test.go
@@ -1,5 +1,4 @@
 //go:build openbsd
-// +build openbsd
 
 package aghnet
 
diff --git a/internal/aghnet/net_unix.go b/internal/aghnet/net_unix.go
index 27b79579..421c5ec0 100644
--- a/internal/aghnet/net_unix.go
+++ b/internal/aghnet/net_unix.go
@@ -1,5 +1,4 @@
-//go:build openbsd || freebsd || linux || darwin
-// +build openbsd freebsd linux darwin
+//go:build darwin || freebsd || linux || openbsd
 
 package aghnet
 
diff --git a/internal/aghnet/net_windows.go b/internal/aghnet/net_windows.go
index 17499cce..1a17ab4d 100644
--- a/internal/aghnet/net_windows.go
+++ b/internal/aghnet/net_windows.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package aghnet
 
diff --git a/internal/aghnet/systemresolvers_others.go b/internal/aghnet/systemresolvers_others.go
index f8afa286..a0bdf953 100644
--- a/internal/aghnet/systemresolvers_others.go
+++ b/internal/aghnet/systemresolvers_others.go
@@ -1,5 +1,4 @@
 //go:build !windows
-// +build !windows
 
 package aghnet
 
diff --git a/internal/aghnet/systemresolvers_others_test.go b/internal/aghnet/systemresolvers_others_test.go
index f7cf9ef0..a9974e0a 100644
--- a/internal/aghnet/systemresolvers_others_test.go
+++ b/internal/aghnet/systemresolvers_others_test.go
@@ -1,5 +1,4 @@
 //go:build !windows
-// +build !windows
 
 package aghnet
 
diff --git a/internal/aghnet/systemresolvers_windows.go b/internal/aghnet/systemresolvers_windows.go
index 2e8ed3df..0f5d8c6e 100644
--- a/internal/aghnet/systemresolvers_windows.go
+++ b/internal/aghnet/systemresolvers_windows.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package aghnet
 
diff --git a/internal/aghnet/systemresolvers_windows_test.go b/internal/aghnet/systemresolvers_windows_test.go
index d8c38396..5b60bda7 100644
--- a/internal/aghnet/systemresolvers_windows_test.go
+++ b/internal/aghnet/systemresolvers_windows_test.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package aghnet
 
diff --git a/internal/aghos/endian_big.go b/internal/aghos/endian_big.go
index 8e6c0b99..d825023e 100644
--- a/internal/aghos/endian_big.go
+++ b/internal/aghos/endian_big.go
@@ -1,5 +1,4 @@
 //go:build mips || mips64
-// +build mips mips64
 
 // This file is an adapted version of github.com/josharian/native.
 
diff --git a/internal/aghos/endian_little.go b/internal/aghos/endian_little.go
index 56a4e3ce..22845305 100644
--- a/internal/aghos/endian_little.go
+++ b/internal/aghos/endian_little.go
@@ -1,5 +1,4 @@
 //go:build amd64 || 386 || arm || arm64 || mipsle || mips64le || ppc64le
-// +build amd64 386 arm arm64 mipsle mips64le ppc64le
 
 // This file is an adapted version of github.com/josharian/native.
 
diff --git a/internal/aghos/os_bsd.go b/internal/aghos/os_bsd.go
index 48b76609..746a8f3f 100644
--- a/internal/aghos/os_bsd.go
+++ b/internal/aghos/os_bsd.go
@@ -1,5 +1,4 @@
-//go:build darwin || netbsd || openbsd
-// +build darwin netbsd openbsd
+//go:build darwin || openbsd
 
 package aghos
 
diff --git a/internal/aghos/os_freebsd.go b/internal/aghos/os_freebsd.go
index 33cd9d3f..081aa55a 100644
--- a/internal/aghos/os_freebsd.go
+++ b/internal/aghos/os_freebsd.go
@@ -1,5 +1,4 @@
 //go:build freebsd
-// +build freebsd
 
 package aghos
 
diff --git a/internal/aghos/os_linux.go b/internal/aghos/os_linux.go
index ed513b00..daffb621 100644
--- a/internal/aghos/os_linux.go
+++ b/internal/aghos/os_linux.go
@@ -1,5 +1,4 @@
 //go:build linux
-// +build linux
 
 package aghos
 
diff --git a/internal/aghos/os_unix.go b/internal/aghos/os_unix.go
index 9a3cc308..da8ee912 100644
--- a/internal/aghos/os_unix.go
+++ b/internal/aghos/os_unix.go
@@ -1,5 +1,4 @@
 //go:build darwin || freebsd || linux || openbsd
-// +build darwin freebsd linux openbsd
 
 package aghos
 
diff --git a/internal/aghos/os_windows.go b/internal/aghos/os_windows.go
index 31fca3ef..c79a603f 100644
--- a/internal/aghos/os_windows.go
+++ b/internal/aghos/os_windows.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package aghos
 
diff --git a/internal/aghos/syslog_others.go b/internal/aghos/syslog_others.go
index 8399bae0..1659ae49 100644
--- a/internal/aghos/syslog_others.go
+++ b/internal/aghos/syslog_others.go
@@ -1,5 +1,4 @@
-//go:build !(windows || plan9)
-// +build !windows,!plan9
+//go:build !windows
 
 package aghos
 
diff --git a/internal/aghos/syslog_windows.go b/internal/aghos/syslog_windows.go
index f562168e..c8e86e78 100644
--- a/internal/aghos/syslog_windows.go
+++ b/internal/aghos/syslog_windows.go
@@ -1,5 +1,4 @@
-//go:build windows || plan9
-// +build windows plan9
+//go:build windows
 
 package aghos
 
diff --git a/internal/aghos/user_unix.go b/internal/aghos/user_unix.go
index 3ccb6eb9..e6da5fca 100644
--- a/internal/aghos/user_unix.go
+++ b/internal/aghos/user_unix.go
@@ -1,5 +1,4 @@
-//go:build darwin || freebsd || linux || netbsd || openbsd
-// +build darwin freebsd linux netbsd openbsd
+//go:build darwin || freebsd || linux || openbsd
 
 package aghos
 
diff --git a/internal/aghos/user_windows.go b/internal/aghos/user_windows.go
index 4fac90e1..a5876588 100644
--- a/internal/aghos/user_windows.go
+++ b/internal/aghos/user_windows.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package aghos
 
diff --git a/internal/dhcpd/broadcast_bsd.go b/internal/dhcpd/broadcast_bsd.go
index b55a903d..a3d0fabb 100644
--- a/internal/dhcpd/broadcast_bsd.go
+++ b/internal/dhcpd/broadcast_bsd.go
@@ -1,5 +1,4 @@
 //go:build freebsd || openbsd
-// +build freebsd openbsd
 
 package dhcpd
 
diff --git a/internal/dhcpd/broadcast_bsd_test.go b/internal/dhcpd/broadcast_bsd_test.go
index ed81d272..dc333ca6 100644
--- a/internal/dhcpd/broadcast_bsd_test.go
+++ b/internal/dhcpd/broadcast_bsd_test.go
@@ -1,5 +1,4 @@
 //go:build freebsd || openbsd
-// +build freebsd openbsd
 
 package dhcpd
 
diff --git a/internal/dhcpd/broadcast_others.go b/internal/dhcpd/broadcast_others.go
index a6e1307a..46326f8d 100644
--- a/internal/dhcpd/broadcast_others.go
+++ b/internal/dhcpd/broadcast_others.go
@@ -1,5 +1,4 @@
-//go:build aix || darwin || dragonfly || linux || netbsd || solaris
-// +build aix darwin dragonfly linux netbsd solaris
+//go:build darwin || linux
 
 package dhcpd
 
diff --git a/internal/dhcpd/broadcast_others_test.go b/internal/dhcpd/broadcast_others_test.go
index cb25ac28..437f114c 100644
--- a/internal/dhcpd/broadcast_others_test.go
+++ b/internal/dhcpd/broadcast_others_test.go
@@ -1,5 +1,4 @@
-//go:build aix || darwin || dragonfly || linux || netbsd || solaris
-// +build aix darwin dragonfly linux netbsd solaris
+//go:build darwin || linux
 
 package dhcpd
 
diff --git a/internal/dhcpd/conn_unix.go b/internal/dhcpd/conn_unix.go
index 01c063d1..ec58afda 100644
--- a/internal/dhcpd/conn_unix.go
+++ b/internal/dhcpd/conn_unix.go
@@ -1,5 +1,4 @@
-//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
-// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris
+//go:build darwin || freebsd || linux || openbsd
 
 package dhcpd
 
diff --git a/internal/dhcpd/conn_unix_test.go b/internal/dhcpd/conn_unix_test.go
index 0fdbfec0..aab79082 100644
--- a/internal/dhcpd/conn_unix_test.go
+++ b/internal/dhcpd/conn_unix_test.go
@@ -1,5 +1,4 @@
-//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
-// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris
+//go:build darwin || freebsd || linux || openbsd
 
 package dhcpd
 
diff --git a/internal/dhcpd/options_unix.go b/internal/dhcpd/options_unix.go
index dc06c429..01edff90 100644
--- a/internal/dhcpd/options_unix.go
+++ b/internal/dhcpd/options_unix.go
@@ -1,5 +1,4 @@
-//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
-// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris
+//go:build darwin || freebsd || linux || openbsd
 
 package dhcpd
 
diff --git a/internal/dhcpd/options_unix_test.go b/internal/dhcpd/options_unix_test.go
index e901284c..d231e6c2 100644
--- a/internal/dhcpd/options_unix_test.go
+++ b/internal/dhcpd/options_unix_test.go
@@ -1,5 +1,4 @@
-//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
-// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris
+//go:build darwin || freebsd || linux || openbsd
 
 package dhcpd
 
diff --git a/internal/dhcpd/os_windows.go b/internal/dhcpd/os_windows.go
index 33e5592b..ae016cfc 100644
--- a/internal/dhcpd/os_windows.go
+++ b/internal/dhcpd/os_windows.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package dhcpd
 
diff --git a/internal/dhcpd/v46_windows.go b/internal/dhcpd/v46_windows.go
index 7f2c4121..f32d5237 100644
--- a/internal/dhcpd/v46_windows.go
+++ b/internal/dhcpd/v46_windows.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package dhcpd
 
diff --git a/internal/home/service_linux.go b/internal/home/service_linux.go
index c885529b..39d572a0 100644
--- a/internal/home/service_linux.go
+++ b/internal/home/service_linux.go
@@ -1,5 +1,4 @@
 //go:build linux
-// +build linux
 
 package home
 
diff --git a/internal/home/service_openbsd.go b/internal/home/service_openbsd.go
index beeabd04..071775b9 100644
--- a/internal/home/service_openbsd.go
+++ b/internal/home/service_openbsd.go
@@ -1,5 +1,4 @@
 //go:build openbsd
-// +build openbsd
 
 package home
 
diff --git a/internal/home/service_others.go b/internal/home/service_others.go
index 6e2afd10..1be34317 100644
--- a/internal/home/service_others.go
+++ b/internal/home/service_others.go
@@ -1,5 +1,4 @@
 //go:build !(openbsd || linux)
-// +build !openbsd,!linux
 
 package home
 
diff --git a/internal/version/norace.go b/internal/version/norace.go
index 38dec06f..326d32cc 100644
--- a/internal/version/norace.go
+++ b/internal/version/norace.go
@@ -1,5 +1,4 @@
 //go:build !race
-// +build !race
 
 package version
 
diff --git a/internal/version/race.go b/internal/version/race.go
index eb1c73a5..4a1ae92d 100644
--- a/internal/version/race.go
+++ b/internal/version/race.go
@@ -1,5 +1,4 @@
 //go:build race
-// +build race
 
 package version
 

From fffa6567585324834eff781a3ddfa4026de3e301 Mon Sep 17 00:00:00 2001
From: Eugene Burkov <e.burkov@adguard.com>
Date: Tue, 13 Sep 2022 23:45:35 +0300
Subject: [PATCH 10/31] Pull request: 4722 dhcp http panic

Merge in DNS/adguard-home from 4722-dhcp-http-panic to master

Updates #4722.

Squashed commit of the following:

commit 8a8db48c3bd4f6bb7fabe65b5b7b162f0986fc76
Merge: 39b344f9 b74b92fc
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 13 20:11:23 2022 +0300

    Merge branch 'master' into 4722-dhcp-http-panic

commit 39b344f97180af17ab22041e5655a27bcc99c29e
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 13 18:33:56 2022 +0300

    dhcpd: imp code, fmt

commit a36d70d2c25791b2e657e21d6f4681b33497f0cd
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 13 17:38:17 2022 +0300

    dhcpd: imp names, docs

commit 600d63da7af62de5cb52fc7670ef28c9f4fe95a7
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 13 17:36:17 2022 +0300

    dhcpd: rename files, imp tags

commit 44f5507649db8536a07c4c21c8ad6e4a60ba3f43
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 13 16:40:26 2022 +0300

    dhcpd: add mock

commit cfc3cfb714705067d3aa71a7cb5df4245e091cfd
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 13 16:15:27 2022 +0300

    all: use ptr instead of value

commit ec526c2cf22df3470641296cfc402113c23c3f9b
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 13 14:57:10 2022 +0300

    all: log changes

commit 0eca09f4c72bbdc73a2334c839d7781847ba3962
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 13 14:50:32 2022 +0300

    dhcpd: let v4 be unconfigured

commit 59636e9ff48aea989d7bdfd216b37899b57137d2
Merge: 9238ca0a bc1503af
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 13 14:50:17 2022 +0300

    Merge branch 'master' into 4722-dhcp-http-panic

commit 9238ca0a1e190ddc344f01959f474932809f086a
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Wed Sep 7 18:28:56 2022 +0300

    dhcpd: imp conf

commit 5f801c9be96c2fa735a50373495d8c6ca2914f32
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 6 16:31:13 2022 +0300

    dhcpd: hide behind iface

commit a95c2741a7e3e5bfe8775bf937a3709217b76da0
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Wed Aug 31 16:24:02 2022 +0300

    dhcpd: separate os files
---
 CHANGELOG.md                                  |  11 +-
 internal/dhcpd/broadcast_bsd.go               |   9 +-
 internal/dhcpd/{server.go => config.go}       | 110 ++++++++++
 internal/dhcpd/db.go                          |   4 +-
 internal/dhcpd/dhcpd.go                       | 191 ++++++++++--------
 .../{dhcpd_test.go => dhcpd_unix_test.go}     |  35 +---
 internal/dhcpd/helpers.go                     |  36 ----
 internal/dhcpd/{http.go => http_unix.go}      | 144 +++++--------
 internal/dhcpd/http_windows.go                |  55 +++++
 .../{http_test.go => http_windows_test.go}    |  13 +-
 internal/dhcpd/options_unix.go                |  26 ++-
 internal/dhcpd/options_unix_test.go           |  16 +-
 internal/dhcpd/v46.go                         |  12 --
 internal/dhcpd/v46_windows.go                 |  28 +--
 internal/dhcpd/{v4.go => v4_unix.go}          | 103 ++++------
 .../dhcpd/{v4_test.go => v4_unix_test.go}     |  28 +--
 internal/dhcpd/{v6.go => v6_unix.go}          |   5 +-
 .../dhcpd/{v6_test.go => v6_unix_test.go}     |   3 +-
 internal/dnsforward/dns_test.go               |   4 +-
 internal/dnsforward/dnsforward.go             |  10 +-
 internal/dnsforward/dnsforward_test.go        |  42 ++--
 internal/dnsforward/filter_test.go            |   2 +-
 internal/home/clients.go                      |   4 +-
 internal/home/clients_test.go                 |   8 +-
 internal/home/config.go                       |   4 +-
 internal/home/home.go                         |  13 +-
 26 files changed, 476 insertions(+), 440 deletions(-)
 rename internal/dhcpd/{server.go => config.go} (55%)
 rename internal/dhcpd/{dhcpd_test.go => dhcpd_unix_test.go} (84%)
 delete mode 100644 internal/dhcpd/helpers.go
 rename internal/dhcpd/{http.go => http_unix.go} (78%)
 create mode 100644 internal/dhcpd/http_windows.go
 rename internal/dhcpd/{http_test.go => http_windows_test.go} (60%)
 delete mode 100644 internal/dhcpd/v46.go
 rename internal/dhcpd/{v4.go => v4_unix.go} (94%)
 rename internal/dhcpd/{v4_test.go => v4_unix_test.go} (97%)
 rename internal/dhcpd/{v6.go => v6_unix.go} (98%)
 rename internal/dhcpd/{v6_test.go => v6_unix_test.go} (97%)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 12c69286..7b348055 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,11 @@ and this project adheres to
 ## [v0.108.0] - 2022-12-01 (APPROX.)
 -->
 
+### Security
+
+- Weaker cipher suites that use the CBC (cipher block chaining) mode of
+  operation have been disabled ([#2993]).
+
 ### Added
 
 - The new optional `dns.ipset_file` property in the configuration file.  It
@@ -26,13 +31,13 @@ and this project adheres to
 - The minimum DHCP message size is reassigned back to BOOTP's constraint of 300
   bytes ([#4904]).
 
-### Security
+### Fixed
 
-- Weaker cipher suites that use the CBC (cipher block chaining) mode of
-  operation have been disabled ([#2993]).
+- Panic when adding a static lease within the disabled DHCP server ([#4722]).
 
 [#2993]: https://github.com/AdguardTeam/AdGuardHome/issues/2993
 [#4686]: https://github.com/AdguardTeam/AdGuardHome/issues/4686
+[#4722]: https://github.com/AdguardTeam/AdGuardHome/issues/4722
 [#4904]: https://github.com/AdguardTeam/AdGuardHome/issues/4904
 
 
diff --git a/internal/dhcpd/broadcast_bsd.go b/internal/dhcpd/broadcast_bsd.go
index a3d0fabb..59372cdf 100644
--- a/internal/dhcpd/broadcast_bsd.go
+++ b/internal/dhcpd/broadcast_bsd.go
@@ -9,11 +9,10 @@ import (
 // broadcast sends resp to the broadcast address specific for network interface.
 func (c *dhcpConn) broadcast(respData []byte, peer *net.UDPAddr) (n int, err error) {
 	// Despite the fact that server4.NewIPv4UDPConn explicitly sets socket
-	// options to allow broadcasting, it also binds the connection to a
-	// specific interface.  On FreeBSD and OpenBSD net.UDPConn.WriteTo
-	// causes errors while writing to the addresses that belong to another
-	// interface.  So, use the broadcast address specific for the interface
-	// bound.
+	// options to allow broadcasting, it also binds the connection to a specific
+	// interface.  On FreeBSD and OpenBSD net.UDPConn.WriteTo causes errors
+	// while writing to the addresses that belong to another interface.  So, use
+	// the broadcast address specific for the interface bound.
 	peer.IP = c.bcastIP
 
 	return c.udpConn.WriteTo(respData, peer)
diff --git a/internal/dhcpd/server.go b/internal/dhcpd/config.go
similarity index 55%
rename from internal/dhcpd/server.go
rename to internal/dhcpd/config.go
index be88804b..9d8ef057 100644
--- a/internal/dhcpd/server.go
+++ b/internal/dhcpd/config.go
@@ -1,10 +1,40 @@
 package dhcpd
 
 import (
+	"fmt"
 	"net"
 	"time"
+
+	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
+	"github.com/AdguardTeam/AdGuardHome/internal/aghnet"
+	"github.com/AdguardTeam/golibs/errors"
+	"github.com/AdguardTeam/golibs/netutil"
 )
 
+// ServerConfig is the configuration for the DHCP server.  The order of YAML
+// fields is important, since the YAML configuration file follows it.
+type ServerConfig struct {
+	// Called when the configuration is changed by HTTP request
+	ConfigModified func() `yaml:"-"`
+
+	// Register an HTTP handler
+	HTTPRegister aghhttp.RegisterFunc `yaml:"-"`
+
+	Enabled       bool   `yaml:"enabled"`
+	InterfaceName string `yaml:"interface_name"`
+
+	// LocalDomainName is the domain name used for DHCP hosts.  For example,
+	// a DHCP client with the hostname "myhost" can be addressed as "myhost.lan"
+	// when LocalDomainName is "lan".
+	LocalDomainName string `yaml:"local_domain_name"`
+
+	Conf4 V4ServerConf `yaml:"dhcpv4"`
+	Conf6 V6ServerConf `yaml:"dhcpv6"`
+
+	WorkDir    string `yaml:"-"`
+	DBFilePath string `yaml:"-"`
+}
+
 // DHCPServer - DHCP server interface
 type DHCPServer interface {
 	// ResetLeases resets leases.
@@ -80,6 +110,86 @@ type V4ServerConf struct {
 	notify func(uint32)
 }
 
+// errNilConfig is an error returned by validation method if the config is nil.
+const errNilConfig errors.Error = "nil config"
+
+// ensureV4 returns a 4-byte version of ip.  An error is returned if the passed
+// ip is not an IPv4.
+func ensureV4(ip net.IP) (ip4 net.IP, err error) {
+	if ip == nil {
+		return nil, fmt.Errorf("%v is not an IP address", ip)
+	}
+
+	ip4 = ip.To4()
+	if ip4 == nil {
+		return nil, fmt.Errorf("%v is not an IPv4 address", ip)
+	}
+
+	return ip4, nil
+}
+
+// Validate returns an error if c is not a valid configuration.
+//
+// TODO(e.burkov):  Don't set the config fields when the server itself will stop
+// containing the config.
+func (c *V4ServerConf) Validate() (err error) {
+	defer func() { err = errors.Annotate(err, "dhcpv4: %w") }()
+
+	if c == nil {
+		return errNilConfig
+	}
+
+	var gatewayIP net.IP
+	gatewayIP, err = ensureV4(c.GatewayIP)
+	if err != nil {
+		// Don't wrap an errors since it's inforative enough as is and there is
+		// an annotation deferred already.
+		return err
+	}
+
+	if c.SubnetMask == nil {
+		return fmt.Errorf("invalid subnet mask: %v", c.SubnetMask)
+	}
+
+	subnetMask := net.IPMask(netutil.CloneIP(c.SubnetMask.To4()))
+	c.subnet = &net.IPNet{
+		IP:   gatewayIP,
+		Mask: subnetMask,
+	}
+	c.broadcastIP = aghnet.BroadcastFromIPNet(c.subnet)
+
+	c.ipRange, err = newIPRange(c.RangeStart, c.RangeEnd)
+	if err != nil {
+		// Don't wrap an errors since it's inforative enough as is and there is
+		// an annotation deferred already.
+		return err
+	}
+
+	if c.ipRange.contains(gatewayIP) {
+		return fmt.Errorf("gateway ip %v in the ip range: %v-%v",
+			gatewayIP,
+			c.RangeStart,
+			c.RangeEnd,
+		)
+	}
+
+	if !c.subnet.Contains(c.RangeStart) {
+		return fmt.Errorf("range start %v is outside network %v",
+			c.RangeStart,
+			c.subnet,
+		)
+	}
+
+	if !c.subnet.Contains(c.RangeEnd) {
+		return fmt.Errorf("range end %v is outside network %v",
+			c.RangeEnd,
+			c.subnet,
+		)
+	}
+
+	return nil
+}
+
 // V6ServerConf - server configuration
 type V6ServerConf struct {
 	Enabled       bool   `yaml:"-" json:"-"`
diff --git a/internal/dhcpd/db.go b/internal/dhcpd/db.go
index af6bf72e..91a83447 100644
--- a/internal/dhcpd/db.go
+++ b/internal/dhcpd/db.go
@@ -32,7 +32,7 @@ func normalizeIP(ip net.IP) net.IP {
 }
 
 // Load lease table from DB
-func (s *Server) dbLoad() (err error) {
+func (s *server) dbLoad() (err error) {
 	dynLeases := []*Lease{}
 	staticLeases := []*Lease{}
 	v6StaticLeases := []*Lease{}
@@ -132,7 +132,7 @@ func normalizeLeases(staticLeases, dynLeases []*Lease) []*Lease {
 }
 
 // Store lease table in DB
-func (s *Server) dbStore() (err error) {
+func (s *server) dbStore() (err error) {
 	// Use an empty slice here as opposed to nil so that it doesn't write
 	// "null" into the database file if leases are empty.
 	leases := []leaseJSON{}
diff --git a/internal/dhcpd/dhcpd.go b/internal/dhcpd/dhcpd.go
index a085e656..875b3d79 100644
--- a/internal/dhcpd/dhcpd.go
+++ b/internal/dhcpd/dhcpd.go
@@ -6,12 +6,11 @@ import (
 	"fmt"
 	"net"
 	"path/filepath"
-	"runtime"
 	"time"
 
-	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
 	"github.com/AdguardTeam/golibs/log"
 	"github.com/AdguardTeam/golibs/netutil"
+	"github.com/AdguardTeam/golibs/timeutil"
 )
 
 const (
@@ -21,9 +20,19 @@ const (
 	// TODO(e.burkov): Remove it when static leases determining mechanism
 	// will be improved.
 	leaseExpireStatic = 1
+
+	// DefaultDHCPLeaseTTL is the default time-to-live for leases.
+	DefaultDHCPLeaseTTL = uint32(timeutil.Day / time.Second)
+
+	// DefaultDHCPTimeoutICMP is the default timeout for waiting ICMP responses.
+	DefaultDHCPTimeoutICMP = 1000
 )
 
-var webHandlersRegistered = false
+// Currently used defaults for ifaceDNSAddrs.
+const (
+	defaultMaxAttempts int           = 10
+	defaultBackoff     time.Duration = 500 * time.Millisecond
+)
 
 // Lease contains the necessary information about a DHCP lease
 type Lease struct {
@@ -119,30 +128,6 @@ func (l *Lease) UnmarshalJSON(data []byte) (err error) {
 	return nil
 }
 
-// ServerConfig is the configuration for the DHCP server.  The order of YAML
-// fields is important, since the YAML configuration file follows it.
-type ServerConfig struct {
-	// Called when the configuration is changed by HTTP request
-	ConfigModified func() `yaml:"-"`
-
-	// Register an HTTP handler
-	HTTPRegister aghhttp.RegisterFunc `yaml:"-"`
-
-	Enabled       bool   `yaml:"enabled"`
-	InterfaceName string `yaml:"interface_name"`
-
-	// LocalDomainName is the domain name used for DHCP hosts.  For example,
-	// a DHCP client with the hostname "myhost" can be addressed as "myhost.lan"
-	// when LocalDomainName is "lan".
-	LocalDomainName string `yaml:"local_domain_name"`
-
-	Conf4 V4ServerConf `yaml:"dhcpv4"`
-	Conf6 V6ServerConf `yaml:"dhcpv6"`
-
-	WorkDir    string `yaml:"-"`
-	DBFilePath string `yaml:"-"`
-}
-
 // OnLeaseChangedT is a callback for lease changes.
 type OnLeaseChangedT func(flags int)
 
@@ -156,8 +141,68 @@ const (
 	LeaseChangedDBStore
 )
 
-// Server - the current state of the DHCP server
-type Server struct {
+// GetLeasesFlags are the flags for GetLeases.
+type GetLeasesFlags uint8
+
+// GetLeasesFlags values
+const (
+	LeasesDynamic GetLeasesFlags = 0b01
+	LeasesStatic  GetLeasesFlags = 0b10
+
+	LeasesAll = LeasesDynamic | LeasesStatic
+)
+
+// Interface is the DHCP server that deals with both IP address families.
+type Interface interface {
+	Start() (err error)
+	Stop() (err error)
+	Enabled() (ok bool)
+
+	Leases(flags GetLeasesFlags) (leases []*Lease)
+	SetOnLeaseChanged(onLeaseChanged OnLeaseChangedT)
+	FindMACbyIP(ip net.IP) (mac net.HardwareAddr)
+
+	WriteDiskConfig(c *ServerConfig)
+}
+
+// MockInterface is a mock Interface implementation.
+//
+// TODO(e.burkov):  Move to aghtest when the API stabilized.
+type MockInterface struct {
+	OnStart             func() (err error)
+	OnStop              func() (err error)
+	OnEnabled           func() (ok bool)
+	OnLeases            func(flags GetLeasesFlags) (leases []*Lease)
+	OnSetOnLeaseChanged func(f OnLeaseChangedT)
+	OnFindMACbyIP       func(ip net.IP) (mac net.HardwareAddr)
+	OnWriteDiskConfig   func(c *ServerConfig)
+}
+
+var _ Interface = (*MockInterface)(nil)
+
+// Start implements the Interface for *MockInterface.
+func (s *MockInterface) Start() (err error) { return s.OnStart() }
+
+// Stop implements the Interface for *MockInterface.
+func (s *MockInterface) Stop() (err error) { return s.OnStop() }
+
+// Enabled implements the Interface for *MockInterface.
+func (s *MockInterface) Enabled() (ok bool) { return s.OnEnabled() }
+
+// Leases implements the Interface for *MockInterface.
+func (s *MockInterface) Leases(flags GetLeasesFlags) (ls []*Lease) { return s.OnLeases(flags) }
+
+// SetOnLeaseChanged implements the Interface for *MockInterface.
+func (s *MockInterface) SetOnLeaseChanged(f OnLeaseChangedT) { s.OnSetOnLeaseChanged(f) }
+
+// FindMACbyIP implements the Interface for *MockInterface.
+func (s *MockInterface) FindMACbyIP(ip net.IP) (mac net.HardwareAddr) { return s.OnFindMACbyIP(ip) }
+
+// WriteDiskConfig implements the Interface for *MockInterface.
+func (s *MockInterface) WriteDiskConfig(c *ServerConfig) { s.OnWriteDiskConfig(c) }
+
+// server is the DHCP service that handles DHCPv4, DHCPv6, and HTTP API.
+type server struct {
 	srv4 DHCPServer
 	srv6 DHCPServer
 
@@ -169,27 +214,15 @@ type Server struct {
 	onLeaseChanged []OnLeaseChangedT
 }
 
-// GetLeasesFlags are the flags for GetLeases.
-type GetLeasesFlags uint8
+// type check
+var _ Interface = (*server)(nil)
 
-// GetLeasesFlags values
-const (
-	LeasesDynamic GetLeasesFlags = 0b0001
-	LeasesStatic  GetLeasesFlags = 0b0010
-
-	LeasesAll = LeasesDynamic | LeasesStatic
-)
-
-// ServerInterface is an interface for servers.
-type ServerInterface interface {
-	Enabled() (ok bool)
-	Leases(flags GetLeasesFlags) (leases []*Lease)
-	SetOnLeaseChanged(onLeaseChanged OnLeaseChangedT)
-}
-
-// Create - create object
-func Create(conf *ServerConfig) (s *Server, err error) {
-	s = &Server{
+// Create initializes and returns the DHCP server handling both address
+// families.  It also registers the corresponding HTTP API endpoints.
+//
+// TODO(e.burkov):  Don't register handlers, see TODO on [aghhttp.RegisterFunc].
+func Create(conf *ServerConfig) (s *server, err error) {
+	s = &server{
 		conf: &ServerConfig{
 			ConfigModified: conf.ConfigModified,
 
@@ -204,35 +237,20 @@ func Create(conf *ServerConfig) (s *Server, err error) {
 		},
 	}
 
-	if !webHandlersRegistered && s.conf.HTTPRegister != nil {
-		if runtime.GOOS == "windows" {
-			// Our DHCP server doesn't work on Windows yet, so
-			// signal that to the front with an HTTP 501.
-			//
-			// TODO(a.garipov): This needs refactoring.  We
-			// shouldn't even try and initialize a DHCP server on
-			// Windows, but there are currently too many
-			// interconnected parts--such as HTTP handlers and
-			// frontend--to make that work properly.
-			s.registerNotImplementedHandlers()
-		} else {
-			s.registerHandlers()
-		}
-
-		webHandlersRegistered = true
-	}
+	s.registerHandlers()
 
 	v4conf := conf.Conf4
-	v4conf.Enabled = s.conf.Enabled
-	if len(v4conf.RangeStart) == 0 {
-		v4conf.Enabled = false
-	}
-
 	v4conf.InterfaceName = s.conf.InterfaceName
 	v4conf.notify = s.onNotify
-	s.srv4, err = v4Create(v4conf)
+	v4conf.Enabled = s.conf.Enabled && len(v4conf.RangeStart) != 0
+
+	s.srv4, err = v4Create(&v4conf)
 	if err != nil {
-		return nil, fmt.Errorf("creating dhcpv4 srv: %w", err)
+		if v4conf.Enabled {
+			return nil, fmt.Errorf("creating dhcpv4 srv: %w", err)
+		}
+
+		log.Error("creating dhcpv4 srv: %s", err)
 	}
 
 	v6conf := conf.Conf6
@@ -265,12 +283,12 @@ func Create(conf *ServerConfig) (s *Server, err error) {
 }
 
 // Enabled returns true when the server is enabled.
-func (s *Server) Enabled() (ok bool) {
+func (s *server) Enabled() (ok bool) {
 	return s.conf.Enabled
 }
 
 // resetLeases resets all leases in the lease database.
-func (s *Server) resetLeases() (err error) {
+func (s *server) resetLeases() (err error) {
 	err = s.srv4.ResetLeases(nil)
 	if err != nil {
 		return err
@@ -287,7 +305,7 @@ func (s *Server) resetLeases() (err error) {
 }
 
 // server calls this function after DB is updated
-func (s *Server) onNotify(flags uint32) {
+func (s *server) onNotify(flags uint32) {
 	if flags == LeaseChangedDBStore {
 		err := s.dbStore()
 		if err != nil {
@@ -301,31 +319,28 @@ func (s *Server) onNotify(flags uint32) {
 }
 
 // SetOnLeaseChanged - set callback
-func (s *Server) SetOnLeaseChanged(onLeaseChanged OnLeaseChangedT) {
+func (s *server) SetOnLeaseChanged(onLeaseChanged OnLeaseChangedT) {
 	s.onLeaseChanged = append(s.onLeaseChanged, onLeaseChanged)
 }
 
-func (s *Server) notify(flags int) {
-	if len(s.onLeaseChanged) == 0 {
-		return
-	}
-
+func (s *server) notify(flags int) {
 	for _, f := range s.onLeaseChanged {
 		f(flags)
 	}
 }
 
 // WriteDiskConfig - write configuration
-func (s *Server) WriteDiskConfig(c *ServerConfig) {
+func (s *server) WriteDiskConfig(c *ServerConfig) {
 	c.Enabled = s.conf.Enabled
 	c.InterfaceName = s.conf.InterfaceName
 	c.LocalDomainName = s.conf.LocalDomainName
+
 	s.srv4.WriteDiskConfig4(&c.Conf4)
 	s.srv6.WriteDiskConfig6(&c.Conf6)
 }
 
 // Start will listen on port 67 and serve DHCP requests.
-func (s *Server) Start() (err error) {
+func (s *server) Start() (err error) {
 	err = s.srv4.Start()
 	if err != nil {
 		return err
@@ -340,7 +355,7 @@ func (s *Server) Start() (err error) {
 }
 
 // Stop closes the listening UDP socket
-func (s *Server) Stop() (err error) {
+func (s *server) Stop() (err error) {
 	err = s.srv4.Stop()
 	if err != nil {
 		return err
@@ -356,12 +371,12 @@ func (s *Server) Stop() (err error) {
 
 // Leases returns the list of active IPv4 and IPv6 DHCP leases.  It's safe for
 // concurrent use.
-func (s *Server) Leases(flags GetLeasesFlags) (leases []*Lease) {
+func (s *server) Leases(flags GetLeasesFlags) (leases []*Lease) {
 	return append(s.srv4.GetLeases(flags), s.srv6.GetLeases(flags)...)
 }
 
 // FindMACbyIP - find a MAC address by IP address in the currently active DHCP leases
-func (s *Server) FindMACbyIP(ip net.IP) net.HardwareAddr {
+func (s *server) FindMACbyIP(ip net.IP) net.HardwareAddr {
 	if ip.To4() != nil {
 		return s.srv4.FindMACbyIP(ip)
 	}
@@ -369,6 +384,6 @@ func (s *Server) FindMACbyIP(ip net.IP) net.HardwareAddr {
 }
 
 // AddStaticLease - add static v4 lease
-func (s *Server) AddStaticLease(l *Lease) error {
+func (s *server) AddStaticLease(l *Lease) error {
 	return s.srv4.AddStaticLease(l)
 }
diff --git a/internal/dhcpd/dhcpd_test.go b/internal/dhcpd/dhcpd_unix_test.go
similarity index 84%
rename from internal/dhcpd/dhcpd_test.go
rename to internal/dhcpd/dhcpd_unix_test.go
index b704cbb4..cd1ca39a 100644
--- a/internal/dhcpd/dhcpd_test.go
+++ b/internal/dhcpd/dhcpd_unix_test.go
@@ -1,5 +1,4 @@
-//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
-// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris
+//go:build darwin || freebsd || linux || openbsd
 
 package dhcpd
 
@@ -26,13 +25,13 @@ func testNotify(flags uint32) {
 // Leases database store/load.
 func TestDB(t *testing.T) {
 	var err error
-	s := Server{
+	s := server{
 		conf: &ServerConfig{
 			DBFilePath: dbFilename,
 		},
 	}
 
-	s.srv4, err = v4Create(V4ServerConf{
+	s.srv4, err = v4Create(&V4ServerConf{
 		Enabled:    true,
 		RangeStart: net.IP{192, 168, 10, 100},
 		RangeEnd:   net.IP{192, 168, 10, 200},
@@ -88,32 +87,6 @@ func TestDB(t *testing.T) {
 	assert.Equal(t, leases[0].Expiry.Unix(), ll[1].Expiry.Unix())
 }
 
-func TestIsValidSubnetMask(t *testing.T) {
-	testCases := []struct {
-		mask net.IP
-		want bool
-	}{{
-		mask: net.IP{255, 255, 255, 0},
-		want: true,
-	}, {
-		mask: net.IP{255, 255, 254, 0},
-		want: true,
-	}, {
-		mask: net.IP{255, 255, 252, 0},
-		want: true,
-	}, {
-		mask: net.IP{255, 255, 253, 0},
-	}, {
-		mask: net.IP{255, 255, 255, 1},
-	}}
-
-	for _, tc := range testCases {
-		t.Run(tc.mask.String(), func(t *testing.T) {
-			assert.Equal(t, tc.want, isValidSubnetMask(tc.mask))
-		})
-	}
-}
-
 func TestNormalizeLeases(t *testing.T) {
 	dynLeases := []*Lease{{
 		HWAddr: net.HardwareAddr{1, 2, 3, 4},
@@ -174,7 +147,7 @@ func TestV4Server_badRange(t *testing.T) {
 				notify:     testNotify,
 			}
 
-			_, err := v4Create(conf)
+			_, err := v4Create(&conf)
 			testutil.AssertErrorMsg(t, tc.wantErrMsg, err)
 		})
 	}
diff --git a/internal/dhcpd/helpers.go b/internal/dhcpd/helpers.go
deleted file mode 100644
index df157a49..00000000
--- a/internal/dhcpd/helpers.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package dhcpd
-
-import (
-	"encoding/binary"
-	"fmt"
-	"net"
-)
-
-func tryTo4(ip net.IP) (ip4 net.IP, err error) {
-	if ip == nil {
-		return nil, fmt.Errorf("%v is not an IP address", ip)
-	}
-
-	ip4 = ip.To4()
-	if ip4 == nil {
-		return nil, fmt.Errorf("%v is not an IPv4 address", ip)
-	}
-
-	return ip4, nil
-}
-
-// Return TRUE if subnet mask is correct (e.g. 255.255.255.0)
-func isValidSubnetMask(mask net.IP) bool {
-	var n uint32
-	n = binary.BigEndian.Uint32(mask)
-	for i := 0; i != 32; i++ {
-		if n == 0 {
-			break
-		}
-		if (n & 0x80000000) == 0 {
-			return false
-		}
-		n <<= 1
-	}
-	return true
-}
diff --git a/internal/dhcpd/http.go b/internal/dhcpd/http_unix.go
similarity index 78%
rename from internal/dhcpd/http.go
rename to internal/dhcpd/http_unix.go
index a8f0c108..8a32dab6 100644
--- a/internal/dhcpd/http.go
+++ b/internal/dhcpd/http_unix.go
@@ -1,3 +1,5 @@
+//go:build darwin || freebsd || linux || openbsd
+
 package dhcpd
 
 import (
@@ -8,14 +10,12 @@ import (
 	"net/http"
 	"os"
 	"strings"
-	"time"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/aghalg"
 	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
 	"github.com/AdguardTeam/AdGuardHome/internal/aghnet"
 	"github.com/AdguardTeam/golibs/errors"
 	"github.com/AdguardTeam/golibs/log"
-	"github.com/AdguardTeam/golibs/timeutil"
 )
 
 type v4ServerConfJSON struct {
@@ -26,12 +26,12 @@ type v4ServerConfJSON struct {
 	LeaseDuration uint32 `json:"lease_duration"`
 }
 
-func v4JSONToServerConf(j *v4ServerConfJSON) V4ServerConf {
+func (j *v4ServerConfJSON) toServerConf() *V4ServerConf {
 	if j == nil {
-		return V4ServerConf{}
+		return &V4ServerConf{}
 	}
 
-	return V4ServerConf{
+	return &V4ServerConf{
 		GatewayIP:     j.GatewayIP,
 		SubnetMask:    j.SubnetMask,
 		RangeStart:    j.RangeStart,
@@ -66,7 +66,7 @@ type dhcpStatusResponse struct {
 	Enabled      bool         `json:"enabled"`
 }
 
-func (s *Server) handleDHCPStatus(w http.ResponseWriter, r *http.Request) {
+func (s *server) handleDHCPStatus(w http.ResponseWriter, r *http.Request) {
 	status := &dhcpStatusResponse{
 		Enabled:   s.conf.Enabled,
 		IfaceName: s.conf.InterfaceName,
@@ -81,6 +81,7 @@ func (s *Server) handleDHCPStatus(w http.ResponseWriter, r *http.Request) {
 	status.StaticLeases = s.Leases(LeasesStatic)
 
 	w.Header().Set("Content-Type", "application/json")
+
 	err := json.NewEncoder(w).Encode(status)
 	if err != nil {
 		aghhttp.Error(
@@ -93,28 +94,26 @@ func (s *Server) handleDHCPStatus(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-func (s *Server) enableDHCP(ifaceName string) (code int, err error) {
+func (s *server) enableDHCP(ifaceName string) (code int, err error) {
 	var hasStaticIP bool
 	hasStaticIP, err = aghnet.IfaceHasStaticIP(ifaceName)
 	if err != nil {
 		if errors.Is(err, os.ErrPermission) {
-			// ErrPermission may happen here on Linux systems where
-			// AdGuard Home is installed using Snap.  That doesn't
-			// necessarily mean that the machine doesn't have
-			// a static IP, so we can assume that it has and go on.
-			// If the machine doesn't, we'll get an error later.
+			// ErrPermission may happen here on Linux systems where AdGuard Home
+			// is installed using Snap.  That doesn't necessarily mean that the
+			// machine doesn't have a static IP, so we can assume that it has
+			// and go on.  If the machine doesn't, we'll get an error later.
 			//
 			// See https://github.com/AdguardTeam/AdGuardHome/issues/2667.
 			//
-			// TODO(a.garipov): I was thinking about moving this
-			// into IfaceHasStaticIP, but then we wouldn't be able
-			// to log it.  Think about it more.
+			// TODO(a.garipov): I was thinking about moving this into
+			// IfaceHasStaticIP, but then we wouldn't be able to log it.  Think
+			// about it more.
 			log.Info("error while checking static ip: %s; "+
 				"assuming machine has static ip and going on", err)
 			hasStaticIP = true
 		} else if errors.Is(err, aghnet.ErrNoStaticIPInfo) {
-			// Couldn't obtain a definitive answer.  Assume static
-			// IP an go on.
+			// Couldn't obtain a definitive answer.  Assume static IP an go on.
 			log.Info("can't check for static ip; " +
 				"assuming machine has static ip and going on")
 			hasStaticIP = true
@@ -149,34 +148,39 @@ type dhcpServerConfigJSON struct {
 	Enabled       aghalg.NullBool   `json:"enabled"`
 }
 
-func (s *Server) handleDHCPSetConfigV4(
+func (s *server) handleDHCPSetConfigV4(
 	conf *dhcpServerConfigJSON,
-) (srv4 DHCPServer, enabled bool, err error) {
+) (srv DHCPServer, enabled bool, err error) {
 	if conf.V4 == nil {
 		return nil, false, nil
 	}
 
-	v4Conf := v4JSONToServerConf(conf.V4)
+	v4Conf := conf.V4.toServerConf()
 	v4Conf.Enabled = conf.Enabled == aghalg.NBTrue
 	if len(v4Conf.RangeStart) == 0 {
 		v4Conf.Enabled = false
 	}
 
-	enabled = v4Conf.Enabled
 	v4Conf.InterfaceName = conf.InterfaceName
 
-	c4 := V4ServerConf{}
-	s.srv4.WriteDiskConfig4(&c4)
+	// Set the default values for the fields not configurable via web API.
+	c4 := &V4ServerConf{
+		notify:      s.onNotify,
+		ICMPTimeout: s.conf.Conf4.ICMPTimeout,
+		Options:     s.conf.Conf4.Options,
+	}
+
+	s.srv4.WriteDiskConfig4(c4)
 	v4Conf.notify = c4.notify
 	v4Conf.ICMPTimeout = c4.ICMPTimeout
 	v4Conf.Options = c4.Options
 
-	srv4, err = v4Create(v4Conf)
+	srv4, err := v4Create(v4Conf)
 
-	return srv4, enabled, err
+	return srv4, srv4.enabled(), err
 }
 
-func (s *Server) handleDHCPSetConfigV6(
+func (s *server) handleDHCPSetConfigV6(
 	conf *dhcpServerConfigJSON,
 ) (srv6 DHCPServer, enabled bool, err error) {
 	if conf.V6 == nil {
@@ -205,7 +209,7 @@ func (s *Server) handleDHCPSetConfigV6(
 	return srv6, enabled, err
 }
 
-func (s *Server) handleDHCPSetConfig(w http.ResponseWriter, r *http.Request) {
+func (s *server) handleDHCPSetConfig(w http.ResponseWriter, r *http.Request) {
 	conf := &dhcpServerConfigJSON{}
 	conf.Enabled = aghalg.BoolToNullBool(s.conf.Enabled)
 	conf.InterfaceName = s.conf.InterfaceName
@@ -287,7 +291,7 @@ type netInterfaceJSON struct {
 	Addrs6       []net.IP `json:"ipv6_addresses"`
 }
 
-func (s *Server) handleDHCPInterfaces(w http.ResponseWriter, r *http.Request) {
+func (s *server) handleDHCPInterfaces(w http.ResponseWriter, r *http.Request) {
 	response := map[string]netInterfaceJSON{}
 
 	ifaces, err := net.Interfaces()
@@ -410,7 +414,7 @@ type dhcpSearchResult struct {
 // . Search for another DHCP server running
 // . Check if a static IP is configured for the network interface
 // Respond with results
-func (s *Server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Request) {
+func (s *server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Request) {
 	// This use of ReadAll is safe, because request's body is now limited.
 	body, err := io.ReadAll(r.Body)
 	if err != nil {
@@ -482,7 +486,7 @@ func (s *Server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Reque
 	}
 }
 
-func (s *Server) handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request) {
+func (s *server) handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request) {
 	l := &Lease{}
 	err := json.NewDecoder(r.Body).Decode(l)
 	if err != nil {
@@ -497,20 +501,16 @@ func (s *Server) handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	ip4 := l.IP.To4()
-	if ip4 == nil {
+	var srv DHCPServer
+	if ip4 := l.IP.To4(); ip4 != nil {
+		l.IP = ip4
+		srv = s.srv4
+	} else {
 		l.IP = l.IP.To16()
-
-		err = s.srv6.AddStaticLease(l)
-		if err != nil {
-			aghhttp.Error(r, w, http.StatusBadRequest, "%s", err)
-		}
-
-		return
+		srv = s.srv6
 	}
 
-	l.IP = ip4
-	err = s.srv4.AddStaticLease(l)
+	err = srv.AddStaticLease(l)
 	if err != nil {
 		aghhttp.Error(r, w, http.StatusBadRequest, "%s", err)
 
@@ -518,7 +518,7 @@ func (s *Server) handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request
 	}
 }
 
-func (s *Server) handleDHCPRemoveStaticLease(w http.ResponseWriter, r *http.Request) {
+func (s *server) handleDHCPRemoveStaticLease(w http.ResponseWriter, r *http.Request) {
 	l := &Lease{}
 	err := json.NewDecoder(r.Body).Decode(l)
 	if err != nil {
@@ -555,14 +555,7 @@ func (s *Server) handleDHCPRemoveStaticLease(w http.ResponseWriter, r *http.Requ
 	}
 }
 
-const (
-	// DefaultDHCPLeaseTTL is the default time-to-live for leases.
-	DefaultDHCPLeaseTTL = uint32(timeutil.Day / time.Second)
-	// DefaultDHCPTimeoutICMP is the default timeout for waiting ICMP responses.
-	DefaultDHCPTimeoutICMP = 1000
-)
-
-func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
+func (s *server) handleReset(w http.ResponseWriter, r *http.Request) {
 	err := s.Stop()
 	if err != nil {
 		aghhttp.Error(r, w, http.StatusInternalServerError, "stopping dhcp: %s", err)
@@ -586,7 +579,7 @@ func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
 		DBFilePath: s.conf.DBFilePath,
 	}
 
-	v4conf := V4ServerConf{
+	v4conf := &V4ServerConf{
 		LeaseDuration: DefaultDHCPLeaseTTL,
 		ICMPTimeout:   DefaultDHCPTimeoutICMP,
 		notify:        s.onNotify,
@@ -602,7 +595,7 @@ func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
 	s.conf.ConfigModified()
 }
 
-func (s *Server) handleResetLeases(w http.ResponseWriter, r *http.Request) {
+func (s *server) handleResetLeases(w http.ResponseWriter, r *http.Request) {
 	err := s.resetLeases()
 	if err != nil {
 		msg := "resetting leases: %s"
@@ -612,7 +605,11 @@ func (s *Server) handleResetLeases(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-func (s *Server) registerHandlers() {
+func (s *server) registerHandlers() {
+	if s.conf.HTTPRegister == nil {
+		return
+	}
+
 	s.conf.HTTPRegister(http.MethodGet, "/control/dhcp/status", s.handleDHCPStatus)
 	s.conf.HTTPRegister(http.MethodGet, "/control/dhcp/interfaces", s.handleDHCPInterfaces)
 	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/set_config", s.handleDHCPSetConfig)
@@ -622,44 +619,3 @@ func (s *Server) registerHandlers() {
 	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/reset", s.handleReset)
 	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/reset_leases", s.handleResetLeases)
 }
-
-// jsonError is a generic JSON error response.
-//
-// TODO(a.garipov): Merge together with the implementations in .../home and
-// other packages after refactoring the web handler registering.
-type jsonError struct {
-	// Message is the error message, an opaque string.
-	Message string `json:"message"`
-}
-
-// notImplemented returns a handler that replies to any request with an HTTP 501
-// Not Implemented status and a JSON error with the provided message msg.
-//
-// TODO(a.garipov): Either take the logger from the server after we've
-// refactored logging or make this not a method of *Server.
-func (s *Server) notImplemented(msg string) (f func(http.ResponseWriter, *http.Request)) {
-	return func(w http.ResponseWriter, _ *http.Request) {
-		w.Header().Set("Content-Type", "application/json")
-		w.WriteHeader(http.StatusNotImplemented)
-
-		err := json.NewEncoder(w).Encode(&jsonError{
-			Message: msg,
-		})
-		if err != nil {
-			log.Debug("writing 501 json response: %s", err)
-		}
-	}
-}
-
-func (s *Server) registerNotImplementedHandlers() {
-	h := s.notImplemented("dhcp is not supported on windows")
-
-	s.conf.HTTPRegister(http.MethodGet, "/control/dhcp/status", h)
-	s.conf.HTTPRegister(http.MethodGet, "/control/dhcp/interfaces", h)
-	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/set_config", h)
-	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/find_active_dhcp", h)
-	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/add_static_lease", h)
-	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/remove_static_lease", h)
-	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/reset", h)
-	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/reset_leases", h)
-}
diff --git a/internal/dhcpd/http_windows.go b/internal/dhcpd/http_windows.go
new file mode 100644
index 00000000..5f7f73c1
--- /dev/null
+++ b/internal/dhcpd/http_windows.go
@@ -0,0 +1,55 @@
+//go:build windows
+
+package dhcpd
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/AdguardTeam/AdGuardHome/internal/aghos"
+	"github.com/AdguardTeam/golibs/log"
+)
+
+// jsonError is a generic JSON error response.
+//
+// TODO(a.garipov): Merge together with the implementations in .../home and
+// other packages after refactoring the web handler registering.
+type jsonError struct {
+	// Message is the error message, an opaque string.
+	Message string `json:"message"`
+}
+
+// notImplemented is a handler that replies to any request with an HTTP 501 Not
+// Implemented status and a JSON error with the provided message msg.
+//
+// TODO(a.garipov): Either take the logger from the server after we've
+// refactored logging or make this not a method of *Server.
+func (s *server) notImplemented(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusNotImplemented)
+
+	err := json.NewEncoder(w).Encode(&jsonError{
+		Message: aghos.Unsupported("dhcp").Error(),
+	})
+	if err != nil {
+		log.Debug("writing 501 json response: %s", err)
+	}
+}
+
+// registerHandlers sets the handlers for DHCP HTTP API that always respond with
+// an HTTP 501, since DHCP server doesn't work on Windows yet.
+//
+// TODO(a.garipov): This needs refactoring.  We shouldn't even try and
+// initialize a DHCP server on Windows, but there are currently too many
+// interconnected parts--such as HTTP handlers and frontend--to make that work
+// properly.
+func (s *server) registerHandlers() {
+	s.conf.HTTPRegister(http.MethodGet, "/control/dhcp/status", s.notImplemented)
+	s.conf.HTTPRegister(http.MethodGet, "/control/dhcp/interfaces", s.notImplemented)
+	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/set_config", s.notImplemented)
+	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/find_active_dhcp", s.notImplemented)
+	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/add_static_lease", s.notImplemented)
+	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/remove_static_lease", s.notImplemented)
+	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/reset", s.notImplemented)
+	s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/reset_leases", s.notImplemented)
+}
diff --git a/internal/dhcpd/http_test.go b/internal/dhcpd/http_windows_test.go
similarity index 60%
rename from internal/dhcpd/http_test.go
rename to internal/dhcpd/http_windows_test.go
index 120e02a3..12b9d7d9 100644
--- a/internal/dhcpd/http_test.go
+++ b/internal/dhcpd/http_windows_test.go
@@ -1,23 +1,28 @@
+//go:build windows
+
 package dhcpd
 
 import (
+	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"testing"
 
+	"github.com/AdguardTeam/AdGuardHome/internal/aghos"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
 func TestServer_notImplemented(t *testing.T) {
-	s := &Server{}
-	h := s.notImplemented("never!")
+	s := &server{}
 
 	w := httptest.NewRecorder()
 	r, err := http.NewRequest(http.MethodGet, "/unsupported", nil)
 	require.NoError(t, err)
 
-	h(w, r)
+	s.notImplemented(w, r)
 	assert.Equal(t, http.StatusNotImplemented, w.Code)
-	assert.Equal(t, `{"message":"never!"}`+"\n", w.Body.String())
+
+	wantStr := fmt.Sprintf("{%q:%q}", "message", aghos.Unsupported("dhcp"))
+	assert.JSONEq(t, wantStr, w.Body.String())
 }
diff --git a/internal/dhcpd/options_unix.go b/internal/dhcpd/options_unix.go
index 01edff90..6950604d 100644
--- a/internal/dhcpd/options_unix.go
+++ b/internal/dhcpd/options_unix.go
@@ -196,10 +196,10 @@ func parseDHCPOption(s string) (code dhcpv4.OptionCode, val dhcpv4.OptionValue,
 
 // prepareOptions builds the set of DHCP options according to host requirements
 // document and values from conf.
-func prepareOptions(conf V4ServerConf) (implicit, explicit dhcpv4.Options) {
+func (s *v4Server) prepareOptions() {
 	// Set default values of host configuration parameters listed in Appendix A
 	// of RFC-2131.
-	implicit = dhcpv4.OptionsFromList(
+	s.implicitOpts = dhcpv4.OptionsFromList(
 		// IP-Layer Per Host
 
 		// An Internet host that includes embedded gateway code MUST have a
@@ -375,14 +375,14 @@ func prepareOptions(conf V4ServerConf) (implicit, explicit dhcpv4.Options) {
 
 		// Set the Router Option to working subnet's IP since it's initialized
 		// with the address of the gateway.
-		dhcpv4.OptRouter(conf.subnet.IP),
+		dhcpv4.OptRouter(s.conf.subnet.IP),
 
-		dhcpv4.OptSubnetMask(conf.subnet.Mask),
+		dhcpv4.OptSubnetMask(s.conf.subnet.Mask),
 	)
 
 	// Set values for explicitly configured options.
-	explicit = dhcpv4.Options{}
-	for i, o := range conf.Options {
+	s.explicitOpts = dhcpv4.Options{}
+	for i, o := range s.conf.Options {
 		code, val, err := parseDHCPOption(o)
 		if err != nil {
 			log.Error("dhcpv4: bad option string at index %d: %s", i, err)
@@ -390,17 +390,15 @@ func prepareOptions(conf V4ServerConf) (implicit, explicit dhcpv4.Options) {
 			continue
 		}
 
-		explicit.Update(dhcpv4.Option{Code: code, Value: val})
+		s.explicitOpts.Update(dhcpv4.Option{Code: code, Value: val})
 		// Remove those from the implicit options.
-		delete(implicit, code.Code())
+		delete(s.implicitOpts, code.Code())
 	}
 
-	log.Debug("dhcpv4: implicit options:\n%s", implicit.Summary(nil))
-	log.Debug("dhcpv4: explicit options:\n%s", explicit.Summary(nil))
+	log.Debug("dhcpv4: implicit options:\n%s", s.implicitOpts.Summary(nil))
+	log.Debug("dhcpv4: explicit options:\n%s", s.explicitOpts.Summary(nil))
 
-	if len(explicit) == 0 {
-		explicit = nil
+	if len(s.explicitOpts) == 0 {
+		s.explicitOpts = nil
 	}
-
-	return implicit, explicit
 }
diff --git a/internal/dhcpd/options_unix_test.go b/internal/dhcpd/options_unix_test.go
index d231e6c2..2b5a5cb0 100644
--- a/internal/dhcpd/options_unix_test.go
+++ b/internal/dhcpd/options_unix_test.go
@@ -249,17 +249,21 @@ func TestPrepareOptions(t *testing.T) {
 	}}
 
 	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			implicit, explicit := prepareOptions(V4ServerConf{
+		s := &v4Server{
+			conf: &V4ServerConf{
 				// Just to avoid nil pointer dereference.
 				subnet:  &net.IPNet{},
 				Options: tc.opts,
-			})
+			},
+		}
 
-			assert.Equal(t, tc.wantExplicit, explicit)
+		t.Run(tc.name, func(t *testing.T) {
+			s.prepareOptions()
 
-			for c := range explicit {
-				assert.NotContains(t, implicit, c)
+			assert.Equal(t, tc.wantExplicit, s.explicitOpts)
+
+			for c := range s.explicitOpts {
+				assert.NotContains(t, s.implicitOpts, c)
 			}
 		})
 	}
diff --git a/internal/dhcpd/v46.go b/internal/dhcpd/v46.go
deleted file mode 100644
index 5e44eca3..00000000
--- a/internal/dhcpd/v46.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package dhcpd
-
-import (
-	"time"
-)
-
-// Currently used defaults for ifaceDNSAddrs.
-const (
-	defaultMaxAttempts int = 10
-
-	defaultBackoff time.Duration = 500 * time.Millisecond
-)
diff --git a/internal/dhcpd/v46_windows.go b/internal/dhcpd/v46_windows.go
index f32d5237..624ec767 100644
--- a/internal/dhcpd/v46_windows.go
+++ b/internal/dhcpd/v46_windows.go
@@ -8,15 +8,19 @@ import "net"
 
 type winServer struct{}
 
-func (s *winServer) ResetLeases(_ []*Lease) (err error)           { return nil }
-func (s *winServer) GetLeases(_ GetLeasesFlags) (leases []*Lease) { return nil }
-func (s *winServer) getLeasesRef() []*Lease                       { return nil }
-func (s *winServer) AddStaticLease(_ *Lease) (err error)          { return nil }
-func (s *winServer) RemoveStaticLease(_ *Lease) (err error)       { return nil }
-func (s *winServer) FindMACbyIP(ip net.IP) (mac net.HardwareAddr) { return nil }
-func (s *winServer) WriteDiskConfig4(c *V4ServerConf)             {}
-func (s *winServer) WriteDiskConfig6(c *V6ServerConf)             {}
-func (s *winServer) Start() (err error)                           { return nil }
-func (s *winServer) Stop() (err error)                            { return nil }
-func v4Create(conf V4ServerConf) (DHCPServer, error)              { return &winServer{}, nil }
-func v6Create(conf V6ServerConf) (DHCPServer, error)              { return &winServer{}, nil }
+// type check
+var _ DHCPServer = winServer{}
+
+func (winServer) ResetLeases(_ []*Lease) (err error)           { return nil }
+func (winServer) GetLeases(_ GetLeasesFlags) (leases []*Lease) { return nil }
+func (winServer) getLeasesRef() []*Lease                       { return nil }
+func (winServer) AddStaticLease(_ *Lease) (err error)          { return nil }
+func (winServer) RemoveStaticLease(_ *Lease) (err error)       { return nil }
+func (winServer) FindMACbyIP(_ net.IP) (mac net.HardwareAddr)  { return nil }
+func (winServer) WriteDiskConfig4(_ *V4ServerConf)             {}
+func (winServer) WriteDiskConfig6(_ *V6ServerConf)             {}
+func (winServer) Start() (err error)                           { return nil }
+func (winServer) Stop() (err error)                            { return nil }
+
+func v4Create(_ *V4ServerConf) (s DHCPServer, err error) { return winServer{}, nil }
+func v6Create(_ V6ServerConf) (s DHCPServer, err error)  { return winServer{}, nil }
diff --git a/internal/dhcpd/v4.go b/internal/dhcpd/v4_unix.go
similarity index 94%
rename from internal/dhcpd/v4.go
rename to internal/dhcpd/v4_unix.go
index 5eec00bd..3735ffdc 100644
--- a/internal/dhcpd/v4.go
+++ b/internal/dhcpd/v4_unix.go
@@ -1,5 +1,4 @@
-//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
-// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris
+//go:build darwin || freebsd || linux || openbsd
 
 package dhcpd
 
@@ -30,8 +29,9 @@ import (
 //
 // TODO(a.garipov): Think about unifying this and v6Server.
 type v4Server struct {
-	conf V4ServerConf
-	srv  *server4.Server
+	conf *V4ServerConf
+
+	srv *server4.Server
 
 	// implicitOpts are the options listed in Appendix A of RFC 2131 initialized
 	// with default values.  It must not have intersections with [explicitOpts].
@@ -55,9 +55,15 @@ type v4Server struct {
 	leases []*Lease
 }
 
+func (s *v4Server) enabled() (ok bool) {
+	return s.conf != nil && s.conf.Enabled
+}
+
 // WriteDiskConfig4 - write configuration
 func (s *v4Server) WriteDiskConfig4(c *V4ServerConf) {
-	*c = s.conf
+	if s.conf != nil {
+		*c = *s.conf
+	}
 }
 
 // WriteDiskConfig6 - write configuration
@@ -114,8 +120,8 @@ func (s *v4Server) validHostnameForClient(cliHostname string, ip net.IP) (hostna
 func (s *v4Server) ResetLeases(leases []*Lease) (err error) {
 	defer func() { err = errors.Annotate(err, "dhcpv4: %w") }()
 
-	if !s.conf.Enabled {
-		return
+	if s.conf == nil {
+		return nil
 	}
 
 	s.leasedOffsets = newBitSet()
@@ -129,12 +135,7 @@ func (s *v4Server) ResetLeases(leases []*Lease) (err error) {
 		err = s.addLease(l)
 		if err != nil {
 			// TODO(a.garipov): Wrap and bubble up the error.
-			log.Error(
-				"dhcpv4: reset: re-adding a lease for %s (%s): %s",
-				l.IP,
-				l.HWAddr,
-				err,
-			)
+			log.Error("dhcpv4: reset: re-adding a lease for %s (%s): %s", l.IP, l.HWAddr, err)
 
 			continue
 		}
@@ -336,11 +337,19 @@ func (s *v4Server) rmLease(lease *Lease) (err error) {
 	return errors.Error("lease not found")
 }
 
+// ErrUnconfigured is returned from the server's method when it requires the
+// server to be configured and it's not.
+const ErrUnconfigured errors.Error = "server is unconfigured"
+
 // AddStaticLease implements the DHCPServer interface for *v4Server.  It is safe
 // for concurrent use.
 func (s *v4Server) AddStaticLease(l *Lease) (err error) {
 	defer func() { err = errors.Annotate(err, "dhcpv4: adding static lease: %w") }()
 
+	if s.conf == nil {
+		return ErrUnconfigured
+	}
+
 	ip := l.IP.To4()
 	if ip == nil {
 		return fmt.Errorf("invalid ip %q, only ipv4 is supported", l.IP)
@@ -414,6 +423,10 @@ func (s *v4Server) AddStaticLease(l *Lease) (err error) {
 func (s *v4Server) RemoveStaticLease(l *Lease) (err error) {
 	defer func() { err = errors.Annotate(err, "dhcpv4: %w") }()
 
+	if s.conf == nil {
+		return ErrUnconfigured
+	}
+
 	if len(l.IP) != 4 {
 		return fmt.Errorf("invalid IP")
 	}
@@ -1140,7 +1153,7 @@ func (s *v4Server) send(peer net.Addr, conn net.PacketConn, req, resp *dhcpv4.DH
 func (s *v4Server) Start() (err error) {
 	defer func() { err = errors.Annotate(err, "dhcpv4: %w") }()
 
-	if !s.conf.Enabled {
+	if !s.enabled() {
 		return nil
 	}
 
@@ -1232,62 +1245,20 @@ func (s *v4Server) Stop() (err error) {
 }
 
 // Create DHCPv4 server
-func v4Create(conf V4ServerConf) (srv DHCPServer, err error) {
-	s := &v4Server{}
-	s.conf = conf
-	s.leaseHosts = stringutil.NewSet()
-
-	// TODO(a.garipov): Don't use a disabled server in other places or just
-	// use an interface.
-	if !conf.Enabled {
-		return s, nil
+func v4Create(conf *V4ServerConf) (srv *v4Server, err error) {
+	s := &v4Server{
+		leaseHosts: stringutil.NewSet(),
 	}
 
-	var routerIP net.IP
-	routerIP, err = tryTo4(s.conf.GatewayIP)
+	err = conf.Validate()
 	if err != nil {
-		return s, fmt.Errorf("dhcpv4: %w", err)
+		// TODO(a.garipov): Don't use a disabled server in other places or just
+		// use an interface.
+		return s, err
 	}
 
-	if s.conf.SubnetMask == nil {
-		return s, fmt.Errorf("dhcpv4: invalid subnet mask: %v", s.conf.SubnetMask)
-	}
-
-	subnetMask := make([]byte, 4)
-	copy(subnetMask, s.conf.SubnetMask.To4())
-
-	s.conf.subnet = &net.IPNet{
-		IP:   routerIP,
-		Mask: subnetMask,
-	}
-	s.conf.broadcastIP = aghnet.BroadcastFromIPNet(s.conf.subnet)
-
-	s.conf.ipRange, err = newIPRange(conf.RangeStart, conf.RangeEnd)
-	if err != nil {
-		return s, fmt.Errorf("dhcpv4: %w", err)
-	}
-
-	if s.conf.ipRange.contains(routerIP) {
-		return s, fmt.Errorf("dhcpv4: gateway ip %v in the ip range: %v-%v",
-			routerIP,
-			conf.RangeStart,
-			conf.RangeEnd,
-		)
-	}
-
-	if !s.conf.subnet.Contains(conf.RangeStart) {
-		return s, fmt.Errorf("dhcpv4: range start %v is outside network %v",
-			conf.RangeStart,
-			s.conf.subnet,
-		)
-	}
-
-	if !s.conf.subnet.Contains(conf.RangeEnd) {
-		return s, fmt.Errorf("dhcpv4: range end %v is outside network %v",
-			conf.RangeEnd,
-			s.conf.subnet,
-		)
-	}
+	s.conf = &V4ServerConf{}
+	*s.conf = *conf
 
 	// TODO(a.garipov, d.seregin): Check that every lease is inside the IPRange.
 	s.leasedOffsets = newBitSet()
@@ -1299,7 +1270,7 @@ func v4Create(conf V4ServerConf) (srv DHCPServer, err error) {
 		s.conf.leaseTime = time.Second * time.Duration(conf.LeaseDuration)
 	}
 
-	s.implicitOpts, s.explicitOpts = prepareOptions(s.conf)
+	s.prepareOptions()
 
 	return s, nil
 }
diff --git a/internal/dhcpd/v4_test.go b/internal/dhcpd/v4_unix_test.go
similarity index 97%
rename from internal/dhcpd/v4_test.go
rename to internal/dhcpd/v4_unix_test.go
index 6d8f513e..c73009a2 100644
--- a/internal/dhcpd/v4_test.go
+++ b/internal/dhcpd/v4_unix_test.go
@@ -1,5 +1,4 @@
-//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
-// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris
+//go:build darwin || freebsd || linux || openbsd
 
 package dhcpd
 
@@ -30,19 +29,16 @@ var (
 	DefaultSubnetMask = net.IP{255, 255, 255, 0}
 )
 
-func notify4(flags uint32) {
-}
-
 // defaultV4ServerConf returns the default configuration for *v4Server to use in
 // tests.
-func defaultV4ServerConf() (conf V4ServerConf) {
-	return V4ServerConf{
+func defaultV4ServerConf() (conf *V4ServerConf) {
+	return &V4ServerConf{
 		Enabled:    true,
 		RangeStart: DefaultRangeStart,
 		RangeEnd:   DefaultRangeEnd,
 		GatewayIP:  DefaultGatewayIP,
 		SubnetMask: DefaultSubnetMask,
-		notify:     notify4,
+		notify:     testNotify,
 		dnsIPAddrs: []net.IP{DefaultSelfIP},
 	}
 }
@@ -350,13 +346,10 @@ func TestV4Server_handle_optionsPriority(t *testing.T) {
 			defer func() { s.implicitOpts.Update(dhcpv4.OptDNS(defaultIP)) }()
 		}
 
-		ss, err := v4Create(conf)
+		var err error
+		s, err = v4Create(conf)
 		require.NoError(t, err)
 
-		var ok bool
-		s, ok = ss.(*v4Server)
-		require.True(t, ok)
-
 		s.conf.dnsIPAddrs = []net.IP{defaultIP}
 
 		return s
@@ -490,10 +483,9 @@ func TestV4Server_updateOptions(t *testing.T) {
 		require.NoError(t, err)
 
 		require.IsType(t, (*v4Server)(nil), s)
-		s4, _ := s.(*v4Server)
 
 		t.Run(tc.name, func(t *testing.T) {
-			s4.updateOptions(req, resp)
+			s.updateOptions(req, resp)
 
 			for c, v := range tc.wantOpts {
 				if v == nil {
@@ -596,13 +588,9 @@ func TestV4DynamicLease_Get(t *testing.T) {
 		"82 ip 1.2.3.4",
 	}
 
-	var err error
-	sIface, err := v4Create(conf)
+	s, err := v4Create(conf)
 	require.NoError(t, err)
 
-	s, ok := sIface.(*v4Server)
-	require.True(t, ok)
-
 	s.conf.dnsIPAddrs = []net.IP{{192, 168, 10, 1}}
 	s.implicitOpts.Update(dhcpv4.OptDNS(s.conf.dnsIPAddrs...))
 
diff --git a/internal/dhcpd/v6.go b/internal/dhcpd/v6_unix.go
similarity index 98%
rename from internal/dhcpd/v6.go
rename to internal/dhcpd/v6_unix.go
index b483384d..96512ddb 100644
--- a/internal/dhcpd/v6.go
+++ b/internal/dhcpd/v6_unix.go
@@ -1,5 +1,4 @@
-//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
-// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris
+//go:build darwin || freebsd || linux || openbsd
 
 package dhcpd
 
@@ -173,7 +172,7 @@ func (s *v6Server) rmDynamicLease(lease *Lease) (err error) {
 func (s *v6Server) AddStaticLease(l *Lease) (err error) {
 	defer func() { err = errors.Annotate(err, "dhcpv6: %w") }()
 
-	if len(l.IP) != 16 {
+	if len(l.IP) != net.IPv6len {
 		return fmt.Errorf("invalid IP")
 	}
 
diff --git a/internal/dhcpd/v6_test.go b/internal/dhcpd/v6_unix_test.go
similarity index 97%
rename from internal/dhcpd/v6_test.go
rename to internal/dhcpd/v6_unix_test.go
index 794b98e4..201f62a6 100644
--- a/internal/dhcpd/v6_test.go
+++ b/internal/dhcpd/v6_unix_test.go
@@ -1,5 +1,4 @@
-//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
-// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris
+//go:build darwin || freebsd || linux || openbsd
 
 package dhcpd
 
diff --git a/internal/dnsforward/dns_test.go b/internal/dnsforward/dns_test.go
index 218edb1a..ebdc716c 100644
--- a/internal/dnsforward/dns_test.go
+++ b/internal/dnsforward/dns_test.go
@@ -267,7 +267,7 @@ func TestServer_ProcessDHCPHosts_localRestriction(t *testing.T) {
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
 			s := &Server{
-				dhcpServer:        &testDHCP{},
+				dhcpServer:        testDHCP,
 				localDomainSuffix: defaultLocalDomainSuffix,
 				tableHostToIP: hostToIPTable{
 					"example." + defaultLocalDomainSuffix: knownIP,
@@ -378,7 +378,7 @@ func TestServer_ProcessDHCPHosts(t *testing.T) {
 
 	for _, tc := range testCases {
 		s := &Server{
-			dhcpServer:        &testDHCP{},
+			dhcpServer:        testDHCP,
 			localDomainSuffix: tc.suffix,
 			tableHostToIP: hostToIPTable{
 				"example." + tc.suffix: knownIP,
diff --git a/internal/dnsforward/dnsforward.go b/internal/dnsforward/dnsforward.go
index 4af874b4..3806ef5b 100644
--- a/internal/dnsforward/dnsforward.go
+++ b/internal/dnsforward/dnsforward.go
@@ -58,10 +58,10 @@ type hostToIPTable = map[string]net.IP
 //
 // The zero Server is empty and ready for use.
 type Server struct {
-	dnsProxy   *proxy.Proxy          // DNS proxy instance
-	dnsFilter  *filtering.DNSFilter  // DNS filter instance
-	dhcpServer dhcpd.ServerInterface // DHCP server instance (optional)
-	queryLog   querylog.QueryLog     // Query log instance
+	dnsProxy   *proxy.Proxy         // DNS proxy instance
+	dnsFilter  *filtering.DNSFilter // DNS filter instance
+	dhcpServer dhcpd.Interface      // DHCP server instance (optional)
+	queryLog   querylog.QueryLog    // Query log instance
 	stats      stats.Interface
 	access     *accessCtx
 
@@ -110,7 +110,7 @@ type DNSCreateParams struct {
 	DNSFilter   *filtering.DNSFilter
 	Stats       stats.Interface
 	QueryLog    querylog.QueryLog
-	DHCPServer  dhcpd.ServerInterface
+	DHCPServer  dhcpd.Interface
 	PrivateNets netutil.SubnetSet
 	Anonymizer  *aghnet.IPMut
 	LocalDomain string
diff --git a/internal/dnsforward/dnsforward_test.go b/internal/dnsforward/dnsforward_test.go
index a76d5a41..b11d1f65 100644
--- a/internal/dnsforward/dnsforward_test.go
+++ b/internal/dnsforward/dnsforward_test.go
@@ -72,7 +72,7 @@ func createTestServer(
 
 	var err error
 	s, err = NewServer(DNSCreateParams{
-		DHCPServer:  &testDHCP{},
+		DHCPServer:  testDHCP,
 		DNSFilter:   f,
 		PrivateNets: netutil.SubnetSetFunc(netutil.IsLocallyServed),
 	})
@@ -776,7 +776,7 @@ func TestBlockedCustomIP(t *testing.T) {
 
 	f := filtering.New(&filtering.Config{}, filters)
 	s, err := NewServer(DNSCreateParams{
-		DHCPServer:  &testDHCP{},
+		DHCPServer:  testDHCP,
 		DNSFilter:   f,
 		PrivateNets: netutil.SubnetSetFunc(netutil.IsLocallyServed),
 	})
@@ -910,7 +910,7 @@ func TestRewrite(t *testing.T) {
 	f.SetEnabled(true)
 
 	s, err := NewServer(DNSCreateParams{
-		DHCPServer:  &testDHCP{},
+		DHCPServer:  testDHCP,
 		DNSFilter:   f,
 		PrivateNets: netutil.SubnetSetFunc(netutil.IsLocallyServed),
 	})
@@ -1005,26 +1005,36 @@ func publicKey(priv any) any {
 	}
 }
 
-type testDHCP struct{}
-
-func (d *testDHCP) Enabled() (ok bool) { return true }
-
-func (d *testDHCP) Leases(flags dhcpd.GetLeasesFlags) (leases []*dhcpd.Lease) {
-	return []*dhcpd.Lease{{
-		IP:       net.IP{192, 168, 12, 34},
-		HWAddr:   net.HardwareAddr{0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA},
-		Hostname: "myhost",
-	}}
+var testDHCP = &dhcpd.MockInterface{
+	OnStart:   func() (err error) { panic("not implemented") },
+	OnStop:    func() (err error) { panic("not implemented") },
+	OnEnabled: func() (ok bool) { return true },
+	OnLeases: func(flags dhcpd.GetLeasesFlags) (leases []*dhcpd.Lease) {
+		return []*dhcpd.Lease{{
+			IP:       net.IP{192, 168, 12, 34},
+			HWAddr:   net.HardwareAddr{0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA},
+			Hostname: "myhost",
+		}}
+	},
+	OnSetOnLeaseChanged: func(olct dhcpd.OnLeaseChangedT) {},
+	OnFindMACbyIP:       func(ip net.IP) (mac net.HardwareAddr) { panic("not implemented") },
+	OnWriteDiskConfig:   func(c *dhcpd.ServerConfig) { panic("not implemented") },
 }
 
-func (d *testDHCP) SetOnLeaseChanged(onLeaseChanged dhcpd.OnLeaseChangedT) {}
+// func (*testDHCP) Leases(flags dhcpd.GetLeasesFlags) (leases []*dhcpd.Lease) {
+// 	return []*dhcpd.Lease{{
+// 		IP:       net.IP{192, 168, 12, 34},
+// 		HWAddr:   net.HardwareAddr{0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA},
+// 		Hostname: "myhost",
+// 	}}
+// }
 
 func TestPTRResponseFromDHCPLeases(t *testing.T) {
 	const localDomain = "lan"
 
 	s, err := NewServer(DNSCreateParams{
 		DNSFilter:   filtering.New(&filtering.Config{}, nil),
-		DHCPServer:  &testDHCP{},
+		DHCPServer:  testDHCP,
 		PrivateNets: netutil.SubnetSetFunc(netutil.IsLocallyServed),
 		LocalDomain: localDomain,
 	})
@@ -1097,7 +1107,7 @@ func TestPTRResponseFromHosts(t *testing.T) {
 
 	var s *Server
 	s, err = NewServer(DNSCreateParams{
-		DHCPServer:  &testDHCP{},
+		DHCPServer:  testDHCP,
 		DNSFilter:   flt,
 		PrivateNets: netutil.SubnetSetFunc(netutil.IsLocallyServed),
 	})
diff --git a/internal/dnsforward/filter_test.go b/internal/dnsforward/filter_test.go
index e25d4037..00c04252 100644
--- a/internal/dnsforward/filter_test.go
+++ b/internal/dnsforward/filter_test.go
@@ -39,7 +39,7 @@ func TestHandleDNSRequest_filterDNSResponse(t *testing.T) {
 	f.SetEnabled(true)
 
 	s, err := NewServer(DNSCreateParams{
-		DHCPServer:  &testDHCP{},
+		DHCPServer:  testDHCP,
 		DNSFilter:   f,
 		PrivateNets: netutil.SubnetSetFunc(netutil.IsLocallyServed),
 	})
diff --git a/internal/home/clients.go b/internal/home/clients.go
index e50b7904..7396e8c6 100644
--- a/internal/home/clients.go
+++ b/internal/home/clients.go
@@ -126,7 +126,7 @@ type clientsContainer struct {
 	allTags *stringutil.Set
 
 	// dhcpServer is used for looking up clients IP addresses by MAC addresses
-	dhcpServer *dhcpd.Server
+	dhcpServer dhcpd.Interface
 
 	// dnsServer is used for checking clients IP status access list status
 	dnsServer *dnsforward.Server
@@ -146,7 +146,7 @@ type clientsContainer struct {
 // Note: this function must be called only once
 func (clients *clientsContainer) Init(
 	objects []*clientObject,
-	dhcpServer *dhcpd.Server,
+	dhcpServer dhcpd.Interface,
 	etcHosts *aghnet.HostsContainer,
 	arpdb aghnet.ARPDB,
 ) {
diff --git a/internal/home/clients_test.go b/internal/home/clients_test.go
index 8afd5621..5b4ccdd3 100644
--- a/internal/home/clients_test.go
+++ b/internal/home/clients_test.go
@@ -279,8 +279,6 @@ func TestClientsAddExisting(t *testing.T) {
 			t.Skip("skipping dhcp test on windows")
 		}
 
-		var err error
-
 		ip := net.IP{1, 2, 3, 4}
 
 		// First, init a DHCP server with a single static lease.
@@ -296,13 +294,15 @@ func TestClientsAddExisting(t *testing.T) {
 			},
 		}
 
-		clients.dhcpServer, err = dhcpd.Create(config)
+		dhcpServer, err := dhcpd.Create(config)
 		require.NoError(t, err)
 		testutil.CleanupAndRequireSuccess(t, func() (err error) {
 			return os.Remove("leases.db")
 		})
 
-		err = clients.dhcpServer.AddStaticLease(&dhcpd.Lease{
+		clients.dhcpServer = dhcpServer
+
+		err = dhcpServer.AddStaticLease(&dhcpd.Lease{
 			HWAddr:   net.HardwareAddr{0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA},
 			IP:       ip,
 			Hostname: "testhost",
diff --git a/internal/home/config.go b/internal/home/config.go
index 7ac6470e..47027692 100644
--- a/internal/home/config.go
+++ b/internal/home/config.go
@@ -433,9 +433,7 @@ func (c *configuration) write() (err error) {
 	}
 
 	if Context.dhcpServer != nil {
-		c := &dhcpd.ServerConfig{}
-		Context.dhcpServer.WriteDiskConfig(c)
-		config.DHCP = c
+		Context.dhcpServer.WriteDiskConfig(config.DHCP)
 	}
 
 	config.Clients.Persistent = Context.clients.forConfig()
diff --git a/internal/home/home.go b/internal/home/home.go
index a84753fd..4b200bc1 100644
--- a/internal/home/home.go
+++ b/internal/home/home.go
@@ -53,7 +53,7 @@ type homeContext struct {
 	rdns       *RDNS                // rDNS module
 	whois      *WHOIS               // WHOIS module
 	dnsFilter  *filtering.DNSFilter // DNS filtering module
-	dhcpServer *dhcpd.Server        // DHCP module
+	dhcpServer dhcpd.Interface      // DHCP module
 	auth       *Auth                // HTTP authentication module
 	filters    Filtering            // DNS filtering module
 	web        *Web                 // Web (HTTP, HTTPS) module
@@ -641,14 +641,9 @@ func configureLogger(args options) {
 			log.Fatalf("cannot initialize syslog: %s", err)
 		}
 	} else {
-		logFilePath := filepath.Join(Context.workDir, ls.File)
-		if filepath.IsAbs(ls.File) {
-			logFilePath = ls.File
-		}
-
-		_, err := os.OpenFile(logFilePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
-		if err != nil {
-			log.Fatalf("cannot create a log file: %s", err)
+		logFilePath := ls.File
+		if !filepath.IsAbs(logFilePath) {
+			logFilePath = filepath.Join(Context.workDir, logFilePath)
 		}
 
 		log.SetOutput(&lumberjack.Logger{

From b9e39c8ccae0c3c285e20a9f4d602934297ecc92 Mon Sep 17 00:00:00 2001
From: Ainar Garipov <a.garipov@adguard.com>
Date: Wed, 14 Sep 2022 16:26:03 +0300
Subject: [PATCH 11/31] Pull request: upd-i18n

Merge in DNS/adguard-home from upd-i18n to master

Squashed commit of the following:

commit 306e7ee489105b76e1a159af9bdb2d5fb75bfa30
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Wed Sep 14 16:16:16 2022 +0300

    client: upd i18n
---
 client/src/__locales/be.json | 4 ++--
 client/src/__locales/uk.json | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/client/src/__locales/be.json b/client/src/__locales/be.json
index cdefa29a..1455825b 100644
--- a/client/src/__locales/be.json
+++ b/client/src/__locales/be.json
@@ -70,7 +70,7 @@
     "dhcp_warning": "Калі вы ўсё адно хочаце ўключыць DHCP-сервер, пераканайцеся, што ў сеціве больш няма актыўных DHCP-сервераў. Інакш гэта можа зламаць доступ у сеціва для падлучаных прылад!",
     "dhcp_error": "AdGuard Home не можа вызначыць, ці ёсць у сетцы іншы актыўны DHCP-сервер",
     "dhcp_static_ip_error": "Для таго, каб выкарыстоўваць DHCP-сервер, павінен быць усталяваны статычны IP-адрас. Мы не змаглі вызначыць, ці выкарыстоўвае гэты інтэрфейс сеціва статычны IP-адрас. Калі ласка, усталюйце яго ручна.",
-    "dhcp_dynamic_ip_found": "Ваша сістэма выкарыстоўвае дынамічны IP-адрас для інтэрфейсу <0>{{interfaceName}}</0>. Каб выкарыстоўваць DHCP-сервер трэба ўсталяваць статычны IP-адрас. Ваш бягучы IP-адрас – <0>{{ipAddress}}</0>. Мы аўтаматычна ўсталюем яго як статычны, калі вы націснеце кнопку Ўключыць DHCP.",
+    "dhcp_dynamic_ip_found": "Ваша сістэма выкарыстоўвае дынамічны IP-адрас для інтэрфейсу <0>{{interfaceName}}</0>. Каб выкарыстоўваць DHCP-сервер трэба ўсталяваць статычны IP-адрас. Ваш бягучы IP-адрас – <0>{{ipAddress}}</0>. Мы аўтаматычна ўсталюем яго як статычны, калі вы націснеце кнопку «Ўключыць DHCP».",
     "dhcp_lease_added": "Статычная арэнда «{{key}}» паспяхова дададзена",
     "dhcp_lease_deleted": "Статычная арэнда «{{key}}» паспяхова выдалена",
     "dhcp_new_static_lease": "Новая статычная арэнда",
@@ -447,7 +447,7 @@
     "access_disallowed_title": "Забароненыя кліенты",
     "access_disallowed_desc": "Спіс CIDR, IP-адрасоў або <a>ClientID</a>. Калі ў гэтым спісе ёсць запісы, AdGuard Home выдаліць запыты ад гэтых кліентаў. Гэта поле ігнаруецца, калі ёсць запісы ў Дазволеныя кліенты.",
     "access_blocked_title": "Заблакаваныя дамены",
-    "access_blocked_desc": "Не блытайце гэта з фільтрамі. AdGuard Home будзе ігнараваць DNS-запыты з гэтымі даменамі.",
+    "access_blocked_desc": "Не блытаць з фільтрамі. AdGuard Home выдаляе запыты DNS, якія адпавядаюць гэтым даменам, і гэтыя запыты нават не з'яўляюцца ў журнале запытаў. Вы можаце ўказаць дакладныя даменныя імёны, падстаноўныя знакі або правілы фільтрацыі URL-адрасоў, напрыклад, «example.org», «*.example.org» ці «||example.org^» адпаведна.",
     "access_settings_saved": "Налады доступу паспяхова захаваны",
     "updates_checked": "Даступная новая версія AdGuard Home",
     "updates_version_equal": "Версія AdGuard Home актуальная",
diff --git a/client/src/__locales/uk.json b/client/src/__locales/uk.json
index e111863d..c613b016 100644
--- a/client/src/__locales/uk.json
+++ b/client/src/__locales/uk.json
@@ -447,7 +447,7 @@
     "access_disallowed_title": "Заборонені клієнти",
     "access_disallowed_desc": "Перелік CIDR, IP-адрес та <a>ClientIDs</a>. Якщо налаштовано, AdGuard Home буде скасовувати запити від цих клієнтів. Проте якщо налаштовано список Дозволених клієнтів, то це поле проігнорується.",
     "access_blocked_title": "Заборонені домени",
-    "access_blocked_desc": "Не плутайте з фільтрами. AdGuard Home буде ігнорувати DNS-запити з цими доменами, такі запити навіть не будуть записані до журналу. Ви можете вказати точні доменні імена, замінні знаки та правила фільтрування URL-адрес, наприклад, 'example.org', '*.example.org' або '||example.org^' відповідно.",
+    "access_blocked_desc": "Не плутайте з фільтрами. AdGuard Home буде ігнорувати DNS-запити з цими доменами, такі запити навіть не будуть записані до журналу. Ви можете вказати точні доменні імена, замінні знаки та правила фільтрування URL-адрес, наприклад, «example.org», «*.example.org» або «||example.org^» відповідно.",
     "access_settings_saved": "Налаштування доступу успішно збережено",
     "updates_checked": "Доступна нова версія AdGuard Home",
     "updates_version_equal": "AdGuard Home останньої версії",

From fc62796e2d4c01e3a053e2e03d4ebd1040ad554c Mon Sep 17 00:00:00 2001
From: Ainar Garipov <a.garipov@adguard.com>
Date: Wed, 14 Sep 2022 18:01:42 +0300
Subject: [PATCH 12/31] Pull request: upd-chlog

Merge in DNS/adguard-home from upd-chlog to master

Squashed commit of the following:

commit 26638a064952ab99f8935eb6f09766f9ea7d8137
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Wed Sep 14 17:57:11 2022 +0300

    all: upd chlog
---
 CHANGELOG.md | 42 ++++++++++++++++++++++++++----------------
 1 file changed, 26 insertions(+), 16 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b348055..0085dade 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,11 +20,30 @@ and this project adheres to
 - Weaker cipher suites that use the CBC (cipher block chaining) mode of
   operation have been disabled ([#2993]).
 
+[#2993]: https://github.com/AdguardTeam/AdGuardHome/issues/2993
+
+
+
+
+<!--
+## [v0.107.14] - 2022-10-05 (APPROX.)
+
+See also the [v0.107.14 GitHub milestone][ms-v0.107.14].
+
+[ms-v0.107.14]:   https://github.com/AdguardTeam/AdGuardHome/milestone/50?closed=1
+-->
+
+
+
+## [v0.107.13] - 2022-09-14
+
+See also the [v0.107.13 GitHub milestone][ms-v0.107.13].
+
 ### Added
 
-- The new optional `dns.ipset_file` property in the configuration file.  It
-  allows loading the `ipset` list from a file, just like `dns.upstream_dns_file`
-  does for upstream servers ([#4686]).
+- The new optional `dns.ipset_file` property, which can be set in the
+  configuration file.  It allows loading the `ipset` list from a file, just like
+  `dns.upstream_dns_file` does for upstream servers ([#4686]).
 
 ### Changed
 
@@ -35,21 +54,11 @@ and this project adheres to
 
 - Panic when adding a static lease within the disabled DHCP server ([#4722]).
 
-[#2993]: https://github.com/AdguardTeam/AdGuardHome/issues/2993
 [#4686]: https://github.com/AdguardTeam/AdGuardHome/issues/4686
 [#4722]: https://github.com/AdguardTeam/AdGuardHome/issues/4722
 [#4904]: https://github.com/AdguardTeam/AdGuardHome/issues/4904
 
-
-
-
-<!--
-## [v0.107.13] - 2022-10-05 (APPROX.)
-
-See also the [v0.107.13 GitHub milestone][ms-v0.107.13].
-
 [ms-v0.107.13]:   https://github.com/AdguardTeam/AdGuardHome/milestone/49?closed=1
--->
 
 
 
@@ -1221,11 +1230,12 @@ See also the [v0.104.2 GitHub milestone][ms-v0.104.2].
 
 
 <!--
-[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.13...HEAD
-[v0.107.13]:  https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.11...v0.107.13
+[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.14...HEAD
+[v0.107.14]:  https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.13...v0.107.14
 -->
 
-[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.12...HEAD
+[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.13...HEAD
+[v0.107.13]:  https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.12...v0.107.13
 [v0.107.12]:  https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.11...v0.107.12
 [v0.107.11]:  https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.10...v0.107.11
 [v0.107.10]:  https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.9...v0.107.10

From 663f0643f2b41b5f1b1d198e616632e7ce86c556 Mon Sep 17 00:00:00 2001
From: Ildar Kamalov <ik@adguard.com>
Date: Fri, 16 Sep 2022 12:43:27 +0300
Subject: [PATCH 13/31] Pull request: 4899 show filter update time

Updates #4899

Squashed commit of the following:

commit 32ea8f3854619be4a3bb125d5c3ef02ba8ef5439
Author: Ildar Kamalov <ik@adguard.com>
Date:   Thu Sep 15 19:22:22 2022 +0300

    client: show hidden filter update time
---
 client/src/components/Filters/Table.js | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/client/src/components/Filters/Table.js b/client/src/components/Filters/Table.js
index 53c22721..045d4be4 100644
--- a/client/src/components/Filters/Table.js
+++ b/client/src/components/Filters/Table.js
@@ -41,13 +41,13 @@ class Table extends Component {
         {
             Header: <Trans>name_table_header</Trans>,
             accessor: 'name',
-            minWidth: 200,
+            minWidth: 180,
             Cell: CellWrap,
         },
         {
             Header: <Trans>list_url_table_header</Trans>,
             accessor: 'url',
-            minWidth: 200,
+            minWidth: 180,
             Cell: ({ value }) => (
                 <div className="logs__row">
                     {isValidAbsolutePath(value) ? value
@@ -73,7 +73,7 @@ class Table extends Component {
             Header: <Trans>last_time_updated_table_header</Trans>,
             accessor: 'lastUpdated',
             className: 'text-center',
-            minWidth: 150,
+            minWidth: 180,
             Cell: this.getDateCell,
         },
         {

From 572fed9f35d2197fa9f9a2ddad5f7aa330032bc4 Mon Sep 17 00:00:00 2001
From: Ildar Kamalov <ik@adguard.com>
Date: Fri, 16 Sep 2022 12:59:30 +0300
Subject: [PATCH 14/31] Pull request: 4679 fix wrong time format after midnight

Updates #4679

Squashed commit of the following:

commit 1864f33120fc35e6d88fb5d448e1391f97c267ef
Merge: b48add05 663f0643
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Sep 16 12:43:58 2022 +0300

    Merge branch 'master' into 4679-time-format

commit b48add059ff330f1a709b6fd723cbf92ce89c60e
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Sep 16 12:39:52 2022 +0300

    client: fix wrong time format after midnight
---
 client/src/helpers/constants.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/client/src/helpers/constants.js b/client/src/helpers/constants.js
index 935e3655..943b28ac 100644
--- a/client/src/helpers/constants.js
+++ b/client/src/helpers/constants.js
@@ -526,8 +526,8 @@ export const DEFAULT_DATE_FORMAT_OPTIONS = {
     month: 'numeric',
     day: 'numeric',
     hour: 'numeric',
+    hourCycle: 'h23',
     minute: 'numeric',
-    hour12: false,
 };
 
 export const DETAILED_DATE_FORMAT_OPTIONS = {

From 3a88ef3be216d2837623fa7c04f1fa1a0291383c Mon Sep 17 00:00:00 2001
From: Ildar Kamalov <ik@adguard.com>
Date: Fri, 16 Sep 2022 16:19:09 +0300
Subject: [PATCH 15/31] Pull request: 4854 return to detailed view on screen
 resize

Updates #4854

Squashed commit of the following:

commit b580b5e26d83db3291dee5714d9580a1e07253b5
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Sep 16 15:33:04 2022 +0300

    client: return to detailed view on screen resize
---
 client/src/components/Logs/index.js | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/client/src/components/Logs/index.js b/client/src/components/Logs/index.js
index abbc3af7..2531c55c 100644
--- a/client/src/components/Logs/index.js
+++ b/client/src/components/Logs/index.js
@@ -104,6 +104,8 @@ const Logs = () => {
         setIsSmallScreen(e.matches);
         if (e.matches) {
             dispatch(toggleDetailedLogs(false));
+        } else {
+            dispatch(toggleDetailedLogs(true));
         }
     };
 

From 42bd0615c2988ce3fd59ecc1f712115cd429c889 Mon Sep 17 00:00:00 2001
From: Ildar Kamalov <ik@adguard.com>
Date: Fri, 16 Sep 2022 19:40:42 +0300
Subject: [PATCH 16/31] Pull request: 4896 remove button outline on modal close

Updates #4896

Squashed commit of the following:

commit 87de3176a8e50146b0a1028695d8181265b6b9b9
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Sep 16 16:32:09 2022 +0300

    client: remove outline on modal close
---
 client/src/components/ui/Tabler.css | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/client/src/components/ui/Tabler.css b/client/src/components/ui/Tabler.css
index c651f728..34d37622 100644
--- a/client/src/components/ui/Tabler.css
+++ b/client/src/components/ui/Tabler.css
@@ -2820,6 +2820,11 @@ fieldset:disabled a.btn {
 }
 
 .btn-outline-primary:focus,
+.btn-outline-primary.focus {
+    box-shadow: none;
+}
+
+.btn-outline-primary:focus-visible,
 .btn-outline-primary.focus {
     box-shadow: 0 0 0 2px rgba(70, 127, 207, 0.5);
 }
@@ -2858,6 +2863,11 @@ fieldset:disabled a.btn {
 }
 
 .btn-outline-secondary:focus,
+.btn-outline-secondary.focus {
+    box-shadow: none;
+}
+
+.btn-outline-secondary:focus-visible,
 .btn-outline-secondary.focus {
     box-shadow: 0 0 0 2px rgba(134, 142, 150, 0.5);
 }

From 95771c7aba105feaf753e3b644fd6abd73d88e89 Mon Sep 17 00:00:00 2001
From: Dmitry Rubtsov <me@mazy.wtf>
Date: Mon, 19 Sep 2022 17:06:32 +0600
Subject: [PATCH 17/31] add support for plain h2c

---
 internal/home/web.go | 12 ++++++++++--
 1 file changed, 10 insertions(+), 2 deletions(-)

diff --git a/internal/home/web.go b/internal/home/web.go
index 99b993bb..5bd1fe30 100644
--- a/internal/home/web.go
+++ b/internal/home/web.go
@@ -15,6 +15,8 @@ import (
 	"github.com/AdguardTeam/golibs/log"
 	"github.com/AdguardTeam/golibs/netutil"
 	"github.com/NYTimes/gziphandler"
+	"golang.org/x/net/http2"
+	"golang.org/x/net/http2/h2c"
 )
 
 // HTTP scheme constants.
@@ -167,12 +169,15 @@ func (web *Web) Start() {
 		printHTTPAddresses(schemeHTTP)
 		errs := make(chan error, 2)
 
+		// h2s adds support for plain h2c
+		h2s := &http2.Server{}
+
 		hostStr := web.conf.BindHost.String()
 		// we need to have new instance, because after Shutdown() the Server is not usable
 		web.httpServer = &http.Server{
 			ErrorLog:          log.StdLog("web: plain", log.DEBUG),
 			Addr:              netutil.JoinHostPort(hostStr, web.conf.BindPort),
-			Handler:           withMiddlewares(Context.mux, limitRequestBody),
+			Handler:           h2c.NewHandler(withMiddlewares(Context.mux, limitRequestBody), h2s),
 			ReadTimeout:       web.conf.ReadTimeout,
 			ReadHeaderTimeout: web.conf.ReadHeaderTimeout,
 			WriteTimeout:      web.conf.WriteTimeout,
@@ -202,10 +207,13 @@ func (web *Web) startBetaServer(hostStr string) {
 		return
 	}
 
+	// h2s adds support for plain h2c
+	h2s := &http2.Server{}
+
 	web.httpServerBeta = &http.Server{
 		ErrorLog:          log.StdLog("web: plain: beta", log.DEBUG),
 		Addr:              netutil.JoinHostPort(hostStr, web.conf.BetaBindPort),
-		Handler:           withMiddlewares(Context.mux, limitRequestBody, web.wrapIndexBeta),
+		Handler:           h2c.NewHandler(withMiddlewares(Context.mux, limitRequestBody, web.wrapIndexBeta), h2s),
 		ReadTimeout:       web.conf.ReadTimeout,
 		ReadHeaderTimeout: web.conf.ReadHeaderTimeout,
 		WriteTimeout:      web.conf.WriteTimeout,

From ed209daf8af27e83c1edd2889b7e0c0f26641e42 Mon Sep 17 00:00:00 2001
From: Ainar Garipov <A.Garipov@AdGuard.COM>
Date: Mon, 19 Sep 2022 17:06:29 +0300
Subject: [PATCH 18/31] all: doc changes

---
 CHANGELOG.md | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0085dade..8c9ccdcf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,7 +20,13 @@ and this project adheres to
 - Weaker cipher suites that use the CBC (cipher block chaining) mode of
   operation have been disabled ([#2993]).
 
+### Added
+
+- Support for plain (unencrypted) HTTP/2 ([#4930]).  This is useful for AdGuard
+  Home installations behind a reverse proxy.
+
 [#2993]: https://github.com/AdguardTeam/AdGuardHome/issues/2993
+[#4930]: https://github.com/AdguardTeam/AdGuardHome/issues/4930
 
 
 

From 27b0251b5b8c1097d53b20a67569bb9bb6ff8bff Mon Sep 17 00:00:00 2001
From: Ainar Garipov <A.Garipov@AdGuard.COM>
Date: Mon, 19 Sep 2022 17:17:12 +0300
Subject: [PATCH 19/31] home: imp docs

---
 internal/home/web.go | 17 ++++++++++-------
 1 file changed, 10 insertions(+), 7 deletions(-)

diff --git a/internal/home/web.go b/internal/home/web.go
index 5bd1fe30..2052df55 100644
--- a/internal/home/web.go
+++ b/internal/home/web.go
@@ -169,15 +169,15 @@ func (web *Web) Start() {
 		printHTTPAddresses(schemeHTTP)
 		errs := make(chan error, 2)
 
-		// h2s adds support for plain h2c
-		h2s := &http2.Server{}
+		// Use an h2c handler to support unencrypted HTTP/2, e.g. for proxies.
+		hdlr := h2c.NewHandler(withMiddlewares(Context.mux, limitRequestBody), &http2.Server{})
 
+		// Create a new instance, because the Web is not usable after Shutdown.
 		hostStr := web.conf.BindHost.String()
-		// we need to have new instance, because after Shutdown() the Server is not usable
 		web.httpServer = &http.Server{
 			ErrorLog:          log.StdLog("web: plain", log.DEBUG),
 			Addr:              netutil.JoinHostPort(hostStr, web.conf.BindPort),
-			Handler:           h2c.NewHandler(withMiddlewares(Context.mux, limitRequestBody), h2s),
+			Handler:           hdlr,
 			ReadTimeout:       web.conf.ReadTimeout,
 			ReadHeaderTimeout: web.conf.ReadHeaderTimeout,
 			WriteTimeout:      web.conf.WriteTimeout,
@@ -207,13 +207,16 @@ func (web *Web) startBetaServer(hostStr string) {
 		return
 	}
 
-	// h2s adds support for plain h2c
-	h2s := &http2.Server{}
+	// Use an h2c handler to support unencrypted HTTP/2, e.g. for proxies.
+	hdlr := h2c.NewHandler(
+		withMiddlewares(Context.mux, limitRequestBody, web.wrapIndexBeta),
+		&http2.Server{},
+	)
 
 	web.httpServerBeta = &http.Server{
 		ErrorLog:          log.StdLog("web: plain: beta", log.DEBUG),
 		Addr:              netutil.JoinHostPort(hostStr, web.conf.BetaBindPort),
-		Handler:           h2c.NewHandler(withMiddlewares(Context.mux, limitRequestBody, web.wrapIndexBeta), h2s),
+		Handler:           hdlr,
 		ReadTimeout:       web.conf.ReadTimeout,
 		ReadHeaderTimeout: web.conf.ReadHeaderTimeout,
 		WriteTimeout:      web.conf.WriteTimeout,

From 8e89cc129cb5b7327975af31dbfaa41f9d7d0dfe Mon Sep 17 00:00:00 2001
From: Ildar Kamalov <ik@adguard.com>
Date: Mon, 19 Sep 2022 19:34:14 +0300
Subject: [PATCH 20/31] Pull request: 4913 IP anonymizer notification

Updates #4913

Squashed commit of the following:

commit baa63c647bdecf10a2c5e91568231864c423c4c3
Merge: 70de6540 9ffe0787
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Sep 19 18:53:24 2022 +0300

    Merge branch 'master' into 4913-anonymizer-notification

commit 70de65405fa34ba764408ce1331f90ec0ef7aec2
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Sep 19 18:03:52 2022 +0300

    client: fix text

commit e6d8db0086903fe61b0aa511807e97dd12bd571c
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Sep 16 20:37:42 2022 +0300

    client: IP anonymizer notification
---
 client/src/__locales/en.json                  |  3 ++-
 .../components/Logs/AnonymizerNotification.js | 16 ++++++++++++++++
 client/src/components/Logs/index.js           | 19 ++++++++++++++-----
 3 files changed, 32 insertions(+), 6 deletions(-)
 create mode 100644 client/src/components/Logs/AnonymizerNotification.js

diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json
index ca423562..e059c9f4 100644
--- a/client/src/__locales/en.json
+++ b/client/src/__locales/en.json
@@ -635,5 +635,6 @@
     "parental_control": "Parental Control",
     "safe_browsing": "Safe Browsing",
     "served_from_cache": "{{value}} <i>(served from cache)</i>",
-    "form_error_password_length": "Password must be at least {{value}} characters long"
+    "form_error_password_length": "Password must be at least {{value}} characters long",
+    "anonymizer_notification": "<0>Note:</0> IP anonymization is enabled. You can disable it in <1>General settings</1>."
 }
diff --git a/client/src/components/Logs/AnonymizerNotification.js b/client/src/components/Logs/AnonymizerNotification.js
new file mode 100644
index 00000000..aca86dc7
--- /dev/null
+++ b/client/src/components/Logs/AnonymizerNotification.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import { Trans } from 'react-i18next';
+import { HashLink as Link } from 'react-router-hash-link';
+
+const AnonymizerNotification = () => (
+    <div className="alert alert-primary mt-6">
+        <Trans components={[
+            <strong key="0">text</strong>,
+            <Link to="/settings#logs-config" key="1">link</Link>,
+        ]}>
+            anonymizer_notification
+        </Trans>
+    </div>
+);
+
+export default AnonymizerNotification;
diff --git a/client/src/components/Logs/index.js b/client/src/components/Logs/index.js
index 2531c55c..3658b5ba 100644
--- a/client/src/components/Logs/index.js
+++ b/client/src/components/Logs/index.js
@@ -25,6 +25,7 @@ import {
 import InfiniteTable from './InfiniteTable';
 import './Logs.css';
 import { BUTTON_PREFIX } from './Cells/helpers';
+import AnonymizerNotification from './AnonymizerNotification';
 
 const processContent = (data) => Object.entries(data)
     .map(([key, value]) => {
@@ -73,6 +74,7 @@ const Logs = () => {
         processingGetConfig,
         processingAdditionalLogs,
         processingGetLogs,
+        anonymize_client_ip: anonymizeClientIp,
     } = useSelector((state) => state.queryLogs, shallowEqual);
     const filter = useSelector((state) => state.queryLogs.filter, shallowEqual);
     const logs = useSelector((state) => state.queryLogs.logs, shallowEqual);
@@ -206,11 +208,18 @@ const Logs = () => {
         </Modal>
     </>;
 
-    return <>
-        {enabled && processingGetConfig && <Loading />}
-        {enabled && !processingGetConfig && renderPage()}
-        {!enabled && !processingGetConfig && <Disabled />}
-    </>;
+    return (
+        <>
+            {enabled && (
+                <>
+                    {processingGetConfig && <Loading />}
+                    {anonymizeClientIp && <AnonymizerNotification />}
+                    {!processingGetConfig && renderPage()}
+                </>
+            )}
+            {!enabled && !processingGetConfig && <Disabled />}
+        </>
+    );
 };
 
 export default Logs;

From ab6da05b513193345d1ca93407a48b7aad001654 Mon Sep 17 00:00:00 2001
From: Ildar Kamalov <ik@adguard.com>
Date: Mon, 19 Sep 2022 20:22:26 +0300
Subject: [PATCH 21/31] Pull request: 4926 fix tabs scroll on mobile

Updates #4926

Squashed commit of the following:

commit 23a352d214b434de8321560ea5c46214672fe816
Merge: 44c64893 8e89cc12
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Sep 19 19:35:48 2022 +0300

    Merge branch 'master' into 4926-scroll

commit 44c64893bb602793285da13b80ef4a5befd1cf9d
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Sep 19 19:34:54 2022 +0300

    fix

commit 3f8f8c72534718a24fcd6349160887c0cbece4b6
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Sep 19 19:30:59 2022 +0300

    client: fix tabs scroll on mobile
---
 client/src/components/ui/Tabs.css | 1 +
 1 file changed, 1 insertion(+)

diff --git a/client/src/components/ui/Tabs.css b/client/src/components/ui/Tabs.css
index 837cf3d8..afa8438a 100644
--- a/client/src/components/ui/Tabs.css
+++ b/client/src/components/ui/Tabs.css
@@ -67,6 +67,7 @@
     height: 24px;
     margin-bottom: 6px;
     fill: #4a4a4a;
+    touch-action: initial;
 }
 
 .tab__text {

From cc2388e0c888dff725fdc032c35143e346155c8a Mon Sep 17 00:00:00 2001
From: Ildar Kamalov <ik@adguard.com>
Date: Tue, 20 Sep 2022 13:48:57 +0300
Subject: [PATCH 22/31] Pull request: 4815 fix query log modal on tablet

Updates #4815

Squashed commit of the following:

commit 148c39ac40963a593885b86a0c851b4010b68ab0
Merge: 3447611d ab6da05b
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Sep 20 13:21:06 2022 +0300

    Merge branch 'master' into 4815-tablet-view

commit 3447611dc0b1c7d2cc1f8235d1c469dd92736166
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Sep 16 17:01:05 2022 +0300

    client: fix query log modal on tablet
---
 .../src/components/Logs/Cells/ClientCell.js   |  2 +-
 .../src/components/Logs/Cells/DomainCell.js   |  2 +-
 .../src/components/Logs/Cells/IconTooltip.css | 10 ++--
 .../src/components/Logs/Cells/ResponseCell.js |  2 +-
 client/src/components/Logs/Logs.css           | 10 ++++
 client/src/components/Logs/index.js           | 47 +++++++++++--------
 6 files changed, 46 insertions(+), 27 deletions(-)

diff --git a/client/src/components/Logs/Cells/ClientCell.js b/client/src/components/Logs/Cells/ClientCell.js
index 79d4cd4f..669f1c0a 100644
--- a/client/src/components/Logs/Cells/ClientCell.js
+++ b/client/src/components/Logs/Cells/ClientCell.js
@@ -62,7 +62,7 @@ const ClientCell = ({
         'white-space--nowrap': isDetailed,
     });
 
-    const hintClass = classNames('icons mr-4 icon--24 icon--lightgray', {
+    const hintClass = classNames('icons mr-4 icon--24 logs__question icon--lightgray', {
         'my-3': isDetailed,
     });
 
diff --git a/client/src/components/Logs/Cells/DomainCell.js b/client/src/components/Logs/Cells/DomainCell.js
index ab511890..6a186dda 100644
--- a/client/src/components/Logs/Cells/DomainCell.js
+++ b/client/src/components/Logs/Cells/DomainCell.js
@@ -34,7 +34,7 @@ const DomainCell = ({
         'my-3': isDetailed,
     });
 
-    const privacyIconClass = classNames('icons mx-2 icon--24 d-none d-sm-block', {
+    const privacyIconClass = classNames('icons mx-2 icon--24 d-none d-sm-block logs__question', {
         'icon--green': hasTracker,
         'icon--disabled': !hasTracker,
         'my-3': isDetailed,
diff --git a/client/src/components/Logs/Cells/IconTooltip.css b/client/src/components/Logs/Cells/IconTooltip.css
index 8a84182a..da7e251d 100644
--- a/client/src/components/Logs/Cells/IconTooltip.css
+++ b/client/src/components/Logs/Cells/IconTooltip.css
@@ -49,6 +49,12 @@
     padding-top: 1rem;
 }
 
+@media (max-width: 1024px) {
+    .grid .key-colon, .grid .title--border {
+        font-weight: 600;
+    }
+}
+
 @media (max-width: 767.98px) {
     .grid {
         grid-template-columns: 35% 55%;
@@ -70,10 +76,6 @@
         grid-column: 2 / span 1;
         margin: 0 !important;
     }
-
-    .grid .key-colon, .grid .title--border {
-        font-weight: 600;
-    }
 }
 
 .grid .key-colon:nth-child(odd)::after {
diff --git a/client/src/components/Logs/Cells/ResponseCell.js b/client/src/components/Logs/Cells/ResponseCell.js
index 772b89e5..4ca37c29 100644
--- a/client/src/components/Logs/Cells/ResponseCell.js
+++ b/client/src/components/Logs/Cells/ResponseCell.js
@@ -97,7 +97,7 @@ const ResponseCell = ({
     return (
         <div className="logs__cell logs__cell--response" role="gridcell">
             <IconTooltip
-                className={classNames('icons mr-4 icon--24 icon--lightgray', { 'my-3': isDetailed })}
+                className={classNames('icons mr-4 icon--24 icon--lightgray logs__question', { 'my-3': isDetailed })}
                 columnClass='grid grid--limited'
                 tooltipClass='px-5 pb-5 pt-4 mw-75 custom-tooltip__response-details'
                 contentItemClass='text-truncate key-colon o-hidden'
diff --git a/client/src/components/Logs/Logs.css b/client/src/components/Logs/Logs.css
index d39835e0..788eef56 100644
--- a/client/src/components/Logs/Logs.css
+++ b/client/src/components/Logs/Logs.css
@@ -485,3 +485,13 @@
 .bg--green {
     color: var(--green79);
 }
+
+@media (max-width: 1024px) {
+    .logs__question {
+        display: none;
+    }
+}
+
+.logs__modal {
+    max-width: 720px;
+}
diff --git a/client/src/components/Logs/index.js b/client/src/components/Logs/index.js
index 3658b5ba..2bf89995 100644
--- a/client/src/components/Logs/index.js
+++ b/client/src/components/Logs/index.js
@@ -184,27 +184,34 @@ const Logs = () => {
                 setButtonType={setButtonType}
                 setModalOpened={setModalOpened}
         />
-        <Modal portalClassName='grid' isOpen={isSmallScreen && isModalOpened}
-               onRequestClose={closeModal}
-               style={{
-                   content: {
-                       width: '100%',
-                       height: 'fit-content',
-                       left: 0,
-                       top: 47,
-                       padding: '1rem 1.5rem 1rem',
-                   },
-                   overlay: {
-                       backgroundColor: 'rgba(0,0,0,0.5)',
-                   },
-               }}
+        <Modal
+            portalClassName='grid'
+            isOpen={isSmallScreen && isModalOpened}
+            onRequestClose={closeModal}
+            style={{
+                content: {
+                    width: '100%',
+                    height: 'fit-content',
+                    left: '50%',
+                    top: 47,
+                    padding: '1rem 1.5rem 1rem',
+                    maxWidth: '720px',
+                    transform: 'translateX(-50%)',
+                },
+                overlay: {
+                    backgroundColor: 'rgba(0,0,0,0.5)',
+                },
+            }}
         >
-            <svg
-                    className="icon icon--24 icon-cross d-block d-md-none cursor--pointer"
-                    onClick={closeModal}>
-                <use xlinkHref="#cross" />
-            </svg>
-            {processContent(detailedDataCurrent, buttonType)}
+            <div className="logs__modal-wrap">
+                <svg
+                    className="icon icon--24 icon-cross d-block cursor--pointer"
+                    onClick={closeModal}
+                >
+                    <use xlinkHref="#cross" />
+                </svg>
+                {processContent(detailedDataCurrent, buttonType)}
+            </div>
         </Modal>
     </>;
 

From 4fc045de115ffb3a5c52cd55c369842880386943 Mon Sep 17 00:00:00 2001
From: Ainar Garipov <a.garipov@adguard.com>
Date: Tue, 20 Sep 2022 15:26:10 +0300
Subject: [PATCH 23/31] Pull request: 4927-ddr-template

Updates #4927.

Squashed commit of the following:

commit 8cf080d5355261ced7e8b10de607cbf37e1d663d
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Tue Sep 20 15:18:48 2022 +0300

    dnsforward: fix doh template
---
 CHANGELOG.md                    | 5 +++++
 internal/dnsforward/dns.go      | 2 +-
 internal/dnsforward/dns_test.go | 2 +-
 3 files changed, 7 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c9ccdcf..dfeae3b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,7 +25,12 @@ and this project adheres to
 - Support for plain (unencrypted) HTTP/2 ([#4930]).  This is useful for AdGuard
   Home installations behind a reverse proxy.
 
+### Fixed
+
+- Incorrect path template in DDR responses ([#4927]).
+
 [#2993]: https://github.com/AdguardTeam/AdGuardHome/issues/2993
+[#4927]: https://github.com/AdguardTeam/AdGuardHome/issues/4927
 [#4930]: https://github.com/AdguardTeam/AdGuardHome/issues/4930
 
 
diff --git a/internal/dnsforward/dns.go b/internal/dnsforward/dns.go
index 3f5642e9..947625e3 100644
--- a/internal/dnsforward/dns.go
+++ b/internal/dnsforward/dns.go
@@ -296,7 +296,7 @@ func (s *Server) makeDDRResponse(req *dns.Msg) (resp *dns.Msg) {
 		values := []dns.SVCBKeyValue{
 			&dns.SVCBAlpn{Alpn: []string{"h2"}},
 			&dns.SVCBPort{Port: uint16(addr.Port)},
-			&dns.SVCBDoHPath{Template: "/dns-query?dns"},
+			&dns.SVCBDoHPath{Template: "/dns-query{?dns}"},
 		}
 
 		ans := &dns.SVCB{
diff --git a/internal/dnsforward/dns_test.go b/internal/dnsforward/dns_test.go
index ebdc716c..da7c8ae6 100644
--- a/internal/dnsforward/dns_test.go
+++ b/internal/dnsforward/dns_test.go
@@ -26,7 +26,7 @@ func TestServer_ProcessDDRQuery(t *testing.T) {
 		Value: []dns.SVCBKeyValue{
 			&dns.SVCBAlpn{Alpn: []string{"h2"}},
 			&dns.SVCBPort{Port: 8044},
-			&dns.SVCBDoHPath{Template: "/dns-query?dns"},
+			&dns.SVCBDoHPath{Template: "/dns-query{?dns}"},
 		},
 	}
 

From c45c02de2962b1d12a05472cc02fcab6d41bb0c0 Mon Sep 17 00:00:00 2001
From: Ainar Garipov <a.garipov@adguard.com>
Date: Wed, 21 Sep 2022 15:02:35 +0300
Subject: [PATCH 24/31] Pull request: imp-stalebot

Merge in DNS/adguard-home from imp-stalebot to master

Squashed commit of the following:

commit d1fb5c6da25eeb168c53abfc7af714827a5242cd
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Wed Sep 21 14:31:50 2022 +0300

    all: imp stalebot
---
 .github/stale.yml | 22 +++++++++++++---------
 1 file changed, 13 insertions(+), 9 deletions(-)

diff --git a/.github/stale.yml b/.github/stale.yml
index 6ed9a7df..6042fc60 100644
--- a/.github/stale.yml
+++ b/.github/stale.yml
@@ -4,15 +4,17 @@
 'daysUntilClose': 15
 # Issues with these labels will never be considered stale.
 'exemptLabels':
-- 'bug'
-- 'documentation'
-- 'enhancement'
-- 'feature request'
-- 'help wanted'
-- 'localization'
-- 'needs investigation'
-- 'recurrent'
-- 'research'
+  - 'bug'
+  - 'documentation'
+  - 'enhancement'
+  - 'feature request'
+  - 'help wanted'
+  - 'localization'
+  - 'needs investigation'
+  - 'recurrent'
+  - 'research'
+# Set to true to ignore issues in a milestone.
+'exemptMilestones': true
 # Label to use when marking an issue as stale.
 'staleLabel': 'wontfix'
 # Comment to post when marking an issue as stale. Set to `false` to disable.
@@ -22,3 +24,5 @@
   for your contributions.
 # Comment to post when closing a stale issue. Set to `false` to disable.
 'closeComment': false
+# Limit the number of actions per hour.
+'limitPerRun': 1

From 11e4f09165fa48522efc13896b0e830ea69e5bd2 Mon Sep 17 00:00:00 2001
From: Ainar Garipov <a.garipov@adguard.com>
Date: Wed, 21 Sep 2022 19:21:13 +0300
Subject: [PATCH 25/31] Pull request: imp-scripts

Merge in DNS/adguard-home from imp-scripts to master

Squashed commit of the following:

commit ab63a8a2dd1b64287e00a2a6f747fd48b530709e
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Wed Sep 21 19:15:06 2022 +0300

    all: imp scripts; upd tools; doc
---
 internal/aghnet/ipset_linux.go | 23 +++++++----------------
 internal/tools/go.mod          | 10 +++++-----
 internal/tools/go.sum          | 20 ++++++++++----------
 scripts/make/go-lint.sh        |  5 +----
 scripts/make/version.sh        |  6 +-----
 5 files changed, 24 insertions(+), 40 deletions(-)

diff --git a/internal/aghnet/ipset_linux.go b/internal/aghnet/ipset_linux.go
index d1376b52..1c970f53 100644
--- a/internal/aghnet/ipset_linux.go
+++ b/internal/aghnet/ipset_linux.go
@@ -18,27 +18,18 @@ import (
 
 // How to test on a real Linux machine:
 //
-// 1.  Run:
+//  1. Run "sudo ipset create example_set hash:ip family ipv4".
 //
-//   sudo ipset create example_set hash:ip family ipv4
+//  2. Run "sudo ipset list example_set".  The Members field should be empty.
 //
-// 2.  Run:
+//  3. Add the line "example.com/example_set" to your AdGuardHome.yaml.
 //
-//   sudo ipset list example_set
+//  4. Start AdGuardHome.
 //
-// The Members field should be empty.
+//  5. Make requests to example.com and its subdomains.
 //
-// 3.  Add the line "example.com/example_set" to your AdGuardHome.yaml.
-//
-// 4.  Start AdGuardHome.
-//
-// 5.  Make requests to example.com and its subdomains.
-//
-// 6.  Run:
-//
-//   sudo ipset list example_set
-//
-// The Members field should contain the resolved IP addresses.
+//  6. Run "sudo ipset list example_set".  The Members field should contain the
+//     resolved IP addresses.
 
 // newIpsetMgr returns a new Linux ipset manager.
 func newIpsetMgr(ipsetConf []string) (set IpsetManager, err error) {
diff --git a/internal/tools/go.mod b/internal/tools/go.mod
index c4f78914..4f813b69 100644
--- a/internal/tools/go.mod
+++ b/internal/tools/go.mod
@@ -9,8 +9,8 @@ require (
 	github.com/kisielk/errcheck v1.6.2
 	github.com/kyoh86/looppointer v0.1.7
 	github.com/securego/gosec/v2 v2.13.1
-	golang.org/x/tools v0.1.13-0.20220803210227-8b9a1fbdf5c3
-	golang.org/x/vuln v0.0.0-20220912202342-0ed43f12cb05
+	golang.org/x/tools v0.1.13-0.20220921142454-16b974289fe5
+	golang.org/x/vuln v0.0.0-20220921153644-d9be10b6cc84
 	honnef.co/go/tools v0.3.3
 	mvdan.cc/gofumpt v0.3.1
 	mvdan.cc/unparam v0.0.0-20220831102321-2fc90a84c7ec
@@ -25,10 +25,10 @@ require (
 	github.com/kyoh86/nolint v0.0.1 // indirect
 	github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354 // indirect
 	github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
-	golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 // indirect
+	golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect
 	golang.org/x/exp/typeparams v0.0.0-20220827204233-334a2380cb91 // indirect
-	golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
+	golang.org/x/mod v0.6.0-dev.0.20220907135952-02c991387e35 // indirect
 	golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde // indirect
-	golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2 // indirect
+	golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 )
diff --git a/internal/tools/go.sum b/internal/tools/go.sum
index 367d3d53..d93d2b10 100644
--- a/internal/tools/go.sum
+++ b/internal/tools/go.sum
@@ -55,15 +55,15 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
-golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e h1:Ctm9yurWsg7aWwIpH9Bnap/IdSVxixymIb3MhiMEQQA=
+golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
 golang.org/x/exp/typeparams v0.0.0-20220827204233-334a2380cb91 h1:Ic/qN6TEifvObMGQy72k0n1LlJr7DjWWEi+MOsDOiSk=
 golang.org/x/exp/typeparams v0.0.0-20220827204233-334a2380cb91/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.6.0-dev.0.20220907135952-02c991387e35 h1:CZP0Rbk/s1EIiUMx5DS2MhK2ct52xpQxqddVD0FmF+o=
+golang.org/x/mod v0.6.0-dev.0.20220907135952-02c991387e35/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
@@ -86,8 +86,8 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2 h1:wM1k/lXfpc5HdkJJyW9GELpd8ERGdnh8sMGL6Gzq3Ho=
-golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 h1:h+EGohizhe9XlX18rfpa8k8RAc5XyaeamM+0VHRd4lc=
+golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -100,10 +100,10 @@ golang.org/x/tools v0.0.0-20200710042808-f1c4188a97a1/go.mod h1:njjCfa9FT2d7l9Bc
 golang.org/x/tools v0.0.0-20201007032633-0806396f153e/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
-golang.org/x/tools v0.1.13-0.20220803210227-8b9a1fbdf5c3 h1:aE4T3aJwdCNz+s35ScSQYUzeGu7BOLDHZ1bBHVurqqY=
-golang.org/x/tools v0.1.13-0.20220803210227-8b9a1fbdf5c3/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/vuln v0.0.0-20220912202342-0ed43f12cb05 h1:NWQHMTdThZhCArzUbnu1Bh+l3LdwUfjZws+ivBR2sxM=
-golang.org/x/vuln v0.0.0-20220912202342-0ed43f12cb05/go.mod h1:7tDfEDtOLlzHQRi4Yzfg5seVBSvouUIjyPzBx4q5CxQ=
+golang.org/x/tools v0.1.13-0.20220921142454-16b974289fe5 h1:o1LhIiY5L+hLK9DWqfFlilCrpZnw/s7WU4iCUkb/bao=
+golang.org/x/tools v0.1.13-0.20220921142454-16b974289fe5/go.mod h1:VsjNM1dMo+Ofkp5d7y7fOdQZD8MTXSQ4w3EPk65AvKU=
+golang.org/x/vuln v0.0.0-20220921153644-d9be10b6cc84 h1:L0qUjdplndgX880fozFRGC242wAtfsViyRXWGlpZQ54=
+golang.org/x/vuln v0.0.0-20220921153644-d9be10b6cc84/go.mod h1:7tDfEDtOLlzHQRi4Yzfg5seVBSvouUIjyPzBx4q5CxQ=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/scripts/make/go-lint.sh b/scripts/make/go-lint.sh
index f6f09592..2cdcc90d 100644
--- a/scripts/make/go-lint.sh
+++ b/scripts/make/go-lint.sh
@@ -219,10 +219,7 @@ exit_on_output gofumpt --extra -e -l .
 
 "$GO" vet ./...
 
-# TODO(a.garipov): Reenable this once https://github.com/golang/go/issues/55035
-# is fixed.
-#
-#	govulncheck ./...
+govulncheck ./...
 
 # Apply more lax standards to the code we haven't properly refactored yet.
 gocyclo --over 17 ./internal/querylog/
diff --git a/scripts/make/version.sh b/scripts/make/version.sh
index 903be7bf..68e84e9c 100644
--- a/scripts/make/version.sh
+++ b/scripts/make/version.sh
@@ -85,11 +85,7 @@ in
 	# num_commits_since_minor is the number of commits since the last new
 	# minor release.  If the current commit is the new minor release,
 	# num_commits_since_minor is zero.
-	num_commits_since_minor="$( git rev-list "${last_minor_zero}..HEAD" | wc -l )"
-
-	# The output of darwin's implementation of wc needs to be trimmed from
-	# redundant spaces.
-	num_commits_since_minor="$( echo "$num_commits_since_minor" | tr -d '[:space:]' )"
+	num_commits_since_minor="$( git rev-list --count "${last_minor_zero}..HEAD" )"
 	readonly num_commits_since_minor
 
 	# next_minor is the next minor release version.

From 47c9c946a3698c22305e69dc67e8ba585eb681d6 Mon Sep 17 00:00:00 2001
From: Eugene Burkov <e.burkov@adguard.com>
Date: Fri, 23 Sep 2022 13:23:35 +0300
Subject: [PATCH 26/31] Pull request: 4871 imp filtering

Merge in DNS/adguard-home from 4871-imp-filtering to master

Closes #4871.

Squashed commit of the following:

commit 618e7c558447703c114332708c94ef1b34362cf9
Merge: 41ff8ab7 11e4f091
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Sep 22 19:27:08 2022 +0300

    Merge branch 'master' into 4871-imp-filtering

commit 41ff8ab755a87170e7334dedcae00f01dcca238a
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Sep 22 19:26:11 2022 +0300

    filtering: imp code, log

commit e4ae1d1788406ffd7ef0fcc6df896a22b0c2db37
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Sep 22 14:11:07 2022 +0300

    filtering: move handlers into single func

commit f7a340b4c10980f512ae935a156f02b0133a1627
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Wed Sep 21 19:21:09 2022 +0300

    all: imp code

commit e064bf4d3de0283e4bda2aaf5b9822bb8a08f4a6
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 20 20:12:16 2022 +0300

    all: imp name

commit e7eda3905762f0821e1be1ac3cf77e0ecbedeff4
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 20 17:51:23 2022 +0300

    all: finally get rid of filtering

commit 188550d873e625cc2951583bb3a2eaad036745f5
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 20 17:36:03 2022 +0300

    filtering: merge refresh

commit e54ed9c7952b17e66b790c835269b28fbc26f9ca
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 20 17:16:23 2022 +0300

    filtering: merge filters

commit 32da31b754a319487d5f9d5e81e607d349b90180
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 20 14:48:13 2022 +0300

    filtering: imp docs

commit 43b0cafa7a27bb9b620c2ba50ccdddcf32cfcecc
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 20 14:38:04 2022 +0300

    all: imp code

commit 253a2ea6c92815d364546e34d631e406dd604644
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Mon Sep 19 20:43:15 2022 +0300

    filtering: rm important flag

commit 1b87f08f946389d410f13412c7e486290d5e752d
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Mon Sep 19 17:05:40 2022 +0300

    all: move filtering to the package

commit daa13499f1dd4fe475c4b75769e34f1eb0915bdf
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Mon Sep 19 15:13:55 2022 +0300

    all: finish merging

commit d6db75eb2e1f23528e9200ea51507eb793eefa3c
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Fri Sep 16 18:18:14 2022 +0300

    all: continue merging

commit 45b4c484deb7198a469aa18d719bb9dbe81e5d22
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Wed Sep 14 15:44:22 2022 +0300

    all: merge filtering types
---
 internal/aghhttp/aghhttp.go                   |   6 +
 internal/dnsforward/dnsforward_test.go        |  30 +-
 internal/dnsforward/filter_test.go            |   3 +-
 internal/filtering/blocked.go                 |  28 +-
 .../{home => filtering}/controlfiltering.go   | 239 ++++----
 internal/filtering/dnsrewrite_test.go         |   2 +-
 internal/{home => filtering}/filter.go        | 517 +++++++++---------
 internal/{home => filtering}/filter_test.go   |  29 +-
 internal/filtering/filtering.go               | 171 +++---
 internal/filtering/filtering_test.go          | 147 ++---
 internal/filtering/rewrites.go                |  33 +-
 internal/filtering/rewrites_test.go           |   8 +-
 internal/filtering/safebrowsing.go            |  14 -
 internal/filtering/safebrowsing_test.go       |   4 +-
 internal/home/config.go                       | 116 ++--
 internal/home/control.go                      |   4 +-
 internal/home/dns.go                          |  52 +-
 internal/home/home.go                         |  28 +-
 internal/home/mobileconfig.go                 |   3 +-
 internal/home/service.go                      |   3 +-
 internal/home/upgrade_test.go                 |   3 +-
 internal/home/web.go                          |  11 +-
 22 files changed, 743 insertions(+), 708 deletions(-)
 rename internal/{home => filtering}/controlfiltering.go (61%)
 rename internal/{home => filtering}/filter.go (51%)
 rename internal/{home => filtering}/filter_test.go (83%)

diff --git a/internal/aghhttp/aghhttp.go b/internal/aghhttp/aghhttp.go
index 57a1c868..23f9f5d3 100644
--- a/internal/aghhttp/aghhttp.go
+++ b/internal/aghhttp/aghhttp.go
@@ -9,6 +9,12 @@ import (
 	"github.com/AdguardTeam/golibs/log"
 )
 
+// HTTP scheme constants.
+const (
+	SchemeHTTP  = "http"
+	SchemeHTTPS = "https"
+)
+
 // RegisterFunc is the function that sets the handler to handle the URL for the
 // method.
 //
diff --git a/internal/dnsforward/dnsforward_test.go b/internal/dnsforward/dnsforward_test.go
index b11d1f65..7d1ae199 100644
--- a/internal/dnsforward/dnsforward_test.go
+++ b/internal/dnsforward/dnsforward_test.go
@@ -67,10 +67,11 @@ func createTestServer(
 		ID: 0, Data: []byte(rules),
 	}}
 
-	f := filtering.New(filterConf, filters)
+	f, err := filtering.New(filterConf, filters)
+	require.NoError(t, err)
+
 	f.SetEnabled(true)
 
-	var err error
 	s, err = NewServer(DNSCreateParams{
 		DHCPServer:  testDHCP,
 		DNSFilter:   f,
@@ -774,7 +775,9 @@ func TestBlockedCustomIP(t *testing.T) {
 		Data: []byte(rules),
 	}}
 
-	f := filtering.New(&filtering.Config{}, filters)
+	f, err := filtering.New(&filtering.Config{}, filters)
+	require.NoError(t, err)
+
 	s, err := NewServer(DNSCreateParams{
 		DHCPServer:  testDHCP,
 		DNSFilter:   f,
@@ -906,7 +909,9 @@ func TestRewrite(t *testing.T) {
 			Type:   dns.TypeCNAME,
 		}},
 	}
-	f := filtering.New(c, nil)
+	f, err := filtering.New(c, nil)
+	require.NoError(t, err)
+
 	f.SetEnabled(true)
 
 	s, err := NewServer(DNSCreateParams{
@@ -1021,19 +1026,14 @@ var testDHCP = &dhcpd.MockInterface{
 	OnWriteDiskConfig:   func(c *dhcpd.ServerConfig) { panic("not implemented") },
 }
 
-// func (*testDHCP) Leases(flags dhcpd.GetLeasesFlags) (leases []*dhcpd.Lease) {
-// 	return []*dhcpd.Lease{{
-// 		IP:       net.IP{192, 168, 12, 34},
-// 		HWAddr:   net.HardwareAddr{0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA},
-// 		Hostname: "myhost",
-// 	}}
-// }
-
 func TestPTRResponseFromDHCPLeases(t *testing.T) {
 	const localDomain = "lan"
 
+	flt, err := filtering.New(&filtering.Config{}, nil)
+	require.NoError(t, err)
+
 	s, err := NewServer(DNSCreateParams{
-		DNSFilter:   filtering.New(&filtering.Config{}, nil),
+		DNSFilter:   flt,
 		DHCPServer:  testDHCP,
 		PrivateNets: netutil.SubnetSetFunc(netutil.IsLocallyServed),
 		LocalDomain: localDomain,
@@ -1100,9 +1100,11 @@ func TestPTRResponseFromHosts(t *testing.T) {
 		assert.Equal(t, uint32(1), atomic.LoadUint32(&eventsCalledCounter))
 	})
 
-	flt := filtering.New(&filtering.Config{
+	flt, err := filtering.New(&filtering.Config{
 		EtcHosts: hc,
 	}, nil)
+	require.NoError(t, err)
+
 	flt.SetEnabled(true)
 
 	var s *Server
diff --git a/internal/dnsforward/filter_test.go b/internal/dnsforward/filter_test.go
index 00c04252..7fa0985a 100644
--- a/internal/dnsforward/filter_test.go
+++ b/internal/dnsforward/filter_test.go
@@ -35,7 +35,8 @@ func TestHandleDNSRequest_filterDNSResponse(t *testing.T) {
 		ID: 0, Data: []byte(rules),
 	}}
 
-	f := filtering.New(&filtering.Config{}, filters)
+	f, err := filtering.New(&filtering.Config{}, filters)
+	require.NoError(t, err)
 	f.SetEnabled(true)
 
 	s, err := NewServer(DNSCreateParams{
diff --git a/internal/filtering/blocked.go b/internal/filtering/blocked.go
index 08866100..489def36 100644
--- a/internal/filtering/blocked.go
+++ b/internal/filtering/blocked.go
@@ -421,31 +421,34 @@ func initBlockedServices() {
 }
 
 // BlockedSvcKnown - return TRUE if a blocked service name is known
-func BlockedSvcKnown(s string) bool {
-	_, ok := serviceRules[s]
+func BlockedSvcKnown(s string) (ok bool) {
+	_, ok = serviceRules[s]
+
 	return ok
 }
 
 // ApplyBlockedServices - set blocked services settings for this DNS request
-func (d *DNSFilter) ApplyBlockedServices(setts *Settings, list []string, global bool) {
+func (d *DNSFilter) ApplyBlockedServices(setts *Settings, list []string) {
 	setts.ServicesRules = []ServiceEntry{}
-	if global {
+	if list == nil {
 		d.confLock.RLock()
 		defer d.confLock.RUnlock()
+
 		list = d.Config.BlockedServices
 	}
+
 	for _, name := range list {
 		rules, ok := serviceRules[name]
-
 		if !ok {
 			log.Error("unknown service name: %s", name)
+
 			continue
 		}
 
-		s := ServiceEntry{}
-		s.Name = name
-		s.Rules = rules
-		setts.ServicesRules = append(setts.ServicesRules, s)
+		setts.ServicesRules = append(setts.ServicesRules, ServiceEntry{
+			Name:  name,
+			Rules: rules,
+		})
 	}
 }
 
@@ -490,10 +493,3 @@ func (d *DNSFilter) handleBlockedServicesSet(w http.ResponseWriter, r *http.Requ
 
 	d.ConfigModified()
 }
-
-// registerBlockedServicesHandlers - register HTTP handlers
-func (d *DNSFilter) registerBlockedServicesHandlers() {
-	d.Config.HTTPRegister(http.MethodGet, "/control/blocked_services/services", d.handleBlockedServicesAvailableServices)
-	d.Config.HTTPRegister(http.MethodGet, "/control/blocked_services/list", d.handleBlockedServicesList)
-	d.Config.HTTPRegister(http.MethodPost, "/control/blocked_services/set", d.handleBlockedServicesSet)
-}
diff --git a/internal/home/controlfiltering.go b/internal/filtering/controlfiltering.go
similarity index 61%
rename from internal/home/controlfiltering.go
rename to internal/filtering/controlfiltering.go
index a4c8651a..1cce8ded 100644
--- a/internal/home/controlfiltering.go
+++ b/internal/filtering/controlfiltering.go
@@ -1,4 +1,4 @@
-package home
+package filtering
 
 import (
 	"encoding/json"
@@ -34,7 +34,7 @@ func validateFilterURL(urlStr string) (err error) {
 		return fmt.Errorf("checking filter url: %w", err)
 	}
 
-	if s := url.Scheme; s != schemeHTTP && s != schemeHTTPS {
+	if s := url.Scheme; s != aghhttp.SchemeHTTP && s != aghhttp.SchemeHTTPS {
 		return fmt.Errorf("checking filter url: invalid scheme %q", s)
 	}
 
@@ -47,7 +47,7 @@ type filterAddJSON struct {
 	Whitelist bool   `json:"whitelist"`
 }
 
-func (f *Filtering) handleFilteringAddURL(w http.ResponseWriter, r *http.Request) {
+func (d *DNSFilter) handleFilteringAddURL(w http.ResponseWriter, r *http.Request) {
 	fj := filterAddJSON{}
 	err := json.NewDecoder(r.Body).Decode(&fj)
 	if err != nil {
@@ -65,14 +65,14 @@ func (f *Filtering) handleFilteringAddURL(w http.ResponseWriter, r *http.Request
 	}
 
 	// Check for duplicates
-	if filterExists(fj.URL) {
+	if d.filterExists(fj.URL) {
 		aghhttp.Error(r, w, http.StatusBadRequest, "Filter URL already added -- %s", fj.URL)
 
 		return
 	}
 
 	// Set necessary properties
-	filt := filter{
+	filt := FilterYAML{
 		Enabled: true,
 		URL:     fj.URL,
 		Name:    fj.Name,
@@ -81,7 +81,7 @@ func (f *Filtering) handleFilteringAddURL(w http.ResponseWriter, r *http.Request
 	filt.ID = assignUniqueFilterID()
 
 	// Download the filter contents
-	ok, err := f.update(&filt)
+	ok, err := d.update(&filt)
 	if err != nil {
 		aghhttp.Error(
 			r,
@@ -109,14 +109,14 @@ func (f *Filtering) handleFilteringAddURL(w http.ResponseWriter, r *http.Request
 
 	// URL is assumed valid so append it to filters, update config, write new
 	// file and reload it to engines.
-	if !filterAdd(filt) {
+	if !d.filterAdd(filt) {
 		aghhttp.Error(r, w, http.StatusBadRequest, "Filter URL already added -- %s", filt.URL)
 
 		return
 	}
 
-	onConfigModified()
-	enableFilters(true)
+	d.ConfigModified()
+	d.EnableFilters(true)
 
 	_, err = fmt.Fprintf(w, "OK %d rules\n", filt.RulesCount)
 	if err != nil {
@@ -124,7 +124,7 @@ func (f *Filtering) handleFilteringAddURL(w http.ResponseWriter, r *http.Request
 	}
 }
 
-func (f *Filtering) handleFilteringRemoveURL(w http.ResponseWriter, r *http.Request) {
+func (d *DNSFilter) handleFilteringRemoveURL(w http.ResponseWriter, r *http.Request) {
 	type request struct {
 		URL       string `json:"url"`
 		Whitelist bool   `json:"whitelist"`
@@ -138,23 +138,23 @@ func (f *Filtering) handleFilteringRemoveURL(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	config.Lock()
-	filters := &config.Filters
+	d.filtersMu.Lock()
+	filters := &d.Filters
 	if req.Whitelist {
-		filters = &config.WhitelistFilters
+		filters = &d.WhitelistFilters
 	}
 
-	var deleted filter
-	var newFilters []filter
-	for _, f := range *filters {
-		if f.URL != req.URL {
-			newFilters = append(newFilters, f)
+	var deleted FilterYAML
+	var newFilters []FilterYAML
+	for _, flt := range *filters {
+		if flt.URL != req.URL {
+			newFilters = append(newFilters, flt)
 
 			continue
 		}
 
-		deleted = f
-		path := f.Path()
+		deleted = flt
+		path := flt.Path(d.DataDir)
 		err = os.Rename(path, path+".old")
 		if err != nil {
 			log.Error("deleting filter %q: %s", path, err)
@@ -162,10 +162,10 @@ func (f *Filtering) handleFilteringRemoveURL(w http.ResponseWriter, r *http.Requ
 	}
 
 	*filters = newFilters
-	config.Unlock()
+	d.filtersMu.Unlock()
 
-	onConfigModified()
-	enableFilters(true)
+	d.ConfigModified()
+	d.EnableFilters(true)
 
 	// NOTE: The old files "filter.txt.old" aren't deleted.  It's not really
 	// necessary, but will require the additional complicated code to run
@@ -191,55 +191,51 @@ type filterURLReq struct {
 	Whitelist bool              `json:"whitelist"`
 }
 
-func (f *Filtering) handleFilteringSetURL(w http.ResponseWriter, r *http.Request) {
+func (d *DNSFilter) handleFilteringSetURL(w http.ResponseWriter, r *http.Request) {
 	fj := filterURLReq{}
 	err := json.NewDecoder(r.Body).Decode(&fj)
 	if err != nil {
-		aghhttp.Error(r, w, http.StatusBadRequest, "json decode: %s", err)
+		aghhttp.Error(r, w, http.StatusBadRequest, "decoding request: %s", err)
 
 		return
 	}
 
 	if fj.Data == nil {
-		err = errors.Error("data cannot be null")
-		aghhttp.Error(r, w, http.StatusBadRequest, "%s", err)
+		aghhttp.Error(r, w, http.StatusBadRequest, "%s", errors.Error("data is absent"))
 
 		return
 	}
 
 	err = validateFilterURL(fj.Data.URL)
 	if err != nil {
-		err = fmt.Errorf("invalid url: %s", err)
-		aghhttp.Error(r, w, http.StatusBadRequest, "%s", err)
+		aghhttp.Error(r, w, http.StatusBadRequest, "invalid url: %s", err)
 
 		return
 	}
 
-	filt := filter{
+	filt := FilterYAML{
 		Enabled: fj.Data.Enabled,
 		Name:    fj.Data.Name,
 		URL:     fj.Data.URL,
 	}
-	status := f.filterSetProperties(fj.URL, filt, fj.Whitelist)
+	status := d.filterSetProperties(fj.URL, filt, fj.Whitelist)
 	if (status & statusFound) == 0 {
-		http.Error(w, "URL doesn't exist", http.StatusBadRequest)
+		aghhttp.Error(r, w, http.StatusBadRequest, "URL doesn't exist")
+
 		return
 	}
 	if (status & statusURLExists) != 0 {
-		http.Error(w, "URL already exists", http.StatusBadRequest)
+		aghhttp.Error(r, w, http.StatusBadRequest, "URL already exists")
+
 		return
 	}
 
-	onConfigModified()
+	d.ConfigModified()
 
 	restart := (status & statusEnabledChanged) != 0
 	if (status&statusUpdateRequired) != 0 && fj.Data.Enabled {
-		// download new filter and apply its rules
-		flags := filterRefreshBlocklists
-		if fj.Whitelist {
-			flags = filterRefreshAllowlists
-		}
-		nUpdated, _ := f.refreshFilters(flags, true)
+		// download new filter and apply its rules.
+		nUpdated := d.refreshFilters(!fj.Whitelist, fj.Whitelist, false)
 		// if at least 1 filter has been updated, refreshFilters() restarts the filtering automatically
 		// if not - we restart the filtering ourselves
 		restart = false
@@ -249,11 +245,11 @@ func (f *Filtering) handleFilteringSetURL(w http.ResponseWriter, r *http.Request
 	}
 
 	if restart {
-		enableFilters(true)
+		d.EnableFilters(true)
 	}
 }
 
-func (f *Filtering) handleFilteringSetRules(w http.ResponseWriter, r *http.Request) {
+func (d *DNSFilter) handleFilteringSetRules(w http.ResponseWriter, r *http.Request) {
 	// This use of ReadAll is safe, because request's body is now limited.
 	body, err := io.ReadAll(r.Body)
 	if err != nil {
@@ -262,12 +258,12 @@ func (f *Filtering) handleFilteringSetRules(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	config.UserRules = strings.Split(string(body), "\n")
-	onConfigModified()
-	enableFilters(true)
+	d.UserRules = strings.Split(string(body), "\n")
+	d.ConfigModified()
+	d.EnableFilters(true)
 }
 
-func (f *Filtering) handleFilteringRefresh(w http.ResponseWriter, r *http.Request) {
+func (d *DNSFilter) handleFilteringRefresh(w http.ResponseWriter, r *http.Request) {
 	type Req struct {
 		White bool `json:"whitelist"`
 	}
@@ -285,35 +281,27 @@ func (f *Filtering) handleFilteringRefresh(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	flags := filterRefreshBlocklists
-	if req.White {
-		flags = filterRefreshAllowlists
-	}
-	func() {
-		// Temporarily unlock the Context.controlLock because the
-		// f.refreshFilters waits for it to be unlocked but it's
-		// actually locked in ensure wrapper.
-		//
-		// TODO(e.burkov):  Reconsider this messy syncing process.
-		Context.controlLock.Unlock()
-		defer Context.controlLock.Lock()
-
-		resp.Updated, err = f.refreshFilters(flags|filterRefreshForce, false)
-	}()
-	if err != nil {
-		aghhttp.Error(r, w, http.StatusInternalServerError, "%s", err)
+	var ok bool
+	resp.Updated, _, ok = d.tryRefreshFilters(!req.White, req.White, true)
+	if !ok {
+		aghhttp.Error(
+			r,
+			w,
+			http.StatusInternalServerError,
+			"filters update procedure is already running",
+		)
 
 		return
 	}
 
-	js, err := json.Marshal(resp)
+	w.Header().Set("Content-Type", "application/json")
+
+	err = json.NewEncoder(w).Encode(resp)
 	if err != nil {
 		aghhttp.Error(r, w, http.StatusInternalServerError, "json encode: %s", err)
 
 		return
 	}
-	w.Header().Set("Content-Type", "application/json")
-	_, _ = w.Write(js)
 }
 
 type filterJSON struct {
@@ -333,7 +321,7 @@ type filteringConfig struct {
 	Enabled          bool         `json:"enabled"`
 }
 
-func filterToJSON(f filter) filterJSON {
+func filterToJSON(f FilterYAML) filterJSON {
 	fj := filterJSON{
 		ID:         f.ID,
 		Enabled:    f.Enabled,
@@ -350,21 +338,21 @@ func filterToJSON(f filter) filterJSON {
 }
 
 // Get filtering configuration
-func (f *Filtering) handleFilteringStatus(w http.ResponseWriter, r *http.Request) {
+func (d *DNSFilter) handleFilteringStatus(w http.ResponseWriter, r *http.Request) {
 	resp := filteringConfig{}
-	config.RLock()
-	resp.Enabled = config.DNS.FilteringEnabled
-	resp.Interval = config.DNS.FiltersUpdateIntervalHours
-	for _, f := range config.Filters {
+	d.filtersMu.RLock()
+	resp.Enabled = d.FilteringEnabled
+	resp.Interval = d.FiltersUpdateIntervalHours
+	for _, f := range d.Filters {
 		fj := filterToJSON(f)
 		resp.Filters = append(resp.Filters, fj)
 	}
-	for _, f := range config.WhitelistFilters {
+	for _, f := range d.WhitelistFilters {
 		fj := filterToJSON(f)
 		resp.WhitelistFilters = append(resp.WhitelistFilters, fj)
 	}
-	resp.UserRules = config.UserRules
-	config.RUnlock()
+	resp.UserRules = d.UserRules
+	d.filtersMu.RUnlock()
 
 	jsonVal, err := json.Marshal(resp)
 	if err != nil {
@@ -380,7 +368,7 @@ func (f *Filtering) handleFilteringStatus(w http.ResponseWriter, r *http.Request
 }
 
 // Set filtering configuration
-func (f *Filtering) handleFilteringConfig(w http.ResponseWriter, r *http.Request) {
+func (d *DNSFilter) handleFilteringConfig(w http.ResponseWriter, r *http.Request) {
 	req := filteringConfig{}
 	err := json.NewDecoder(r.Body).Decode(&req)
 	if err != nil {
@@ -389,22 +377,22 @@ func (f *Filtering) handleFilteringConfig(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	if !checkFiltersUpdateIntervalHours(req.Interval) {
+	if !ValidateUpdateIvl(req.Interval) {
 		aghhttp.Error(r, w, http.StatusBadRequest, "Unsupported interval")
 
 		return
 	}
 
 	func() {
-		config.Lock()
-		defer config.Unlock()
+		d.filtersMu.Lock()
+		defer d.filtersMu.Unlock()
 
-		config.DNS.FilteringEnabled = req.Enabled
-		config.DNS.FiltersUpdateIntervalHours = req.Interval
+		d.FilteringEnabled = req.Enabled
+		d.FiltersUpdateIntervalHours = req.Interval
 	}()
 
-	onConfigModified()
-	enableFilters(true)
+	d.ConfigModified()
+	d.EnableFilters(true)
 }
 
 type checkHostRespRule struct {
@@ -435,15 +423,15 @@ type checkHostResp struct {
 	FilterID int64 `json:"filter_id"`
 }
 
-func (f *Filtering) handleCheckHost(w http.ResponseWriter, r *http.Request) {
-	q := r.URL.Query()
-	host := q.Get("name")
+func (d *DNSFilter) handleCheckHost(w http.ResponseWriter, r *http.Request) {
+	host := r.URL.Query().Get("name")
 
-	setts := Context.dnsFilter.GetConfig()
+	setts := d.GetConfig()
 	setts.FilteringEnabled = true
 	setts.ProtectionEnabled = true
-	Context.dnsFilter.ApplyBlockedServices(&setts, nil, true)
-	result, err := Context.dnsFilter.CheckHost(host, dns.TypeA, &setts)
+
+	d.ApplyBlockedServices(&setts, nil)
+	result, err := d.CheckHost(host, dns.TypeA, &setts)
 	if err != nil {
 		aghhttp.Error(
 			r,
@@ -457,18 +445,20 @@ func (f *Filtering) handleCheckHost(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	resp := checkHostResp{}
-	resp.Reason = result.Reason.String()
-	resp.SvcName = result.ServiceName
-	resp.CanonName = result.CanonName
-	resp.IPList = result.IPList
+	rulesLen := len(result.Rules)
+	resp := checkHostResp{
+		Reason:    result.Reason.String(),
+		SvcName:   result.ServiceName,
+		CanonName: result.CanonName,
+		IPList:    result.IPList,
+		Rules:     make([]*checkHostRespRule, len(result.Rules)),
+	}
 
-	if len(result.Rules) > 0 {
+	if rulesLen > 0 {
 		resp.FilterID = result.Rules[0].FilterListID
 		resp.Rule = result.Rules[0].Text
 	}
 
-	resp.Rules = make([]*checkHostRespRule, len(result.Rules))
 	for i, r := range result.Rules {
 		resp.Rules[i] = &checkHostRespRule{
 			FilterListID: r.FilterListID,
@@ -476,28 +466,51 @@ func (f *Filtering) handleCheckHost(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	js, err := json.Marshal(resp)
-	if err != nil {
-		aghhttp.Error(r, w, http.StatusInternalServerError, "json encode: %s", err)
-
-		return
-	}
 	w.Header().Set("Content-Type", "application/json")
-	_, _ = w.Write(js)
+	err = json.NewEncoder(w).Encode(resp)
+	if err != nil {
+		aghhttp.Error(r, w, http.StatusInternalServerError, "encoding response: %s", err)
+	}
 }
 
 // RegisterFilteringHandlers - register handlers
-func (f *Filtering) RegisterFilteringHandlers() {
-	httpRegister(http.MethodGet, "/control/filtering/status", f.handleFilteringStatus)
-	httpRegister(http.MethodPost, "/control/filtering/config", f.handleFilteringConfig)
-	httpRegister(http.MethodPost, "/control/filtering/add_url", f.handleFilteringAddURL)
-	httpRegister(http.MethodPost, "/control/filtering/remove_url", f.handleFilteringRemoveURL)
-	httpRegister(http.MethodPost, "/control/filtering/set_url", f.handleFilteringSetURL)
-	httpRegister(http.MethodPost, "/control/filtering/refresh", f.handleFilteringRefresh)
-	httpRegister(http.MethodPost, "/control/filtering/set_rules", f.handleFilteringSetRules)
-	httpRegister(http.MethodGet, "/control/filtering/check_host", f.handleCheckHost)
+func (d *DNSFilter) RegisterFilteringHandlers() {
+	registerHTTP := d.HTTPRegister
+	if registerHTTP == nil {
+		return
+	}
+
+	registerHTTP(http.MethodPost, "/control/safebrowsing/enable", d.handleSafeBrowsingEnable)
+	registerHTTP(http.MethodPost, "/control/safebrowsing/disable", d.handleSafeBrowsingDisable)
+	registerHTTP(http.MethodGet, "/control/safebrowsing/status", d.handleSafeBrowsingStatus)
+
+	registerHTTP(http.MethodPost, "/control/parental/enable", d.handleParentalEnable)
+	registerHTTP(http.MethodPost, "/control/parental/disable", d.handleParentalDisable)
+	registerHTTP(http.MethodGet, "/control/parental/status", d.handleParentalStatus)
+
+	registerHTTP(http.MethodPost, "/control/safesearch/enable", d.handleSafeSearchEnable)
+	registerHTTP(http.MethodPost, "/control/safesearch/disable", d.handleSafeSearchDisable)
+	registerHTTP(http.MethodGet, "/control/safesearch/status", d.handleSafeSearchStatus)
+
+	registerHTTP(http.MethodGet, "/control/rewrite/list", d.handleRewriteList)
+	registerHTTP(http.MethodPost, "/control/rewrite/add", d.handleRewriteAdd)
+	registerHTTP(http.MethodPost, "/control/rewrite/delete", d.handleRewriteDelete)
+
+	registerHTTP(http.MethodGet, "/control/blocked_services/services", d.handleBlockedServicesAvailableServices)
+	registerHTTP(http.MethodGet, "/control/blocked_services/list", d.handleBlockedServicesList)
+	registerHTTP(http.MethodPost, "/control/blocked_services/set", d.handleBlockedServicesSet)
+
+	registerHTTP(http.MethodGet, "/control/filtering/status", d.handleFilteringStatus)
+	registerHTTP(http.MethodPost, "/control/filtering/config", d.handleFilteringConfig)
+	registerHTTP(http.MethodPost, "/control/filtering/add_url", d.handleFilteringAddURL)
+	registerHTTP(http.MethodPost, "/control/filtering/remove_url", d.handleFilteringRemoveURL)
+	registerHTTP(http.MethodPost, "/control/filtering/set_url", d.handleFilteringSetURL)
+	registerHTTP(http.MethodPost, "/control/filtering/refresh", d.handleFilteringRefresh)
+	registerHTTP(http.MethodPost, "/control/filtering/set_rules", d.handleFilteringSetRules)
+	registerHTTP(http.MethodGet, "/control/filtering/check_host", d.handleCheckHost)
 }
 
-func checkFiltersUpdateIntervalHours(i uint32) bool {
+// ValidateUpdateIvl returns false if i is not a valid filters update interval.
+func ValidateUpdateIvl(i uint32) bool {
 	return i == 0 || i == 1 || i == 12 || i == 1*24 || i == 3*24 || i == 7*24
 }
diff --git a/internal/filtering/dnsrewrite_test.go b/internal/filtering/dnsrewrite_test.go
index f8415fbf..c75ea2b9 100644
--- a/internal/filtering/dnsrewrite_test.go
+++ b/internal/filtering/dnsrewrite_test.go
@@ -49,7 +49,7 @@ func TestDNSFilter_CheckHostRules_dnsrewrite(t *testing.T) {
 |1.2.3.5.in-addr.arpa^$dnsrewrite=NOERROR;PTR;new-ptr-with-dot.
 `
 
-	f := newForTest(t, nil, []Filter{{ID: 0, Data: []byte(text)}})
+	f, _ := newForTest(t, nil, []Filter{{ID: 0, Data: []byte(text)}})
 	setts := &Settings{
 		FilteringEnabled: true,
 	}
diff --git a/internal/home/filter.go b/internal/filtering/filter.go
similarity index 51%
rename from internal/home/filter.go
rename to internal/filtering/filter.go
index 78abd76a..fcba11aa 100644
--- a/internal/home/filter.go
+++ b/internal/filtering/filter.go
@@ -1,4 +1,4 @@
-package home
+package filtering
 
 import (
 	"bufio"
@@ -8,63 +8,29 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
-	"regexp"
 	"strconv"
 	"strings"
-	"sync"
-	"sync/atomic"
 	"time"
 
-	"github.com/AdguardTeam/AdGuardHome/internal/filtering"
 	"github.com/AdguardTeam/golibs/errors"
 	"github.com/AdguardTeam/golibs/log"
 	"github.com/AdguardTeam/golibs/stringutil"
+	"golang.org/x/exp/slices"
 )
 
-var nextFilterID = time.Now().Unix() // semi-stable way to generate an unique ID
+// filterDir is the subdirectory of a data directory to store downloaded
+// filters.
+const filterDir = "filters"
 
-// Filtering - module object
-type Filtering struct {
-	// conf FilteringConf
-	refreshStatus     uint32 // 0:none; 1:in progress
-	refreshLock       sync.Mutex
-	filterTitleRegexp *regexp.Regexp
-}
+// nextFilterID is a way to seed a unique ID generation.
+//
+// TODO(e.burkov):  Use more deterministic approach.
+var nextFilterID = time.Now().Unix()
 
-// Init - initialize the module
-func (f *Filtering) Init() {
-	f.filterTitleRegexp = regexp.MustCompile(`^! Title: +(.*)$`)
-	_ = os.MkdirAll(filepath.Join(Context.getDataDir(), filterDir), 0o755)
-	f.loadFilters(config.Filters)
-	f.loadFilters(config.WhitelistFilters)
-	deduplicateFilters()
-	updateUniqueFilterID(config.Filters)
-	updateUniqueFilterID(config.WhitelistFilters)
-}
-
-// Start - start the module
-func (f *Filtering) Start() {
-	f.RegisterFilteringHandlers()
-
-	// Here we should start updating filters,
-	//  but currently we can't wake up the periodic task to do so.
-	// So for now we just start this periodic task from here.
-	go f.periodicallyRefreshFilters()
-}
-
-// Close - close the module
-func (f *Filtering) Close() {
-}
-
-func defaultFilters() []filter {
-	return []filter{
-		{Filter: filtering.Filter{ID: 1}, Enabled: true, URL: "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt", Name: "AdGuard DNS filter"},
-		{Filter: filtering.Filter{ID: 2}, Enabled: false, URL: "https://adaway.org/hosts.txt", Name: "AdAway Default Blocklist"},
-	}
-}
-
-// field ordering is important -- yaml fields will mirror ordering from here
-type filter struct {
+// FilterYAML respresents a filter list in the configuration file.
+//
+// TODO(e.burkov):  Investigate if the field oredering is important.
+type FilterYAML struct {
 	Enabled     bool
 	URL         string    // URL or a file path
 	Name        string    `yaml:"name"`
@@ -73,91 +39,108 @@ type filter struct {
 	checksum    uint32    // checksum of the file data
 	white       bool
 
-	filtering.Filter `yaml:",inline"`
+	Filter `yaml:",inline"`
+}
+
+// Clear filter rules
+func (filter *FilterYAML) unload() {
+	filter.RulesCount = 0
+	filter.checksum = 0
+}
+
+// Path to the filter contents
+func (filter *FilterYAML) Path(dataDir string) string {
+	return filepath.Join(dataDir, filterDir, strconv.FormatInt(filter.ID, 10)+".txt")
 }
 
 const (
-	statusFound          = 1
-	statusEnabledChanged = 2
-	statusURLChanged     = 4
-	statusURLExists      = 8
-	statusUpdateRequired = 0x10
+	statusFound = 1 << iota
+	statusEnabledChanged
+	statusURLChanged
+	statusURLExists
+	statusUpdateRequired
 )
 
 // Update properties for a filter specified by its URL
 // Return status* flags.
-func (f *Filtering) filterSetProperties(url string, newf filter, whitelist bool) int {
+func (d *DNSFilter) filterSetProperties(url string, newf FilterYAML, whitelist bool) int {
 	r := 0
-	config.Lock()
-	defer config.Unlock()
+	d.filtersMu.Lock()
+	defer d.filtersMu.Unlock()
 
-	filters := &config.Filters
+	filters := d.Filters
 	if whitelist {
-		filters = &config.WhitelistFilters
+		filters = d.WhitelistFilters
 	}
 
-	for i := range *filters {
-		filt := &(*filters)[i]
-		if filt.URL != url {
-			continue
+	i := slices.IndexFunc(filters, func(filt FilterYAML) bool {
+		return filt.URL == url
+	})
+	if i == -1 {
+		return 0
+	}
+
+	filt := &filters[i]
+
+	log.Debug("filter: set properties: %s: {%s %s %v}", filt.URL, newf.Name, newf.URL, newf.Enabled)
+	filt.Name = newf.Name
+
+	if filt.URL != newf.URL {
+		r |= statusURLChanged | statusUpdateRequired
+		if d.filterExistsNoLock(newf.URL) {
+			return statusURLExists
 		}
 
-		log.Debug("filter: set properties: %s: {%s %s %v}",
-			filt.URL, newf.Name, newf.URL, newf.Enabled)
-		filt.Name = newf.Name
+		filt.URL = newf.URL
+		filt.unload()
+		filt.LastUpdated = time.Time{}
+		filt.checksum = 0
+		filt.RulesCount = 0
+	}
 
-		if filt.URL != newf.URL {
-			r |= statusURLChanged | statusUpdateRequired
-			if filterExistsNoLock(newf.URL) {
-				return statusURLExists
-			}
-			filt.URL = newf.URL
-			filt.unload()
-			filt.LastUpdated = time.Time{}
-			filt.checksum = 0
-			filt.RulesCount = 0
-		}
+	if filt.Enabled != newf.Enabled {
+		r |= statusEnabledChanged
+		filt.Enabled = newf.Enabled
+		if filt.Enabled {
+			if (r & statusURLChanged) == 0 {
+				err := d.load(filt)
+				if err != nil {
+					// TODO(e.burkov):  It seems the error is only returned when
+					// the file exists and couldn't be open.  Investigate and
+					// improve.
+					log.Error("loading filter %d: %s", filt.ID, err)
 
-		if filt.Enabled != newf.Enabled {
-			r |= statusEnabledChanged
-			filt.Enabled = newf.Enabled
-			if filt.Enabled {
-				if (r & statusURLChanged) == 0 {
-					e := f.load(filt)
-					if e != nil {
-						// This isn't a fatal error,
-						//  because it may occur when someone removes the file from disk.
-						filt.LastUpdated = time.Time{}
-						filt.checksum = 0
-						filt.RulesCount = 0
-						r |= statusUpdateRequired
-					}
+					filt.LastUpdated = time.Time{}
+					filt.checksum = 0
+					filt.RulesCount = 0
+					r |= statusUpdateRequired
 				}
-			} else {
-				filt.unload()
 			}
+		} else {
+			filt.unload()
 		}
-
-		return r | statusFound
 	}
-	return 0
+
+	return r | statusFound
 }
 
 // Return TRUE if a filter with this URL exists
-func filterExists(url string) bool {
-	config.RLock()
-	r := filterExistsNoLock(url)
-	config.RUnlock()
+func (d *DNSFilter) filterExists(url string) bool {
+	d.filtersMu.RLock()
+	defer d.filtersMu.RUnlock()
+
+	r := d.filterExistsNoLock(url)
+
 	return r
 }
 
-func filterExistsNoLock(url string) bool {
-	for _, f := range config.Filters {
+func (d *DNSFilter) filterExistsNoLock(url string) bool {
+	for _, f := range d.Filters {
 		if f.URL == url {
 			return true
 		}
 	}
-	for _, f := range config.WhitelistFilters {
+	for _, f := range d.WhitelistFilters {
 		if f.URL == url {
 			return true
 		}
@@ -167,26 +150,26 @@ func filterExistsNoLock(url string) bool {
 
 // Add a filter
 // Return FALSE if a filter with this URL exists
-func filterAdd(f filter) bool {
-	config.Lock()
-	defer config.Unlock()
+func (d *DNSFilter) filterAdd(flt FilterYAML) bool {
+	d.filtersMu.Lock()
+	defer d.filtersMu.Unlock()
 
 	// Check for duplicates
-	if filterExistsNoLock(f.URL) {
+	if d.filterExistsNoLock(flt.URL) {
 		return false
 	}
 
-	if f.white {
-		config.WhitelistFilters = append(config.WhitelistFilters, f)
+	if flt.white {
+		d.WhitelistFilters = append(d.WhitelistFilters, flt)
 	} else {
-		config.Filters = append(config.Filters, f)
+		d.Filters = append(d.Filters, flt)
 	}
 	return true
 }
 
 // Load filters from the disk
 // And if any filter has zero ID, assign a new one
-func (f *Filtering) loadFilters(array []filter) {
+func (d *DNSFilter) loadFilters(array []FilterYAML) {
 	for i := range array {
 		filter := &array[i] // otherwise we're operating on a copy
 		if filter.ID == 0 {
@@ -198,32 +181,30 @@ func (f *Filtering) loadFilters(array []filter) {
 			continue
 		}
 
-		err := f.load(filter)
+		err := d.load(filter)
 		if err != nil {
 			log.Error("Couldn't load filter %d contents due to %s", filter.ID, err)
 		}
 	}
 }
 
-func deduplicateFilters() {
-	// Deduplicate filters
-	i := 0 // output index, used for deletion later
-	urls := map[string]bool{}
-	for _, filter := range config.Filters {
-		if _, ok := urls[filter.URL]; !ok {
-			// we didn't see it before, keep it
-			urls[filter.URL] = true // remember the URL
-			config.Filters[i] = filter
-			i++
+func deduplicateFilters(filters []FilterYAML) (deduplicated []FilterYAML) {
+	urls := stringutil.NewSet()
+	lastIdx := 0
+
+	for _, filter := range filters {
+		if !urls.Has(filter.URL) {
+			urls.Add(filter.URL)
+			filters[lastIdx] = filter
+			lastIdx++
 		}
 	}
 
-	// all entries we want to keep are at front, delete the rest
-	config.Filters = config.Filters[:i]
+	return filters[:lastIdx]
 }
 
 // Set the next filter ID to max(filter.ID) + 1
-func updateUniqueFilterID(filters []filter) {
+func updateUniqueFilterID(filters []FilterYAML) {
 	for _, filter := range filters {
 		if nextFilterID < filter.ID {
 			nextFilterID = filter.ID + 1
@@ -238,22 +219,19 @@ func assignUniqueFilterID() int64 {
 }
 
 // Sets up a timer that will be checking for filters updates periodically
-func (f *Filtering) periodicallyRefreshFilters() {
+func (d *DNSFilter) periodicallyRefreshFilters() {
 	const maxInterval = 1 * 60 * 60
 	intval := 5 // use a dynamically increasing time interval
 	for {
-		isNetworkErr := false
-		if config.DNS.FiltersUpdateIntervalHours != 0 && atomic.CompareAndSwapUint32(&f.refreshStatus, 0, 1) {
-			f.refreshLock.Lock()
-			_, isNetworkErr = f.refreshFiltersIfNecessary(filterRefreshBlocklists | filterRefreshAllowlists)
-			f.refreshLock.Unlock()
-			f.refreshStatus = 0
-			if !isNetworkErr {
+		isNetErr, ok := false, false
+		if d.FiltersUpdateIntervalHours != 0 {
+			_, isNetErr, ok = d.tryRefreshFilters(true, true, false)
+			if ok && !isNetErr {
 				intval = maxInterval
 			}
 		}
 
-		if isNetworkErr {
+		if isNetErr {
 			intval *= 2
 			if intval > maxInterval {
 				intval = maxInterval
@@ -264,51 +242,73 @@ func (f *Filtering) periodicallyRefreshFilters() {
 	}
 }
 
-// Refresh filters
-// flags: filterRefresh*
-// important:
+// tryRefreshFilters is like [refreshFilters], but backs down if the update is
+// already going on.
 //
-// TRUE: ignore the fact that we're currently updating the filters
-func (f *Filtering) refreshFilters(flags int, important bool) (int, error) {
-	set := atomic.CompareAndSwapUint32(&f.refreshStatus, 0, 1)
-	if !important && !set {
-		return 0, fmt.Errorf("filters update procedure is already running")
+// TODO(e.burkov):  Get rid of the concurrency pattern which requires the
+// sync.Mutex.TryLock.
+func (d *DNSFilter) tryRefreshFilters(block, allow, force bool) (updated int, isNetworkErr, ok bool) {
+	if ok = d.refreshLock.TryLock(); !ok {
+		return 0, false, ok
 	}
+	defer d.refreshLock.Unlock()
 
-	f.refreshLock.Lock()
-	nUpdated, _ := f.refreshFiltersIfNecessary(flags)
-	f.refreshLock.Unlock()
-	f.refreshStatus = 0
-	return nUpdated, nil
+	updated, isNetworkErr = d.refreshFiltersIntl(block, allow, force)
+
+	return updated, isNetworkErr, ok
 }
 
-func (f *Filtering) refreshFiltersArray(filters *[]filter, force bool) (int, []filter, []bool, bool) {
-	var updateFilters []filter
+// refreshFilters updates the lists and returns the number of updated ones.
+// It's safe for concurrent use, but blocks at least until the previous
+// refreshing is finished.
+func (d *DNSFilter) refreshFilters(block, allow, force bool) (updated int) {
+	d.refreshLock.Lock()
+	defer d.refreshLock.Unlock()
+
+	updated, _ = d.refreshFiltersIntl(block, allow, force)
+
+	return updated
+}
+
+// listsToUpdate returns the slice of filter lists that could be updated.
+func (d *DNSFilter) listsToUpdate(filters *[]FilterYAML, force bool) (toUpd []FilterYAML) {
+	now := time.Now()
+
+	d.filtersMu.RLock()
+	defer d.filtersMu.RUnlock()
+
+	for i := range *filters {
+		flt := &(*filters)[i] // otherwise we will be operating on a copy
+		log.Debug("checking list at index %d: %v", i, flt)
+
+		if !flt.Enabled {
+			continue
+		}
+
+		if !force {
+			exp := flt.LastUpdated.Add(time.Duration(d.FiltersUpdateIntervalHours) * time.Hour)
+			if now.Before(exp) {
+				continue
+			}
+		}
+
+		toUpd = append(toUpd, FilterYAML{
+			Filter: Filter{
+				ID: flt.ID,
+			},
+			URL:      flt.URL,
+			Name:     flt.Name,
+			checksum: flt.checksum,
+		})
+	}
+
+	return toUpd
+}
+
+func (d *DNSFilter) refreshFiltersArray(filters *[]FilterYAML, force bool) (int, []FilterYAML, []bool, bool) {
 	var updateFlags []bool // 'true' if filter data has changed
 
-	now := time.Now()
-	config.RLock()
-	for i := range *filters {
-		f := &(*filters)[i] // otherwise we will be operating on a copy
-
-		if !f.Enabled {
-			continue
-		}
-
-		expireTime := f.LastUpdated.Unix() + int64(config.DNS.FiltersUpdateIntervalHours)*60*60
-		if !force && expireTime > now.Unix() {
-			continue
-		}
-
-		var uf filter
-		uf.ID = f.ID
-		uf.URL = f.URL
-		uf.Name = f.Name
-		uf.checksum = f.checksum
-		updateFilters = append(updateFilters, uf)
-	}
-	config.RUnlock()
-
+	updateFilters := d.listsToUpdate(filters, force)
 	if len(updateFilters) == 0 {
 		return 0, nil, nil, false
 	}
@@ -316,7 +316,7 @@ func (f *Filtering) refreshFiltersArray(filters *[]filter, force bool) (int, []f
 	nfail := 0
 	for i := range updateFilters {
 		uf := &updateFilters[i]
-		updated, err := f.update(uf)
+		updated, err := d.update(uf)
 		updateFlags = append(updateFlags, updated)
 		if err != nil {
 			nfail++
@@ -334,7 +334,7 @@ func (f *Filtering) refreshFiltersArray(filters *[]filter, force bool) (int, []f
 		uf := &updateFilters[i]
 		updated := updateFlags[i]
 
-		config.Lock()
+		d.filtersMu.Lock()
 		for k := range *filters {
 			f := &(*filters)[k]
 			if f.ID != uf.ID || f.URL != uf.URL {
@@ -352,20 +352,14 @@ func (f *Filtering) refreshFiltersArray(filters *[]filter, force bool) (int, []f
 			f.checksum = uf.checksum
 			updateCount++
 		}
-		config.Unlock()
+		d.filtersMu.Unlock()
 	}
 
 	return updateCount, updateFilters, updateFlags, false
 }
 
-const (
-	filterRefreshForce      = 1 // ignore last file modification date
-	filterRefreshAllowlists = 2 // update allow-lists
-	filterRefreshBlocklists = 4 // update block-lists
-)
-
-// refreshFiltersIfNecessary checks filters and updates them if necessary.  If
-// force is true, it ignores the filter.LastUpdated field value.
+// refreshFiltersIntl checks filters and updates them if necessary.  If force is
+// true, it ignores the filter.LastUpdated field value.
 //
 // Algorithm:
 //
@@ -378,53 +372,49 @@ const (
 //     that this method works only on Unix systems.  On Windows, don't pass
 //     files to filtering, pass the whole data.
 //
-// refreshFiltersIfNecessary returns the number of updated filters.  It also
-// returns true if there was a network error and nothing could be updated.
+// refreshFiltersIntl returns the number of updated filters.  It also returns
+// true if there was a network error and nothing could be updated.
 //
 // TODO(a.garipov, e.burkov): What the hell?
-func (f *Filtering) refreshFiltersIfNecessary(flags int) (int, bool) {
-	log.Debug("Filters: updating...")
+func (d *DNSFilter) refreshFiltersIntl(block, allow, force bool) (int, bool) {
+	log.Debug("filtering: updating...")
 
-	updateCount := 0
-	var updateFilters []filter
-	var updateFlags []bool
-	netError := false
-	netErrorW := false
-	force := false
-	if (flags & filterRefreshForce) != 0 {
-		force = true
+	updNum := 0
+	var lists []FilterYAML
+	var toUpd []bool
+	isNetErr := false
+
+	if block {
+		updNum, lists, toUpd, isNetErr = d.refreshFiltersArray(&d.Filters, force)
 	}
-	if (flags & filterRefreshBlocklists) != 0 {
-		updateCount, updateFilters, updateFlags, netError = f.refreshFiltersArray(&config.Filters, force)
+	if allow {
+		updNumAl, listsAl, toUpdAl, isNetErrAl := d.refreshFiltersArray(&d.WhitelistFilters, force)
+
+		updNum += updNumAl
+		lists = append(lists, listsAl...)
+		toUpd = append(toUpd, toUpdAl...)
+		isNetErr = isNetErr || isNetErrAl
 	}
-	if (flags & filterRefreshAllowlists) != 0 {
-		updateCountW := 0
-		var updateFiltersW []filter
-		var updateFlagsW []bool
-		updateCountW, updateFiltersW, updateFlagsW, netErrorW = f.refreshFiltersArray(&config.WhitelistFilters, force)
-		updateCount += updateCountW
-		updateFilters = append(updateFilters, updateFiltersW...)
-		updateFlags = append(updateFlags, updateFlagsW...)
-	}
-	if netError && netErrorW {
+	if isNetErr {
 		return 0, true
 	}
 
-	if updateCount != 0 {
-		enableFilters(false)
+	if updNum != 0 {
+		d.EnableFilters(false)
 
-		for i := range updateFilters {
-			uf := &updateFilters[i]
-			updated := updateFlags[i]
+		for i := range lists {
+			uf := &lists[i]
+			updated := toUpd[i]
 			if !updated {
 				continue
 			}
-			_ = os.Remove(uf.Path() + ".old")
+			_ = os.Remove(uf.Path(d.DataDir) + ".old")
 		}
 	}
 
-	log.Debug("Filters: update finished")
-	return updateCount, false
+	log.Debug("filtering: update finished")
+
+	return updNum, false
 }
 
 // Allows printable UTF-8 text with CR, LF, TAB characters
@@ -440,7 +430,7 @@ func isPrintableText(data []byte, len int) bool {
 }
 
 // A helper function that parses filter contents and returns a number of rules and a filter name (if there's any)
-func (f *Filtering) parseFilterContents(file io.Reader) (int, uint32, string) {
+func (d *DNSFilter) parseFilterContents(file io.Reader) (int, uint32, string) {
 	rulesCount := 0
 	name := ""
 	seenTitle := false
@@ -455,7 +445,7 @@ func (f *Filtering) parseFilterContents(file io.Reader) (int, uint32, string) {
 		if len(line) == 0 {
 			//
 		} else if line[0] == '!' {
-			m := f.filterTitleRegexp.FindAllStringSubmatch(line, -1)
+			m := d.filterTitleRegexp.FindAllStringSubmatch(line, -1)
 			if len(m) > 0 && len(m[0]) >= 2 && !seenTitle {
 				name = m[0][1]
 				seenTitle = true
@@ -476,11 +466,11 @@ func (f *Filtering) parseFilterContents(file io.Reader) (int, uint32, string) {
 }
 
 // Perform upgrade on a filter and update LastUpdated value
-func (f *Filtering) update(filter *filter) (bool, error) {
-	b, err := f.updateIntl(filter)
+func (d *DNSFilter) update(filter *FilterYAML) (bool, error) {
+	b, err := d.updateIntl(filter)
 	filter.LastUpdated = time.Now()
 	if !b {
-		e := os.Chtimes(filter.Path(), filter.LastUpdated, filter.LastUpdated)
+		e := os.Chtimes(filter.Path(d.DataDir), filter.LastUpdated, filter.LastUpdated)
 		if e != nil {
 			log.Error("os.Chtimes(): %v", e)
 		}
@@ -488,7 +478,7 @@ func (f *Filtering) update(filter *filter) (bool, error) {
 	return b, err
 }
 
-func (f *Filtering) read(reader io.Reader, tmpFile *os.File, filter *filter) (int, error) {
+func (d *DNSFilter) read(reader io.Reader, tmpFile *os.File, filter *FilterYAML) (int, error) {
 	htmlTest := true
 	firstChunk := make([]byte, 4*1024)
 	firstChunkLen := 0
@@ -539,20 +529,20 @@ func (f *Filtering) read(reader io.Reader, tmpFile *os.File, filter *filter) (in
 // finalizeUpdate closes and gets rid of temporary file f with filter's content
 // according to updated.  It also saves new values of flt's name, rules number
 // and checksum if sucсeeded.
-func finalizeUpdate(
-	f *os.File,
-	flt *filter,
+func (d *DNSFilter) finalizeUpdate(
+	file *os.File,
+	flt *FilterYAML,
 	updated bool,
 	name string,
 	rnum int,
 	cs uint32,
 ) (err error) {
-	tmpFileName := f.Name()
+	tmpFileName := file.Name()
 
 	// Close the file before renaming it because it's required on Windows.
 	//
 	// See https://github.com/adguardTeam/adGuardHome/issues/1553.
-	if err = f.Close(); err != nil {
+	if err = file.Close(); err != nil {
 		return fmt.Errorf("closing temporary file: %w", err)
 	}
 
@@ -562,9 +552,9 @@ func finalizeUpdate(
 		return os.Remove(tmpFileName)
 	}
 
-	log.Printf("saving filter %d contents to: %s", flt.ID, flt.Path())
+	log.Printf("saving filter %d contents to: %s", flt.ID, flt.Path(d.DataDir))
 
-	if err = os.Rename(tmpFileName, flt.Path()); err != nil {
+	if err = os.Rename(tmpFileName, flt.Path(d.DataDir)); err != nil {
 		return errors.WithDeferred(err, os.Remove(tmpFileName))
 	}
 
@@ -578,12 +568,12 @@ func finalizeUpdate(
 // processUpdate copies filter's content from src to dst and returns the name,
 // rules number, and checksum for it.  It also returns the number of bytes read
 // from src.
-func (f *Filtering) processUpdate(
+func (d *DNSFilter) processUpdate(
 	src io.Reader,
 	dst *os.File,
-	flt *filter,
+	flt *FilterYAML,
 ) (name string, rnum int, cs uint32, n int, err error) {
-	if n, err = f.read(src, dst, flt); err != nil {
+	if n, err = d.read(src, dst, flt); err != nil {
 		return "", 0, 0, 0, err
 	}
 
@@ -591,14 +581,14 @@ func (f *Filtering) processUpdate(
 		return "", 0, 0, 0, err
 	}
 
-	rnum, cs, name = f.parseFilterContents(dst)
+	rnum, cs, name = d.parseFilterContents(dst)
 
 	return name, rnum, cs, n, nil
 }
 
 // updateIntl updates the flt rewriting it's actual file.  It returns true if
 // the actual update has been performed.
-func (f *Filtering) updateIntl(flt *filter) (ok bool, err error) {
+func (d *DNSFilter) updateIntl(flt *FilterYAML) (ok bool, err error) {
 	log.Tracef("downloading update for filter %d from %s", flt.ID, flt.URL)
 
 	var name string
@@ -606,12 +596,12 @@ func (f *Filtering) updateIntl(flt *filter) (ok bool, err error) {
 	var cs uint32
 
 	var tmpFile *os.File
-	tmpFile, err = os.CreateTemp(filepath.Join(Context.getDataDir(), filterDir), "")
+	tmpFile, err = os.CreateTemp(filepath.Join(d.DataDir, filterDir), "")
 	if err != nil {
 		return false, err
 	}
 	defer func() {
-		err = errors.WithDeferred(err, finalizeUpdate(tmpFile, flt, ok, name, rnum, cs))
+		err = errors.WithDeferred(err, d.finalizeUpdate(tmpFile, flt, ok, name, rnum, cs))
 		ok = ok && err == nil
 		if ok {
 			log.Printf("updated filter %d: %d bytes, %d rules", flt.ID, n, rnum)
@@ -638,7 +628,7 @@ func (f *Filtering) updateIntl(flt *filter) (ok bool, err error) {
 		r = file
 	} else {
 		var resp *http.Response
-		resp, err = Context.client.Get(flt.URL)
+		resp, err = d.HTTPClient.Get(flt.URL)
 		if err != nil {
 			log.Printf("requesting filter from %s, skip: %s", flt.URL, err)
 
@@ -655,16 +645,16 @@ func (f *Filtering) updateIntl(flt *filter) (ok bool, err error) {
 		r = resp.Body
 	}
 
-	name, rnum, cs, n, err = f.processUpdate(r, tmpFile, flt)
+	name, rnum, cs, n, err = d.processUpdate(r, tmpFile, flt)
 
 	return cs != flt.checksum, err
 }
 
 // loads filter contents from the file in dataDir
-func (f *Filtering) load(filter *filter) (err error) {
-	filterFilePath := filter.Path()
+func (d *DNSFilter) load(filter *FilterYAML) (err error) {
+	filterFilePath := filter.Path(d.DataDir)
 
-	log.Tracef("filtering: loading filter %d contents to: %s", filter.ID, filterFilePath)
+	log.Tracef("filtering: loading filter %d from %s", filter.ID, filterFilePath)
 
 	file, err := os.Open(filterFilePath)
 	if errors.Is(err, os.ErrNotExist) {
@@ -682,7 +672,7 @@ func (f *Filtering) load(filter *filter) (err error) {
 
 	log.Tracef("filtering: File %s, id %d, length %d", filterFilePath, filter.ID, st.Size())
 
-	rulesCount, checksum, _ := f.parseFilterContents(file)
+	rulesCount, checksum, _ := d.parseFilterContents(file)
 
 	filter.RulesCount = rulesCount
 	filter.checksum = checksum
@@ -691,56 +681,45 @@ func (f *Filtering) load(filter *filter) (err error) {
 	return nil
 }
 
-// Clear filter rules
-func (filter *filter) unload() {
-	filter.RulesCount = 0
-	filter.checksum = 0
+func (d *DNSFilter) EnableFilters(async bool) {
+	d.filtersMu.RLock()
+	defer d.filtersMu.RUnlock()
+
+	d.enableFiltersLocked(async)
 }
 
-// Path to the filter contents
-func (filter *filter) Path() string {
-	return filepath.Join(Context.getDataDir(), filterDir, strconv.FormatInt(filter.ID, 10)+".txt")
-}
-
-func enableFilters(async bool) {
-	config.RLock()
-	defer config.RUnlock()
-
-	enableFiltersLocked(async)
-}
-
-func enableFiltersLocked(async bool) {
-	filters := []filtering.Filter{{
-		ID:   filtering.CustomListID,
-		Data: []byte(strings.Join(config.UserRules, "\n")),
+func (d *DNSFilter) enableFiltersLocked(async bool) {
+	filters := []Filter{{
+		ID:   CustomListID,
+		Data: []byte(strings.Join(d.UserRules, "\n")),
 	}}
 
-	for _, filter := range config.Filters {
+	for _, filter := range d.Filters {
 		if !filter.Enabled {
 			continue
 		}
 
-		filters = append(filters, filtering.Filter{
+		filters = append(filters, Filter{
 			ID:       filter.ID,
-			FilePath: filter.Path(),
+			FilePath: filter.Path(d.DataDir),
 		})
 	}
 
-	var allowFilters []filtering.Filter
-	for _, filter := range config.WhitelistFilters {
+	var allowFilters []Filter
+	for _, filter := range d.WhitelistFilters {
 		if !filter.Enabled {
 			continue
 		}
 
-		allowFilters = append(allowFilters, filtering.Filter{
+		allowFilters = append(allowFilters, Filter{
 			ID:       filter.ID,
-			FilePath: filter.Path(),
+			FilePath: filter.Path(d.DataDir),
 		})
 	}
 
-	if err := Context.dnsFilter.SetFilters(filters, allowFilters, async); err != nil {
+	if err := d.SetFilters(filters, allowFilters, async); err != nil {
 		log.Debug("enabling filters: %s", err)
 	}
 
-	Context.dnsFilter.SetEnabled(config.DNS.FilteringEnabled)
+	d.SetEnabled(d.FilteringEnabled)
 }
diff --git a/internal/home/filter_test.go b/internal/filtering/filter_test.go
similarity index 83%
rename from internal/home/filter_test.go
rename to internal/filtering/filter_test.go
index 08290562..b37dd10e 100644
--- a/internal/home/filter_test.go
+++ b/internal/filtering/filter_test.go
@@ -1,4 +1,4 @@
-package home
+package filtering
 
 import (
 	"io/fs"
@@ -51,15 +51,17 @@ func TestFilters(t *testing.T) {
 
 	l := testStartFilterListener(t, &fltContent)
 
-	Context = homeContext{
-		workDir: t.TempDir(),
-		client: &http.Client{
+	tempDir := t.TempDir()
+
+	filters, err := New(&Config{
+		DataDir: tempDir,
+		HTTPClient: &http.Client{
 			Timeout: 5 * time.Second,
 		},
-	}
-	Context.filters.Init()
+	}, nil)
+	require.NoError(t, err)
 
-	f := &filter{
+	f := &FilterYAML{
 		URL: (&url.URL{
 			Scheme: "http",
 			Host: (&netutil.IPPort{
@@ -71,21 +73,22 @@ func TestFilters(t *testing.T) {
 	}
 
 	updateAndAssert := func(t *testing.T, want require.BoolAssertionFunc, wantRulesCount int) {
-		ok, err := Context.filters.update(f)
+		var ok bool
+		ok, err = filters.update(f)
 		require.NoError(t, err)
 		want(t, ok)
 
 		assert.Equal(t, wantRulesCount, f.RulesCount)
 
 		var dir []fs.DirEntry
-		dir, err = os.ReadDir(filepath.Join(Context.getDataDir(), filterDir))
+		dir, err = os.ReadDir(filepath.Join(tempDir, filterDir))
 		require.NoError(t, err)
 
 		assert.Len(t, dir, 1)
 
-		require.FileExists(t, f.Path())
+		require.FileExists(t, f.Path(tempDir))
 
-		err = Context.filters.load(f)
+		err = filters.load(f)
 		require.NoError(t, err)
 	}
 
@@ -105,11 +108,9 @@ func TestFilters(t *testing.T) {
 	})
 
 	t.Run("load_unload", func(t *testing.T) {
-		err := Context.filters.load(f)
+		err = filters.load(f)
 		require.NoError(t, err)
 
 		f.unload()
 	})
-
-	require.NoError(t, os.Remove(f.Path()))
 }
diff --git a/internal/filtering/filtering.go b/internal/filtering/filtering.go
index 446ad4ac..ab884056 100644
--- a/internal/filtering/filtering.go
+++ b/internal/filtering/filtering.go
@@ -6,7 +6,10 @@ import (
 	"fmt"
 	"io/fs"
 	"net"
+	"net/http"
 	"os"
+	"path/filepath"
+	"regexp"
 	"runtime"
 	"runtime/debug"
 	"strings"
@@ -24,6 +27,7 @@ import (
 	"github.com/AdguardTeam/urlfilter/filterlist"
 	"github.com/AdguardTeam/urlfilter/rules"
 	"github.com/miekg/dns"
+	"golang.org/x/exp/slices"
 )
 
 // The IDs of built-in filter lists.
@@ -69,8 +73,13 @@ type Config struct {
 	// enabled is used to be returned within Settings.
 	//
 	// It is of type uint32 to be accessed by atomic.
+	//
+	// TODO(e.burkov):  Use atomic.Bool in Go 1.19.
 	enabled uint32
 
+	FilteringEnabled           bool   `yaml:"filtering_enabled"`       // whether or not use filter lists
+	FiltersUpdateIntervalHours uint32 `yaml:"filters_update_interval"` // time period to update filters (in hours)
+
 	ParentalEnabled     bool `yaml:"parental_enabled"`
 	SafeSearchEnabled   bool `yaml:"safesearch_enabled"`
 	SafeBrowsingEnabled bool `yaml:"safebrowsing_enabled"`
@@ -98,6 +107,24 @@ type Config struct {
 
 	// CustomResolver is the resolver used by DNSFilter.
 	CustomResolver Resolver `yaml:"-"`
+
+	// HTTPClient is the client to use for updating the remote filters.
+	HTTPClient *http.Client `yaml:"-"`
+
+	// DataDir is used to store filters' contents.
+	DataDir string `yaml:"-"`
+
+	// filtersMu protects filter lists.
+	filtersMu *sync.RWMutex
+
+	// Filters are the blocking filter lists.
+	Filters []FilterYAML `yaml:"-"`
+
+	// WhitelistFilters are the allowing filter lists.
+	WhitelistFilters []FilterYAML `yaml:"-"`
+
+	// UserRules is the global list of custom rules.
+	UserRules []string `yaml:"-"`
 }
 
 // LookupStats store stats collected during safebrowsing or parental checks
@@ -128,11 +155,13 @@ type hostChecker struct {
 
 // DNSFilter matches hostnames and DNS requests against filtering rules.
 type DNSFilter struct {
-	rulesStorage         *filterlist.RuleStorage
-	filteringEngine      *urlfilter.DNSEngine
+	rulesStorage    *filterlist.RuleStorage
+	filteringEngine *urlfilter.DNSEngine
+
 	rulesStorageAllow    *filterlist.RuleStorage
 	filteringEngineAllow *urlfilter.DNSEngine
-	engineLock           sync.RWMutex
+
+	engineLock sync.RWMutex
 
 	parentalServer       string // access via methods
 	safeBrowsingServer   string // access via methods
@@ -156,6 +185,12 @@ type DNSFilter struct {
 	// TODO(e.burkov): Use upstream that configured in dnsforward instead.
 	resolver Resolver
 
+	refreshLock *sync.Mutex
+
+	// filterTitleRegexp is the regular expression to retrieve a name of a
+	// filter list.
+	filterTitleRegexp *regexp.Regexp
+
 	hostCheckers []hostChecker
 }
 
@@ -168,7 +203,7 @@ type Filter struct {
 	Data []byte `yaml:"-"`
 
 	// ID is automatically assigned when filter is added using nextFilterID.
-	ID int64
+	ID int64 `yaml:"id"`
 }
 
 // Reason holds an enum detailing why it was filtered or not filtered
@@ -245,15 +280,7 @@ func (r Reason) String() string {
 }
 
 // In returns true if reasons include r.
-func (r Reason) In(reasons ...Reason) (ok bool) {
-	for _, reason := range reasons {
-		if r == reason {
-			return true
-		}
-	}
-
-	return false
-}
+func (r Reason) In(reasons ...Reason) (ok bool) { return slices.Contains(reasons, r) }
 
 // SetEnabled sets the status of the *DNSFilter.
 func (d *DNSFilter) SetEnabled(enabled bool) {
@@ -261,6 +288,7 @@ func (d *DNSFilter) SetEnabled(enabled bool) {
 	if enabled {
 		i = 1
 	}
+
 	atomic.StoreUint32(&d.enabled, uint32(i))
 }
 
@@ -279,11 +307,20 @@ func (d *DNSFilter) GetConfig() (s Settings) {
 
 // WriteDiskConfig - write configuration
 func (d *DNSFilter) WriteDiskConfig(c *Config) {
-	d.confLock.Lock()
-	defer d.confLock.Unlock()
+	func() {
+		d.confLock.Lock()
+		defer d.confLock.Unlock()
 
-	*c = d.Config
-	c.Rewrites = cloneRewrites(c.Rewrites)
+		*c = d.Config
+		c.Rewrites = cloneRewrites(c.Rewrites)
+	}()
+
+	d.filtersMu.RLock()
+	defer d.filtersMu.RUnlock()
+
+	c.Filters = slices.Clone(d.Filters)
+	c.WhitelistFilters = slices.Clone(d.WhitelistFilters)
+	c.UserRules = slices.Clone(d.UserRules)
 }
 
 // cloneRewrites returns a deep copy of entries.
@@ -309,6 +346,8 @@ func (d *DNSFilter) SetFilters(blockFilters, allowFilters []Filter, async bool)
 		}
 
 		d.filtersInitializerLock.Lock() // prevent multiple writers from adding more than 1 task
+		defer d.filtersInitializerLock.Unlock()
+
 		// remove all pending tasks
 		stop := false
 		for !stop {
@@ -321,7 +360,6 @@ func (d *DNSFilter) SetFilters(blockFilters, allowFilters []Filter, async bool)
 		}
 
 		d.filtersInitializerChan <- params
-		d.filtersInitializerLock.Unlock()
 		return nil
 	}
 
@@ -350,22 +388,19 @@ func (d *DNSFilter) filtersInitializer() {
 func (d *DNSFilter) Close() {
 	d.engineLock.Lock()
 	defer d.engineLock.Unlock()
+
 	d.reset()
 }
 
 func (d *DNSFilter) reset() {
-	var err error
-
 	if d.rulesStorage != nil {
-		err = d.rulesStorage.Close()
-		if err != nil {
+		if err := d.rulesStorage.Close(); err != nil {
 			log.Error("filtering: rulesStorage.Close: %s", err)
 		}
 	}
 
 	if d.rulesStorageAllow != nil {
-		err = d.rulesStorageAllow.Close()
-		if err != nil {
+		if err := d.rulesStorageAllow.Close(); err != nil {
 			log.Error("filtering: rulesStorageAllow.Close: %s", err)
 		}
 	}
@@ -885,29 +920,30 @@ func InitModule() {
 	initBlockedServices()
 }
 
-// New creates properly initialized DNS Filter that is ready to be used.
-func New(c *Config, blockFilters []Filter) (d *DNSFilter) {
+// New creates properly initialized DNS Filter that is ready to be used.  c must
+// be non-nil.
+func New(c *Config, blockFilters []Filter) (d *DNSFilter, err error) {
 	d = &DNSFilter{
-		resolver: net.DefaultResolver,
+		resolver:          net.DefaultResolver,
+		refreshLock:       &sync.Mutex{},
+		filterTitleRegexp: regexp.MustCompile(`^! Title: +(.*)$`),
 	}
-	if c != nil {
 
-		d.safebrowsingCache = cache.New(cache.Config{
-			EnableLRU: true,
-			MaxSize:   c.SafeBrowsingCacheSize,
-		})
-		d.safeSearchCache = cache.New(cache.Config{
-			EnableLRU: true,
-			MaxSize:   c.SafeSearchCacheSize,
-		})
-		d.parentalCache = cache.New(cache.Config{
-			EnableLRU: true,
-			MaxSize:   c.ParentalCacheSize,
-		})
+	d.safebrowsingCache = cache.New(cache.Config{
+		EnableLRU: true,
+		MaxSize:   c.SafeBrowsingCacheSize,
+	})
+	d.safeSearchCache = cache.New(cache.Config{
+		EnableLRU: true,
+		MaxSize:   c.SafeSearchCacheSize,
+	})
+	d.parentalCache = cache.New(cache.Config{
+		EnableLRU: true,
+		MaxSize:   c.ParentalCacheSize,
+	})
 
-		if c.CustomResolver != nil {
-			d.resolver = c.CustomResolver
-		}
+	if r := c.CustomResolver; r != nil {
+		d.resolver = r
 	}
 
 	d.hostCheckers = []hostChecker{{
@@ -930,27 +966,26 @@ func New(c *Config, blockFilters []Filter) (d *DNSFilter) {
 		name:  "safe search",
 	}}
 
-	err := d.initSecurityServices()
-	if err != nil {
-		log.Error("filtering: initialize services: %s", err)
+	defer func() { err = errors.Annotate(err, "filtering: %w") }()
 
-		return nil
+	err = d.initSecurityServices()
+	if err != nil {
+		return nil, fmt.Errorf("initializing services: %s", err)
 	}
 
-	if c != nil {
-		d.Config = *c
-		err = d.prepareRewrites()
-		if err != nil {
-			log.Error("rewrites: preparing: %s", err)
+	d.Config = *c
+	d.filtersMu = &sync.RWMutex{}
 
-			return nil
-		}
+	err = d.prepareRewrites()
+	if err != nil {
+		return nil, fmt.Errorf("rewrites: preparing: %s", err)
 	}
 
 	bsvcs := []string{}
 	for _, s := range d.BlockedServices {
 		if !BlockedSvcKnown(s) {
 			log.Debug("skipping unknown blocked-service %q", s)
+
 			continue
 		}
 		bsvcs = append(bsvcs, s)
@@ -960,13 +995,24 @@ func New(c *Config, blockFilters []Filter) (d *DNSFilter) {
 	if blockFilters != nil {
 		err = d.initFiltering(nil, blockFilters)
 		if err != nil {
-			log.Error("Can't initialize filtering subsystem: %s", err)
 			d.Close()
-			return nil
+
+			return nil, fmt.Errorf("initializing filtering subsystem: %s", err)
 		}
 	}
 
-	return d
+	_ = os.MkdirAll(filepath.Join(d.DataDir, filterDir), 0o755)
+
+	d.loadFilters(d.Filters)
+	d.loadFilters(d.WhitelistFilters)
+
+	d.Filters = deduplicateFilters(d.Filters)
+	d.WhitelistFilters = deduplicateFilters(d.WhitelistFilters)
+
+	updateUniqueFilterID(d.Filters)
+	updateUniqueFilterID(d.WhitelistFilters)
+
+	return d, nil
 }
 
 // Start - start the module:
@@ -976,9 +1022,10 @@ func (d *DNSFilter) Start() {
 	d.filtersInitializerChan = make(chan filtersInitializerParams, 1)
 	go d.filtersInitializer()
 
-	if d.Config.HTTPRegister != nil { // for tests
-		d.registerSecurityHandlers()
-		d.registerRewritesHandlers()
-		d.registerBlockedServicesHandlers()
-	}
+	d.RegisterFilteringHandlers()
+
+	// Here we should start updating filters,
+	//  but currently we can't wake up the periodic task to do so.
+	// So for now we just start this periodic task from here.
+	go d.periodicallyRefreshFilters()
 }
diff --git a/internal/filtering/filtering_test.go b/internal/filtering/filtering_test.go
index 95554b07..4fc9182d 100644
--- a/internal/filtering/filtering_test.go
+++ b/internal/filtering/filtering_test.go
@@ -26,10 +26,6 @@ const (
 	pcBlocked = "pornhub.com"
 )
 
-var setts = Settings{
-	ProtectionEnabled: true,
-}
-
 // Helpers.
 
 func purgeCaches(d *DNSFilter) {
@@ -44,8 +40,8 @@ func purgeCaches(d *DNSFilter) {
 	}
 }
 
-func newForTest(t testing.TB, c *Config, filters []Filter) *DNSFilter {
-	setts = Settings{
+func newForTest(t testing.TB, c *Config, filters []Filter) (f *DNSFilter, setts *Settings) {
+	setts = &Settings{
 		ProtectionEnabled: true,
 		FilteringEnabled:  true,
 	}
@@ -57,26 +53,31 @@ func newForTest(t testing.TB, c *Config, filters []Filter) *DNSFilter {
 		setts.SafeSearchEnabled = c.SafeSearchEnabled
 		setts.SafeBrowsingEnabled = c.SafeBrowsingEnabled
 		setts.ParentalEnabled = c.ParentalEnabled
+	} else {
+		// It must not be nil.
+		c = &Config{}
 	}
-	d := New(c, filters)
-	purgeCaches(d)
+	f, err := New(c, filters)
+	require.NoError(t, err)
 
-	return d
+	purgeCaches(f)
+
+	return f, setts
 }
 
-func (d *DNSFilter) checkMatch(t *testing.T, hostname string) {
+func (d *DNSFilter) checkMatch(t *testing.T, hostname string, setts *Settings) {
 	t.Helper()
 
-	res, err := d.CheckHost(hostname, dns.TypeA, &setts)
+	res, err := d.CheckHost(hostname, dns.TypeA, setts)
 	require.NoErrorf(t, err, "host %q", hostname)
 
 	assert.Truef(t, res.IsFiltered, "host %q", hostname)
 }
 
-func (d *DNSFilter) checkMatchIP(t *testing.T, hostname, ip string, qtype uint16) {
+func (d *DNSFilter) checkMatchIP(t *testing.T, hostname, ip string, qtype uint16, setts *Settings) {
 	t.Helper()
 
-	res, err := d.CheckHost(hostname, qtype, &setts)
+	res, err := d.CheckHost(hostname, qtype, setts)
 	require.NoErrorf(t, err, "host %q", hostname, err)
 	require.NotEmpty(t, res.Rules, "host %q", hostname)
 
@@ -88,10 +89,10 @@ func (d *DNSFilter) checkMatchIP(t *testing.T, hostname, ip string, qtype uint16
 	assert.Equalf(t, ip, r.IP.String(), "host %q", hostname)
 }
 
-func (d *DNSFilter) checkMatchEmpty(t *testing.T, hostname string) {
+func (d *DNSFilter) checkMatchEmpty(t *testing.T, hostname string, setts *Settings) {
 	t.Helper()
 
-	res, err := d.CheckHost(hostname, dns.TypeA, &setts)
+	res, err := d.CheckHost(hostname, dns.TypeA, setts)
 	require.NoErrorf(t, err, "host %q", hostname)
 
 	assert.Falsef(t, res.IsFiltered, "host %q", hostname)
@@ -111,19 +112,19 @@ func TestEtcHostsMatching(t *testing.T) {
 	filters := []Filter{{
 		ID: 0, Data: []byte(text),
 	}}
-	d := newForTest(t, nil, filters)
+	d, setts := newForTest(t, nil, filters)
 	t.Cleanup(d.Close)
 
-	d.checkMatchIP(t, "google.com", addr, dns.TypeA)
-	d.checkMatchIP(t, "www.google.com", addr, dns.TypeA)
-	d.checkMatchEmpty(t, "subdomain.google.com")
-	d.checkMatchEmpty(t, "example.org")
+	d.checkMatchIP(t, "google.com", addr, dns.TypeA, setts)
+	d.checkMatchIP(t, "www.google.com", addr, dns.TypeA, setts)
+	d.checkMatchEmpty(t, "subdomain.google.com", setts)
+	d.checkMatchEmpty(t, "example.org", setts)
 
 	// IPv4 match.
-	d.checkMatchIP(t, "block.com", "0.0.0.0", dns.TypeA)
+	d.checkMatchIP(t, "block.com", "0.0.0.0", dns.TypeA, setts)
 
 	// Empty IPv6.
-	res, err := d.CheckHost("block.com", dns.TypeAAAA, &setts)
+	res, err := d.CheckHost("block.com", dns.TypeAAAA, setts)
 	require.NoError(t, err)
 
 	assert.True(t, res.IsFiltered)
@@ -134,10 +135,10 @@ func TestEtcHostsMatching(t *testing.T) {
 	assert.Empty(t, res.Rules[0].IP)
 
 	// IPv6 match.
-	d.checkMatchIP(t, "ipv6.com", addr6, dns.TypeAAAA)
+	d.checkMatchIP(t, "ipv6.com", addr6, dns.TypeAAAA, setts)
 
 	// Empty IPv4.
-	res, err = d.CheckHost("ipv6.com", dns.TypeA, &setts)
+	res, err = d.CheckHost("ipv6.com", dns.TypeA, setts)
 	require.NoError(t, err)
 
 	assert.True(t, res.IsFiltered)
@@ -148,7 +149,7 @@ func TestEtcHostsMatching(t *testing.T) {
 	assert.Empty(t, res.Rules[0].IP)
 
 	// Two IPv4, both must be returned.
-	res, err = d.CheckHost("host2", dns.TypeA, &setts)
+	res, err = d.CheckHost("host2", dns.TypeA, setts)
 	require.NoError(t, err)
 
 	assert.True(t, res.IsFiltered)
@@ -159,7 +160,7 @@ func TestEtcHostsMatching(t *testing.T) {
 	assert.Equal(t, res.Rules[1].IP, net.IP{0, 0, 0, 2})
 
 	// One IPv6 address.
-	res, err = d.CheckHost("host2", dns.TypeAAAA, &setts)
+	res, err = d.CheckHost("host2", dns.TypeAAAA, setts)
 	require.NoError(t, err)
 
 	assert.True(t, res.IsFiltered)
@@ -176,27 +177,27 @@ func TestSafeBrowsing(t *testing.T) {
 	aghtest.ReplaceLogWriter(t, logOutput)
 	aghtest.ReplaceLogLevel(t, log.DEBUG)
 
-	d := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil)
+	d, setts := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil)
 	t.Cleanup(d.Close)
 
 	d.SetSafeBrowsingUpstream(aghtest.NewBlockUpstream(sbBlocked, true))
-	d.checkMatch(t, sbBlocked)
+	d.checkMatch(t, sbBlocked, setts)
 
 	require.Contains(t, logOutput.String(), fmt.Sprintf("safebrowsing lookup for %q", sbBlocked))
 
-	d.checkMatch(t, "test."+sbBlocked)
-	d.checkMatchEmpty(t, "yandex.ru")
-	d.checkMatchEmpty(t, pcBlocked)
+	d.checkMatch(t, "test."+sbBlocked, setts)
+	d.checkMatchEmpty(t, "yandex.ru", setts)
+	d.checkMatchEmpty(t, pcBlocked, setts)
 
 	// Cached result.
 	d.safeBrowsingServer = "127.0.0.1"
-	d.checkMatch(t, sbBlocked)
-	d.checkMatchEmpty(t, pcBlocked)
+	d.checkMatch(t, sbBlocked, setts)
+	d.checkMatchEmpty(t, pcBlocked, setts)
 	d.safeBrowsingServer = defaultSafebrowsingServer
 }
 
 func TestParallelSB(t *testing.T) {
-	d := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil)
+	d, setts := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil)
 	t.Cleanup(d.Close)
 
 	d.SetSafeBrowsingUpstream(aghtest.NewBlockUpstream(sbBlocked, true))
@@ -205,10 +206,10 @@ func TestParallelSB(t *testing.T) {
 		for i := 0; i < 100; i++ {
 			t.Run(fmt.Sprintf("aaa%d", i), func(t *testing.T) {
 				t.Parallel()
-				d.checkMatch(t, sbBlocked)
-				d.checkMatch(t, "test."+sbBlocked)
-				d.checkMatchEmpty(t, "yandex.ru")
-				d.checkMatchEmpty(t, pcBlocked)
+				d.checkMatch(t, sbBlocked, setts)
+				d.checkMatch(t, "test."+sbBlocked, setts)
+				d.checkMatchEmpty(t, "yandex.ru", setts)
+				d.checkMatchEmpty(t, pcBlocked, setts)
 			})
 		}
 	})
@@ -217,7 +218,7 @@ func TestParallelSB(t *testing.T) {
 // Safe Search.
 
 func TestSafeSearch(t *testing.T) {
-	d := newForTest(t, &Config{SafeSearchEnabled: true}, nil)
+	d, _ := newForTest(t, &Config{SafeSearchEnabled: true}, nil)
 	t.Cleanup(d.Close)
 	val, ok := d.SafeSearchDomain("www.google.com")
 	require.True(t, ok)
@@ -226,7 +227,7 @@ func TestSafeSearch(t *testing.T) {
 }
 
 func TestCheckHostSafeSearchYandex(t *testing.T) {
-	d := newForTest(t, &Config{
+	d, setts := newForTest(t, &Config{
 		SafeSearchEnabled: true,
 	}, nil)
 	t.Cleanup(d.Close)
@@ -243,7 +244,7 @@ func TestCheckHostSafeSearchYandex(t *testing.T) {
 		"www.yandex.com",
 	} {
 		t.Run(strings.ToLower(host), func(t *testing.T) {
-			res, err := d.CheckHost(host, dns.TypeA, &setts)
+			res, err := d.CheckHost(host, dns.TypeA, setts)
 			require.NoError(t, err)
 
 			assert.True(t, res.IsFiltered)
@@ -258,7 +259,7 @@ func TestCheckHostSafeSearchYandex(t *testing.T) {
 
 func TestCheckHostSafeSearchGoogle(t *testing.T) {
 	resolver := &aghtest.TestResolver{}
-	d := newForTest(t, &Config{
+	d, setts := newForTest(t, &Config{
 		SafeSearchEnabled: true,
 		CustomResolver:    resolver,
 	}, nil)
@@ -277,7 +278,7 @@ func TestCheckHostSafeSearchGoogle(t *testing.T) {
 		"www.google.je",
 	} {
 		t.Run(host, func(t *testing.T) {
-			res, err := d.CheckHost(host, dns.TypeA, &setts)
+			res, err := d.CheckHost(host, dns.TypeA, setts)
 			require.NoError(t, err)
 
 			assert.True(t, res.IsFiltered)
@@ -291,12 +292,12 @@ func TestCheckHostSafeSearchGoogle(t *testing.T) {
 }
 
 func TestSafeSearchCacheYandex(t *testing.T) {
-	d := newForTest(t, nil, nil)
+	d, setts := newForTest(t, nil, nil)
 	t.Cleanup(d.Close)
 	const domain = "yandex.ru"
 
 	// Check host with disabled safesearch.
-	res, err := d.CheckHost(domain, dns.TypeA, &setts)
+	res, err := d.CheckHost(domain, dns.TypeA, setts)
 	require.NoError(t, err)
 
 	assert.False(t, res.IsFiltered)
@@ -305,10 +306,10 @@ func TestSafeSearchCacheYandex(t *testing.T) {
 
 	yandexIP := net.IPv4(213, 180, 193, 56)
 
-	d = newForTest(t, &Config{SafeSearchEnabled: true}, nil)
+	d, setts = newForTest(t, &Config{SafeSearchEnabled: true}, nil)
 	t.Cleanup(d.Close)
 
-	res, err = d.CheckHost(domain, dns.TypeA, &setts)
+	res, err = d.CheckHost(domain, dns.TypeA, setts)
 	require.NoError(t, err)
 
 	// For yandex we already know valid IP.
@@ -325,20 +326,20 @@ func TestSafeSearchCacheYandex(t *testing.T) {
 
 func TestSafeSearchCacheGoogle(t *testing.T) {
 	resolver := &aghtest.TestResolver{}
-	d := newForTest(t, &Config{
+	d, setts := newForTest(t, &Config{
 		CustomResolver: resolver,
 	}, nil)
 	t.Cleanup(d.Close)
 
 	const domain = "www.google.ru"
-	res, err := d.CheckHost(domain, dns.TypeA, &setts)
+	res, err := d.CheckHost(domain, dns.TypeA, setts)
 	require.NoError(t, err)
 
 	assert.False(t, res.IsFiltered)
 
 	require.Empty(t, res.Rules)
 
-	d = newForTest(t, &Config{SafeSearchEnabled: true}, nil)
+	d, setts = newForTest(t, &Config{SafeSearchEnabled: true}, nil)
 	t.Cleanup(d.Close)
 	d.resolver = resolver
 
@@ -358,7 +359,7 @@ func TestSafeSearchCacheGoogle(t *testing.T) {
 		}
 	}
 
-	res, err = d.CheckHost(domain, dns.TypeA, &setts)
+	res, err = d.CheckHost(domain, dns.TypeA, setts)
 	require.NoError(t, err)
 	require.Len(t, res.Rules, 1)
 
@@ -379,22 +380,22 @@ func TestParentalControl(t *testing.T) {
 	aghtest.ReplaceLogWriter(t, logOutput)
 	aghtest.ReplaceLogLevel(t, log.DEBUG)
 
-	d := newForTest(t, &Config{ParentalEnabled: true}, nil)
+	d, setts := newForTest(t, &Config{ParentalEnabled: true}, nil)
 	t.Cleanup(d.Close)
 
 	d.SetParentalUpstream(aghtest.NewBlockUpstream(pcBlocked, true))
-	d.checkMatch(t, pcBlocked)
+	d.checkMatch(t, pcBlocked, setts)
 	require.Contains(t, logOutput.String(), fmt.Sprintf("parental lookup for %q", pcBlocked))
 
-	d.checkMatch(t, "www."+pcBlocked)
-	d.checkMatchEmpty(t, "www.yandex.ru")
-	d.checkMatchEmpty(t, "yandex.ru")
-	d.checkMatchEmpty(t, "api.jquery.com")
+	d.checkMatch(t, "www."+pcBlocked, setts)
+	d.checkMatchEmpty(t, "www.yandex.ru", setts)
+	d.checkMatchEmpty(t, "yandex.ru", setts)
+	d.checkMatchEmpty(t, "api.jquery.com", setts)
 
 	// Test cached result.
 	d.parentalServer = "127.0.0.1"
-	d.checkMatch(t, pcBlocked)
-	d.checkMatchEmpty(t, "yandex.ru")
+	d.checkMatch(t, pcBlocked, setts)
+	d.checkMatchEmpty(t, "yandex.ru", setts)
 }
 
 // Filtering.
@@ -679,10 +680,10 @@ func TestMatching(t *testing.T) {
 	for _, tc := range testCases {
 		t.Run(fmt.Sprintf("%s-%s", tc.name, tc.host), func(t *testing.T) {
 			filters := []Filter{{ID: 0, Data: []byte(tc.rules)}}
-			d := newForTest(t, nil, filters)
+			d, setts := newForTest(t, nil, filters)
 			t.Cleanup(d.Close)
 
-			res, err := d.CheckHost(tc.host, tc.wantDNSType, &setts)
+			res, err := d.CheckHost(tc.host, tc.wantDNSType, setts)
 			require.NoError(t, err)
 
 			assert.Equalf(t, tc.wantIsFiltered, res.IsFiltered, "Hostname %s has wrong result (%v must be %v)", tc.host, res.IsFiltered, tc.wantIsFiltered)
@@ -705,7 +706,7 @@ func TestWhitelist(t *testing.T) {
 	whiteFilters := []Filter{{
 		ID: 0, Data: []byte(whiteRules),
 	}}
-	d := newForTest(t, nil, filters)
+	d, setts := newForTest(t, nil, filters)
 
 	err := d.SetFilters(filters, whiteFilters, false)
 	require.NoError(t, err)
@@ -713,7 +714,7 @@ func TestWhitelist(t *testing.T) {
 	t.Cleanup(d.Close)
 
 	// Matched by white filter.
-	res, err := d.CheckHost("host1", dns.TypeA, &setts)
+	res, err := d.CheckHost("host1", dns.TypeA, setts)
 	require.NoError(t, err)
 
 	assert.False(t, res.IsFiltered)
@@ -724,7 +725,7 @@ func TestWhitelist(t *testing.T) {
 	assert.Equal(t, "||host1^", res.Rules[0].Text)
 
 	// Not matched by white filter, but matched by block filter.
-	res, err = d.CheckHost("host2", dns.TypeA, &setts)
+	res, err = d.CheckHost("host2", dns.TypeA, setts)
 	require.NoError(t, err)
 
 	assert.True(t, res.IsFiltered)
@@ -750,7 +751,7 @@ func applyClientSettings(setts *Settings) {
 }
 
 func TestClientSettings(t *testing.T) {
-	d := newForTest(t,
+	d, setts := newForTest(t,
 		&Config{
 			ParentalEnabled:     true,
 			SafeBrowsingEnabled: false,
@@ -796,7 +797,7 @@ func TestClientSettings(t *testing.T) {
 		return func(t *testing.T) {
 			t.Helper()
 
-			r, err := d.CheckHost(tc.host, dns.TypeA, &setts)
+			r, err := d.CheckHost(tc.host, dns.TypeA, setts)
 			require.NoError(t, err)
 
 			if before {
@@ -814,7 +815,7 @@ func TestClientSettings(t *testing.T) {
 		t.Run(tc.name, makeTester(tc, tc.before))
 	}
 
-	applyClientSettings(&setts)
+	applyClientSettings(setts)
 
 	for _, tc := range testCases {
 		t.Run(tc.name, makeTester(tc, !tc.before))
@@ -824,13 +825,13 @@ func TestClientSettings(t *testing.T) {
 // Benchmarks.
 
 func BenchmarkSafeBrowsing(b *testing.B) {
-	d := newForTest(b, &Config{SafeBrowsingEnabled: true}, nil)
+	d, setts := newForTest(b, &Config{SafeBrowsingEnabled: true}, nil)
 	b.Cleanup(d.Close)
 
 	d.SetSafeBrowsingUpstream(aghtest.NewBlockUpstream(sbBlocked, true))
 
 	for n := 0; n < b.N; n++ {
-		res, err := d.CheckHost(sbBlocked, dns.TypeA, &setts)
+		res, err := d.CheckHost(sbBlocked, dns.TypeA, setts)
 		require.NoError(b, err)
 
 		assert.Truef(b, res.IsFiltered, "expected hostname %q to match", sbBlocked)
@@ -838,14 +839,14 @@ func BenchmarkSafeBrowsing(b *testing.B) {
 }
 
 func BenchmarkSafeBrowsingParallel(b *testing.B) {
-	d := newForTest(b, &Config{SafeBrowsingEnabled: true}, nil)
+	d, setts := newForTest(b, &Config{SafeBrowsingEnabled: true}, nil)
 	b.Cleanup(d.Close)
 
 	d.SetSafeBrowsingUpstream(aghtest.NewBlockUpstream(sbBlocked, true))
 
 	b.RunParallel(func(pb *testing.PB) {
 		for pb.Next() {
-			res, err := d.CheckHost(sbBlocked, dns.TypeA, &setts)
+			res, err := d.CheckHost(sbBlocked, dns.TypeA, setts)
 			require.NoError(b, err)
 
 			assert.Truef(b, res.IsFiltered, "expected hostname %q to match", sbBlocked)
@@ -854,7 +855,7 @@ func BenchmarkSafeBrowsingParallel(b *testing.B) {
 }
 
 func BenchmarkSafeSearch(b *testing.B) {
-	d := newForTest(b, &Config{SafeSearchEnabled: true}, nil)
+	d, _ := newForTest(b, &Config{SafeSearchEnabled: true}, nil)
 	b.Cleanup(d.Close)
 	for n := 0; n < b.N; n++ {
 		val, ok := d.SafeSearchDomain("www.google.com")
@@ -865,7 +866,7 @@ func BenchmarkSafeSearch(b *testing.B) {
 }
 
 func BenchmarkSafeSearchParallel(b *testing.B) {
-	d := newForTest(b, &Config{SafeSearchEnabled: true}, nil)
+	d, _ := newForTest(b, &Config{SafeSearchEnabled: true}, nil)
 	b.Cleanup(d.Close)
 	b.RunParallel(func(pb *testing.PB) {
 		for pb.Next() {
diff --git a/internal/filtering/rewrites.go b/internal/filtering/rewrites.go
index c1557158..8f0d5ebf 100644
--- a/internal/filtering/rewrites.go
+++ b/internal/filtering/rewrites.go
@@ -133,34 +133,31 @@ func matchDomainWildcard(host, wildcard string) (ok bool) {
 //  1. A and AAAA > CNAME
 //  2. wildcard > exact
 //  3. lower level wildcard > higher level wildcard
+//
+// TODO(a.garipov):  Replace with slices.Sort.
 type rewritesSorted []*LegacyRewrite
 
-// Len implements the sort.Interface interface for legacyRewritesSorted.
+// Len implements the sort.Interface interface for rewritesSorted.
 func (a rewritesSorted) Len() (l int) { return len(a) }
 
-// Swap implements the sort.Interface interface for legacyRewritesSorted.
+// Swap implements the sort.Interface interface for rewritesSorted.
 func (a rewritesSorted) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
 
-// Less implements the sort.Interface interface for legacyRewritesSorted.
+// Less implements the sort.Interface interface for rewritesSorted.
 func (a rewritesSorted) Less(i, j int) (less bool) {
-	if a[i].Type == dns.TypeCNAME && a[j].Type != dns.TypeCNAME {
+	ith, jth := a[i], a[j]
+	if ith.Type == dns.TypeCNAME && jth.Type != dns.TypeCNAME {
 		return true
-	} else if a[i].Type != dns.TypeCNAME && a[j].Type == dns.TypeCNAME {
+	} else if ith.Type != dns.TypeCNAME && jth.Type == dns.TypeCNAME {
 		return false
 	}
 
-	if isWildcard(a[i].Domain) {
-		if !isWildcard(a[j].Domain) {
-			return false
-		}
-	} else {
-		if isWildcard(a[j].Domain) {
-			return true
-		}
+	if iw, jw := isWildcard(ith.Domain), isWildcard(jth.Domain); iw != jw {
+		return jw
 	}
 
-	// Both are wildcards.
-	return len(a[i].Domain) > len(a[j].Domain)
+	// Both are either wildcards or not.
+	return len(ith.Domain) > len(jth.Domain)
 }
 
 // prepareRewrites normalizes and validates all legacy DNS rewrites.
@@ -313,9 +310,3 @@ func (d *DNSFilter) handleRewriteDelete(w http.ResponseWriter, r *http.Request)
 
 	d.Config.ConfigModified()
 }
-
-func (d *DNSFilter) registerRewritesHandlers() {
-	d.Config.HTTPRegister(http.MethodGet, "/control/rewrite/list", d.handleRewriteList)
-	d.Config.HTTPRegister(http.MethodPost, "/control/rewrite/add", d.handleRewriteAdd)
-	d.Config.HTTPRegister(http.MethodPost, "/control/rewrite/delete", d.handleRewriteDelete)
-}
diff --git a/internal/filtering/rewrites_test.go b/internal/filtering/rewrites_test.go
index 5c3de110..17caa167 100644
--- a/internal/filtering/rewrites_test.go
+++ b/internal/filtering/rewrites_test.go
@@ -12,7 +12,7 @@ import (
 // TODO(e.burkov): All the tests in this file may and should me merged together.
 
 func TestRewrites(t *testing.T) {
-	d := newForTest(t, nil, nil)
+	d, _ := newForTest(t, nil, nil)
 	t.Cleanup(d.Close)
 
 	d.Rewrites = []*LegacyRewrite{{
@@ -188,7 +188,7 @@ func TestRewrites(t *testing.T) {
 }
 
 func TestRewritesLevels(t *testing.T) {
-	d := newForTest(t, nil, nil)
+	d, _ := newForTest(t, nil, nil)
 	t.Cleanup(d.Close)
 	// Exact host, wildcard L2, wildcard L3.
 	d.Rewrites = []*LegacyRewrite{{
@@ -235,7 +235,7 @@ func TestRewritesLevels(t *testing.T) {
 }
 
 func TestRewritesExceptionCNAME(t *testing.T) {
-	d := newForTest(t, nil, nil)
+	d, _ := newForTest(t, nil, nil)
 	t.Cleanup(d.Close)
 	// Wildcard and exception for a sub-domain.
 	d.Rewrites = []*LegacyRewrite{{
@@ -286,7 +286,7 @@ func TestRewritesExceptionCNAME(t *testing.T) {
 }
 
 func TestRewritesExceptionIP(t *testing.T) {
-	d := newForTest(t, nil, nil)
+	d, _ := newForTest(t, nil, nil)
 	t.Cleanup(d.Close)
 	// Exception for AAAA record.
 	d.Rewrites = []*LegacyRewrite{{
diff --git a/internal/filtering/safebrowsing.go b/internal/filtering/safebrowsing.go
index 9d1d0fa4..fe844977 100644
--- a/internal/filtering/safebrowsing.go
+++ b/internal/filtering/safebrowsing.go
@@ -415,17 +415,3 @@ func (d *DNSFilter) handleParentalStatus(w http.ResponseWriter, r *http.Request)
 		aghhttp.Error(r, w, http.StatusInternalServerError, "Unable to write response json: %s", err)
 	}
 }
-
-func (d *DNSFilter) registerSecurityHandlers() {
-	d.Config.HTTPRegister(http.MethodPost, "/control/safebrowsing/enable", d.handleSafeBrowsingEnable)
-	d.Config.HTTPRegister(http.MethodPost, "/control/safebrowsing/disable", d.handleSafeBrowsingDisable)
-	d.Config.HTTPRegister(http.MethodGet, "/control/safebrowsing/status", d.handleSafeBrowsingStatus)
-
-	d.Config.HTTPRegister(http.MethodPost, "/control/parental/enable", d.handleParentalEnable)
-	d.Config.HTTPRegister(http.MethodPost, "/control/parental/disable", d.handleParentalDisable)
-	d.Config.HTTPRegister(http.MethodGet, "/control/parental/status", d.handleParentalStatus)
-
-	d.Config.HTTPRegister(http.MethodPost, "/control/safesearch/enable", d.handleSafeSearchEnable)
-	d.Config.HTTPRegister(http.MethodPost, "/control/safesearch/disable", d.handleSafeSearchDisable)
-	d.Config.HTTPRegister(http.MethodGet, "/control/safesearch/status", d.handleSafeSearchStatus)
-}
diff --git a/internal/filtering/safebrowsing_test.go b/internal/filtering/safebrowsing_test.go
index f2cc846c..a7abf878 100644
--- a/internal/filtering/safebrowsing_test.go
+++ b/internal/filtering/safebrowsing_test.go
@@ -107,7 +107,7 @@ func TestSafeBrowsingCache(t *testing.T) {
 }
 
 func TestSBPC_checkErrorUpstream(t *testing.T) {
-	d := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil)
+	d, _ := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil)
 	t.Cleanup(d.Close)
 
 	ups := aghtest.NewErrorUpstream()
@@ -128,7 +128,7 @@ func TestSBPC_checkErrorUpstream(t *testing.T) {
 }
 
 func TestSBPC(t *testing.T) {
-	d := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil)
+	d, _ := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil)
 	t.Cleanup(d.Close)
 
 	const hostname = "example.org"
diff --git a/internal/home/config.go b/internal/home/config.go
index 47027692..ff597761 100644
--- a/internal/home/config.go
+++ b/internal/home/config.go
@@ -14,7 +14,6 @@ import (
 	"github.com/AdguardTeam/AdGuardHome/internal/filtering"
 	"github.com/AdguardTeam/AdGuardHome/internal/querylog"
 	"github.com/AdguardTeam/AdGuardHome/internal/stats"
-	"github.com/AdguardTeam/AdGuardHome/internal/version"
 	"github.com/AdguardTeam/dnsproxy/fastip"
 	"github.com/AdguardTeam/golibs/errors"
 	"github.com/AdguardTeam/golibs/log"
@@ -23,10 +22,9 @@ import (
 	yaml "gopkg.in/yaml.v3"
 )
 
-const (
-	dataDir   = "data"    // data storage
-	filterDir = "filters" // cache location for downloaded filters, it's under DataDir
-)
+// dataDir is the name of a directory under the working one to store some
+// persistent data.
+const dataDir = "data"
 
 // logSettings are the logging settings part of the configuration file.
 //
@@ -108,9 +106,16 @@ type configuration struct {
 	DNS dnsConfig         `yaml:"dns"`
 	TLS tlsConfigSettings `yaml:"tls"`
 
-	Filters          []filter `yaml:"filters"`
-	WhitelistFilters []filter `yaml:"whitelist_filters"`
-	UserRules        []string `yaml:"user_rules"`
+	// Filters reflects the filters from [filtering.Config].  It's cloned to the
+	// config used in the filtering module at the startup.  Afterwards it's
+	// cloned from the filtering module back here.
+	//
+	// TODO(e.burkov):  Move all the filtering configuration fields into the
+	// only configuration subsection covering the changes with a single
+	// migration.
+	Filters          []filtering.FilterYAML `yaml:"filters"`
+	WhitelistFilters []filtering.FilterYAML `yaml:"whitelist_filters"`
+	UserRules        []string               `yaml:"user_rules"`
 
 	DHCP *dhcpd.ServerConfig `yaml:"dhcp"`
 
@@ -145,9 +150,7 @@ type dnsConfig struct {
 
 	dnsforward.FilteringConfig `yaml:",inline"`
 
-	FilteringEnabled           bool             `yaml:"filtering_enabled"`       // whether or not use filter lists
-	FiltersUpdateIntervalHours uint32           `yaml:"filters_update_interval"` // time period to update filters (in hours)
-	DnsfilterConf              filtering.Config `yaml:",inline"`
+	DnsfilterConf *filtering.Config `yaml:",inline"`
 
 	// UpstreamTimeout is the timeout for querying upstream servers.
 	UpstreamTimeout timeutil.Duration `yaml:"upstream_timeout"`
@@ -193,15 +196,20 @@ type tlsConfigSettings struct {
 //
 // TODO(a.garipov, e.burkov): This global is awful and must be removed.
 var config = &configuration{
-	BindPort:     3000,
-	BetaBindPort: 0,
-	BindHost:     net.IP{0, 0, 0, 0},
-	AuthAttempts: 5,
-	AuthBlockMin: 15,
+	BindPort:           3000,
+	BetaBindPort:       0,
+	BindHost:           net.IP{0, 0, 0, 0},
+	AuthAttempts:       5,
+	AuthBlockMin:       15,
+	WebSessionTTLHours: 30 * 24,
 	DNS: dnsConfig{
-		BindHosts:     []net.IP{{0, 0, 0, 0}},
-		Port:          defaultPortDNS,
-		StatsInterval: 1,
+		BindHosts:           []net.IP{{0, 0, 0, 0}},
+		Port:                defaultPortDNS,
+		StatsInterval:       1,
+		QueryLogEnabled:     true,
+		QueryLogFileEnabled: true,
+		QueryLogInterval:    timeutil.Duration{Duration: 90 * timeutil.Day},
+		QueryLogMemSize:     1000,
 		FilteringConfig: dnsforward.FilteringConfig{
 			ProtectionEnabled:  true, // whether or not use any of filtering features
 			BlockingMode:       dnsforward.BlockingModeDefault,
@@ -222,18 +230,42 @@ var config = &configuration{
 			// was later increased to 300 due to https://github.com/AdguardTeam/AdGuardHome/issues/2257
 			MaxGoroutines: 300,
 		},
-		FilteringEnabled:           true, // whether or not use filter lists
-		FiltersUpdateIntervalHours: 24,
-		UpstreamTimeout:            timeutil.Duration{Duration: dnsforward.DefaultTimeout},
-		UsePrivateRDNS:             true,
+		DnsfilterConf: &filtering.Config{
+			SafeBrowsingCacheSize:      1 * 1024 * 1024,
+			SafeSearchCacheSize:        1 * 1024 * 1024,
+			ParentalCacheSize:          1 * 1024 * 1024,
+			CacheTime:                  30,
+			FilteringEnabled:           true,
+			FiltersUpdateIntervalHours: 24,
+		},
+		UpstreamTimeout: timeutil.Duration{Duration: dnsforward.DefaultTimeout},
+		UsePrivateRDNS:  true,
 	},
 	TLS: tlsConfigSettings{
 		PortHTTPS:       defaultPortHTTPS,
 		PortDNSOverTLS:  defaultPortTLS, // needs to be passed through to dnsproxy
 		PortDNSOverQUIC: defaultPortQUIC,
 	},
+	Filters: []filtering.FilterYAML{{
+		Filter:  filtering.Filter{ID: 1},
+		Enabled: true,
+		URL:     "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt",
+		Name:    "AdGuard DNS filter",
+	}, {
+		Filter:  filtering.Filter{ID: 2},
+		Enabled: false,
+		URL:     "https://adaway.org/hosts.txt",
+		Name:    "AdAway Default Blocklist",
+	}},
 	DHCP: &dhcpd.ServerConfig{
 		LocalDomainName: "lan",
+		Conf4: dhcpd.V4ServerConf{
+			LeaseDuration: dhcpd.DefaultDHCPLeaseTTL,
+			ICMPTimeout:   dhcpd.DefaultDHCPTimeoutICMP,
+		},
+		Conf6: dhcpd.V6ServerConf{
+			LeaseDuration: dhcpd.DefaultDHCPLeaseTTL,
+		},
 	},
 	Clients: &clientsConfig{
 		Sources: &clientSourcesConf{
@@ -255,31 +287,6 @@ var config = &configuration{
 	SchemaVersion: currentSchemaVersion,
 }
 
-// initConfig initializes default configuration for the current OS&ARCH
-func initConfig() {
-	config.WebSessionTTLHours = 30 * 24
-
-	config.DNS.QueryLogEnabled = true
-	config.DNS.QueryLogFileEnabled = true
-	config.DNS.QueryLogInterval = timeutil.Duration{Duration: 90 * timeutil.Day}
-	config.DNS.QueryLogMemSize = 1000
-
-	config.DNS.CacheSize = 4 * 1024 * 1024
-	config.DNS.DnsfilterConf.SafeBrowsingCacheSize = 1 * 1024 * 1024
-	config.DNS.DnsfilterConf.SafeSearchCacheSize = 1 * 1024 * 1024
-	config.DNS.DnsfilterConf.ParentalCacheSize = 1 * 1024 * 1024
-	config.DNS.DnsfilterConf.CacheTime = 30
-	config.Filters = defaultFilters()
-
-	config.DHCP.Conf4.LeaseDuration = dhcpd.DefaultDHCPLeaseTTL
-	config.DHCP.Conf4.ICMPTimeout = dhcpd.DefaultDHCPTimeoutICMP
-	config.DHCP.Conf6.LeaseDuration = dhcpd.DefaultDHCPLeaseTTL
-
-	if ch := version.Channel(); ch == version.ChannelEdge || ch == version.ChannelDevelopment {
-		config.BetaBindPort = 3001
-	}
-}
-
 // getConfigFilename returns path to the current config file
 func (c *configuration) getConfigFilename() string {
 	configFile, err := filepath.EvalSymlinks(Context.configFilename)
@@ -348,8 +355,8 @@ func parseConfig() (err error) {
 		return fmt.Errorf("validating udp ports: %w", err)
 	}
 
-	if !checkFiltersUpdateIntervalHours(config.DNS.FiltersUpdateIntervalHours) {
-		config.DNS.FiltersUpdateIntervalHours = 24
+	if !filtering.ValidateUpdateIvl(config.DNS.DnsfilterConf.FiltersUpdateIntervalHours) {
+		config.DNS.DnsfilterConf.FiltersUpdateIntervalHours = 24
 	}
 
 	if config.DNS.UpstreamTimeout.Duration == 0 {
@@ -418,10 +425,11 @@ func (c *configuration) write() (err error) {
 		config.DNS.AnonymizeClientIP = dc.AnonymizeClientIP
 	}
 
-	if Context.dnsFilter != nil {
-		c := filtering.Config{}
-		Context.dnsFilter.WriteDiskConfig(&c)
-		config.DNS.DnsfilterConf = c
+	if Context.filters != nil {
+		Context.filters.WriteDiskConfig(config.DNS.DnsfilterConf)
+		config.Filters = config.DNS.DnsfilterConf.Filters
+		config.WhitelistFilters = config.DNS.DnsfilterConf.WhitelistFilters
+		config.UserRules = config.DNS.DnsfilterConf.UserRules
 	}
 
 	if s := Context.dnsServer; s != nil {
diff --git a/internal/home/control.go b/internal/home/control.go
index 54d1652a..829063e9 100644
--- a/internal/home/control.go
+++ b/internal/home/control.go
@@ -291,7 +291,7 @@ func handleHTTPSRedirect(w http.ResponseWriter, r *http.Request) (ok bool) {
 		}
 
 		httpsURL := &url.URL{
-			Scheme:   schemeHTTPS,
+			Scheme:   aghhttp.SchemeHTTPS,
 			Host:     hostPort,
 			Path:     r.URL.Path,
 			RawQuery: r.URL.RawQuery,
@@ -307,7 +307,7 @@ func handleHTTPSRedirect(w http.ResponseWriter, r *http.Request) (ok bool) {
 	//
 	// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin.
 	originURL := &url.URL{
-		Scheme: schemeHTTP,
+		Scheme: aghhttp.SchemeHTTP,
 		Host:   r.Host,
 	}
 	w.Header().Set("Access-Control-Allow-Origin", originURL.String())
diff --git a/internal/home/dns.go b/internal/home/dns.go
index 88ae8ef2..06c38bcc 100644
--- a/internal/home/dns.go
+++ b/internal/home/dns.go
@@ -31,7 +31,10 @@ const (
 
 // Called by other modules when configuration is changed
 func onConfigModified() {
-	_ = config.write()
+	err := config.write()
+	if err != nil {
+		log.Error("writing config: %s", err)
+	}
 }
 
 // initDNSServer creates an instance of the dnsforward.Server
@@ -71,11 +74,11 @@ func initDNSServer() (err error) {
 	}
 	Context.queryLog = querylog.New(conf)
 
-	filterConf := config.DNS.DnsfilterConf
-	filterConf.EtcHosts = Context.etcHosts
-	filterConf.ConfigModified = onConfigModified
-	filterConf.HTTPRegister = httpRegister
-	Context.dnsFilter = filtering.New(&filterConf, nil)
+	Context.filters, err = filtering.New(config.DNS.DnsfilterConf, nil)
+	if err != nil {
+		// Don't wrap the error, since it's informative enough as is.
+		return err
+	}
 
 	var privateNets netutil.SubnetSet
 	switch len(config.DNS.PrivateNets) {
@@ -83,13 +86,10 @@ func initDNSServer() (err error) {
 		// Use an optimized locally-served matcher.
 		privateNets = netutil.SubnetSetFunc(netutil.IsLocallyServed)
 	case 1:
-		var n *net.IPNet
-		n, err = netutil.ParseSubnet(config.DNS.PrivateNets[0])
+		privateNets, err = netutil.ParseSubnet(config.DNS.PrivateNets[0])
 		if err != nil {
 			return fmt.Errorf("preparing the set of private subnets: %w", err)
 		}
-
-		privateNets = n
 	default:
 		var nets []*net.IPNet
 		nets, err = netutil.ParseSubnets(config.DNS.PrivateNets...)
@@ -101,15 +101,13 @@ func initDNSServer() (err error) {
 	}
 
 	p := dnsforward.DNSCreateParams{
-		DNSFilter:   Context.dnsFilter,
+		DNSFilter:   Context.filters,
 		Stats:       Context.stats,
 		QueryLog:    Context.queryLog,
 		PrivateNets: privateNets,
 		Anonymizer:  anonymizer,
 		LocalDomain: config.DHCP.LocalDomainName,
-	}
-	if Context.dhcpServer != nil {
-		p.DHCPServer = Context.dhcpServer
+		DHCPServer:  Context.dhcpServer,
 	}
 
 	Context.dnsServer, err = dnsforward.NewServer(p)
@@ -143,7 +141,6 @@ func initDNSServer() (err error) {
 		Context.whois = initWHOIS(&Context.clients)
 	}
 
-	Context.filters.Init()
 	return nil
 }
 
@@ -335,9 +332,12 @@ func getDNSEncryption() (de dnsEncryption) {
 // applyAdditionalFiltering adds additional client information and settings if
 // the client has them.
 func applyAdditionalFiltering(clientIP net.IP, clientID string, setts *filtering.Settings) {
-	Context.dnsFilter.ApplyBlockedServices(setts, nil, true)
+	// pref is a prefix for logging messages around the scope.
+	const pref = "applying filters"
 
-	log.Debug("looking up settings for client with ip %s and clientid %q", clientIP, clientID)
+	Context.filters.ApplyBlockedServices(setts, nil)
+
+	log.Debug("%s: looking for client with ip %s and clientid %q", pref, clientIP, clientID)
 
 	if clientIP == nil {
 		return
@@ -349,16 +349,16 @@ func applyAdditionalFiltering(clientIP net.IP, clientID string, setts *filtering
 	if !ok {
 		c, ok = Context.clients.Find(clientIP.String())
 		if !ok {
-			log.Debug("client with ip %s and clientid %q not found", clientIP, clientID)
+			log.Debug("%s: no clients with ip %s and clientid %q", pref, clientIP, clientID)
 
 			return
 		}
 	}
 
-	log.Debug("using settings for client %q with ip %s and clientid %q", c.Name, clientIP, clientID)
+	log.Debug("%s: using settings for client %q (%s; %q)", pref, c.Name, clientIP, clientID)
 
 	if c.UseOwnBlockedServices {
-		Context.dnsFilter.ApplyBlockedServices(setts, c.BlockedServices, false)
+		Context.filters.ApplyBlockedServices(setts, c.BlockedServices)
 	}
 
 	setts.ClientName = c.Name
@@ -381,7 +381,7 @@ func startDNSServer() error {
 		return fmt.Errorf("unable to start forwarding DNS server: Already running")
 	}
 
-	enableFiltersLocked(false)
+	Context.filters.EnableFilters(false)
 
 	Context.clients.Start()
 
@@ -390,7 +390,6 @@ func startDNSServer() error {
 		return fmt.Errorf("couldn't start forwarding DNS server: %w", err)
 	}
 
-	Context.dnsFilter.Start()
 	Context.filters.Start()
 	Context.stats.Start()
 	Context.queryLog.Start()
@@ -449,10 +448,7 @@ func closeDNSServer() {
 		Context.dnsServer = nil
 	}
 
-	if Context.dnsFilter != nil {
-		Context.dnsFilter.Close()
-		Context.dnsFilter = nil
-	}
+	Context.filters.Close()
 
 	if Context.stats != nil {
 		err := Context.stats.Close()
@@ -469,7 +465,5 @@ func closeDNSServer() {
 		Context.queryLog = nil
 	}
 
-	Context.filters.Close()
-
-	log.Debug("Closed all DNS modules")
+	log.Debug("all dns modules are closed")
 }
diff --git a/internal/home/home.go b/internal/home/home.go
index 4b200bc1..76f4ac82 100644
--- a/internal/home/home.go
+++ b/internal/home/home.go
@@ -20,6 +20,7 @@ import (
 	"time"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/aghalg"
+	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
 	"github.com/AdguardTeam/AdGuardHome/internal/aghnet"
 	"github.com/AdguardTeam/AdGuardHome/internal/aghos"
 	"github.com/AdguardTeam/AdGuardHome/internal/aghtls"
@@ -33,6 +34,7 @@ import (
 	"github.com/AdguardTeam/golibs/errors"
 	"github.com/AdguardTeam/golibs/log"
 	"github.com/AdguardTeam/golibs/netutil"
+	"golang.org/x/exp/slices"
 	"gopkg.in/natefinch/lumberjack.v2"
 )
 
@@ -52,10 +54,9 @@ type homeContext struct {
 	dnsServer  *dnsforward.Server   // DNS module
 	rdns       *RDNS                // rDNS module
 	whois      *WHOIS               // WHOIS module
-	dnsFilter  *filtering.DNSFilter // DNS filtering module
 	dhcpServer dhcpd.Interface      // DHCP module
 	auth       *Auth                // HTTP authentication module
-	filters    Filtering            // DNS filtering module
+	filters    *filtering.DNSFilter // DNS filtering module
 	web        *Web                 // Web (HTTP, HTTPS) module
 	tls        *TLSMod              // TLS module
 	// etcHosts is an IP-hostname pairs set taken from system configuration
@@ -140,7 +141,12 @@ func setupContext(args options) {
 		checkPermissions()
 	}
 
-	initConfig()
+	switch version.Channel() {
+	case version.ChannelEdge, version.ChannelDevelopment:
+		config.BetaBindPort = 3001
+	default:
+		// Go on.
+	}
 
 	Context.tlsRoots = LoadSystemRootCAs()
 	Context.transport = &http.Transport{
@@ -265,6 +271,14 @@ func setupHostsContainer() (err error) {
 }
 
 func setupConfig(args options) (err error) {
+	config.DNS.DnsfilterConf.EtcHosts = Context.etcHosts
+	config.DNS.DnsfilterConf.ConfigModified = onConfigModified
+	config.DNS.DnsfilterConf.HTTPRegister = httpRegister
+	config.DNS.DnsfilterConf.DataDir = Context.getDataDir()
+	config.DNS.DnsfilterConf.Filters = slices.Clone(config.Filters)
+	config.DNS.DnsfilterConf.WhitelistFilters = slices.Clone(config.WhitelistFilters)
+	config.DNS.DnsfilterConf.HTTPClient = Context.client
+
 	config.DHCP.WorkDir = Context.workDir
 	config.DHCP.HTTPRegister = httpRegister
 	config.DHCP.ConfigModified = onConfigModified
@@ -384,8 +398,6 @@ func fatalOnError(err error) {
 
 // run configures and starts AdGuard Home.
 func run(args options, clientBuildFS fs.FS) {
-	var err error
-
 	// configure config filename
 	initConfigFilename(args)
 
@@ -404,7 +416,7 @@ func run(args options, clientBuildFS fs.FS) {
 
 	setupContext(args)
 
-	err = configureOS(config)
+	err := configureOS(config)
 	fatalOnError(err)
 
 	// clients package uses filtering package's static data (filtering.BlockedSvcKnown()),
@@ -763,12 +775,12 @@ func printHTTPAddresses(proto string) {
 	}
 
 	port := config.BindPort
-	if proto == schemeHTTPS {
+	if proto == aghhttp.SchemeHTTPS {
 		port = tlsConf.PortHTTPS
 	}
 
 	// TODO(e.burkov): Inspect and perhaps merge with the previous condition.
-	if proto == schemeHTTPS && tlsConf.ServerName != "" {
+	if proto == aghhttp.SchemeHTTPS && tlsConf.ServerName != "" {
 		printWebAddrs(proto, tlsConf.ServerName, tlsConf.PortHTTPS, 0)
 
 		return
diff --git a/internal/home/mobileconfig.go b/internal/home/mobileconfig.go
index 40094a6a..e2f7283f 100644
--- a/internal/home/mobileconfig.go
+++ b/internal/home/mobileconfig.go
@@ -8,6 +8,7 @@ import (
 	"net/url"
 	"path"
 
+	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
 	"github.com/AdguardTeam/AdGuardHome/internal/dnsforward"
 	"github.com/AdguardTeam/golibs/errors"
 	"github.com/AdguardTeam/golibs/log"
@@ -82,7 +83,7 @@ func encodeMobileConfig(d *dnsSettings, clientID string) ([]byte, error) {
 	case dnsProtoHTTPS:
 		dspName = fmt.Sprintf("%s DoH", d.ServerName)
 		u := &url.URL{
-			Scheme: schemeHTTPS,
+			Scheme: aghhttp.SchemeHTTPS,
 			Host:   d.ServerName,
 			Path:   path.Join("/dns-query", clientID),
 		}
diff --git a/internal/home/service.go b/internal/home/service.go
index 20367718..c670ebe2 100644
--- a/internal/home/service.go
+++ b/internal/home/service.go
@@ -11,6 +11,7 @@ import (
 	"syscall"
 	"time"
 
+	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
 	"github.com/AdguardTeam/AdGuardHome/internal/aghos"
 	"github.com/AdguardTeam/AdGuardHome/internal/version"
 	"github.com/AdguardTeam/golibs/errors"
@@ -277,7 +278,7 @@ AdGuard Home is successfully installed and will automatically start on boot.
 There are a few more things that must be configured before you can use it.
 Click on the link below and follow the Installation Wizard steps to finish setup.
 AdGuard Home is now available at the following addresses:`)
-		printHTTPAddresses(schemeHTTP)
+		printHTTPAddresses(aghhttp.SchemeHTTP)
 	}
 }
 
diff --git a/internal/home/upgrade_test.go b/internal/home/upgrade_test.go
index a5267032..949dac5f 100644
--- a/internal/home/upgrade_test.go
+++ b/internal/home/upgrade_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/AdguardTeam/AdGuardHome/internal/filtering"
 	"github.com/AdguardTeam/golibs/testutil"
 	"github.com/AdguardTeam/golibs/timeutil"
 	"github.com/stretchr/testify/assert"
@@ -160,7 +161,7 @@ func assertEqualExcept(t *testing.T, oldConf, newConf yobj, oldKeys, newKeys []s
 }
 
 func testDiskConf(schemaVersion int) (diskConf yobj) {
-	filters := []filter{{
+	filters := []filtering.FilterYAML{{
 		URL:        "https://filters.adtidy.org/android/filters/111_optimized.txt",
 		Name:       "Latvian filter",
 		RulesCount: 100,
diff --git a/internal/home/web.go b/internal/home/web.go
index 2052df55..5a26de59 100644
--- a/internal/home/web.go
+++ b/internal/home/web.go
@@ -9,6 +9,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
 	"github.com/AdguardTeam/AdGuardHome/internal/aghnet"
 	"github.com/AdguardTeam/AdGuardHome/internal/aghtls"
 	"github.com/AdguardTeam/golibs/errors"
@@ -19,12 +20,6 @@ import (
 	"golang.org/x/net/http2/h2c"
 )
 
-// HTTP scheme constants.
-const (
-	schemeHTTP  = "http"
-	schemeHTTPS = "https"
-)
-
 const (
 	// readTimeout is the maximum duration for reading the entire request,
 	// including the body.
@@ -166,7 +161,7 @@ func (web *Web) Start() {
 
 	// this loop is used as an ability to change listening host and/or port
 	for !web.httpsServer.shutdown {
-		printHTTPAddresses(schemeHTTP)
+		printHTTPAddresses(aghhttp.SchemeHTTP)
 		errs := make(chan error, 2)
 
 		// Use an h2c handler to support unencrypted HTTP/2, e.g. for proxies.
@@ -286,7 +281,7 @@ func (web *Web) tlsServerLoop() {
 			WriteTimeout:      web.conf.WriteTimeout,
 		}
 
-		printHTTPAddresses(schemeHTTPS)
+		printHTTPAddresses(aghhttp.SchemeHTTPS)
 		err := web.httpsServer.server.ListenAndServeTLS("", "")
 		if err != http.ErrServerClosed {
 			cleanupAlways()

From d45fa5801eb73fbdee0b3b230df0511cfad3ec13 Mon Sep 17 00:00:00 2001
From: Ainar Garipov <a.garipov@adguard.com>
Date: Thu, 29 Sep 2022 17:17:27 +0300
Subject: [PATCH 27/31] Pull request: upd-i18n

Merge in DNS/adguard-home from upd-i18n to master

Squashed commit of the following:

commit c8ad18e03d1d6206c3220751c5c720a5eef3e3a9
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Thu Sep 29 17:12:22 2022 +0300

    client: upd i18n
---
 client/src/__locales/be.json    | 3 ++-
 client/src/__locales/cs.json    | 3 ++-
 client/src/__locales/da.json    | 3 ++-
 client/src/__locales/de.json    | 3 ++-
 client/src/__locales/es.json    | 3 ++-
 client/src/__locales/fi.json    | 3 ++-
 client/src/__locales/fr.json    | 3 ++-
 client/src/__locales/hr.json    | 3 ++-
 client/src/__locales/hu.json    | 3 ++-
 client/src/__locales/id.json    | 3 ++-
 client/src/__locales/it.json    | 3 ++-
 client/src/__locales/ja.json    | 3 ++-
 client/src/__locales/ko.json    | 3 ++-
 client/src/__locales/nl.json    | 7 ++++---
 client/src/__locales/pl.json    | 3 ++-
 client/src/__locales/pt-br.json | 3 ++-
 client/src/__locales/pt-pt.json | 3 ++-
 client/src/__locales/ro.json    | 3 ++-
 client/src/__locales/ru.json    | 3 ++-
 client/src/__locales/sk.json    | 3 ++-
 client/src/__locales/sl.json    | 3 ++-
 client/src/__locales/sr-cs.json | 3 ++-
 client/src/__locales/sv.json    | 3 ++-
 client/src/__locales/tr.json    | 7 ++++---
 client/src/__locales/uk.json    | 3 ++-
 client/src/__locales/vi.json    | 3 ++-
 client/src/__locales/zh-cn.json | 3 ++-
 client/src/__locales/zh-tw.json | 3 ++-
 28 files changed, 60 insertions(+), 32 deletions(-)

diff --git a/client/src/__locales/be.json b/client/src/__locales/be.json
index 1455825b..c666daab 100644
--- a/client/src/__locales/be.json
+++ b/client/src/__locales/be.json
@@ -635,5 +635,6 @@
     "parental_control": "Бацькоўскі кантроль",
     "safe_browsing": "Бяспечны інтэрнэт",
     "served_from_cache": "{{value}} <i>(атрымана з кэша)</i>",
-    "form_error_password_length": "Пароль павінен быць не менш за {{value}} сімвалаў"
+    "form_error_password_length": "Пароль павінен быць не менш за {{value}} сімвалаў",
+    "anonymizer_notification": "<0>Заўвага:</0> Ананімізацыя IP уключана. Вы можаце адключыць яго ў <1>Агульных наладах</1> ."
 }
diff --git a/client/src/__locales/cs.json b/client/src/__locales/cs.json
index 4e48bc46..8c879b09 100644
--- a/client/src/__locales/cs.json
+++ b/client/src/__locales/cs.json
@@ -635,5 +635,6 @@
     "parental_control": "Rodičovská ochrana",
     "safe_browsing": "Bezpečné prohlížení",
     "served_from_cache": "{{value}} <i>(převzato z mezipaměti)</i>",
-    "form_error_password_length": "Heslo musí být alespoň {{value}} znaků dlouhé"
+    "form_error_password_length": "Heslo musí být alespoň {{value}} znaků dlouhé",
+    "anonymizer_notification": "<0>Poznámka:</0> Anonymizace IP je zapnuta. Můžete ji vypnout v <1>Obecných nastaveních</1>."
 }
diff --git a/client/src/__locales/da.json b/client/src/__locales/da.json
index dddf6249..0df5ff72 100644
--- a/client/src/__locales/da.json
+++ b/client/src/__locales/da.json
@@ -635,5 +635,6 @@
     "parental_control": "Forældrekontrol",
     "safe_browsing": "Sikker Browsing",
     "served_from_cache": "{{value}} <i>(leveret fra cache)</i>",
-    "form_error_password_length": "Adgangskoden skal udgøre mindst {{value}} tegn."
+    "form_error_password_length": "Adgangskoden skal udgøre mindst {{value}} tegn.",
+    "anonymizer_notification": "<0>Bemærk:</0> IP-anonymisering er aktiveret. Det kan deaktiveres via <1>Generelle indstillinger</1>."
 }
diff --git a/client/src/__locales/de.json b/client/src/__locales/de.json
index 20e2572f..cff3d43d 100644
--- a/client/src/__locales/de.json
+++ b/client/src/__locales/de.json
@@ -635,5 +635,6 @@
     "parental_control": "Kindersicherung",
     "safe_browsing": "Internetsicherheit",
     "served_from_cache": "{{value}} <i>(aus dem Cache abgerufen)</i>",
-    "form_error_password_length": "Das Passwort muss mindestens {{value}} Zeichen enthalten"
+    "form_error_password_length": "Das Passwort muss mindestens {{value}} Zeichen enthalten",
+    "anonymizer_notification": "<0>Hinweis:</0> Die IP-Anonymisierung ist aktiviert. Sie können sie in den <1>Allgemeinen Einstellungen</1> deaktivieren."
 }
diff --git a/client/src/__locales/es.json b/client/src/__locales/es.json
index 3ac31670..506f98d7 100644
--- a/client/src/__locales/es.json
+++ b/client/src/__locales/es.json
@@ -635,5 +635,6 @@
     "parental_control": "Control parental",
     "safe_browsing": "Navegación segura",
     "served_from_cache": "{{value}} <i>(servido desde la caché)</i>",
-    "form_error_password_length": "La contraseña debe tener al menos {{value}} caracteres"
+    "form_error_password_length": "La contraseña debe tener al menos {{value}} caracteres",
+    "anonymizer_notification": "<0>Nota:</0> La anonimización de IP está habilitada. Puedes deshabilitarla en <1>Configuración general</1>."
 }
diff --git a/client/src/__locales/fi.json b/client/src/__locales/fi.json
index 6772c20d..3231d087 100644
--- a/client/src/__locales/fi.json
+++ b/client/src/__locales/fi.json
@@ -635,5 +635,6 @@
     "parental_control": "Lapsilukko",
     "safe_browsing": "Turvallinen selaus",
     "served_from_cache": "{{value}} <i>(jaettu välimuistista)</i>",
-    "form_error_password_length": "Salasanan on oltava ainakin {{value}} merkkiä"
+    "form_error_password_length": "Salasanan on oltava ainakin {{value}} merkkiä",
+    "anonymizer_notification": "<0>Huomioi:</0> IP-osoitteen anonymisointi on käytössä. Voit poistaa sen käytöstä <1>Yleisistä asetuksista</1>."
 }
diff --git a/client/src/__locales/fr.json b/client/src/__locales/fr.json
index 21742d64..f9528e82 100644
--- a/client/src/__locales/fr.json
+++ b/client/src/__locales/fr.json
@@ -635,5 +635,6 @@
     "parental_control": "Contrôle parental",
     "safe_browsing": "Navigation sécurisée",
     "served_from_cache": "{{value}} <i>(depuis le cache)</i>",
-    "form_error_password_length": "Le mot de passe doit comporter au moins {{value}} caractères"
+    "form_error_password_length": "Le mot de passe doit comporter au moins {{value}} caractères",
+    "anonymizer_notification": "<0>Note :</0> L'anonymisation IP est activée. Vous pouvez la désactiver dans les <1>paramètres généraux</1>."
 }
diff --git a/client/src/__locales/hr.json b/client/src/__locales/hr.json
index e4c44dff..8a64f6c8 100644
--- a/client/src/__locales/hr.json
+++ b/client/src/__locales/hr.json
@@ -635,5 +635,6 @@
     "parental_control": "Roditeljska zaštita",
     "safe_browsing": "Sigurno surfanje",
     "served_from_cache": "{{value}} <i>(dohvaćeno iz predmemorije)</i>",
-    "form_error_password_length": "Lozinka mora imati najmanje {{value}} znakova"
+    "form_error_password_length": "Lozinka mora imati najmanje {{value}} znakova",
+    "anonymizer_notification": "<0>Napomena:</0>IP anonimizacija je omogućena. Možete ju onemogućiti u <1>općim postavkama</1>."
 }
diff --git a/client/src/__locales/hu.json b/client/src/__locales/hu.json
index ed414ddd..7bc43b33 100644
--- a/client/src/__locales/hu.json
+++ b/client/src/__locales/hu.json
@@ -635,5 +635,6 @@
     "parental_control": "Szülői felügyelet",
     "safe_browsing": "Biztonságos böngészés",
     "served_from_cache": "{{value}} <i>(gyorsítótárból kiszolgálva)</i>",
-    "form_error_password_length": "A jelszó legalább {{value}} karakter hosszú kell, hogy legyen"
+    "form_error_password_length": "A jelszó legalább {{value}} karakter hosszú kell, hogy legyen",
+    "anonymizer_notification": "<0>Megjegyzés:</0> Az IP anonimizálás engedélyezve van. Az <1>Általános beállításoknál letilthatja</1> ."
 }
diff --git a/client/src/__locales/id.json b/client/src/__locales/id.json
index 5246344d..beb011f0 100644
--- a/client/src/__locales/id.json
+++ b/client/src/__locales/id.json
@@ -635,5 +635,6 @@
     "parental_control": "Kontrol Orang Tua",
     "safe_browsing": "Penjelajahan Aman",
     "served_from_cache": "{{value}} <i>(disajikan dari cache)</i>",
-    "form_error_password_length": "Kata sandi harus minimal {{value}} karakter"
+    "form_error_password_length": "Kata sandi harus minimal {{value}} karakter",
+    "anonymizer_notification": "<0>Catatan:</0> Anonimisasi IP diaktifkan. Anda dapat menonaktifkannya di <1>Pengaturan umum</1> ."
 }
diff --git a/client/src/__locales/it.json b/client/src/__locales/it.json
index 70722dcd..74b109ff 100644
--- a/client/src/__locales/it.json
+++ b/client/src/__locales/it.json
@@ -635,5 +635,6 @@
     "parental_control": "Controllo Parentale",
     "safe_browsing": "Navigazione Sicura",
     "served_from_cache": "{{value}} <i>(fornito dalla cache)</i>",
-    "form_error_password_length": "La password deve contenere almeno {{value}} caratteri"
+    "form_error_password_length": "La password deve contenere almeno {{value}} caratteri",
+    "anonymizer_notification": "<0>Attenzione:</0> L'anonimizzazione dell'IP è abilitata. Puoi disabilitarla in <1>Impostazioni generali</1>."
 }
diff --git a/client/src/__locales/ja.json b/client/src/__locales/ja.json
index 2942a504..9fc9ac6f 100644
--- a/client/src/__locales/ja.json
+++ b/client/src/__locales/ja.json
@@ -635,5 +635,6 @@
     "parental_control": "ペアレンタルコントロール",
     "safe_browsing": "セーフブラウジング",
     "served_from_cache": "{{value}} <i>(キャッシュから応答)</i>",
-    "form_error_password_length": "パスワードは{{value}}文字以上にしてください"
+    "form_error_password_length": "パスワードは{{value}}文字以上にしてください",
+    "anonymizer_notification": "【<0>注意</0>】IPの匿名化が有効になっています。 <1>一般設定</1>で無効にできます。"
 }
diff --git a/client/src/__locales/ko.json b/client/src/__locales/ko.json
index 5afd8c61..96da066c 100644
--- a/client/src/__locales/ko.json
+++ b/client/src/__locales/ko.json
@@ -635,5 +635,6 @@
     "parental_control": "자녀 보호",
     "safe_browsing": "세이프 브라우징",
     "served_from_cache": "{{value}} <i>(캐시에서 제공)</i>",
-    "form_error_password_length": "비밀번호는 {{value}}자 이상이어야 합니다"
+    "form_error_password_length": "비밀번호는 {{value}}자 이상이어야 합니다",
+    "anonymizer_notification": "<0>참고:</0> IP 익명화가 활성화되었습니다. <1>일반 설정</1>에서 비활성화할 수 있습니다."
 }
diff --git a/client/src/__locales/nl.json b/client/src/__locales/nl.json
index 06602c96..6a2c2ac4 100644
--- a/client/src/__locales/nl.json
+++ b/client/src/__locales/nl.json
@@ -557,7 +557,7 @@
     "fastest_addr_desc": "Alle DNS-servers bevragen en het snelste IP adres terugkoppelen. Dit zal de DNS verzoeken vertragen omdat AdGuard Home moet wachten op de antwoorden van alles DNS-servers, maar verbetert wel de connectiviteit.",
     "autofix_warning_text": "Als je op \"Repareren\" klikt, configureert AdGuard Home jouw systeem om de AdGuard Home DNS-server te gebruiken.",
     "autofix_warning_list": "De volgende taken worden uitgevoerd: <0> Deactiveren van Systeem DNSStubListener</0> <0> DNS-serveradres instellen op 127.0.0.1 </0> <0> Symbolisch koppelingsdoel van /etc/resolv.conf vervangen door /run/systemd/resolve/resolv.conf </0> <0> Stop DNSStubListener (herlaad systemd-resolved service) </0>",
-    "autofix_warning_result": "Als gevolg hiervan worden alle DNS-verzoeken van je systeem standaard door AdGuard Home verwerkt.",
+    "autofix_warning_result": "Als gevolg hiervan worden alle DNS-aanvragen van je systeem standaard door AdGuard Home verwerkt.",
     "tags_title": "Labels",
     "tags_desc": "Je kunt labels selecteren die overeenkomen met de client. Labels kunnen worden opgenomen in de filterregels om ze \n nauwkeuriger toe te passen. <0>Meer informatie</0>.",
     "form_select_tags": "Client tags selecteren",
@@ -628,12 +628,13 @@
     "original_response": "Oorspronkelijke reactie",
     "click_to_view_queries": "Klik om queries te bekijken",
     "port_53_faq_link": "Poort 53 wordt vaak gebruikt door services als DNSStubListener- of de systeem DNS-resolver. Lees a.u.b. <0>deze instructie</0> hoe dit is op te lossen.",
-    "adg_will_drop_dns_queries": "AdGuard Home zal alle DNS-verzoeken van deze cliënt laten vervallen.",
+    "adg_will_drop_dns_queries": "AdGuard Home zal alle DNS-aanvragen van deze cliënt laten vervallen.",
     "filter_allowlist": "WAARSCHUWING: Deze actie zal ook de regel \"{{disallowed_rule}}\" uitsluiten van de lijst met toegestane clients.",
     "last_rule_in_allowlist": "Kan deze client niet weigeren omdat het uitsluiten van de regel \"{{disallowed_rule}}\" de lijst \"Toegestane clients\" zal UITSCHAKELEN.",
     "use_saved_key": "De eerder opgeslagen sleutel gebruiken",
     "parental_control": "Ouderlijk toezicht",
     "safe_browsing": "Veilig browsen",
     "served_from_cache": "{{value}} <i>(geleverd vanuit cache)</i>",
-    "form_error_password_length": "Wachtwoord moet minimaal {{value}} tekens lang zijn"
+    "form_error_password_length": "Wachtwoord moet minimaal {{value}} tekens lang zijn",
+    "anonymizer_notification": "<0>Opmerking:</0> IP-anonimisering is ingeschakeld. Je kunt het uitschakelen in <1>Algemene instellingen</1>."
 }
diff --git a/client/src/__locales/pl.json b/client/src/__locales/pl.json
index 06fb0bd0..48e75a4f 100644
--- a/client/src/__locales/pl.json
+++ b/client/src/__locales/pl.json
@@ -635,5 +635,6 @@
     "parental_control": "Kontrola rodzicielska",
     "safe_browsing": "Bezpieczne przeglądanie",
     "served_from_cache": "{{value}} <i>(podawane z pamięci podręcznej)</i>",
-    "form_error_password_length": "Hasło musi mieć co najmniej {{value}} znaków"
+    "form_error_password_length": "Hasło musi mieć co najmniej {{value}} znaków",
+    "anonymizer_notification": "<0>Uwaga:</0> Anonimizacja IP jest włączona. Możesz ją wyłączyć w <1>Ustawieniach ogólnych</1>."
 }
diff --git a/client/src/__locales/pt-br.json b/client/src/__locales/pt-br.json
index 123ad2c0..1a1bbecb 100644
--- a/client/src/__locales/pt-br.json
+++ b/client/src/__locales/pt-br.json
@@ -635,5 +635,6 @@
     "parental_control": "Controle parental",
     "safe_browsing": "Navegação segura",
     "served_from_cache": "{{value}} <i>(servido do cache)</i>",
-    "form_error_password_length": "A senha deve ter pelo menos {{value}} caracteres"
+    "form_error_password_length": "A senha deve ter pelo menos {{value}} caracteres",
+    "anonymizer_notification": "<0>Observação:</0> A anonimização de IP está ativada. Você pode desativá-lo em <1>Configurações gerais</1>."
 }
diff --git a/client/src/__locales/pt-pt.json b/client/src/__locales/pt-pt.json
index 88299c5a..8be5b304 100644
--- a/client/src/__locales/pt-pt.json
+++ b/client/src/__locales/pt-pt.json
@@ -635,5 +635,6 @@
     "parental_control": "Controlo parental",
     "safe_browsing": "Navegação segura",
     "served_from_cache": "{{value}} <i>(servido do cache)</i>",
-    "form_error_password_length": "A palavra-passe deve ter pelo menos {{value}} caracteres"
+    "form_error_password_length": "A palavra-passe deve ter pelo menos {{value}} caracteres",
+    "anonymizer_notification": "<0>Observação:</0> A anonimização de IP está ativada. Você pode desativá-la em <1>Definições gerais</1>."
 }
diff --git a/client/src/__locales/ro.json b/client/src/__locales/ro.json
index 7a723ac0..cbc458e7 100644
--- a/client/src/__locales/ro.json
+++ b/client/src/__locales/ro.json
@@ -635,5 +635,6 @@
     "parental_control": "Control Parental",
     "safe_browsing": "Navigare în siguranță",
     "served_from_cache": "{{value}} <i>(furnizat din cache)</i>",
-    "form_error_password_length": "Parola trebuie să aibă cel puțin {{value}} caractere"
+    "form_error_password_length": "Parola trebuie să aibă cel puțin {{value}} caractere",
+    "anonymizer_notification": "<0>Nota:</0> Anonimizarea IP este activată. Puteți să o dezactivați în <1>Setări generale</1>."
 }
diff --git a/client/src/__locales/ru.json b/client/src/__locales/ru.json
index 9ecdbad2..fd605df7 100644
--- a/client/src/__locales/ru.json
+++ b/client/src/__locales/ru.json
@@ -635,5 +635,6 @@
     "parental_control": "Родительский контроль",
     "safe_browsing": "Безопасный интернет",
     "served_from_cache": "{{value}} <i>(получено из кеша)</i>",
-    "form_error_password_length": "Пароль должен быть длиной не меньше {{value}} символов"
+    "form_error_password_length": "Пароль должен быть длиной не меньше {{value}} символов",
+    "anonymizer_notification": "<0>Внимание:</0> включена анонимизация IP-адресов. Вы можете отключить её в разделе <1>Основные настройки</1>."
 }
diff --git a/client/src/__locales/sk.json b/client/src/__locales/sk.json
index e3db6a43..631d2457 100644
--- a/client/src/__locales/sk.json
+++ b/client/src/__locales/sk.json
@@ -635,5 +635,6 @@
     "parental_control": "Rodičovská kontrola",
     "safe_browsing": "Bezpečné prehliadanie",
     "served_from_cache": "{{value}} <i>(prevzatá z cache pamäte)</i>",
-    "form_error_password_length": "Heslo musí mať dĺžku aspoň {{value}} znakov"
+    "form_error_password_length": "Heslo musí mať dĺžku aspoň {{value}} znakov",
+    "anonymizer_notification": "<0>Poznámka:</0> Anonymizácia IP je zapnutá. Môžete ju vypnúť vo <1>Všeobecných nastaveniach</1>."
 }
diff --git a/client/src/__locales/sl.json b/client/src/__locales/sl.json
index 7f0ac8ee..b779868d 100644
--- a/client/src/__locales/sl.json
+++ b/client/src/__locales/sl.json
@@ -635,5 +635,6 @@
     "parental_control": "Starševski nadzor",
     "safe_browsing": "Varno brskanje",
     "served_from_cache": "{{value}} <i>(postreženo iz predpomnilnika)</i>",
-    "form_error_password_length": "Geslo mora vsebovati najmanj {{value}} znakov"
+    "form_error_password_length": "Geslo mora vsebovati najmanj {{value}} znakov",
+    "anonymizer_notification": "<0>Opomba:</0> Anonimizacija IP je omogočena. Onemogočite ga lahko v <1>Splošnih nastavitvah</1>."
 }
diff --git a/client/src/__locales/sr-cs.json b/client/src/__locales/sr-cs.json
index 4dc6978b..23f5647b 100644
--- a/client/src/__locales/sr-cs.json
+++ b/client/src/__locales/sr-cs.json
@@ -635,5 +635,6 @@
     "parental_control": "Roditeljska kontrola",
     "safe_browsing": "Sigurno pregledanje",
     "served_from_cache": "{{value}} <i>(posluženo iz predmemorije)</i>",
-    "form_error_password_length": "Lozinka mora imati najmanje {{value}} znakova"
+    "form_error_password_length": "Lozinka mora imati najmanje {{value}} znakova",
+    "anonymizer_notification": "<0>Nota:</0> IP prepoznavanje je omogućeno. Možete ga onemogućiti u opštim <1>postavkama</1>."
 }
diff --git a/client/src/__locales/sv.json b/client/src/__locales/sv.json
index c0d78cbe..8818f9d7 100644
--- a/client/src/__locales/sv.json
+++ b/client/src/__locales/sv.json
@@ -635,5 +635,6 @@
     "parental_control": "Föräldrakontroll",
     "safe_browsing": "Säker surfning",
     "served_from_cache": "{{value}} <i>(levereras från cache)</i>",
-    "form_error_password_length": "Lösenordet måste vara minst {{value}} tecken långt"
+    "form_error_password_length": "Lösenordet måste vara minst {{value}} tecken långt",
+    "anonymizer_notification": "<0>Observera:</0> IP-anonymisering är aktiverad. Du kan inaktivera den i <1>Allmänna inställningar</1>."
 }
diff --git a/client/src/__locales/tr.json b/client/src/__locales/tr.json
index cacb857d..bcd06813 100644
--- a/client/src/__locales/tr.json
+++ b/client/src/__locales/tr.json
@@ -368,7 +368,7 @@
     "encryption_server_enter": "Alan adınızı girin",
     "encryption_server_desc": "Ayarlanırsa, AdGuard Home ClientID'leri algılar, DDR sorgularına yanıt verir ve ek bağlantı doğrulamaları gerçekleştirir. Ayarlanmazsa, bu özellikler devre dışı bırakılır. Sertifikadaki DNS Adlarından biriyle eşleşmelidir.",
     "encryption_redirect": "Otomatik olarak HTTPS'e yönlendir",
-    "encryption_redirect_desc": "Etkinleştirirseniz, AdGuard Home sizi HTTP adresi yerine HTTPS adresine yönlendirir.",
+    "encryption_redirect_desc": "İşaretlenirse, AdGuard Home sizi otomatik olarak HTTP adresinden HTTPS adreslerine yönlendirecektir.",
     "encryption_https": "HTTPS bağlantı noktası",
     "encryption_https_desc": "HTTPS bağlantı noktası yapılandırılırsa, AdGuard Home yönetici arayüzüne HTTPS aracılığıyla erişilebilir olacak ve ayrıca '/dns-query' üzerinden DNS-over-HTTPS bağlantısı sağlayacaktır.",
     "encryption_dot": "DNS-over-TLS bağlantı noktası",
@@ -408,7 +408,7 @@
     "fix": "Düzelt",
     "dns_providers": "Aralarından seçim yapabileceğiniz, bilinen <0>DNS sağlayıcıların listesi</0>.",
     "update_now": "Şimdi güncelle",
-    "update_failed": "Otomatik güncelleme başarısız oldu. Elle güncellemek için lütfen <a>bu adımları uygulayın</a>.",
+    "update_failed": "Otomatik güncelleme başarısız oldu. Elle güncellemek için lütfen <a>bu adımları izleyin</a>.",
     "manual_update": "Elle güncellemek için lütfen <a>bu adımları uygulayın</a>.",
     "processing_update": "Lütfen bekleyin, AdGuard Home güncelleniyor",
     "clients_title": "Kalıcı istemciler",
@@ -635,5 +635,6 @@
     "parental_control": "Ebeveyn Denetimi",
     "safe_browsing": "Güvenli Gezinti",
     "served_from_cache": "{{value}} <i>(önbellekten kullanıldı)</i>",
-    "form_error_password_length": "Parola en az {{value}} karakter uzunluğunda olmalıdır"
+    "form_error_password_length": "Parola en az {{value}} karakter uzunluğunda olmalıdır",
+    "anonymizer_notification": "<0>Not:</0> IP anonimleştirme etkinleştirildi. Bunu <1>Genel ayarlardan</1> devre dışı bırakabilirsiniz."
 }
diff --git a/client/src/__locales/uk.json b/client/src/__locales/uk.json
index c613b016..b882e856 100644
--- a/client/src/__locales/uk.json
+++ b/client/src/__locales/uk.json
@@ -635,5 +635,6 @@
     "parental_control": "Батьківський контроль",
     "safe_browsing": "Безпечний перегляд",
     "served_from_cache": "{{value}} <i>(отримано з кешу)</i>",
-    "form_error_password_length": "Пароль мусить мати принаймні {{value}} символів"
+    "form_error_password_length": "Пароль мусить мати принаймні {{value}} символів",
+    "anonymizer_notification": "<0>Примітка:</0> IP-анонімізацію ввімкнено. Ви можете вимкнути його в <1>Загальні налаштування</1> ."
 }
diff --git a/client/src/__locales/vi.json b/client/src/__locales/vi.json
index c8109e40..05c9cf46 100644
--- a/client/src/__locales/vi.json
+++ b/client/src/__locales/vi.json
@@ -635,5 +635,6 @@
     "parental_control": "Quản lý của phụ huynh",
     "safe_browsing": "Duyệt web an toàn",
     "served_from_cache": "{{value}} <i>(được phục vụ từ bộ nhớ cache)</i>",
-    "form_error_password_length": "Mật khẩu phải có ít nhất {{value}} ký tự"
+    "form_error_password_length": "Mật khẩu phải có ít nhất {{value}} ký tự",
+    "anonymizer_notification": "<0> Lưu ý:</0> Tính năng ẩn danh IP được bật. Bạn có thể tắt nó trong <1> Cài đặt chung</1>."
 }
diff --git a/client/src/__locales/zh-cn.json b/client/src/__locales/zh-cn.json
index e8dc8e43..aa51902f 100644
--- a/client/src/__locales/zh-cn.json
+++ b/client/src/__locales/zh-cn.json
@@ -635,5 +635,6 @@
     "parental_control": "家长控制",
     "safe_browsing": "安全浏览",
     "served_from_cache": "{{value}}<i>(由缓存提供)</i>",
-    "form_error_password_length": "密码必须至少有 {{value}} 个字符"
+    "form_error_password_length": "密码必须至少有 {{value}} 个字符",
+    "anonymizer_notification": "<0>注意:</0> IP 匿名化已启用。您可以在<1>常规设置</1>中禁用它。"
 }
diff --git a/client/src/__locales/zh-tw.json b/client/src/__locales/zh-tw.json
index 73bd956b..9ca99a64 100644
--- a/client/src/__locales/zh-tw.json
+++ b/client/src/__locales/zh-tw.json
@@ -635,5 +635,6 @@
     "parental_control": "家長控制",
     "safe_browsing": "安全瀏覽",
     "served_from_cache": "{{value}} <i>(由快取提供)</i>",
-    "form_error_password_length": "密碼必須為至少長 {{value}} 個字元"
+    "form_error_password_length": "密碼必須為至少長 {{value}} 個字元",
+    "anonymizer_notification": "<0>注意:</0>IP 匿名化被啟用。您可在<1>一般設定</1>中禁用它。"
 }

From b71a5d86deaf3520ecf0874701b5656f0ada571d Mon Sep 17 00:00:00 2001
From: Eugene Burkov <e.burkov@adguard.com>
Date: Thu, 29 Sep 2022 18:30:35 +0300
Subject: [PATCH 28/31] Pull request: 4945 fix user rules

Merge in DNS/adguard-home from 4945-fix-user-rules to master

Updates #4945.
Updates #4871.

Squashed commit of the following:

commit 415a262e5af0821b658ed2a1b365d471f1452a6a
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Sep 29 18:05:48 2022 +0300

    home: fix user rules
---
 internal/home/home.go | 1 +
 1 file changed, 1 insertion(+)

diff --git a/internal/home/home.go b/internal/home/home.go
index 76f4ac82..0f88f57b 100644
--- a/internal/home/home.go
+++ b/internal/home/home.go
@@ -277,6 +277,7 @@ func setupConfig(args options) (err error) {
 	config.DNS.DnsfilterConf.DataDir = Context.getDataDir()
 	config.DNS.DnsfilterConf.Filters = slices.Clone(config.Filters)
 	config.DNS.DnsfilterConf.WhitelistFilters = slices.Clone(config.WhitelistFilters)
+	config.DNS.DnsfilterConf.UserRules = slices.Clone(config.UserRules)
 	config.DNS.DnsfilterConf.HTTPClient = Context.client
 
 	config.DHCP.WorkDir = Context.workDir

From 756b14a61de138889130c239406dae43f1f115cb Mon Sep 17 00:00:00 2001
From: Ainar Garipov <a.garipov@adguard.com>
Date: Thu, 29 Sep 2022 19:04:26 +0300
Subject: [PATCH 29/31] Pull request: HOFTIX-csrf

Merge in DNS/adguard-home from HOFTIX-csrf to master

Squashed commit of the following:

commit 75ab27bf6c52b80ab4e7347d7c254fa659eac244
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Thu Sep 29 18:45:54 2022 +0300

    all: imp cookie security; rm plain-text apis
---
 CHANGELOG.md                                  |  35 ++
 SECURITY.md                                   |  18 ++
 client/src/actions/filtering.js               |   4 +-
 client/src/actions/index.js                   |  11 +-
 client/src/api/Api.js                         |  21 +-
 internal/aghhttp/aghhttp.go                   |  39 +++
 internal/aghhttp/header.go                    |  22 ++
 internal/dhcpd/http_unix.go                   |  57 ++--
 .../{controlfiltering.go => http.go}          |  19 +-
 internal/home/auth.go                         | 300 +++++++++---------
 internal/home/auth_test.go                    |  18 +-
 internal/home/clientshttp.go                  |  14 +-
 internal/home/config.go                       |   8 +-
 internal/home/control.go                      |  26 +-
 internal/home/controlinstall.go               |  38 +--
 internal/home/controlupdate.go                |   7 +-
 internal/home/home.go                         |   8 +-
 internal/home/i18n.go                         |  55 ++--
 internal/home/service.go                      |   3 +-
 internal/home/tls.go                          |  13 +-
 internal/home/upgrade.go                      |   4 +-
 openapi/CHANGELOG.md                          |  64 ++++
 openapi/openapi.yaml                          |  54 ++--
 23 files changed, 494 insertions(+), 344 deletions(-)
 create mode 100644 SECURITY.md
 create mode 100644 internal/aghhttp/header.go
 rename internal/filtering/{controlfiltering.go => http.go} (97%)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index dfeae3b1..ed20426c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,41 @@ and this project adheres to
 
 ### Security
 
+#### `SameSite` Policy
+
+The `SameSite` policy on the AdGuard Home session cookies is now set to `Lax`.
+Which means that the only cross-site HTTP request for which the browser is
+allowed to send the session cookie is navigating to the AdGuard Home domain.
+
+**Users are strongly advised to log out, clear browser cache, and log in again
+after updating.**
+
+#### Removal Of Plain-Text APIs (BREAKING API CHANGE)
+
+A Cross-Site Request Forgery (CSRF) vulnerability has been discovered.  We have
+implemented several measures to prevent such vulnerabilities in the future, but
+some of these measures break backwards compatibility for the sake of better
+protection.
+
+The following APIs, which previously accepted or returned `text/plain` data,
+now accept or return data as JSON.  All new formats for the request and response
+bodies are documented in `openapi/openapi.yaml` and `openapi/CHANGELOG.md`.
+
+- `GET  /control/i18n/current_language`;
+- `POST /control/dhcp/find_active_dhcp`;
+- `POST /control/filtering/set_rules`;
+- `POST /control/i18n/change_language`.
+
+The CVE number is to be assigned.  We thank Daniel Elkabes from Mend.io for
+reporting this vulnerability to us.
+
+#### Stricter Content-Type Checks (BREAKING API CHANGE)
+
+All JSON APIs now check if the request actually has the `application/json`
+content-type.
+
+#### Other Security Changes
+
 - Weaker cipher suites that use the CBC (cipher block chaining) mode of
   operation have been disabled ([#2993]).
 
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..2fa3a67b
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,18 @@
+ #  Security Policy
+
+## Reporting a Vulnerability
+
+Please send your vulnerability reports to <security@adguard.com>.  To make sure
+that your report reaches us, please:
+
+1.  Include the words “AdGuard Home” and “vulnerability” to the subject line as
+    well as a short description of the vulnerability.  For example:
+
+     >  AdGuard Home API vulnerability: possible XSS attack
+
+2.  Make sure that the message body contains a clear description of the
+    vulnerability.
+
+If you have not received a reply to your email within 7 days, please make sure
+to follow up with us again at <security@adguard.com>.  Once again, make sure
+that the word “vulnerability” is in the subject line.
diff --git a/client/src/actions/filtering.js b/client/src/actions/filtering.js
index 02bc97f8..6039fdcc 100644
--- a/client/src/actions/filtering.js
+++ b/client/src/actions/filtering.js
@@ -31,7 +31,9 @@ export const setRulesSuccess = createAction('SET_RULES_SUCCESS');
 export const setRules = (rules) => async (dispatch) => {
     dispatch(setRulesRequest());
     try {
-        const normalizedRules = normalizeRulesTextarea(rules);
+        const normalizedRules = {
+            rules: normalizeRulesTextarea(rules)?.split('\n'),
+        };
         await apiClient.setRules(normalizedRules);
         dispatch(addSuccessToast('updated_custom_filtering_toast'));
         dispatch(setRulesSuccess());
diff --git a/client/src/actions/index.js b/client/src/actions/index.js
index e1b4e96c..2242490b 100644
--- a/client/src/actions/index.js
+++ b/client/src/actions/index.js
@@ -355,7 +355,7 @@ export const changeLanguageSuccess = createAction('CHANGE_LANGUAGE_SUCCESS');
 export const changeLanguage = (lang) => async (dispatch) => {
     dispatch(changeLanguageRequest());
     try {
-        await apiClient.changeLanguage(lang);
+        await apiClient.changeLanguage({ language: lang });
         dispatch(changeLanguageSuccess());
     } catch (error) {
         dispatch(addErrorToast({ error }));
@@ -370,8 +370,8 @@ export const getLanguageSuccess = createAction('GET_LANGUAGE_SUCCESS');
 export const getLanguage = () => async (dispatch) => {
     dispatch(getLanguageRequest());
     try {
-        const language = await apiClient.getCurrentLanguage();
-        dispatch(getLanguageSuccess(language));
+        const langSettings = await apiClient.getCurrentLanguage();
+        dispatch(getLanguageSuccess(langSettings.language));
     } catch (error) {
         dispatch(addErrorToast({ error }));
         dispatch(getLanguageFailure());
@@ -421,7 +421,10 @@ export const findActiveDhcpFailure = createAction('FIND_ACTIVE_DHCP_FAILURE');
 export const findActiveDhcp = (name) => async (dispatch, getState) => {
     dispatch(findActiveDhcpRequest());
     try {
-        const activeDhcp = await apiClient.findActiveDhcp(name);
+        const req = {
+            interface: name,
+        };
+        const activeDhcp = await apiClient.findActiveDhcp(req);
         dispatch(findActiveDhcpSuccess(activeDhcp));
         const { check, interface_name, interfaces } = getState().dhcp;
         const selectedInterface = getState().form[FORM_NAME.DHCP_INTERFACES].values.interface_name;
diff --git a/client/src/api/Api.js b/client/src/api/Api.js
index d5693bfe..113c2c00 100644
--- a/client/src/api/Api.js
+++ b/client/src/api/Api.js
@@ -130,7 +130,7 @@ class Api {
         const { path, method } = this.FILTERING_SET_RULES;
         const parameters = {
             data: rules,
-            headers: { 'Content-Type': 'text/plain' },
+            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -173,12 +173,7 @@ class Api {
 
     enableParentalControl() {
         const { path, method } = this.PARENTAL_ENABLE;
-        const parameter = 'sensitivity=TEEN'; // this parameter TEEN is hardcoded
-        const config = {
-            data: parameter,
-            headers: { 'Content-Type': 'text/plain' },
-        };
-        return this.makeRequest(path, method, config);
+        return this.makeRequest(path, method);
     }
 
     disableParentalControl() {
@@ -240,11 +235,11 @@ class Api {
         return this.makeRequest(path, method);
     }
 
-    changeLanguage(lang) {
+    changeLanguage(config) {
         const { path, method } = this.CHANGE_LANGUAGE;
         const parameters = {
-            data: lang,
-            headers: { 'Content-Type': 'text/plain' },
+            data: config,
+            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -285,11 +280,11 @@ class Api {
         return this.makeRequest(path, method, parameters);
     }
 
-    findActiveDhcp(name) {
+    findActiveDhcp(req) {
         const { path, method } = this.DHCP_FIND_ACTIVE;
         const parameters = {
-            data: name,
-            headers: { 'Content-Type': 'text/plain' },
+            data: req,
+            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
diff --git a/internal/aghhttp/aghhttp.go b/internal/aghhttp/aghhttp.go
index 23f9f5d3..8f786749 100644
--- a/internal/aghhttp/aghhttp.go
+++ b/internal/aghhttp/aghhttp.go
@@ -2,10 +2,12 @@
 package aghhttp
 
 import (
+	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 
+	"github.com/AdguardTeam/AdGuardHome/internal/version"
 	"github.com/AdguardTeam/golibs/log"
 )
 
@@ -34,3 +36,40 @@ func Error(r *http.Request, w http.ResponseWriter, code int, format string, args
 	log.Error("%s %s: %s", r.Method, r.URL, text)
 	http.Error(w, text, code)
 }
+
+// UserAgent returns the ID of the service as a User-Agent string.  It can also
+// be used as the value of the Server HTTP header.
+func UserAgent() (ua string) {
+	return fmt.Sprintf("AdGuardHome/%s", version.Version())
+}
+
+// textPlainDeprMsg is the message returned to API users when they try to use
+// an API that used to accept "text/plain" but doesn't anymore.
+const textPlainDeprMsg = `using this api with the text/plain content-type is deprecated; ` +
+	`use application/json`
+
+// WriteTextPlainDeprecated responds to the request with a message about
+// deprecation and removal of a plain-text API if the request is made with the
+// "text/plain" content-type.
+func WriteTextPlainDeprecated(w http.ResponseWriter, r *http.Request) (isPlainText bool) {
+	if r.Header.Get(HdrNameContentType) != HdrValTextPlain {
+		return false
+	}
+
+	Error(r, w, http.StatusUnsupportedMediaType, textPlainDeprMsg)
+
+	return true
+}
+
+// WriteJSONResponse sets the content-type header in w.Header() to
+// "application/json", encodes resp to w, calls Error on any returned error, and
+// returns it as well.
+func WriteJSONResponse(w http.ResponseWriter, r *http.Request, resp any) (err error) {
+	w.Header().Set(HdrNameContentType, HdrValApplicationJSON)
+	err = json.NewEncoder(w).Encode(resp)
+	if err != nil {
+		Error(r, w, http.StatusInternalServerError, "encoding resp: %s", err)
+	}
+
+	return err
+}
diff --git a/internal/aghhttp/header.go b/internal/aghhttp/header.go
new file mode 100644
index 00000000..1509a7e0
--- /dev/null
+++ b/internal/aghhttp/header.go
@@ -0,0 +1,22 @@
+package aghhttp
+
+// HTTP Headers
+
+// HTTP header name constants.
+//
+// TODO(a.garipov): Remove unused.
+const (
+	HdrNameAcceptEncoding           = "Accept-Encoding"
+	HdrNameAccessControlAllowOrigin = "Access-Control-Allow-Origin"
+	HdrNameContentType              = "Content-Type"
+	HdrNameContentEncoding          = "Content-Encoding"
+	HdrNameServer                   = "Server"
+	HdrNameTrailer                  = "Trailer"
+	HdrNameUserAgent                = "User-Agent"
+)
+
+// HTTP header value constants.
+const (
+	HdrValApplicationJSON = "application/json"
+	HdrValTextPlain       = "text/plain"
+)
diff --git a/internal/dhcpd/http_unix.go b/internal/dhcpd/http_unix.go
index 8a32dab6..de06431f 100644
--- a/internal/dhcpd/http_unix.go
+++ b/internal/dhcpd/http_unix.go
@@ -5,11 +5,9 @@ package dhcpd
 import (
 	"encoding/json"
 	"fmt"
-	"io"
 	"net"
 	"net/http"
 	"os"
-	"strings"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/aghalg"
 	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
@@ -410,31 +408,37 @@ type dhcpSearchResult struct {
 	V6 dhcpSearchV6Result `json:"v6"`
 }
 
-// Perform the following tasks:
-// . Search for another DHCP server running
-// . Check if a static IP is configured for the network interface
-// Respond with results
+// findActiveServerReq is the JSON structure for the request to find active DHCP
+// servers.
+type findActiveServerReq struct {
+	Interface string `json:"interface"`
+}
+
+// handleDHCPFindActiveServer performs the following tasks:
+//  1. searches for another DHCP server in the network;
+//  2. check if a static IP is configured for the network interface;
+//  3. responds with the results.
 func (s *server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Request) {
-	// This use of ReadAll is safe, because request's body is now limited.
-	body, err := io.ReadAll(r.Body)
+	if aghhttp.WriteTextPlainDeprecated(w, r) {
+		return
+	}
+
+	req := &findActiveServerReq{}
+	err := json.NewDecoder(r.Body).Decode(req)
 	if err != nil {
-		msg := fmt.Sprintf("failed to read request body: %s", err)
-		log.Error(msg)
-		http.Error(w, msg, http.StatusBadRequest)
+		aghhttp.Error(r, w, http.StatusBadRequest, "reading req: %s", err)
 
 		return
 	}
 
-	ifaceName := strings.TrimSpace(string(body))
+	ifaceName := req.Interface
 	if ifaceName == "" {
-		msg := "empty interface name specified"
-		log.Error(msg)
-		http.Error(w, msg, http.StatusBadRequest)
+		aghhttp.Error(r, w, http.StatusBadRequest, "empty interface name")
 
 		return
 	}
 
-	result := dhcpSearchResult{
+	result := &dhcpSearchResult{
 		V4: dhcpSearchV4Result{
 			OtherServer: dhcpSearchOtherResult{
 				Found: "no",
@@ -459,6 +463,14 @@ func (s *server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Reque
 		result.V4.StaticIP.IP = aghnet.GetSubnet(ifaceName).String()
 	}
 
+	setOtherDHCPResult(ifaceName, result)
+
+	_ = aghhttp.WriteJSONResponse(w, r, result)
+}
+
+// setOtherDHCPResult sets the results of the check for another DHCP server in
+// result.
+func setOtherDHCPResult(ifaceName string, result *dhcpSearchResult) {
 	found4, found6, err4, err6 := aghnet.CheckOtherDHCP(ifaceName)
 	if err4 != nil {
 		result.V4.OtherServer.Found = "error"
@@ -466,24 +478,13 @@ func (s *server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Reque
 	} else if found4 {
 		result.V4.OtherServer.Found = "yes"
 	}
+
 	if err6 != nil {
 		result.V6.OtherServer.Found = "error"
 		result.V6.OtherServer.Error = err6.Error()
 	} else if found6 {
 		result.V6.OtherServer.Found = "yes"
 	}
-
-	w.Header().Set("Content-Type", "application/json")
-	err = json.NewEncoder(w).Encode(result)
-	if err != nil {
-		aghhttp.Error(
-			r,
-			w,
-			http.StatusInternalServerError,
-			"Failed to marshal DHCP found json: %s",
-			err,
-		)
-	}
 }
 
 func (s *server) handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/filtering/controlfiltering.go b/internal/filtering/http.go
similarity index 97%
rename from internal/filtering/controlfiltering.go
rename to internal/filtering/http.go
index 1cce8ded..50890f93 100644
--- a/internal/filtering/controlfiltering.go
+++ b/internal/filtering/http.go
@@ -3,13 +3,11 @@ package filtering
 import (
 	"encoding/json"
 	"fmt"
-	"io"
 	"net"
 	"net/http"
 	"net/url"
 	"os"
 	"path/filepath"
-	"strings"
 	"time"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
@@ -249,16 +247,25 @@ func (d *DNSFilter) handleFilteringSetURL(w http.ResponseWriter, r *http.Request
 	}
 }
 
+// filteringRulesReq is the JSON structure for settings custom filtering rules.
+type filteringRulesReq struct {
+	Rules []string `json:"rules"`
+}
+
 func (d *DNSFilter) handleFilteringSetRules(w http.ResponseWriter, r *http.Request) {
-	// This use of ReadAll is safe, because request's body is now limited.
-	body, err := io.ReadAll(r.Body)
+	if aghhttp.WriteTextPlainDeprecated(w, r) {
+		return
+	}
+
+	req := &filteringRulesReq{}
+	err := json.NewDecoder(r.Body).Decode(req)
 	if err != nil {
-		aghhttp.Error(r, w, http.StatusBadRequest, "Failed to read request body: %s", err)
+		aghhttp.Error(r, w, http.StatusBadRequest, "reading req: %s", err)
 
 		return
 	}
 
-	d.UserRules = strings.Split(string(body), "\n")
+	d.UserRules = req.Rules
 	d.ConfigModified()
 	d.EnableFilters(true)
 }
diff --git a/internal/home/auth.go b/internal/home/auth.go
index ca708f4a..14d2f52b 100644
--- a/internal/home/auth.go
+++ b/internal/home/auth.go
@@ -8,12 +8,14 @@ import (
 	"fmt"
 	"net"
 	"net/http"
+	"path"
 	"strconv"
 	"strings"
 	"sync"
 	"time"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
+	"github.com/AdguardTeam/golibs/errors"
 	"github.com/AdguardTeam/golibs/log"
 	"github.com/AdguardTeam/golibs/netutil"
 	"github.com/AdguardTeam/golibs/timeutil"
@@ -32,7 +34,8 @@ const sessionTokenSize = 16
 
 type session struct {
 	userName string
-	expire   uint32 // expiration time (in seconds)
+	// expire is the expiration time, in seconds.
+	expire uint32
 }
 
 func (s *session) serialize() []byte {
@@ -64,29 +67,29 @@ func (s *session) deserialize(data []byte) bool {
 
 // Auth - global object
 type Auth struct {
-	db         *bbolt.DB
-	blocker    *authRateLimiter
-	sessions   map[string]*session
-	users      []User
-	lock       sync.Mutex
-	sessionTTL uint32
+	db          *bbolt.DB
+	raleLimiter *authRateLimiter
+	sessions    map[string]*session
+	users       []webUser
+	lock        sync.Mutex
+	sessionTTL  uint32
 }
 
-// User object
-type User struct {
+// webUser represents a user of the Web UI.
+type webUser struct {
 	Name         string `yaml:"name"`
-	PasswordHash string `yaml:"password"` // bcrypt hash
+	PasswordHash string `yaml:"password"`
 }
 
 // InitAuth - create a global object
-func InitAuth(dbFilename string, users []User, sessionTTL uint32, blocker *authRateLimiter) *Auth {
+func InitAuth(dbFilename string, users []webUser, sessionTTL uint32, rateLimiter *authRateLimiter) *Auth {
 	log.Info("Initializing auth module: %s", dbFilename)
 
 	a := &Auth{
-		sessionTTL: sessionTTL,
-		blocker:    blocker,
-		sessions:   make(map[string]*session),
-		users:      users,
+		sessionTTL:  sessionTTL,
+		raleLimiter: rateLimiter,
+		sessions:    make(map[string]*session),
+		users:       users,
 	}
 	var err error
 	a.db, err = bbolt.Open(dbFilename, 0o644, nil)
@@ -326,35 +329,25 @@ func newSessionToken() (data []byte, err error) {
 	return randData, nil
 }
 
-// cookieTimeFormat is the format to be used in (time.Time).Format for cookie's
-// expiry field.
-const cookieTimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT"
-
-// cookieExpiryFormat returns the formatted exp to be used in cookie string.
-// It's quite simple for now, but probably will be expanded in the future.
-func cookieExpiryFormat(exp time.Time) (formatted string) {
-	return exp.Format(cookieTimeFormat)
-}
-
-func (a *Auth) httpCookie(req loginJSON, addr string) (cookie string, err error) {
-	blocker := a.blocker
-	u := a.UserFind(req.Name, req.Password)
-	if len(u.Name) == 0 {
-		if blocker != nil {
-			blocker.inc(addr)
+// newCookie creates a new authentication cookie.
+func (a *Auth) newCookie(req loginJSON, addr string) (c *http.Cookie, err error) {
+	rateLimiter := a.raleLimiter
+	u, ok := a.findUser(req.Name, req.Password)
+	if !ok {
+		if rateLimiter != nil {
+			rateLimiter.inc(addr)
 		}
 
-		return "", err
+		return nil, errors.Error("invalid username or password")
 	}
 
-	if blocker != nil {
-		blocker.remove(addr)
+	if rateLimiter != nil {
+		rateLimiter.remove(addr)
 	}
 
-	var sess []byte
-	sess, err = newSessionToken()
+	sess, err := newSessionToken()
 	if err != nil {
-		return "", err
+		return nil, fmt.Errorf("generating token: %w", err)
 	}
 
 	now := time.Now().UTC()
@@ -364,11 +357,15 @@ func (a *Auth) httpCookie(req loginJSON, addr string) (cookie string, err error)
 		expire:   uint32(now.Unix()) + a.sessionTTL,
 	})
 
-	return fmt.Sprintf(
-		"%s=%s; Path=/; HttpOnly; Expires=%s",
-		sessionCookieName, hex.EncodeToString(sess),
-		cookieExpiryFormat(now.Add(cookieTTL)),
-	), nil
+	return &http.Cookie{
+		Name:    sessionCookieName,
+		Value:   hex.EncodeToString(sess),
+		Path:    "/",
+		Expires: now.Add(cookieTTL),
+
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+	}, nil
 }
 
 // realIP extracts the real IP address of the client from an HTTP request using
@@ -436,8 +433,8 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	if blocker := Context.auth.blocker; blocker != nil {
-		if left := blocker.check(remoteAddr); left > 0 {
+	if rateLimiter := Context.auth.raleLimiter; rateLimiter != nil {
+		if left := rateLimiter.check(remoteAddr); left > 0 {
 			w.Header().Set("Retry-After", strconv.Itoa(int(left.Seconds())))
 			aghhttp.Error(r, w, http.StatusTooManyRequests, "auth: blocked for %s", left)
 
@@ -445,10 +442,9 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	var cookie string
-	cookie, err = Context.auth.httpCookie(req, remoteAddr)
+	cookie, err := Context.auth.newCookie(req, remoteAddr)
 	if err != nil {
-		aghhttp.Error(r, w, http.StatusBadRequest, "crypto rand reader: %s", err)
+		aghhttp.Error(r, w, http.StatusForbidden, "%s", err)
 
 		return
 	}
@@ -462,20 +458,11 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
 		log.Error("auth: unknown ip")
 	}
 
-	if len(cookie) == 0 {
-		log.Info("auth: failed to login user %q from ip %v", req.Name, ip)
-
-		time.Sleep(1 * time.Second)
-
-		http.Error(w, "invalid username or password", http.StatusBadRequest)
-
-		return
-	}
-
 	log.Info("auth: user %q successfully logged in from ip %v", req.Name, ip)
 
+	http.SetCookie(w, cookie)
+
 	h := w.Header()
-	h.Set("Set-Cookie", cookie)
 	h.Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
 	h.Set("Pragma", "no-cache")
 	h.Set("Expires", "0")
@@ -484,17 +471,31 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
 }
 
 func handleLogout(w http.ResponseWriter, r *http.Request) {
-	cookie := r.Header.Get("Cookie")
-	sess := parseCookie(cookie)
+	respHdr := w.Header()
+	c, err := r.Cookie(sessionCookieName)
+	if err != nil {
+		// The only error that is returned from r.Cookie is [http.ErrNoCookie].
+		// The user is already logged out.
+		respHdr.Set("Location", "/login.html")
+		w.WriteHeader(http.StatusFound)
 
-	Context.auth.RemoveSession(sess)
+		return
+	}
 
-	w.Header().Set("Location", "/login.html")
+	Context.auth.RemoveSession(c.Value)
 
-	s := fmt.Sprintf("%s=; Path=/; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
-		sessionCookieName)
-	w.Header().Set("Set-Cookie", s)
+	c = &http.Cookie{
+		Name:    sessionCookieName,
+		Value:   "",
+		Path:    "/",
+		Expires: time.Unix(0, 0),
 
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+	}
+
+	respHdr.Set("Location", "/login.html")
+	respHdr.Set("Set-Cookie", c.String())
 	w.WriteHeader(http.StatusFound)
 }
 
@@ -504,101 +505,108 @@ func RegisterAuthHandlers() {
 	httpRegister(http.MethodGet, "/control/logout", handleLogout)
 }
 
-func parseCookie(cookie string) string {
-	pairs := strings.Split(cookie, ";")
-	for _, pair := range pairs {
-		pair = strings.TrimSpace(pair)
-		kv := strings.SplitN(pair, "=", 2)
-		if len(kv) != 2 {
-			continue
-		}
-		if kv[0] == sessionCookieName {
-			return kv[1]
-		}
-	}
-	return ""
-}
-
 // optionalAuthThird return true if user should authenticate first.
-func optionalAuthThird(w http.ResponseWriter, r *http.Request) (authFirst bool) {
-	authFirst = false
+func optionalAuthThird(w http.ResponseWriter, r *http.Request) (mustAuth bool) {
+	if glProcessCookie(r) {
+		log.Debug("auth: authentication is handled by GL-Inet submodule")
+
+		return false
+	}
 
 	// redirect to login page if not authenticated
-	ok := false
+	isAuthenticated := false
 	cookie, err := r.Cookie(sessionCookieName)
-
-	if glProcessCookie(r) {
-		log.Debug("auth: authentication was handled by GL-Inet submodule")
-		ok = true
-	} else if err == nil {
-		r := Context.auth.checkSession(cookie.Value)
-		if r == checkSessionOK {
-			ok = true
-		} else if r < 0 {
-			log.Debug("auth: invalid cookie value: %s", cookie)
-		}
-	} else {
-		// there's no Cookie, check Basic authentication
-		user, pass, ok2 := r.BasicAuth()
-		if ok2 {
-			u := Context.auth.UserFind(user, pass)
-			if len(u.Name) != 0 {
-				ok = true
-			} else {
+	if err != nil {
+		// The only error that is returned from r.Cookie is [http.ErrNoCookie].
+		// Check Basic authentication.
+		user, pass, hasBasic := r.BasicAuth()
+		if hasBasic {
+			_, isAuthenticated = Context.auth.findUser(user, pass)
+			if !isAuthenticated {
 				log.Info("auth: invalid Basic Authorization value")
 			}
 		}
-	}
-	if !ok {
-		if r.URL.Path == "/" || r.URL.Path == "/index.html" {
-			if glProcessRedirect(w, r) {
-				log.Debug("auth: redirected to login page by GL-Inet submodule")
-			} else {
-				w.Header().Set("Location", "/login.html")
-				w.WriteHeader(http.StatusFound)
-			}
-		} else {
-			w.WriteHeader(http.StatusForbidden)
-			_, _ = w.Write([]byte("Forbidden"))
+	} else {
+		res := Context.auth.checkSession(cookie.Value)
+		isAuthenticated = res == checkSessionOK
+		if !isAuthenticated {
+			log.Debug("auth: invalid cookie value: %s", cookie)
 		}
-		authFirst = true
 	}
 
-	return authFirst
+	if isAuthenticated {
+		return false
+	}
+
+	if p := r.URL.Path; p == "/" || p == "/index.html" {
+		if glProcessRedirect(w, r) {
+			log.Debug("auth: redirected to login page by GL-Inet submodule")
+		} else {
+			log.Debug("auth: redirected to login page")
+			w.Header().Set("Location", "/login.html")
+			w.WriteHeader(http.StatusFound)
+		}
+	} else {
+		log.Debug("auth: responded with forbidden to %s %s", r.Method, p)
+		w.WriteHeader(http.StatusForbidden)
+		_, _ = w.Write([]byte("Forbidden"))
+	}
+
+	return true
 }
 
-func optionalAuth(handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
+// TODO(a.garipov): Use [http.Handler] consistently everywhere throughout the
+// project.
+func optionalAuth(
+	h func(http.ResponseWriter, *http.Request),
+) (wrapped func(http.ResponseWriter, *http.Request)) {
 	return func(w http.ResponseWriter, r *http.Request) {
-		if r.URL.Path == "/login.html" {
-			// redirect to dashboard if already authenticated
-			authRequired := Context.auth != nil && Context.auth.AuthRequired()
+		p := r.URL.Path
+		authRequired := Context.auth != nil && Context.auth.AuthRequired()
+		if p == "/login.html" {
 			cookie, err := r.Cookie(sessionCookieName)
 			if authRequired && err == nil {
-				r := Context.auth.checkSession(cookie.Value)
-				if r == checkSessionOK {
+				// Redirect to the dashboard if already authenticated.
+				res := Context.auth.checkSession(cookie.Value)
+				if res == checkSessionOK {
 					w.Header().Set("Location", "/")
 					w.WriteHeader(http.StatusFound)
 
 					return
-				} else if r == checkSessionNotFound {
-					log.Debug("auth: invalid cookie value: %s", cookie)
 				}
-			}
 
-		} else if strings.HasPrefix(r.URL.Path, "/assets/") ||
-			strings.HasPrefix(r.URL.Path, "/login.") {
-			// process as usual
-			// no additional auth requirements
-		} else if Context.auth != nil && Context.auth.AuthRequired() {
+				log.Debug("auth: invalid cookie value: %s", cookie)
+			}
+		} else if isPublicResource(p) {
+			// Process as usual, no additional auth requirements.
+		} else if authRequired {
 			if optionalAuthThird(w, r) {
 				return
 			}
 		}
 
-		handler(w, r)
+		h(w, r)
 	}
 }
 
+// isPublicResource returns true if p is a path to a public resource.
+func isPublicResource(p string) (ok bool) {
+	isAsset, err := path.Match("/assets/*", p)
+	if err != nil {
+		// The only error that is returned from path.Match is
+		// [path.ErrBadPattern].  This is a programmer error.
+		panic(fmt.Errorf("bad asset pattern: %w", err))
+	}
+
+	isLogin, err := path.Match("/login.*", p)
+	if err != nil {
+		// Same as above.
+		panic(fmt.Errorf("bad login pattern: %w", err))
+	}
+
+	return isAsset || isLogin
+}
+
 type authHandler struct {
 	handler http.Handler
 }
@@ -612,7 +620,7 @@ func optionalAuthHandler(handler http.Handler) http.Handler {
 }
 
 // UserAdd - add new user
-func (a *Auth) UserAdd(u *User, password string) {
+func (a *Auth) UserAdd(u *webUser, password string) {
 	if len(password) == 0 {
 		return
 	}
@@ -631,31 +639,35 @@ func (a *Auth) UserAdd(u *User, password string) {
 	log.Debug("auth: added user: %s", u.Name)
 }
 
-// UserFind - find a user
-func (a *Auth) UserFind(login, password string) User {
+// findUser returns a user if there is one.
+func (a *Auth) findUser(login, password string) (u webUser, ok bool) {
 	a.lock.Lock()
 	defer a.lock.Unlock()
-	for _, u := range a.users {
+
+	for _, u = range a.users {
 		if u.Name == login &&
 			bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil {
-			return u
+			return u, true
 		}
 	}
-	return User{}
+
+	return webUser{}, false
 }
 
 // getCurrentUser returns the current user.  It returns an empty User if the
 // user is not found.
-func (a *Auth) getCurrentUser(r *http.Request) User {
+func (a *Auth) getCurrentUser(r *http.Request) (u webUser) {
 	cookie, err := r.Cookie(sessionCookieName)
 	if err != nil {
 		// There's no Cookie, check Basic authentication.
 		user, pass, ok := r.BasicAuth()
 		if ok {
-			return Context.auth.UserFind(user, pass)
+			u, _ = Context.auth.findUser(user, pass)
+
+			return u
 		}
 
-		return User{}
+		return webUser{}
 	}
 
 	a.lock.Lock()
@@ -663,20 +675,20 @@ func (a *Auth) getCurrentUser(r *http.Request) User {
 
 	s, ok := a.sessions[cookie.Value]
 	if !ok {
-		return User{}
+		return webUser{}
 	}
 
-	for _, u := range a.users {
+	for _, u = range a.users {
 		if u.Name == s.userName {
 			return u
 		}
 	}
 
-	return User{}
+	return webUser{}
 }
 
 // GetUsers - get users
-func (a *Auth) GetUsers() []User {
+func (a *Auth) GetUsers() []webUser {
 	a.lock.Lock()
 	users := a.users
 	a.lock.Unlock()
diff --git a/internal/home/auth_test.go b/internal/home/auth_test.go
index 6a2ebea7..1bf38753 100644
--- a/internal/home/auth_test.go
+++ b/internal/home/auth_test.go
@@ -43,14 +43,14 @@ func TestAuth(t *testing.T) {
 	dir := t.TempDir()
 	fn := filepath.Join(dir, "sessions.db")
 
-	users := []User{{
+	users := []webUser{{
 		Name:         "name",
 		PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2",
 	}}
 	a := InitAuth(fn, nil, 60, nil)
 	s := session{}
 
-	user := User{Name: "name"}
+	user := webUser{Name: "name"}
 	a.UserAdd(&user, "password")
 
 	assert.Equal(t, checkSessionNotFound, a.checkSession("notfound"))
@@ -84,7 +84,8 @@ func TestAuth(t *testing.T) {
 	a.storeSession(sess, &s)
 	a.Close()
 
-	u := a.UserFind("name", "password")
+	u, ok := a.findUser("name", "password")
+	assert.True(t, ok)
 	assert.NotEmpty(t, u.Name)
 
 	time.Sleep(3 * time.Second)
@@ -118,7 +119,7 @@ func TestAuthHTTP(t *testing.T) {
 	dir := t.TempDir()
 	fn := filepath.Join(dir, "sessions.db")
 
-	users := []User{
+	users := []webUser{
 		{Name: "name", PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2"},
 	}
 	Context.auth = InitAuth(fn, users, 60, nil)
@@ -150,18 +151,19 @@ func TestAuthHTTP(t *testing.T) {
 	assert.True(t, handlerCalled)
 
 	// perform login
-	cookie, err := Context.auth.httpCookie(loginJSON{Name: "name", Password: "password"}, "")
+	cookie, err := Context.auth.newCookie(loginJSON{Name: "name", Password: "password"}, "")
 	require.NoError(t, err)
-	assert.NotEmpty(t, cookie)
+	require.NotNil(t, cookie)
 
 	// get /
 	handler2 = optionalAuth(handler)
 	w.hdr = make(http.Header)
-	r.Header.Set("Cookie", cookie)
+	r.Header.Set("Cookie", cookie.String())
 	r.URL = &url.URL{Path: "/"}
 	handlerCalled = false
 	handler2(&w, &r)
 	assert.True(t, handlerCalled)
+
 	r.Header.Del("Cookie")
 
 	// get / with basic auth
@@ -177,7 +179,7 @@ func TestAuthHTTP(t *testing.T) {
 	// get login page with a valid cookie - we're redirected to /
 	handler2 = optionalAuth(handler)
 	w.hdr = make(http.Header)
-	r.Header.Set("Cookie", cookie)
+	r.Header.Set("Cookie", cookie.String())
 	r.URL = &url.URL{Path: loginURL}
 	handlerCalled = false
 	handler2(&w, &r)
diff --git a/internal/home/clientshttp.go b/internal/home/clientshttp.go
index 5f10ccbe..59821d0a 100644
--- a/internal/home/clientshttp.go
+++ b/internal/home/clientshttp.go
@@ -93,13 +93,7 @@ func (clients *clientsContainer) handleGetClients(w http.ResponseWriter, r *http
 
 	data.Tags = clientTags
 
-	w.Header().Set("Content-Type", "application/json")
-	e := json.NewEncoder(w).Encode(data)
-	if e != nil {
-		aghhttp.Error(r, w, http.StatusInternalServerError, "failed to encode to json: %v", e)
-
-		return
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, data)
 }
 
 // Convert JSON object to Client object
@@ -249,11 +243,7 @@ func (clients *clientsContainer) handleFindClient(w http.ResponseWriter, r *http
 		})
 	}
 
-	w.Header().Set("Content-Type", "application/json")
-	err := json.NewEncoder(w).Encode(data)
-	if err != nil {
-		aghhttp.Error(r, w, http.StatusInternalServerError, "Couldn't write response: %s", err)
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, data)
 }
 
 // findRuntime looks up the IP in runtime and temporary storages, like
diff --git a/internal/home/config.go b/internal/home/config.go
index ff597761..ed337c86 100644
--- a/internal/home/config.go
+++ b/internal/home/config.go
@@ -85,10 +85,10 @@ type configuration struct {
 	// It's reset after config is parsed
 	fileData []byte
 
-	BindHost     net.IP `yaml:"bind_host"`      // BindHost is the IP address of the HTTP server to bind to
-	BindPort     int    `yaml:"bind_port"`      // BindPort is the port the HTTP server
-	BetaBindPort int    `yaml:"beta_bind_port"` // BetaBindPort is the port for new client
-	Users        []User `yaml:"users"`          // Users that can access HTTP server
+	BindHost     net.IP    `yaml:"bind_host"`      // BindHost is the IP address of the HTTP server to bind to
+	BindPort     int       `yaml:"bind_port"`      // BindPort is the port the HTTP server
+	BetaBindPort int       `yaml:"beta_bind_port"` // BetaBindPort is the port for new client
+	Users        []webUser `yaml:"users"`          // Users that can access HTTP server
 	// AuthAttempts is the maximum number of failed login attempts a user
 	// can do before being blocked.
 	AuthAttempts uint `yaml:"auth_attempts"`
diff --git a/internal/home/control.go b/internal/home/control.go
index 829063e9..881e957a 100644
--- a/internal/home/control.go
+++ b/internal/home/control.go
@@ -146,13 +146,7 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
 		resp.IsDHCPAvailable = Context.dhcpServer != nil
 	}
 
-	w.Header().Set("Content-Type", "application/json")
-	err = json.NewEncoder(w).Encode(resp)
-	if err != nil {
-		aghhttp.Error(r, w, http.StatusInternalServerError, "Unable to write response json: %s", err)
-
-		return
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, resp)
 }
 
 type profileJSON struct {
@@ -162,13 +156,16 @@ type profileJSON struct {
 func handleGetProfile(w http.ResponseWriter, r *http.Request) {
 	pj := profileJSON{}
 	u := Context.auth.getCurrentUser(r)
+
 	pj.Name = u.Name
 
 	data, err := json.Marshal(pj)
 	if err != nil {
 		aghhttp.Error(r, w, http.StatusInternalServerError, "json.Marshal: %s", err)
+
 		return
 	}
+
 	_, _ = w.Write(data)
 }
 
@@ -207,11 +204,24 @@ func ensure(method string, handler func(http.ResponseWriter, *http.Request)) fun
 		log.Debug("%s %v", r.Method, r.URL)
 
 		if r.Method != method {
-			http.Error(w, "This request must be "+method, http.StatusMethodNotAllowed)
+			aghhttp.Error(r, w, http.StatusMethodNotAllowed, "only %s is allowed", method)
+
 			return
 		}
 
 		if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete {
+			if r.Header.Get(aghhttp.HdrNameContentType) != aghhttp.HdrValApplicationJSON {
+				aghhttp.Error(
+					r,
+					w,
+					http.StatusUnsupportedMediaType,
+					"only %s is allowed",
+					aghhttp.HdrValApplicationJSON,
+				)
+
+				return
+			}
+
 			Context.controlLock.Lock()
 			defer Context.controlLock.Unlock()
 		}
diff --git a/internal/home/controlinstall.go b/internal/home/controlinstall.go
index c46f3459..78de64f7 100644
--- a/internal/home/controlinstall.go
+++ b/internal/home/controlinstall.go
@@ -59,19 +59,7 @@ func (web *Web) handleInstallGetAddresses(w http.ResponseWriter, r *http.Request
 		data.Interfaces[iface.Name] = iface
 	}
 
-	w.Header().Set("Content-Type", "application/json")
-	err = json.NewEncoder(w).Encode(data)
-	if err != nil {
-		aghhttp.Error(
-			r,
-			w,
-			http.StatusInternalServerError,
-			"Unable to marshal default addresses to json: %s",
-			err,
-		)
-
-		return
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, data)
 }
 
 type checkConfReqEnt struct {
@@ -201,13 +189,7 @@ func (web *Web) handleInstallCheckConfig(w http.ResponseWriter, r *http.Request)
 		resp.StaticIP = handleStaticIP(req.DNS.IP, req.SetStaticIP)
 	}
 
-	w.Header().Set("Content-Type", "application/json")
-	err = json.NewEncoder(w).Encode(resp)
-	if err != nil {
-		aghhttp.Error(r, w, http.StatusInternalServerError, "encoding the response: %s", err)
-
-		return
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, resp)
 }
 
 // handleStaticIP - handles static IP request
@@ -424,7 +406,7 @@ func (web *Web) handleInstallConfigure(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	u := &User{
+	u := &webUser{
 		Name: req.Username,
 	}
 	Context.auth.UserAdd(u, req.Password)
@@ -688,19 +670,7 @@ func (web *Web) handleInstallGetAddressesBeta(w http.ResponseWriter, r *http.Req
 
 	data.Interfaces = ifaces
 
-	w.Header().Set("Content-Type", "application/json")
-	err = json.NewEncoder(w).Encode(data)
-	if err != nil {
-		aghhttp.Error(
-			r,
-			w,
-			http.StatusInternalServerError,
-			"Unable to marshal default addresses to json: %s",
-			err,
-		)
-
-		return
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, data)
 }
 
 // registerBetaInstallHandlers registers the install handlers for new client
diff --git a/internal/home/controlupdate.go b/internal/home/controlupdate.go
index 91164696..ef4f0659 100644
--- a/internal/home/controlupdate.go
+++ b/internal/home/controlupdate.go
@@ -28,8 +28,6 @@ type temporaryError interface {
 
 // Get the latest available version from the Internet
 func handleGetVersionJSON(w http.ResponseWriter, r *http.Request) {
-	w.Header().Set("Content-Type", "application/json")
-
 	resp := &versionResponse{}
 	if Context.disableUpdate {
 		resp.Disabled = true
@@ -71,10 +69,7 @@ func handleGetVersionJSON(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	err = json.NewEncoder(w).Encode(resp)
-	if err != nil {
-		aghhttp.Error(r, w, http.StatusInternalServerError, "writing body: %s", err)
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, resp)
 }
 
 // requestVersionInfo sets the VersionInfo field of resp if it can reach the
diff --git a/internal/home/home.go b/internal/home/home.go
index 0f88f57b..f917b6a5 100644
--- a/internal/home/home.go
+++ b/internal/home/home.go
@@ -409,7 +409,7 @@ func run(args options, clientBuildFS fs.FS) {
 	configureLogger(args)
 
 	// Print the first message after logger is configured.
-	log.Println(version.Full())
+	log.Info(version.Full())
 	log.Debug("current working directory is %s", Context.workDir)
 	if args.runningAsService {
 		log.Info("AdGuard Home is running as a service")
@@ -455,9 +455,9 @@ func run(args options, clientBuildFS fs.FS) {
 
 	sessFilename := filepath.Join(Context.getDataDir(), "sessions.db")
 	GLMode = args.glinetMode
-	var arl *authRateLimiter
+	var rateLimiter *authRateLimiter
 	if config.AuthAttempts > 0 && config.AuthBlockMin > 0 {
-		arl = newAuthRateLimiter(
+		rateLimiter = newAuthRateLimiter(
 			time.Duration(config.AuthBlockMin)*time.Minute,
 			config.AuthAttempts,
 		)
@@ -469,7 +469,7 @@ func run(args options, clientBuildFS fs.FS) {
 		sessFilename,
 		config.Users,
 		config.WebSessionTTLHours*60*60,
-		arl,
+		rateLimiter,
 	)
 	if Context.auth == nil {
 		log.Fatalf("Couldn't initialize Auth module")
diff --git a/internal/home/i18n.go b/internal/home/i18n.go
index d58dfcf3..cc8da8fe 100644
--- a/internal/home/i18n.go
+++ b/internal/home/i18n.go
@@ -1,10 +1,8 @@
 package home
 
 import (
-	"fmt"
-	"io"
+	"encoding/json"
 	"net/http"
-	"strings"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
 	"github.com/AdguardTeam/golibs/log"
@@ -51,43 +49,35 @@ var allowedLanguages = stringutil.NewSet(
 	"zh-tw",
 )
 
-func handleI18nCurrentLanguage(w http.ResponseWriter, _ *http.Request) {
-	w.Header().Set("Content-Type", "text/plain")
-	log.Printf("config.Language is %s", config.Language)
-	_, err := fmt.Fprintf(w, "%s\n", config.Language)
-	if err != nil {
-		msg := fmt.Sprintf("Unable to write response json: %s", err)
-		log.Println(msg)
-		http.Error(w, msg, http.StatusInternalServerError)
+// languageJSON is the JSON structure for language requests and responses.
+type languageJSON struct {
+	Language string `json:"language"`
+}
 
-		return
-	}
+func handleI18nCurrentLanguage(w http.ResponseWriter, r *http.Request) {
+	log.Printf("home: language is %s", config.Language)
+
+	_ = aghhttp.WriteJSONResponse(w, r, &languageJSON{
+		Language: config.Language,
+	})
 }
 
 func handleI18nChangeLanguage(w http.ResponseWriter, r *http.Request) {
-	// This use of ReadAll is safe, because request's body is now limited.
-	body, err := io.ReadAll(r.Body)
+	if aghhttp.WriteTextPlainDeprecated(w, r) {
+		return
+	}
+
+	langReq := &languageJSON{}
+	err := json.NewDecoder(r.Body).Decode(langReq)
 	if err != nil {
-		msg := fmt.Sprintf("failed to read request body: %s", err)
-		log.Println(msg)
-		http.Error(w, msg, http.StatusBadRequest)
+		aghhttp.Error(r, w, http.StatusInternalServerError, "reading req: %s", err)
 
 		return
 	}
 
-	language := strings.TrimSpace(string(body))
-	if language == "" {
-		msg := "empty language specified"
-		log.Println(msg)
-		http.Error(w, msg, http.StatusBadRequest)
-
-		return
-	}
-
-	if !allowedLanguages.Has(language) {
-		msg := fmt.Sprintf("unknown language specified: %s", language)
-		log.Println(msg)
-		http.Error(w, msg, http.StatusBadRequest)
+	lang := langReq.Language
+	if !allowedLanguages.Has(lang) {
+		aghhttp.Error(r, w, http.StatusBadRequest, "unknown language: %q", lang)
 
 		return
 	}
@@ -96,7 +86,8 @@ func handleI18nChangeLanguage(w http.ResponseWriter, r *http.Request) {
 		config.Lock()
 		defer config.Unlock()
 
-		config.Language = language
+		config.Language = lang
+		log.Printf("home: language is set to %s", lang)
 	}()
 
 	onConfigModified()
diff --git a/internal/home/service.go b/internal/home/service.go
index c670ebe2..e52f9799 100644
--- a/internal/home/service.go
+++ b/internal/home/service.go
@@ -176,7 +176,8 @@ func handleServiceControlAction(opts options, clientBuildFS fs.FS) {
 	chooseSystem()
 
 	action := opts.serviceControlAction
-	log.Printf("service: control action: %s", action)
+	log.Info(version.Full())
+	log.Info("service: control action: %s", action)
 
 	if action == "reload" {
 		sendSigReload()
diff --git a/internal/home/tls.go b/internal/home/tls.go
index b454e152..359a7a9d 100644
--- a/internal/home/tls.go
+++ b/internal/home/tls.go
@@ -680,8 +680,6 @@ func unmarshalTLS(r *http.Request) (tlsConfigSettingsExt, error) {
 }
 
 func marshalTLS(w http.ResponseWriter, r *http.Request, data tlsConfig) {
-	w.Header().Set("Content-Type", "application/json")
-
 	if data.CertificateChain != "" {
 		encoded := base64.StdEncoding.EncodeToString([]byte(data.CertificateChain))
 		data.CertificateChain = encoded
@@ -692,16 +690,7 @@ func marshalTLS(w http.ResponseWriter, r *http.Request, data tlsConfig) {
 		data.PrivateKey = ""
 	}
 
-	err := json.NewEncoder(w).Encode(data)
-	if err != nil {
-		aghhttp.Error(
-			r,
-			w,
-			http.StatusInternalServerError,
-			"Failed to marshal json with TLS status: %s",
-			err,
-		)
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, data)
 }
 
 // registerWebHandlers registers HTTP handlers for TLS configuration
diff --git a/internal/home/upgrade.go b/internal/home/upgrade.go
index 63e17030..132a82af 100644
--- a/internal/home/upgrade.go
+++ b/internal/home/upgrade.go
@@ -278,11 +278,11 @@ func upgradeSchema4to5(diskConf yobj) error {
 		log.Fatalf("Can't use password \"%s\": bcrypt.GenerateFromPassword: %s", passStr, err)
 		return nil
 	}
-	u := User{
+	u := webUser{
 		Name:         nameStr,
 		PasswordHash: string(hash),
 	}
-	users := []User{u}
+	users := []webUser{u}
 	diskConf["users"] = users
 	return nil
 }
diff --git a/openapi/CHANGELOG.md b/openapi/CHANGELOG.md
index fbbe7169..d9946115 100644
--- a/openapi/CHANGELOG.md
+++ b/openapi/CHANGELOG.md
@@ -4,6 +4,64 @@
 
 ## v0.108.0: API changes
 
+
+
+## v0.107.14: BREAKING API CHANGES
+
+A Cross-Site Request Forgery (CSRF) vulnerability has been discovered.  We have
+implemented several measures to prevent such vulnerabilities in the future, but
+some of these measures break backwards compatibility for the sake of better
+protection.
+
+All new formats for the request and response bodies are documented in
+`openapi.yaml`.
+
+### `POST /control/filtering/set_rules` And Other Plain-Text APIs
+
+The following APIs, which previously accepted or returned `text/plain` data,
+now accept or return data as JSON.
+
+#### `POST /control/filtering/set_rules`
+
+Previously, the API accepted a raw list of filters as a plain-text file.  Now,
+the filters must be presented in a JSON object with the following format:
+
+```json
+{
+  "rules":
+  [
+    "||example.com^",
+    "# comment",
+    "@@||www.example.com^"
+  ]
+}
+```
+
+#### `GET /control/i18n/current_language` And `POST /control/i18n/change_language`
+
+Previously, these APIs accepted and returned the language code in plain text.
+Now, they accept and return them in a JSON object with the following format:
+
+```json
+{
+  "language": "en"
+}
+```
+
+#### `POST /control/dhcp/find_active_dhcp`
+
+Previously, the API accepted the name of the network interface as a plain-text
+string.  Now, it must be contained within a JSON object with the following
+format:
+
+```json
+{
+  "interface": "eth0"
+}
+```
+
+
+
 ## v0.107.12: API changes
 
 ### `GET /control/blocked_services/services`
@@ -11,6 +69,8 @@
 * The new `GET /control/blocked_services/services` HTTP API allows inspecting
   all available services.
 
+
+
 ## v0.107.7: API changes
 
 ### The new optional field `"ecs"` in `QueryLogItem`
@@ -24,6 +84,8 @@
   `POST /install/configure` which means that the specified password does not
   meet the strength requirements.
 
+
+
 ## v0.107.3: API changes
 
 ### The new field `"version"` in `AddressesInfo`
@@ -31,6 +93,8 @@
 * The new field `"version"` in `GET /install/get_addresses` is the version of
   the AdGuard Home instance.
 
+
+
 ## v0.107.0: API changes
 
 ### The new field `"cached"` in `QueryLogItem`
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index ad57d807..c6451fa7 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -413,6 +413,11 @@
       - 'dhcp'
       'operationId': 'checkActiveDhcp'
       'summary': 'Searches for an active DHCP server on the network'
+      'requestBody':
+        'content':
+          'application/json':
+            'schema':
+              '$ref': '#/components/schemas/DhcpFindActiveReq'
       'responses':
         '200':
           'description': 'OK.'
@@ -667,24 +672,6 @@
       - 'parental'
       'operationId': 'parentalEnable'
       'summary': 'Enable parental filtering'
-      'requestBody':
-        'content':
-          'text/plain':
-            'schema':
-              'type': 'string'
-              'enum':
-              - 'EARLY_CHILDHOOD'
-              - 'YOUNG'
-              - 'TEEN'
-              - 'MATURE'
-              'example': 'sensitivity=TEEN'
-        'description': |
-          Age sensitivity for parental filtering,
-          EARLY_CHILDHOOD is 3
-          YOUNG is 10
-          TEEN is 13
-          MATURE is 17
-        'required': true
       'responses':
         '200':
           'description': 'OK.'
@@ -958,10 +945,9 @@
         Change current language.  Argument must be an ISO 639-1 two-letter code.
       'requestBody':
         'content':
-          'text/plain':
+          'application/json':
             'schema':
-              'type': 'string'
-              'example': 'en'
+              '$ref': '#/components/schemas/LanguageSettings'
         'description': >
           New language.  It must be known to the server and must be an ISO 639-1
           two-letter code.
@@ -980,10 +966,9 @@
         '200':
           'description': 'OK.'
           'content':
-            'text/plain':
-              'examples':
-                'response':
-                  'value': 'en'
+            'application/json':
+              'schema':
+                '$ref': '#/components/schemas/LanguageSettings'
   '/install/get_addresses_beta':
     'get':
       'tags':
@@ -1777,6 +1762,16 @@
       'additionalProperties':
         '$ref': '#/components/schemas/NetInterface'
 
+    'DhcpFindActiveReq':
+      'description': >
+        Request for checking for other DHCP servers in the network.
+      'properties':
+        'interface':
+          'description': 'The name of the network interface'
+          'example': 'eth0'
+          'type': 'string'
+      'type': 'object'
+
     'DhcpSearchResult':
       'type': 'object'
       'description': >
@@ -2692,6 +2687,15 @@
           'description': 'The error message, an opaque string.'
           'type': 'string'
       'type': 'object'
+    'LanguageSettings':
+      'description': 'Language settings object.'
+      'properties':
+        'language':
+          'description': 'The current language or the language to set.'
+          'type': 'string'
+      'required':
+      - 'language'
+      'type': 'object'
   'securitySchemes':
     'basicAuth':
       'type': 'http'

From 7b4886304121b14f29f546f238fa142095d6e4e2 Mon Sep 17 00:00:00 2001
From: Ainar Garipov <a.garipov@adguard.com>
Date: Thu, 29 Sep 2022 19:51:33 +0300
Subject: [PATCH 30/31] Pull request: upd-chlog

Merge in DNS/adguard-home from upd-chlog to master

Squashed commit of the following:

commit b53de96bc5d1bc0ff81ceb6c716614fd094913e7
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Thu Sep 29 19:46:36 2022 +0300

    all: upd chlog
---
 CHANGELOG.md | 48 ++++++++++++++++++++++++++++--------------------
 1 file changed, 28 insertions(+), 20 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed20426c..492ba690 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,11 +12,31 @@ and this project adheres to
 ## [Unreleased]
 
 <!--
-## [v0.108.0] - 2022-12-01 (APPROX.)
+## [v0.108.0] - TBA (APPROX.)
 -->
 
+
+
+<!--
+## [v0.107.15] - 2022-10-26 (APPROX.)
+
+See also the [v0.107.15 GitHub milestone][ms-v0.107.15].
+
+[ms-v0.107.15]:   https://github.com/AdguardTeam/AdGuardHome/milestone/51?closed=1
+-->
+
+
+
+## [v0.107.14] - 2022-09-29
+
+See also the [v0.107.14 GitHub milestone][ms-v0.107.14].
+
 ### Security
 
+A Cross-Site Request Forgery (CSRF) vulnerability has been discovered.  The CVE
+number is to be assigned.  We thank Daniel Elkabes from Mend.io for reporting
+this vulnerability to us.
+
 #### `SameSite` Policy
 
 The `SameSite` policy on the AdGuard Home session cookies is now set to `Lax`.
@@ -28,10 +48,9 @@ after updating.**
 
 #### Removal Of Plain-Text APIs (BREAKING API CHANGE)
 
-A Cross-Site Request Forgery (CSRF) vulnerability has been discovered.  We have
-implemented several measures to prevent such vulnerabilities in the future, but
-some of these measures break backwards compatibility for the sake of better
-protection.
+We have implemented several measures to prevent such vulnerabilities in the
+future, but some of these measures break backwards compatibility for the sake of
+better protection.
 
 The following APIs, which previously accepted or returned `text/plain` data,
 now accept or return data as JSON.  All new formats for the request and response
@@ -42,9 +61,6 @@ bodies are documented in `openapi/openapi.yaml` and `openapi/CHANGELOG.md`.
 - `POST /control/filtering/set_rules`;
 - `POST /control/i18n/change_language`.
 
-The CVE number is to be assigned.  We thank Daniel Elkabes from Mend.io for
-reporting this vulnerability to us.
-
 #### Stricter Content-Type Checks (BREAKING API CHANGE)
 
 All JSON APIs now check if the request actually has the `application/json`
@@ -68,16 +84,7 @@ content-type.
 [#4927]: https://github.com/AdguardTeam/AdGuardHome/issues/4927
 [#4930]: https://github.com/AdguardTeam/AdGuardHome/issues/4930
 
-
-
-
-<!--
-## [v0.107.14] - 2022-10-05 (APPROX.)
-
-See also the [v0.107.14 GitHub milestone][ms-v0.107.14].
-
 [ms-v0.107.14]:   https://github.com/AdguardTeam/AdGuardHome/milestone/50?closed=1
--->
 
 
 
@@ -1276,11 +1283,12 @@ See also the [v0.104.2 GitHub milestone][ms-v0.104.2].
 
 
 <!--
-[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.14...HEAD
-[v0.107.14]:  https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.13...v0.107.14
+[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.15...HEAD
+[v0.107.15]:  https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.14...v0.107.15
 -->
 
-[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.13...HEAD
+[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.14...HEAD
+[v0.107.14]:  https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.13...v0.107.14
 [v0.107.13]:  https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.12...v0.107.13
 [v0.107.12]:  https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.11...v0.107.12
 [v0.107.11]:  https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.10...v0.107.11

From 4d404b887fd56caecb7cec00191437e661ea4692 Mon Sep 17 00:00:00 2001
From: Ainar Garipov <a.garipov@adguard.com>
Date: Fri, 30 Sep 2022 14:41:25 +0300
Subject: [PATCH 31/31] Pull request: 4970-error-415

Updates #4970.

Squashed commit of the following:

commit 10365d9c8474e9d9735f581fb32b2892b2153cc4
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Fri Sep 30 14:23:06 2022 +0300

    all: imp docs, names

commit cff1103a0618a6430dc91e7e018febbf313c12ba
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Fri Sep 30 14:02:38 2022 +0300

    home: imp content-type check
---
 CHANGELOG.md                | 17 +++++++-
 client/src/api/Api.js       | 36 ++++-------------
 internal/aghhttp/aghhttp.go |  2 +-
 internal/aghhttp/header.go  |  2 +-
 internal/home/control.go    | 80 ++++++++++++++++++++++++++-----------
 openapi/CHANGELOG.md        | 25 ++++++++++++
 openapi/openapi.yaml        | 20 ++++++++--
 7 files changed, 122 insertions(+), 60 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 492ba690..8e8ab361 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,19 @@ and this project adheres to
 ## [v0.108.0] - TBA (APPROX.)
 -->
 
+### Security
+
+- As an additional CSRF protection measure, AdGuard Home now ensures that
+  requests that change its state but have no body (such as `POST
+  /control/stats_reset` requests) do not have a `Content-Type` header set on
+  them ([#4970]).
+
+### Fixed
+
+- `only application/json is allowed` errors in various APIs ([#4970]).
+
+[#4970]: https://github.com/AdguardTeam/AdGuardHome/issues/4970
+
 
 
 <!--
@@ -63,8 +76,8 @@ bodies are documented in `openapi/openapi.yaml` and `openapi/CHANGELOG.md`.
 
 #### Stricter Content-Type Checks (BREAKING API CHANGE)
 
-All JSON APIs now check if the request actually has the `application/json`
-content-type.
+All JSON APIs that expect a body now check if the request actually has
+`Content-Type` set to `application/json`.
 
 #### Other Security Changes
 
diff --git a/client/src/api/Api.js b/client/src/api/Api.js
index 113c2c00..036f9050 100644
--- a/client/src/api/Api.js
+++ b/client/src/api/Api.js
@@ -10,11 +10,17 @@ class Api {
     async makeRequest(path, method = 'POST', config) {
         const url = `${this.baseUrl}/${path}`;
 
+        const axiosConfig = config || {};
+        if (method !== 'GET' && axiosConfig.data) {
+            axiosConfig.headers = axiosConfig.headers || {};
+            axiosConfig.headers['Content-Type'] = axiosConfig.headers['Content-Type'] || 'application/json';
+        }
+
         try {
             const response = await axios({
                 url,
                 method,
-                ...config,
+                ...axiosConfig,
             });
             return response.data;
         } catch (error) {
@@ -55,7 +61,6 @@ class Api {
         const { path, method } = this.GLOBAL_TEST_UPSTREAM_DNS;
         const config = {
             data: servers,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, config);
     }
@@ -64,7 +69,6 @@ class Api {
         const { path, method } = this.GLOBAL_VERSION;
         const config = {
             data,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, config);
     }
@@ -100,7 +104,6 @@ class Api {
         const { path, method } = this.FILTERING_REFRESH;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
 
         return this.makeRequest(path, method, parameters);
@@ -110,7 +113,6 @@ class Api {
         const { path, method } = this.FILTERING_ADD_FILTER;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
 
         return this.makeRequest(path, method, parameters);
@@ -120,7 +122,6 @@ class Api {
         const { path, method } = this.FILTERING_REMOVE_FILTER;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
 
         return this.makeRequest(path, method, parameters);
@@ -130,7 +131,6 @@ class Api {
         const { path, method } = this.FILTERING_SET_RULES;
         const parameters = {
             data: rules,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -139,7 +139,6 @@ class Api {
         const { path, method } = this.FILTERING_CONFIG;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -148,7 +147,6 @@ class Api {
         const { path, method } = this.FILTERING_SET_URL;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -239,7 +237,6 @@ class Api {
         const { path, method } = this.CHANGE_LANGUAGE;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -275,7 +272,6 @@ class Api {
         const { path, method } = this.DHCP_SET_CONFIG;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -284,7 +280,6 @@ class Api {
         const { path, method } = this.DHCP_FIND_ACTIVE;
         const parameters = {
             data: req,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -293,7 +288,6 @@ class Api {
         const { path, method } = this.DHCP_ADD_STATIC_LEASE;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -302,7 +296,6 @@ class Api {
         const { path, method } = this.DHCP_REMOVE_STATIC_LEASE;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -333,7 +326,6 @@ class Api {
         const { path, method } = this.INSTALL_CONFIGURE;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -342,7 +334,6 @@ class Api {
         const { path, method } = this.INSTALL_CHECK_CONFIG;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -363,7 +354,6 @@ class Api {
         const { path, method } = this.TLS_CONFIG;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -372,7 +362,6 @@ class Api {
         const { path, method } = this.TLS_VALIDATE;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -397,7 +386,6 @@ class Api {
         const { path, method } = this.ADD_CLIENT;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -406,7 +394,6 @@ class Api {
         const { path, method } = this.DELETE_CLIENT;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -415,7 +402,6 @@ class Api {
         const { path, method } = this.UPDATE_CLIENT;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -440,7 +426,6 @@ class Api {
         const { path, method } = this.ACCESS_SET;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -461,7 +446,6 @@ class Api {
         const { path, method } = this.REWRITE_ADD;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -470,7 +454,6 @@ class Api {
         const { path, method } = this.REWRITE_DELETE;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -496,7 +479,6 @@ class Api {
         const { path, method } = this.BLOCKED_SERVICES_SET;
         const parameters = {
             data: config,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -524,7 +506,6 @@ class Api {
         const { path, method } = this.STATS_CONFIG;
         const config = {
             data,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, config);
     }
@@ -560,7 +541,6 @@ class Api {
         const { path, method } = this.QUERY_LOG_CONFIG;
         const config = {
             data,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, config);
     }
@@ -577,7 +557,6 @@ class Api {
         const { path, method } = this.LOGIN;
         const config = {
             data,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, config);
     }
@@ -604,7 +583,6 @@ class Api {
         const { path, method } = this.SET_DNS_CONFIG;
         const config = {
             data,
-            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, config);
     }
diff --git a/internal/aghhttp/aghhttp.go b/internal/aghhttp/aghhttp.go
index 8f786749..f03ebf7d 100644
--- a/internal/aghhttp/aghhttp.go
+++ b/internal/aghhttp/aghhttp.go
@@ -33,7 +33,7 @@ func OK(w http.ResponseWriter) {
 // Error writes formatted message to w and also logs it.
 func Error(r *http.Request, w http.ResponseWriter, code int, format string, args ...any) {
 	text := fmt.Sprintf(format, args...)
-	log.Error("%s %s: %s", r.Method, r.URL, text)
+	log.Error("%s %s %s: %s", r.Method, r.Host, r.URL, text)
 	http.Error(w, text, code)
 }
 
diff --git a/internal/aghhttp/header.go b/internal/aghhttp/header.go
index 1509a7e0..a0b79425 100644
--- a/internal/aghhttp/header.go
+++ b/internal/aghhttp/header.go
@@ -8,8 +8,8 @@ package aghhttp
 const (
 	HdrNameAcceptEncoding           = "Accept-Encoding"
 	HdrNameAccessControlAllowOrigin = "Access-Control-Allow-Origin"
-	HdrNameContentType              = "Content-Type"
 	HdrNameContentEncoding          = "Content-Encoding"
+	HdrNameContentType              = "Content-Type"
 	HdrNameServer                   = "Server"
 	HdrNameTrailer                  = "Trailer"
 	HdrNameUserAgent                = "User-Agent"
diff --git a/internal/home/control.go b/internal/home/control.go
index 881e957a..8abdb264 100644
--- a/internal/home/control.go
+++ b/internal/home/control.go
@@ -8,6 +8,7 @@ import (
 	"net/url"
 	"runtime"
 	"strings"
+	"time"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
 	"github.com/AdguardTeam/AdGuardHome/internal/aghnet"
@@ -97,16 +98,16 @@ func collectDNSAddresses() (addrs []string, err error) {
 
 // statusResponse is a response for /control/status endpoint.
 type statusResponse struct {
+	Version             string   `json:"version"`
+	Language            string   `json:"language"`
 	DNSAddrs            []string `json:"dns_addresses"`
 	DNSPort             int      `json:"dns_port"`
 	HTTPPort            int      `json:"http_port"`
 	IsProtectionEnabled bool     `json:"protection_enabled"`
 	// TODO(e.burkov): Inspect if front-end doesn't requires this field as
 	// openapi.yaml declares.
-	IsDHCPAvailable bool   `json:"dhcp_available"`
-	IsRunning       bool   `json:"running"`
-	Version         string `json:"version"`
-	Language        string `json:"language"`
+	IsDHCPAvailable bool `json:"dhcp_available"`
+	IsRunning       bool `json:"running"`
 }
 
 func handleStatus(w http.ResponseWriter, r *http.Request) {
@@ -125,12 +126,12 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
 		defer config.RUnlock()
 
 		resp = statusResponse{
+			Version:   version.Version(),
 			DNSAddrs:  dnsAddrs,
 			DNSPort:   config.DNS.Port,
 			HTTPPort:  config.BindPort,
-			IsRunning: isRunning(),
-			Version:   version.Version(),
 			Language:  config.Language,
+			IsRunning: isRunning(),
 		}
 	}()
 
@@ -196,29 +197,26 @@ func httpRegister(method, url string, handler http.HandlerFunc) {
 	Context.mux.Handle(url, postInstallHandler(optionalAuthHandler(gziphandler.GzipHandler(ensureHandler(method, handler)))))
 }
 
-// ----------------------------------
-// helper functions for HTTP handlers
-// ----------------------------------
-func ensure(method string, handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
+// ensure returns a wrapped handler that makes sure that the request has the
+// correct method as well as additional method and header checks.
+func ensure(
+	method string,
+	handler func(http.ResponseWriter, *http.Request),
+) (wrapped func(http.ResponseWriter, *http.Request)) {
 	return func(w http.ResponseWriter, r *http.Request) {
-		log.Debug("%s %v", r.Method, r.URL)
+		start := time.Now()
+		m, u := r.Method, r.URL
+		log.Debug("started %s %s %s", m, r.Host, u)
+		defer func() { log.Debug("finished %s %s %s in %s", m, r.Host, u, time.Since(start)) }()
 
-		if r.Method != method {
-			aghhttp.Error(r, w, http.StatusMethodNotAllowed, "only %s is allowed", method)
+		if m != method {
+			aghhttp.Error(r, w, http.StatusMethodNotAllowed, "only method %s is allowed", method)
 
 			return
 		}
 
-		if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete {
-			if r.Header.Get(aghhttp.HdrNameContentType) != aghhttp.HdrValApplicationJSON {
-				aghhttp.Error(
-					r,
-					w,
-					http.StatusUnsupportedMediaType,
-					"only %s is allowed",
-					aghhttp.HdrValApplicationJSON,
-				)
-
+		if modifiesData(m) {
+			if !ensureContentType(w, r) {
 				return
 			}
 
@@ -230,6 +228,42 @@ func ensure(method string, handler func(http.ResponseWriter, *http.Request)) fun
 	}
 }
 
+// modifiesData returns true if m is an HTTP method that can modify data.
+func modifiesData(m string) (ok bool) {
+	return m == http.MethodPost || m == http.MethodPut || m == http.MethodDelete
+}
+
+// ensureContentType makes sure that the content type of a data-modifying
+// request is set correctly.  If it is not, ensureContentType writes a response
+// to w, and ok is false.
+func ensureContentType(w http.ResponseWriter, r *http.Request) (ok bool) {
+	const statusUnsup = http.StatusUnsupportedMediaType
+
+	cType := r.Header.Get(aghhttp.HdrNameContentType)
+	if r.ContentLength == 0 {
+		if cType == "" {
+			return true
+		}
+
+		// Assume that browsers always send a content type when submitting HTML
+		// forms and require no content type for requests with no body to make
+		// sure that the request comes from JavaScript.
+		aghhttp.Error(r, w, statusUnsup, "empty body with content-type %q not allowed", cType)
+
+		return false
+
+	}
+
+	const wantCType = aghhttp.HdrValApplicationJSON
+	if cType == wantCType {
+		return true
+	}
+
+	aghhttp.Error(r, w, statusUnsup, "only content-type %s is allowed", wantCType)
+
+	return false
+}
+
 func ensurePOST(handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
 	return ensure(http.MethodPost, handler)
 }
diff --git a/openapi/CHANGELOG.md b/openapi/CHANGELOG.md
index d9946115..d0a450ed 100644
--- a/openapi/CHANGELOG.md
+++ b/openapi/CHANGELOG.md
@@ -6,6 +6,28 @@
 
 
 
+## v0.107.15: `POST` Requests Without Bodies
+
+As an additional CSRF protection measure, AdGuard Home now ensures that requests
+that change its state but have no body do not have a `Content-Type` header set
+on them.
+
+This concerns the following APIs:
+
+* `POST /control/dhcp/reset_leases`;
+* `POST /control/dhcp/reset`;
+* `POST /control/parental/disable`;
+* `POST /control/parental/enable`;
+* `POST /control/querylog_clear`;
+* `POST /control/safebrowsing/disable`;
+* `POST /control/safebrowsing/enable`;
+* `POST /control/safesearch/disable`;
+* `POST /control/safesearch/enable`;
+* `POST /control/stats_reset`;
+* `POST /control/update`.
+
+
+
 ## v0.107.14: BREAKING API CHANGES
 
 A Cross-Site Request Forgery (CSRF) vulnerability has been discovered.  We have
@@ -13,6 +35,9 @@ implemented several measures to prevent such vulnerabilities in the future, but
 some of these measures break backwards compatibility for the sake of better
 protection.
 
+All JSON APIs that expect a body now check if the request actually has
+`Content-Type` set to `application/json`.
+
 All new formats for the request and response bodies are documented in
 `openapi.yaml`.
 
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index c6451fa7..195b0a5e 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -601,11 +601,10 @@
       'summary': 'Set user-defined filter rules'
       'requestBody':
         'content':
-          'text/plain':
+          'application/json':
             'schema':
-              'type': 'string'
-              'example': '@@||yandex.ru^|'
-        'description': 'All filtering rules, one line per rule'
+              '$ref': '#/components/schemas/SetRulesRequest'
+        'description': 'Custom filtering rules.'
       'responses':
         '200':
           'description': 'OK.'
@@ -1538,6 +1537,19 @@
       'properties':
         'updated':
           'type': 'integer'
+    'SetRulesRequest':
+      'description': 'Custom filtering rules setting request.'
+      'example':
+        'rules':
+        - '||example.com^'
+        - '# comment'
+        - '@@||www.example.com^'
+      'properties':
+        'rules':
+          'items':
+            'type': 'string'
+          'type': 'array'
+      'type': 'object'
     'GetVersionRequest':
       'type': 'object'
       'description': '/version.json request data'