From 5a50efadb2be6493d4c6f5fd2104b6d5861471f5 Mon Sep 17 00:00:00 2001
From: Eugene Burkov <e.burkov@adguard.com>
Date: Wed, 20 Jan 2021 15:59:24 +0300
Subject: [PATCH] Pull request: 2509 type-safety vol.1

Merge in DNS/adguard-home from 2509-type-safety to master

Updates #2509.

Squashed commit of the following:

commit 535968eb7de3a9e0817ddb57bc2320e5c5a55086
Author: Eugene Burkov <e.burkov@adguard.com>
Date:   Wed Jan 20 15:06:16 2021 +0300

    dhcpd: fix comments

commit dc79b80381fe7a8ecec6f9659fd23710c9229f59
Author: Eugene Burkov <e.burkov@adguard.com>
Date:   Wed Jan 20 14:08:10 2021 +0300

    all: improve docs

commit 156ebf6c9bad95f82cd121f019f3b59b77b18ba6
Author: Eugene Burkov <e.burkov@adguard.com>
Date:   Tue Jan 19 17:08:15 2021 +0300

    all: improve JSON encoding and decoding
---
 internal/dhcpd/dhcpd.go            |  35 +++++
 internal/dhcpd/dhcphttp.go         | 216 ++++++++++++-----------------
 internal/dhcpd/server.go           |  30 ++--
 internal/dhcpd/v4.go               |   5 +-
 internal/dhcpd/v6.go               |   5 +-
 internal/dnsfilter/safebrowsing.go |  29 ++--
 internal/dnsfilter/safesearch.go   |  15 +-
 openapi/openapi.yaml               |  47 +++++--
 8 files changed, 202 insertions(+), 180 deletions(-)

diff --git a/internal/dhcpd/dhcpd.go b/internal/dhcpd/dhcpd.go
index ecb7d48d..17d817cf 100644
--- a/internal/dhcpd/dhcpd.go
+++ b/internal/dhcpd/dhcpd.go
@@ -3,6 +3,8 @@ package dhcpd
 
 import (
 	"encoding/hex"
+	"encoding/json"
+	"fmt"
 	"net"
 	"net/http"
 	"path/filepath"
@@ -33,6 +35,39 @@ type Lease struct {
 	Expiry time.Time `json:"expires"`
 }
 
+// MarshalJSON implements the json.Marshaler interface for *Lease.
+func (l *Lease) MarshalJSON() ([]byte, error) {
+	type lease Lease
+	return json.Marshal(&struct {
+		HWAddr string `json:"mac"`
+		*lease
+	}{
+		HWAddr: l.HWAddr.String(),
+		lease:  (*lease)(l),
+	})
+}
+
+// UnmarshalJSON implements the json.Unmarshaler interface for *Lease.
+func (l *Lease) UnmarshalJSON(data []byte) (err error) {
+	type lease Lease
+	aux := struct {
+		HWAddr string `json:"mac"`
+		*lease
+	}{
+		lease: (*lease)(l),
+	}
+	if err = json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+
+	l.HWAddr, err = net.ParseMAC(aux.HWAddr)
+	if err != nil {
+		return fmt.Errorf("couldn't parse MAC address: %w", err)
+	}
+
+	return nil
+}
+
 // ServerConfig - DHCP server configuration
 // field ordering is important -- yaml fields will mirror ordering from here
 type ServerConfig struct {
diff --git a/internal/dhcpd/dhcphttp.go b/internal/dhcpd/dhcphttp.go
index e35322f8..36bc64ef 100644
--- a/internal/dhcpd/dhcphttp.go
+++ b/internal/dhcpd/dhcphttp.go
@@ -8,7 +8,6 @@ import (
 	"net/http"
 	"os"
 	"strings"
-	"time"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/sysutil"
 	"github.com/AdguardTeam/AdGuardHome/internal/util"
@@ -22,25 +21,6 @@ func httpError(r *http.Request, w http.ResponseWriter, code int, format string,
 	http.Error(w, text, code)
 }
 
-// []Lease -> JSON
-func convertLeases(inputLeases []Lease, includeExpires bool) []map[string]string {
-	leases := []map[string]string{}
-	for _, l := range inputLeases {
-		lease := map[string]string{
-			"mac":      l.HWAddr.String(),
-			"ip":       l.IP.String(),
-			"hostname": l.Hostname,
-		}
-
-		if includeExpires {
-			lease["expires"] = l.Expiry.Format(time.RFC3339)
-		}
-
-		leases = append(leases, lease)
-	}
-	return leases
-}
-
 type v4ServerConfJSON struct {
 	GatewayIP     net.IP `json:"gateway_ip"`
 	SubnetMask    net.IP `json:"subnet_mask"`
@@ -49,22 +29,12 @@ type v4ServerConfJSON struct {
 	LeaseDuration uint32 `json:"lease_duration"`
 }
 
-func v4ServerConfToJSON(c V4ServerConf) v4ServerConfJSON {
-	return v4ServerConfJSON{
-		GatewayIP:     c.GatewayIP,
-		SubnetMask:    c.SubnetMask,
-		RangeStart:    c.RangeStart,
-		RangeEnd:      c.RangeEnd,
-		LeaseDuration: c.LeaseDuration,
-	}
-}
-
 func v4JSONToServerConf(j v4ServerConfJSON) V4ServerConf {
 	return V4ServerConf{
-		GatewayIP:     j.GatewayIP.To4(),
-		SubnetMask:    j.SubnetMask.To4(),
-		RangeStart:    j.RangeStart.To4(),
-		RangeEnd:      j.RangeEnd.To4(),
+		GatewayIP:     j.GatewayIP,
+		SubnetMask:    j.SubnetMask,
+		RangeStart:    j.RangeStart,
+		RangeEnd:      j.RangeEnd,
 		LeaseDuration: j.LeaseDuration,
 	}
 }
@@ -74,13 +44,6 @@ type v6ServerConfJSON struct {
 	LeaseDuration uint32 `json:"lease_duration"`
 }
 
-func v6ServerConfToJSON(c V6ServerConf) v6ServerConfJSON {
-	return v6ServerConfJSON{
-		RangeStart:    c.RangeStart,
-		LeaseDuration: c.LeaseDuration,
-	}
-}
-
 func v6JSONToServerConf(j v6ServerConfJSON) V6ServerConf {
 	return V6ServerConf{
 		RangeStart:    j.RangeStart,
@@ -88,25 +51,30 @@ func v6JSONToServerConf(j v6ServerConfJSON) V6ServerConf {
 	}
 }
 
+// dhcpStatusResponse is the response for /control/dhcp/status endpoint.
+type dhcpStatusResponse struct {
+	Enabled      bool         `json:"enabled"`
+	IfaceName    string       `json:"interface_name"`
+	V4           V4ServerConf `json:"v4"`
+	V6           V6ServerConf `json:"v6"`
+	Leases       []Lease      `json:"leases"`
+	StaticLeases []Lease      `json:"static_leases"`
+}
+
 func (s *Server) handleDHCPStatus(w http.ResponseWriter, r *http.Request) {
-	leases := convertLeases(s.Leases(LeasesDynamic), true)
-	staticLeases := convertLeases(s.Leases(LeasesStatic), false)
-
-	v4conf := V4ServerConf{}
-	s.srv4.WriteDiskConfig4(&v4conf)
-
-	v6conf := V6ServerConf{}
-	s.srv6.WriteDiskConfig6(&v6conf)
-
-	status := map[string]interface{}{
-		"enabled":        s.conf.Enabled,
-		"interface_name": s.conf.InterfaceName,
-		"v4":             v4ServerConfToJSON(v4conf),
-		"v6":             v6ServerConfToJSON(v6conf),
-		"leases":         leases,
-		"static_leases":  staticLeases,
+	status := &dhcpStatusResponse{
+		Enabled:   s.conf.Enabled,
+		IfaceName: s.conf.InterfaceName,
+		V4:        V4ServerConf{},
+		V6:        V6ServerConf{},
 	}
 
+	s.srv4.WriteDiskConfig4(&status.V4)
+	s.srv6.WriteDiskConfig6(&status.V6)
+
+	status.Leases = s.Leases(LeasesDynamic)
+	status.StaticLeases = s.Leases(LeasesStatic)
+
 	w.Header().Set("Content-Type", "application/json")
 	err := json.NewEncoder(w).Encode(status)
 	if err != nil {
@@ -115,12 +83,6 @@ func (s *Server) handleDHCPStatus(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-type staticLeaseJSON struct {
-	HWAddr   string `json:"mac"`
-	IP       net.IP `json:"ip"`
-	Hostname string `json:"hostname"`
-}
-
 type dhcpServerConfigJSON struct {
 	Enabled       bool             `json:"enabled"`
 	InterfaceName string           `json:"interface_name"`
@@ -233,7 +195,7 @@ type netInterfaceJSON struct {
 }
 
 func (s *Server) handleDHCPInterfaces(w http.ResponseWriter, r *http.Request) {
-	response := map[string]interface{}{}
+	response := map[string]netInterfaceJSON{}
 
 	ifaces, err := util.GetValidNetInterfaces()
 	if err != nil {
@@ -295,6 +257,40 @@ func (s *Server) handleDHCPInterfaces(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// dhcpSearchOtherResult contains information about other DHCP server for
+// specific network interface.
+type dhcpSearchOtherResult struct {
+	Found string `json:"found,omitempty"`
+	Error string `json:"error,omitempty"`
+}
+
+// dhcpStaticIPStatus contains information about static IP address for DHCP
+// server.
+type dhcpStaticIPStatus struct {
+	Static string `json:"static"`
+	IP     string `json:"ip,omitempty"`
+	Error  string `json:"error,omitempty"`
+}
+
+// dhcpSearchV4Result contains information about DHCPv4 server for specific
+// network interface.
+type dhcpSearchV4Result struct {
+	OtherServer dhcpSearchOtherResult `json:"other_server"`
+	StaticIP    dhcpStaticIPStatus    `json:"static_ip"`
+}
+
+// dhcpSearchV6Result contains information about DHCPv6 server for specific
+// network interface.
+type dhcpSearchV6Result struct {
+	OtherServer dhcpSearchOtherResult `json:"other_server"`
+}
+
+// dhcpSearchResult is a response for /control/dhcp/find_active_dhcp endpoint.
+type dhcpSearchResult struct {
+	V4 dhcpSearchV4Result `json:"v4"`
+	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
@@ -317,50 +313,42 @@ func (s *Server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
+	result := dhcpSearchResult{
+		V4: dhcpSearchV4Result{
+			OtherServer: dhcpSearchOtherResult{},
+			StaticIP:    dhcpStaticIPStatus{},
+		},
+		V6: dhcpSearchV6Result{
+			OtherServer: dhcpSearchOtherResult{},
+		},
+	}
+
 	found4, err4 := CheckIfOtherDHCPServersPresentV4(interfaceName)
 
-	staticIP := map[string]interface{}{}
 	isStaticIP, err := sysutil.IfaceHasStaticIP(interfaceName)
-	staticIPStatus := "yes"
 	if err != nil {
-		staticIPStatus = "error"
-		staticIP["error"] = err.Error()
+		result.V4.StaticIP.Static = "error"
+		result.V4.StaticIP.Error = err.Error()
 	} else if !isStaticIP {
-		staticIPStatus = "no"
-		staticIP["ip"] = util.GetSubnet(interfaceName)
+		result.V4.StaticIP.Static = "no"
+		result.V4.StaticIP.IP = util.GetSubnet(interfaceName)
 	}
-	staticIP["static"] = staticIPStatus
 
-	v4 := map[string]interface{}{}
-	othSrv := map[string]interface{}{}
-	foundVal := "no"
 	if found4 {
-		foundVal = "yes"
+		result.V4.OtherServer.Found = "yes"
 	} else if err4 != nil {
-		foundVal = "error"
-		othSrv["error"] = err4.Error()
+		result.V4.OtherServer.Found = "error"
+		result.V4.OtherServer.Error = err4.Error()
 	}
-	othSrv["found"] = foundVal
-	v4["other_server"] = othSrv
-	v4["static_ip"] = staticIP
 
 	found6, err6 := CheckIfOtherDHCPServersPresentV6(interfaceName)
 
-	v6 := map[string]interface{}{}
-	othSrv = map[string]interface{}{}
-	foundVal = "no"
 	if found6 {
-		foundVal = "yes"
+		result.V6.OtherServer.Found = "yes"
 	} else if err6 != nil {
-		foundVal = "error"
-		othSrv["error"] = err6.Error()
+		result.V6.OtherServer.Found = "error"
+		result.V6.OtherServer.Error = err6.Error()
 	}
-	othSrv["found"] = foundVal
-	v6["other_server"] = othSrv
-
-	result := map[string]interface{}{}
-	result["v4"] = v4
-	result["v6"] = v6
 
 	w.Header().Set("Content-Type", "application/json")
 	err = json.NewEncoder(w).Encode(result)
@@ -371,7 +359,7 @@ func (s *Server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Reque
 }
 
 func (s *Server) handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request) {
-	lj := staticLeaseJSON{}
+	lj := Lease{}
 	err := json.NewDecoder(r.Body).Decode(&lj)
 	if err != nil {
 		httpError(r, w, http.StatusBadRequest, "json.Decode: %s", err)
@@ -387,21 +375,10 @@ func (s *Server) handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request
 
 	ip4 := lj.IP.To4()
 
-	mac, err := net.ParseMAC(lj.HWAddr)
-	lease := Lease{
-		HWAddr: mac,
-	}
-
 	if ip4 == nil {
-		lease.IP = lj.IP.To16()
+		lj.IP = lj.IP.To16()
 
-		if err != nil {
-			httpError(r, w, http.StatusBadRequest, "invalid MAC")
-
-			return
-		}
-
-		err = s.srv6.AddStaticLease(lease)
+		err = s.srv6.AddStaticLease(lj)
 		if err != nil {
 			httpError(r, w, http.StatusBadRequest, "%s", err)
 		}
@@ -409,9 +386,8 @@ func (s *Server) handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	lease.IP = ip4
-	lease.Hostname = lj.Hostname
-	err = s.srv4.AddStaticLease(lease)
+	lj.IP = ip4
+	err = s.srv4.AddStaticLease(lj)
 	if err != nil {
 		httpError(r, w, http.StatusBadRequest, "%s", err)
 
@@ -420,7 +396,7 @@ func (s *Server) handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request
 }
 
 func (s *Server) handleDHCPRemoveStaticLease(w http.ResponseWriter, r *http.Request) {
-	lj := staticLeaseJSON{}
+	lj := Lease{}
 	err := json.NewDecoder(r.Body).Decode(&lj)
 	if err != nil {
 		httpError(r, w, http.StatusBadRequest, "json.Decode: %s", err)
@@ -436,21 +412,10 @@ func (s *Server) handleDHCPRemoveStaticLease(w http.ResponseWriter, r *http.Requ
 
 	ip4 := lj.IP.To4()
 
-	mac, err := net.ParseMAC(lj.HWAddr)
-	lease := Lease{
-		HWAddr: mac,
-	}
-
 	if ip4 == nil {
-		lease.IP = lj.IP.To16()
+		lj.IP = lj.IP.To16()
 
-		if err != nil {
-			httpError(r, w, http.StatusBadRequest, "invalid MAC")
-
-			return
-		}
-
-		err = s.srv6.RemoveStaticLease(lease)
+		err = s.srv6.RemoveStaticLease(lj)
 		if err != nil {
 			httpError(r, w, http.StatusBadRequest, "%s", err)
 		}
@@ -458,9 +423,8 @@ func (s *Server) handleDHCPRemoveStaticLease(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	lease.IP = ip4
-	lease.Hostname = lj.Hostname
-	err = s.srv4.RemoveStaticLease(lease)
+	lj.IP = ip4
+	err = s.srv4.RemoveStaticLease(lj)
 	if err != nil {
 		httpError(r, w, http.StatusBadRequest, "%s", err)
 
diff --git a/internal/dhcpd/server.go b/internal/dhcpd/server.go
index 20f6cad3..261ad4db 100644
--- a/internal/dhcpd/server.go
+++ b/internal/dhcpd/server.go
@@ -33,22 +33,22 @@ type DHCPServer interface {
 
 // V4ServerConf - server configuration
 type V4ServerConf struct {
-	Enabled       bool   `yaml:"-"`
-	InterfaceName string `yaml:"-"`
+	Enabled       bool   `yaml:"-" json:"-"`
+	InterfaceName string `yaml:"-" json:"-"`
 
-	GatewayIP  net.IP `yaml:"gateway_ip"`
-	SubnetMask net.IP `yaml:"subnet_mask"`
+	GatewayIP  net.IP `yaml:"gateway_ip" json:"gateway_ip"`
+	SubnetMask net.IP `yaml:"subnet_mask" json:"subnet_mask"`
 
 	// The first & the last IP address for dynamic leases
 	// Bytes [0..2] of the last allowed IP address must match the first IP
-	RangeStart net.IP `yaml:"range_start"`
-	RangeEnd   net.IP `yaml:"range_end"`
+	RangeStart net.IP `yaml:"range_start" json:"range_start"`
+	RangeEnd   net.IP `yaml:"range_end" json:"range_end"`
 
-	LeaseDuration uint32 `yaml:"lease_duration"` // in seconds
+	LeaseDuration uint32 `yaml:"lease_duration" json:"lease_duration"` // in seconds
 
 	// IP conflict detector: time (ms) to wait for ICMP reply
 	// 0: disable
-	ICMPTimeout uint32 `yaml:"icmp_timeout_msec"`
+	ICMPTimeout uint32 `yaml:"icmp_timeout_msec" json:"-"`
 
 	// Custom Options.
 	//
@@ -58,7 +58,7 @@ type V4ServerConf struct {
 	//
 	// Option with IP data (only 1 IP is supported):
 	//     DEC_CODE ip IP_ADDR
-	Options []string `yaml:"options"`
+	Options []string `yaml:"options" json:"-"`
 
 	ipStart    net.IP        // starting IP address for dynamic leases
 	ipEnd      net.IP        // ending IP address for dynamic leases
@@ -74,17 +74,17 @@ type V4ServerConf struct {
 
 // V6ServerConf - server configuration
 type V6ServerConf struct {
-	Enabled       bool   `yaml:"-"`
-	InterfaceName string `yaml:"-"`
+	Enabled       bool   `yaml:"-" json:"-"`
+	InterfaceName string `yaml:"-" json:"-"`
 
 	// The first IP address for dynamic leases
 	// The last allowed IP address ends with 0xff byte
-	RangeStart string `yaml:"range_start"`
+	RangeStart string `yaml:"range_start" json:"range_start"`
 
-	LeaseDuration uint32 `yaml:"lease_duration"` // in seconds
+	LeaseDuration uint32 `yaml:"lease_duration" json:"lease_duration"` // in seconds
 
-	RaSlaacOnly  bool `yaml:"ra_slaac_only"`  // send ICMPv6.RA packets without MO flags
-	RaAllowSlaac bool `yaml:"ra_allow_slaac"` // send ICMPv6.RA packets with MO flags
+	RaSlaacOnly  bool `yaml:"ra_slaac_only" json:"-"`  // send ICMPv6.RA packets without MO flags
+	RaAllowSlaac bool `yaml:"ra_allow_slaac" json:"-"` // send ICMPv6.RA packets with MO flags
 
 	ipStart    net.IP        // starting IP address for dynamic leases
 	leaseTime  time.Duration // the time during which a dynamic lease is considered valid
diff --git a/internal/dhcpd/v4.go b/internal/dhcpd/v4.go
index 038d8f9c..2f5484a2 100644
--- a/internal/dhcpd/v4.go
+++ b/internal/dhcpd/v4.go
@@ -77,7 +77,10 @@ func (s *v4Server) blacklisted(l *Lease) bool {
 
 // GetLeases returns the list of current DHCP leases (thread-safe)
 func (s *v4Server) GetLeases(flags int) []Lease {
-	var result []Lease
+	// The function shouldn't return nil value because zero-length slice
+	// behaves differently in cases like marshalling.  Our front-end also
+	// requires non-nil value in the response.
+	result := []Lease{}
 	now := time.Now().Unix()
 
 	s.leasesLock.Lock()
diff --git a/internal/dhcpd/v6.go b/internal/dhcpd/v6.go
index 2dd41b5c..0f8cddfd 100644
--- a/internal/dhcpd/v6.go
+++ b/internal/dhcpd/v6.go
@@ -72,7 +72,10 @@ func (s *v6Server) ResetLeases(ll []*Lease) {
 
 // GetLeases - get current leases
 func (s *v6Server) GetLeases(flags int) []Lease {
-	var result []Lease
+	// The function shouldn't return nil value because zero-length slice
+	// behaves differently in cases like marshalling.  Our front-end also
+	// requires non-nil value in the response.
+	result := []Lease{}
 	s.leasesLock.Lock()
 	for _, lease := range s.leases {
 		if lease.Expiry.Unix() == leaseExpireStatic {
diff --git a/internal/dnsfilter/safebrowsing.go b/internal/dnsfilter/safebrowsing.go
index 87cd2607..cec3081b 100644
--- a/internal/dnsfilter/safebrowsing.go
+++ b/internal/dnsfilter/safebrowsing.go
@@ -346,16 +346,12 @@ func (d *DNSFilter) handleSafeBrowsingDisable(w http.ResponseWriter, r *http.Req
 }
 
 func (d *DNSFilter) handleSafeBrowsingStatus(w http.ResponseWriter, r *http.Request) {
-	data := map[string]interface{}{
-		"enabled": d.Config.SafeBrowsingEnabled,
-	}
-	jsonVal, err := json.Marshal(data)
-	if err != nil {
-		httpError(r, w, http.StatusInternalServerError, "Unable to marshal status json: %s", err)
-	}
-
 	w.Header().Set("Content-Type", "application/json")
-	_, err = w.Write(jsonVal)
+	err := json.NewEncoder(w).Encode(&struct {
+		Enabled bool `json:"enabled"`
+	}{
+		Enabled: d.Config.SafeBrowsingEnabled,
+	})
 	if err != nil {
 		httpError(r, w, http.StatusInternalServerError, "Unable to write response json: %s", err)
 		return
@@ -373,17 +369,12 @@ func (d *DNSFilter) handleParentalDisable(w http.ResponseWriter, r *http.Request
 }
 
 func (d *DNSFilter) handleParentalStatus(w http.ResponseWriter, r *http.Request) {
-	data := map[string]interface{}{
-		"enabled": d.Config.ParentalEnabled,
-	}
-	jsonVal, err := json.Marshal(data)
-	if err != nil {
-		httpError(r, w, http.StatusInternalServerError, "Unable to marshal status json: %s", err)
-		return
-	}
-
 	w.Header().Set("Content-Type", "application/json")
-	_, err = w.Write(jsonVal)
+	err := json.NewEncoder(w).Encode(&struct {
+		Enabled bool `json:"enabled"`
+	}{
+		Enabled: d.Config.ParentalEnabled,
+	})
 	if err != nil {
 		httpError(r, w, http.StatusInternalServerError, "Unable to write response json: %s", err)
 		return
diff --git a/internal/dnsfilter/safesearch.go b/internal/dnsfilter/safesearch.go
index 4aefa5e1..958962db 100644
--- a/internal/dnsfilter/safesearch.go
+++ b/internal/dnsfilter/safesearch.go
@@ -133,17 +133,12 @@ func (d *DNSFilter) handleSafeSearchDisable(w http.ResponseWriter, r *http.Reque
 }
 
 func (d *DNSFilter) handleSafeSearchStatus(w http.ResponseWriter, r *http.Request) {
-	data := map[string]interface{}{
-		"enabled": d.Config.SafeSearchEnabled,
-	}
-	jsonVal, err := json.Marshal(data)
-	if err != nil {
-		httpError(r, w, http.StatusInternalServerError, "Unable to marshal status json: %s", err)
-		return
-	}
-
 	w.Header().Set("Content-Type", "application/json")
-	_, err = w.Write(jsonVal)
+	err := json.NewEncoder(w).Encode(&struct {
+		Enabled bool `json:"enabled"`
+	}{
+		Enabled: d.Config.SafeSearchEnabled,
+	})
 	if err != nil {
 		httpError(r, w, http.StatusInternalServerError, "Unable to write response json: %s", err)
 		return
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index dbad4e50..e41eac24 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -350,7 +350,26 @@
             'application/json':
               'schema':
                 '$ref': '#/components/schemas/DhcpStatus'
-        '501':
+        '500':
+          'content':
+            'application/json':
+              'schema':
+                '$ref': '#/components/schemas/Error'
+          'description': 'Not implemented (for example, on Windows).'
+  '/dhcp/interfaces':
+    'get':
+      'tags':
+      - 'dhcp'
+      'operationId': 'dhcpInterfaces'
+      'summary': 'Gets the available interfaces'
+      'responses':
+        '200':
+          'description': 'OK.'
+          'content':
+            'application/json':
+              'schema':
+                '$ref': '#/components/schemas/NetInterfaces'
+        '500':
           'content':
             'application/json':
               'schema':
@@ -1620,6 +1639,12 @@
           'type': 'array'
           'items':
             '$ref': '#/components/schemas/DhcpStaticLease'
+    'NetInterfaces':
+      'type': 'object'
+      'description': >
+        Network interfaces dictionary, keys are interface names.
+      'additionalProperties':
+        '$ref': '#/components/schemas/NetInterface'
 
     'DhcpSearchResult':
       'type': 'object'
@@ -1650,7 +1675,12 @@
       'properties':
         'found':
           'type': 'string'
-          'description': 'yes|no|error'
+          'enum':
+          - 'yes'
+          - 'no'
+          - 'error'
+          'description': >
+            The result of searching the other DHCP server.
           'example': 'no'
         'error':
           'type': 'string'
@@ -1662,7 +1692,12 @@
       'properties':
         'static':
           'type': 'string'
-          'description': 'yes|no|error'
+          'enum':
+          - 'yes'
+          - 'no'
+          - 'error'
+          'description': >
+            The result of determining static IP address.
           'example': 'yes'
         'ip':
           'type': 'string'
@@ -2015,11 +2050,7 @@
           'format': 'uint16'
           'example': 80
         'interfaces':
-          'type': 'object'
-          'description': >
-            Network interfaces dictionary, keys are interface names.
-          'additionalProperties':
-            '$ref': '#/components/schemas/NetInterface'
+          '$ref': '#/components/schemas/NetInterfaces'
     'AddressesInfoBeta':
       'type': 'object'
       'description': 'AdGuard Home addresses configuration'