Compare commits

...

8 Commits

Author SHA1 Message Date
83c9ff1856 Merge pull request 'release/v1.1.3' (#24) from release/v1.1.3 into main
All checks were successful
Release Binary / Build & Upload Linux Binary (release) Successful in 2m58s
Reviewed-on: #24
2026-07-05 16:52:21 +00:00
Patrick Asmus
4613f14593 Release: Version v1.1.3 2026-06-10 13:25:19 +02:00
Patrick Asmus
86db4935b8 fix: restore recurring GeoIP checks 2026-06-10 13:22:12 +02:00
0716458426 Merge pull request 'release/v1.1.2' (#23) from release/v1.1.2 into main
All checks were successful
Release Binary / Build & Upload Linux Binary (release) Successful in 3m55s
Reviewed-on: #23
2026-06-04 08:46:26 +00:00
Patrick Asmus
ad7c01e167 Release: Version v1.1.2 2026-06-04 10:43:10 +02:00
Patrick Asmus
95c3922576 feat: IP-Status-Diagnose hinzufügen 2026-06-04 10:30:38 +02:00
5ce4d46cf6 Merge pull request 'Release: Version v1.1.1 mit stabilem Report-Test' (#22) from release/v1.1.1 into main
All checks were successful
Release Binary / Build & Upload Linux Binary (release) Successful in 4m25s
Reviewed-on: #22
2026-05-14 21:11:08 +00:00
Patrick Asmus
482454f01b Release: Version v1.1.1 mit stabilem Report-Test 2026-05-14 23:10:43 +02:00
18 changed files with 491 additions and 28 deletions

View File

@@ -66,7 +66,7 @@ Die benötigten Pakete werden vom Installer auf Ubuntu/Debian automatisch instal
```bash
# Release-Archiv herunterladen und entpacken
curl -fL -o adguard-shield-linux-amd64.tar.gz \
https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.1.0/adguard-shield-linux-amd64.tar.gz
https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.1.3/adguard-shield-linux-amd64.tar.gz
tar -xzf adguard-shield-linux-amd64.tar.gz
chmod +x ./adguard-shield
```
@@ -155,6 +155,7 @@ sudo adguard-shield <befehl>
| Befehl | Beschreibung |
|---|---|
| `status` | Aktive Sperren und Konfigurationsübersicht anzeigen |
| `ip-status <IP>` | 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 |

View File

@@ -92,7 +92,7 @@ ABUSEIPDB_CATEGORIES="4" # 4 = DDoS Attack (siehe abuseipdb.com/categ
GEOIP_ENABLED=false
GEOIP_MODE="blocklist" # blocklist oder allowlist
GEOIP_COUNTRIES="" # ISO 3166-1 Alpha-2 Codes, z.B. "CN,RU,KP,IR"
GEOIP_CHECK_INTERVAL=0 # Legacy: Daemon nutzt den zentralen CHECK_INTERVAL-Poller
GEOIP_CHECK_INTERVAL=0 # 0 = nutzt CHECK_INTERVAL; sonst eigenes GeoIP-Prüfintervall
GEOIP_NOTIFY=true
GEOIP_SKIP_PRIVATE=true # Private IPs ausnehmen
GEOIP_LICENSE_KEY="" # MaxMind GeoLite2 Key (optional, für Auto-Download)

View File

@@ -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 <IP>")
}
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 <IP> 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

BIN
dist/adguard-shield-v1.1.2-linux-amd64 vendored Normal file

Binary file not shown.

BIN
dist/adguard-shield-v1.1.3-linux-amd64 vendored Normal file

Binary file not shown.

View File

@@ -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 <IP>`, 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.
---

View File

@@ -250,13 +250,13 @@ ABUSEIPDB_API_KEY="..."
### Service gestartet
```text
AdGuard Shield v1.1.0 wurde auf dns1 gestartet.
AdGuard Shield v1.1.3 wurde auf dns1 gestartet.
```
### Service gestoppt
```text
AdGuard Shield v1.1.0 wurde auf dns1 gestoppt.
AdGuard Shield v1.1.3 wurde auf dns1 gestoppt.
```
### Rate-Limit-Sperre

View File

@@ -560,7 +560,7 @@ ABUSEIPDB_CATEGORIES="4"
| `GEOIP_ENABLED` | `false` | GeoIP-Filter aktivieren |
| `GEOIP_MODE` | `blocklist` | Filtermodus |
| `GEOIP_COUNTRIES` | leer | Ländercodes nach ISO 3166-1 Alpha-2 |
| `GEOIP_CHECK_INTERVAL` | `0` | Legacy-Parameter (Go-Version nutzt den zentralen Poller) |
| `GEOIP_CHECK_INTERVAL` | `0` | Eigenes GeoIP-Prüfintervall in Sekunden; `0` nutzt `CHECK_INTERVAL`. |
| `GEOIP_NOTIFY` | `true` | Benachrichtigungen bei GeoIP-Sperren senden |
| `GEOIP_SKIP_PRIVATE` | `true` | Private/lokale IPs überspringen |
| `GEOIP_LICENSE_KEY` | leer | MaxMind-License-Key für automatischen Download |

View File

@@ -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 <IP>` | 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 |

View File

@@ -34,7 +34,7 @@ Du brauchst ein fertiges Linux-Binary. Das kann aus einem Release, aus CI oder a
```bash
curl -fL -o adguard-shield-linux-amd64.tar.gz \
https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.1.0/adguard-shield-linux-amd64.tar.gz
https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.1.3/adguard-shield-linux-amd64.tar.gz
tar -xzf adguard-shield-linux-amd64.tar.gz
chmod +x ./adguard-shield
```

View File

@@ -1,5 +1,5 @@
package appinfo
var Version = "v1.1.0"
var Version = "v1.1.3"
const ProjectURL = "https://git.techniverse.net/scriptos/adguard-shield.git"

View File

@@ -40,7 +40,7 @@ type Daemon struct {
mu sync.Mutex
seen map[string]time.Time
events []queryEvent
geoSeen map[string]bool
geoSeen map[string]time.Time
wl map[string]bool
serviceMu sync.Mutex
@@ -93,7 +93,7 @@ func New(c *config.Config) (*Daemon, error) {
d := &Daemon{
Config: c, Store: st, FW: fw, Logger: logger,
Client: &http.Client{Timeout: 20 * time.Second, Transport: tr},
seen: map[string]time.Time{}, geoSeen: map[string]bool{},
seen: map[string]time.Time{}, geoSeen: map[string]time.Time{},
}
d.Geo = geoip.New(c.GeoIPMMDBPath, c.GeoIPLicenseKey, filepath.Join(filepath.Dir(c.Path), "geoip"), c.GeoIPCacheTTL, st)
return d, nil
@@ -409,13 +409,6 @@ func (d *Daemon) checkGeoIP(ctx context.Context, ip string) {
if d.Config.GeoIPSkipPrivate && geoip.IsPrivateIP(ip) {
return
}
d.mu.Lock()
if d.geoSeen[ip] {
d.mu.Unlock()
return
}
d.geoSeen[ip] = true
d.mu.Unlock()
if d.isWhitelisted(ip) {
return
}
@@ -423,6 +416,9 @@ func (d *Daemon) checkGeoIP(ctx context.Context, ip string) {
if exists {
return
}
if !d.shouldCheckGeoIP(ip, time.Now()) {
return
}
cc, err := d.Geo.Lookup(ip)
if err != nil || cc == "" {
return
@@ -432,6 +428,41 @@ func (d *Daemon) checkGeoIP(ctx context.Context, ip string) {
}
}
func (d *Daemon) shouldCheckGeoIP(ip string, now time.Time) bool {
interval := d.geoIPCheckInterval()
d.mu.Lock()
defer d.mu.Unlock()
if last, ok := d.geoSeen[ip]; ok && now.Sub(last) < interval {
return false
}
d.geoSeen[ip] = now
d.pruneGeoSeenLocked(now, interval)
return true
}
func (d *Daemon) geoIPCheckInterval() time.Duration {
seconds := 0
if d.Config != nil {
seconds = d.Config.GeoIPCheckInterval
if seconds <= 0 {
seconds = d.Config.CheckInterval
}
}
if seconds <= 0 {
seconds = 1
}
return time.Duration(seconds) * time.Second
}
func (d *Daemon) pruneGeoSeenLocked(now time.Time, interval time.Duration) {
cutoff := now.Add(-2 * interval)
for ip, last := range d.geoSeen {
if last.Before(cutoff) {
delete(d.geoSeen, ip)
}
}
}
func (d *Daemon) Ban(ctx context.Context, ip, domain string, count int, proto, reason, source, country string, permanent bool) error {
if d.isWhitelisted(ip) {
return nil

View File

@@ -363,3 +363,37 @@ func TestDryRunDoesNotInsertActiveBan(t *testing.T) {
t.Fatal("dry-run must not create an active ban")
}
}
func TestGeoIPCheckGateUsesConfiguredInterval(t *testing.T) {
now := time.Unix(1000, 0)
d := &Daemon{
Config: &config.Config{CheckInterval: 10, GeoIPCheckInterval: 30},
geoSeen: map[string]time.Time{},
}
if !d.shouldCheckGeoIP("203.0.113.7", now) {
t.Fatal("first GeoIP check should be allowed")
}
if d.shouldCheckGeoIP("203.0.113.7", now.Add(29*time.Second)) {
t.Fatal("GeoIP check inside configured interval should be skipped")
}
if !d.shouldCheckGeoIP("203.0.113.7", now.Add(30*time.Second)) {
t.Fatal("GeoIP check after configured interval should be allowed")
}
}
func TestGeoIPCheckGateFallsBackToPollInterval(t *testing.T) {
now := time.Unix(1000, 0)
d := &Daemon{
Config: &config.Config{CheckInterval: 10},
geoSeen: map[string]time.Time{},
}
if !d.shouldCheckGeoIP("203.0.113.7", now) {
t.Fatal("first GeoIP check should be allowed")
}
if d.shouldCheckGeoIP("203.0.113.7", now.Add(9*time.Second)) {
t.Fatal("GeoIP check inside poll interval should be skipped")
}
if !d.shouldCheckGeoIP("203.0.113.7", now.Add(10*time.Second)) {
t.Fatal("GeoIP check after poll interval should be allowed")
}
}

View File

@@ -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()
}

View File

@@ -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)
}
}

View File

@@ -14,6 +14,7 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/oschwald/maxminddb-golang"
@@ -31,6 +32,7 @@ type Resolver struct {
Dir string
TTL int64
Store Store
mu sync.RWMutex
reader *maxminddb.Reader
cache map[string]string
mtime int64
@@ -65,7 +67,9 @@ func (r *Resolver) Open(ctx context.Context) error {
r.mtime = st.ModTime().Unix()
if r.Store != nil {
if c, err := r.Store.LoadGeoIPCache(r.TTL, r.mtime); err == nil {
r.mu.Lock()
r.cache = c
r.mu.Unlock()
}
}
return nil
@@ -79,9 +83,12 @@ func (r *Resolver) Close() error {
}
func (r *Resolver) Lookup(ip string) (string, error) {
r.mu.RLock()
if v, ok := r.cache[ip]; ok {
r.mu.RUnlock()
return v, nil
}
r.mu.RUnlock()
if r.reader == nil {
return r.lookupLegacy(ip)
}
@@ -104,33 +111,44 @@ func (r *Resolver) Lookup(ip string) (string, error) {
if cc == "" {
cc = strings.ToUpper(rec.RegisteredCountry.ISOCode)
}
if cc != "" {
r.cache[ip] = cc
if r.Store != nil {
_ = r.Store.UpsertGeoIP(ip, cc, r.mtime)
}
}
r.storeCache(ip, cc)
return cc, nil
}
func (r *Resolver) lookupLegacy(ip string) (string, error) {
if strings.Contains(ip, ":") {
if cc, err := runGeoIPCommand("geoiplookup6", ip); err == nil && cc != "" {
r.storeCache(ip, cc)
return cc, nil
}
} else {
if cc, err := runGeoIPCommand("geoiplookup", ip); err == nil && cc != "" {
r.storeCache(ip, cc)
return cc, nil
}
}
if r.effectivePath != "" {
if cc, err := runGeoIPCommand("mmdblookup", "--file", r.effectivePath, "--ip", ip, "country", "iso_code"); err == nil && cc != "" {
r.storeCache(ip, cc)
return cc, nil
}
}
return "", fmt.Errorf("no GeoIP result for %s", ip)
}
func (r *Resolver) storeCache(ip, country string) {
country = strings.ToUpper(strings.TrimSpace(country))
if country == "" {
return
}
r.mu.Lock()
r.cache[ip] = country
r.mu.Unlock()
if r.Store != nil {
_ = r.Store.UpsertGeoIP(ip, country, r.mtime)
}
}
func runGeoIPCommand(name string, args ...string) (string, error) {
if _, err := exec.LookPath(name); err != nil {
return "", err

View File

@@ -2,6 +2,23 @@ package geoip
import "testing"
type fakeGeoIPStore struct {
ip string
country string
mtime int64
}
func (s *fakeGeoIPStore) LoadGeoIPCache(ttl, dbMtime int64) (map[string]string, error) {
return map[string]string{}, nil
}
func (s *fakeGeoIPStore) UpsertGeoIP(ip, country string, dbMtime int64) error {
s.ip = ip
s.country = country
s.mtime = dbMtime
return nil
}
func TestShouldBlockModes(t *testing.T) {
countries := []string{"CN", "RU"}
if !ShouldBlock("cn", "blocklist", countries) {
@@ -28,3 +45,18 @@ func TestIsPrivateIP(t *testing.T) {
t.Fatal("8.8.8.8 should be public")
}
}
func TestStoreCachePersistsLegacyLookupResult(t *testing.T) {
store := &fakeGeoIPStore{}
r := New("", "", "", 86400, store)
r.mtime = 123
r.storeCache("203.0.113.7", "cn")
if got := r.cache["203.0.113.7"]; got != "CN" {
t.Fatalf("cache = %q, want CN", got)
}
if store.ip != "203.0.113.7" || store.country != "CN" || store.mtime != 123 {
t.Fatalf("store got ip=%q country=%q mtime=%d", store.ip, store.country, store.mtime)
}
}

View File

@@ -19,7 +19,7 @@ func TestGenerateUsesTemplatesAndFullStats(t *testing.T) {
}
defer store.Close()
yesterday := time.Now().AddDate(0, 0, -1)
yesterday := midnight(time.Now()).AddDate(0, 0, -1).Add(12 * time.Hour)
events := []struct {
ip string
domain string