BREAKING CHANGE: Die alte Shell-Version muss vor der Installation der Go-Version deinstalliert werden.
385 lines
10 KiB
Go
385 lines
10 KiB
Go
package daemon
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"adguard-shield/internal/db"
|
|
"adguard-shield/internal/syslog"
|
|
)
|
|
|
|
type LiveOptions struct {
|
|
Interval time.Duration
|
|
Top int
|
|
Recent int
|
|
LogLevel string
|
|
Once bool
|
|
}
|
|
|
|
type liveSnapshot struct {
|
|
At time.Time
|
|
APIEntries int
|
|
Window int
|
|
Limit int
|
|
Events []queryEvent
|
|
TopPairs []liveCount
|
|
SubdomainGroups []liveCount
|
|
ActiveBans []db.Ban
|
|
Offenses int
|
|
ExpiredOffenses int
|
|
WhitelistCount int
|
|
BlocklistBans int
|
|
SystemLogs []string
|
|
}
|
|
|
|
type liveCount struct {
|
|
Client string
|
|
Domain string
|
|
Count int
|
|
Protocol string
|
|
}
|
|
|
|
func (d *Daemon) Live(ctx context.Context, w io.Writer, opts LiveOptions) error {
|
|
if opts.Interval <= 0 {
|
|
opts.Interval = time.Duration(d.Config.CheckInterval) * time.Second
|
|
}
|
|
if opts.Interval <= 0 {
|
|
opts.Interval = 2 * time.Second
|
|
}
|
|
if opts.Top <= 0 {
|
|
opts.Top = 10
|
|
}
|
|
if opts.Recent <= 0 {
|
|
opts.Recent = 12
|
|
}
|
|
if strings.TrimSpace(opts.LogLevel) == "" {
|
|
opts.LogLevel = "INFO"
|
|
}
|
|
|
|
for {
|
|
snap, err := d.liveSnapshot(ctx, opts)
|
|
renderLive(w, d, snap, err, opts)
|
|
if opts.Once {
|
|
return err
|
|
}
|
|
timer := time.NewTimer(opts.Interval)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *Daemon) liveSnapshot(ctx context.Context, opts LiveOptions) (liveSnapshot, error) {
|
|
snap := liveSnapshot{
|
|
At: time.Now(),
|
|
Window: d.Config.RateLimitWindow,
|
|
Limit: d.Config.RateLimitMaxRequests,
|
|
}
|
|
items, err := d.FetchQueryLog(ctx)
|
|
if err != nil {
|
|
return snap, err
|
|
}
|
|
snap.APIEntries = len(items)
|
|
events := dedupeEvents(d.toEvents(items))
|
|
sort.Slice(events, func(i, j int) bool { return events[i].At.After(events[j].At) })
|
|
if len(events) > opts.Recent {
|
|
snap.Events = append([]queryEvent(nil), events[:opts.Recent]...)
|
|
} else {
|
|
snap.Events = append([]queryEvent(nil), events...)
|
|
}
|
|
snap.TopPairs = topQueryPairs(events, d.Config.RateLimitWindow, opts.Top)
|
|
snap.SubdomainGroups = topSubdomainGroups(events, d.Config.SubdomainFloodWindow, opts.Top)
|
|
|
|
if bans, err := d.Store.ActiveBans(); err == nil {
|
|
snap.ActiveBans = bans
|
|
}
|
|
if n, err := d.Store.CountOffenses(); err == nil {
|
|
snap.Offenses = n
|
|
}
|
|
if n, err := d.Store.CountExpiredOffenses(d.Config.ProgressiveBanResetAfter); err == nil {
|
|
snap.ExpiredOffenses = n
|
|
}
|
|
if wl, err := d.Store.AllWhitelist(); err == nil {
|
|
snap.WhitelistCount = len(wl)
|
|
}
|
|
if n, err := d.Store.CountBySource("external-blocklist"); err == nil {
|
|
snap.BlocklistBans = n
|
|
}
|
|
snap.SystemLogs = RecentLogLines(d.Config.LogFile, opts.LogLevel, opts.Recent)
|
|
return snap, nil
|
|
}
|
|
|
|
func dedupeEvents(events []queryEvent) []queryEvent {
|
|
seen := map[string]bool{}
|
|
out := make([]queryEvent, 0, len(events))
|
|
for _, ev := range events {
|
|
key := ev.At.Format(time.RFC3339Nano) + "|" + ev.Client + "|" + ev.Domain + "|" + ev.Protocol
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, ev)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func topQueryPairs(events []queryEvent, window, limit int) []liveCount {
|
|
cut := time.Now().Add(-time.Duration(window) * time.Second)
|
|
counts := map[string]*liveCount{}
|
|
protos := map[string]map[string]bool{}
|
|
for _, ev := range events {
|
|
if ev.At.Before(cut) {
|
|
continue
|
|
}
|
|
key := ev.Client + "|" + ev.Domain
|
|
if counts[key] == nil {
|
|
counts[key] = &liveCount{Client: ev.Client, Domain: ev.Domain}
|
|
protos[key] = map[string]bool{}
|
|
}
|
|
counts[key].Count++
|
|
protos[key][formatProtocol(ev.Protocol)] = true
|
|
}
|
|
out := make([]liveCount, 0, len(counts))
|
|
for key, item := range counts {
|
|
item.Protocol = strings.Join(sortedKeys(protos[key]), ",")
|
|
out = append(out, *item)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Count == out[j].Count {
|
|
return out[i].Client+"|"+out[i].Domain < out[j].Client+"|"+out[j].Domain
|
|
}
|
|
return out[i].Count > out[j].Count
|
|
})
|
|
if limit > 0 && len(out) > limit {
|
|
return out[:limit]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func topSubdomainGroups(events []queryEvent, window, limit int) []liveCount {
|
|
cut := time.Now().Add(-time.Duration(window) * time.Second)
|
|
sets := map[string]map[string]bool{}
|
|
protos := map[string]map[string]bool{}
|
|
for _, ev := range events {
|
|
if ev.At.Before(cut) {
|
|
continue
|
|
}
|
|
base := baseDomain(ev.Domain)
|
|
if base == "" || base == ev.Domain {
|
|
continue
|
|
}
|
|
key := ev.Client + "|" + base
|
|
if sets[key] == nil {
|
|
sets[key] = map[string]bool{}
|
|
protos[key] = map[string]bool{}
|
|
}
|
|
sets[key][ev.Domain] = true
|
|
protos[key][formatProtocol(ev.Protocol)] = true
|
|
}
|
|
out := make([]liveCount, 0, len(sets))
|
|
for key, set := range sets {
|
|
client, domain, _ := strings.Cut(key, "|")
|
|
out = append(out, liveCount{
|
|
Client: client,
|
|
Domain: domain,
|
|
Count: len(set),
|
|
Protocol: strings.Join(sortedKeys(protos[key]), ","),
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Count == out[j].Count {
|
|
return out[i].Client+"|"+out[i].Domain < out[j].Client+"|"+out[j].Domain
|
|
}
|
|
return out[i].Count > out[j].Count
|
|
})
|
|
if limit > 0 && len(out) > limit {
|
|
return out[:limit]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func renderLive(w io.Writer, d *Daemon, snap liveSnapshot, snapErr error, opts LiveOptions) {
|
|
fmt.Fprint(w, "\033[H\033[2J")
|
|
fmt.Fprintf(w, "AdGuard Shield Live | %s | Strg+C beendet\n", snap.At.Format("2006-01-02 15:04:05"))
|
|
fmt.Fprintln(w, strings.Repeat("=", 92))
|
|
fmt.Fprintf(w, "Config: %s | API: %s | Log: %s (ab %s)\n", d.Config.Path, d.Config.AdGuardURL, d.Config.LogFile, strings.ToUpper(opts.LogLevel))
|
|
if snapErr != nil {
|
|
fmt.Fprintf(w, "\nFEHLER: Live-Snapshot konnte nicht geladen werden: %v\n", snapErr)
|
|
return
|
|
}
|
|
|
|
fmt.Fprintf(w, "\nWorker und Module\n")
|
|
fmt.Fprintf(w, " Query-Poller: alle %ds | API-Eintraege: %d | Zeitfenster: %ds | Limit: %d\n", d.Config.CheckInterval, snap.APIEntries, snap.Window, snap.Limit)
|
|
fmt.Fprintf(w, " GeoIP: %s | Modus: %s | Laender: %s\n", enabled(d.Config.GeoIPEnabled), d.Config.GeoIPMode, listOrDash(d.Config.GeoIPCountries))
|
|
fmt.Fprintf(w, " Externe Blocklist: %s | Intervall: %ds | URLs: %d | aktive Sperren: %d\n", enabled(d.Config.ExternalBlocklistEnabled), d.Config.ExternalBlocklistInterval, len(d.Config.ExternalBlocklistURLs), snap.BlocklistBans)
|
|
fmt.Fprintf(w, " Externe Whitelist: %s | Intervall: %ds | URLs: %d | aufgeloeste IPs: %d\n", enabled(d.Config.ExternalWhitelistEnabled), d.Config.ExternalWhitelistInterval, len(d.Config.ExternalWhitelistURLs), snap.WhitelistCount)
|
|
fmt.Fprintf(w, " Offense-Cleanup: %s | Zaehler: %d | davon abgelaufen: %d\n", enabled(d.Config.ProgressiveBanEnabled), snap.Offenses, snap.ExpiredOffenses)
|
|
|
|
fmt.Fprintf(w, "\nTop Client/Domain im Rate-Limit-Fenster\n")
|
|
if len(snap.TopPairs) == 0 {
|
|
fmt.Fprintln(w, " Keine Anfragen im aktuellen Zeitfenster.")
|
|
} else {
|
|
for _, item := range snap.TopPairs {
|
|
fmt.Fprintf(w, " %5s %-39s %-34s %s\n", fmt.Sprintf("%d/%d", item.Count, snap.Limit), trim(item.Client, 39), trim(item.Domain, 34), item.Protocol)
|
|
}
|
|
}
|
|
|
|
if d.Config.SubdomainFloodEnabled {
|
|
fmt.Fprintf(w, "\nSubdomain-Flood-Kandidaten\n")
|
|
if len(snap.SubdomainGroups) == 0 {
|
|
fmt.Fprintln(w, " Keine Subdomain-Gruppen im aktuellen Zeitfenster.")
|
|
} else {
|
|
for _, item := range snap.SubdomainGroups {
|
|
fmt.Fprintf(w, " %5s %-39s %-34s %s\n", fmt.Sprintf("%d/%d", item.Count, d.Config.SubdomainFloodMaxUnique), trim(item.Client, 39), trim(item.Domain, 34), item.Protocol)
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(w, "\nLetzte Queries\n")
|
|
if len(snap.Events) == 0 {
|
|
fmt.Fprintln(w, " Keine Querylog-Eintraege gefunden.")
|
|
} else {
|
|
for _, ev := range snap.Events {
|
|
fmt.Fprintf(w, " %s %-39s %-8s %s\n", ev.At.Local().Format("15:04:05"), trim(ev.Client, 39), formatProtocol(ev.Protocol), trim(ev.Domain, 44))
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(w, "\nAktive Sperren\n")
|
|
if len(snap.ActiveBans) == 0 {
|
|
fmt.Fprintln(w, " Keine aktiven Sperren.")
|
|
} else {
|
|
maxBans := opts.Top
|
|
if len(snap.ActiveBans) < maxBans {
|
|
maxBans = len(snap.ActiveBans)
|
|
}
|
|
for _, b := range snap.ActiveBans[:maxBans] {
|
|
fmt.Fprintf(w, " %-39s %-20s %-18s %s\n", trim(b.IP, 39), trim(b.Source, 20), trim(b.Reason, 18), banUntil(b))
|
|
}
|
|
if len(snap.ActiveBans) > maxBans {
|
|
fmt.Fprintf(w, " ... %d weitere\n", len(snap.ActiveBans)-maxBans)
|
|
}
|
|
}
|
|
|
|
if strings.ToLower(opts.LogLevel) != "off" {
|
|
fmt.Fprintf(w, "\nSystemereignisse\n")
|
|
if len(snap.SystemLogs) == 0 {
|
|
fmt.Fprintln(w, " Keine passenden Logeintraege.")
|
|
} else {
|
|
for _, line := range snap.SystemLogs {
|
|
fmt.Fprintf(w, " %s\n", trim(line, 88))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func RecentLogLines(path, minLevel string, limit int) []string {
|
|
if strings.EqualFold(strings.TrimSpace(minLevel), "off") || path == "" || limit <= 0 {
|
|
return nil
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer f.Close()
|
|
min := syslog.ParseLevel(minLevel, syslog.Info)
|
|
ring := make([]string, limit)
|
|
count := 0
|
|
sc := bufio.NewScanner(f)
|
|
sc.Buffer(make([]byte, 1024), 1024*1024)
|
|
for sc.Scan() {
|
|
line := sc.Text()
|
|
if logLineLevel(line) < min {
|
|
continue
|
|
}
|
|
ring[count%limit] = line
|
|
count++
|
|
}
|
|
n := count
|
|
if n > limit {
|
|
n = limit
|
|
}
|
|
out := make([]string, 0, n)
|
|
start := count - n
|
|
for i := 0; i < n; i++ {
|
|
out = append(out, ring[(start+i)%limit])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func logLineLevel(line string) syslog.Level {
|
|
for _, level := range []syslog.Level{syslog.Error, syslog.Warn, syslog.Info, syslog.Debug} {
|
|
if strings.Contains(line, "["+syslog.LevelName(level)+"]") {
|
|
return level
|
|
}
|
|
}
|
|
return syslog.Info
|
|
}
|
|
|
|
func sortedKeys(m map[string]bool) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
if k != "" {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func formatProtocol(proto string) string {
|
|
switch strings.ToLower(strings.TrimSpace(proto)) {
|
|
case "doh":
|
|
return "DoH"
|
|
case "dot":
|
|
return "DoT"
|
|
case "doq":
|
|
return "DoQ"
|
|
case "dnscrypt":
|
|
return "DNSCrypt"
|
|
case "", "dns":
|
|
return "DNS"
|
|
default:
|
|
return proto
|
|
}
|
|
}
|
|
|
|
func enabled(ok bool) string {
|
|
if ok {
|
|
return "aktiv"
|
|
}
|
|
return "inaktiv"
|
|
}
|
|
|
|
func listOrDash(items []string) string {
|
|
if len(items) == 0 {
|
|
return "-"
|
|
}
|
|
return strings.Join(items, ",")
|
|
}
|
|
|
|
func trim(s string, max int) string {
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
if max <= 1 {
|
|
return s[:max]
|
|
}
|
|
return s[:max-1] + "~"
|
|
}
|
|
|
|
func banUntil(b db.Ban) string {
|
|
if b.Permanent || b.BanUntil == 0 {
|
|
return "permanent"
|
|
}
|
|
return time.Unix(b.BanUntil, 0).Format("2006-01-02 15:04:05")
|
|
}
|