feat!: Migration auf Go-Binary
BREAKING CHANGE: Die alte Shell-Version muss vor der Installation der Go-Version deinstalliert werden.
This commit is contained in:
1221
internal/daemon/daemon.go
Normal file
1221
internal/daemon/daemon.go
Normal file
File diff suppressed because it is too large
Load Diff
365
internal/daemon/daemon_test.go
Normal file
365
internal/daemon/daemon_test.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"adguard-shield/internal/config"
|
||||
"adguard-shield/internal/db"
|
||||
"adguard-shield/internal/firewall"
|
||||
)
|
||||
|
||||
func TestParseListEntry(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"1.2.3.4 # comment": "1.2.3.4",
|
||||
"0.0.0.0 bad.example": "bad.example",
|
||||
"2001:db8::/32": "2001:db8::/32",
|
||||
}
|
||||
for input, want := range cases {
|
||||
got := parseListEntry(input)
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("%q -> %#v, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
if got := parseListEntry("http://example.invalid/list"); got != nil {
|
||||
t.Fatalf("URL should be rejected: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationFormatting(t *testing.T) {
|
||||
d := &Daemon{Config: &config.Config{
|
||||
RateLimitWindow: 60,
|
||||
SubdomainFloodWindow: 120,
|
||||
ProgressiveBanMaxLevel: 3,
|
||||
}}
|
||||
b := db.Ban{
|
||||
IP: "203.0.113.7",
|
||||
Domain: "abb.com",
|
||||
Count: 110,
|
||||
Duration: 3600,
|
||||
OffenseLevel: 1,
|
||||
Reason: "rate-limit",
|
||||
Protocol: "dns",
|
||||
Source: "monitor",
|
||||
}
|
||||
if got, want := d.displayBanReason(b), "110x abb.com in 60s via DNS, Rate-Limit"; got != want {
|
||||
t.Fatalf("reason = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := d.displayBanDuration(b), "1h 0m [Stufe 1/3]"; got != want {
|
||||
t.Fatalf("duration = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
b.Permanent = true
|
||||
b.Duration = 0
|
||||
b.OffenseLevel = 3
|
||||
if got, want := d.displayBanDuration(b), "PERMANENT [Stufe 3/3]"; got != want {
|
||||
t.Fatalf("permanent duration = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNTFYNotificationTitleDoesNotDuplicateShieldTag(t *testing.T) {
|
||||
d := &Daemon{Config: &config.Config{
|
||||
NotifyType: "ntfy",
|
||||
NTFYServerURL: "https://ntfy.example",
|
||||
NTFYTopic: "adguard-shield",
|
||||
NTFYPriority: "4",
|
||||
}}
|
||||
req, err := d.notificationRequest(context.Background(), "🛡️ AdGuard Shield", "test", db.Ban{IP: "203.0.113.7", Reason: "rate-limit", Source: "monitor"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req == nil {
|
||||
t.Fatal("request must be created")
|
||||
}
|
||||
if got, want := req.Header.Get("Title"), "🛡️ AdGuard Shield"; got != want {
|
||||
t.Fatalf("title = %q, want %q", got, want)
|
||||
}
|
||||
if got := req.Header.Get("Tags"); strings.Contains(got, "shield") {
|
||||
t.Fatalf("tags must not duplicate title shield emoji: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationRequestsForWebhookProviders(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
notifyType string
|
||||
wantType string
|
||||
wantPayload []string
|
||||
}{
|
||||
{
|
||||
name: "discord",
|
||||
notifyType: "discord",
|
||||
wantType: "application/json",
|
||||
wantPayload: []string{`"content":"title\n\nmessage"`},
|
||||
},
|
||||
{
|
||||
name: "slack",
|
||||
notifyType: "slack",
|
||||
wantType: "application/json",
|
||||
wantPayload: []string{`"text":"title\n\nmessage"`},
|
||||
},
|
||||
{
|
||||
name: "generic",
|
||||
notifyType: "generic",
|
||||
wantType: "application/json",
|
||||
wantPayload: []string{`"action":"unban"`, `"client":"203.0.113.7"`, `"message":"message"`},
|
||||
},
|
||||
{
|
||||
name: "gotify",
|
||||
notifyType: "gotify",
|
||||
wantType: "application/x-www-form-urlencoded",
|
||||
wantPayload: []string{`title=title`, `message=message`, `priority=5`},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d := &Daemon{Config: &config.Config{
|
||||
NotifyType: tc.notifyType,
|
||||
NotifyWebhook: "https://hooks.example/notify",
|
||||
}}
|
||||
req, err := d.notificationRequest(context.Background(), "title", "message", db.Ban{IP: "203.0.113.7", Reason: "manual"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req == nil {
|
||||
t.Fatal("request must be created")
|
||||
}
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", req.Method)
|
||||
}
|
||||
if got := req.Header.Get("Content-Type"); got != tc.wantType {
|
||||
t.Fatalf("content type = %q, want %q", got, tc.wantType)
|
||||
}
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := string(body)
|
||||
for _, want := range tc.wantPayload {
|
||||
if !strings.Contains(payload, want) {
|
||||
t.Fatalf("payload %q does not contain %q", payload, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceNotificationsSendStartAndStopOnce(t *testing.T) {
|
||||
requests := make(chan string, 4)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
requests <- string(body)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := &Daemon{
|
||||
Config: &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: srv.URL},
|
||||
Client: srv.Client(),
|
||||
}
|
||||
d.NotifyServiceStart(context.Background())
|
||||
d.NotifyServiceStart(context.Background())
|
||||
d.NotifyServiceStop(context.Background())
|
||||
d.NotifyServiceStop(context.Background())
|
||||
|
||||
var payloads []string
|
||||
for len(payloads) < 2 {
|
||||
select {
|
||||
case payload := <-requests:
|
||||
payloads = append(payloads, payload)
|
||||
case <-time.After(4 * time.Second):
|
||||
t.Fatalf("service notifications sent %d payloads, want 2", len(payloads))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(payloads[0], `"action":"service_start"`) || !strings.Contains(payloads[0], "gestartet") {
|
||||
t.Fatalf("unexpected service start payload: %s", payloads[0])
|
||||
}
|
||||
if !strings.Contains(payloads[1], `"action":"service_stop"`) || !strings.Contains(payloads[1], "gestoppt") {
|
||||
t.Fatalf("unexpected service stop payload: %s", payloads[1])
|
||||
}
|
||||
select {
|
||||
case payload := <-requests:
|
||||
t.Fatalf("duplicate service notification sent: %s", payload)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbanSendsNotificationForMonitorBan(t *testing.T) {
|
||||
requests := make(chan string, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
requests <- string(body)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.InsertBan(db.Ban{IP: "127.0.0.1", Reason: "rate-limit", Source: "monitor"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &Daemon{
|
||||
Config: &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: srv.URL},
|
||||
Store: store,
|
||||
FW: firewall.New(firewall.OSExecutor{}, "ADGUARD_SHIELD", []string{"53"}, "host", true),
|
||||
Client: srv.Client(),
|
||||
}
|
||||
if err := d.Unban(context.Background(), "127.0.0.1", "manual"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case payload := <-requests:
|
||||
if !strings.Contains(payload, `"action":"unban"`) || !strings.Contains(payload, "AdGuard Shield Freigabe") {
|
||||
t.Fatalf("unexpected payload: %s", payload)
|
||||
}
|
||||
case <-time.After(4 * time.Second):
|
||||
t.Fatal("unban notification was not sent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbanStillSendsExternalBlocklistNotificationWhenBanNotificationsDisabled(t *testing.T) {
|
||||
requests := make(chan string, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
requests <- string(body)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.InsertBan(db.Ban{IP: "127.0.0.1", Reason: "external-blocklist", Source: "external-blocklist"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &Daemon{
|
||||
Config: &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: srv.URL, ExternalBlocklistNotify: false},
|
||||
Store: store,
|
||||
FW: firewall.New(firewall.OSExecutor{}, "ADGUARD_SHIELD", []string{"53"}, "host", true),
|
||||
Client: srv.Client(),
|
||||
}
|
||||
if err := d.Unban(context.Background(), "127.0.0.1", "external-blocklist-removed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case payload := <-requests:
|
||||
if !strings.Contains(payload, `"action":"unban"`) || !strings.Contains(payload, "AdGuard Shield Freigabe") {
|
||||
t.Fatalf("unexpected payload: %s", payload)
|
||||
}
|
||||
case <-time.After(4 * time.Second):
|
||||
t.Fatal("external blocklist unban notification was not sent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbanQuietSkipsIndividualNotificationAndBulkSummarySendsOnce(t *testing.T) {
|
||||
requests := make(chan string, 2)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
requests <- string(body)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.InsertBan(db.Ban{IP: "127.0.0.1", Reason: "rate-limit", Source: "monitor"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &Daemon{
|
||||
Config: &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: srv.URL},
|
||||
Store: store,
|
||||
FW: firewall.New(firewall.OSExecutor{}, "ADGUARD_SHIELD", []string{"53"}, "host", true),
|
||||
Client: srv.Client(),
|
||||
}
|
||||
if err := d.UnbanQuiet(context.Background(), "127.0.0.1", "manual-flush"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case payload := <-requests:
|
||||
t.Fatalf("quiet unban sent individual notification: %s", payload)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
|
||||
d.NotifyBulkUnban(context.Background(), "manual-flush", 1)
|
||||
select {
|
||||
case payload := <-requests:
|
||||
if !strings.Contains(payload, `"action":"manual-flush"`) || !strings.Contains(payload, "Bulk-Freigabe") || !strings.Contains(payload, "Freigegebene IPs: 1") {
|
||||
t.Fatalf("unexpected payload: %s", payload)
|
||||
}
|
||||
case <-time.After(4 * time.Second):
|
||||
t.Fatal("bulk unban notification was not sent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbuseReportingScope(t *testing.T) {
|
||||
d := &Daemon{Config: &config.Config{AbuseIPDBEnabled: true, AbuseIPDBAPIKey: "key"}}
|
||||
if !d.shouldReportAbuseIPDB(db.Ban{Permanent: true, Source: "monitor"}) {
|
||||
t.Fatal("monitor permanent ban should be reported")
|
||||
}
|
||||
if d.shouldReportAbuseIPDB(db.Ban{Permanent: true, Source: "geoip"}) {
|
||||
t.Fatal("geoip ban must not be reported")
|
||||
}
|
||||
if d.shouldReportAbuseIPDB(db.Ban{Permanent: false, Source: "monitor"}) {
|
||||
t.Fatal("temporary ban must not be reported")
|
||||
}
|
||||
|
||||
d.Config.RateLimitWindow = 60
|
||||
got := d.abuseIPDBComment(db.Ban{Count: 110, Domain: "abb.com", Reason: "rate-limit"})
|
||||
want := "DNS flooding on our DNS server: 110x abb.com in 60s. Banned by Adguard Shield 🔗 https://tnvs.de/as"
|
||||
if got != want {
|
||||
t.Fatalf("comment = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbuseIPDBCheckURL(t *testing.T) {
|
||||
if got := abuseIPDBCheckURL("65.185.189.75"); !strings.Contains(got, "https://www.abuseipdb.com/check/65.185.189.75") {
|
||||
t.Fatalf("unexpected AbuseIPDB url: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseDomain(t *testing.T) {
|
||||
if got := baseDomain("a.b.example.com"); got != "example.com" {
|
||||
t.Fatalf("unexpected base domain: %s", got)
|
||||
}
|
||||
if got := baseDomain("a.b.example.co.uk"); got != "example.co.uk" {
|
||||
t.Fatalf("unexpected multipart base domain: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunDoesNotInsertActiveBan(t *testing.T) {
|
||||
store, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
d := &Daemon{
|
||||
Config: &config.Config{DryRun: true, BanDuration: 60},
|
||||
Store: store,
|
||||
FW: firewall.New(firewall.OSExecutor{}, "ADGUARD_SHIELD", []string{"53"}, "host", true),
|
||||
wl: map[string]bool{},
|
||||
}
|
||||
if err := d.Ban(context.Background(), "1.2.3.4", "example.com", 99, "dns", "rate-limit", "monitor", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, err := store.BanExists("1.2.3.4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("dry-run must not create an active ban")
|
||||
}
|
||||
}
|
||||
384
internal/daemon/live.go
Normal file
384
internal/daemon/live.go
Normal file
@@ -0,0 +1,384 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user