From 8104c902eed397d2b34f43be485d49e444ca8cc7 Mon Sep 17 00:00:00 2001 From: Simon Zolin Date: Mon, 26 Aug 2019 11:54:38 +0300 Subject: [PATCH 1/6] * querylog: move code to a separate package + config: "querylog_interval" setting /control/querylog_config, /control/querylog_info + POST /control/querylog_clear --- AGHTechDoc.md | 37 +++++ dnsforward/dnsforward.go | 29 ++-- dnsforward/dnsforward_test.go | 66 +-------- home/config.go | 7 + home/control.go | 33 +---- home/control_querylog.go | 91 ++++++++++++ home/dns.go | 9 +- dnsforward/querylog.go => querylog/qlog.go | 105 ++++++++++---- querylog/querylog.go | 33 +++++ {dnsforward => querylog}/querylog_file.go | 154 ++++++++++++++++++++- querylog/querylog_test.go | 43 ++++++ 11 files changed, 457 insertions(+), 150 deletions(-) create mode 100644 home/control_querylog.go rename dnsforward/querylog.go => querylog/qlog.go (72%) create mode 100644 querylog/querylog.go rename {dnsforward => querylog}/querylog_file.go (56%) create mode 100644 querylog/querylog_test.go diff --git a/AGHTechDoc.md b/AGHTechDoc.md index 4b2833d7..e2c88d12 100644 --- a/AGHTechDoc.md +++ b/AGHTechDoc.md @@ -42,6 +42,9 @@ Contents: * API: Clear statistics data * API: Set statistics parameters * API: Get statistics parameters +* Query logs + * API: Set querylog parameters + * API: Get querylog parameters ## First startup @@ -976,3 +979,37 @@ Response: { "interval": 1 | 7 | 30 | 90 } + + +## Query logs + +### API: Set querylog parameters + +Request: + + POST /control/querylog_config + + { + "enabled": true | false + "interval": 1 | 7 | 30 | 90 + } + +Response: + + 200 OK + + +### API: Get querylog parameters + +Request: + + GET /control/querylog_info + +Response: + + 200 OK + + { + "enabled": true | false + "interval": 1 | 7 | 30 | 90 + } diff --git a/dnsforward/dnsforward.go b/dnsforward/dnsforward.go index 0fffd0a5..ee1c1e0d 100644 --- a/dnsforward/dnsforward.go +++ b/dnsforward/dnsforward.go @@ -11,6 +11,7 @@ import ( "time" "github.com/AdguardTeam/AdGuardHome/dnsfilter" + "github.com/AdguardTeam/AdGuardHome/querylog" "github.com/AdguardTeam/AdGuardHome/stats" "github.com/AdguardTeam/dnsproxy/proxy" "github.com/AdguardTeam/dnsproxy/upstream" @@ -40,7 +41,7 @@ const ( type Server struct { dnsProxy *proxy.Proxy // DNS proxy instance dnsFilter *dnsfilter.Dnsfilter // DNS filter instance - queryLog *queryLog // Query log instance + queryLog querylog.QueryLog // Query log instance stats stats.Stats AllowedClients map[string]bool // IP addresses of whitelist clients @@ -54,16 +55,11 @@ type Server struct { } // NewServer creates a new instance of the dnsforward.Server -// baseDir is the base directory for query logs // Note: this function must be called only once -func NewServer(baseDir string, stats stats.Stats) *Server { - s := &Server{ - queryLog: newQueryLog(baseDir), - } +func NewServer(stats stats.Stats, queryLog querylog.QueryLog) *Server { + s := &Server{} s.stats = stats - - log.Printf("Start DNS server periodic jobs") - go s.queryLog.periodicQueryLogRotate() + s.queryLog = queryLog return s } @@ -75,6 +71,7 @@ type FilteringConfig struct { BlockingMode string `yaml:"blocking_mode"` // mode how to answer filtered requests BlockedResponseTTL uint32 `yaml:"blocked_response_ttl"` // if 0, then default is used (3600) QueryLogEnabled bool `yaml:"querylog_enabled"` // if true, query log is enabled + QueryLogInterval uint32 `yaml:"querylog_interval"` // time interval for query log (in days) Ratelimit int `yaml:"ratelimit"` // max number of requests per second from a given IP (0 to disable) RatelimitWhitelist []string `yaml:"ratelimit_whitelist"` // a list of whitelisted client IP addresses RefuseAny bool `yaml:"refuse_any"` // if true, refuse ANY requests @@ -303,8 +300,7 @@ func (s *Server) stopInternal() error { s.dnsFilter = nil } - // flush remainder to file - return s.queryLog.flushLogBuffer(true) + return nil } // IsRunning returns true if the DNS server is running @@ -343,13 +339,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.RUnlock() } -// GetQueryLog returns a map with the current query log ready to be converted to a JSON -func (s *Server) GetQueryLog() []map[string]interface{} { - s.RLock() - defer s.RUnlock() - return s.queryLog.getQueryLog() -} - // Return TRUE if this client should be blocked func (s *Server) isBlockedIP(ip string) bool { if len(s.AllowedClients) != 0 || len(s.AllowedClientsIPNet) != 0 { @@ -469,12 +458,12 @@ func (s *Server) handleDNSRequest(p *proxy.Proxy, d *proxy.DNSContext) error { } elapsed := time.Since(start) - if s.conf.QueryLogEnabled && shouldLog { + if s.conf.QueryLogEnabled && shouldLog && s.queryLog != nil { upstreamAddr := "" if d.Upstream != nil { upstreamAddr = d.Upstream.Address() } - _ = s.queryLog.logRequest(msg, d.Res, res, elapsed, d.Addr, upstreamAddr) + s.queryLog.Add(msg, d.Res, res, elapsed, d.Addr, upstreamAddr) } s.updateStats(d, elapsed, *res) diff --git a/dnsforward/dnsforward_test.go b/dnsforward/dnsforward_test.go index 740a43b8..92a1e01b 100644 --- a/dnsforward/dnsforward_test.go +++ b/dnsforward/dnsforward_test.go @@ -10,7 +10,6 @@ import ( "encoding/pem" "math/big" "net" - "os" "sync" "testing" "time" @@ -18,18 +17,15 @@ import ( "github.com/AdguardTeam/AdGuardHome/dnsfilter" "github.com/AdguardTeam/dnsproxy/proxy" "github.com/miekg/dns" - "github.com/stretchr/testify/assert" ) const ( tlsServerName = "testdns.adguard.com" - dataDir = "testData" testMessagesCount = 10 ) func TestServer(t *testing.T) { s := createTestServer(t) - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -45,10 +41,6 @@ func TestServer(t *testing.T) { } assertGoogleAResponse(t, reply) - // check query log and stats - log := s.GetQueryLog() - assert.Equal(t, 1, len(log), "Log size") - // message over TCP req = createGoogleATestMessage() addr = s.dnsProxy.Addr("tcp") @@ -59,10 +51,6 @@ func TestServer(t *testing.T) { } assertGoogleAResponse(t, reply) - // check query log and stats again - log = s.GetQueryLog() - assert.Equal(t, 2, len(log), "Log size") - err = s.Stop() if err != nil { t.Fatalf("DNS server failed to stop: %s", err) @@ -72,7 +60,6 @@ func TestServer(t *testing.T) { func TestServerWithProtectionDisabled(t *testing.T) { s := createTestServer(t) s.conf.ProtectionEnabled = false - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -88,10 +75,6 @@ func TestServerWithProtectionDisabled(t *testing.T) { } assertGoogleAResponse(t, reply) - // check query log and stats - log := s.GetQueryLog() - assert.Equal(t, 1, len(log), "Log size") - err = s.Stop() if err != nil { t.Fatalf("DNS server failed to stop: %s", err) @@ -102,7 +85,6 @@ func TestDotServer(t *testing.T) { // Prepare the proxy server _, certPem, keyPem := createServerTLSConfig(t) s := createTestServer(t) - defer removeDataDir(t) s.conf.TLSConfig = TLSConfig{ TLSListenAddr: &net.TCPAddr{Port: 0}, @@ -143,7 +125,6 @@ func TestDotServer(t *testing.T) { func TestServerRace(t *testing.T) { s := createTestServer(t) - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -168,7 +149,6 @@ func TestServerRace(t *testing.T) { func TestSafeSearch(t *testing.T) { s := createTestServer(t) s.conf.SafeSearchEnabled = true - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -210,7 +190,6 @@ func TestSafeSearch(t *testing.T) { func TestInvalidRequest(t *testing.T) { s := createTestServer(t) - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -229,11 +208,6 @@ func TestInvalidRequest(t *testing.T) { t.Fatalf("got a response to an invalid query") } - // check query log and stats - // invalid requests aren't written to the query log - log := s.GetQueryLog() - assert.Equal(t, 0, len(log), "Log size") - err = s.Stop() if err != nil { t.Fatalf("DNS server failed to stop: %s", err) @@ -242,7 +216,6 @@ func TestInvalidRequest(t *testing.T) { func TestBlockedRequest(t *testing.T) { s := createTestServer(t) - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -267,10 +240,6 @@ func TestBlockedRequest(t *testing.T) { t.Fatalf("Wrong response: %s", reply.String()) } - // check query log and stats - log := s.GetQueryLog() - assert.Equal(t, 1, len(log), "Log size") - err = s.Stop() if err != nil { t.Fatalf("DNS server failed to stop: %s", err) @@ -280,7 +249,6 @@ func TestBlockedRequest(t *testing.T) { func TestNullBlockedRequest(t *testing.T) { s := createTestServer(t) s.conf.FilteringConfig.BlockingMode = "null_ip" - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -312,10 +280,6 @@ func TestNullBlockedRequest(t *testing.T) { t.Fatalf("DNS server %s returned wrong answer type instead of A: %v", addr, reply.Answer[0]) } - // check query log and stats - log := s.GetQueryLog() - assert.Equal(t, 1, len(log), "Log size") - err = s.Stop() if err != nil { t.Fatalf("DNS server failed to stop: %s", err) @@ -324,7 +288,6 @@ func TestNullBlockedRequest(t *testing.T) { func TestBlockedByHosts(t *testing.T) { s := createTestServer(t) - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -356,10 +319,6 @@ func TestBlockedByHosts(t *testing.T) { t.Fatalf("DNS server %s returned wrong answer type instead of A: %v", addr, reply.Answer[0]) } - // check query log and stats - log := s.GetQueryLog() - assert.Equal(t, 1, len(log), "Log size") - err = s.Stop() if err != nil { t.Fatalf("DNS server failed to stop: %s", err) @@ -368,7 +327,6 @@ func TestBlockedByHosts(t *testing.T) { func TestBlockedBySafeBrowsing(t *testing.T) { s := createTestServer(t) - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -411,10 +369,6 @@ func TestBlockedBySafeBrowsing(t *testing.T) { t.Fatalf("DNS server %s returned wrong answer type instead of A: %v", addr, reply.Answer[0]) } - // check query log and stats - log := s.GetQueryLog() - assert.Equal(t, 1, len(log), "Log size") - err = s.Stop() if err != nil { t.Fatalf("DNS server failed to stop: %s", err) @@ -422,7 +376,7 @@ func TestBlockedBySafeBrowsing(t *testing.T) { } func createTestServer(t *testing.T) *Server { - s := NewServer(createDataDir(t), nil) + s := NewServer(nil, nil) s.conf.UDPListenAddr = &net.UDPAddr{Port: 0} s.conf.TCPListenAddr = &net.TCPAddr{Port: 0} @@ -489,21 +443,6 @@ func createServerTLSConfig(t *testing.T) (*tls.Config, []byte, []byte) { return &tls.Config{Certificates: []tls.Certificate{cert}, ServerName: tlsServerName, MinVersion: tls.VersionTLS12}, certPem, keyPem } -func createDataDir(t *testing.T) string { - err := os.MkdirAll(dataDir, 0755) - if err != nil { - t.Fatalf("Cannot create %s: %s", dataDir, err) - } - return dataDir -} - -func removeDataDir(t *testing.T) { - err := os.RemoveAll(dataDir) - if err != nil { - t.Fatalf("Cannot remove %s: %s", dataDir, err) - } -} - func sendTestMessageAsync(t *testing.T, conn *dns.Conn, g *sync.WaitGroup) { defer func() { g.Done() @@ -607,7 +546,6 @@ func TestIsBlockedIPAllowed(t *testing.T) { s.conf.AllowedClients = []string{"1.1.1.1", "2.2.0.0/16"} err := s.Start(nil) - defer removeDataDir(t) if err != nil { t.Fatalf("Failed to start server: %s", err) } @@ -631,7 +569,6 @@ func TestIsBlockedIPDisallowed(t *testing.T) { s.conf.DisallowedClients = []string{"1.1.1.1", "2.2.0.0/16"} err := s.Start(nil) - defer removeDataDir(t) if err != nil { t.Fatalf("Failed to start server: %s", err) } @@ -655,7 +592,6 @@ func TestIsBlockedIPBlockedDomain(t *testing.T) { s.conf.BlockedHosts = []string{"host1", "host2"} err := s.Start(nil) - defer removeDataDir(t) if err != nil { t.Fatalf("Failed to start server: %s", err) } diff --git a/home/config.go b/home/config.go index 4edc168f..9323b1ce 100644 --- a/home/config.go +++ b/home/config.go @@ -12,6 +12,7 @@ import ( "github.com/AdguardTeam/AdGuardHome/dhcpd" "github.com/AdguardTeam/AdGuardHome/dnsfilter" "github.com/AdguardTeam/AdGuardHome/dnsforward" + "github.com/AdguardTeam/AdGuardHome/querylog" "github.com/AdguardTeam/AdGuardHome/stats" "github.com/AdguardTeam/golibs/file" "github.com/AdguardTeam/golibs/log" @@ -70,6 +71,7 @@ type configuration struct { transport *http.Transport client *http.Client stats stats.Stats + queryLog querylog.QueryLog // cached version.json to avoid hammering github.io for each page reload versionCheckJSON []byte @@ -175,6 +177,7 @@ var config = configuration{ BlockingMode: "nxdomain", // mode how to answer filtered requests BlockedResponseTTL: 10, // in seconds QueryLogEnabled: true, + QueryLogInterval: 1, Ratelimit: 20, RefuseAny: true, BootstrapDNS: defaultBootstrap, @@ -274,6 +277,10 @@ func parseConfig() error { config.DNS.StatsInterval = 1 } + if !checkQueryLogInterval(config.DNS.QueryLogInterval) { + config.DNS.QueryLogInterval = 1 + } + for _, cy := range config.Clients { cli := Client{ Name: cy.Name, diff --git a/home/control.go b/home/control.go index 5ac71d6d..27187626 100644 --- a/home/control.go +++ b/home/control.go @@ -146,35 +146,6 @@ func handleProtectionDisable(w http.ResponseWriter, r *http.Request) { httpUpdateConfigReloadDNSReturnOK(w, r) } -// ----- -// stats -// ----- -func handleQueryLogEnable(w http.ResponseWriter, r *http.Request) { - config.DNS.QueryLogEnabled = true - httpUpdateConfigReloadDNSReturnOK(w, r) -} - -func handleQueryLogDisable(w http.ResponseWriter, r *http.Request) { - config.DNS.QueryLogEnabled = false - httpUpdateConfigReloadDNSReturnOK(w, r) -} - -func handleQueryLog(w http.ResponseWriter, r *http.Request) { - data := config.dnsServer.GetQueryLog() - - jsonVal, err := json.Marshal(data) - if err != nil { - httpError(w, http.StatusInternalServerError, "Couldn't marshal data into json: %s", err) - return - } - - w.Header().Set("Content-Type", "application/json") - _, err = w.Write(jsonVal) - if err != nil { - httpError(w, http.StatusInternalServerError, "Unable to write response json: %s", err) - } -} - // ----------------------- // upstreams configuration // ----------------------- @@ -570,9 +541,6 @@ func registerControlHandlers() { httpRegister(http.MethodGet, "/control/status", handleStatus) httpRegister(http.MethodPost, "/control/enable_protection", handleProtectionEnable) httpRegister(http.MethodPost, "/control/disable_protection", handleProtectionDisable) - httpRegister(http.MethodGet, "/control/querylog", handleQueryLog) - httpRegister(http.MethodPost, "/control/querylog_enable", handleQueryLogEnable) - httpRegister(http.MethodPost, "/control/querylog_disable", handleQueryLogDisable) httpRegister(http.MethodPost, "/control/set_upstreams_config", handleSetUpstreamConfig) httpRegister(http.MethodPost, "/control/test_upstream_dns", handleTestUpstreamDNS) httpRegister(http.MethodPost, "/control/i18n/change_language", handleI18nChangeLanguage) @@ -611,6 +579,7 @@ func registerControlHandlers() { RegisterClientsHandlers() registerRewritesHandlers() RegisterBlockedServicesHandlers() + RegisterQueryLogHandlers() RegisterStatsHandlers() http.HandleFunc("/dns-query", postInstall(handleDOH)) diff --git a/home/control_querylog.go b/home/control_querylog.go new file mode 100644 index 00000000..43ac3869 --- /dev/null +++ b/home/control_querylog.go @@ -0,0 +1,91 @@ +package home + +import ( + "encoding/json" + "net/http" + + "github.com/AdguardTeam/AdGuardHome/querylog" +) + +func handleQueryLog(w http.ResponseWriter, r *http.Request) { + data := config.queryLog.GetData() + + jsonVal, err := json.Marshal(data) + if err != nil { + httpError(w, http.StatusInternalServerError, "Couldn't marshal data into json: %s", err) + return + } + + w.Header().Set("Content-Type", "application/json") + _, err = w.Write(jsonVal) + if err != nil { + httpError(w, http.StatusInternalServerError, "Unable to write response json: %s", err) + } +} + +func handleQueryLogClear(w http.ResponseWriter, r *http.Request) { + config.queryLog.Clear() + returnOK(w) +} + +type qlogConfig struct { + Enabled bool `json:"enabled"` + Interval uint32 `json:"interval"` +} + +// Get configuration +func handleQueryLogInfo(w http.ResponseWriter, r *http.Request) { + resp := qlogConfig{} + resp.Enabled = config.DNS.QueryLogEnabled + resp.Interval = config.DNS.QueryLogInterval + + jsonVal, err := json.Marshal(resp) + if err != nil { + httpError(w, http.StatusInternalServerError, "json encode: %s", err) + return + } + w.Header().Set("Content-Type", "application/json") + _, err = w.Write(jsonVal) + if err != nil { + httpError(w, http.StatusInternalServerError, "http write: %s", err) + } +} + +// Set configuration +func handleQueryLogConfig(w http.ResponseWriter, r *http.Request) { + + reqData := qlogConfig{} + err := json.NewDecoder(r.Body).Decode(&reqData) + if err != nil { + httpError(w, http.StatusBadRequest, "json decode: %s", err) + return + } + + if !checkQueryLogInterval(reqData.Interval) { + httpError(w, http.StatusBadRequest, "Unsupported interval") + return + } + + config.DNS.QueryLogEnabled = reqData.Enabled + config.DNS.QueryLogInterval = reqData.Interval + _ = config.write() + + conf := querylog.Config{ + Interval: config.DNS.QueryLogInterval * 24, + } + config.queryLog.Configure(conf) + + returnOK(w) +} + +func checkQueryLogInterval(i uint32) bool { + return i == 1 || i == 7 || i == 30 || i == 90 +} + +// RegisterQueryLogHandlers - register handlers +func RegisterQueryLogHandlers() { + httpRegister(http.MethodGet, "/control/querylog", handleQueryLog) + httpRegister(http.MethodGet, "/control/querylog_info", handleQueryLogInfo) + httpRegister(http.MethodPost, "/control/querylog_clear", handleQueryLogClear) + httpRegister(http.MethodPost, "/control/querylog_config", handleQueryLogConfig) +} diff --git a/home/dns.go b/home/dns.go index b1d1b0ca..53958bd5 100644 --- a/home/dns.go +++ b/home/dns.go @@ -9,6 +9,7 @@ import ( "github.com/AdguardTeam/AdGuardHome/dnsfilter" "github.com/AdguardTeam/AdGuardHome/dnsforward" + "github.com/AdguardTeam/AdGuardHome/querylog" "github.com/AdguardTeam/AdGuardHome/stats" "github.com/AdguardTeam/dnsproxy/proxy" "github.com/AdguardTeam/dnsproxy/upstream" @@ -40,7 +41,12 @@ func initDNSServer(baseDir string) { if err != nil { log.Fatal("Couldn't initialize statistics module") } - config.dnsServer = dnsforward.NewServer(baseDir, config.stats) + conf := querylog.Config{ + BaseDir: baseDir, + Interval: config.DNS.QueryLogInterval * 24, + } + config.queryLog = querylog.New(conf) + config.dnsServer = dnsforward.NewServer(config.stats, config.queryLog) initRDNS() } @@ -186,6 +192,7 @@ func stopDNSServer() error { } config.stats.Close() + config.queryLog.Close() return nil } diff --git a/dnsforward/querylog.go b/querylog/qlog.go similarity index 72% rename from dnsforward/querylog.go rename to querylog/qlog.go index fbbeb7f2..cfc0604c 100644 --- a/dnsforward/querylog.go +++ b/querylog/qlog.go @@ -1,8 +1,9 @@ -package dnsforward +package querylog import ( "fmt" "net" + "os" "path/filepath" "strconv" "strings" @@ -15,16 +16,14 @@ import ( ) const ( - logBufferCap = 5000 // maximum capacity of logBuffer before it's flushed to disk - queryLogTimeLimit = time.Hour * 24 // how far in the past we care about querylogs - queryLogRotationPeriod = time.Hour * 24 // rotate the log every 24 hours - queryLogFileName = "querylog.json" // .gz added during compression - queryLogSize = 5000 // maximum API response for /querylog - queryLogTopSize = 500 // Keep in memory only top N values + logBufferCap = 5000 // maximum capacity of logBuffer before it's flushed to disk + queryLogFileName = "querylog.json" // .gz added during compression + queryLogSize = 5000 // maximum API response for /querylog ) // queryLog is a structure that writes and reads the DNS query log type queryLog struct { + conf Config logFile string // path to the log file logBufferLock sync.RWMutex @@ -32,16 +31,53 @@ type queryLog struct { fileFlushLock sync.Mutex // synchronize a file-flushing goroutine and main thread flushPending bool // don't start another goroutine while the previous one is still running - queryLogCache []*logEntry - queryLogLock sync.RWMutex + cache []*logEntry + lock sync.RWMutex } // newQueryLog creates a new instance of the query log -func newQueryLog(baseDir string) *queryLog { - l := &queryLog{ - logFile: filepath.Join(baseDir, queryLogFileName), +func newQueryLog(conf Config) *queryLog { + l := queryLog{} + l.logFile = filepath.Join(conf.BaseDir, queryLogFileName) + l.conf = conf + go l.periodicQueryLogRotate() + go l.fillFromFile() + return &l +} + +func (l *queryLog) Close() { + _ = l.flushLogBuffer(true) +} + +func (l *queryLog) Configure(conf Config) { + l.conf = conf +} + +// Clear memory buffer and remove the file +func (l *queryLog) Clear() { + l.fileFlushLock.Lock() + defer l.fileFlushLock.Unlock() + + l.logBufferLock.Lock() + l.logBuffer = nil + l.flushPending = false + l.logBufferLock.Unlock() + + l.lock.Lock() + l.cache = nil + l.lock.Unlock() + + err := os.Remove(l.logFile + ".1") + if err != nil { + log.Error("file remove: %s: %s", l.logFile+".1", err) } - return l + + err = os.Remove(l.logFile) + if err != nil { + log.Error("file remove: %s: %s", l.logFile, err) + } + + log.Debug("Query log: cleared") } type logEntry struct { @@ -54,17 +90,28 @@ type logEntry struct { Upstream string `json:",omitempty"` // if empty, means it was cached } -func (l *queryLog) logRequest(question *dns.Msg, answer *dns.Msg, result *dnsfilter.Result, elapsed time.Duration, addr net.Addr, upstream string) *logEntry { +// getIPString is a helper function that extracts IP address from net.Addr +func getIPString(addr net.Addr) string { + switch addr := addr.(type) { + case *net.UDPAddr: + return addr.IP.String() + case *net.TCPAddr: + return addr.IP.String() + } + return "" +} + +func (l *queryLog) Add(question *dns.Msg, answer *dns.Msg, result *dnsfilter.Result, elapsed time.Duration, addr net.Addr, upstream string) { var q []byte var a []byte var err error - ip := GetIPString(addr) + ip := getIPString(addr) if question != nil { q, err = question.Pack() if err != nil { log.Printf("failed to pack question for querylog: %s", err) - return nil + return } } @@ -72,7 +119,7 @@ func (l *queryLog) logRequest(question *dns.Msg, answer *dns.Msg, result *dnsfil a, err = answer.Pack() if err != nil { log.Printf("failed to pack answer for querylog: %s", err) - return nil + return } } @@ -101,13 +148,13 @@ func (l *queryLog) logRequest(question *dns.Msg, answer *dns.Msg, result *dnsfil } } l.logBufferLock.Unlock() - l.queryLogLock.Lock() - l.queryLogCache = append(l.queryLogCache, &entry) - if len(l.queryLogCache) > queryLogSize { - toremove := len(l.queryLogCache) - queryLogSize - l.queryLogCache = l.queryLogCache[toremove:] + l.lock.Lock() + l.cache = append(l.cache, &entry) + if len(l.cache) > queryLogSize { + toremove := len(l.cache) - queryLogSize + l.cache = l.cache[toremove:] } - l.queryLogLock.Unlock() + l.lock.Unlock() // if buffer needs to be flushed to disk, do it now if needFlush { @@ -115,16 +162,14 @@ func (l *queryLog) logRequest(question *dns.Msg, answer *dns.Msg, result *dnsfil // do it in separate goroutine -- we are stalling DNS response this whole time go l.flushLogBuffer(false) // nolint } - - return &entry } // getQueryLogJson returns a map with the current query log ready to be converted to a JSON -func (l *queryLog) getQueryLog() []map[string]interface{} { - l.queryLogLock.RLock() - values := make([]*logEntry, len(l.queryLogCache)) - copy(values, l.queryLogCache) - l.queryLogLock.RUnlock() +func (l *queryLog) GetData() []map[string]interface{} { + l.lock.RLock() + values := make([]*logEntry, len(l.cache)) + copy(values, l.cache) + l.lock.RUnlock() // reverse it so that newest is first for left, right := 0, len(values)-1; left < right; left, right = left+1, right-1 { diff --git a/querylog/querylog.go b/querylog/querylog.go new file mode 100644 index 00000000..c995183b --- /dev/null +++ b/querylog/querylog.go @@ -0,0 +1,33 @@ +package querylog + +import ( + "net" + "time" + + "github.com/AdguardTeam/AdGuardHome/dnsfilter" + "github.com/miekg/dns" +) + +// QueryLog - main interface +type QueryLog interface { + Close() + + // Set new configuration at runtime + // Currently only 'Interval' field is supported. + Configure(conf Config) + + Add(question *dns.Msg, answer *dns.Msg, result *dnsfilter.Result, elapsed time.Duration, addr net.Addr, upstream string) + GetData() []map[string]interface{} + Clear() +} + +// Config - configuration object +type Config struct { + BaseDir string // directory where log file is stored + Interval uint32 // interval to rotate logs (in hours) +} + +// New - create instance +func New(conf Config) QueryLog { + return newQueryLog(conf) +} diff --git a/dnsforward/querylog_file.go b/querylog/querylog_file.go similarity index 56% rename from dnsforward/querylog_file.go rename to querylog/querylog_file.go index e990fdec..6f6f887a 100644 --- a/dnsforward/querylog_file.go +++ b/querylog/querylog_file.go @@ -1,4 +1,4 @@ -package dnsforward +package querylog import ( "bytes" @@ -11,6 +11,7 @@ import ( "github.com/AdguardTeam/golibs/log" "github.com/go-test/deep" + "github.com/miekg/dns" ) var ( @@ -170,7 +171,7 @@ func (l *queryLog) rotateQueryLog() error { } func (l *queryLog) periodicQueryLogRotate() { - for range time.Tick(queryLogRotationPeriod) { + for range time.Tick(time.Duration(l.conf.Interval) * time.Hour) { err := l.rotateQueryLog() if err != nil { log.Error("Failed to rotate querylog: %s", err) @@ -178,3 +179,152 @@ func (l *queryLog) periodicQueryLogRotate() { } } } + +// Reader is the DB reader context +type Reader struct { + f *os.File + jd *json.Decoder + now time.Time + ql *queryLog + + files []string + ifile int + + count uint64 // returned elements counter +} + +// OpenReader locks the file and returns reader object or nil on error +func (l *queryLog) OpenReader() *Reader { + r := Reader{} + r.ql = l + r.now = time.Now() + + return &r +} + +// Close closes the reader +func (r *Reader) Close() { + elapsed := time.Since(r.now) + var perunit time.Duration + if r.count > 0 { + perunit = elapsed / time.Duration(r.count) + } + log.Debug("querylog: read %d entries in %v, %v/entry", + r.count, elapsed, perunit) + + if r.f != nil { + r.f.Close() + } +} + +// BeginRead starts reading +func (r *Reader) BeginRead() { + r.files = []string{ + r.ql.logFile, + r.ql.logFile + ".1", + } +} + +// Next returns the next entry or nil if reading is finished +func (r *Reader) Next() *logEntry { // nolint + var err error + for { + // open file if needed + if r.f == nil { + if r.ifile == len(r.files) { + return nil + } + fn := r.files[r.ifile] + r.f, err = os.Open(fn) + if err != nil { + log.Error("Failed to open file \"%s\": %s", fn, err) + r.ifile++ + continue + } + } + + // open decoder if needed + if r.jd == nil { + r.jd = json.NewDecoder(r.f) + } + + // check if there's data + if !r.jd.More() { + r.jd = nil + r.f.Close() + r.f = nil + r.ifile++ + continue + } + + // read data + var entry logEntry + err = r.jd.Decode(&entry) + if err != nil { + log.Error("Failed to decode: %s", err) + // next entry can be fine, try more + continue + } + r.count++ + return &entry + } +} + +// Total returns the total number of items +func (r *Reader) Total() int { + return 0 +} + +// Fill cache from file +func (l *queryLog) fillFromFile() { + now := time.Now() + validFrom := now.Unix() - int64(l.conf.Interval*60*60) + r := l.OpenReader() + if r == nil { + return + } + + r.BeginRead() + + for { + entry := r.Next() + if entry == nil { + break + } + + if entry.Time.Unix() < validFrom { + continue + } + + if len(entry.Question) == 0 { + log.Printf("entry question is absent, skipping") + continue + } + + if entry.Time.After(now) { + log.Printf("t %v vs %v is in the future, ignoring", entry.Time, now) + continue + } + + q := new(dns.Msg) + if err := q.Unpack(entry.Question); err != nil { + log.Printf("failed to unpack dns message question: %s", err) + continue + } + + if len(q.Question) != 1 { + log.Printf("malformed dns message, has no questions, skipping") + continue + } + + l.lock.Lock() + l.cache = append(l.cache, entry) + if len(l.cache) > queryLogSize { + toremove := len(l.cache) - queryLogSize + l.cache = l.cache[toremove:] + } + l.lock.Unlock() + } + + r.Close() +} diff --git a/querylog/querylog_test.go b/querylog/querylog_test.go new file mode 100644 index 00000000..8da84183 --- /dev/null +++ b/querylog/querylog_test.go @@ -0,0 +1,43 @@ +package querylog + +import ( + "net" + "testing" + + "github.com/AdguardTeam/AdGuardHome/dnsfilter" + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" +) + +func TestQueryLog(t *testing.T) { + conf := Config{ + Interval: 1, + } + l := New(conf) + + q := dns.Msg{} + q.Question = append(q.Question, dns.Question{ + Name: "example.org.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }) + + a := dns.Msg{} + a.Question = append(a.Question, q.Question[0]) + answer := new(dns.A) + answer.Hdr = dns.RR_Header{ + Name: q.Question[0].Name, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + } + answer.A = net.IP{1, 2, 3, 4} + a.Answer = append(a.Answer, answer) + + res := dnsfilter.Result{} + l.Add(&q, &a, &res, 0, nil, "upstream") + + d := l.GetData() + m := d[0] + mq := m["question"].(map[string]interface{}) + assert.True(t, mq["host"].(string) == "example.org") +} From 05bb705e432082c0affde03c7f24debfb9303de1 Mon Sep 17 00:00:00 2001 From: Simon Zolin Date: Mon, 9 Sep 2019 20:12:32 +0300 Subject: [PATCH 2/6] + doc: Relations between subsystems --- AGHTechDoc.md | 6 ++++++ agh-arch.png | Bin 0 -> 77733 bytes 2 files changed, 6 insertions(+) create mode 100644 agh-arch.png diff --git a/AGHTechDoc.md b/AGHTechDoc.md index e2c88d12..cfeddd40 100644 --- a/AGHTechDoc.md +++ b/AGHTechDoc.md @@ -47,6 +47,12 @@ Contents: * API: Get querylog parameters +## Relations between subsystems + +![](agh-arch.png) + + + ## First startup The first application startup is detected when there's no .yaml configuration file. diff --git a/agh-arch.png b/agh-arch.png new file mode 100644 index 0000000000000000000000000000000000000000..a041007561fb7a6f10a2f8428d81a8aee32b4f31 GIT binary patch literal 77733 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≺ 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

XjfcPDT1{^El|k}dTj!)Enw9NMLm|q}CdpAo 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 Date: Mon, 29 Jul 2019 18:51:55 +0300 Subject: [PATCH 3/6] + openapi: /querylog_info, /querylog_config, /querylog_clear --- openapi/openapi.yaml | 48 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index f7f56379..6542ee02 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -191,21 +191,42 @@ paths: description: OK schema: $ref: '#/definitions/QueryLog' - /querylog_enable: - post: + + /querylog_info: + get: tags: - log - operationId: querylogEnable - summary: 'Enable querylog' + operationId: queryLogInfo + summary: 'Get query log parameters' responses: 200: description: OK - /querylog_disable: + schema: + $ref: "#/definitions/QueryLogConfig" + + /querylog_config: post: tags: - log - operationId: querylogDisable - summary: 'Disable filtering' + operationId: queryLogConfig + summary: "Set query log parameters" + consumes: + - application/json + parameters: + - in: "body" + name: "body" + schema: + $ref: "#/definitions/QueryLogConfig" + responses: + 200: + description: OK + + /querylog_clear: + post: + tags: + - log + operationId: querylogClear + summary: 'Clear query log' responses: 200: description: OK @@ -244,6 +265,7 @@ paths: summary: 'Get statistics parameters' responses: 200: + description: OK schema: $ref: "#/definitions/StatsConfig" description: OK @@ -1360,6 +1382,18 @@ definitions: description: "Query log" items: $ref: "#/definitions/QueryLogItem" + + QueryLogConfig: + type: "object" + description: "Query log configuration" + properties: + enabled: + type: "boolean" + description: "Is query log enabled" + interval: + type: "integer" + description: "Time period to keep data (1 | 7 | 30 | 90)" + TlsConfig: type: "object" description: "TLS configuration settings and status" From a753ae86cc9c546658a12be77f600bcf0f18623f Mon Sep 17 00:00:00 2001 From: Ildar Kamalov Date: Wed, 4 Sep 2019 17:39:35 +0300 Subject: [PATCH 4/6] + client: handle logs configuration --- client/package-lock.json | 5 - client/package.json | 1 - client/src/__locales/en.json | 15 +-- client/src/actions/index.js | 65 +--------- client/src/actions/queryLogs.js | 69 +++++++++++ client/src/actions/stats.js | 6 +- client/src/api/Api.js | 54 +++++---- client/src/components/Logs/index.js | 112 ++++++------------ .../components/Settings/LogsConfig/Form.js | 82 +++++++++++++ .../components/Settings/LogsConfig/index.js | 69 +++++++++++ .../components/Settings/StatsConfig/Form.js | 11 +- .../components/Settings/StatsConfig/index.js | 10 +- client/src/components/Settings/index.js | 34 ++++-- client/src/components/ui/Checkbox.css | 2 +- client/src/containers/Logs.js | 6 +- client/src/containers/Settings.js | 10 +- client/src/helpers/constants.js | 2 + client/src/reducers/index.js | 28 +---- client/src/reducers/queryLogs.js | 49 ++++++++ 19 files changed, 401 insertions(+), 229 deletions(-) create mode 100644 client/src/actions/queryLogs.js create mode 100644 client/src/components/Settings/LogsConfig/Form.js create mode 100644 client/src/components/Settings/LogsConfig/index.js create mode 100644 client/src/reducers/queryLogs.js diff --git a/client/package-lock.json b/client/package-lock.json index 0c596c27..b2070cdb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -5027,11 +5027,6 @@ } } }, - "file-saver": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-1.3.8.tgz", - "integrity": "sha512-spKHSBQIxxS81N/O21WmuXA2F6wppUCsutpzenOeZzOCCJ5gEfcbqJP983IrpLXzYmXnMUa6J03SubcNPdKrlg==" - }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", diff --git a/client/package.json b/client/package.json index 6c5f8e4e..0e8b6d4d 100644 --- a/client/package.json +++ b/client/package.json @@ -13,7 +13,6 @@ "axios": "^0.19.0", "classnames": "^2.2.6", "date-fns": "^1.29.0", - "file-saver": "^1.3.8", "i18next": "^12.0.0", "i18next-browser-languagedetector": "^2.2.3", "lodash": "^4.17.15", diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index 119c1828..570d28e1 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -98,7 +98,6 @@ "enforce_safe_search": "Enforce safe search", "enforce_save_search_hint": "AdGuard Home can enforce safe search in the following search engines: Google, Youtube, Bing, DuckDuckGo and Yandex.", "no_servers_specified": "No servers specified", - "no_settings": "No settings", "general_settings": "General settings", "dns_settings": "DNS settings", "encryption_settings": "Encryption settings", @@ -163,10 +162,7 @@ "show_all_filter_type": "Show all", "show_filtered_type": "Show filtered", "no_logs_found": "No logs found", - "disabled_log_btn": "Disable log", - "download_log_file_btn": "Download log file", "refresh_btn": "Refresh", - "enabled_log_btn": "Enable log", "last_dns_queries": "Last 5000 DNS queries", "previous_btn": "Previous", "next_btn": "Next", @@ -177,10 +173,15 @@ "updated_custom_filtering_toast": "Updated the custom filtering rules", "rule_removed_from_custom_filtering_toast": "Rule removed from the custom filtering rules", "rule_added_to_custom_filtering_toast": "Rule added to the custom filtering rules", - "query_log_disabled_toast": "Query log disabled", - "query_log_enabled_toast": "Query log enabled", "query_log_response_status": "Status: {{value}}", "query_log_filtered": "Filtered by {{filter}}", + "query_log_confirm_clear": "Are you sure you want to clear the entire query log? This will also clear statistics on the dashboard.", + "query_log_cleared": "The query log has been successfully cleared", + "query_log_clear": "Clear query logs", + "query_log_retention": "Query logs retention", + "query_log_enable": "Enable log", + "query_log_configuration": "Logs configuration", + "query_log_disabled": "The query log is disabled and can be configured in the <0>settings", "source_label": "Source", "found_in_known_domain_db": "Found in the known domains database.", "category_label": "Category", @@ -372,7 +373,7 @@ "domain": "Domain", "answer": "Answer", "filter_added_successfully": "The filter has been successfully added", - "statistics_logs": "Statistics and logs", + "statistics_configuration": "Statistics configuration", "statistics_retention": "Statistics retention", "statistics_retention_desc": "If you decrease the interval value, some data will be lost", "statistics_clear": " Clear statistics", diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 98d77ca4..70913e29 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -4,7 +4,7 @@ import { showLoading, hideLoading } from 'react-redux-loading-bar'; import axios from 'axios'; import versionCompare from '../helpers/versionCompare'; -import { normalizeFilteringStatus, normalizeLogs, normalizeTextarea, sortClients } from '../helpers/helpers'; +import { normalizeFilteringStatus, normalizeTextarea, sortClients } from '../helpers/helpers'; import { SETTINGS_NAMES, CHECK_TIMEOUT } from '../helpers/constants'; import { getTlsStatus } from './encryption'; import Api from '../api/Api'; @@ -292,52 +292,6 @@ export const disableDns = () => async (dispatch) => { } }; -export const getLogsRequest = createAction('GET_LOGS_REQUEST'); -export const getLogsFailure = createAction('GET_LOGS_FAILURE'); -export const getLogsSuccess = createAction('GET_LOGS_SUCCESS'); - -export const getLogs = () => async (dispatch, getState) => { - dispatch(getLogsRequest()); - const timer = setInterval(async () => { - const state = getState(); - if (state.dashboard.isCoreRunning) { - clearInterval(timer); - try { - const logs = normalizeLogs(await apiClient.getQueryLog()); - dispatch(getLogsSuccess(logs)); - } catch (error) { - dispatch(addErrorToast({ error })); - dispatch(getLogsFailure(error)); - } - } - }, 100); -}; - -export const toggleLogStatusRequest = createAction('TOGGLE_LOGS_REQUEST'); -export const toggleLogStatusFailure = createAction('TOGGLE_LOGS_FAILURE'); -export const toggleLogStatusSuccess = createAction('TOGGLE_LOGS_SUCCESS'); - -export const toggleLogStatus = queryLogEnabled => async (dispatch) => { - dispatch(toggleLogStatusRequest()); - let toggleMethod; - let successMessage; - if (queryLogEnabled) { - toggleMethod = apiClient.disableQueryLog.bind(apiClient); - successMessage = 'query_log_disabled_toast'; - } else { - toggleMethod = apiClient.enableQueryLog.bind(apiClient); - successMessage = 'query_log_enabled_toast'; - } - try { - await toggleMethod(); - dispatch(addSuccessToast(successMessage)); - dispatch(toggleLogStatusSuccess()); - } catch (error) { - dispatch(addErrorToast({ error })); - dispatch(toggleLogStatusFailure()); - } -}; - export const setRulesRequest = createAction('SET_RULES_REQUEST'); export const setRulesFailure = createAction('SET_RULES_FAILURE'); export const setRulesSuccess = createAction('SET_RULES_SUCCESS'); @@ -465,23 +419,6 @@ export const removeFilter = url => async (dispatch) => { export const toggleFilteringModal = createAction('FILTERING_MODAL_TOGGLE'); -export const downloadQueryLogRequest = createAction('DOWNLOAD_QUERY_LOG_REQUEST'); -export const downloadQueryLogFailure = createAction('DOWNLOAD_QUERY_LOG_FAILURE'); -export const downloadQueryLogSuccess = createAction('DOWNLOAD_QUERY_LOG_SUCCESS'); - -export const downloadQueryLog = () => async (dispatch) => { - let data; - dispatch(downloadQueryLogRequest()); - try { - data = await apiClient.downloadQueryLog(); - dispatch(downloadQueryLogSuccess()); - } catch (error) { - dispatch(addErrorToast({ error })); - dispatch(downloadQueryLogFailure()); - } - return data; -}; - export const handleUpstreamChange = createAction('HANDLE_UPSTREAM_CHANGE'); export const setUpstreamRequest = createAction('SET_UPSTREAM_REQUEST'); export const setUpstreamFailure = createAction('SET_UPSTREAM_FAILURE'); diff --git a/client/src/actions/queryLogs.js b/client/src/actions/queryLogs.js new file mode 100644 index 00000000..1295ff6a --- /dev/null +++ b/client/src/actions/queryLogs.js @@ -0,0 +1,69 @@ +import { createAction } from 'redux-actions'; + +import Api from '../api/Api'; +import { addErrorToast, addSuccessToast } from './index'; +import { normalizeLogs } from '../helpers/helpers'; + +const apiClient = new Api(); + +export const getLogsRequest = createAction('GET_LOGS_REQUEST'); +export const getLogsFailure = createAction('GET_LOGS_FAILURE'); +export const getLogsSuccess = createAction('GET_LOGS_SUCCESS'); + +export const getLogs = () => async (dispatch) => { + dispatch(getLogsRequest()); + try { + const logs = normalizeLogs(await apiClient.getQueryLog()); + dispatch(getLogsSuccess(logs)); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(getLogsFailure(error)); + } +}; + +export const clearLogsRequest = createAction('CLEAR_LOGS_REQUEST'); +export const clearLogsFailure = createAction('CLEAR_LOGS_FAILURE'); +export const clearLogsSuccess = createAction('CLEAR_LOGS_SUCCESS'); + +export const clearLogs = () => async (dispatch) => { + dispatch(clearLogsRequest()); + try { + await apiClient.clearQueryLog(); + dispatch(clearLogsSuccess()); + dispatch(addSuccessToast('query_log_cleared')); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(clearLogsFailure(error)); + } +}; + +export const getLogsConfigRequest = createAction('GET_LOGS_CONFIG_REQUEST'); +export const getLogsConfigFailure = createAction('GET_LOGS_CONFIG_FAILURE'); +export const getLogsConfigSuccess = createAction('GET_LOGS_CONFIG_SUCCESS'); + +export const getLogsConfig = () => async (dispatch) => { + dispatch(getLogsConfigRequest()); + try { + const data = await apiClient.getQueryLogInfo(); + dispatch(getLogsConfigSuccess(data)); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(getLogsConfigFailure()); + } +}; + +export const setLogsConfigRequest = createAction('SET_LOGS_CONFIG_REQUEST'); +export const setLogsConfigFailure = createAction('SET_LOGS_CONFIG_FAILURE'); +export const setLogsConfigSuccess = createAction('SET_LOGS_CONFIG_SUCCESS'); + +export const setLogsConfig = config => async (dispatch) => { + dispatch(setLogsConfigRequest()); + try { + await apiClient.setQueryLogConfig(config); + dispatch(addSuccessToast('config_successfully_saved')); + dispatch(setLogsConfigSuccess(config)); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(setLogsConfigFailure()); + } +}; diff --git a/client/src/actions/stats.js b/client/src/actions/stats.js index a24b3ec7..e8483443 100644 --- a/client/src/actions/stats.js +++ b/client/src/actions/stats.js @@ -6,9 +6,9 @@ import { normalizeTopStats, secondsToMilliseconds } from '../helpers/helpers'; const apiClient = new Api(); -export const getStatsConfigRequest = createAction('GET_LOGS_CONFIG_REQUEST'); -export const getStatsConfigFailure = createAction('GET_LOGS_CONFIG_FAILURE'); -export const getStatsConfigSuccess = createAction('GET_LOGS_CONFIG_SUCCESS'); +export const getStatsConfigRequest = createAction('GET_STATS_CONFIG_REQUEST'); +export const getStatsConfigFailure = createAction('GET_STATS_CONFIG_FAILURE'); +export const getStatsConfigSuccess = createAction('GET_STATS_CONFIG_SUCCESS'); export const getStatsConfig = () => async (dispatch) => { dispatch(getStatsConfigRequest()); diff --git a/client/src/api/Api.js b/client/src/api/Api.js index 0f63f2ea..495be5b8 100644 --- a/client/src/api/Api.js +++ b/client/src/api/Api.js @@ -25,9 +25,6 @@ export default class Api { GLOBAL_START = { path: 'start', method: 'POST' }; GLOBAL_STATUS = { path: 'status', method: 'GET' }; GLOBAL_STOP = { path: 'stop', method: 'POST' }; - GLOBAL_QUERY_LOG = { path: 'querylog', method: 'GET' }; - GLOBAL_QUERY_LOG_ENABLE = { path: 'querylog_enable', method: 'POST' }; - GLOBAL_QUERY_LOG_DISABLE = { path: 'querylog_disable', method: 'POST' }; GLOBAL_SET_UPSTREAM_DNS = { path: 'set_upstreams_config', method: 'POST' }; GLOBAL_TEST_UPSTREAM_DNS = { path: 'test_upstream_dns', method: 'POST' }; GLOBAL_VERSION = { path: 'version.json', method: 'POST' }; @@ -50,27 +47,6 @@ export default class Api { return this.makeRequest(path, method); } - getQueryLog() { - const { path, method } = this.GLOBAL_QUERY_LOG; - return this.makeRequest(path, method); - } - - downloadQueryLog() { - const { path, method } = this.GLOBAL_QUERY_LOG; - const queryString = '?download=1'; - return this.makeRequest(path + queryString, method); - } - - enableQueryLog() { - const { path, method } = this.GLOBAL_QUERY_LOG_ENABLE; - return this.makeRequest(path, method); - } - - disableQueryLog() { - const { path, method } = this.GLOBAL_QUERY_LOG_DISABLE; - return this.makeRequest(path, method); - } - setUpstream(url) { const { path, method } = this.GLOBAL_SET_UPSTREAM_DNS; const config = { @@ -521,4 +497,34 @@ export default class Api { const { path, method } = this.STATS_RESET; return this.makeRequest(path, method); } + + // Query log + GET_QUERY_LOG = { path: 'querylog', method: 'GET' }; + QUERY_LOG_CONFIG = { path: 'querylog_config', method: 'POST' }; + QUERY_LOG_INFO = { path: 'querylog_info', method: 'GET' }; + QUERY_LOG_CLEAR = { path: 'querylog_clear', method: 'POST' }; + + getQueryLog() { + const { path, method } = this.GET_QUERY_LOG; + return this.makeRequest(path, method); + } + + getQueryLogInfo() { + const { path, method } = this.QUERY_LOG_INFO; + return this.makeRequest(path, method); + } + + setQueryLogConfig(data) { + const { path, method } = this.QUERY_LOG_CONFIG; + const config = { + data, + headers: { 'Content-Type': 'application/json' }, + }; + return this.makeRequest(path, method, config); + } + + clearQueryLog() { + const { path, method } = this.QUERY_LOG_CLEAR; + return this.makeRequest(path, method); + } } diff --git a/client/src/components/Logs/index.js b/client/src/components/Logs/index.js index 5d5471e9..5c1db03c 100644 --- a/client/src/components/Logs/index.js +++ b/client/src/components/Logs/index.js @@ -1,7 +1,6 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import ReactTable from 'react-table'; -import { saveAs } from 'file-saver/FileSaver'; import escapeRegExp from 'lodash/escapeRegExp'; import endsWith from 'lodash/endsWith'; import { Trans, withNamespaces } from 'react-i18next'; @@ -17,7 +16,6 @@ import PopoverFiltered from '../ui/PopoverFilter'; import Popover from '../ui/Popover'; import './Logs.css'; -const DOWNLOAD_LOG_FILENAME = 'dns-logs.txt'; const FILTERED_REASON = 'Filtered'; const RESPONSE_FILTER = { ALL: 'all', @@ -29,18 +27,19 @@ class Logs extends Component { this.getLogs(); this.props.getFilteringStatus(); this.props.getClients(); + this.props.getLogsConfig(); } componentDidUpdate(prevProps) { // get logs when queryLog becomes enabled - if (this.props.dashboard.queryLogEnabled && !prevProps.dashboard.queryLogEnabled) { + if (this.props.queryLogs.enabled && !prevProps.queryLogs.enabled) { this.props.getLogs(); } } getLogs = () => { // get logs on initialization if queryLogIsEnabled - if (this.props.dashboard.queryLogEnabled) { + if (this.props.queryLogs.enabled) { this.props.getLogs(); } }; @@ -155,10 +154,7 @@ class Logs extends Component { } else { const filterItem = Object.keys(filters).filter(key => filters[key].id === filterId)[0]; - if ( - typeof filterItem !== 'undefined' && - typeof filters[filterItem] !== 'undefined' - ) { + if (typeof filterItem !== 'undefined' && typeof filters[filterItem] !== 'undefined') { filterName = filters[filterItem].name; } @@ -255,10 +251,7 @@ class Logs extends Component { if (filter.value === RESPONSE_FILTER.FILTERED) { // eslint-disable-next-line no-underscore-dangle const { reason } = row._original; - return ( - this.checkFiltered(reason) || - this.checkWhiteList(reason) - ); + return this.checkFiltered(reason) || this.checkWhiteList(reason); } return true; }, @@ -347,74 +340,44 @@ class Logs extends Component { return null; } - handleDownloadButton = async (e) => { - e.preventDefault(); - const data = await this.props.downloadQueryLog(); - const jsonStr = JSON.stringify(data); - const dataBlob = new Blob([jsonStr], { type: 'text/plain;charset=utf-8' }); - saveAs(dataBlob, DOWNLOAD_LOG_FILENAME); - }; - - renderButtons(queryLogEnabled, logStatusProcessing) { - if (queryLogEnabled) { - return ( - - - - - - ); - } - - return ( - - ); - } - render() { const { queryLogs, dashboard, t } = this.props; - const { queryLogEnabled } = dashboard; + const { enabled, processingGetLogs, processingGetConfig } = queryLogs; + const { processingClients } = dashboard; + const isDataReady = + !processingGetLogs && !processingGetConfig && !dashboard.processingClients; + + const refreshButton = enabled ? ( + + ) : ( + '' + ); + return ( -

- {this.renderButtons(queryLogEnabled, dashboard.logStatusProcessing)} -
+ {refreshButton} - {queryLogEnabled && - queryLogs.getLogsProcessing && - dashboard.processingClients && } - {queryLogEnabled && - !queryLogs.getLogsProcessing && - !dashboard.processingClients && - this.renderLogs(queryLogs.logs)} + {enabled && (processingGetLogs || processingClients || processingGetConfig) && ( + + )} + {enabled && isDataReady && this.renderLogs(queryLogs.logs)} + {!enabled && isDataReady && ( +
+ link]}> + query_log_disabled + +
+ )}
); @@ -425,13 +388,12 @@ Logs.propTypes = { getLogs: PropTypes.func.isRequired, queryLogs: PropTypes.object.isRequired, dashboard: PropTypes.object.isRequired, - toggleLogStatus: PropTypes.func.isRequired, - downloadQueryLog: PropTypes.func.isRequired, getFilteringStatus: PropTypes.func.isRequired, filtering: PropTypes.object.isRequired, setRules: PropTypes.func.isRequired, addSuccessToast: PropTypes.func.isRequired, getClients: PropTypes.func.isRequired, + getLogsConfig: PropTypes.func.isRequired, t: PropTypes.func.isRequired, }; diff --git a/client/src/components/Settings/LogsConfig/Form.js b/client/src/components/Settings/LogsConfig/Form.js new file mode 100644 index 00000000..e90c6487 --- /dev/null +++ b/client/src/components/Settings/LogsConfig/Form.js @@ -0,0 +1,82 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Field, reduxForm } from 'redux-form'; +import { Trans, withNamespaces } from 'react-i18next'; +import flow from 'lodash/flow'; + +import { renderSelectField, renderRadioField, toNumber } from '../../../helpers/form'; +import { QUERY_LOG_INTERVALS_DAYS } from '../../../helpers/constants'; + +const getIntervalFields = (processing, t, handleChange, toNumber) => + QUERY_LOG_INTERVALS_DAYS.map((interval) => { + const title = + interval === 1 ? t('interval_24_hour') : t('interval_days', { count: interval }); + + return ( + + ); + }); + +const Form = (props) => { + const { + handleSubmit, handleChange, processing, t, + } = props; + + return ( +
+
+
+
+ +
+
+
+ +
+
+
+
+ {getIntervalFields(processing, t, handleChange, toNumber)} +
+
+
+
+
+ ); +}; + +Form.propTypes = { + handleSubmit: PropTypes.func.isRequired, + handleChange: PropTypes.func, + change: PropTypes.func.isRequired, + submitting: PropTypes.bool.isRequired, + invalid: PropTypes.bool.isRequired, + processing: PropTypes.bool.isRequired, + t: PropTypes.func.isRequired, +}; + +export default flow([ + withNamespaces(), + reduxForm({ + form: 'logConfigForm', + }), +])(Form); diff --git a/client/src/components/Settings/LogsConfig/index.js b/client/src/components/Settings/LogsConfig/index.js new file mode 100644 index 00000000..3c7b0a47 --- /dev/null +++ b/client/src/components/Settings/LogsConfig/index.js @@ -0,0 +1,69 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withNamespaces, Trans } from 'react-i18next'; +import debounce from 'lodash/debounce'; + +import { DEBOUNCE_TIMEOUT } from '../../../helpers/constants'; +import Card from '../../ui/Card'; +import Form from './Form'; + +class LogsConfig extends Component { + handleFormChange = debounce((values) => { + this.props.setLogsConfig(values); + }, DEBOUNCE_TIMEOUT); + + handleLogsClear = () => { + const { t, clearLogs } = this.props; + // eslint-disable-next-line no-alert + if (window.confirm(t('query_log_confirm_clear'))) { + clearLogs(); + } + }; + + render() { + const { + t, enabled, interval, processing, processingClear, + } = this.props; + + return ( + +
+
+ + +
+
+ ); + } +} + +LogsConfig.propTypes = { + interval: PropTypes.number.isRequired, + enabled: PropTypes.bool.isRequired, + processing: PropTypes.bool.isRequired, + processingClear: PropTypes.bool.isRequired, + setLogsConfig: PropTypes.func.isRequired, + clearLogs: PropTypes.func.isRequired, + t: PropTypes.func.isRequired, +}; + +export default withNamespaces()(LogsConfig); diff --git a/client/src/components/Settings/StatsConfig/Form.js b/client/src/components/Settings/StatsConfig/Form.js index 463476c9..7da5286f 100644 --- a/client/src/components/Settings/StatsConfig/Form.js +++ b/client/src/components/Settings/StatsConfig/Form.js @@ -9,9 +9,8 @@ import { STATS_INTERVALS_DAYS } from '../../../helpers/constants'; const getIntervalFields = (processing, t, handleChange, toNumber) => STATS_INTERVALS_DAYS.map((interval) => { - const title = interval === 1 - ? t('interval_24_hour') - : t('interval_days', { count: interval }); + const title = + interval === 1 ? t('interval_24_hour') : t('interval_days', { count: interval }); return ( {
-
-
+
{getIntervalFields(processing, t, handleChange, toNumber)}
@@ -69,6 +68,6 @@ Form.propTypes = { export default flow([ withNamespaces(), reduxForm({ - form: 'logConfigForm', + form: 'statsConfigForm', }), ])(Form); diff --git a/client/src/components/Settings/StatsConfig/index.js b/client/src/components/Settings/StatsConfig/index.js index b649c6c9..0b9775d6 100644 --- a/client/src/components/Settings/StatsConfig/index.js +++ b/client/src/components/Settings/StatsConfig/index.js @@ -4,15 +4,15 @@ import { withNamespaces, Trans } from 'react-i18next'; import debounce from 'lodash/debounce'; import { DEBOUNCE_TIMEOUT } from '../../../helpers/constants'; -import Form from './Form'; import Card from '../../ui/Card'; +import Form from './Form'; class StatsConfig extends Component { handleFormChange = debounce((values) => { this.props.setStatsConfig(values); }, DEBOUNCE_TIMEOUT); - handleReset = () => { + handleStatsReset = () => { const { t, resetStats } = this.props; // eslint-disable-next-line no-alert if (window.confirm(t('statistics_clear_confirm'))) { @@ -26,7 +26,7 @@ class StatsConfig extends Component { } = this.props; return ( - +
statistics_clear diff --git a/client/src/components/Settings/index.js b/client/src/components/Settings/index.js index 5f7f2fd6..2ca90782 100644 --- a/client/src/components/Settings/index.js +++ b/client/src/components/Settings/index.js @@ -1,9 +1,10 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; -import { withNamespaces, Trans } from 'react-i18next'; +import { withNamespaces } from 'react-i18next'; import Services from './Services'; import StatsConfig from './StatsConfig'; +import LogsConfig from './LogsConfig'; import Checkbox from '../ui/Checkbox'; import Loading from '../ui/Loading'; import PageTitle from '../ui/PageTitle'; @@ -39,6 +40,7 @@ class Settings extends Component { this.props.initSettings(this.settings); this.props.getBlockedServices(); this.props.getStatsConfig(); + this.props.getLogsConfig(); } renderSettings = (settings) => { @@ -55,11 +57,7 @@ class Settings extends Component { ); }); } - return ( -
- no_settings -
- ); + return ''; }; render() { @@ -70,13 +68,23 @@ class Settings extends Component { setStatsConfig, resetStats, stats, + queryLogs, + setLogsConfig, + clearLogs, t, } = this.props; + + const isDataReady = + !settings.processing && + !services.processing && + !stats.processingGetConfig && + !queryLogs.processingGetConfig; + return ( - {settings.processing && } - {!settings.processing && ( + {!isDataReady && } + {isDataReady && (
@@ -95,6 +103,16 @@ class Settings extends Component { resetStats={resetStats} />
+
+ +
{ @@ -10,12 +11,11 @@ const mapStateToProps = (state) => { const mapDispatchToProps = { getLogs, - toggleLogStatus, - downloadQueryLog, getFilteringStatus, setRules, addSuccessToast, getClients, + getLogsConfig, }; export default connect( diff --git a/client/src/containers/Settings.js b/client/src/containers/Settings.js index 14f08cfc..726e10a0 100644 --- a/client/src/containers/Settings.js +++ b/client/src/containers/Settings.js @@ -2,14 +2,18 @@ import { connect } from 'react-redux'; import { initSettings, toggleSetting } from '../actions'; import { getBlockedServices, setBlockedServices } from '../actions/services'; import { getStatsConfig, setStatsConfig, resetStats } from '../actions/stats'; +import { toggleLogStatus, clearLogs, getLogsConfig, setLogsConfig } from '../actions/queryLogs'; import Settings from '../components/Settings'; const mapStateToProps = (state) => { - const { settings, services, stats } = state; + const { + settings, services, stats, queryLogs, + } = state; const props = { settings, services, stats, + queryLogs, }; return props; }; @@ -22,6 +26,10 @@ const mapDispatchToProps = { getStatsConfig, setStatsConfig, resetStats, + toggleLogStatus, + clearLogs, + getLogsConfig, + setLogsConfig, }; export default connect( diff --git a/client/src/helpers/constants.js b/client/src/helpers/constants.js index 3551a41a..6001293a 100644 --- a/client/src/helpers/constants.js +++ b/client/src/helpers/constants.js @@ -263,3 +263,5 @@ export const FILTERED_STATUS = { }; export const STATS_INTERVALS_DAYS = [1, 7, 30, 90]; + +export const QUERY_LOG_INTERVALS_DAYS = [1, 7, 30, 90]; diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index 2af571b2..49e7729f 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -12,6 +12,7 @@ import access from './access'; import rewrites from './rewrites'; import services from './services'; import stats from './stats'; +import queryLogs from './queryLogs'; const settings = handleActions({ [actions.initSettingsRequest]: state => ({ ...state, processing: true }), @@ -43,6 +44,7 @@ const settings = handleActions({ processingTestUpstream: false, processingSetUpstream: false, processingDhcpStatus: false, + settingsList: {}, }); const dashboard = handleActions({ @@ -54,7 +56,6 @@ const dashboard = handleActions({ running, dns_port: dnsPort, dns_addresses: dnsAddresses, - querylog_enabled: queryLogEnabled, upstream_dns: upstreamDns, bootstrap_dns: bootstrapDns, all_servers: allServers, @@ -69,7 +70,6 @@ const dashboard = handleActions({ dnsVersion: version, dnsPort, dnsAddresses, - queryLogEnabled, upstreamDns: upstreamDns.join('\n'), bootstrapDns: bootstrapDns.join('\n'), allServers, @@ -94,13 +94,6 @@ const dashboard = handleActions({ return newState; }, - [actions.toggleLogStatusRequest]: state => ({ ...state, logStatusProcessing: true }), - [actions.toggleLogStatusFailure]: state => ({ ...state, logStatusProcessing: false }), - [actions.toggleLogStatusSuccess]: (state) => { - const { queryLogEnabled } = state; - return ({ ...state, queryLogEnabled: !queryLogEnabled, logStatusProcessing: false }); - }, - [actions.getVersionRequest]: state => ({ ...state, processingVersion: true }), [actions.getVersionFailure]: state => ({ ...state, processingVersion: false }), [actions.getVersionSuccess]: (state, { payload }) => { @@ -179,7 +172,6 @@ const dashboard = handleActions({ }, { processing: true, isCoreRunning: false, - logStatusProcessing: false, processingVersion: true, processingFiltering: true, processingClients: true, @@ -197,22 +189,6 @@ const dashboard = handleActions({ autoClients: [], }); -const queryLogs = handleActions({ - [actions.getLogsRequest]: state => ({ ...state, getLogsProcessing: true }), - [actions.getLogsFailure]: state => ({ ...state, getLogsProcessing: false }), - [actions.getLogsSuccess]: (state, { payload }) => { - const newState = { ...state, logs: payload, getLogsProcessing: false }; - return newState; - }, - [actions.downloadQueryLogRequest]: state => ({ ...state, logsDownloading: true }), - [actions.downloadQueryLogFailure]: state => ({ ...state, logsDownloading: false }), - [actions.downloadQueryLogSuccess]: state => ({ ...state, logsDownloading: false }), -}, { - getLogsProcessing: false, - logsDownloading: false, - logs: [], -}); - const filtering = handleActions({ [actions.setRulesRequest]: state => ({ ...state, processingRules: true }), [actions.setRulesFailure]: state => ({ ...state, processingRules: false }), diff --git a/client/src/reducers/queryLogs.js b/client/src/reducers/queryLogs.js new file mode 100644 index 00000000..3dfbadff --- /dev/null +++ b/client/src/reducers/queryLogs.js @@ -0,0 +1,49 @@ +import { handleActions } from 'redux-actions'; + +import * as actions from '../actions/queryLogs'; + +const queryLogs = handleActions( + { + [actions.getLogsRequest]: state => ({ ...state, processingGetLogs: true }), + [actions.getLogsFailure]: state => ({ ...state, processingGetLogs: false }), + [actions.getLogsSuccess]: (state, { payload }) => { + const newState = { ...state, logs: payload, processingGetLogs: false }; + return newState; + }, + + [actions.clearLogsRequest]: state => ({ ...state, processingClear: true }), + [actions.clearLogsFailure]: state => ({ ...state, processingClear: false }), + [actions.clearLogsSuccess]: state => ({ + ...state, + logs: [], + processingClear: false, + }), + + [actions.getLogsConfigRequest]: state => ({ ...state, processingGetConfig: true }), + [actions.getLogsConfigFailure]: state => ({ ...state, processingGetConfig: false }), + [actions.getLogsConfigSuccess]: (state, { payload }) => ({ + ...state, + ...payload, + processingGetConfig: false, + }), + + [actions.setLogsConfigRequest]: state => ({ ...state, processingSetConfig: true }), + [actions.setLogsConfigFailure]: state => ({ ...state, processingSetConfig: false }), + [actions.setLogsConfigSuccess]: (state, { payload }) => ({ + ...state, + ...payload, + processingSetConfig: false, + }), + }, + { + processingGetLogs: true, + processingClear: false, + processingGetConfig: false, + processingSetConfig: false, + logs: [], + interval: 1, + enabled: true, + }, +); + +export default queryLogs; From 0e6c795c5b56433688d4ef2a07715a00bc7b0c27 Mon Sep 17 00:00:00 2001 From: Ildar Kamalov Date: Wed, 4 Sep 2019 18:06:09 +0300 Subject: [PATCH 5/6] - client: fix id and method name --- client/src/components/Logs/index.js | 2 +- client/src/components/Settings/LogsConfig/index.js | 6 +++--- client/src/components/Settings/StatsConfig/index.js | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/src/components/Logs/index.js b/client/src/components/Logs/index.js index 5c1db03c..d9cd3bfa 100644 --- a/client/src/components/Logs/index.js +++ b/client/src/components/Logs/index.js @@ -373,7 +373,7 @@ class Logs extends Component { {enabled && isDataReady && this.renderLogs(queryLogs.logs)} {!enabled && isDataReady && (
- link]}> + link]}> query_log_disabled
diff --git a/client/src/components/Settings/LogsConfig/index.js b/client/src/components/Settings/LogsConfig/index.js index 3c7b0a47..8b519827 100644 --- a/client/src/components/Settings/LogsConfig/index.js +++ b/client/src/components/Settings/LogsConfig/index.js @@ -12,7 +12,7 @@ class LogsConfig extends Component { this.props.setLogsConfig(values); }, DEBOUNCE_TIMEOUT); - handleLogsClear = () => { + handleClear = () => { const { t, clearLogs } = this.props; // eslint-disable-next-line no-alert if (window.confirm(t('query_log_confirm_clear'))) { @@ -29,7 +29,7 @@ class LogsConfig extends Component {
query_log_clear diff --git a/client/src/components/Settings/StatsConfig/index.js b/client/src/components/Settings/StatsConfig/index.js index 0b9775d6..86c23b38 100644 --- a/client/src/components/Settings/StatsConfig/index.js +++ b/client/src/components/Settings/StatsConfig/index.js @@ -12,7 +12,7 @@ class StatsConfig extends Component { this.props.setStatsConfig(values); }, DEBOUNCE_TIMEOUT); - handleStatsReset = () => { + handleReset = () => { const { t, resetStats } = this.props; // eslint-disable-next-line no-alert if (window.confirm(t('statistics_clear_confirm'))) { @@ -40,7 +40,7 @@ class StatsConfig extends Component {