feat: Mailtemplates und Reportversand wiederherstellen

This commit is contained in:
Patrick Asmus
2026-05-03 13:26:53 +02:00
parent 7bbdb104c4
commit de521f73ed
8 changed files with 970 additions and 131 deletions

View File

@@ -380,7 +380,7 @@ Details zu allen Kanälen stehen in [Benachrichtigungen](benachrichtigungen.md).
| `REPORT_EMAIL_FROM` | `adguard-shield@example.com` | Absenderadresse |
| `REPORT_FORMAT` | `html` | Report-Format |
| `REPORT_MAIL_CMD` | `msmtp` | Mailprogramm für den Versand |
| `REPORT_BUSIEST_DAY_RANGE` | `30` | Zeitraum für "Aktivster Tag" (Kompatibilitätsparameter) |
| `REPORT_BUSIEST_DAY_RANGE` | `30` | Zeitraum für "Aktivster Tag"; `0` nutzt den Berichtszeitraum |
### Verfügbare Intervalle
@@ -388,7 +388,7 @@ Details zu allen Kanälen stehen in [Benachrichtigungen](benachrichtigungen.md).
|---|---|
| `daily` | Täglich zur konfigurierten Uhrzeit |
| `weekly` | Montags zur konfigurierten Uhrzeit |
| `biweekly` | Am 1. und 15. des Monats |
| `biweekly` | Montags in ungeraden ISO-Kalenderwochen |
| `monthly` | Am 1. des Monats |
### Verfügbare Formate
@@ -418,6 +418,8 @@ sudo /opt/adguard-shield/adguard-shield report-install
Details stehen in [E-Mail Report](report.md).
Die Templates werden bei `install`/`update` nach `/opt/adguard-shield/templates` geschrieben. Eigene Templates können alternativ über `ADGUARD_SHIELD_TEMPLATE_DIR` bereitgestellt werden.
---
## Externe Whitelist

View File

@@ -17,11 +17,26 @@ Der Report basiert auf der SQLite-Datenbank:
| Zeitraum | Start- und Enddatum des Berichtszeitraums |
| Sperren | Anzahl der Sperren im Zeitraum |
| Freigaben | Anzahl der Freigaben im Zeitraum |
| Zeitraum-Schnellübersicht | Heute, gestern, letzte 7/14/30 Tage |
| Aktive Sperren | Derzeit aktive Sperren zum Zeitpunkt der Report-Erstellung |
| Permanente Sperren | Anzahl permanenter Sperren im Zeitraum |
| AbuseIPDB | Anzahl erfolgreicher AbuseIPDB-Meldungen im Zeitraum |
| Angriffsarten | Rate-Limit, Subdomain-Flood und externe Blocklist |
| Top-Clients | Die am häufigsten gesperrten Client-IPs |
| Sperrgründe | Aufschlüsselung nach Grund (Rate-Limit, Subdomain-Flood, GeoIP usw.) |
| Sperrquellen | Aufschlüsselung nach Quelle (Monitor, GeoIP, Blocklist, manuell) |
| Letzte Ereignisse | Die letzten 20 Einträge aus der Ban-History |
| Top-Domains | Die am häufigsten betroffenen Domains |
| Protokolle | Verteilung nach DNS, DoH, DoT, DoQ usw. |
| Letzte Sperren | Die letzten 10 Sperren im Berichtszeitraum |
### Templates
Die Standard-Templates liegen im Code unter:
```text
internal/report/templates/report.html
internal/report/templates/report.txt
```
Sie werden ins Binary eingebettet und bei `install` sowie `update` nach `/opt/adguard-shield/templates` geschrieben. Zur Laufzeit verwendet AdGuard Shield zuerst externe Templates aus `ADGUARD_SHIELD_TEMPLATE_DIR`, danach `templates` neben der Konfiguration bzw. neben dem Binary und fällt erst zuletzt auf die eingebetteten Standard-Templates zurück.
---
@@ -36,7 +51,7 @@ Der Report basiert auf der SQLite-Datenbank:
| `REPORT_EMAIL_FROM` | `adguard-shield@example.com` | Absenderadresse |
| `REPORT_FORMAT` | `html` | Report-Format (`html` oder `txt`) |
| `REPORT_MAIL_CMD` | `msmtp` | Mailprogramm für den Versand |
| `REPORT_BUSIEST_DAY_RANGE` | `30` | Kompatibilitätsparameter für den Zeitraum "Aktivster Tag" |
| `REPORT_BUSIEST_DAY_RANGE` | `30` | Zeitraum für "Aktivster Tag"; `0` nutzt den Berichtszeitraum |
### Versandintervalle
@@ -44,7 +59,7 @@ Der Report basiert auf der SQLite-Datenbank:
|---|---|
| `daily` | Täglich zur Uhrzeit aus `REPORT_TIME` |
| `weekly` | Montags zur Uhrzeit aus `REPORT_TIME` |
| `biweekly` | Am 1. und 15. des Monats zur Uhrzeit aus `REPORT_TIME` |
| `biweekly` | Montags in ungeraden ISO-Kalenderwochen zur Uhrzeit aus `REPORT_TIME` |
| `monthly` | Am 1. des Monats zur Uhrzeit aus `REPORT_TIME` |
### Formate
@@ -181,7 +196,7 @@ Der Cron-Eintrag ruft das installierte Binary mit der installierten Konfiguratio
|---|---|
| `daily` | Täglich zur Uhrzeit aus `REPORT_TIME` |
| `weekly` | Montags zur Uhrzeit aus `REPORT_TIME` |
| `biweekly` | Am 1. und 15. des Monats |
| `biweekly` | Montags in ungeraden ISO-Kalenderwochen |
| `monthly` | Am 1. des Monats |
### Cron-Job entfernen

View File

@@ -26,15 +26,26 @@ type Ban struct {
}
type ReportStats struct {
Since int64
Until int64
TotalBans int
TotalUnbans int
ActiveBans int
TopClients []ReportCount
Reasons []ReportCount
Sources []ReportCount
RecentEvents []string
Since int64
Until int64
TotalBans int
TotalUnbans int
UniqueIPs int
PermanentBans int
ActiveBans int
AbuseIPDBReports int
RateLimitBans int
SubdomainFloodBans int
ExternalBlocklistBans int
BusiestDay string
BusiestDayCount int
TopClients []ReportCount
TopDomains []ReportCount
Protocols []ReportCount
Reasons []ReportCount
Sources []ReportCount
RecentBans []ReportEvent
RecentEvents []string
}
type ReportCount struct {
@@ -42,6 +53,17 @@ type ReportCount struct {
Count int
}
type ReportEvent struct {
Timestamp string
Action string
IP string
Domain string
Count string
Duration string
Protocol string
Reason string
}
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
@@ -356,7 +378,7 @@ func (s *Store) ClearGeoIPCache() (int64, error) {
return res.RowsAffected()
}
func (s *Store) ReportStats(since, until int64, limit int) (ReportStats, error) {
func (s *Store) ReportStats(since, until, busiestSince int64, limit int) (ReportStats, error) {
st := ReportStats{Since: since, Until: until}
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ?`, since, until).Scan(&st.TotalBans); err != nil {
return st, err
@@ -364,14 +386,43 @@ func (s *Store) ReportStats(since, until int64, limit int) (ReportStats, error)
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='UNBAN' AND timestamp_epoch BETWEEN ? AND ?`, since, until).Scan(&st.TotalUnbans); err != nil {
return st, err
}
if err := s.DB.QueryRow(`SELECT COUNT(DISTINCT client_ip) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ?`, since, until).Scan(&st.UniqueIPs); err != nil {
return st, err
}
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? AND LOWER(COALESCE(duration,'')) LIKE '%permanent%'`, since, until).Scan(&st.PermanentBans); err != nil {
return st, err
}
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM active_bans`).Scan(&st.ActiveBans); err != nil {
return st, err
}
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? AND LOWER(COALESCE(reason,'')) LIKE '%rate%limit%'`, since, until).Scan(&st.RateLimitBans); err != nil {
return st, err
}
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? AND LOWER(COALESCE(reason,'')) LIKE '%subdomain%flood%'`, since, until).Scan(&st.SubdomainFloodBans); err != nil {
return st, err
}
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? AND LOWER(COALESCE(reason,'')) LIKE '%external%blocklist%'`, since, until).Scan(&st.ExternalBlocklistBans); err != nil {
return st, err
}
if busiestSince <= 0 {
busiestSince = since
}
if err := s.DB.QueryRow(`SELECT SUBSTR(timestamp_text, 1, 10), COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? GROUP BY SUBSTR(timestamp_text, 1, 10) ORDER BY COUNT(*) DESC, SUBSTR(timestamp_text, 1, 10) DESC LIMIT 1`, busiestSince, until).Scan(&st.BusiestDay, &st.BusiestDayCount); err != nil && err != sql.ErrNoRows {
return st, err
}
var err error
st.TopClients, err = s.reportCounts(`SELECT client_ip, COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? GROUP BY client_ip ORDER BY COUNT(*) DESC, client_ip LIMIT ?`, since, until, limit)
if err != nil {
return st, err
}
st.TopDomains, err = s.reportCounts(`SELECT domain, COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? AND COALESCE(domain,'') NOT IN ('', '-') GROUP BY domain ORDER BY COUNT(*) DESC, domain LIMIT ?`, since, until, limit)
if err != nil {
return st, err
}
st.Protocols, err = s.reportCounts(`SELECT COALESCE(NULLIF(protocol,''), 'unbekannt'), COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? GROUP BY COALESCE(NULLIF(protocol,''), 'unbekannt') ORDER BY COUNT(*) DESC LIMIT ?`, since, until, limit)
if err != nil {
return st, err
}
st.Reasons, err = s.reportCounts(`SELECT COALESCE(NULLIF(reason,''), 'unknown'), COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? GROUP BY COALESCE(NULLIF(reason,''), 'unknown') ORDER BY COUNT(*) DESC LIMIT ?`, since, until, limit)
if err != nil {
return st, err
@@ -380,6 +431,10 @@ func (s *Store) ReportStats(since, until int64, limit int) (ReportStats, error)
if err != nil {
return st, err
}
st.RecentBans, err = s.reportRecentBans(since, until, 10)
if err != nil {
return st, err
}
st.RecentEvents, err = s.RecentHistory(limit)
return st, err
}
@@ -406,3 +461,21 @@ func (s *Store) reportCounts(query string, since, until int64, limit int) ([]Rep
}
return out, rows.Err()
}
func (s *Store) reportRecentBans(since, until int64, limit int) ([]ReportEvent, 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 action='BAN' AND timestamp_epoch BETWEEN ? AND ? ORDER BY id DESC LIMIT ?`, since, until, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ReportEvent
for rows.Next() {
var e ReportEvent
if err := rows.Scan(&e.Timestamp, &e.Action, &e.IP, &e.Domain, &e.Count, &e.Duration, &e.Protocol, &e.Reason); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}

View File

@@ -12,6 +12,8 @@ import (
"runtime"
"sort"
"strings"
"adguard-shield/internal/report"
)
const (
@@ -59,23 +61,23 @@ func Install(opts Options) error {
opts = normalize(opts)
fmt.Println("AdGuard Shield Go-Installation")
fmt.Printf("Installationspfad: %s\n", opts.InstallDir)
fmt.Println("1/8 Pruefe Betriebssystem und root-Rechte ...")
fmt.Println("1/9 Pruefe Betriebssystem und root-Rechte ...")
if err := requireLinuxRoot(); err != nil {
return err
}
fmt.Println("2/8 Pruefe auf scriptbasierte Altinstallation ...")
fmt.Println("2/9 Pruefe auf scriptbasierte Altinstallation ...")
if findings := DetectLegacy(opts.InstallDir); len(findings) > 0 {
return &LegacyError{Findings: findings}
}
if !opts.SkipDeps {
fmt.Println("3/8 Pruefe System-Abhaengigkeiten ...")
fmt.Println("3/9 Pruefe System-Abhaengigkeiten ...")
if err := ensureDependencies(); err != nil {
return err
}
} else {
fmt.Println("3/8 System-Abhaengigkeiten uebersprungen (--skip-deps)")
fmt.Println("3/9 System-Abhaengigkeiten uebersprungen (--skip-deps)")
}
fmt.Println("4/8 Erstelle Verzeichnisse ...")
fmt.Println("4/9 Erstelle Verzeichnisse ...")
if err := os.MkdirAll(opts.InstallDir, 0755); err != nil {
return err
}
@@ -85,19 +87,23 @@ func Install(opts Options) error {
if err := os.MkdirAll(filepath.Join(opts.InstallDir, "geoip"), 0755); err != nil {
return err
}
fmt.Println("5/8 Installiere Binary ...")
fmt.Println("5/9 Installiere Binary ...")
if err := copySelf(filepath.Join(opts.InstallDir, "adguard-shield")); err != nil {
return err
}
fmt.Println("6/8 Installiere oder migriere Konfiguration ...")
fmt.Println("6/9 Installiere Report-Templates ...")
if err := report.InstallTemplates(filepath.Join(opts.InstallDir, "templates")); err != nil {
return err
}
fmt.Println("7/9 Installiere oder migriere Konfiguration ...")
if err := ensureConfig(opts); err != nil {
return err
}
fmt.Println("7/8 Schreibe systemd-Service ...")
fmt.Println("8/9 Schreibe systemd-Service ...")
if err := writeService(opts.InstallDir); err != nil {
return err
}
fmt.Println("8/8 Aktualisiere systemd ...")
fmt.Println("9/9 Aktualisiere systemd ...")
_ = run("systemctl", "daemon-reload")
if opts.Enable {
fmt.Println("Aktiviere Autostart ...")

View File

@@ -1,10 +1,15 @@
package report
import (
"bytes"
"bufio"
"context"
"embed"
"encoding/json"
"fmt"
"html"
"io/fs"
"mime"
"net/http"
"os"
"os/exec"
"path/filepath"
@@ -12,12 +17,16 @@ import (
"strings"
"time"
"adguard-shield/internal/appinfo"
"adguard-shield/internal/config"
"adguard-shield/internal/db"
)
//go:embed templates/report.html templates/report.txt
var embeddedTemplates embed.FS
type Store interface {
ReportStats(since, until int64, limit int) (db.ReportStats, error)
ReportStats(since, until, busiestSince int64, limit int) (db.ReportStats, error)
}
const cronPath = "/etc/cron.d/adguard-shield-report"
@@ -27,50 +36,90 @@ func Status(c *config.Config) string {
if _, err := os.Stat(cronPath); err == nil {
cron = "installiert (" + cronPath + ")"
}
return fmt.Sprintf(`E-Mail Report
Aktiv: %v
Intervall: %s
Zeit: %s
Empfaenger: %s
Absender: %s
Format: %s
Mail-Befehl: %s
Cron: %s
`, c.ReportEnabled, c.ReportInterval, c.ReportTime, c.ReportEmailTo, c.ReportEmailFrom, c.ReportFormat, c.ReportMailCmd, cron)
mailer := "nicht gefunden"
if parts := strings.Fields(c.ReportMailCmd); len(parts) > 0 {
if p, err := exec.LookPath(parts[0]); err == nil {
mailer = "gefunden (" + p + ")"
}
}
return fmt.Sprintf(`AdGuard Shield - Report Status
Report aktiviert: %v
Intervall: %s
Uhrzeit: %s
Format: %s
Empfaenger: %s
Absender: %s
Mail-Befehl: %s
Mail-Befehl Status: %s
Aktivster Tag: letzte %d Tage
Cron: %s
`, c.ReportEnabled, c.ReportInterval, c.ReportTime, c.ReportFormat, empty(c.ReportEmailTo, "nicht konfiguriert"), c.ReportEmailFrom, c.ReportMailCmd, mailer, c.ReportBusiestDayRange, cron)
}
func InstallTemplates(dir string) error {
if dir == "" {
return fmt.Errorf("Template-Zielverzeichnis ist leer")
}
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
for _, name := range []string{"report.html", "report.txt"} {
data, err := embeddedTemplates.ReadFile("templates/" + name)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, name), data, 0644); err != nil {
return err
}
}
return nil
}
func Generate(c *config.Config, st Store, format string) (string, error) {
if format == "" {
format = c.ReportFormat
}
since, until := window(c.ReportInterval)
stats, err := st.ReportStats(since, until, 20)
format = normalizeFormat(format, c.ReportFormat)
since, until, period := reportWindow(c.ReportInterval, time.Now())
busiestSince := busiestWindowStart(c, since)
stats, err := st.ReportStats(since, until, busiestSince, 20)
if err != nil {
return "", err
}
if strings.EqualFold(format, "html") {
return renderHTML(c, stats), nil
stats.AbuseIPDBReports = countAbuseReports(c.LogFile, since, until)
tpl, err := loadTemplate(c, format)
if err != nil {
return "", err
}
return renderText(c, stats), nil
values, err := templateValues(c, st, stats, period, format)
if err != nil {
return "", err
}
for key, value := range values {
tpl = strings.ReplaceAll(tpl, "{{"+key+"}}", value)
}
return tpl, nil
}
func Send(ctx context.Context, c *config.Config, st Store) error {
body, err := Generate(c, st, c.ReportFormat)
format := normalizeFormat(c.ReportFormat, "html")
body, err := Generate(c, st, format)
if err != nil {
return err
}
return sendMail(ctx, c, "AdGuard Shield Report", body)
_, _, period := reportWindow(c.ReportInterval, time.Now())
return sendMail(ctx, c, "AdGuard Shield "+period+" - "+hostname(), body, format, "AdGuard Shield Report Generator")
}
func SendTest(ctx context.Context, c *config.Config) error {
body := fmt.Sprintf("AdGuard Shield Test-Mail\n\nHostname: %s\nZeitpunkt: %s\nEmpfaenger: %s\nAbsender: %s\n", hostname(), time.Now().Format("2006-01-02 15:04:05"), c.ReportEmailTo, c.ReportEmailFrom)
if strings.EqualFold(c.ReportFormat, "html") {
body = "<!doctype html><html><body><h1>AdGuard Shield Test-Mail</h1><p>Hostname: " + html.EscapeString(hostname()) + "</p><p>Zeitpunkt: " + html.EscapeString(time.Now().Format("2006-01-02 15:04:05")) + "</p></body></html>"
}
return sendMail(ctx, c, "AdGuard Shield Test-Mail", body)
format := normalizeFormat(c.ReportFormat, "html")
body := testBody(c, format)
return sendMail(ctx, c, "AdGuard Shield Test-Mail - "+hostname(), body, format, "AdGuard Shield Report Generator (Test)")
}
func InstallCron(binary, configPath string, c *config.Config) error {
if !c.ReportEnabled {
return fmt.Errorf("Report ist deaktiviert (REPORT_ENABLED=false)")
}
minute, hour, err := parseReportTime(c.ReportTime)
if err != nil {
return err
@@ -82,7 +131,18 @@ func InstallCron(binary, configPath string, c *config.Config) error {
if configPath == "" {
configPath = "/opt/adguard-shield/adguard-shield.conf"
}
line := fmt.Sprintf("SHELL=/bin/sh\nPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n%s root %s -config %s report-send\n", schedule, binary, configPath)
command := shellQuote(binary) + " -config " + shellQuote(configPath) + " report-send"
if strings.EqualFold(c.ReportInterval, "biweekly") {
command = "[ $(( $(date +\\%V) \\% 2 )) -eq 1 ] && " + command
}
line := fmt.Sprintf(`# AdGuard Shield - Automatischer Report
# Intervall: %s
# Uhrzeit: %s
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
%s root %s >> %s 2>&1
`, c.ReportInterval, c.ReportTime, schedule, command, shellQuote(c.LogFile))
return os.WriteFile(cronPath, []byte(line), 0644)
}
@@ -93,25 +153,32 @@ func RemoveCron() error {
return nil
}
func sendMail(ctx context.Context, c *config.Config, subject, body string) error {
if c.ReportEmailTo == "" {
func sendMail(ctx context.Context, c *config.Config, subject, body, format, xMailer string) error {
if strings.TrimSpace(c.ReportEmailTo) == "" {
return fmt.Errorf("REPORT_EMAIL_TO ist leer")
}
if c.ReportMailCmd == "" {
return fmt.Errorf("REPORT_MAIL_CMD ist leer")
}
contentType := "text/plain; charset=utf-8"
if strings.EqualFold(c.ReportFormat, "html") {
contentType = "text/html; charset=utf-8"
}
msg := "From: " + c.ReportEmailFrom + "\n" +
"To: " + c.ReportEmailTo + "\n" +
"Subject: " + subject + "\n" +
"Content-Type: " + contentType + "\n\n" + body
parts := strings.Fields(c.ReportMailCmd)
if len(parts) == 0 {
return fmt.Errorf("REPORT_MAIL_CMD ist leer")
}
if _, err := exec.LookPath(parts[0]); err != nil {
return fmt.Errorf("Mail-Befehl nicht gefunden: %s", parts[0])
}
contentType := "text/plain; charset=UTF-8"
if strings.EqualFold(format, "html") {
contentType = "text/html; charset=UTF-8"
}
msg := strings.Join([]string{
"From: " + c.ReportEmailFrom,
"To: " + c.ReportEmailTo,
"Subject: " + mime.QEncoding.Encode("utf-8", subject),
"MIME-Version: 1.0",
"Content-Type: " + contentType,
"Content-Transfer-Encoding: 8bit",
"X-Mailer: " + xMailer,
"",
body,
}, "\n")
args := append(parts[1:], "-t")
cmd := exec.CommandContext(ctx, parts[0], args...)
cmd.Stdin = strings.NewReader(msg)
@@ -120,6 +187,273 @@ func sendMail(ctx context.Context, c *config.Config, subject, body string) error
return cmd.Run()
}
func templateValues(c *config.Config, st Store, stats db.ReportStats, period, format string) (map[string]string, error) {
updateHTML, updateText := checkForUpdate()
busiestDay := "-"
if stats.BusiestDay != "" {
if t, err := time.ParseInLocation("2006-01-02", stats.BusiestDay, time.Local); err == nil {
busiestDay = t.Format("02.01.2006") + " (" + strconv.Itoa(stats.BusiestDayCount) + ")"
} else {
busiestDay = stats.BusiestDay + " (" + strconv.Itoa(stats.BusiestDayCount) + ")"
}
}
busiestLabel := "Aktivster Tag"
if c.ReportBusiestDayRange > 0 {
busiestLabel = fmt.Sprintf("Aktivster Tag (%d Tage)", c.ReportBusiestDayRange)
}
values := map[string]string{
"REPORT_PERIOD": period,
"REPORT_DATE": time.Now().Format("02.01.2006 15:04:05"),
"HOSTNAME": hostname(),
"VERSION": appinfo.Version,
"TOTAL_BANS": strconv.Itoa(stats.TotalBans),
"TOTAL_UNBANS": strconv.Itoa(stats.TotalUnbans),
"UNIQUE_IPS": strconv.Itoa(stats.UniqueIPs),
"PERMANENT_BANS": strconv.Itoa(stats.PermanentBans),
"ACTIVE_BANS": strconv.Itoa(stats.ActiveBans),
"ABUSEIPDB_REPORTS": strconv.Itoa(stats.AbuseIPDBReports),
"RATELIMIT_BANS": strconv.Itoa(stats.RateLimitBans),
"SUBDOMAIN_FLOOD_BANS": strconv.Itoa(stats.SubdomainFloodBans),
"EXTERNAL_BLOCKLIST_BANS": strconv.Itoa(stats.ExternalBlocklistBans),
"BUSIEST_DAY": busiestDay,
"BUSIEST_DAY_LABEL": busiestLabel,
"TOP10_IPS_TABLE": topCountsHTML(stats.TopClients, "IP-Adresse"),
"TOP10_DOMAINS_TABLE": topCountsHTML(stats.TopDomains, "Domain"),
"PROTOCOL_TABLE": protocolHTML(stats.Protocols),
"RECENT_BANS_TABLE": recentBansHTML(stats.RecentBans),
"TOP10_IPS_TEXT": topCountsText(stats.TopClients, "IP-Adresse"),
"TOP10_DOMAINS_TEXT": topCountsText(stats.TopDomains, "Domain"),
"PROTOCOL_TEXT": protocolText(stats.Protocols),
"RECENT_BANS_TEXT": recentBansText(stats.RecentBans),
"UPDATE_NOTICE": updateHTML,
"UPDATE_NOTICE_TXT": updateText,
"PERIOD_OVERVIEW_TABLE": "",
"PERIOD_OVERVIEW_TEXT": "",
}
if strings.EqualFold(format, "html") {
values["PERIOD_OVERVIEW_TABLE"] = periodOverviewHTML(st)
} else {
values["PERIOD_OVERVIEW_TEXT"] = periodOverviewText(st)
}
return values, nil
}
func topCountsHTML(rows []db.ReportCount, nameHeader string) string {
if len(rows) == 0 {
return `<div class="no-data">Keine Daten im Berichtszeitraum</div>`
}
maxCount := rows[0].Count
var b strings.Builder
b.WriteString("<table><tr><th>#</th><th>" + html.EscapeString(nameHeader) + "</th><th>Sperren</th></tr>")
for i, r := range rows {
width := 100
if maxCount > 0 {
width = r.Count * 100 / maxCount
}
class := ""
if i < 3 {
class = " top3"
}
cellClass := ""
if strings.Contains(strings.ToLower(nameHeader), "ip") {
cellClass = ` class="ip-cell"`
}
fmt.Fprintf(&b, `<tr><td><span class="rank%s">%d</span></td><td%s>%s</td><td><div class="bar-container"><div class="bar" style="width:%d%%"></div><span class="bar-value">%d</span></div></td></tr>`, class, i+1, cellClass, html.EscapeString(r.Name), width, r.Count)
}
b.WriteString("</table>")
return b.String()
}
func protocolHTML(rows []db.ReportCount) string {
if len(rows) == 0 {
return `<div class="no-data">Keine Daten im Berichtszeitraum</div>`
}
var b strings.Builder
b.WriteString("<table><tr><th>Protokoll</th><th>Anzahl Sperren</th></tr>")
for _, r := range rows {
class := protocolClass(r.Name)
fmt.Fprintf(&b, `<tr><td><span class="protocol-badge %s">%s</span></td><td>%d</td></tr>`, class, html.EscapeString(r.Name), r.Count)
}
b.WriteString("</table>")
return b.String()
}
func recentBansHTML(rows []db.ReportEvent) string {
if len(rows) == 0 {
return `<div class="no-data">Keine Sperren im Berichtszeitraum</div>`
}
var b strings.Builder
b.WriteString("<table><tr><th>Zeitpunkt</th><th>IP</th><th>Domain</th><th>Grund</th></tr>")
for _, e := range rows {
reason := fallback(e.Reason, "rate-limit")
domain := fallbackDash(e.Domain)
fmt.Fprintf(&b, `<tr><td>%s</td><td class="ip-cell">%s</td><td>%s</td><td><span class="reason-badge %s">%s</span></td></tr>`, html.EscapeString(shortTime(e.Timestamp)), html.EscapeString(e.IP), html.EscapeString(domain), reasonClass(reason), html.EscapeString(reason))
}
b.WriteString("</table>")
return b.String()
}
func periodOverviewHTML(st Store) string {
rows := periodOverviewRows(st)
var b strings.Builder
b.WriteString("<table><tr><th>Zeitraum</th><th>Sperren</th><th>Entsperrt</th><th>Unique IPs</th><th>Dauerhaft gebannt</th></tr>")
for _, r := range rows {
class := ""
if r.Label == "Heute" {
class = ` class="period-today"`
} else if r.Label == "Gestern" {
class = ` class="period-gestern"`
}
fmt.Fprintf(&b, `<tr%s><td><strong>%s</strong></td><td>%d</td><td>%d</td><td>%d</td><td>%d</td></tr>`, class, html.EscapeString(r.Label), r.TotalBans, r.TotalUnbans, r.UniqueIPs, r.PermanentBans)
}
b.WriteString("</table>")
return b.String()
}
func topCountsText(rows []db.ReportCount, nameHeader string) string {
if len(rows) == 0 {
return " Keine Daten im Berichtszeitraum"
}
var b strings.Builder
fmt.Fprintf(&b, " %-4s %-42s %s\n", "#", nameHeader, "Sperren")
fmt.Fprintf(&b, " %-4s %-42s %s\n", "--", strings.Repeat("-", 42), "-------")
for i, r := range rows {
fmt.Fprintf(&b, " %-4s %-42s %d\n", strconv.Itoa(i+1)+".", r.Name, r.Count)
}
return strings.TrimRight(b.String(), "\n")
}
func protocolText(rows []db.ReportCount) string {
if len(rows) == 0 {
return " Keine Daten im Berichtszeitraum"
}
var b strings.Builder
fmt.Fprintf(&b, " %-20s %s\n", "Protokoll", "Anzahl")
fmt.Fprintf(&b, " %-20s %s\n", strings.Repeat("-", 20), "------")
for _, r := range rows {
fmt.Fprintf(&b, " %-20s %d\n", r.Name, r.Count)
}
return strings.TrimRight(b.String(), "\n")
}
func recentBansText(rows []db.ReportEvent) string {
if len(rows) == 0 {
return " Keine Sperren im Berichtszeitraum"
}
var b strings.Builder
fmt.Fprintf(&b, " %-17s %-42s %-30s %s\n", "Zeitpunkt", "IP", "Domain", "Grund")
fmt.Fprintf(&b, " %-17s %-42s %-30s %s\n", strings.Repeat("-", 17), strings.Repeat("-", 42), strings.Repeat("-", 30), "----------")
for _, e := range rows {
fmt.Fprintf(&b, " %-17s %-42s %-30s %s\n", shortTime(e.Timestamp), e.IP, fallbackDash(e.Domain), fallback(e.Reason, "rate-limit"))
}
return strings.TrimRight(b.String(), "\n")
}
func periodOverviewText(st Store) string {
rows := periodOverviewRows(st)
var b strings.Builder
fmt.Fprintf(&b, " %-15s %-9s %-12s %-14s %-11s\n", "Zeitraum", "Sperren", "Entsperrt", "Unique IPs", "Dauerhaft")
fmt.Fprintf(&b, " %-15s %-9s %-12s %-14s %-11s\n", strings.Repeat("-", 15), strings.Repeat("-", 9), strings.Repeat("-", 12), strings.Repeat("-", 14), strings.Repeat("-", 11))
for _, r := range rows {
fmt.Fprintf(&b, " %-15s %-9d %-12d %-14d %-11d\n", r.Label, r.TotalBans, r.TotalUnbans, r.UniqueIPs, r.PermanentBans)
}
return strings.TrimRight(b.String(), "\n")
}
type overviewRow struct {
Label string
db.ReportStats
}
func periodOverviewRows(st Store) []overviewRow {
now := time.Now()
today := midnight(now)
defs := []struct {
label string
since time.Time
until time.Time
}{}
if now.Hour() >= 20 {
defs = append(defs, struct {
label string
since time.Time
until time.Time
}{"Heute", today, now})
}
defs = append(defs,
struct {
label string
since time.Time
until time.Time
}{"Gestern", today.AddDate(0, 0, -1), today.Add(-time.Second)},
struct {
label string
since time.Time
until time.Time
}{"Letzte 7 Tage", today.AddDate(0, 0, -7), now},
struct {
label string
since time.Time
until time.Time
}{"Letzte 14 Tage", today.AddDate(0, 0, -14), now},
struct {
label string
since time.Time
until time.Time
}{"Letzte 30 Tage", today.AddDate(0, 0, -30), now},
)
rows := make([]overviewRow, 0, len(defs))
for _, d := range defs {
stats, err := st.ReportStats(d.since.Unix(), d.until.Unix(), d.since.Unix(), 0)
if err != nil {
stats = db.ReportStats{}
}
rows = append(rows, overviewRow{Label: d.label, ReportStats: stats})
}
return rows
}
func loadTemplate(c *config.Config, format string) (string, error) {
name := "report." + format
for _, dir := range templateDirs(c) {
data, err := os.ReadFile(filepath.Join(dir, name))
if err == nil {
return string(data), nil
}
}
data, err := fs.ReadFile(embeddedTemplates, "templates/"+name)
if err != nil {
return "", fmt.Errorf("Report-Template nicht gefunden: %s", name)
}
return string(data), nil
}
func templateDirs(c *config.Config) []string {
var dirs []string
if v := strings.TrimSpace(os.Getenv("ADGUARD_SHIELD_TEMPLATE_DIR")); v != "" {
dirs = append(dirs, v)
}
if c.Path != "" {
dirs = append(dirs, filepath.Join(filepath.Dir(c.Path), "templates"))
}
if wd, err := os.Getwd(); err == nil {
dirs = append(dirs, filepath.Join(wd, "templates"))
}
if exe, err := os.Executable(); err == nil {
dirs = append(dirs, filepath.Join(filepath.Dir(exe), "templates"))
}
seen := map[string]bool{}
out := dirs[:0]
for _, d := range dirs {
if d != "" && !seen[d] {
seen[d] = true
out = append(out, d)
}
}
return out
}
func parseReportTime(value string) (string, string, error) {
parts := strings.Split(value, ":")
if len(parts) != 2 {
@@ -140,8 +474,8 @@ func cronSchedule(interval, minute, hour string) string {
switch strings.ToLower(interval) {
case "daily":
return fmt.Sprintf("%s %s * * *", minute, hour)
case "biweekly":
return fmt.Sprintf("%s %s 1,15 * *", minute, hour)
case "biweekly", "weekly":
return fmt.Sprintf("%s %s * * 1", minute, hour)
case "monthly":
return fmt.Sprintf("%s %s 1 * *", minute, hour)
default:
@@ -149,88 +483,213 @@ func cronSchedule(interval, minute, hour string) string {
}
}
func window(interval string) (int64, int64) {
now := time.Now()
days := 7
func reportWindow(interval string, now time.Time) (int64, int64, string) {
today := midnight(now)
days, label := 7, "Bericht"
switch strings.ToLower(interval) {
case "daily":
days = 1
days, label = 1, "Tagesbericht"
case "weekly":
days, label = 7, "Wochenbericht"
case "biweekly":
days = 14
days, label = 14, "Zweiwochenbericht"
case "monthly":
days = 30
days, label = 30, "Monatsbericht"
}
return now.AddDate(0, 0, -days).Unix(), now.Unix()
start := today.AddDate(0, 0, -days)
end := today.Add(-time.Second)
if strings.EqualFold(interval, "daily") {
return start.Unix(), end.Unix(), label + ": " + start.Format("02.01.2006")
}
return start.Unix(), end.Unix(), label + ": " + start.Format("02.01.2006") + " - " + end.Format("02.01.2006")
}
func renderText(c *config.Config, st db.ReportStats) string {
var b strings.Builder
b.WriteString("AdGuard Shield Report\n")
b.WriteString("Zeitraum: " + formatTime(st.Since) + " bis " + formatTime(st.Until) + "\n\n")
b.WriteString("Bans: " + strconv.Itoa(st.TotalBans) + "\n")
b.WriteString("Unbans: " + strconv.Itoa(st.TotalUnbans) + "\n")
b.WriteString("Aktive Sperren: " + strconv.Itoa(st.ActiveBans) + "\n\n")
writeCountsText(&b, "Top Clients", st.TopClients)
writeCountsText(&b, "Gruende", st.Reasons)
writeCountsText(&b, "Aktive Quellen", st.Sources)
if len(st.RecentEvents) > 0 {
b.WriteString("Letzte Ereignisse\n")
for _, e := range st.RecentEvents {
b.WriteString("- " + e + "\n")
func busiestWindowStart(c *config.Config, fallbackSince int64) int64 {
if c.ReportBusiestDayRange <= 0 {
return fallbackSince
}
return midnight(time.Now()).AddDate(0, 0, -c.ReportBusiestDayRange).Unix()
}
func midnight(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}
func countAbuseReports(path string, since, until int64) int {
f, err := os.Open(path)
if err != nil {
return 0
}
defer f.Close()
count := 0
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if !strings.Contains(line, "AbuseIPDB:") || !strings.Contains(line, "erfolgreich gemeldet") || len(line) < 21 {
continue
}
ts := strings.TrimPrefix(line[:20], "[")
t, err := time.ParseInLocation("2006-01-02 15:04:05", ts, time.Local)
if err == nil && t.Unix() >= since && t.Unix() <= until {
count++
}
}
_ = c
return b.String()
return count
}
func renderHTML(c *config.Config, st db.ReportStats) string {
var b bytes.Buffer
b.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>AdGuard Shield Report</title>")
b.WriteString("<style>body{font-family:Arial,sans-serif;color:#1f2937}table{border-collapse:collapse;margin:12px 0}td,th{border:1px solid #d1d5db;padding:6px 9px;text-align:left}th{background:#f3f4f6}</style>")
b.WriteString("</head><body>")
b.WriteString("<h1>AdGuard Shield Report</h1>")
b.WriteString("<p>Zeitraum: " + html.EscapeString(formatTime(st.Since)) + " bis " + html.EscapeString(formatTime(st.Until)) + "</p>")
b.WriteString("<ul><li>Bans: " + strconv.Itoa(st.TotalBans) + "</li><li>Unbans: " + strconv.Itoa(st.TotalUnbans) + "</li><li>Aktive Sperren: " + strconv.Itoa(st.ActiveBans) + "</li></ul>")
writeCountsHTML(&b, "Top Clients", st.TopClients)
writeCountsHTML(&b, "Gruende", st.Reasons)
writeCountsHTML(&b, "Aktive Quellen", st.Sources)
if len(st.RecentEvents) > 0 {
b.WriteString("<h2>Letzte Ereignisse</h2><table><tr><th>Ereignis</th></tr>")
for _, e := range st.RecentEvents {
b.WriteString("<tr><td>" + html.EscapeString(e) + "</td></tr>")
func checkForUpdate() (string, string) {
if appinfo.Version == "" || appinfo.Version == "unknown" || strings.EqualFold(os.Getenv("ADGUARD_SHIELD_SKIP_UPDATE_CHECK"), "true") {
return "", ""
}
client := http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://git.techniverse.net/api/v1/repos/scriptos/adguard-shield/releases?limit=1&page=1")
if err != nil {
return "", ""
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return "", ""
}
var releases []struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil || len(releases) == 0 {
return "", ""
}
latest := releases[0].TagName
if !versionGreater(latest, appinfo.Version) {
return "", ""
}
htmlNotice := `<div class="update-notice">Update verfuegbar: <strong>` + html.EscapeString(latest) + `</strong> · <a href="https://git.techniverse.net/scriptos/adguard-shield/releases">Jetzt aktualisieren</a></div>`
textNotice := " Neue Version verfuegbar: " + latest + "\n Update: https://git.techniverse.net/scriptos/adguard-shield/releases\n"
return htmlNotice, textNotice
}
func versionGreater(a, b string) bool {
ap := versionParts(a)
bp := versionParts(b)
max := len(ap)
if len(bp) > max {
max = len(bp)
}
for i := 0; i < max; i++ {
ai, bi := 0, 0
if i < len(ap) {
ai = ap[i]
}
if i < len(bp) {
bi = bp[i]
}
if ai != bi {
return ai > bi
}
b.WriteString("</table>")
}
b.WriteString("</body></html>")
_ = c
return b.String()
return false
}
func writeCountsText(b *strings.Builder, title string, rows []db.ReportCount) {
b.WriteString(title + "\n")
if len(rows) == 0 {
b.WriteString("- keine Daten\n\n")
return
func versionParts(v string) []int {
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
fields := strings.FieldsFunc(v, func(r rune) bool { return r == '.' || r == '-' || r == '+' })
out := make([]int, 0, len(fields))
for _, f := range fields {
n, err := strconv.Atoi(f)
if err != nil {
break
}
out = append(out, n)
}
for _, r := range rows {
b.WriteString("- " + r.Name + ": " + strconv.Itoa(r.Count) + "\n")
}
b.WriteByte('\n')
return out
}
func writeCountsHTML(b *bytes.Buffer, title string, rows []db.ReportCount) {
b.WriteString("<h2>" + html.EscapeString(title) + "</h2><table><tr><th>Name</th><th>Anzahl</th></tr>")
if len(rows) == 0 {
b.WriteString("<tr><td colspan=\"2\">keine Daten</td></tr>")
func testBody(c *config.Config, format string) string {
now := time.Now().Format("02.01.2006 15:04:05")
host := hostname()
if strings.EqualFold(format, "html") {
return `<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"></head><body style="font-family:sans-serif;background:#f0f2f5;padding:30px;"><div style="max-width:600px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08);"><div style="background:#0f3460;color:#fff;padding:30px;text-align:center;"><h1 style="margin:0;">AdGuard Shield Test-Mail</h1><p style="margin:6px 0 0;color:#ccd6f6;">E-Mail-Versand funktioniert</p></div><div style="padding:30px;"><table style="width:100%;border-collapse:collapse;"><tr><td>Hostname</td><td><strong>` + html.EscapeString(host) + `</strong></td></tr><tr><td>Zeitpunkt</td><td><strong>` + html.EscapeString(now) + `</strong></td></tr><tr><td>Empfaenger</td><td><strong>` + html.EscapeString(c.ReportEmailTo) + `</strong></td></tr><tr><td>Absender</td><td><strong>` + html.EscapeString(c.ReportEmailFrom) + `</strong></td></tr><tr><td>Mail-Befehl</td><td><strong>` + html.EscapeString(c.ReportMailCmd) + `</strong></td></tr><tr><td>Format</td><td><strong>` + html.EscapeString(format) + `</strong></td></tr></table></div></div></body></html>`
}
for _, r := range rows {
b.WriteString("<tr><td>" + html.EscapeString(r.Name) + "</td><td>" + strconv.Itoa(r.Count) + "</td></tr>")
}
b.WriteString("</table>")
return fmt.Sprintf(`AdGuard Shield - Test-Mail
E-Mail-Versand funktioniert.
Hostname: %s
Zeitpunkt: %s
Empfaenger: %s
Absender: %s
Mail-Befehl: %s
Format: %s
`, host, now, c.ReportEmailTo, c.ReportEmailFrom, c.ReportMailCmd, format)
}
func formatTime(epoch int64) string {
return time.Unix(epoch, 0).Format("2006-01-02 15:04:05")
func normalizeFormat(value, fallback string) string {
format := strings.ToLower(strings.TrimSpace(value))
if format == "" {
format = strings.ToLower(strings.TrimSpace(fallback))
}
if format != "txt" {
format = "html"
}
return format
}
func shortTime(value string) string {
if len(value) >= 16 {
return value[5:16]
}
return value
}
func protocolClass(proto string) string {
s := strings.ToLower(proto)
switch {
case strings.HasPrefix(s, "dns"):
return "dns"
case strings.HasPrefix(s, "doh"):
return "doh"
case strings.HasPrefix(s, "dot"):
return "dot"
case strings.HasPrefix(s, "doq"):
return "doq"
default:
return ""
}
}
func reasonClass(reason string) string {
s := strings.ToLower(reason)
switch {
case strings.Contains(s, "subdomain"):
return "subdomain-flood"
case strings.Contains(s, "external"):
return "external"
default:
return "rate-limit"
}
}
func fallback(value, def string) string {
if strings.TrimSpace(value) == "" {
return def
}
return value
}
func fallbackDash(value string) string {
if strings.TrimSpace(value) == "" || strings.TrimSpace(value) == "-" {
return "-"
}
return value
}
func empty(s, fallback string) string {
if strings.TrimSpace(s) == "" {
return fallback
}
return s
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
func hostname() string {

View File

@@ -0,0 +1,108 @@
package report
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"adguard-shield/internal/config"
"adguard-shield/internal/db"
)
func TestGenerateUsesTemplatesAndFullStats(t *testing.T) {
t.Setenv("ADGUARD_SHIELD_SKIP_UPDATE_CHECK", "true")
store, err := db.Open(filepath.Join(t.TempDir(), "report.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
yesterday := time.Now().AddDate(0, 0, -1)
events := []struct {
ip string
domain string
duration string
proto string
reason string
}{
{"1.2.3.4", "example.com", "3600s", "DNS", "rate-limit"},
{"1.2.3.4", "example.com", "permanent", "DoH", "subdomain-flood"},
{"5.6.7.8", "block.example", "permanent", "DoT", "external-blocklist"},
}
for i, e := range events {
ts := yesterday.Add(time.Duration(i) * time.Hour)
_, err := store.DB.Exec(`INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip, domain, count, duration, protocol, reason) VALUES (?, ?, 'BAN', ?, ?, '42', ?, ?, ?)`,
ts.Unix(), ts.Format("2006-01-02 15:04:05"), e.ip, e.domain, e.duration, e.proto, e.reason)
if err != nil {
t.Fatal(err)
}
}
ts := yesterday.Add(4 * time.Hour)
if _, err := store.DB.Exec(`INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip, domain, count, duration, protocol, reason) VALUES (?, ?, 'UNBAN', '1.2.3.4', '-', '-', '-', '-', 'manual')`, ts.Unix(), ts.Format("2006-01-02 15:04:05")); err != nil {
t.Fatal(err)
}
if err := store.InsertBan(db.Ban{IP: "5.6.7.8", Domain: "block.example", Permanent: true, Reason: "external-blocklist", Protocol: "DoT", Source: "external-blocklist"}); err != nil {
t.Fatal(err)
}
logPath := filepath.Join(t.TempDir(), "shield.log")
if err := writeTestLog(logPath, yesterday); err != nil {
t.Fatal(err)
}
cfg := &config.Config{
Path: filepath.Join(t.TempDir(), "adguard-shield.conf"),
ReportInterval: "weekly",
ReportFormat: "html",
ReportBusiestDayRange: 30,
LogFile: logPath,
ReportEmailTo: "admin@example.test",
ReportEmailFrom: "shield@example.test",
ReportMailCmd: "msmtp",
}
htmlReport, err := Generate(cfg, store, "html")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"AdGuard Shield", "example.com", "1.2.3.4", "DoH", "Subdomain-Flood Sperren", "AbuseIPDB Reports"} {
if !strings.Contains(htmlReport, want) {
t.Fatalf("HTML report missing %q\n%s", want, htmlReport)
}
}
if strings.Contains(htmlReport, "{{") {
t.Fatalf("HTML report still contains placeholders")
}
txtReport, err := Generate(cfg, store, "txt")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"Sperren gesamt: 3", "Entsperrungen: 1", "Permanente Sperren: 2", "block.example"} {
if !strings.Contains(txtReport, want) {
t.Fatalf("TXT report missing %q\n%s", want, txtReport)
}
}
}
func TestVersionGreater(t *testing.T) {
tests := []struct {
a, b string
want bool
}{
{"v1.0.1", "v1.0.0", true},
{"v1.0.0", "v1.0.0", false},
{"v1.2.0", "v1.10.0", false},
}
for _, tt := range tests {
if got := versionGreater(tt.a, tt.b); got != tt.want {
t.Fatalf("versionGreater(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
}
}
}
func writeTestLog(path string, when time.Time) error {
line := "[" + when.Format("2006-01-02 15:04:05") + "] [INFO] AbuseIPDB: 5.6.7.8 erfolgreich gemeldet\n"
return os.WriteFile(path, []byte(line), 0644)
}

View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AdGuard Shield - Report</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f0f2f5; margin: 0; padding: 0; color: #1a1a2e; }
.container { max-width: 700px; margin: 30px auto; background: #ffffff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow: hidden; }
.header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); color: #ffffff; padding: 30px 35px; text-align: center; }
.header h1 { margin: 0 0 6px 0; font-size: 26px; font-weight: 700; letter-spacing: 0.5px; }
.header .subtitle { font-size: 14px; color: #a8b2d1; margin: 0; }
.header .period { display: inline-block; margin-top: 14px; padding: 6px 18px; background: rgba(255,255,255,0.12); border-radius: 20px; font-size: 13px; color: #ccd6f6; }
.content { padding: 30px 35px; }
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 28px; }
.stat-card { background: #f8f9fc; border-radius: 10px; padding: 18px 20px; border-left: 4px solid #0f3460; }
.stat-card.danger { border-left-color: #e74c3c; }
.stat-card.warning { border-left-color: #f39c12; }
.stat-card.success { border-left-color: #27ae60; }
.stat-card.info { border-left-color: #3498db; }
.stat-card .stat-value { font-size: 28px; font-weight: 700; color: #1a1a2e; line-height: 1.2; }
.stat-card .stat-label { font-size: 12px; color: #6c757d; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
h2 { font-size: 18px; color: #1a1a2e; margin: 28px 0 14px 0; padding-bottom: 8px; border-bottom: 2px solid #f0f2f5; }
h2:first-child { margin-top: 0; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; font-size: 14px; }
th { background: #f8f9fc; color: #1a1a2e; font-weight: 600; text-align: left; padding: 10px 14px; border-bottom: 2px solid #e8ecf1; font-size: 12px; text-transform: uppercase; letter-spacing: 0.3px; }
td { padding: 10px 14px; border-bottom: 1px solid #f0f2f5; color: #495057; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafbfd; }
.rank { display: inline-block; width: 24px; height: 24px; line-height: 24px; text-align: center; background: #e8ecf1; border-radius: 50%; font-size: 12px; font-weight: 600; color: #495057; }
.rank.top3 { background: #0f3460; color: #ffffff; }
.ip-cell { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; font-size: 13px; color: #1a1a2e; }
.bar-container { display: flex; align-items: center; gap: 8px; }
.bar { height: 8px; background: linear-gradient(90deg, #0f3460, #3498db); border-radius: 4px; min-width: 4px; }
.bar-value { font-size: 13px; font-weight: 600; color: #1a1a2e; white-space: nowrap; }
.protocol-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; background: #e8ecf1; color: #495057; margin: 2px; }
.protocol-badge.dns { background: #dff0d8; color: #3c763d; }
.protocol-badge.doh { background: #d9edf7; color: #31708f; }
.protocol-badge.dot { background: #fcf8e3; color: #8a6d3b; }
.protocol-badge.doq { background: #f2dede; color: #a94442; }
.reason-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
.reason-badge.rate-limit { background: #fcf8e3; color: #8a6d3b; }
.reason-badge.subdomain-flood { background: #f2dede; color: #a94442; }
.reason-badge.external { background: #d9edf7; color: #31708f; }
.no-data { text-align: center; padding: 30px; color: #adb5bd; font-style: italic; }
.footer { background: #f8f9fc; padding: 24px 35px; text-align: center; font-size: 12px; color: #6c757d; border-top: 1px solid #e8ecf1; }
.footer a { color: #0f3460; text-decoration: none; font-weight: 600; }
.footer .links { margin-top: 10px; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.footer .separator { margin: 0 8px; color: #ced4da; }
.version-tag { display: block; margin-top: 8px; font-size: 11px; color: #adb5bd; }
.update-notice { display: inline-block; margin-top: 10px; padding: 7px 14px; background: #fff8e1; border: 1px solid #ffc107; border-radius: 8px; color: #7a5700; font-size: 12px; font-weight: 600; }
.update-notice a { color: #7a5700; text-decoration: none; font-weight: 700; }
.period-today td { background: #eef4ff; font-weight: 600; }
.period-today td:first-child { color: #0f3460; }
.period-gestern td { background: #f0faf3; font-weight: 600; }
.period-gestern td:first-child { color: #27ae60; }
@media (max-width: 700px) { .container { margin: 0; border-radius: 0; } .content, .header, .footer { padding-left: 18px; padding-right: 18px; } .stats-grid { grid-template-columns: 1fr; } table { font-size: 12px; } th, td { padding: 8px 8px; } .footer .links { display: block; } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>AdGuard Shield</h1>
<p class="subtitle">Sicherheits-Report</p>
<div class="period">{{REPORT_PERIOD}}</div>
</div>
<div class="content">
<h2>Zeitraum-Schnelluebersicht</h2>
{{PERIOD_OVERVIEW_TABLE}}
<h2>Uebersicht</h2>
<div class="stats-grid">
<div class="stat-card danger"><div class="stat-value">{{TOTAL_BANS}}</div><div class="stat-label">Sperren gesamt</div></div>
<div class="stat-card success"><div class="stat-value">{{TOTAL_UNBANS}}</div><div class="stat-label">Entsperrungen</div></div>
<div class="stat-card warning"><div class="stat-value">{{UNIQUE_IPS}}</div><div class="stat-label">Eindeutige IPs</div></div>
<div class="stat-card info"><div class="stat-value">{{PERMANENT_BANS}}</div><div class="stat-label">Permanente Sperren</div></div>
<div class="stat-card"><div class="stat-value">{{ACTIVE_BANS}}</div><div class="stat-label">Aktuell aktive Sperren</div></div>
<div class="stat-card info"><div class="stat-value">{{ABUSEIPDB_REPORTS}}</div><div class="stat-label">AbuseIPDB Reports</div></div>
</div>
<h2>Angriffsarten</h2>
<div class="stats-grid">
<div class="stat-card warning"><div class="stat-value">{{RATELIMIT_BANS}}</div><div class="stat-label">Rate-Limit Sperren</div></div>
<div class="stat-card danger"><div class="stat-value">{{SUBDOMAIN_FLOOD_BANS}}</div><div class="stat-label">Subdomain-Flood Sperren</div></div>
<div class="stat-card"><div class="stat-value">{{EXTERNAL_BLOCKLIST_BANS}}</div><div class="stat-label">Externe Blocklist</div></div>
<div class="stat-card success"><div class="stat-value">{{BUSIEST_DAY}}</div><div class="stat-label">{{BUSIEST_DAY_LABEL}}</div></div>
</div>
<h2>Top 10 - Auffaelligste IPs</h2>
{{TOP10_IPS_TABLE}}
<h2>Top 10 - Meistbetroffene Domains</h2>
{{TOP10_DOMAINS_TABLE}}
<h2>Protokoll-Verteilung</h2>
{{PROTOCOL_TABLE}}
<h2>Letzte 10 Sperren</h2>
{{RECENT_BANS_TABLE}}
</div>
<div class="footer">
<div class="links">
<span><a href="https://www.patrick-asmus.de">Patrick-Asmus.de</a><span class="separator">|</span><a href="https://www.cleveradmin.de">CleverAdmin.de</a></span>
<span><a href="https://git.techniverse.net/scriptos/adguard-shield.git">AdGuard Shield auf Gitea</a><span class="separator">|</span><a href="https://git.techniverse.net/scriptos/adguard-shield/src/branch/main/docs">docs</a></span>
</div>
<br>
Dieser Report wurde automatisch von <strong>AdGuard Shield</strong> generiert.<br>
Generiert am: {{REPORT_DATE}}
<div class="version-tag">AdGuard Shield {{VERSION}} · {{HOSTNAME}}</div>
{{UPDATE_NOTICE}}
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,68 @@
================================================================
AdGuard Shield - Sicherheits-Report
================================================================
Zeitraum: {{REPORT_PERIOD}}
Erstellt: {{REPORT_DATE}}
Host: {{HOSTNAME}}
----------------------------------------------------------------
ZEITRAUM-SCHNELLUEBERSICHT
----------------------------------------------------------------
{{PERIOD_OVERVIEW_TEXT}}
----------------------------------------------------------------
UEBERSICHT (Berichtszeitraum)
----------------------------------------------------------------
Sperren gesamt: {{TOTAL_BANS}}
Entsperrungen: {{TOTAL_UNBANS}}
Eindeutige IPs: {{UNIQUE_IPS}}
Permanente Sperren: {{PERMANENT_BANS}}
Aktuell aktive Sperren: {{ACTIVE_BANS}}
AbuseIPDB Reports: {{ABUSEIPDB_REPORTS}}
----------------------------------------------------------------
ANGRIFFSARTEN
----------------------------------------------------------------
Rate-Limit Sperren: {{RATELIMIT_BANS}}
Subdomain-Flood Sperren: {{SUBDOMAIN_FLOOD_BANS}}
Externe Blocklist: {{EXTERNAL_BLOCKLIST_BANS}}
{{BUSIEST_DAY_LABEL}}: {{BUSIEST_DAY}}
----------------------------------------------------------------
TOP 10 - AUFFAELLIGSTE IPs
----------------------------------------------------------------
{{TOP10_IPS_TEXT}}
----------------------------------------------------------------
TOP 10 - MEISTBETROFFENE DOMAINS
----------------------------------------------------------------
{{TOP10_DOMAINS_TEXT}}
----------------------------------------------------------------
PROTOKOLL-VERTEILUNG
----------------------------------------------------------------
{{PROTOCOL_TEXT}}
----------------------------------------------------------------
LETZTE 10 SPERREN
----------------------------------------------------------------
{{RECENT_BANS_TEXT}}
{{UPDATE_NOTICE_TXT}}
================================================================
Dieser Report wurde automatisch von AdGuard Shield generiert.
AdGuard Shield {{VERSION}}
Web: https://www.patrick-asmus.de
Blog: https://www.cleveradmin.de
Repo: https://git.techniverse.net/scriptos/adguard-shield.git
Docs: https://git.techniverse.net/scriptos/adguard-shield/src/branch/main/docs
================================================================