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:
Patrick Asmus
2026-05-01 00:08:57 +02:00
parent 0d1f7db43b
commit 4f17f7ff81
50 changed files with 8012 additions and 9496 deletions

384
internal/daemon/live.go Normal file
View 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")
}