Pull request: 2509 type-safety vol.2
Merge in DNS/adguard-home from 2509-type-safety-vol2 to master Updates #2509. Squashed commit of the following: commit c944e4e0a9949fc894c90b4bc1f739148a67fd9d Author: Eugene Burkov <e.burkov@adguard.com> Date: Thu Jan 21 19:36:20 2021 +0300 all: imp docs commit e8ac1815c492b0a9434596e35a48755cac2b9f3b Author: Eugene Burkov <e.burkov@adguard.com> Date: Wed Jan 20 12:38:48 2021 +0300 all: imp JSON encoding, decoding
This commit is contained in:
parent
7fab31beae
commit
d9482b7588
|
@ -44,41 +44,44 @@ func addDNSAddress(dnsAddresses *[]string, addr net.IP) {
|
||||||
*dnsAddresses = append(*dnsAddresses, hostport)
|
*dnsAddresses = append(*dnsAddresses, hostport)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// statusResponse is a response for /control/status endpoint.
|
||||||
|
type statusResponse struct {
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
func handleStatus(w http.ResponseWriter, _ *http.Request) {
|
func handleStatus(w http.ResponseWriter, _ *http.Request) {
|
||||||
c := dnsforward.FilteringConfig{}
|
resp := statusResponse{
|
||||||
|
DNSAddrs: getDNSAddresses(),
|
||||||
|
DNSPort: config.DNS.Port,
|
||||||
|
HTTPPort: config.BindPort,
|
||||||
|
IsRunning: isRunning(),
|
||||||
|
Version: version.Version(),
|
||||||
|
Language: config.Language,
|
||||||
|
}
|
||||||
|
|
||||||
|
var c *dnsforward.FilteringConfig
|
||||||
if Context.dnsServer != nil {
|
if Context.dnsServer != nil {
|
||||||
Context.dnsServer.WriteDiskConfig(&c)
|
c = &dnsforward.FilteringConfig{}
|
||||||
|
Context.dnsServer.WriteDiskConfig(c)
|
||||||
|
resp.IsProtectionEnabled = c.ProtectionEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]interface{}{
|
// IsDHCPAvailable field is now false by default for Windows.
|
||||||
"dns_addresses": getDNSAddresses(),
|
if runtime.GOOS != "windows" {
|
||||||
"http_port": config.BindPort,
|
resp.IsDHCPAvailable = Context.dhcpServer != nil
|
||||||
"dns_port": config.DNS.Port,
|
|
||||||
"running": isRunning(),
|
|
||||||
"version": version.Version(),
|
|
||||||
"language": config.Language,
|
|
||||||
|
|
||||||
"protection_enabled": c.ProtectionEnabled,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
// Set the DHCP to false explicitly, because Context.dhcpServer
|
|
||||||
// is probably not nil, despite the fact that there is no
|
|
||||||
// support for DHCP on Windows in AdGuardHome.
|
|
||||||
//
|
|
||||||
// See also the TODO in dhcpd.Create.
|
|
||||||
data["dhcp_available"] = false
|
|
||||||
} else {
|
|
||||||
data["dhcp_available"] = (Context.dhcpServer != nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonVal, err := json.Marshal(data)
|
|
||||||
if err != nil {
|
|
||||||
httpError(w, http.StatusInternalServerError, "Unable to marshal status json: %s", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, err = w.Write(jsonVal)
|
err := json.NewEncoder(w).Encode(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(w, http.StatusInternalServerError, "Unable to write response json: %s", err)
|
httpError(w, http.StatusInternalServerError, "Unable to write response json: %s", err)
|
||||||
return
|
return
|
||||||
|
|
|
@ -21,23 +21,16 @@ import (
|
||||||
"github.com/AdguardTeam/golibs/log"
|
"github.com/AdguardTeam/golibs/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type firstRunData struct {
|
// getAddrsResponse is the response for /install/get_addresses endpoint.
|
||||||
WebPort int `json:"web_port"`
|
type getAddrsResponse struct {
|
||||||
DNSPort int `json:"dns_port"`
|
WebPort int `json:"web_port"`
|
||||||
Interfaces map[string]interface{} `json:"interfaces"`
|
DNSPort int `json:"dns_port"`
|
||||||
|
Interfaces map[string]*util.NetInterface `json:"interfaces"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type netInterfaceJSON struct {
|
// handleInstallGetAddresses is the handler for /install/get_addresses endpoint.
|
||||||
Name string `json:"name"`
|
|
||||||
MTU int `json:"mtu"`
|
|
||||||
HardwareAddr string `json:"hardware_address"`
|
|
||||||
Addresses []net.IP `json:"ip_addresses"`
|
|
||||||
Flags string `json:"flags"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get initial installation settings
|
|
||||||
func (web *Web) handleInstallGetAddresses(w http.ResponseWriter, r *http.Request) {
|
func (web *Web) handleInstallGetAddresses(w http.ResponseWriter, r *http.Request) {
|
||||||
data := firstRunData{}
|
data := getAddrsResponse{}
|
||||||
data.WebPort = 80
|
data.WebPort = 80
|
||||||
data.DNSPort = 53
|
data.DNSPort = 53
|
||||||
|
|
||||||
|
@ -47,16 +40,9 @@ func (web *Web) handleInstallGetAddresses(w http.ResponseWriter, r *http.Request
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data.Interfaces = make(map[string]interface{})
|
data.Interfaces = make(map[string]*util.NetInterface)
|
||||||
for _, iface := range ifaces {
|
for _, iface := range ifaces {
|
||||||
ifaceJSON := netInterfaceJSON{
|
data.Interfaces[iface.Name] = iface
|
||||||
Name: iface.Name,
|
|
||||||
MTU: iface.MTU,
|
|
||||||
HardwareAddr: iface.HardwareAddr,
|
|
||||||
Addresses: iface.Addresses,
|
|
||||||
Flags: iface.Flags,
|
|
||||||
}
|
|
||||||
data.Interfaces[iface.Name] = ifaceJSON
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
@ -520,15 +506,15 @@ func (web *Web) handleInstallConfigureBeta(w http.ResponseWriter, r *http.Reques
|
||||||
web.handleInstallConfigure(w, r)
|
web.handleInstallConfigure(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstRunDataBeta is a struct representing new client's getting addresses
|
// getAddrsResponseBeta is a struct representing new client's getting addresses
|
||||||
// request body. It uses array of structs instead of map.
|
// request body. It uses array of structs instead of map.
|
||||||
//
|
//
|
||||||
// TODO(e.burkov): This should removed with the API v1 when the appropriate
|
// TODO(e.burkov): This should removed with the API v1 when the appropriate
|
||||||
// functionality will appear in default firstRunData.
|
// functionality will appear in default firstRunData.
|
||||||
type firstRunDataBeta struct {
|
type getAddrsResponseBeta struct {
|
||||||
WebPort int `json:"web_port"`
|
WebPort int `json:"web_port"`
|
||||||
DNSPort int `json:"dns_port"`
|
DNSPort int `json:"dns_port"`
|
||||||
Interfaces []netInterfaceJSON `json:"interfaces"`
|
Interfaces []*util.NetInterface `json:"interfaces"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleInstallConfigureBeta is a substitution of /install/get_addresses
|
// handleInstallConfigureBeta is a substitution of /install/get_addresses
|
||||||
|
@ -537,7 +523,7 @@ type firstRunDataBeta struct {
|
||||||
// TODO(e.burkov): This should removed with the API v1 when the appropriate
|
// TODO(e.burkov): This should removed with the API v1 when the appropriate
|
||||||
// functionality will appear in default handleInstallGetAddresses.
|
// functionality will appear in default handleInstallGetAddresses.
|
||||||
func (web *Web) handleInstallGetAddressesBeta(w http.ResponseWriter, r *http.Request) {
|
func (web *Web) handleInstallGetAddressesBeta(w http.ResponseWriter, r *http.Request) {
|
||||||
data := firstRunDataBeta{}
|
data := getAddrsResponseBeta{}
|
||||||
data.WebPort = 80
|
data.WebPort = 80
|
||||||
data.DNSPort = 53
|
data.DNSPort = 53
|
||||||
|
|
||||||
|
@ -547,17 +533,7 @@ func (web *Web) handleInstallGetAddressesBeta(w http.ResponseWriter, r *http.Req
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data.Interfaces = make([]netInterfaceJSON, 0, len(ifaces))
|
data.Interfaces = ifaces
|
||||||
for _, iface := range ifaces {
|
|
||||||
ifaceJSON := netInterfaceJSON{
|
|
||||||
Name: iface.Name,
|
|
||||||
MTU: iface.MTU,
|
|
||||||
HardwareAddr: iface.HardwareAddr,
|
|
||||||
Addresses: iface.Addresses,
|
|
||||||
Flags: iface.Flags,
|
|
||||||
}
|
|
||||||
data.Interfaces = append(data.Interfaces, ifaceJSON)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
err = json.NewEncoder(w).Encode(data)
|
err = json.NewEncoder(w).Encode(data)
|
||||||
|
|
|
@ -16,10 +16,6 @@ import (
|
||||||
"github.com/AdguardTeam/golibs/log"
|
"github.com/AdguardTeam/golibs/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type getVersionJSONRequest struct {
|
|
||||||
RecheckNow bool `json:"recheck_now"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// temporaryError is the interface for temporary errors from the Go standard
|
// temporaryError is the interface for temporary errors from the Go standard
|
||||||
// library.
|
// library.
|
||||||
type temporaryError interface {
|
type temporaryError interface {
|
||||||
|
@ -29,31 +25,34 @@ type temporaryError interface {
|
||||||
|
|
||||||
// Get the latest available version from the Internet
|
// Get the latest available version from the Internet
|
||||||
func handleGetVersionJSON(w http.ResponseWriter, r *http.Request) {
|
func handleGetVersionJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp := &versionResponse{}
|
||||||
if Context.disableUpdate {
|
if Context.disableUpdate {
|
||||||
resp := make(map[string]interface{})
|
// w.Header().Set("Content-Type", "application/json")
|
||||||
resp["disabled"] = true
|
resp.Disabled = true
|
||||||
d, _ := json.Marshal(resp)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
_, _ = w.Write(d)
|
// TODO(e.burkov): Add error handling and deal with headers.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req := getVersionJSONRequest{}
|
req := &struct {
|
||||||
|
Recheck bool `json:"recheck_now"`
|
||||||
|
}{}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if r.ContentLength != 0 {
|
if r.ContentLength != 0 {
|
||||||
err = json.NewDecoder(r.Body).Decode(&req)
|
err = json.NewDecoder(r.Body).Decode(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(w, http.StatusBadRequest, "JSON parse: %s", err)
|
httpError(w, http.StatusBadRequest, "JSON parse: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var info updater.VersionInfo
|
|
||||||
for i := 0; i != 3; i++ {
|
for i := 0; i != 3; i++ {
|
||||||
func() {
|
func() {
|
||||||
Context.controlLock.Lock()
|
Context.controlLock.Lock()
|
||||||
defer Context.controlLock.Unlock()
|
defer Context.controlLock.Unlock()
|
||||||
|
|
||||||
info, err = Context.updater.VersionInfo(req.RecheckNow)
|
resp.VersionInfo, err = Context.updater.VersionInfo(req.Recheck)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -76,13 +75,16 @@ func handleGetVersionJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
vcu := Context.updater.VersionCheckURL()
|
vcu := Context.updater.VersionCheckURL()
|
||||||
|
// TODO(a.garipov): Figure out the purpose of %T verb.
|
||||||
httpError(w, http.StatusBadGateway, "Couldn't get version check json from %s: %T %s\n", vcu, err, err)
|
httpError(w, http.StatusBadGateway, "Couldn't get version check json from %s: %T %s\n", vcu, err, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resp.confirmAutoUpdate()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, err = w.Write(getVersionResp(info))
|
err = json.NewEncoder(w).Encode(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(w, http.StatusInternalServerError, "Couldn't write body: %s", err)
|
httpError(w, http.StatusInternalServerError, "Couldn't write body: %s", err)
|
||||||
}
|
}
|
||||||
|
@ -109,21 +111,24 @@ func handleUpdate(w http.ResponseWriter, _ *http.Request) {
|
||||||
go finishUpdate()
|
go finishUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert version.json data to our JSON response
|
// versionResponse is the response for /control/version.json endpoint.
|
||||||
func getVersionResp(info updater.VersionInfo) []byte {
|
type versionResponse struct {
|
||||||
ret := make(map[string]interface{})
|
Disabled bool `json:"disabled"`
|
||||||
ret["can_autoupdate"] = false
|
updater.VersionInfo
|
||||||
ret["new_version"] = info.NewVersion
|
}
|
||||||
ret["announcement"] = info.Announcement
|
|
||||||
ret["announcement_url"] = info.AnnouncementURL
|
|
||||||
|
|
||||||
if info.CanAutoUpdate {
|
// confirmAutoUpdate checks the real possibility of auto update.
|
||||||
|
func (vr *versionResponse) confirmAutoUpdate() {
|
||||||
|
if vr.CanAutoUpdate != nil && *vr.CanAutoUpdate {
|
||||||
canUpdate := true
|
canUpdate := true
|
||||||
|
|
||||||
tlsConf := tlsConfigSettings{}
|
var tlsConf *tlsConfigSettings
|
||||||
Context.tls.WriteDiskConfig(&tlsConf)
|
if runtime.GOOS != "windows" {
|
||||||
|
tlsConf = &tlsConfigSettings{}
|
||||||
|
Context.tls.WriteDiskConfig(tlsConf)
|
||||||
|
}
|
||||||
|
|
||||||
if runtime.GOOS != "windows" &&
|
if tlsConf != nil &&
|
||||||
((tlsConf.Enabled && (tlsConf.PortHTTPS < 1024 ||
|
((tlsConf.Enabled && (tlsConf.PortHTTPS < 1024 ||
|
||||||
tlsConf.PortDNSOverTLS < 1024 ||
|
tlsConf.PortDNSOverTLS < 1024 ||
|
||||||
tlsConf.PortDNSOverQUIC < 1024)) ||
|
tlsConf.PortDNSOverQUIC < 1024)) ||
|
||||||
|
@ -131,11 +136,8 @@ func getVersionResp(info updater.VersionInfo) []byte {
|
||||||
config.DNS.Port < 1024) {
|
config.DNS.Port < 1024) {
|
||||||
canUpdate, _ = sysutil.CanBindPrivilegedPorts()
|
canUpdate, _ = sysutil.CanBindPrivilegedPorts()
|
||||||
}
|
}
|
||||||
ret["can_autoupdate"] = canUpdate
|
vr.CanAutoUpdate = &canUpdate
|
||||||
}
|
}
|
||||||
|
|
||||||
d, _ := json.Marshal(ret)
|
|
||||||
return d
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete an update procedure
|
// Complete an update procedure
|
||||||
|
|
|
@ -19,26 +19,43 @@ func httpError(r *http.Request, w http.ResponseWriter, code int, format string,
|
||||||
http.Error(w, text, code)
|
http.Error(w, text, code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return data
|
// statsResponse is a response for getting statistics.
|
||||||
|
type statsResponse struct {
|
||||||
|
TimeUnits string `json:"time_units"`
|
||||||
|
|
||||||
|
NumDNSQueries uint64 `json:"num_dns_queries"`
|
||||||
|
NumBlockedFiltering uint64 `json:"num_blocked_filtering"`
|
||||||
|
NumReplacedSafebrowsing uint64 `json:"num_replaced_safebrowsing"`
|
||||||
|
NumReplacedSafesearch uint64 `json:"num_replaced_safesearch"`
|
||||||
|
NumReplacedParental uint64 `json:"num_replaced_parental"`
|
||||||
|
|
||||||
|
AvgProcessingTime float64 `json:"avg_processing_time"`
|
||||||
|
|
||||||
|
TopQueried []map[string]uint64 `json:"top_queried_domains"`
|
||||||
|
TopClients []map[string]uint64 `json:"top_clients"`
|
||||||
|
TopBlocked []map[string]uint64 `json:"top_blocked_domains"`
|
||||||
|
|
||||||
|
DNSQueries []uint64 `json:"dns_queries"`
|
||||||
|
|
||||||
|
BlockedFiltering []uint64 `json:"blocked_filtering"`
|
||||||
|
ReplacedSafebrowsing []uint64 `json:"replaced_safebrowsing"`
|
||||||
|
ReplacedParental []uint64 `json:"replaced_parental"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStats is a handler for getting statistics.
|
||||||
func (s *statsCtx) handleStats(w http.ResponseWriter, r *http.Request) {
|
func (s *statsCtx) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
d := s.getData()
|
response, ok := s.getData()
|
||||||
log.Debug("Stats: prepared data in %v", time.Since(start))
|
log.Debug("Stats: prepared data in %v", time.Since(start))
|
||||||
|
|
||||||
if d == nil {
|
if !ok {
|
||||||
httpError(r, w, http.StatusInternalServerError, "Couldn't get statistics data")
|
httpError(r, w, http.StatusInternalServerError, "Couldn't get statistics data")
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(d)
|
|
||||||
if err != nil {
|
|
||||||
httpError(r, w, http.StatusInternalServerError, "json encode: %s", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
err := json.NewEncoder(w).Encode(response)
|
||||||
_, err = w.Write(data)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(r, w, http.StatusInternalServerError, "json encode: %s", err)
|
httpError(r, w, http.StatusInternalServerError, "json encode: %s", err)
|
||||||
|
|
||||||
|
|
|
@ -50,34 +50,36 @@ func TestStats(t *testing.T) {
|
||||||
e.Time = 123456
|
e.Time = 123456
|
||||||
s.Update(e)
|
s.Update(e)
|
||||||
|
|
||||||
d := s.getData()
|
d, ok := s.getData()
|
||||||
|
assert.True(t, ok)
|
||||||
|
|
||||||
a := []uint64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}
|
a := []uint64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}
|
||||||
assert.True(t, UIntArrayEquals(d["dns_queries"].([]uint64), a))
|
assert.True(t, UIntArrayEquals(d.DNSQueries, a))
|
||||||
|
|
||||||
a = []uint64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
a = []uint64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
||||||
assert.True(t, UIntArrayEquals(d["blocked_filtering"].([]uint64), a))
|
assert.True(t, UIntArrayEquals(d.BlockedFiltering, a))
|
||||||
|
|
||||||
a = []uint64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
a = []uint64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||||
assert.True(t, UIntArrayEquals(d["replaced_safebrowsing"].([]uint64), a))
|
assert.True(t, UIntArrayEquals(d.ReplacedSafebrowsing, a))
|
||||||
|
|
||||||
a = []uint64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
a = []uint64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||||
assert.True(t, UIntArrayEquals(d["replaced_parental"].([]uint64), a))
|
assert.True(t, UIntArrayEquals(d.ReplacedParental, a))
|
||||||
|
|
||||||
m := d["top_queried_domains"].([]map[string]uint64)
|
m := d.TopQueried
|
||||||
assert.EqualValues(t, 1, m[0]["domain"])
|
assert.EqualValues(t, 1, m[0]["domain"])
|
||||||
|
|
||||||
m = d["top_blocked_domains"].([]map[string]uint64)
|
m = d.TopBlocked
|
||||||
assert.EqualValues(t, 1, m[0]["domain"])
|
assert.EqualValues(t, 1, m[0]["domain"])
|
||||||
|
|
||||||
m = d["top_clients"].([]map[string]uint64)
|
m = d.TopClients
|
||||||
assert.EqualValues(t, 2, m[0]["127.0.0.1"])
|
assert.EqualValues(t, 2, m[0]["127.0.0.1"])
|
||||||
|
|
||||||
assert.EqualValues(t, 2, d["num_dns_queries"].(uint64))
|
assert.EqualValues(t, 2, d.NumDNSQueries)
|
||||||
assert.EqualValues(t, 1, d["num_blocked_filtering"].(uint64))
|
assert.EqualValues(t, 1, d.NumBlockedFiltering)
|
||||||
assert.EqualValues(t, 0, d["num_replaced_safebrowsing"].(uint64))
|
assert.EqualValues(t, 0, d.NumReplacedSafebrowsing)
|
||||||
assert.EqualValues(t, 0, d["num_replaced_safesearch"].(uint64))
|
assert.EqualValues(t, 0, d.NumReplacedSafesearch)
|
||||||
assert.EqualValues(t, 0, d["num_replaced_parental"].(uint64))
|
assert.EqualValues(t, 0, d.NumReplacedParental)
|
||||||
assert.EqualValues(t, 0.123456, d["avg_processing_time"].(float64))
|
assert.EqualValues(t, 0.123456, d.AvgProcessingTime)
|
||||||
|
|
||||||
topClients := s.GetTopClientsIP(2)
|
topClients := s.GetTopClientsIP(2)
|
||||||
assert.True(t, net.IP{127, 0, 0, 1}.Equal(topClients[0]))
|
assert.True(t, net.IP{127, 0, 0, 1}.Equal(topClients[0]))
|
||||||
|
@ -120,8 +122,9 @@ func TestLargeNumbers(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
d := s.getData()
|
d, ok := s.getData()
|
||||||
assert.EqualValues(t, int(hour)*n, d["num_dns_queries"])
|
assert.True(t, ok)
|
||||||
|
assert.EqualValues(t, int(hour)*n, d.NumDNSQueries)
|
||||||
|
|
||||||
s.Close()
|
s.Close()
|
||||||
os.Remove(conf.Filename)
|
os.Remove(conf.Filename)
|
||||||
|
|
|
@ -545,10 +545,9 @@ func (s *statsCtx) loadUnits(limit uint32) ([]*unitDB, uint32) {
|
||||||
* parental-blocked
|
* parental-blocked
|
||||||
These values are just the sum of data for all units.
|
These values are just the sum of data for all units.
|
||||||
*/
|
*/
|
||||||
func (s *statsCtx) getData() map[string]interface{} {
|
func (s *statsCtx) getData() (statsResponse, bool) {
|
||||||
limit := s.conf.limit
|
limit := s.conf.limit
|
||||||
|
|
||||||
d := map[string]interface{}{}
|
|
||||||
timeUnit := Hours
|
timeUnit := Hours
|
||||||
if limit/24 > 7 {
|
if limit/24 > 7 {
|
||||||
timeUnit = Days
|
timeUnit = Days
|
||||||
|
@ -556,7 +555,7 @@ func (s *statsCtx) getData() map[string]interface{} {
|
||||||
|
|
||||||
units, firstID := s.loadUnits(limit)
|
units, firstID := s.loadUnits(limit)
|
||||||
if units == nil {
|
if units == nil {
|
||||||
return nil
|
return statsResponse{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// per time unit counters:
|
// per time unit counters:
|
||||||
|
@ -604,18 +603,14 @@ func (s *statsCtx) getData() map[string]interface{} {
|
||||||
log.Fatalf("len(dnsQueries) != limit: %d %d", len(dnsQueries), limit)
|
log.Fatalf("len(dnsQueries) != limit: %d %d", len(dnsQueries), limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
statsData := map[string]interface{}{
|
data := statsResponse{
|
||||||
"dns_queries": dnsQueries,
|
DNSQueries: dnsQueries,
|
||||||
"blocked_filtering": statsCollector(func(u *unitDB) (num uint64) { return u.NResult[RFiltered] }),
|
BlockedFiltering: statsCollector(func(u *unitDB) (num uint64) { return u.NResult[RFiltered] }),
|
||||||
"replaced_safebrowsing": statsCollector(func(u *unitDB) (num uint64) { return u.NResult[RSafeBrowsing] }),
|
ReplacedSafebrowsing: statsCollector(func(u *unitDB) (num uint64) { return u.NResult[RSafeBrowsing] }),
|
||||||
"replaced_parental": statsCollector(func(u *unitDB) (num uint64) { return u.NResult[RParental] }),
|
ReplacedParental: statsCollector(func(u *unitDB) (num uint64) { return u.NResult[RParental] }),
|
||||||
"top_queried_domains": topsCollector(maxDomains, func(u *unitDB) (pairs []countPair) { return u.Domains }),
|
TopQueried: topsCollector(maxDomains, func(u *unitDB) (pairs []countPair) { return u.Domains }),
|
||||||
"top_blocked_domains": topsCollector(maxDomains, func(u *unitDB) (pairs []countPair) { return u.BlockedDomains }),
|
TopBlocked: topsCollector(maxDomains, func(u *unitDB) (pairs []countPair) { return u.BlockedDomains }),
|
||||||
"top_clients": topsCollector(maxClients, func(u *unitDB) (pairs []countPair) { return u.Clients }),
|
TopClients: topsCollector(maxClients, func(u *unitDB) (pairs []countPair) { return u.Clients }),
|
||||||
}
|
|
||||||
|
|
||||||
for dataKey, dataValue := range statsData {
|
|
||||||
d[dataKey] = dataValue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// total counters:
|
// total counters:
|
||||||
|
@ -635,24 +630,22 @@ func (s *statsCtx) getData() map[string]interface{} {
|
||||||
sum.NResult[RParental] += u.NResult[RParental]
|
sum.NResult[RParental] += u.NResult[RParental]
|
||||||
}
|
}
|
||||||
|
|
||||||
d["num_dns_queries"] = sum.NTotal
|
data.NumDNSQueries = sum.NTotal
|
||||||
d["num_blocked_filtering"] = sum.NResult[RFiltered]
|
data.NumBlockedFiltering = sum.NResult[RFiltered]
|
||||||
d["num_replaced_safebrowsing"] = sum.NResult[RSafeBrowsing]
|
data.NumReplacedSafebrowsing = sum.NResult[RSafeBrowsing]
|
||||||
d["num_replaced_safesearch"] = sum.NResult[RSafeSearch]
|
data.NumReplacedSafesearch = sum.NResult[RSafeSearch]
|
||||||
d["num_replaced_parental"] = sum.NResult[RParental]
|
data.NumReplacedParental = sum.NResult[RParental]
|
||||||
|
|
||||||
avgTime := float64(0)
|
|
||||||
if timeN != 0 {
|
if timeN != 0 {
|
||||||
avgTime = float64(sum.TimeAvg/uint32(timeN)) / 1000000
|
data.AvgProcessingTime = float64(sum.TimeAvg/uint32(timeN)) / 1000000
|
||||||
}
|
}
|
||||||
d["avg_processing_time"] = avgTime
|
|
||||||
|
|
||||||
d["time_units"] = "hours"
|
data.TimeUnits = "hours"
|
||||||
if timeUnit == Days {
|
if timeUnit == Days {
|
||||||
d["time_units"] = "days"
|
data.TimeUnits = "days"
|
||||||
}
|
}
|
||||||
|
|
||||||
return d
|
return data, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *statsCtx) GetTopClientsIP(maxCount uint) []net.IP {
|
func (s *statsCtx) GetTopClientsIP(maxCount uint) []net.IP {
|
||||||
|
|
|
@ -15,11 +15,11 @@ const versionCheckPeriod = 8 * time.Hour
|
||||||
|
|
||||||
// VersionInfo contains information about a new version.
|
// VersionInfo contains information about a new version.
|
||||||
type VersionInfo struct {
|
type VersionInfo struct {
|
||||||
NewVersion string
|
NewVersion string `json:"new_version,omitempty"`
|
||||||
Announcement string
|
Announcement string `json:"announcement,omitempty"`
|
||||||
AnnouncementURL string
|
AnnouncementURL string `json:"announcement_url,omitempty"`
|
||||||
SelfUpdateMinVersion string
|
SelfUpdateMinVersion string `json:"-"`
|
||||||
CanAutoUpdate bool
|
CanAutoUpdate *bool `json:"can_autoupdate,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaxResponseSize is responses on server's requests maximum length in bytes.
|
// MaxResponseSize is responses on server's requests maximum length in bytes.
|
||||||
|
@ -64,27 +64,37 @@ func (u *Updater) VersionInfo(forceRecheck bool) (VersionInfo, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *Updater) parseVersionResponse(data []byte) (VersionInfo, error) {
|
func (u *Updater) parseVersionResponse(data []byte) (VersionInfo, error) {
|
||||||
info := VersionInfo{}
|
var canAutoUpdate bool
|
||||||
versionJSON := make(map[string]interface{})
|
info := VersionInfo{
|
||||||
|
CanAutoUpdate: &canAutoUpdate,
|
||||||
|
}
|
||||||
|
versionJSON := map[string]string{
|
||||||
|
"version": "",
|
||||||
|
"announcement": "",
|
||||||
|
"announcement_url": "",
|
||||||
|
"selfupdate_min_version": "",
|
||||||
|
}
|
||||||
err := json.Unmarshal(data, &versionJSON)
|
err := json.Unmarshal(data, &versionJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return info, fmt.Errorf("version.json: %w", err)
|
return info, fmt.Errorf("version.json: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var ok1, ok2, ok3, ok4 bool
|
for _, v := range versionJSON {
|
||||||
info.NewVersion, ok1 = versionJSON["version"].(string)
|
if v == "" {
|
||||||
info.Announcement, ok2 = versionJSON["announcement"].(string)
|
return info, fmt.Errorf("version.json: invalid data")
|
||||||
info.AnnouncementURL, ok3 = versionJSON["announcement_url"].(string)
|
}
|
||||||
info.SelfUpdateMinVersion, ok4 = versionJSON["selfupdate_min_version"].(string)
|
|
||||||
if !ok1 || !ok2 || !ok3 || !ok4 {
|
|
||||||
return info, fmt.Errorf("version.json: invalid data")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info.NewVersion = versionJSON["version"]
|
||||||
|
info.Announcement = versionJSON["announcement"]
|
||||||
|
info.AnnouncementURL = versionJSON["announcement_url"]
|
||||||
|
info.SelfUpdateMinVersion = versionJSON["selfupdate_min_version"]
|
||||||
|
|
||||||
packageURL, ok := u.downloadURL(versionJSON)
|
packageURL, ok := u.downloadURL(versionJSON)
|
||||||
if ok &&
|
if ok &&
|
||||||
info.NewVersion != u.version &&
|
info.NewVersion != u.version &&
|
||||||
strings.TrimPrefix(u.version, "v") >= strings.TrimPrefix(info.SelfUpdateMinVersion, "v") {
|
strings.TrimPrefix(u.version, "v") >= strings.TrimPrefix(info.SelfUpdateMinVersion, "v") {
|
||||||
info.CanAutoUpdate = true
|
canAutoUpdate = true
|
||||||
}
|
}
|
||||||
|
|
||||||
u.newVersion = info.NewVersion
|
u.newVersion = info.NewVersion
|
||||||
|
@ -94,7 +104,7 @@ func (u *Updater) parseVersionResponse(data []byte) (VersionInfo, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadURL returns the download URL for current build.
|
// downloadURL returns the download URL for current build.
|
||||||
func (u *Updater) downloadURL(json map[string]interface{}) (string, bool) {
|
func (u *Updater) downloadURL(json map[string]string) (string, bool) {
|
||||||
var key string
|
var key string
|
||||||
|
|
||||||
if u.goarch == "arm" && u.goarm != "" {
|
if u.goarch == "arm" && u.goarm != "" {
|
||||||
|
@ -113,5 +123,5 @@ func (u *Updater) downloadURL(json map[string]interface{}) (string, bool) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
return val.(string), true
|
return val, true
|
||||||
}
|
}
|
||||||
|
|
|
@ -90,7 +90,9 @@ func TestUpdateGetVersion(t *testing.T) {
|
||||||
assert.Equal(t, "AdGuard Home v0.103.0-beta.2 is now available!", info.Announcement)
|
assert.Equal(t, "AdGuard Home v0.103.0-beta.2 is now available!", info.Announcement)
|
||||||
assert.Equal(t, "https://github.com/AdguardTeam/AdGuardHome/internal/releases", info.AnnouncementURL)
|
assert.Equal(t, "https://github.com/AdguardTeam/AdGuardHome/internal/releases", info.AnnouncementURL)
|
||||||
assert.Equal(t, "v0.0", info.SelfUpdateMinVersion)
|
assert.Equal(t, "v0.0", info.SelfUpdateMinVersion)
|
||||||
assert.True(t, info.CanAutoUpdate)
|
if assert.NotNil(t, info.CanAutoUpdate) {
|
||||||
|
assert.True(t, *info.CanAutoUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
// check cached
|
// check cached
|
||||||
_, err = u.VersionInfo(false)
|
_, err = u.VersionInfo(false)
|
||||||
|
@ -275,7 +277,9 @@ func TestUpdater_VersionInto_ARM(t *testing.T) {
|
||||||
assert.Equal(t, "AdGuard Home v0.103.0-beta.2 is now available!", info.Announcement)
|
assert.Equal(t, "AdGuard Home v0.103.0-beta.2 is now available!", info.Announcement)
|
||||||
assert.Equal(t, "https://github.com/AdguardTeam/AdGuardHome/internal/releases", info.AnnouncementURL)
|
assert.Equal(t, "https://github.com/AdguardTeam/AdGuardHome/internal/releases", info.AnnouncementURL)
|
||||||
assert.Equal(t, "v0.0", info.SelfUpdateMinVersion)
|
assert.Equal(t, "v0.0", info.SelfUpdateMinVersion)
|
||||||
assert.True(t, info.CanAutoUpdate)
|
if assert.NotNil(t, info.CanAutoUpdate) {
|
||||||
|
assert.True(t, *info.CanAutoUpdate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdater_VersionInto_MIPS(t *testing.T) {
|
func TestUpdater_VersionInto_MIPS(t *testing.T) {
|
||||||
|
@ -312,5 +316,7 @@ func TestUpdater_VersionInto_MIPS(t *testing.T) {
|
||||||
assert.Equal(t, "AdGuard Home v0.103.0-beta.2 is now available!", info.Announcement)
|
assert.Equal(t, "AdGuard Home v0.103.0-beta.2 is now available!", info.Announcement)
|
||||||
assert.Equal(t, "https://github.com/AdguardTeam/AdGuardHome/internal/releases", info.AnnouncementURL)
|
assert.Equal(t, "https://github.com/AdguardTeam/AdGuardHome/internal/releases", info.AnnouncementURL)
|
||||||
assert.Equal(t, "v0.0", info.SelfUpdateMinVersion)
|
assert.Equal(t, "v0.0", info.SelfUpdateMinVersion)
|
||||||
assert.True(t, info.CanAutoUpdate)
|
if assert.NotNil(t, info.CanAutoUpdate) {
|
||||||
|
assert.True(t, *info.CanAutoUpdate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
@ -13,14 +14,30 @@ import (
|
||||||
"github.com/AdguardTeam/golibs/log"
|
"github.com/AdguardTeam/golibs/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NetInterface represents a list of network interfaces
|
// NetInterface represents an entry of network interfaces map.
|
||||||
type NetInterface struct {
|
type NetInterface struct {
|
||||||
Name string // Network interface name
|
MTU int `json:"mtu"`
|
||||||
MTU int // MTU
|
Name string `json:"name"`
|
||||||
HardwareAddr string // Hardware address
|
HardwareAddr net.HardwareAddr `json:"hardware_address"`
|
||||||
Addresses []net.IP // Array with the network interface addresses
|
Flags net.Flags `json:"flags"`
|
||||||
Subnets []*net.IPNet // Array with CIDR addresses of this network interface
|
// Array with the network interface addresses.
|
||||||
Flags string // Network interface flags (up, broadcast, etc)
|
Addresses []net.IP `json:"ip_addresses,omitempty"`
|
||||||
|
// Array with IP networks for this network interface.
|
||||||
|
Subnets []*net.IPNet `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements the json.Marshaler interface for *NetInterface.
|
||||||
|
func (iface *NetInterface) MarshalJSON() ([]byte, error) {
|
||||||
|
type netInterface NetInterface
|
||||||
|
return json.Marshal(&struct {
|
||||||
|
HardwareAddr string `json:"hardware_address"`
|
||||||
|
Flags string `json:"flags"`
|
||||||
|
*netInterface
|
||||||
|
}{
|
||||||
|
HardwareAddr: iface.HardwareAddr.String(),
|
||||||
|
Flags: iface.Flags.String(),
|
||||||
|
netInterface: (*netInterface)(iface),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetValidNetInterfaces returns interfaces that are eligible for DNS and/or DHCP
|
// GetValidNetInterfaces returns interfaces that are eligible for DNS and/or DHCP
|
||||||
|
@ -40,7 +57,7 @@ func GetValidNetInterfaces() ([]net.Interface, error) {
|
||||||
|
|
||||||
// GetValidNetInterfacesForWeb returns interfaces that are eligible for DNS and WEB only
|
// GetValidNetInterfacesForWeb returns interfaces that are eligible for DNS and WEB only
|
||||||
// we do not return link-local addresses here
|
// we do not return link-local addresses here
|
||||||
func GetValidNetInterfacesForWeb() ([]NetInterface, error) {
|
func GetValidNetInterfacesForWeb() ([]*NetInterface, error) {
|
||||||
ifaces, err := GetValidNetInterfaces()
|
ifaces, err := GetValidNetInterfaces()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("couldn't get interfaces: %w", err)
|
return nil, fmt.Errorf("couldn't get interfaces: %w", err)
|
||||||
|
@ -49,7 +66,7 @@ func GetValidNetInterfacesForWeb() ([]NetInterface, error) {
|
||||||
return nil, errors.New("couldn't find any legible interface")
|
return nil, errors.New("couldn't find any legible interface")
|
||||||
}
|
}
|
||||||
|
|
||||||
var netInterfaces []NetInterface
|
var netInterfaces []*NetInterface
|
||||||
|
|
||||||
for _, iface := range ifaces {
|
for _, iface := range ifaces {
|
||||||
addrs, err := iface.Addrs()
|
addrs, err := iface.Addrs()
|
||||||
|
@ -57,24 +74,21 @@ func GetValidNetInterfacesForWeb() ([]NetInterface, error) {
|
||||||
return nil, fmt.Errorf("failed to get addresses for interface %s: %w", iface.Name, err)
|
return nil, fmt.Errorf("failed to get addresses for interface %s: %w", iface.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
netIface := NetInterface{
|
netIface := &NetInterface{
|
||||||
Name: iface.Name,
|
|
||||||
MTU: iface.MTU,
|
MTU: iface.MTU,
|
||||||
HardwareAddr: iface.HardwareAddr.String(),
|
Name: iface.Name,
|
||||||
|
HardwareAddr: iface.HardwareAddr,
|
||||||
|
Flags: iface.Flags,
|
||||||
}
|
}
|
||||||
|
|
||||||
if iface.Flags != 0 {
|
// Collect network interface addresses.
|
||||||
netIface.Flags = iface.Flags.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect network interface addresses
|
|
||||||
for _, addr := range addrs {
|
for _, addr := range addrs {
|
||||||
ipNet, ok := addr.(*net.IPNet)
|
ipNet, ok := addr.(*net.IPNet)
|
||||||
if !ok {
|
if !ok {
|
||||||
// not an IPNet, should not happen
|
// Should be net.IPNet, this is weird.
|
||||||
return nil, fmt.Errorf("got iface.Addrs() element %s that is not net.IPNet, it is %T", addr, addr)
|
return nil, fmt.Errorf("got iface.Addrs() element %s that is not net.IPNet, it is %T", addr, addr)
|
||||||
}
|
}
|
||||||
// ignore link-local
|
// Ignore link-local.
|
||||||
if ipNet.IP.IsLinkLocalUnicast() {
|
if ipNet.IP.IsLinkLocalUnicast() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -82,7 +96,7 @@ func GetValidNetInterfacesForWeb() ([]NetInterface, error) {
|
||||||
netIface.Subnets = append(netIface.Subnets, ipNet)
|
netIface.Subnets = append(netIface.Subnets, ipNet)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discard interfaces with no addresses
|
// Discard interfaces with no addresses.
|
||||||
if len(netIface.Addresses) != 0 {
|
if len(netIface.Addresses) != 0 {
|
||||||
netInterfaces = append(netInterfaces, netIface)
|
netInterfaces = append(netInterfaces, netIface)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1452,7 +1452,13 @@
|
||||||
'type': 'object'
|
'type': 'object'
|
||||||
'description': >
|
'description': >
|
||||||
Information about the latest available version of AdGuard Home.
|
Information about the latest available version of AdGuard Home.
|
||||||
|
'required':
|
||||||
|
- 'disabled'
|
||||||
'properties':
|
'properties':
|
||||||
|
'disabled':
|
||||||
|
'type': 'boolean'
|
||||||
|
'description': >
|
||||||
|
If true then other fields doesn't appear.
|
||||||
'new_version':
|
'new_version':
|
||||||
'type': 'string'
|
'type': 'string'
|
||||||
'example': 'v0.9'
|
'example': 'v0.9'
|
||||||
|
@ -1471,7 +1477,10 @@
|
||||||
'properties':
|
'properties':
|
||||||
'time_units':
|
'time_units':
|
||||||
'type': 'string'
|
'type': 'string'
|
||||||
'description': 'Time units (hours | days)'
|
'enum':
|
||||||
|
- 'hours'
|
||||||
|
- 'days'
|
||||||
|
'description': 'Time units'
|
||||||
'example': 'hours'
|
'example': 'hours'
|
||||||
'num_dns_queries':
|
'num_dns_queries':
|
||||||
'type': 'integer'
|
'type': 'integer'
|
||||||
|
@ -1988,6 +1997,10 @@
|
||||||
'properties':
|
'properties':
|
||||||
'flags':
|
'flags':
|
||||||
'type': 'string'
|
'type': 'string'
|
||||||
|
'description': >
|
||||||
|
Flags could be any combination of the following values, divided by
|
||||||
|
the "|" character: "up", "broadcast", "loopback", "pointtopoint" and
|
||||||
|
"multicast".
|
||||||
'example': 'up|broadcast|multicast'
|
'example': 'up|broadcast|multicast'
|
||||||
'hardware_address':
|
'hardware_address':
|
||||||
'type': 'string'
|
'type': 'string'
|
||||||
|
|
Loading…
Reference in New Issue