diff --git a/README.md b/README.md index 71156f5..72ef588 100644 --- a/README.md +++ b/README.md @@ -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.2/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 ``` diff --git a/adguard-shield.conf b/adguard-shield.conf index ed4a7a4..a30740b 100644 --- a/adguard-shield.conf +++ b/adguard-shield.conf @@ -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) diff --git a/dist/adguard-shield-v1.1.3-linux-amd64 b/dist/adguard-shield-v1.1.3-linux-amd64 new file mode 100644 index 0000000..9ddf644 Binary files /dev/null and b/dist/adguard-shield-v1.1.3-linux-amd64 differ diff --git a/docs/benachrichtigungen.md b/docs/benachrichtigungen.md index 6f3bcde..9ea2b4f 100644 --- a/docs/benachrichtigungen.md +++ b/docs/benachrichtigungen.md @@ -250,13 +250,13 @@ ABUSEIPDB_API_KEY="..." ### Service gestartet ```text -AdGuard Shield v1.1.2 wurde auf dns1 gestartet. +AdGuard Shield v1.1.3 wurde auf dns1 gestartet. ``` ### Service gestoppt ```text -AdGuard Shield v1.1.2 wurde auf dns1 gestoppt. +AdGuard Shield v1.1.3 wurde auf dns1 gestoppt. ``` ### Rate-Limit-Sperre diff --git a/docs/konfiguration.md b/docs/konfiguration.md index c739a6d..db6a323 100644 --- a/docs/konfiguration.md +++ b/docs/konfiguration.md @@ -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 | diff --git a/docs/update.md b/docs/update.md index 1d44a4c..4a2ea85 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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.2/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 ``` diff --git a/internal/appinfo/appinfo.go b/internal/appinfo/appinfo.go index 996fe37..28ea761 100644 --- a/internal/appinfo/appinfo.go +++ b/internal/appinfo/appinfo.go @@ -1,5 +1,5 @@ package appinfo -var Version = "v1.1.2" +var Version = "v1.1.3" const ProjectURL = "https://git.techniverse.net/scriptos/adguard-shield.git" diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index c705da6..6283b14 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -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 diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 94b4b39..aee4179 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -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") + } +} diff --git a/internal/geoip/geoip.go b/internal/geoip/geoip.go index 6a1e1f3..69eb11f 100644 --- a/internal/geoip/geoip.go +++ b/internal/geoip/geoip.go @@ -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 diff --git a/internal/geoip/geoip_test.go b/internal/geoip/geoip_test.go index 8ae40b2..8559d32 100644 --- a/internal/geoip/geoip_test.go +++ b/internal/geoip/geoip_test.go @@ -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) + } +}