diff --git a/README.md b/README.md index 3459186..c83e481 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ sudo adguard-shield | Befehl | Beschreibung | |---|---| | `status` | Aktive Sperren und Konfigurationsübersicht anzeigen | +| `ip-status ` | Status einer einzelnen IP anzeigen | | `live` / `watch` | Terminal-Live-Ansicht mit Queries, Top-Clients, Sperren und Logs | | `live --interval 2` | Live-Ansicht mit benutzerdefiniertem Aktualisierungsintervall | | `live --top 20` | Live-Ansicht mit mehr Top-Einträgen | diff --git a/cmd/adguard-shieldd/main.go b/cmd/adguard-shieldd/main.go index bff71dc..efc8e41 100644 --- a/cmd/adguard-shieldd/main.go +++ b/cmd/adguard-shieldd/main.go @@ -5,6 +5,7 @@ import ( "errors" "flag" "fmt" + "net/netip" "os" "os/exec" "os/signal" @@ -90,6 +91,11 @@ func run() error { fmt.Printf("Verbindung erfolgreich. %d Querylog-Einträge gefunden.\n", len(items)) case "status": return status(d) + case "ip-status": + if len(args) < 1 { + return fmt.Errorf("Nutzung: adguard-shield ip-status ") + } + return ipStatus(d, args[0]) case "live", "watch": return liveCommand(ctx, d, args) case "logs": @@ -269,6 +275,146 @@ func run() error { return nil } +func ipStatus(d *daemon.Daemon, rawIP string) error { + ip := strings.TrimSpace(rawIP) + if _, err := netip.ParseAddr(ip); err != nil { + return fmt.Errorf("ungültige IP %q", rawIP) + } + + fmt.Println("IP Status") + fmt.Printf("IP: %s\n", ip) + + printIPBanStatus(d, ip) + printIPWhitelistStatus(d, ip) + printIPOffenseStatus(d, ip) + printIPGeoIPCacheStatus(d, ip) + return printIPHistory(d, ip, 5) +} + +func printIPBanStatus(d *daemon.Daemon, ip string) { + b, ok, err := d.Store.BanByIP(ip) + if err != nil { + fmt.Printf("Sperre: Fehler (%v)\n", err) + return + } + if !ok { + fmt.Println("Sperre: nein") + return + } + fmt.Println("Sperre: aktiv") + fmt.Printf(" Quelle: %s\n", empty(b.Source, "unbekannt")) + fmt.Printf(" Grund: %s\n", empty(b.Reason, "unbekannt")) + if b.Domain != "" && b.Domain != "-" { + fmt.Printf(" Domain: %s\n", b.Domain) + } + if b.Count > 0 { + fmt.Printf(" Anzahl: %d\n", b.Count) + } + if b.Protocol != "" && b.Protocol != "-" { + fmt.Printf(" Protokoll: %s\n", b.Protocol) + } + fmt.Printf(" Dauer: %s\n", ipStatusDuration(b.Permanent, b.Duration)) + if !b.Permanent && b.BanUntil > 0 { + until := time.Unix(b.BanUntil, 0) + state := until.Format("2006-01-02 15:04:05") + if time.Now().Unix() >= b.BanUntil { + state += " (abgelaufen, Cleanup ausstehend)" + } + fmt.Printf(" Ablauf: %s\n", state) + } + if b.OffenseLevel > 0 { + fmt.Printf(" Offense-Stufe bei Sperre: %d\n", b.OffenseLevel) + } + if b.GeoIPCountry != "" { + fmt.Printf(" GeoIP: %s (%s)\n", b.GeoIPCountry, empty(b.GeoIPMode, "unbekannt")) + } +} + +func printIPWhitelistStatus(d *daemon.Daemon, ip string) { + var hits []string + for _, entry := range d.Config.Whitelist { + if strings.TrimSpace(entry) == ip { + hits = append(hits, "statisch") + break + } + } + wl, ok, err := d.Store.WhitelistByIP(ip) + if err != nil { + fmt.Printf("Whitelist: Fehler (%v)\n", err) + return + } + if ok { + source := empty(wl.Source, "extern") + if wl.ResolvedAt != "" { + source += ", aufgeloest " + wl.ResolvedAt + } + hits = append(hits, source) + } + if len(hits) == 0 { + fmt.Println("Whitelist: nein") + return + } + fmt.Printf("Whitelist: ja (%s)\n", strings.Join(hits, "; ")) +} + +func printIPOffenseStatus(d *daemon.Daemon, ip string) { + o, ok, err := d.Store.OffenseByIP(ip) + if err != nil { + fmt.Printf("Offense-Zaehler: Fehler (%v)\n", err) + return + } + if !ok { + fmt.Println("Offense-Zaehler: keiner") + return + } + state := "aktiv" + if o.LastEpoch > 0 && time.Now().Unix()-o.LastEpoch > d.Config.ProgressiveBanResetAfter { + state = "abgelaufen" + } + fmt.Printf("Offense-Zaehler: Stufe %d (%s)\n", o.Level, state) + if o.First != "" { + fmt.Printf(" Erster Treffer: %s\n", o.First) + } + if o.Last != "" { + fmt.Printf(" Letzter Treffer: %s\n", o.Last) + } + if d.Config.ProgressiveBanResetAfter > 0 { + fmt.Printf(" Reset nach: %ds\n", d.Config.ProgressiveBanResetAfter) + } +} + +func printIPGeoIPCacheStatus(d *daemon.Daemon, ip string) { + cache, ok, err := d.Store.GeoIPCacheByIP(ip) + if err != nil { + fmt.Printf("GeoIP-Cache: Fehler (%v)\n", err) + return + } + if !ok { + fmt.Println("GeoIP-Cache: kein Eintrag") + return + } + fmt.Printf("GeoIP-Cache: %s\n", empty(cache.CountryCode, "unbekannt")) + if cache.LookedUpAtEpoch > 0 { + fmt.Printf(" Nachgeschlagen: %s\n", time.Unix(cache.LookedUpAtEpoch, 0).Format("2006-01-02 15:04:05")) + } +} + +func printIPHistory(d *daemon.Daemon, ip string, limit int) error { + lines, err := d.Store.RecentHistoryByIP(ip, limit) + if err != nil { + return err + } + if len(lines) == 0 { + fmt.Println("History: keine Eintraege") + return nil + } + fmt.Printf("History: letzte %d Eintraege\n", len(lines)) + for _, l := range lines { + fmt.Printf(" %s\n", l) + } + return nil +} + func status(d *daemon.Daemon) error { bans, err := d.Store.ActiveBans() if err != nil { @@ -290,11 +436,18 @@ func status(d *daemon.Daemon) error { fmt.Printf(" %s | %s | %s | %s\n", b.IP, b.Source, b.Reason, until) } if len(bans) > limit { - fmt.Printf(" ... %d weitere Sperren. Details mit: adguard-shield history oder direkt in SQLite.\n", len(bans)-limit) + fmt.Printf(" ... %d weitere Sperren. Details mit: adguard-shield ip-status oder history.\n", len(bans)-limit) } return nil } +func ipStatusDuration(permanent bool, seconds int64) string { + if permanent || seconds == 0 { + return "permanent" + } + return strconv.FormatInt(seconds, 10) + "s" +} + func blocklistStatus(d *daemon.Daemon) error { count, err := d.Store.CountBySource("external-blocklist") if err != nil { @@ -472,7 +625,7 @@ Nutzung: adguard-shield uninstall [--keep-config] adguard-shield install-status adguard-shield [-config PATH] run|start|stop|dry-run - adguard-shield status|history [N]|test|flush|ban IP|unban IP|reset-offenses [IP] + adguard-shield status|ip-status IP|history [N]|test|flush|ban IP|unban IP|reset-offenses [IP] adguard-shield live [--interval N] [--top N] [--recent N] [--logs LEVEL] [--once] adguard-shield logs [--level LEVEL] [--limit N]|logs-follow [--level LEVEL] adguard-shield offense-status|offense-cleanup diff --git a/dist/adguard-shield-v1.1.2-linux-amd64 b/dist/adguard-shield-v1.1.2-linux-amd64 new file mode 100644 index 0000000..fed17ce Binary files /dev/null and b/dist/adguard-shield-v1.1.2-linux-amd64 differ diff --git a/docs/befehle.md b/docs/befehle.md index d5a511b..5b86485 100644 --- a/docs/befehle.md +++ b/docs/befehle.md @@ -58,6 +58,7 @@ sudo systemctl status adguard-shield # Diagnose und Monitoring sudo adguard-shield test sudo adguard-shield status +sudo adguard-shield ip-status 192.168.1.100 sudo adguard-shield live sudo adguard-shield history 100 sudo adguard-shield logs --level warn --limit 100 @@ -360,7 +361,26 @@ Zeigt eine Übersicht des aktuellen Zustands: - Externe Whitelist (aktiv/inaktiv, Anzahl URLs) - Aktive Sperren mit IP, Quelle, Grund und Ablaufzeit -Bei sehr vielen aktiven Sperren werden nur die ersten 50 angezeigt. Für Details nutze `history` oder frage SQLite direkt ab. +Bei sehr vielen aktiven Sperren werden nur die ersten 50 angezeigt. Für Details zu einer konkreten Adresse nutze `ip-status `, für Ereignisse `history`. + +--- + +## IP-Status + +```bash +sudo adguard-shield ip-status 192.168.1.100 +``` + +Zeigt den Status einer einzelnen IP-Adresse: + +- ob eine aktive Sperre existiert +- Quelle, Grund, Domain, Protokoll, Dauer und Ablaufzeit der Sperre +- statische oder externe Whitelist-Treffer +- Offense-Zähler für progressive Sperren +- GeoIP-Cache-Eintrag +- letzte History-Einträge für diese IP + +Der Befehl ist besonders hilfreich, wenn eine IP nicht in der gekürzten `status`-Übersicht auftaucht oder du prüfen möchtest, warum ein Client gesperrt, nicht gesperrt oder nicht erneut eskaliert wurde. --- diff --git a/docs/tipps-und-troubleshooting.md b/docs/tipps-und-troubleshooting.md index 6e09613..14daa9d 100644 --- a/docs/tipps-und-troubleshooting.md +++ b/docs/tipps-und-troubleshooting.md @@ -4,7 +4,7 @@ Dieses Dokument hilft beim Eingrenzen typischer Probleme im Betrieb. Die Reihenf ## Erste Diagnose -Diese fünf Befehle liefern meistens schon genug Hinweise, um ein Problem einzugrenzen: +Diese Befehle liefern meistens schon genug Hinweise, um ein Problem einzugrenzen: ```bash # 1. Läuft der Service? @@ -19,7 +19,10 @@ sudo adguard-shield test # 4. Was ist der aktuelle Zustand? sudo adguard-shield status -# 5. Gibt es Warnungen oder Fehler? +# 5. Was ist zu einer konkreten IP bekannt? +sudo adguard-shield ip-status 192.168.1.100 + +# 6. Gibt es Warnungen oder Fehler? sudo adguard-shield logs --level warn --limit 100 ``` @@ -132,6 +135,7 @@ sudo adguard-shield logs --level debug --limit 100 ```bash sudo adguard-shield status +sudo adguard-shield ip-status 192.168.1.100 sudo adguard-shield history 100 ``` @@ -536,6 +540,7 @@ Ohne `--keep-config` werden Installationsverzeichnis, State-Verzeichnis und Logd | `journalctl -u adguard-shield -n 100` | Systemd-Journal ansehen | | `test` | API-Verbindung prüfen | | `status` | Aktuellen Zustand und aktive Sperren anzeigen | +| `ip-status ` | Einzelne IP auf Sperre, Whitelist, Offenses, GeoIP und History prüfen | | `live` | Echtzeit-Ansicht mit Queries, Sperren und Logs | | `history 100` | Ban-History anzeigen | | `logs --level warn --limit 100` | Warnungen und Fehler anzeigen | diff --git a/internal/appinfo/appinfo.go b/internal/appinfo/appinfo.go index 0d9d75d..996fe37 100644 --- a/internal/appinfo/appinfo.go +++ b/internal/appinfo/appinfo.go @@ -1,5 +1,5 @@ package appinfo -var Version = "v1.1.1" +var Version = "v1.1.2" const ProjectURL = "https://git.techniverse.net/scriptos/adguard-shield.git" diff --git a/internal/db/db.go b/internal/db/db.go index 8ea12de..8afc971 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -25,6 +25,27 @@ type Ban struct { GeoIPMode string } +type Offense struct { + IP string + Level int + LastEpoch int64 + Last string + First string +} + +type WhitelistEntry struct { + IP string + Source string + ResolvedAt string +} + +type GeoIPCacheEntry struct { + IP string + CountryCode string + LookedUpAtEpoch int64 + DBMtime int64 +} + type ReportStats struct { Since int64 Until int64 @@ -120,6 +141,22 @@ func (s *Store) BanExists(ip string) (bool, error) { return err == nil, err } +func (s *Store) BanByIP(ip string) (Ban, bool, error) { + row := s.DB.QueryRow(`SELECT client_ip, COALESCE(domain,''), COALESCE(count,0), COALESCE(ban_until_epoch,0), +COALESCE(ban_duration,0), COALESCE(offense_level,0), COALESCE(is_permanent,0), COALESCE(reason,''), COALESCE(protocol,''), +COALESCE(source,''), COALESCE(geoip_country,''), COALESCE(geoip_mode,'') FROM active_bans WHERE client_ip=? LIMIT 1`, ip) + var b Ban + var perm int + if err := row.Scan(&b.IP, &b.Domain, &b.Count, &b.BanUntil, &b.Duration, &b.OffenseLevel, &perm, &b.Reason, &b.Protocol, &b.Source, &b.GeoIPCountry, &b.GeoIPMode); err != nil { + if err == sql.ErrNoRows { + return Ban{}, false, nil + } + return Ban{}, false, err + } + b.Permanent = perm == 1 + return b, true, nil +} + func (s *Store) InsertBan(b Ban) error { now := time.Now() perm := 0 @@ -259,6 +296,18 @@ func (s *Store) WhitelistContains(ip string) (bool, error) { return err == nil, err } +func (s *Store) WhitelistByIP(ip string) (WhitelistEntry, bool, error) { + var e WhitelistEntry + err := s.DB.QueryRow(`SELECT ip_address, COALESCE(source,''), COALESCE(resolved_at,'') FROM whitelist_cache WHERE ip_address=? LIMIT 1`, ip).Scan(&e.IP, &e.Source, &e.ResolvedAt) + if err == sql.ErrNoRows { + return WhitelistEntry{}, false, nil + } + if err != nil { + return WhitelistEntry{}, false, err + } + return e, true, nil +} + func (s *Store) ReplaceWhitelist(ips []string, source string) error { tx, err := s.DB.Begin() if err != nil { @@ -348,6 +397,19 @@ func (s *Store) CountExpiredOffenses(resetAfter int64) (int, error) { return count, err } +func (s *Store) OffenseByIP(ip string) (Offense, bool, error) { + var o Offense + err := s.DB.QueryRow(`SELECT client_ip, COALESCE(offense_level,0), COALESCE(last_offense_epoch,0), COALESCE(last_offense,''), COALESCE(first_offense,'') +FROM offense_tracking WHERE client_ip=? LIMIT 1`, ip).Scan(&o.IP, &o.Level, &o.LastEpoch, &o.Last, &o.First) + if err == sql.ErrNoRows { + return Offense{}, false, nil + } + if err != nil { + return Offense{}, false, err + } + return o, true, nil +} + func (s *Store) LoadGeoIPCache(ttl, dbMtime int64) (map[string]string, error) { rows, err := s.DB.Query(`SELECT ip, country_code FROM geoip_cache WHERE looked_up_at_epoch >= ? AND (db_mtime=? OR db_mtime=0)`, time.Now().Unix()-ttl, dbMtime) if err != nil { @@ -365,6 +427,18 @@ func (s *Store) LoadGeoIPCache(ttl, dbMtime int64) (map[string]string, error) { return out, rows.Err() } +func (s *Store) GeoIPCacheByIP(ip string) (GeoIPCacheEntry, bool, error) { + var e GeoIPCacheEntry + err := s.DB.QueryRow(`SELECT ip, country_code, COALESCE(looked_up_at_epoch,0), COALESCE(db_mtime,0) FROM geoip_cache WHERE ip=? LIMIT 1`, ip).Scan(&e.IP, &e.CountryCode, &e.LookedUpAtEpoch, &e.DBMtime) + if err == sql.ErrNoRows { + return GeoIPCacheEntry{}, false, nil + } + if err != nil { + return GeoIPCacheEntry{}, false, err + } + return e, true, nil +} + func (s *Store) UpsertGeoIP(ip, country string, dbMtime int64) error { _, err := s.DB.Exec(`INSERT OR REPLACE INTO geoip_cache (ip, country_code, looked_up_at_epoch, db_mtime) VALUES (?, ?, ?, ?)`, ip, country, time.Now().Unix(), dbMtime) return err @@ -479,3 +553,21 @@ FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? ORDER BY } return out, rows.Err() } + +func (s *Store) RecentHistoryByIP(ip string, limit int) ([]string, error) { + rows, err := s.DB.Query(`SELECT timestamp_text, action, client_ip, COALESCE(domain,''), COALESCE(count,''), COALESCE(duration,''), COALESCE(protocol,''), COALESCE(reason,'') +FROM ban_history WHERE client_ip=? ORDER BY id DESC LIMIT ?`, ip, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var ts, action, clientIP, domain, count, duration, proto, reason string + if err := rows.Scan(&ts, &action, &clientIP, &domain, &count, &duration, &proto, &reason); err != nil { + return nil, err + } + out = append(out, fmt.Sprintf("%s | %s | %s | %s | %s | %s | %s | %s", ts, action, clientIP, domain, count, duration, proto, reason)) + } + return out, rows.Err() +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go index d808a62..5e120a1 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -29,3 +29,80 @@ func TestStoreBanAndGeoIPCache(t *testing.T) { t.Fatalf("unexpected cache: %#v", cache) } } + +func TestStoreIPStatusQueries(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + ban := Ban{ + IP: "192.0.2.10", + Domain: "example.com", + Count: 42, + BanUntil: 1234567890, + Duration: 600, + OffenseLevel: 2, + Reason: "rate-limit", + Protocol: "dns", + Source: "monitor", + } + if err := s.InsertBan(ban); err != nil { + t.Fatal(err) + } + gotBan, ok, err := s.BanByIP("192.0.2.10") + if err != nil { + t.Fatal(err) + } + if !ok || gotBan.IP != ban.IP || gotBan.Count != ban.Count || gotBan.Reason != ban.Reason { + t.Fatalf("unexpected ban lookup: %#v found=%v", gotBan, ok) + } + if _, ok, err := s.BanByIP("192.0.2.11"); err != nil || ok { + t.Fatalf("unexpected missing ban lookup: found=%v err=%v", ok, err) + } + + if _, err := s.DB.Exec(`INSERT INTO whitelist_cache (ip_address, source) VALUES (?, ?)`, "192.0.2.10", "external"); err != nil { + t.Fatal(err) + } + wl, ok, err := s.WhitelistByIP("192.0.2.10") + if err != nil { + t.Fatal(err) + } + if !ok || wl.Source != "external" { + t.Fatalf("unexpected whitelist lookup: %#v found=%v", wl, ok) + } + + if _, err := s.IncrementOffense("192.0.2.10", 86400); err != nil { + t.Fatal(err) + } + offense, ok, err := s.OffenseByIP("192.0.2.10") + if err != nil { + t.Fatal(err) + } + if !ok || offense.Level != 1 || offense.LastEpoch == 0 { + t.Fatalf("unexpected offense lookup: %#v found=%v", offense, ok) + } + + if err := s.UpsertGeoIP("192.0.2.10", "DE", 456); err != nil { + t.Fatal(err) + } + geo, ok, err := s.GeoIPCacheByIP("192.0.2.10") + if err != nil { + t.Fatal(err) + } + if !ok || geo.CountryCode != "DE" || geo.DBMtime != 456 { + t.Fatalf("unexpected geo lookup: %#v found=%v", geo, ok) + } + + if err := s.History("BAN", "192.0.2.10", "example.com", "42", "600s", "dns", "rate-limit"); err != nil { + t.Fatal(err) + } + history, err := s.RecentHistoryByIP("192.0.2.10", 5) + if err != nil { + t.Fatal(err) + } + if len(history) != 1 { + t.Fatalf("unexpected history: %#v", history) + } +}