*(querylog): added offset/limit parameters
Actually, this is a serious refactoring of the query log module. The rest API part is refactored, it's now more clear how the search is conducted. Split the logic into more files and added more tests. Closes: https://github.com/AdguardTeam/AdGuardHome/issues/1559
This commit is contained in:
267
querylog/qlog.go
267
querylog/qlog.go
@@ -1,11 +1,8 @@
|
||||
package querylog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -17,10 +14,6 @@ import (
|
||||
|
||||
const (
|
||||
queryLogFileName = "querylog.json" // .gz added during compression
|
||||
getDataLimit = 500 // GetData(): maximum log entries to return
|
||||
|
||||
// maximum entries to parse when searching
|
||||
maxSearchEntries = 50000
|
||||
)
|
||||
|
||||
// queryLog is a structure that writes and reads the DNS query log
|
||||
@@ -36,6 +29,23 @@ type queryLog struct {
|
||||
fileWriteLock sync.Mutex
|
||||
}
|
||||
|
||||
// logEntry - represents a single log entry
|
||||
type logEntry struct {
|
||||
IP string `json:"IP"` // Client IP
|
||||
Time time.Time `json:"T"`
|
||||
|
||||
QHost string `json:"QH"`
|
||||
QType string `json:"QT"`
|
||||
QClass string `json:"QC"`
|
||||
|
||||
Answer []byte `json:",omitempty"` // sometimes empty answers happen like binerdunt.top or rev2.globalrootservers.net
|
||||
OrigAnswer []byte `json:",omitempty"`
|
||||
|
||||
Result dnsfilter.Result
|
||||
Elapsed time.Duration
|
||||
Upstream string `json:",omitempty"` // if empty, means it was cached
|
||||
}
|
||||
|
||||
// create a new instance of the query log
|
||||
func newQueryLog(conf Config) *queryLog {
|
||||
l := queryLog{}
|
||||
@@ -93,22 +103,6 @@ func (l *queryLog) clear() {
|
||||
log.Debug("Query log: cleared")
|
||||
}
|
||||
|
||||
type logEntry struct {
|
||||
IP string `json:"IP"`
|
||||
Time time.Time `json:"T"`
|
||||
|
||||
QHost string `json:"QH"`
|
||||
QType string `json:"QT"`
|
||||
QClass string `json:"QC"`
|
||||
|
||||
Answer []byte `json:",omitempty"` // sometimes empty answers happen like binerdunt.top or rev2.globalrootservers.net
|
||||
OrigAnswer []byte `json:",omitempty"`
|
||||
|
||||
Result dnsfilter.Result
|
||||
Elapsed time.Duration
|
||||
Upstream string `json:",omitempty"` // if empty, means it was cached
|
||||
}
|
||||
|
||||
func (l *queryLog) Add(params AddParams) {
|
||||
if !l.conf.Enabled {
|
||||
return
|
||||
@@ -173,230 +167,3 @@ func (l *queryLog) Add(params AddParams) {
|
||||
go l.flushLogBuffer(false) // nolint
|
||||
}
|
||||
}
|
||||
|
||||
// Parameters for getData()
|
||||
type getDataParams struct {
|
||||
OlderThan time.Time // return entries that are older than this value
|
||||
Domain string // filter by domain name in question
|
||||
Client string // filter by client IP
|
||||
QuestionType string // filter by question type
|
||||
ResponseStatus responseStatusType // filter by response status
|
||||
StrictMatchDomain bool // if Domain value must be matched strictly
|
||||
StrictMatchClient bool // if Client value must be matched strictly
|
||||
}
|
||||
|
||||
// Response status
|
||||
type responseStatusType int32
|
||||
|
||||
// Response status constants
|
||||
const (
|
||||
responseStatusAll responseStatusType = iota + 1
|
||||
responseStatusFiltered
|
||||
)
|
||||
|
||||
// Gets log entries
|
||||
func (l *queryLog) getData(params getDataParams) map[string]interface{} {
|
||||
now := time.Now()
|
||||
|
||||
if len(params.Client) != 0 && l.conf.AnonymizeClientIP {
|
||||
params.Client = l.getClientIP(params.Client)
|
||||
}
|
||||
|
||||
// add from file
|
||||
fileEntries, oldest, total := l.searchFiles(params)
|
||||
|
||||
if params.OlderThan.IsZero() {
|
||||
// In case if the timer is not precise (for instance, on Windows)
|
||||
// We really want to get all records including those added just before the call
|
||||
params.OlderThan = now.Add(time.Millisecond)
|
||||
}
|
||||
|
||||
// add from memory buffer
|
||||
l.bufferLock.Lock()
|
||||
total += len(l.buffer)
|
||||
memoryEntries := make([]*logEntry, 0)
|
||||
|
||||
// go through the buffer in the reverse order
|
||||
// from NEWER to OLDER
|
||||
for i := len(l.buffer) - 1; i >= 0; i-- {
|
||||
entry := l.buffer[i]
|
||||
|
||||
if entry.Time.UnixNano() >= params.OlderThan.UnixNano() {
|
||||
// Ignore entries newer than what was requested
|
||||
continue
|
||||
}
|
||||
|
||||
if !matchesGetDataParams(entry, params) {
|
||||
continue
|
||||
}
|
||||
|
||||
memoryEntries = append(memoryEntries, entry)
|
||||
}
|
||||
l.bufferLock.Unlock()
|
||||
|
||||
// now let's get a unified collection
|
||||
entries := append(memoryEntries, fileEntries...)
|
||||
if len(entries) > getDataLimit {
|
||||
// remove extra records
|
||||
entries = entries[:getDataLimit]
|
||||
}
|
||||
if len(entries) == getDataLimit {
|
||||
// change the "oldest" value here.
|
||||
// we cannot use the "oldest" we got from "searchFiles" anymore
|
||||
// because after adding in-memory records and removing extra records
|
||||
// the situation has changed
|
||||
oldest = entries[len(entries)-1].Time
|
||||
}
|
||||
|
||||
// init the response object
|
||||
var data = []map[string]interface{}{}
|
||||
|
||||
// the elements order is already reversed (from newer to older)
|
||||
for i := 0; i < len(entries); i++ {
|
||||
entry := entries[i]
|
||||
jsonEntry := l.logEntryToJSONEntry(entry)
|
||||
data = append(data, jsonEntry)
|
||||
}
|
||||
|
||||
log.Debug("QueryLog: prepared data (%d/%d) older than %s in %s",
|
||||
len(entries), total, params.OlderThan, time.Since(now))
|
||||
|
||||
var result = map[string]interface{}{}
|
||||
result["oldest"] = ""
|
||||
if !oldest.IsZero() {
|
||||
result["oldest"] = oldest.Format(time.RFC3339Nano)
|
||||
}
|
||||
result["data"] = data
|
||||
return result
|
||||
}
|
||||
|
||||
// Get Client IP address
|
||||
func (l *queryLog) getClientIP(clientIP string) string {
|
||||
if l.conf.AnonymizeClientIP {
|
||||
ip := net.ParseIP(clientIP)
|
||||
if ip != nil {
|
||||
ip4 := ip.To4()
|
||||
const AnonymizeClientIP4Mask = 24
|
||||
const AnonymizeClientIP6Mask = 112
|
||||
if ip4 != nil {
|
||||
clientIP = ip4.Mask(net.CIDRMask(AnonymizeClientIP4Mask, 32)).String()
|
||||
} else {
|
||||
clientIP = ip.Mask(net.CIDRMask(AnonymizeClientIP6Mask, 128)).String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clientIP
|
||||
}
|
||||
|
||||
func (l *queryLog) logEntryToJSONEntry(entry *logEntry) map[string]interface{} {
|
||||
var msg *dns.Msg
|
||||
|
||||
if len(entry.Answer) > 0 {
|
||||
msg = new(dns.Msg)
|
||||
if err := msg.Unpack(entry.Answer); err != nil {
|
||||
log.Debug("Failed to unpack dns message answer: %s: %s", err, string(entry.Answer))
|
||||
msg = nil
|
||||
}
|
||||
}
|
||||
|
||||
jsonEntry := map[string]interface{}{
|
||||
"reason": entry.Result.Reason.String(),
|
||||
"elapsedMs": strconv.FormatFloat(entry.Elapsed.Seconds()*1000, 'f', -1, 64),
|
||||
"time": entry.Time.Format(time.RFC3339Nano),
|
||||
"client": l.getClientIP(entry.IP),
|
||||
}
|
||||
jsonEntry["question"] = map[string]interface{}{
|
||||
"host": entry.QHost,
|
||||
"type": entry.QType,
|
||||
"class": entry.QClass,
|
||||
}
|
||||
|
||||
if msg != nil {
|
||||
jsonEntry["status"] = dns.RcodeToString[msg.Rcode]
|
||||
|
||||
opt := msg.IsEdns0()
|
||||
dnssecOk := false
|
||||
if opt != nil {
|
||||
dnssecOk = opt.Do()
|
||||
}
|
||||
jsonEntry["answer_dnssec"] = dnssecOk
|
||||
}
|
||||
|
||||
if len(entry.Result.Rule) > 0 {
|
||||
jsonEntry["rule"] = entry.Result.Rule
|
||||
jsonEntry["filterId"] = entry.Result.FilterID
|
||||
}
|
||||
|
||||
if len(entry.Result.ServiceName) != 0 {
|
||||
jsonEntry["service_name"] = entry.Result.ServiceName
|
||||
}
|
||||
|
||||
answers := answerToMap(msg)
|
||||
if answers != nil {
|
||||
jsonEntry["answer"] = answers
|
||||
}
|
||||
|
||||
if len(entry.OrigAnswer) != 0 {
|
||||
a := new(dns.Msg)
|
||||
err := a.Unpack(entry.OrigAnswer)
|
||||
if err == nil {
|
||||
answers = answerToMap(a)
|
||||
if answers != nil {
|
||||
jsonEntry["original_answer"] = answers
|
||||
}
|
||||
} else {
|
||||
log.Debug("Querylog: msg.Unpack(entry.OrigAnswer): %s: %s", err, string(entry.OrigAnswer))
|
||||
}
|
||||
}
|
||||
|
||||
return jsonEntry
|
||||
}
|
||||
|
||||
func answerToMap(a *dns.Msg) []map[string]interface{} {
|
||||
if a == nil || len(a.Answer) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var answers = []map[string]interface{}{}
|
||||
for _, k := range a.Answer {
|
||||
header := k.Header()
|
||||
answer := map[string]interface{}{
|
||||
"type": dns.TypeToString[header.Rrtype],
|
||||
"ttl": header.Ttl,
|
||||
}
|
||||
// try most common record types
|
||||
switch v := k.(type) {
|
||||
case *dns.A:
|
||||
answer["value"] = v.A.String()
|
||||
case *dns.AAAA:
|
||||
answer["value"] = v.AAAA.String()
|
||||
case *dns.MX:
|
||||
answer["value"] = fmt.Sprintf("%v %v", v.Preference, v.Mx)
|
||||
case *dns.CNAME:
|
||||
answer["value"] = v.Target
|
||||
case *dns.NS:
|
||||
answer["value"] = v.Ns
|
||||
case *dns.SPF:
|
||||
answer["value"] = v.Txt
|
||||
case *dns.TXT:
|
||||
answer["value"] = v.Txt
|
||||
case *dns.PTR:
|
||||
answer["value"] = v.Ptr
|
||||
case *dns.SOA:
|
||||
answer["value"] = fmt.Sprintf("%v %v %v %v %v %v %v", v.Ns, v.Mbox, v.Serial, v.Refresh, v.Retry, v.Expire, v.Minttl)
|
||||
case *dns.CAA:
|
||||
answer["value"] = fmt.Sprintf("%v %v \"%v\"", v.Flag, v.Tag, v.Value)
|
||||
case *dns.HINFO:
|
||||
answer["value"] = fmt.Sprintf("\"%v\" \"%v\"", v.Cpu, v.Os)
|
||||
case *dns.RRSIG:
|
||||
answer["value"] = fmt.Sprintf("%v %v %v %v %v %v %v %v %v", dns.TypeToString[v.TypeCovered], v.Algorithm, v.Labels, v.OrigTtl, v.Expiration, v.Inception, v.KeyTag, v.SignerName, v.Signature)
|
||||
default:
|
||||
// type unknown, marshall it as-is
|
||||
answer["value"] = v
|
||||
}
|
||||
answers = append(answers, answer)
|
||||
}
|
||||
|
||||
return answers
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user