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_EMAIL_FROM` | `adguard-shield@example.com` | Absenderadresse |
| `REPORT_FORMAT` | `html` | Report-Format | | `REPORT_FORMAT` | `html` | Report-Format |
| `REPORT_MAIL_CMD` | `msmtp` | Mailprogramm für den Versand | | `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 ### Verfügbare Intervalle
@@ -388,7 +388,7 @@ Details zu allen Kanälen stehen in [Benachrichtigungen](benachrichtigungen.md).
|---|---| |---|---|
| `daily` | Täglich zur konfigurierten Uhrzeit | | `daily` | Täglich zur konfigurierten Uhrzeit |
| `weekly` | Montags 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 | | `monthly` | Am 1. des Monats |
### Verfügbare Formate ### Verfügbare Formate
@@ -418,6 +418,8 @@ sudo /opt/adguard-shield/adguard-shield report-install
Details stehen in [E-Mail Report](report.md). 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 ## Externe Whitelist

View File

@@ -17,11 +17,26 @@ Der Report basiert auf der SQLite-Datenbank:
| Zeitraum | Start- und Enddatum des Berichtszeitraums | | Zeitraum | Start- und Enddatum des Berichtszeitraums |
| Sperren | Anzahl der Sperren im Zeitraum | | Sperren | Anzahl der Sperren im Zeitraum |
| Freigaben | Anzahl der Freigaben 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 | | 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 | | Top-Clients | Die am häufigsten gesperrten Client-IPs |
| Sperrgründe | Aufschlüsselung nach Grund (Rate-Limit, Subdomain-Flood, GeoIP usw.) | | Top-Domains | Die am häufigsten betroffenen Domains |
| Sperrquellen | Aufschlüsselung nach Quelle (Monitor, GeoIP, Blocklist, manuell) | | Protokolle | Verteilung nach DNS, DoH, DoT, DoQ usw. |
| Letzte Ereignisse | Die letzten 20 Einträge aus der Ban-History | | 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_EMAIL_FROM` | `adguard-shield@example.com` | Absenderadresse |
| `REPORT_FORMAT` | `html` | Report-Format (`html` oder `txt`) | | `REPORT_FORMAT` | `html` | Report-Format (`html` oder `txt`) |
| `REPORT_MAIL_CMD` | `msmtp` | Mailprogramm für den Versand | | `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 ### Versandintervalle
@@ -44,7 +59,7 @@ Der Report basiert auf der SQLite-Datenbank:
|---|---| |---|---|
| `daily` | Täglich zur Uhrzeit aus `REPORT_TIME` | | `daily` | Täglich zur Uhrzeit aus `REPORT_TIME` |
| `weekly` | Montags 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` | | `monthly` | Am 1. des Monats zur Uhrzeit aus `REPORT_TIME` |
### Formate ### Formate
@@ -181,7 +196,7 @@ Der Cron-Eintrag ruft das installierte Binary mit der installierten Konfiguratio
|---|---| |---|---|
| `daily` | Täglich zur Uhrzeit aus `REPORT_TIME` | | `daily` | Täglich zur Uhrzeit aus `REPORT_TIME` |
| `weekly` | Montags 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 | | `monthly` | Am 1. des Monats |
### Cron-Job entfernen ### Cron-Job entfernen

View File

@@ -30,10 +30,21 @@ type ReportStats struct {
Until int64 Until int64
TotalBans int TotalBans int
TotalUnbans int TotalUnbans int
UniqueIPs int
PermanentBans int
ActiveBans int ActiveBans int
AbuseIPDBReports int
RateLimitBans int
SubdomainFloodBans int
ExternalBlocklistBans int
BusiestDay string
BusiestDayCount int
TopClients []ReportCount TopClients []ReportCount
TopDomains []ReportCount
Protocols []ReportCount
Reasons []ReportCount Reasons []ReportCount
Sources []ReportCount Sources []ReportCount
RecentBans []ReportEvent
RecentEvents []string RecentEvents []string
} }
@@ -42,6 +53,17 @@ type ReportCount struct {
Count int 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) { func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)") db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil { if err != nil {
@@ -356,7 +378,7 @@ func (s *Store) ClearGeoIPCache() (int64, error) {
return res.RowsAffected() 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} 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 { 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 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 { 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 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 { if err := s.DB.QueryRow(`SELECT COUNT(*) FROM active_bans`).Scan(&st.ActiveBans); err != nil {
return st, err 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 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) 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 { if err != nil {
return st, err 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) 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 { if err != nil {
return st, err return st, err
@@ -380,6 +431,10 @@ func (s *Store) ReportStats(since, until int64, limit int) (ReportStats, error)
if err != nil { if err != nil {
return st, err return st, err
} }
st.RecentBans, err = s.reportRecentBans(since, until, 10)
if err != nil {
return st, err
}
st.RecentEvents, err = s.RecentHistory(limit) st.RecentEvents, err = s.RecentHistory(limit)
return st, err return st, err
} }
@@ -406,3 +461,21 @@ func (s *Store) reportCounts(query string, since, until int64, limit int) ([]Rep
} }
return out, rows.Err() 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" "runtime"
"sort" "sort"
"strings" "strings"
"adguard-shield/internal/report"
) )
const ( const (
@@ -59,23 +61,23 @@ func Install(opts Options) error {
opts = normalize(opts) opts = normalize(opts)
fmt.Println("AdGuard Shield Go-Installation") fmt.Println("AdGuard Shield Go-Installation")
fmt.Printf("Installationspfad: %s\n", opts.InstallDir) 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 { if err := requireLinuxRoot(); err != nil {
return err 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 { if findings := DetectLegacy(opts.InstallDir); len(findings) > 0 {
return &LegacyError{Findings: findings} return &LegacyError{Findings: findings}
} }
if !opts.SkipDeps { if !opts.SkipDeps {
fmt.Println("3/8 Pruefe System-Abhaengigkeiten ...") fmt.Println("3/9 Pruefe System-Abhaengigkeiten ...")
if err := ensureDependencies(); err != nil { if err := ensureDependencies(); err != nil {
return err return err
} }
} else { } 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 { if err := os.MkdirAll(opts.InstallDir, 0755); err != nil {
return err return err
} }
@@ -85,19 +87,23 @@ func Install(opts Options) error {
if err := os.MkdirAll(filepath.Join(opts.InstallDir, "geoip"), 0755); err != nil { if err := os.MkdirAll(filepath.Join(opts.InstallDir, "geoip"), 0755); err != nil {
return err 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 { if err := copySelf(filepath.Join(opts.InstallDir, "adguard-shield")); err != nil {
return err 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 { if err := ensureConfig(opts); err != nil {
return err return err
} }
fmt.Println("7/8 Schreibe systemd-Service ...") fmt.Println("8/9 Schreibe systemd-Service ...")
if err := writeService(opts.InstallDir); err != nil { if err := writeService(opts.InstallDir); err != nil {
return err return err
} }
fmt.Println("8/8 Aktualisiere systemd ...") fmt.Println("9/9 Aktualisiere systemd ...")
_ = run("systemctl", "daemon-reload") _ = run("systemctl", "daemon-reload")
if opts.Enable { if opts.Enable {
fmt.Println("Aktiviere Autostart ...") fmt.Println("Aktiviere Autostart ...")

View File

@@ -1,10 +1,15 @@
package report package report
import ( import (
"bytes" "bufio"
"context" "context"
"embed"
"encoding/json"
"fmt" "fmt"
"html" "html"
"io/fs"
"mime"
"net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -12,12 +17,16 @@ import (
"strings" "strings"
"time" "time"
"adguard-shield/internal/appinfo"
"adguard-shield/internal/config" "adguard-shield/internal/config"
"adguard-shield/internal/db" "adguard-shield/internal/db"
) )
//go:embed templates/report.html templates/report.txt
var embeddedTemplates embed.FS
type Store interface { 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" 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 { if _, err := os.Stat(cronPath); err == nil {
cron = "installiert (" + cronPath + ")" cron = "installiert (" + cronPath + ")"
} }
return fmt.Sprintf(`E-Mail Report mailer := "nicht gefunden"
Aktiv: %v 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 Intervall: %s
Zeit: %s Uhrzeit: %s
Format: %s
Empfaenger: %s Empfaenger: %s
Absender: %s Absender: %s
Format: %s
Mail-Befehl: %s Mail-Befehl: %s
Mail-Befehl Status: %s
Aktivster Tag: letzte %d Tage
Cron: %s Cron: %s
`, c.ReportEnabled, c.ReportInterval, c.ReportTime, c.ReportEmailTo, c.ReportEmailFrom, c.ReportFormat, c.ReportMailCmd, cron) `, c.ReportEnabled, c.ReportInterval, c.ReportTime, c.ReportFormat, empty(c.ReportEmailTo, "nicht konfiguriert"), c.ReportEmailFrom, c.ReportMailCmd, mailer, c.ReportBusiestDayRange, cron)
} }
func Generate(c *config.Config, st Store, format string) (string, error) { func InstallTemplates(dir string) error {
if format == "" { if dir == "" {
format = c.ReportFormat return fmt.Errorf("Template-Zielverzeichnis ist leer")
} }
since, until := window(c.ReportInterval) if err := os.MkdirAll(dir, 0755); err != nil {
stats, err := st.ReportStats(since, until, 20) return err
if err != nil {
return "", err
} }
if strings.EqualFold(format, "html") { for _, name := range []string{"report.html", "report.txt"} {
return renderHTML(c, stats), nil data, err := embeddedTemplates.ReadFile("templates/" + name)
}
return renderText(c, stats), nil
}
func Send(ctx context.Context, c *config.Config, st Store) error {
body, err := Generate(c, st, c.ReportFormat)
if err != nil { if err != nil {
return err return err
} }
return sendMail(ctx, c, "AdGuard Shield Report", body) 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) {
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
}
stats.AbuseIPDBReports = countAbuseReports(c.LogFile, since, until)
tpl, err := loadTemplate(c, format)
if err != nil {
return "", err
}
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 {
format := normalizeFormat(c.ReportFormat, "html")
body, err := Generate(c, st, format)
if err != nil {
return err
}
_, _, 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 { 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) format := normalizeFormat(c.ReportFormat, "html")
if strings.EqualFold(c.ReportFormat, "html") { body := testBody(c, format)
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 - "+hostname(), body, format, "AdGuard Shield Report Generator (Test)")
}
return sendMail(ctx, c, "AdGuard Shield Test-Mail", body)
} }
func InstallCron(binary, configPath string, c *config.Config) error { 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) minute, hour, err := parseReportTime(c.ReportTime)
if err != nil { if err != nil {
return err return err
@@ -82,7 +131,18 @@ func InstallCron(binary, configPath string, c *config.Config) error {
if configPath == "" { if configPath == "" {
configPath = "/opt/adguard-shield/adguard-shield.conf" 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) return os.WriteFile(cronPath, []byte(line), 0644)
} }
@@ -93,25 +153,32 @@ func RemoveCron() error {
return nil return nil
} }
func sendMail(ctx context.Context, c *config.Config, subject, body string) error { func sendMail(ctx context.Context, c *config.Config, subject, body, format, xMailer string) error {
if c.ReportEmailTo == "" { if strings.TrimSpace(c.ReportEmailTo) == "" {
return fmt.Errorf("REPORT_EMAIL_TO ist leer") 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) parts := strings.Fields(c.ReportMailCmd)
if len(parts) == 0 { if len(parts) == 0 {
return fmt.Errorf("REPORT_MAIL_CMD ist leer") 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") args := append(parts[1:], "-t")
cmd := exec.CommandContext(ctx, parts[0], args...) cmd := exec.CommandContext(ctx, parts[0], args...)
cmd.Stdin = strings.NewReader(msg) cmd.Stdin = strings.NewReader(msg)
@@ -120,6 +187,273 @@ func sendMail(ctx context.Context, c *config.Config, subject, body string) error
return cmd.Run() 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) { func parseReportTime(value string) (string, string, error) {
parts := strings.Split(value, ":") parts := strings.Split(value, ":")
if len(parts) != 2 { if len(parts) != 2 {
@@ -140,8 +474,8 @@ func cronSchedule(interval, minute, hour string) string {
switch strings.ToLower(interval) { switch strings.ToLower(interval) {
case "daily": case "daily":
return fmt.Sprintf("%s %s * * *", minute, hour) return fmt.Sprintf("%s %s * * *", minute, hour)
case "biweekly": case "biweekly", "weekly":
return fmt.Sprintf("%s %s 1,15 * *", minute, hour) return fmt.Sprintf("%s %s * * 1", minute, hour)
case "monthly": case "monthly":
return fmt.Sprintf("%s %s 1 * *", minute, hour) return fmt.Sprintf("%s %s 1 * *", minute, hour)
default: default:
@@ -149,88 +483,213 @@ func cronSchedule(interval, minute, hour string) string {
} }
} }
func window(interval string) (int64, int64) { func reportWindow(interval string, now time.Time) (int64, int64, string) {
now := time.Now() today := midnight(now)
days := 7 days, label := 7, "Bericht"
switch strings.ToLower(interval) { switch strings.ToLower(interval) {
case "daily": case "daily":
days = 1 days, label = 1, "Tagesbericht"
case "weekly":
days, label = 7, "Wochenbericht"
case "biweekly": case "biweekly":
days = 14 days, label = 14, "Zweiwochenbericht"
case "monthly": 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 { func busiestWindowStart(c *config.Config, fallbackSince int64) int64 {
var b strings.Builder if c.ReportBusiestDayRange <= 0 {
b.WriteString("AdGuard Shield Report\n") return fallbackSince
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")
} }
} return midnight(time.Now()).AddDate(0, 0, -c.ReportBusiestDayRange).Unix()
_ = c
return b.String()
} }
func renderHTML(c *config.Config, st db.ReportStats) string { func midnight(t time.Time) time.Time {
var b bytes.Buffer y, m, d := t.Date()
b.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>AdGuard Shield Report</title>") return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
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>")
}
b.WriteString("</table>")
}
b.WriteString("</body></html>")
_ = c
return b.String()
} }
func writeCountsText(b *strings.Builder, title string, rows []db.ReportCount) { func countAbuseReports(path string, since, until int64) int {
b.WriteString(title + "\n") f, err := os.Open(path)
if len(rows) == 0 { if err != nil {
b.WriteString("- keine Daten\n\n") return 0
return
} }
for _, r := range rows { defer f.Close()
b.WriteString("- " + r.Name + ": " + strconv.Itoa(r.Count) + "\n") 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
} }
b.WriteByte('\n') 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++
}
}
return count
} }
func writeCountsHTML(b *bytes.Buffer, title string, rows []db.ReportCount) { func checkForUpdate() (string, string) {
b.WriteString("<h2>" + html.EscapeString(title) + "</h2><table><tr><th>Name</th><th>Anzahl</th></tr>") if appinfo.Version == "" || appinfo.Version == "unknown" || strings.EqualFold(os.Getenv("ADGUARD_SHIELD_SKIP_UPDATE_CHECK"), "true") {
if len(rows) == 0 { return "", ""
b.WriteString("<tr><td colspan=\"2\">keine Daten</td></tr>")
} }
for _, r := range rows { client := http.Client{Timeout: 5 * time.Second}
b.WriteString("<tr><td>" + html.EscapeString(r.Name) + "</td><td>" + strconv.Itoa(r.Count) + "</td></tr>") resp, err := client.Get("https://git.techniverse.net/api/v1/repos/scriptos/adguard-shield/releases?limit=1&page=1")
if err != nil {
return "", ""
} }
b.WriteString("</table>") 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 formatTime(epoch int64) string { func versionGreater(a, b string) bool {
return time.Unix(epoch, 0).Format("2006-01-02 15:04:05") 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
}
}
return false
}
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)
}
return out
}
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>`
}
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 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 { 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
================================================================