Files
dip-ids/internal/report/pdf.go
T
kilyabin 78e5c90ed0 feat: healthcheck backend and fix data types
also small various features
2026-04-28 00:10:06 +04:00

520 lines
15 KiB
Go

package report
import (
"fmt"
"log"
"os"
"time"
"dip-ids/internal/storage"
"github.com/johnfercher/maroto/v2"
"github.com/johnfercher/maroto/v2/pkg/components/col"
"github.com/johnfercher/maroto/v2/pkg/components/line"
"github.com/johnfercher/maroto/v2/pkg/components/row"
"github.com/johnfercher/maroto/v2/pkg/components/text"
"github.com/johnfercher/maroto/v2/pkg/config"
"github.com/johnfercher/maroto/v2/pkg/consts/align"
"github.com/johnfercher/maroto/v2/pkg/consts/border"
"github.com/johnfercher/maroto/v2/pkg/consts/fontstyle"
"github.com/johnfercher/maroto/v2/pkg/core"
"github.com/johnfercher/maroto/v2/pkg/props"
"github.com/johnfercher/maroto/v2/pkg/repository"
)
// Data данные для генерации отчёта
type Data struct {
GeneratedAt time.Time
Session *storage.CaptureSession
Alerts []storage.Alert
BySeverity []storage.SeverityCount
ByType []storage.TypeCount
TopIPs []storage.IPCount
TrafficByProto []storage.ProtocolCount
RecentStats []storage.PacketStat
}
var cyrillicFontCandidates = []string{
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu-sans/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
}
var cyrillicBoldCandidates = []string{
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu-sans/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
}
func findFont(candidates []string) string {
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
const fontFamily = "DejaVu"
// цветовая палитра
var (
colorBlue = &props.Color{Red: 37, Green: 99, Blue: 235}
colorBlueDark = &props.Color{Red: 30, Green: 64, Blue: 175}
colorWhite = &props.Color{Red: 255, Green: 255, Blue: 255}
colorGray100 = &props.Color{Red: 248, Green: 250, Blue: 252}
colorGray200 = &props.Color{Red: 226, Green: 232, Blue: 240}
colorGray500 = &props.Color{Red: 100, Green: 116, Blue: 139}
colorGray700 = &props.Color{Red: 55, Green: 65, Blue: 81}
)
// Generate создаёт PDF-отчёт и возвращает байты
func Generate(d Data) ([]byte, error) {
normalFont := findFont(cyrillicFontCandidates)
boldFont := findFont(cyrillicBoldCandidates)
builder := config.NewBuilder().
WithLeftMargin(15).
WithRightMargin(15).
WithTopMargin(15).
WithBottomMargin(15)
if normalFont != "" && boldFont != "" {
fonts, err := repository.New().
AddUTF8Font(fontFamily, fontstyle.Normal, normalFont).
AddUTF8Font(fontFamily, fontstyle.Bold, boldFont).
AddUTF8Font(fontFamily, fontstyle.Italic, normalFont).
AddUTF8Font(fontFamily, fontstyle.BoldItalic, boldFont).
Load()
if err != nil {
log.Printf("[report] failed to load DejaVu font: %v", err)
} else {
builder = builder.
WithCustomFonts(fonts).
WithDefaultFont(&props.Font{Family: fontFamily, Size: 9})
}
} else {
log.Println("[report] DejaVu font not found, Cyrillic may not render. Install ttf-dejavu.")
}
m := maroto.New(builder.Build())
// ── Заголовок ─────────────────────────────────────────────────────────────
m.AddRows(buildHeader(d)...)
m.AddRow(2, line.NewCol(12, props.Line{Color: colorBlue, Thickness: 0.5}))
m.AddRows(buildMeta(d)...)
m.AddRow(6)
// ── Сводка по критичности ─────────────────────────────────────────────────
m.AddRows(buildSectionTitle("1. Сводка по уровням критичности")...)
m.AddRow(2)
m.AddRows(buildSeverityTable(d.BySeverity)...)
m.AddRow(6)
// ── Типы угроз ────────────────────────────────────────────────────────────
m.AddRows(buildSectionTitle("2. Типы обнаруженных угроз")...)
m.AddRow(2)
m.AddRows(buildTypeTable(d.ByType)...)
m.AddRow(6)
// ── Топ источников ────────────────────────────────────────────────────────
if len(d.TopIPs) > 0 {
m.AddRows(buildSectionTitle("3. Топ источников атак")...)
m.AddRow(2)
m.AddRows(buildTopIPsTable(d.TopIPs)...)
m.AddRow(6)
}
// ── Статистика трафика по протоколам ──────────────────────────────────────
if len(d.TrafficByProto) > 0 {
m.AddRows(buildSectionTitle("4. Статистика трафика по протоколам")...)
m.AddRow(2)
m.AddRows(buildTrafficProtoTable(d.TrafficByProto)...)
m.AddRow(6)
}
// ── Лог трафика (последние записи) ────────────────────────────────────────
if len(d.RecentStats) > 0 {
limit := len(d.RecentStats)
if limit > 100 {
limit = 100
}
m.AddRows(buildSectionTitle(fmt.Sprintf("5. Журнал трафика (последние %d записей)", limit))...)
m.AddRow(2)
m.AddRows(buildTrafficLogHeader()...)
for i, s := range d.RecentStats {
if i >= 100 {
break
}
m.AddRows(buildTrafficLogRow(s, i)...)
}
m.AddRow(6)
}
// ── Журнал инцидентов ─────────────────────────────────────────────────────
sectionNum := 6
if len(d.TrafficByProto) == 0 {
sectionNum = 4
}
m.AddRows(buildSectionTitle(fmt.Sprintf("%d. Журнал инцидентов", sectionNum))...)
m.AddRow(2)
m.AddRows(buildAlertsTableHeader()...)
for i, a := range d.Alerts {
if i >= 200 {
break
}
m.AddRows(buildAlertRow(a, i)...)
}
doc, err := m.Generate()
if err != nil {
return nil, fmt.Errorf("generate pdf: %w", err)
}
return doc.GetBytes(), nil
}
// ── Заголовок отчёта ──────────────────────────────────────────────────────────
func buildHeader(d Data) []core.Row {
return []core.Row{
row.New(10).Add(
col.New(8).Add(
text.New("СИСТЕМА ОБНАРУЖЕНИЯ ВТОРЖЕНИЙ", props.Text{
Size: 14,
Style: fontstyle.Bold,
Color: colorBlue,
Top: 1,
}),
text.New("Отчёт об инцидентах сетевой безопасности", props.Text{
Size: 9,
Top: 7,
Color: colorGray500,
}),
),
col.New(4).Add(
text.New("DIP-IDS v1.0", props.Text{
Size: 9,
Align: align.Right,
Style: fontstyle.Bold,
Color: colorBlue,
Top: 1,
}),
text.New(fmt.Sprintf("Дата: %s", d.GeneratedAt.Format("02.01.2006 15:04:05")), props.Text{
Size: 8,
Top: 6,
Align: align.Right,
Color: colorGray500,
}),
),
),
}
}
func buildMeta(d Data) []core.Row {
source, mode, duration, pkts, bytes := "—", "—", "—", "—", "—"
if d.Session != nil {
source = d.Session.Source
mode = modeLabel(d.Session.Mode)
pkts = fmt.Sprintf("%d", d.Session.TotalPkts)
bytes = humanBytes(d.Session.TotalBytes)
if d.Session.EndedAt != nil {
duration = fmt.Sprintf("%.0f сек", d.Session.EndedAt.Sub(d.Session.CreatedAt).Seconds())
} else {
duration = "активна"
}
}
r := row.New(8).Add(
col.New(3).Add(metaCell("Источник", source)),
col.New(3).Add(metaCell("Режим", mode)),
col.New(3).Add(metaCell("Длительность", duration)),
col.New(3).Add(metaCell("Пакетов / Объём", pkts+" / "+bytes)),
)
r.WithStyle(&props.Cell{
BackgroundColor: colorGray100,
BorderColor: colorGray200,
BorderType: border.Full,
BorderThickness: 0.3,
})
return []core.Row{r}
}
func metaCell(label, value string) core.Component {
return text.New(label+": "+value, props.Text{
Size: 8,
Left: 2,
Top: 2,
Color: colorGray700,
})
}
func buildSectionTitle(title string) []core.Row {
r := row.New(8).Add(
col.New(12).Add(
text.New(title, props.Text{
Size: 10,
Style: fontstyle.Bold,
Color: colorBlueDark,
Left: 1,
Top: 2,
}),
),
)
r.WithStyle(&props.Cell{
BackgroundColor: &props.Color{Red: 239, Green: 246, Blue: 255},
BorderColor: colorBlue,
BorderType: border.Left,
BorderThickness: 2,
})
return []core.Row{r}
}
// ── Таблицы ───────────────────────────────────────────────────────────────────
func buildSeverityTable(data []storage.SeverityCount) []core.Row {
rows := []core.Row{tableHeaderRow([]colDef{
{w: 6, label: "Уровень критичности"},
{w: 6, label: "Количество алертов"},
})}
counts := map[string]int64{}
for _, s := range data {
counts[s.Severity] = s.Count
}
for i, sev := range []string{"critical", "high", "medium", "low"} {
r := row.New(7).Add(
col.New(6).Add(text.New(severityLabel(sev), props.Text{
Size: 9,
Style: fontstyle.Bold,
Color: severityColor(sev),
Left: 2,
Top: 2,
})),
col.New(6).Add(text.New(fmt.Sprintf("%d", counts[sev]), props.Text{
Size: 9,
Left: 2,
Top: 2,
})),
)
r.WithStyle(dataRowStyle(i))
rows = append(rows, r)
}
return rows
}
func buildTypeTable(data []storage.TypeCount) []core.Row {
rows := []core.Row{tableHeaderRow([]colDef{
{w: 6, label: "Тип атаки"},
{w: 6, label: "Количество"},
})}
for i, t := range data {
r := row.New(7).Add(
col.New(6).Add(text.New(typeLabel(t.Type), props.Text{Size: 9, Left: 2, Top: 2})),
col.New(6).Add(text.New(fmt.Sprintf("%d", t.Count), props.Text{Size: 9, Left: 2, Top: 2})),
)
r.WithStyle(dataRowStyle(i))
rows = append(rows, r)
}
return rows
}
func buildTopIPsTable(data []storage.IPCount) []core.Row {
rows := []core.Row{tableHeaderRow([]colDef{
{w: 1, label: "#"},
{w: 8, label: "IP-адрес источника"},
{w: 3, label: "Алертов"},
})}
for i, ip := range data {
r := row.New(7).Add(
col.New(1).Add(text.New(fmt.Sprintf("%d", i+1), props.Text{Size: 8, Left: 2, Top: 2})),
col.New(8).Add(text.New(ip.SrcIP, props.Text{Size: 8, Left: 2, Top: 2})),
col.New(3).Add(text.New(fmt.Sprintf("%d", ip.Count), props.Text{Size: 8, Left: 2, Top: 2})),
)
r.WithStyle(dataRowStyle(i))
rows = append(rows, r)
}
return rows
}
func buildTrafficProtoTable(data []storage.ProtocolCount) []core.Row {
rows := []core.Row{tableHeaderRow([]colDef{
{w: 4, label: "Протокол"},
{w: 4, label: "Пакетов"},
{w: 4, label: "Объём трафика"},
})}
for i, p := range data {
r := row.New(7).Add(
col.New(4).Add(text.New(p.Protocol, props.Text{Size: 9, Style: fontstyle.Bold, Left: 2, Top: 2})),
col.New(4).Add(text.New(fmt.Sprintf("%d", p.Count), props.Text{Size: 9, Left: 2, Top: 2})),
col.New(4).Add(text.New(humanBytes(p.Bytes), props.Text{Size: 9, Left: 2, Top: 2})),
)
r.WithStyle(dataRowStyle(i))
rows = append(rows, r)
}
return rows
}
func buildTrafficLogHeader() []core.Row {
return []core.Row{tableHeaderRow([]colDef{
{w: 3, label: "Время"},
{w: 2, label: "Протокол"},
{w: 4, label: "Источник"},
{w: 3, label: "Назначение"},
})}
}
func buildTrafficLogRow(s storage.PacketStat, idx int) []core.Row {
r := row.New(6).Add(
col.New(3).Add(text.New(s.CreatedAt.Format("02.01 15:04:05"), props.Text{Size: 7, Left: 2, Top: 1})),
col.New(2).Add(text.New(s.Protocol, props.Text{Size: 7, Style: fontstyle.Bold, Left: 2, Top: 1})),
col.New(4).Add(text.New(s.SrcIP, props.Text{Size: 7, Left: 2, Top: 1})),
col.New(3).Add(text.New(s.DstIP+" "+humanBytes(s.Bytes), props.Text{Size: 7, Left: 2, Top: 1})),
)
r.WithStyle(dataRowStyle(idx))
return []core.Row{r}
}
func buildAlertsTableHeader() []core.Row {
return []core.Row{tableHeaderRow([]colDef{
{w: 3, label: "Дата/Время"},
{w: 2, label: "Тип"},
{w: 2, label: "Критичность"},
{w: 3, label: "Источник"},
{w: 2, label: "Описание"},
})}
}
func buildAlertRow(a storage.Alert, idx int) []core.Row {
r := row.New(7).Add(
col.New(3).Add(text.New(a.CreatedAt.Format("02.01.06 15:04"), props.Text{Size: 7, Left: 2, Top: 2})),
col.New(2).Add(text.New(typeLabel(a.Type), props.Text{Size: 7, Left: 2, Top: 2})),
col.New(2).Add(text.New(severityLabel(a.Severity), props.Text{
Size: 7,
Style: fontstyle.Bold,
Color: severityColor(a.Severity),
Left: 2,
Top: 2,
})),
col.New(3).Add(text.New(a.SrcIP, props.Text{Size: 7, Left: 2, Top: 2})),
col.New(2).Add(text.New(truncate(a.Description, 40), props.Text{Size: 7, Left: 2, Top: 2})),
)
r.WithStyle(dataRowStyle(idx))
return []core.Row{r}
}
// ── Вспомогательные типы и функции ───────────────────────────────────────────
type colDef struct {
w int
label string
}
func tableHeaderRow(cols []colDef) core.Row {
cells := make([]core.Col, 0, len(cols))
for _, c := range cols {
cells = append(cells, col.New(c.w).Add(
text.New(c.label, props.Text{
Size: 9,
Style: fontstyle.Bold,
Color: colorWhite,
Left: 2,
Top: 2,
}),
))
}
r := row.New(8).Add(cells...)
r.WithStyle(&props.Cell{
BackgroundColor: colorBlue,
BorderColor: colorBlueDark,
BorderType: border.Full,
BorderThickness: 0.3,
})
return r
}
func dataRowStyle(idx int) *props.Cell {
bg := colorWhite
if idx%2 == 0 {
bg = colorGray100
}
return &props.Cell{
BackgroundColor: bg,
BorderColor: colorGray200,
BorderType: border.Bottom,
BorderThickness: 0.3,
}
}
func severityLabel(s string) string {
switch s {
case "critical":
return "Критический"
case "high":
return "Высокий"
case "medium":
return "Средний"
case "low":
return "Низкий"
default:
return s
}
}
func severityColor(s string) *props.Color {
switch s {
case "critical":
return &props.Color{Red: 185, Green: 28, Blue: 28}
case "high":
return &props.Color{Red: 194, Green: 65, Blue: 12}
case "medium":
return &props.Color{Red: 161, Green: 98, Blue: 7}
default:
return &props.Color{Red: 21, Green: 128, Blue: 61}
}
}
func typeLabel(t string) string {
switch t {
case "port_scan":
return "Port Scan"
case "brute_force":
return "Brute Force"
case "dos":
return "DoS/DDoS"
case "arp_spoof":
return "ARP Spoof"
case "dns_anomaly":
return "DNS Anomaly"
default:
return t
}
}
func modeLabel(m string) string {
if m == "live" {
return "Live захват"
}
return "PCAP файл"
}
func humanBytes(b int64) string {
switch {
case b >= 1<<30:
return fmt.Sprintf("%.2f GB", float64(b)/(1<<30))
case b >= 1<<20:
return fmt.Sprintf("%.2f MB", float64(b)/(1<<20))
case b >= 1<<10:
return fmt.Sprintf("%.2f KB", float64(b)/(1<<10))
default:
return fmt.Sprintf("%d B", b)
}
}
func truncate(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n-3]) + "..."
}