Files
Patrick Asmus 4f17f7ff81 feat!: Migration auf Go-Binary
BREAKING CHANGE: Die alte Shell-Version muss vor der Installation der Go-Version deinstalliert werden.
2026-05-01 00:08:57 +02:00

243 lines
7.5 KiB
Go

package report
import (
"bytes"
"context"
"fmt"
"html"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"adguard-shield/internal/config"
"adguard-shield/internal/db"
)
type Store interface {
ReportStats(since, until int64, limit int) (db.ReportStats, error)
}
const cronPath = "/etc/cron.d/adguard-shield-report"
func Status(c *config.Config) string {
cron := "nicht installiert"
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)
}
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)
if err != nil {
return "", err
}
if strings.EqualFold(format, "html") {
return renderHTML(c, stats), nil
}
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 {
return err
}
return sendMail(ctx, c, "AdGuard Shield Report", body)
}
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)
}
func InstallCron(binary, configPath string, c *config.Config) error {
minute, hour, err := parseReportTime(c.ReportTime)
if err != nil {
return err
}
schedule := cronSchedule(c.ReportInterval, minute, hour)
if binary == "" {
binary = "/opt/adguard-shield/adguard-shield"
}
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)
return os.WriteFile(cronPath, []byte(line), 0644)
}
func RemoveCron() error {
if err := os.Remove(cronPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func sendMail(ctx context.Context, c *config.Config, subject, body string) error {
if 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")
}
args := append(parts[1:], "-t")
cmd := exec.CommandContext(ctx, parts[0], args...)
cmd.Stdin = strings.NewReader(msg)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func parseReportTime(value string) (string, string, error) {
parts := strings.Split(value, ":")
if len(parts) != 2 {
return "", "", fmt.Errorf("REPORT_TIME muss HH:MM sein")
}
hour, err := strconv.Atoi(parts[0])
if err != nil || hour < 0 || hour > 23 {
return "", "", fmt.Errorf("REPORT_TIME hat ungueltige Stunde")
}
minute, err := strconv.Atoi(parts[1])
if err != nil || minute < 0 || minute > 59 {
return "", "", fmt.Errorf("REPORT_TIME hat ungueltige Minute")
}
return strconv.Itoa(minute), strconv.Itoa(hour), nil
}
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 "monthly":
return fmt.Sprintf("%s %s 1 * *", minute, hour)
default:
return fmt.Sprintf("%s %s * * 1", minute, hour)
}
}
func window(interval string) (int64, int64) {
now := time.Now()
days := 7
switch strings.ToLower(interval) {
case "daily":
days = 1
case "biweekly":
days = 14
case "monthly":
days = 30
}
return now.AddDate(0, 0, -days).Unix(), now.Unix()
}
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")
}
}
_ = c
return b.String()
}
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>")
}
b.WriteString("</table>")
}
b.WriteString("</body></html>")
_ = c
return b.String()
}
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
}
for _, r := range rows {
b.WriteString("- " + r.Name + ": " + strconv.Itoa(r.Count) + "\n")
}
b.WriteByte('\n')
}
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>")
}
for _, r := range rows {
b.WriteString("<tr><td>" + html.EscapeString(r.Name) + "</td><td>" + strconv.Itoa(r.Count) + "</td></tr>")
}
b.WriteString("</table>")
}
func formatTime(epoch int64) string {
return time.Unix(epoch, 0).Format("2006-01-02 15:04:05")
}
func hostname() string {
name, err := os.Hostname()
if err != nil || name == "" {
return filepath.Base(os.Args[0])
}
return name
}