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

View 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")
}
}