From 6a7b1aba8b9acbdcb403642a4904d9dc8670c63b Mon Sep 17 00:00:00 2001 From: Simon Zolin Date: Wed, 24 Apr 2019 17:58:04 +0300 Subject: [PATCH 01/19] + doc: clients --- AGHTechDoc.md | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/AGHTechDoc.md b/AGHTechDoc.md index e88bb055..62ea41d4 100644 --- a/AGHTechDoc.md +++ b/AGHTechDoc.md @@ -12,6 +12,12 @@ Contents: * Updating * Get version command * Update command +* Device Names and Per-client Settings + * Per-client settings + * Get list of clients + * Add client + * Update client + * Delete client * Enable DHCP server * "Check DHCP" command * "Enable DHCP" command @@ -420,3 +426,135 @@ Step 2. If we would set a different IP address, we'd need to replace the IP address for the current network configuration. But currently this step isn't necessary. ip addr replace dev eth0 192.168.0.1/24 + + +## Device Names and Per-client Settings + +When a client requests information from DNS server, he's identified by IP address. +Administrator can set a name for a client with a known IP and also override global settings for this client. The name is used to improve readability of DNS logs: client's name is shown in UI next to its IP address. The names are loaded from 3 sources: +* automatically from "/etc/hosts" file. It's a list of `IP<->Name` entries which is loaded once on AGH startup from "/etc/hosts" file. +* automatically using rDNS. It's a list of `IP<->Name` entries which is added in runtime using rDNS mechanism when a client first makes a DNS request. +* manually configured via UI. It's a list of client's names and their settings which is loaded from configuration file and stored on disk. + +### Per-client settings + +UI provides means to manage the list of known clients (List/Add/Update/Delete) and their settings. These settings are stored in configuration file as an array of objects. + +Notes: + +* `name`, `ip` and `mac` values are unique. + +* `ip` & `mac` values can't be set both at the same time. + +* If `mac` is set and DHCP server is enabled, IP is taken from DHCP lease table. + +* If `use_global_settings` is true, then DNS responses for this client are processed and filtered using global settings. + +* If `use_global_settings` is false, then the client-specific settings are used to override (disable) global settings. For example, if global setting `parental_enabled` is true, then per-client setting `parental_enabled:false` can disable Parental Control for this specific client. + + +### Get list of clients + +Request: + + GET /control/clients + +Response: + + 200 OK + + { + clients: [ + { + name: "client1" + ip: "..." + mac: "..." + use_global_settings: true + filtering_enabled: false + parental_enabled: false + safebrowsing_enabled: false + safesearch_enabled: false + } + ] + auto_clients: [ + { + name: "host" + ip: "..." + source: "etc/hosts" || "rDNS" + } + ] + } + + +### Add client + +Request: + + POST /control/clients/add + + { + name: "client1" + ip: "..." + mac: "..." + use_global_settings: true + filtering_enabled: false + parental_enabled: false + safebrowsing_enabled: false + safesearch_enabled: false + } + +Response: + + 200 OK + +Error response (Client already exists): + + 400 + + +### Update client + +Request: + + POST /control/clients/update + + { + name: "client1" + data: { + name: "client1" + ip: "..." + mac: "..." + use_global_settings: true + filtering_enabled: false + parental_enabled: false + safebrowsing_enabled: false + safesearch_enabled: false + } + } + +Response: + + 200 OK + +Error response (Client not found): + + 400 + + +### Delete client + +Request: + + POST /control/clients/delete + + { + name: "client1" + } + +Response: + + 200 OK + +Error response (Client not found): + + 400 From 5fb7e44e79bfd30f165b9cc69dd09f9689a7fbf6 Mon Sep 17 00:00:00 2001 From: Simon Zolin Date: Fri, 26 Apr 2019 15:10:29 +0300 Subject: [PATCH 02/19] + clients API * /clients handler: new format + /clients/add handler + /clients/delete handler + /clients/update handler --- clients.go | 436 +++++++++++++++++++++++++++++++++++++++++++++--- clients_test.go | 122 ++++++++++++++ 2 files changed, 535 insertions(+), 23 deletions(-) create mode 100644 clients_test.go diff --git a/clients.go b/clients.go index 87a5c3fe..88143737 100644 --- a/clients.go +++ b/clients.go @@ -2,32 +2,264 @@ package main import ( "encoding/json" + "fmt" "io/ioutil" + "net" "net/http" "os" "runtime" "strings" + "sync" "github.com/AdguardTeam/golibs/log" ) // Client information type Client struct { - IP string - Name string - //Source source // Hosts file / User settings / DHCP + IP string + MAC string + Name string + UseOwnSettings bool // false: use global settings + FilteringEnabled bool + SafeSearchEnabled bool + SafeBrowsingEnabled bool + ParentalEnabled bool } type clientJSON struct { - IP string `json:"ip"` - Name string `json:"name"` + IP string `json:"ip"` + MAC string `json:"mac"` + Name string `json:"name"` + UseGlobalSettings bool `json:"use_global_settings"` + FilteringEnabled bool `json:"filtering_enabled"` + ParentalEnabled bool `json:"parental_enabled"` + SafeSearchEnabled bool `json:"safebrowsing_enabled"` + SafeBrowsingEnabled bool `json:"safesearch_enabled"` } -var clients []Client -var clientsFilled bool +type clientSource uint + +const ( + ClientSourceHostsFile clientSource = 0 // from /etc/hosts + ClientSourceRDNS clientSource = 1 // from rDNS +) + +// ClientHost information +type ClientHost struct { + Host string + Source clientSource +} + +type clientsContainer struct { + list map[string]*Client + ipIndex map[string]*Client + ipHost map[string]ClientHost // IP -> Hostname + lock sync.Mutex +} + +var clients clientsContainer + +// Initialize clients container +func clientsInit() { + if clients.list != nil { + log.Fatal("clients.list != nil") + } + clients.list = make(map[string]*Client) + clients.ipIndex = make(map[string]*Client) + clients.ipHost = make(map[string]ClientHost) + + clientsAddFromHostsFile() +} + +func clientsGetList() map[string]*Client { + return clients.list +} + +func clientExists(ip string) bool { + clients.lock.Lock() + defer clients.lock.Unlock() + + _, ok := clients.ipIndex[ip] + if ok { + return true + } + + _, ok = clients.ipHost[ip] + return ok +} + +// Search for a client by IP +func clientFind(ip string) (Client, bool) { + clients.lock.Lock() + defer clients.lock.Unlock() + + c, ok := clients.ipIndex[ip] + if ok { + return *c, true + } + + for _, c = range clients.list { + if len(c.MAC) != 0 { + mac, err := net.ParseMAC(c.MAC) + if err != nil { + continue + } + ipAddr := dhcpServer.FindIPbyMAC(mac) + if ipAddr == nil { + continue + } + if ip == ipAddr.String() { + return *c, true + } + } + } + + return Client{}, false +} + +// Check if Client object's fields are correct +func clientCheck(c *Client) error { + if len(c.Name) == 0 { + return fmt.Errorf("Invalid Name") + } + + if (len(c.IP) == 0 && len(c.MAC) == 0) || + (len(c.IP) != 0 && len(c.MAC) != 0) { + return fmt.Errorf("IP or MAC required") + } + + if len(c.IP) != 0 { + ip := net.ParseIP(c.IP) + if ip == nil { + return fmt.Errorf("Invalid IP") + } + c.IP = ip.String() + } else { + _, err := net.ParseMAC(c.MAC) + if err != nil { + return fmt.Errorf("Invalid MAC: %s", err) + } + } + return nil +} + +// Add a new client object +// Return true: success; false: client exists. +func clientAdd(c Client) (bool, error) { + e := clientCheck(&c) + if e != nil { + return false, e + } + + clients.lock.Lock() + defer clients.lock.Unlock() + + // check Name index + _, ok := clients.list[c.Name] + if ok { + return false, nil + } + + // check IP index + if len(c.IP) != 0 { + c2, ok := clients.ipIndex[c.IP] + if ok { + return false, fmt.Errorf("Another client uses the same IP address: %s", c2.Name) + } + } + + clients.list[c.Name] = &c + if len(c.IP) != 0 { + clients.ipIndex[c.IP] = &c + } + + log.Tracef("'%s': '%s' | '%s' -> [%d]", c.Name, c.IP, c.MAC, len(clients.list)) + return true, nil +} + +// Remove a client +func clientDel(name string) bool { + clients.lock.Lock() + defer clients.lock.Unlock() + + c, ok := clients.list[name] + if !ok { + return false + } + + delete(clients.list, name) + delete(clients.ipIndex, c.IP) + return true +} + +// Update a client +func clientUpdate(name string, c Client) error { + err := clientCheck(&c) + if err != nil { + return err + } + + clients.lock.Lock() + defer clients.lock.Unlock() + + old, ok := clients.list[name] + if !ok { + return fmt.Errorf("Client not found") + } + + // check Name index + if old.Name != c.Name { + _, ok = clients.list[c.Name] + if ok { + return fmt.Errorf("Client already exists") + } + } + + // check IP index + if old.IP != c.IP && len(c.IP) != 0 { + c2, ok := clients.ipIndex[c.IP] + if ok { + return fmt.Errorf("Another client uses the same IP address: %s", c2.Name) + } + } + + // update Name index + if old.Name != c.Name { + delete(clients.list, old.Name) + } + clients.list[c.Name] = &c + + // update IP index + if old.IP != c.IP { + delete(clients.ipIndex, old.IP) + } + if len(c.IP) != 0 { + clients.ipIndex[c.IP] = &c + } + + return nil +} + +func clientAddHost(ip, host string, source clientSource) (bool, error) { + clients.lock.Lock() + defer clients.lock.Unlock() + + // check index + _, ok := clients.ipHost[ip] + if ok { + return false, nil + } + + clients.ipHost[ip] = ClientHost{ + Host: host, + Source: source, + } + log.Tracef("'%s': '%s' -> [%d]", host, ip, len(clients.ipHost)) + return true, nil +} // Parse system 'hosts' file and fill clients array -func fillClientInfo() { +func clientsAddFromHostsFile() { hostsFn := "/etc/hosts" if runtime.GOOS == "windows" { hostsFn = os.ExpandEnv("$SystemRoot\\system32\\drivers\\etc\\hosts") @@ -40,6 +272,7 @@ func fillClientInfo() { } lines := strings.Split(string(d), "\n") + n := 0 for _, ln := range lines { ln = strings.TrimSpace(ln) if len(ln) == 0 || ln[0] == '#' { @@ -51,33 +284,71 @@ func fillClientInfo() { continue } - var c Client - c.IP = fields[0] - c.Name = fields[1] - clients = append(clients, c) - log.Tracef("%s -> %s", c.IP, c.Name) + ok, e := clientAddHost(fields[0], fields[1], ClientSourceHostsFile) + if e != nil { + log.Tracef("%s", e) + } + if ok { + n++ + } } - log.Info("Added %d client aliases from %s", len(clients), hostsFn) - clientsFilled = true + log.Info("Added %d client aliases from %s", n, hostsFn) +} + +type clientHostJSON struct { + IP string `json:"ip"` + Name string `json:"name"` + Source string `json:"source"` +} + +type clientListJSON struct { + Clients []clientJSON `json:"clients"` + AutoClients []clientHostJSON `json:"auto_clients"` } // respond with information about configured clients func handleGetClients(w http.ResponseWriter, r *http.Request) { log.Tracef("%s %v", r.Method, r.URL) - if !clientsFilled { - fillClientInfo() - } + data := clientListJSON{} - data := []clientJSON{} - for _, c := range clients { + clients.lock.Lock() + for _, c := range clients.list { cj := clientJSON{ - IP: c.IP, - Name: c.Name, + IP: c.IP, + MAC: c.MAC, + Name: c.Name, + UseGlobalSettings: !c.UseOwnSettings, + FilteringEnabled: c.FilteringEnabled, + ParentalEnabled: c.ParentalEnabled, + SafeSearchEnabled: c.SafeSearchEnabled, + SafeBrowsingEnabled: c.SafeBrowsingEnabled, } - data = append(data, cj) + + if len(c.MAC) != 0 { + hwAddr, _ := net.ParseMAC(c.MAC) + ipAddr := dhcpServer.FindIPbyMAC(hwAddr) + if ipAddr != nil { + cj.IP = ipAddr.String() + } + } + + data.Clients = append(data.Clients, cj) } + for ip, ch := range clients.ipHost { + cj := clientHostJSON{ + IP: ip, + Name: ch.Host, + } + cj.Source = "etc/hosts" + if ch.Source == ClientSourceRDNS { + cj.Source = "rDNS" + } + data.AutoClients = append(data.AutoClients, cj) + } + clients.lock.Unlock() + w.Header().Set("Content-Type", "application/json") e := json.NewEncoder(w).Encode(data) if e != nil { @@ -86,7 +357,126 @@ func handleGetClients(w http.ResponseWriter, r *http.Request) { } } +// Convert JSON object to Client object +func jsonToClient(cj clientJSON) (*Client, error) { + c := Client{ + IP: cj.IP, + MAC: cj.MAC, + Name: cj.Name, + UseOwnSettings: !cj.UseGlobalSettings, + FilteringEnabled: cj.FilteringEnabled, + ParentalEnabled: cj.ParentalEnabled, + SafeSearchEnabled: cj.SafeSearchEnabled, + SafeBrowsingEnabled: cj.SafeBrowsingEnabled, + } + return &c, nil +} + +// Add a new client +func handleAddClient(w http.ResponseWriter, r *http.Request) { + log.Tracef("%s %v", r.Method, r.URL) + body, err := ioutil.ReadAll(r.Body) + if err != nil { + httpError(w, http.StatusBadRequest, "failed to read request body: %s", err) + return + } + + cj := clientJSON{} + err = json.Unmarshal(body, &cj) + if err != nil { + httpError(w, http.StatusBadRequest, "JSON parse: %s", err) + return + } + + c, err := jsonToClient(cj) + if err != nil { + httpError(w, http.StatusBadRequest, "%s", err) + return + } + ok, err := clientAdd(*c) + if err != nil { + httpError(w, http.StatusBadRequest, "%s", err) + return + } + if !ok { + httpError(w, http.StatusBadRequest, "Client already exists") + return + } + + _ = writeAllConfigsAndReloadDNS() + returnOK(w) +} + +// Remove client +func handleDelClient(w http.ResponseWriter, r *http.Request) { + log.Tracef("%s %v", r.Method, r.URL) + body, err := ioutil.ReadAll(r.Body) + if err != nil { + httpError(w, http.StatusBadRequest, "failed to read request body: %s", err) + return + } + + cj := clientJSON{} + err = json.Unmarshal(body, &cj) + if err != nil || len(cj.Name) == 0 { + httpError(w, http.StatusBadRequest, "JSON parse: %s", err) + return + } + + if !clientDel(cj.Name) { + httpError(w, http.StatusBadRequest, "Client not found") + return + } + + _ = writeAllConfigsAndReloadDNS() + returnOK(w) +} + +type clientUpdateJSON struct { + Name string `json:"name"` + Data clientJSON `json:"data"` +} + +// Update client's properties +func handleUpdateClient(w http.ResponseWriter, r *http.Request) { + log.Tracef("%s %v", r.Method, r.URL) + body, err := ioutil.ReadAll(r.Body) + if err != nil { + httpError(w, http.StatusBadRequest, "failed to read request body: %s", err) + return + } + + var dj clientUpdateJSON + err = json.Unmarshal(body, &dj) + if err != nil { + httpError(w, http.StatusBadRequest, "JSON parse: %s", err) + return + } + if len(dj.Name) == 0 { + httpError(w, http.StatusBadRequest, "Invalid request") + return + } + + c, err := jsonToClient(dj.Data) + if err != nil { + httpError(w, http.StatusBadRequest, "%s", err) + return + } + + err = clientUpdate(dj.Name, *c) + if err != nil { + httpError(w, http.StatusBadRequest, "%s", err) + return + } + + _ = writeAllConfigsAndReloadDNS() + returnOK(w) +} + // RegisterClientsHandlers registers HTTP handlers func RegisterClientsHandlers() { http.HandleFunc("/control/clients", postInstall(optionalAuth(ensureGET(handleGetClients)))) + http.HandleFunc("/control/clients/add", postInstall(optionalAuth(ensurePOST(handleAddClient)))) + http.HandleFunc("/control/clients/delete", postInstall(optionalAuth(ensurePOST(handleDelClient)))) + http.HandleFunc("/control/clients/update", postInstall(optionalAuth(ensurePOST(handleUpdateClient)))) } diff --git a/clients_test.go b/clients_test.go new file mode 100644 index 00000000..83bb9a6a --- /dev/null +++ b/clients_test.go @@ -0,0 +1,122 @@ +package main + +import "testing" + +func TestClients(t *testing.T) { + var c Client + var e error + var b bool + + clientsInit() + + // add + c = Client{ + IP: "1.1.1.1", + Name: "client1", + } + b, e = clientAdd(c) + if !b || e != nil { + t.Fatalf("clientAdd #1") + } + + // add #2 + c = Client{ + IP: "2.2.2.2", + Name: "client2", + } + b, e = clientAdd(c) + if !b || e != nil { + t.Fatalf("clientAdd #2") + } + + // failed add - name in use + c = Client{ + IP: "1.2.3.5", + Name: "client1", + } + b, e = clientAdd(c) + if b { + t.Fatalf("clientAdd - name in use") + } + + // failed add - ip in use + c = Client{ + IP: "2.2.2.2", + Name: "client3", + } + b, e = clientAdd(c) + if b || e == nil { + t.Fatalf("clientAdd - ip in use") + } + + // get + if clientExists("1.2.3.4") { + t.Fatalf("clientExists") + } + if !clientExists("1.1.1.1") { + t.Fatalf("clientExists #1") + } + if !clientExists("2.2.2.2") { + t.Fatalf("clientExists #2") + } + + // failed update - no such name + c.IP = "1.2.3.0" + c.Name = "client3" + if clientUpdate("client3", c) == nil { + t.Fatalf("clientUpdate") + } + + // failed update - name in use + c.IP = "1.2.3.0" + c.Name = "client2" + if clientUpdate("client1", c) == nil { + t.Fatalf("clientUpdate - name in use") + } + + // failed update - ip in use + c.IP = "2.2.2.2" + c.Name = "client1" + if clientUpdate("client1", c) == nil { + t.Fatalf("clientUpdate - ip in use") + } + + // update + c.IP = "1.1.1.2" + c.Name = "client1" + if clientUpdate("client1", c) != nil { + t.Fatalf("clientUpdate") + } + + // get after update + if clientExists("1.1.1.1") || !clientExists("1.1.1.2") { + t.Fatalf("clientExists - get after update") + } + + // failed remove - no such name + if clientDel("client3") { + t.Fatalf("clientDel - no such name") + } + + // remove + if !clientDel("client1") || clientExists("1.1.1.2") { + t.Fatalf("clientDel") + } + + // add host client + b, e = clientAddHost("1.1.1.1", "host", ClientSourceHostsFile) + if !b || e != nil { + t.Fatalf("clientAddHost") + } + + // failed add - ip exists + b, e = clientAddHost("1.1.1.1", "host", ClientSourceHostsFile) + if b { + t.Fatalf("clientAddHost - ip exists") + } + + // get + if !clientExists("1.1.1.1") { + t.Fatalf("clientAddHost") + } +} From 8f7aff93d7297ed93620c53088ce9c70135dae26 Mon Sep 17 00:00:00 2001 From: Simon Zolin Date: Fri, 26 Apr 2019 16:04:22 +0300 Subject: [PATCH 03/19] + clients: config: save/restore clients info array --- config.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/config.go b/config.go index c6edde0f..65bf8025 100644 --- a/config.go +++ b/config.go @@ -27,6 +27,17 @@ type logSettings struct { Verbose bool `yaml:"verbose"` // If true, verbose logging is enabled } +type clientObject struct { + Name string `yaml:"name"` + IP string `yaml:"ip"` + MAC string `yaml:"mac"` + UseGlobalSettings bool `yaml:"use_global_settings"` + FilteringEnabled bool `yaml:"filtering_enabled"` + ParentalEnabled bool `yaml:"parental_enabled"` + SafeSearchEnabled bool `yaml:"safebrowsing_enabled"` + SafeBrowsingEnabled bool `yaml:"safesearch_enabled"` +} + // configuration is loaded from YAML // field ordering is important -- yaml fields will mirror ordering from here type configuration struct { @@ -54,6 +65,9 @@ type configuration struct { UserRules []string `yaml:"user_rules"` DHCP dhcpd.ServerConfig `yaml:"dhcp"` + // Note: this array is filled only before file read/write and then it's cleared + Clients []clientObject `yaml:"clients"` + logSettings `yaml:",inline"` sync.RWMutex `yaml:"-"` @@ -206,6 +220,25 @@ func parseConfig() error { return err } + clientsInit() + for _, cy := range config.Clients { + cli := Client{ + Name: cy.Name, + IP: cy.IP, + MAC: cy.MAC, + UseOwnSettings: !cy.UseGlobalSettings, + FilteringEnabled: cy.FilteringEnabled, + ParentalEnabled: cy.ParentalEnabled, + SafeSearchEnabled: cy.SafeSearchEnabled, + SafeBrowsingEnabled: cy.SafeBrowsingEnabled, + } + _, err = clientAdd(cli) + if err != nil { + log.Tracef("clientAdd: %s", err) + } + } + config.Clients = nil + // Deduplicate filters deduplicateFilters() @@ -233,9 +266,30 @@ func readConfigFile() ([]byte, error) { func (c *configuration) write() error { c.Lock() defer c.Unlock() + + clientsList := clientsGetList() + for _, cli := range clientsList { + ip := cli.IP + if len(cli.MAC) != 0 { + ip = "" + } + cy := clientObject{ + Name: cli.Name, + IP: ip, + MAC: cli.MAC, + UseGlobalSettings: !cli.UseOwnSettings, + FilteringEnabled: cli.FilteringEnabled, + ParentalEnabled: cli.ParentalEnabled, + SafeSearchEnabled: cli.SafeSearchEnabled, + SafeBrowsingEnabled: cli.SafeBrowsingEnabled, + } + config.Clients = append(config.Clients, cy) + } + configFile := config.getConfigFilename() log.Debug("Writing YAML file: %s", configFile) yamlText, err := yaml.Marshal(&config) + config.Clients = nil if err != nil { log.Error("Couldn't generate YAML file: %s", err) return err From 3f89335ed2f820db627b09a5c32fdc96d79ccdad Mon Sep 17 00:00:00 2001 From: Simon Zolin Date: Fri, 26 Apr 2019 16:07:12 +0300 Subject: [PATCH 04/19] + dns: use per-client filtering settings --- dns.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/dns.go b/dns.go index 001876d0..e637fa4a 100644 --- a/dns.go +++ b/dns.go @@ -70,9 +70,32 @@ func generateServerConfig() dnsforward.ServerConfig { newconfig.Upstreams = upstreamConfig.Upstreams newconfig.DomainsReservedUpstreams = upstreamConfig.DomainReservedUpstreams newconfig.AllServers = config.DNS.AllServers + newconfig.FilterHandler = applyClientSettings return newconfig } +// If a client has his own settings, apply them +func applyClientSettings(clientAddr string, setts *dnsfilter.RequestFilteringSettings) { + c, ok := clientFind(clientAddr) + if !ok || !c.UseOwnSettings { + return + } + + log.Debug("Using settings for client with IP %s", clientAddr) + if !c.FilteringEnabled { + setts.FilteringEnabled = false + } + if !c.SafeSearchEnabled { + setts.SafeSearchEnabled = false + } + if !c.SafeBrowsingEnabled { + setts.SafeBrowsingEnabled = false + } + if !c.ParentalEnabled { + setts.ParentalEnabled = false + } +} + func startDNSServer() error { if isRunning() { return fmt.Errorf("unable to start forwarding DNS server: Already running") From 4bb7b654ab06f31514ac6b2a78cf2ed8a159cf9f Mon Sep 17 00:00:00 2001 From: Simon Zolin Date: Tue, 28 May 2019 14:11:47 +0300 Subject: [PATCH 05/19] + dhcp: FindIPbyMAC() --- dhcpd/dhcpd.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/dhcpd/dhcpd.go b/dhcpd/dhcpd.go index 4a9eaf73..fd3e807a 100644 --- a/dhcpd/dhcpd.go +++ b/dhcpd/dhcpd.go @@ -540,6 +540,19 @@ func (s *Server) printLeases() { } } +// FindIPbyMAC finds an IP address by MAC address in the currently active DHCP leases +func (s *Server) FindIPbyMAC(mac net.HardwareAddr) net.IP { + now := time.Now().Unix() + s.RLock() + defer s.RUnlock() + for _, l := range s.leases { + if l.Expiry.Unix() > now && bytes.Equal(mac, l.HWAddr) { + return l.IP + } + } + return nil +} + // Reset internal state func (s *Server) reset() { s.Lock() From 8bf76c331d8a5244cd760bff5d7ed249de79478e Mon Sep 17 00:00:00 2001 From: Simon Zolin Date: Tue, 28 May 2019 14:14:12 +0300 Subject: [PATCH 06/19] + dnsfilter: use callback function for applying per-client settings --- dnsfilter/dnsfilter.go | 44 +++++++++++++++++------ dnsfilter/dnsfilter_test.go | 71 ++++++++++++++++++++++++++++++------- dnsforward/dnsforward.go | 6 +++- 3 files changed, 98 insertions(+), 23 deletions(-) diff --git a/dnsfilter/dnsfilter.go b/dnsfilter/dnsfilter.go index bf1e7a40..08604f34 100644 --- a/dnsfilter/dnsfilter.go +++ b/dnsfilter/dnsfilter.go @@ -46,6 +46,14 @@ const shortcutLength = 6 // used for rule search optimization, 6 hits the sweet const enableFastLookup = true // flag for debugging, must be true in production for faster performance const enableDelayedCompilation = true // flag for debugging, must be true in production for faster performance +// Custom filtering settings +type RequestFilteringSettings struct { + FilteringEnabled bool + SafeSearchEnabled bool + SafeBrowsingEnabled bool + ParentalEnabled bool +} + // Config allows you to configure DNS filtering with New() or just change variables directly. type Config struct { FilteringTempFilename string `yaml:"filtering_temp_filename"` // temporary file for storing unused filtering rules @@ -55,6 +63,9 @@ type Config struct { SafeSearchEnabled bool `yaml:"safesearch_enabled"` SafeBrowsingEnabled bool `yaml:"safebrowsing_enabled"` ResolverAddress string // DNS server address + + // Filtering callback function + FilterHandler func(clientAddr string, settings *RequestFilteringSettings) `yaml:"-"` } type privateConfig struct { @@ -149,7 +160,7 @@ func (r Reason) Matched() bool { } // CheckHost tries to match host against rules, then safebrowsing and parental if they are enabled -func (d *Dnsfilter) CheckHost(host string, qtype uint16) (Result, error) { +func (d *Dnsfilter) CheckHost(host string, qtype uint16, clientAddr string) (Result, error) { // sometimes DNS clients will try to resolve ".", which is a request to get root servers if host == "" { return Result{Reason: NotFilteredNotFound}, nil @@ -160,17 +171,30 @@ func (d *Dnsfilter) CheckHost(host string, qtype uint16) (Result, error) { return Result{}, nil } - // try filter lists first - result, err := d.matchHost(host, qtype) - if err != nil { - return result, err + var setts RequestFilteringSettings + setts.FilteringEnabled = true + setts.SafeSearchEnabled = d.SafeSearchEnabled + setts.SafeBrowsingEnabled = d.SafeBrowsingEnabled + setts.ParentalEnabled = d.ParentalEnabled + if len(clientAddr) != 0 && d.FilterHandler != nil { + d.FilterHandler(clientAddr, &setts) } - if result.Reason.Matched() { - return result, nil + + var result Result + var err error + // try filter lists first + if setts.FilteringEnabled { + result, err = d.matchHost(host, qtype) + if err != nil { + return result, err + } + if result.Reason.Matched() { + return result, nil + } } // check safeSearch if no match - if d.SafeSearchEnabled { + if setts.SafeSearchEnabled { result, err = d.checkSafeSearch(host) if err != nil { log.Printf("Failed to safesearch HTTP lookup, ignoring check: %v", err) @@ -183,7 +207,7 @@ func (d *Dnsfilter) CheckHost(host string, qtype uint16) (Result, error) { } // check safebrowsing if no match - if d.SafeBrowsingEnabled { + if setts.SafeBrowsingEnabled { result, err = d.checkSafeBrowsing(host) if err != nil { // failed to do HTTP lookup -- treat it as if we got empty response, but don't save cache @@ -196,7 +220,7 @@ func (d *Dnsfilter) CheckHost(host string, qtype uint16) (Result, error) { } // check parental if no match - if d.ParentalEnabled { + if setts.ParentalEnabled { result, err = d.checkParental(host) if err != nil { // failed to do HTTP lookup -- treat it as if we got empty response, but don't save cache diff --git a/dnsfilter/dnsfilter_test.go b/dnsfilter/dnsfilter_test.go index 4619310f..7488985f 100644 --- a/dnsfilter/dnsfilter_test.go +++ b/dnsfilter/dnsfilter_test.go @@ -18,6 +18,7 @@ import ( // SAFE SEARCH // PARENTAL // FILTERING +// CLIENTS SETTINGS // BENCHMARKS // HELPERS @@ -52,7 +53,7 @@ func NewForTestFilters(filters map[int]string) *Dnsfilter { func (d *Dnsfilter) checkMatch(t *testing.T, hostname string) { t.Helper() - ret, err := d.CheckHost(hostname, dns.TypeA) + ret, err := d.CheckHost(hostname, dns.TypeA, "") if err != nil { t.Errorf("Error while matching host %s: %s", hostname, err) } @@ -63,7 +64,7 @@ func (d *Dnsfilter) checkMatch(t *testing.T, hostname string) { func (d *Dnsfilter) checkMatchIP(t *testing.T, hostname string, ip string, qtype uint16) { t.Helper() - ret, err := d.CheckHost(hostname, qtype) + ret, err := d.CheckHost(hostname, qtype, "") if err != nil { t.Errorf("Error while matching host %s: %s", hostname, err) } @@ -77,7 +78,7 @@ func (d *Dnsfilter) checkMatchIP(t *testing.T, hostname string, ip string, qtype func (d *Dnsfilter) checkMatchEmpty(t *testing.T, hostname string) { t.Helper() - ret, err := d.CheckHost(hostname, dns.TypeA) + ret, err := d.CheckHost(hostname, dns.TypeA, "") if err != nil { t.Errorf("Error while matching host %s: %s", hostname, err) } @@ -212,7 +213,7 @@ func TestCheckHostSafeSearchYandex(t *testing.T) { // Check host for each domain for _, host := range yandex { - result, err := d.CheckHost(host, dns.TypeA) + result, err := d.CheckHost(host, dns.TypeA, "") if err != nil { t.Errorf("SafeSearch doesn't work for yandex domain `%s` cause %s", host, err) } @@ -235,7 +236,7 @@ func TestCheckHostSafeSearchGoogle(t *testing.T) { // Check host for each domain for _, host := range googleDomains { - result, err := d.CheckHost(host, dns.TypeA) + result, err := d.CheckHost(host, dns.TypeA, "") if err != nil { t.Errorf("SafeSearch doesn't work for %s cause %s", host, err) } @@ -255,7 +256,7 @@ func TestSafeSearchCacheYandex(t *testing.T) { var err error // Check host with disabled safesearch - result, err = d.CheckHost(domain, dns.TypeA) + result, err = d.CheckHost(domain, dns.TypeA, "") if err != nil { t.Fatalf("Cannot check host due to %s", err) } @@ -265,7 +266,7 @@ func TestSafeSearchCacheYandex(t *testing.T) { // Enable safesearch d.SafeSearchEnabled = true - result, err = d.CheckHost(domain, dns.TypeA) + result, err = d.CheckHost(domain, dns.TypeA, "") if err != nil { t.Fatalf("CheckHost for safesearh domain %s failed cause %s", domain, err) } @@ -295,7 +296,7 @@ func TestSafeSearchCacheGoogle(t *testing.T) { d := NewForTest() defer d.Destroy() domain := "www.google.ru" - result, err := d.CheckHost(domain, dns.TypeA) + result, err := d.CheckHost(domain, dns.TypeA, "") if err != nil { t.Fatalf("Cannot check host due to %s", err) } @@ -324,7 +325,7 @@ func TestSafeSearchCacheGoogle(t *testing.T) { } } - result, err = d.CheckHost(domain, dns.TypeA) + result, err = d.CheckHost(domain, dns.TypeA, "") if err != nil { t.Fatalf("CheckHost for safesearh domain %s failed cause %s", domain, err) } @@ -441,7 +442,7 @@ func TestMatching(t *testing.T) { d := NewForTestFilters(filters) defer d.Destroy() - ret, err := d.CheckHost(test.hostname, dns.TypeA) + ret, err := d.CheckHost(test.hostname, dns.TypeA, "") if err != nil { t.Errorf("Error while matching host %s: %s", test.hostname, err) } @@ -455,6 +456,52 @@ func TestMatching(t *testing.T) { } } +// CLIENT SETTINGS + +func applyClientSettings(clientAddr string, setts *RequestFilteringSettings) { + setts.FilteringEnabled = false + setts.ParentalEnabled = false +} + +func TestClientSettings(t *testing.T) { + var r Result + filters := make(map[int]string) + filters[0] = "||example.org^\n" + d := NewForTestFilters(filters) + defer d.Destroy() + d.ParentalEnabled = true + d.ParentalSensitivity = 3 + + // no client settings: + + // blocked by filters + r, _ = d.CheckHost("example.org", dns.TypeA, "1.1.1.1") + if !r.IsFiltered || r.Reason != FilteredBlackList { + t.Fatalf("CheckHost FilteredBlackList") + } + + // blocked by parental + r, _ = d.CheckHost("pornhub.com", dns.TypeA, "1.1.1.1") + if !r.IsFiltered || r.Reason != FilteredParental { + t.Fatalf("CheckHost FilteredParental") + } + + // override client settings: + d.FilterHandler = applyClientSettings + + // override filtering settings + r, _ = d.CheckHost("example.org", dns.TypeA, "1.1.1.1") + if r.IsFiltered { + t.Fatalf("CheckHost") + } + + // override parental settings + r, _ = d.CheckHost("pornhub.com", dns.TypeA, "1.1.1.1") + if r.IsFiltered { + t.Fatalf("CheckHost") + } +} + // BENCHMARKS func BenchmarkSafeBrowsing(b *testing.B) { @@ -463,7 +510,7 @@ func BenchmarkSafeBrowsing(b *testing.B) { d.SafeBrowsingEnabled = true for n := 0; n < b.N; n++ { hostname := "wmconvirus.narod.ru" - ret, err := d.CheckHost(hostname, dns.TypeA) + ret, err := d.CheckHost(hostname, dns.TypeA, "") if err != nil { b.Errorf("Error while matching host %s: %s", hostname, err) } @@ -480,7 +527,7 @@ func BenchmarkSafeBrowsingParallel(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { hostname := "wmconvirus.narod.ru" - ret, err := d.CheckHost(hostname, dns.TypeA) + ret, err := d.CheckHost(hostname, dns.TypeA, "") if err != nil { b.Errorf("Error while matching host %s: %s", hostname, err) } diff --git a/dnsforward/dnsforward.go b/dnsforward/dnsforward.go index b728c90f..34e6285c 100644 --- a/dnsforward/dnsforward.go +++ b/dnsforward/dnsforward.go @@ -378,7 +378,11 @@ func (s *Server) filterDNSRequest(d *proxy.DNSContext) (*dnsfilter.Result, error var res dnsfilter.Result var err error - res, err = dnsFilter.CheckHost(host, d.Req.Question[0].Qtype) + clientAddr := "" + if d.Addr != nil { + clientAddr, _, _ = net.SplitHostPort(d.Addr.String()) + } + res, err = dnsFilter.CheckHost(host, d.Req.Question[0].Qtype, clientAddr) if err != nil { // Return immediately if there's an error return nil, errorx.Decorate(err, "dnsfilter failed to check host '%s'", host) From eb159e6997e064f54e684872d1d9cb4963e60eac Mon Sep 17 00:00:00 2001 From: Simon Zolin Date: Tue, 7 May 2019 10:45:18 +0300 Subject: [PATCH 07/19] + openapi.yaml: add /clients handlers --- openapi/openapi.yaml | 104 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 2f0f98db..5830be90 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2,7 +2,7 @@ swagger: '2.0' info: title: 'AdGuard Home' description: 'AdGuard Home REST API. Admin web interface is built on top of this REST API.' - version: 0.95.0 + version: 0.96.0 schemes: - http basePath: /control @@ -698,6 +698,54 @@ paths: schema: $ref: "#/definitions/Clients" + /clients/add: + post: + tags: + - clients + operationId: clientsAdd + summary: 'Add a new client' + parameters: + - in: "body" + name: "body" + required: true + schema: + $ref: "#/definitions/Client" + responses: + 200: + description: OK + + /clients/delete: + post: + tags: + - clients + operationId: clientsDelete + summary: 'Remove a client' + parameters: + - in: "body" + name: "body" + required: true + schema: + $ref: "#/definitions/ClientDelete" + responses: + 200: + description: OK + + /clients/update: + post: + tags: + - clients + operationId: clientsUpdate + summary: 'Update client information' + parameters: + - in: "body" + name: "body" + required: true + schema: + $ref: "#/definitions/ClientUpdate" + responses: + 200: + description: OK + # -------------------------------------------------- # I18N methods # -------------------------------------------------- @@ -1377,11 +1425,65 @@ definitions: type: "string" description: "Name" example: "localhost" + mac: + type: "string" + use_global_settings: + type: "boolean" + filtering_enabled: + type: "boolean" + parental_enabled: + type: "boolean" + safebrowsing_enabled: + type: "boolean" + safesearch_enabled: + type: "boolean" + ClientAuto: + type: "object" + description: "Auto-Client information" + properties: + ip: + type: "string" + description: "IP address" + example: "127.0.0.1" + name: + type: "string" + description: "Name" + example: "localhost" + source: + type: "string" + description: "The source of this information" + example: "etc/hosts" + ClientUpdate: + type: "object" + description: "Client update request" + properties: + name: + type: "string" + data: + $ref: "#/definitions/Client" + ClientDelete: + type: "object" + description: "Client delete request" + properties: + name: + type: "string" Clients: + type: "object" + properties: + clients: + $ref: "#/definitions/ClientsArray" + auto_clients: + $ref: "#/definitions/ClientsAutoArray" + ClientsArray: type: "array" items: $ref: "#/definitions/Client" description: "Clients array" + ClientsAutoArray: + type: "array" + items: + $ref: "#/definitions/ClientAuto" + description: "Auto-Clients array" CheckConfigRequest: type: "object" description: "Configuration to be checked" From 22c7efd2d1cc8fec15b667dbd676c8332717d5dd Mon Sep 17 00:00:00 2001 From: Simon Zolin Date: Tue, 7 May 2019 10:46:02 +0300 Subject: [PATCH 08/19] - openapi.yaml: fix HTTP methods --- openapi/openapi.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 5830be90..83856cbe 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -424,7 +424,7 @@ paths: description: OK /filtering/add_url: - put: + post: tags: - filtering operationId: filteringAddURL @@ -444,7 +444,7 @@ paths: description: OK /filtering/remove_url: - delete: + post: tags: - filtering operationId: filteringRemoveURL @@ -530,7 +530,7 @@ paths: description: OK with how many filters were actually updated /filtering/set_rules: - put: + post: tags: - filtering operationId: filteringSetRules From 22d3c38df280528fcc54bfbb1854a96a5ceb5b86 Mon Sep 17 00:00:00 2001 From: Ildar Kamalov Date: Wed, 22 May 2019 17:59:57 +0300 Subject: [PATCH 09/19] + client: handle per-client settings --- client/src/__locales/en.json | 24 +- client/src/actions/clients.js | 86 ++++++ client/src/actions/index.js | 50 ++-- client/src/api/Api.js | 38 +++ client/src/components/Logs/Logs.css | 4 + .../src/components/Settings/Clients/Form.js | 215 +++++++++++++++ .../src/components/Settings/Clients/Modal.js | 57 ++++ .../src/components/Settings/Clients/index.js | 252 ++++++++++++++++++ client/src/components/Settings/index.js | 50 +++- client/src/components/ui/Modal.css | 8 +- client/src/containers/Settings.js | 12 + client/src/helpers/constants.js | 14 +- client/src/helpers/form.js | 9 +- client/src/helpers/helpers.js | 18 ++ client/src/reducers/clients.js | 63 +++++ client/src/reducers/index.js | 3 + 16 files changed, 863 insertions(+), 40 deletions(-) create mode 100644 client/src/actions/clients.js create mode 100644 client/src/components/Settings/Clients/Form.js create mode 100644 client/src/components/Settings/Clients/Modal.js create mode 100644 client/src/components/Settings/Clients/index.js create mode 100644 client/src/reducers/clients.js diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index e0cf54e7..1235d10f 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -19,6 +19,7 @@ "dhcp_config_saved": "Saved DHCP server config", "form_error_required": "Required field", "form_error_ip_format": "Invalid IPv4 format", + "form_error_mac_format": "Invalid MAC format", "form_error_positive": "Must be greater than 0", "dhcp_form_gateway_input": "Gateway IP", "dhcp_form_subnet_input": "Subnet mask", @@ -105,6 +106,7 @@ "rules_count_table_header": "Rules count", "last_time_updated_table_header": "Last time updated", "actions_table_header": "Actions", + "edit_table_action": "Edit", "delete_table_action": "Delete", "filters_and_hosts": "Filters and hosts blocklists", "filters_and_hosts_hint": "AdGuard Home understands basic adblock rules and hosts files syntax.", @@ -263,5 +265,25 @@ "dns_providers": "Here is a <0>list of known DNS providers to choose from.", "update_now": "Update now", "update_failed": "Auto-update failed. Please follow the steps<\/a> to update manually.", - "processing_update": "Please wait, AdGuard Home is being updated" + "processing_update": "Please wait, AdGuard Home is being updated", + "clients_title": "Clients", + "clients_desc": "Configure devices connected to AdGuard Home", + "settings_global": "Global", + "settings_custom": "Custom", + "add_client": "Add Client", + "table_client": "Client", + "table_name": "Name", + "save_btn": "Save", + "client_new": "New Client", + "client_identifier": "Identifier", + "ip_address": "IP address", + "client_identifier_desc": "Clients can be identified by the IP address or MAC address. Please note, that using MAC as identifier is possible only if AdGuard Home is also a <0>DHCP server", + "form_enter_ip": "Enter IP", + "form_enter_mac": "Enter MAC", + "form_client_name": "Enter client name", + "client_global_settings": "Use global settings", + "client_deleted": "Client \"{{key}}\" successfully deleted", + "client_added": "Client \"{{key}}\" successfully added", + "client_updated": "Client \"{{key}}\" successfully updated", + "table_statistics": "Statistics (last 24 hours)" } \ No newline at end of file diff --git a/client/src/actions/clients.js b/client/src/actions/clients.js new file mode 100644 index 00000000..1947ad25 --- /dev/null +++ b/client/src/actions/clients.js @@ -0,0 +1,86 @@ +import { createAction } from 'redux-actions'; +import { t } from 'i18next'; +import Api from '../api/Api'; +import { addErrorToast, addSuccessToast, getClients } from './index'; +import { CLIENT_ID } from '../helpers/constants'; + +const apiClient = new Api(); + +export const toggleClientModal = createAction('TOGGLE_CLIENT_MODAL'); + +export const addClientRequest = createAction('ADD_CLIENT_REQUEST'); +export const addClientFailure = createAction('ADD_CLIENT_FAILURE'); +export const addClientSuccess = createAction('ADD_CLIENT_SUCCESS'); + +export const addClient = config => async (dispatch) => { + dispatch(addClientRequest()); + try { + let data; + if (config.identifier === CLIENT_ID.MAC) { + const { ip, identifier, ...values } = config; + + data = { ...values }; + } else { + const { mac, identifier, ...values } = config; + + data = { ...values }; + } + + await apiClient.addClient(data); + dispatch(addClientSuccess()); + dispatch(toggleClientModal()); + dispatch(addSuccessToast(t('client_added', { key: config.name }))); + dispatch(getClients()); + } catch (error) { + dispatch(toggleClientModal()); + dispatch(addErrorToast({ error })); + dispatch(addClientFailure()); + } +}; + +export const deleteClientRequest = createAction('DELETE_CLIENT_REQUEST'); +export const deleteClientFailure = createAction('DELETE_CLIENT_FAILURE'); +export const deleteClientSuccess = createAction('DELETE_CLIENT_SUCCESS'); + +export const deleteClient = config => async (dispatch) => { + dispatch(deleteClientRequest()); + try { + await apiClient.deleteClient(config); + dispatch(deleteClientSuccess()); + dispatch(addSuccessToast(t('client_deleted', { key: config.name }))); + dispatch(getClients()); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(deleteClientFailure()); + } +}; + +export const updateClientRequest = createAction('UPDATE_CLIENT_REQUEST'); +export const updateClientFailure = createAction('UPDATE_CLIENT_FAILURE'); +export const updateClientSuccess = createAction('UPDATE_CLIENT_SUCCESS'); + +export const updateClient = (config, name) => async (dispatch) => { + dispatch(updateClientRequest()); + try { + let data; + if (config.identifier === CLIENT_ID.MAC) { + const { ip, identifier, ...values } = config; + + data = { name, data: { ...values } }; + } else { + const { mac, identifier, ...values } = config; + + data = { name, data: { ...values } }; + } + + await apiClient.updateClient(data); + dispatch(updateClientSuccess()); + dispatch(toggleClientModal()); + dispatch(addSuccessToast(t('client_updated', { key: name }))); + dispatch(getClients()); + } catch (error) { + dispatch(toggleClientModal()); + dispatch(addErrorToast({ error })); + dispatch(updateClientFailure()); + } +}; diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 070c9324..493a4140 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -4,7 +4,7 @@ import { t } from 'i18next'; import { showLoading, hideLoading } from 'react-redux-loading-bar'; import axios from 'axios'; -import { normalizeHistory, normalizeFilteringStatus, normalizeLogs, normalizeTextarea } from '../helpers/helpers'; +import { normalizeHistory, normalizeFilteringStatus, normalizeLogs, normalizeTextarea, sortClients } from '../helpers/helpers'; import { SETTINGS_NAMES, CHECK_TIMEOUT } from '../helpers/constants'; import Api from '../api/Api'; @@ -213,14 +213,36 @@ export const getClientsSuccess = createAction('GET_CLIENTS_SUCCESS'); export const getClients = () => async (dispatch) => { dispatch(getClientsRequest()); try { - const clients = await apiClient.getGlobalClients(); - dispatch(getClientsSuccess(clients)); + const clients = await apiClient.getClients(); + const sortedClients = sortClients(clients); + dispatch(getClientsSuccess(sortedClients)); } catch (error) { dispatch(addErrorToast({ error })); dispatch(getClientsFailure()); } }; +export const getTopStatsRequest = createAction('GET_TOP_STATS_REQUEST'); +export const getTopStatsFailure = createAction('GET_TOP_STATS_FAILURE'); +export const getTopStatsSuccess = createAction('GET_TOP_STATS_SUCCESS'); + +export const getTopStats = () => async (dispatch, getState) => { + dispatch(getTopStatsRequest()); + const timer = setInterval(async () => { + const state = getState(); + if (state.dashboard.isCoreRunning) { + clearInterval(timer); + try { + const stats = await apiClient.getGlobalStatsTop(); + dispatch(getTopStatsSuccess(stats)); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(getTopStatsFailure(error)); + } + } + }, 100); +}; + export const dnsStatusRequest = createAction('DNS_STATUS_REQUEST'); export const dnsStatusFailure = createAction('DNS_STATUS_FAILURE'); export const dnsStatusSuccess = createAction('DNS_STATUS_SUCCESS'); @@ -232,6 +254,7 @@ export const getDnsStatus = () => async (dispatch) => { dispatch(dnsStatusSuccess(dnsStatus)); dispatch(getVersion()); dispatch(getClients()); + dispatch(getTopStats()); } catch (error) { dispatch(addErrorToast({ error })); dispatch(initSettingsFailure()); @@ -289,27 +312,6 @@ export const getStats = () => async (dispatch) => { } }; -export const getTopStatsRequest = createAction('GET_TOP_STATS_REQUEST'); -export const getTopStatsFailure = createAction('GET_TOP_STATS_FAILURE'); -export const getTopStatsSuccess = createAction('GET_TOP_STATS_SUCCESS'); - -export const getTopStats = () => async (dispatch, getState) => { - dispatch(getTopStatsRequest()); - const timer = setInterval(async () => { - const state = getState(); - if (state.dashboard.isCoreRunning) { - clearInterval(timer); - try { - const stats = await apiClient.getGlobalStatsTop(); - dispatch(getTopStatsSuccess(stats)); - } catch (error) { - dispatch(addErrorToast({ error })); - dispatch(getTopStatsFailure(error)); - } - } - }, 100); -}; - export const getLogsRequest = createAction('GET_LOGS_REQUEST'); export const getLogsFailure = createAction('GET_LOGS_FAILURE'); export const getLogsSuccess = createAction('GET_LOGS_SUCCESS'); diff --git a/client/src/api/Api.js b/client/src/api/Api.js index 1743cc06..79abd2fb 100644 --- a/client/src/api/Api.js +++ b/client/src/api/Api.js @@ -409,4 +409,42 @@ export default class Api { }; return this.makeRequest(path, method, parameters); } + + // Per-client settings + GET_CLIENTS = { path: 'clients', method: 'GET' } + ADD_CLIENT = { path: 'clients/add', method: 'POST' } + DELETE_CLIENT = { path: 'clients/delete', method: 'POST' } + UPDATE_CLIENT = { path: 'clients/update', method: 'POST' } + + getClients() { + const { path, method } = this.GET_CLIENTS; + return this.makeRequest(path, method); + } + + addClient(config) { + const { path, method } = this.ADD_CLIENT; + const parameters = { + data: config, + headers: { 'Content-Type': 'application/json' }, + }; + return this.makeRequest(path, method, parameters); + } + + deleteClient(config) { + const { path, method } = this.DELETE_CLIENT; + const parameters = { + data: config, + headers: { 'Content-Type': 'application/json' }, + }; + return this.makeRequest(path, method, parameters); + } + + updateClient(config) { + const { path, method } = this.UPDATE_CLIENT; + const parameters = { + data: config, + headers: { 'Content-Type': 'application/json' }, + }; + return this.makeRequest(path, method, parameters); + } } diff --git a/client/src/components/Logs/Logs.css b/client/src/components/Logs/Logs.css index d7df63bb..1b39f592 100644 --- a/client/src/components/Logs/Logs.css +++ b/client/src/components/Logs/Logs.css @@ -5,6 +5,10 @@ min-height: 26px; } +.logs__row--center { + justify-content: center; +} + .logs__row--overflow { overflow: hidden; } diff --git a/client/src/components/Settings/Clients/Form.js b/client/src/components/Settings/Clients/Form.js new file mode 100644 index 00000000..1874a746 --- /dev/null +++ b/client/src/components/Settings/Clients/Form.js @@ -0,0 +1,215 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { Field, reduxForm, formValueSelector } from 'redux-form'; +import { Trans, withNamespaces } from 'react-i18next'; +import flow from 'lodash/flow'; + +import { renderField, renderSelectField, ipv4, mac, required } from '../../../helpers/form'; +import { CLIENT_ID } from '../../../helpers/constants'; + +let Form = (props) => { + const { + t, + handleSubmit, + reset, + pristine, + submitting, + clientIdentifier, + useGlobalSettings, + toggleClientModal, + processingAdding, + processingUpdating, + } = props; + + return ( +
+ + +
+
+ + +
+
+
+ ); +}; + +Form.propTypes = { + pristine: PropTypes.bool.isRequired, + handleSubmit: PropTypes.func.isRequired, + reset: PropTypes.func.isRequired, + submitting: PropTypes.bool.isRequired, + toggleClientModal: PropTypes.func.isRequired, + clientIdentifier: PropTypes.string, + useGlobalSettings: PropTypes.bool, + t: PropTypes.func.isRequired, + processingAdding: PropTypes.bool.isRequired, + processingUpdating: PropTypes.bool.isRequired, +}; + +const selector = formValueSelector('clientForm'); + +Form = connect((state) => { + const clientIdentifier = selector(state, 'identifier'); + const useGlobalSettings = selector(state, 'use_global_settings'); + return { + clientIdentifier, + useGlobalSettings, + }; +})(Form); + +export default flow([ + withNamespaces(), + reduxForm({ + form: 'clientForm', + enableReinitialize: true, + }), +])(Form); diff --git a/client/src/components/Settings/Clients/Modal.js b/client/src/components/Settings/Clients/Modal.js new file mode 100644 index 00000000..2c7cf7ce --- /dev/null +++ b/client/src/components/Settings/Clients/Modal.js @@ -0,0 +1,57 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Trans, withNamespaces } from 'react-i18next'; +import ReactModal from 'react-modal'; + +import Form from './Form'; + +const Modal = (props) => { + const { + isModalOpen, + currentClientData, + handleSubmit, + toggleClientModal, + processingAdding, + processingUpdating, + } = props; + + return ( + toggleClientModal()} + > +
+
+

+ client_new +

+ +
+
+
+
+ ); +}; + +Modal.propTypes = { + isModalOpen: PropTypes.bool.isRequired, + currentClientData: PropTypes.object.isRequired, + handleSubmit: PropTypes.func.isRequired, + toggleClientModal: PropTypes.func.isRequired, + processingAdding: PropTypes.bool.isRequired, + processingUpdating: PropTypes.bool.isRequired, +}; + +export default withNamespaces()(Modal); diff --git a/client/src/components/Settings/Clients/index.js b/client/src/components/Settings/Clients/index.js new file mode 100644 index 00000000..a8ed09ab --- /dev/null +++ b/client/src/components/Settings/Clients/index.js @@ -0,0 +1,252 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Trans, withNamespaces } from 'react-i18next'; +import ReactTable from 'react-table'; + +import { MODAL_TYPE, CLIENT_ID } from '../../../helpers/constants'; +import Card from '../../ui/Card'; +import Modal from './Modal'; + +class Clients extends Component { + handleFormAdd = (values) => { + this.props.addClient(values); + }; + + handleFormUpdate = (values, name) => { + this.props.updateClient(values, name); + }; + + handleSubmit = (values) => { + if (this.props.modalType === MODAL_TYPE.EDIT) { + this.handleFormUpdate(values, this.props.modalClientName); + } else { + this.handleFormAdd(values); + } + }; + + cellWrap = ({ value }) => ( +
+ + {value} + +
+ ); + + getClient = (name, clients) => { + const client = clients.find(item => name === item.name); + + if (client) { + const identifier = client.mac ? CLIENT_ID.MAC : CLIENT_ID.IP; + + return { + identifier, + use_global_settings: true, + ...client, + }; + } + + return { + identifier: 'ip', + use_global_settings: true, + }; + }; + + getStats = (ip, stats) => { + if (stats && stats.top_clients) { + return stats.top_clients[ip]; + } + + return ''; + }; + + columns = [ + { + Header: this.props.t('table_client'), + accessor: 'ip', + Cell: (row) => { + if (row.value) { + return ( +
+ + {row.value} (IP) + +
+ ); + } else if (row.original && row.original.mac) { + return ( +
+ + {row.original.mac} (MAC) + +
+ ); + } + + return ''; + }, + }, + { + Header: this.props.t('table_name'), + accessor: 'name', + Cell: this.cellWrap, + }, + { + Header: this.props.t('settings'), + accessor: 'use_global_settings', + maxWidth: 180, + minWidth: 150, + Cell: ({ value }) => { + const title = value ? ( + settings_global + ) : ( + settings_custom + ); + + return ( +
+
+ {title} +
+
+ ); + }, + }, + { + Header: this.props.t('table_statistics'), + accessor: 'statistics', + Cell: (row) => { + const clientIP = row.original.ip; + const clientStats = clientIP && this.getStats(clientIP, this.props.topStats); + + if (clientStats) { + return ( +
+
+ {clientStats} +
+
+ ); + } + + return '–'; + }, + }, + { + Header: this.props.t('actions_table_header'), + accessor: 'actions', + maxWidth: 220, + minWidth: 150, + Cell: (row) => { + const clientName = row.original.name; + const { + toggleClientModal, + deleteClient, + processingDeleting, + processingUpdating, + } = this.props; + + return ( +
+ + +
+ ); + }, + }, + ]; + + render() { + const { + t, + clients, + isModalOpen, + modalClientName, + toggleClientModal, + processingAdding, + processingUpdating, + } = this.props; + + const currentClientData = this.getClient(modalClientName, clients); + + return ( + + + + + + + + + ); + } +} + +Clients.propTypes = { + t: PropTypes.func.isRequired, + clients: PropTypes.array.isRequired, + topStats: PropTypes.object.isRequired, + toggleClientModal: PropTypes.func.isRequired, + deleteClient: PropTypes.func.isRequired, + addClient: PropTypes.func.isRequired, + updateClient: PropTypes.func.isRequired, + isModalOpen: PropTypes.bool.isRequired, + modalType: PropTypes.string.isRequired, + modalClientName: PropTypes.string.isRequired, + processingAdding: PropTypes.bool.isRequired, + processingDeleting: PropTypes.bool.isRequired, + processingUpdating: PropTypes.bool.isRequired, +}; + +export default withNamespaces()(Clients); diff --git a/client/src/components/Settings/index.js b/client/src/components/Settings/index.js index e6c39cf7..9292efb3 100644 --- a/client/src/components/Settings/index.js +++ b/client/src/components/Settings/index.js @@ -4,6 +4,7 @@ import { withNamespaces, Trans } from 'react-i18next'; import Upstream from './Upstream'; import Dhcp from './Dhcp'; import Encryption from './Encryption'; +import Clients from './Clients'; import Checkbox from '../ui/Checkbox'; import Loading from '../ui/Loading'; import PageTitle from '../ui/PageTitle'; @@ -46,29 +47,38 @@ class Settings extends Component { return Object.keys(settings).map((key) => { const setting = settings[key]; const { enabled } = setting; - return ( this.props.toggleSetting(key, enabled)} - />); + return ( + this.props.toggleSetting(key, enabled)} + /> + ); }); } return ( -
no_settings
+
+ no_settings +
); - } + }; render() { - const { settings, dashboard, t } = this.props; + const { + settings, dashboard, clients, t, + } = this.props; return ( - + {settings.processing && } - {!settings.processing && + {!settings.processing && (
- +
{this.renderSettings(settings.settingsList)}
@@ -82,6 +92,22 @@ class Settings extends Component { processingTestUpstream={settings.processingTestUpstream} processingSetUpstream={settings.processingSetUpstream} /> + {!dashboard.processingTopStats && !dashboard.processingClients && ( + + )}
- } + )} ); } diff --git a/client/src/components/ui/Modal.css b/client/src/components/ui/Modal.css index 4a000cb1..43f35f64 100644 --- a/client/src/components/ui/Modal.css +++ b/client/src/components/ui/Modal.css @@ -5,7 +5,7 @@ overflow-x: hidden; overflow-y: auto; background-color: rgba(0, 0, 0, 0.5); - z-index: 1; + z-index: 105; } .ReactModal__Overlay--after-open { @@ -38,3 +38,9 @@ border: none; background-color: transparent; } + +@media (min-width: 576px) { + .modal-dialog--clients { + max-width: 650px; + } +} diff --git a/client/src/containers/Settings.js b/client/src/containers/Settings.js index d593761a..95be768b 100644 --- a/client/src/containers/Settings.js +++ b/client/src/containers/Settings.js @@ -17,6 +17,12 @@ import { setTlsConfig, validateTlsConfig, } from '../actions/encryption'; +import { + addClient, + updateClient, + deleteClient, + toggleClientModal, +} from '../actions/clients'; import Settings from '../components/Settings'; const mapStateToProps = (state) => { @@ -25,12 +31,14 @@ const mapStateToProps = (state) => { dashboard, dhcp, encryption, + clients, } = state; const props = { settings, dashboard, dhcp, encryption, + clients, }; return props; }; @@ -50,6 +58,10 @@ const mapDispatchToProps = { getTlsStatus, setTlsConfig, validateTlsConfig, + addClient, + updateClient, + deleteClient, + toggleClientModal, }; export default connect( diff --git a/client/src/helpers/constants.js b/client/src/helpers/constants.js index 503ca2ed..d5f94f8c 100644 --- a/client/src/helpers/constants.js +++ b/client/src/helpers/constants.js @@ -1,5 +1,6 @@ export const R_URL_REQUIRES_PROTOCOL = /^https?:\/\/\w[\w_\-.]*\.[a-z]{2,8}[^\s]*$/; export const R_IPV4 = /^(?:(?:^|\.)(?:2(?:5[0-5]|[0-4]\d)|1?\d?\d)){4}$/g; +export const R_MAC = /^((([a-fA-F0-9][a-fA-F0-9]+[-]){5}|([a-fA-F0-9][a-fA-F0-9]+[:]){5})([a-fA-F0-9][a-fA-F0-9])$)|(^([a-fA-F0-9][a-fA-F0-9][a-fA-F0-9][a-fA-F0-9]+[.]){2}([a-fA-F0-9][a-fA-F0-9][a-fA-F0-9][a-fA-F0-9]))$/g; export const STATS_NAMES = { avg_processing_time: 'average_processing_time', @@ -19,7 +20,8 @@ export const STATUS_COLORS = { export const REPOSITORY = { URL: 'https://github.com/AdguardTeam/AdGuardHome', - TRACKERS_DB: 'https://github.com/AdguardTeam/AdGuardHome/tree/master/client/src/helpers/trackers/adguard.json', + TRACKERS_DB: + 'https://github.com/AdguardTeam/AdGuardHome/tree/master/client/src/helpers/trackers/adguard.json', }; export const PRIVACY_POLICY_LINK = 'https://adguard.com/privacy/home.html'; @@ -165,3 +167,13 @@ export const DHCP_STATUS_RESPONSE = { NO: 'no', ERROR: 'error', }; + +export const MODAL_TYPE = { + ADD: 'add', + EDIT: 'edit', +}; + +export const CLIENT_ID = { + MAC: 'mac', + IP: 'ip', +}; diff --git a/client/src/helpers/form.js b/client/src/helpers/form.js index c9703033..72397396 100644 --- a/client/src/helpers/form.js +++ b/client/src/helpers/form.js @@ -1,7 +1,7 @@ import React, { Fragment } from 'react'; import { Trans } from 'react-i18next'; -import { R_IPV4, UNSAFE_PORTS } from '../helpers/constants'; +import { R_IPV4, R_MAC, UNSAFE_PORTS } from '../helpers/constants'; export const renderField = ({ input, id, className, placeholder, type, disabled, meta: { touched, error }, @@ -55,6 +55,13 @@ export const ipv4 = (value) => { return false; }; +export const mac = (value) => { + if (value && !new RegExp(R_MAC).test(value)) { + return form_error_mac_format; + } + return false; +}; + export const isPositive = (value) => { if ((value || value === 0) && (value <= 0)) { return form_error_positive; diff --git a/client/src/helpers/helpers.js b/client/src/helpers/helpers.js index d3128f5d..773b54f9 100644 --- a/client/src/helpers/helpers.js +++ b/client/src/helpers/helpers.js @@ -208,3 +208,21 @@ export const getClientName = (clients, ip) => { const client = clients.find(item => ip === item.ip); return (client && client.name) || ''; }; + +export const sortClients = (clients) => { + const compare = (a, b) => { + const nameA = a.name.toUpperCase(); + const nameB = b.name.toUpperCase(); + let comparison = 0; + + if (nameA > nameB) { + comparison = 1; + } else if (nameA < nameB) { + comparison = -1; + } + + return comparison; + }; + + return clients.sort(compare); +}; diff --git a/client/src/reducers/clients.js b/client/src/reducers/clients.js new file mode 100644 index 00000000..0946e437 --- /dev/null +++ b/client/src/reducers/clients.js @@ -0,0 +1,63 @@ +import { handleActions } from 'redux-actions'; + +import * as actions from '../actions/clients'; + +const clients = handleActions({ + [actions.addClientRequest]: state => ({ ...state, processingAdding: true }), + [actions.addClientFailure]: state => ({ ...state, processingAdding: false }), + [actions.addClientSuccess]: (state) => { + const newState = { + ...state, + processingAdding: false, + }; + return newState; + }, + + [actions.deleteClientRequest]: state => ({ ...state, processingDeleting: true }), + [actions.deleteClientFailure]: state => ({ ...state, processingDeleting: false }), + [actions.deleteClientSuccess]: (state) => { + const newState = { + ...state, + processingDeleting: false, + }; + return newState; + }, + + [actions.updateClientRequest]: state => ({ ...state, processingUpdating: true }), + [actions.updateClientFailure]: state => ({ ...state, processingUpdating: false }), + [actions.updateClientSuccess]: (state) => { + const newState = { + ...state, + processingUpdating: false, + }; + return newState; + }, + + [actions.toggleClientModal]: (state, { payload }) => { + if (payload) { + const newState = { + ...state, + modalType: payload.type || '', + modalClientName: payload.name || '', + isModalOpen: !state.isModalOpen, + }; + return newState; + } + + const newState = { + ...state, + isModalOpen: !state.isModalOpen, + }; + return newState; + }, +}, { + processing: true, + processingAdding: false, + processingDeleting: false, + processingUpdating: false, + isModalOpen: false, + modalClientName: '', + modalType: '', +}); + +export default clients; diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index 09c93a99..206a0de4 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -7,6 +7,7 @@ import versionCompare from '../helpers/versionCompare'; import * as actions from '../actions'; import toasts from './toasts'; import encryption from './encryption'; +import clients from './clients'; const settings = handleActions({ [actions.initSettingsRequest]: state => ({ ...state, processing: true }), @@ -209,6 +210,7 @@ const dashboard = handleActions({ dnsAddresses: [], dnsVersion: '', clients: [], + topStats: [], }); const queryLogs = handleActions({ @@ -361,6 +363,7 @@ export default combineReducers({ toasts, dhcp, encryption, + clients, loadingBar: loadingBarReducer, form: formReducer, }); From 68a4cc597f4da5bcd507c182e936683e426b0709 Mon Sep 17 00:00:00 2001 From: Ildar Kamalov Date: Wed, 22 May 2019 18:28:16 +0300 Subject: [PATCH 10/19] * client: fix sort helper --- client/src/helpers/helpers.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/src/helpers/helpers.js b/client/src/helpers/helpers.js index 773b54f9..031747f3 100644 --- a/client/src/helpers/helpers.js +++ b/client/src/helpers/helpers.js @@ -213,15 +213,14 @@ export const sortClients = (clients) => { const compare = (a, b) => { const nameA = a.name.toUpperCase(); const nameB = b.name.toUpperCase(); - let comparison = 0; if (nameA > nameB) { - comparison = 1; + return 1; } else if (nameA < nameB) { - comparison = -1; + return -1; } - return comparison; + return 0; }; return clients.sort(compare); From bb34381a0d73a85b798122e940ae5c9fc1a255ff Mon Sep 17 00:00:00 2001 From: Ildar Kamalov Date: Wed, 22 May 2019 18:50:06 +0300 Subject: [PATCH 11/19] * client: fix no data text --- client/src/__locales/en.json | 3 ++- client/src/components/Settings/Clients/index.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index 1235d10f..64d0b813 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -285,5 +285,6 @@ "client_deleted": "Client \"{{key}}\" successfully deleted", "client_added": "Client \"{{key}}\" successfully added", "client_updated": "Client \"{{key}}\" successfully updated", - "table_statistics": "Statistics (last 24 hours)" + "table_statistics": "Statistics (last 24 hours)", + "clients_not_found": "No clients found" } \ No newline at end of file diff --git a/client/src/components/Settings/Clients/index.js b/client/src/components/Settings/Clients/index.js index a8ed09ab..c4d95f10 100644 --- a/client/src/components/Settings/Clients/index.js +++ b/client/src/components/Settings/Clients/index.js @@ -197,7 +197,6 @@ class Clients extends Component {