Initial commit

This commit is contained in:
kilyabin
2026-04-12 22:26:26 +04:00
commit 68f9a01ed5
44 changed files with 7134 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Go workspace file
go.work
go.work.sum
# Go build artifacts
nids
cmd/*/
!cmd/*/
# Dependency directories
# vendor/
# Environment variables
.env
.env.*
!.env.example
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Temp directories
.temp
.tmp
.tmp-*
.cache
.nyc_output
test-results/
playwright-report/
# Node modules
node_modules/
jspm_packages/
# Package manager files
package-lock.json
yarn.lock
pnpm-lock.yaml
.pnpm-store
# Nuxt/Vue frontend (frontend/)
frontend/dist/
frontend/.nuxt/
frontend/.nuxt-*/
frontend/.output/
frontend/.output-*/
frontend/.gen/
frontend/.test/
frontend/.eslintcache
frontend/eslint-typegen.d.ts
frontend/node_modules/
# Nuxt build outputs
.vercel
.vercel_build_output
.netlify
.build-*
# Coverage
coverage/
*.lcov
# IDE/Editor
.vscode/
*.iml
.idea/
!.idea/nuxt.iml
!.idea/modules.xml
!.idea/inspectionProfiles/Project_Default.xml
# AI assistants
.agents/
.claude/
# OS files
.DS_Store
.AppleDouble
.LSOverride
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdrive
# Lychee cache
.lycheecache
# Junit reports
reports/
# Temp
temp/
temp-*
fixtures-temp/
# Vue templates compile cache
frontend/packages/ui-templates/node-compile-cache/
+249
View File
@@ -0,0 +1,249 @@
# DIP-IDS — Система обнаружения вторжений на основе анализа сетевого трафика
Дипломная практическая работа. Сетевая IDS с веб-панелью управления и генерацией PDF-отчётов.
---
## Быстрый старт
### Требования
| Компонент | Версия |
|-----------|--------|
| Go | ≥ 1.22 |
| Bun | ≥ 1.1 |
| libpcap | любая |
| Linux | — |
```bash
# Debian/Ubuntu
sudo apt install libpcap-dev gcc
# Arch
sudo pacman -S libpcap gcc
```
### Бэкенд
```bash
cd /path/to/dip-ids
go mod tidy
go build -o nids ./cmd/ids
sudo ./nids # sudo нужен для live-захвата
```
По умолчанию API поднимается на `http://0.0.0.0:8080`.
### Фронтенд (dev)
```bash
cd frontend
bun install
bun run dev # http://localhost:3000
```
Nuxt автоматически проксирует `/api/*``localhost:8080`.
### Сборка для продакшена
```bash
cd frontend
bun run generate # статические файлы в frontend/.output/public/
```
Готовые файлы можно раздавать любым статическим сервером или встроить в Go-бинарник через `//go:embed`.
---
## Архитектура
```
┌──────────────────────────────────────────────────────────────┐
│ Nuxt 3 Frontend │
│ Dashboard · Alerts · Capture · Stats │
│ Nuxt UI · Chart.js · vue-chartjs │
└─────────────────────────┬────────────────────────────────────┘
│ HTTP / REST API
┌─────────────────────────▼────────────────────────────────────┐
│ Go Backend │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Capture │──▶│ Engine │──▶│ Storage │ │
│ │ │ │ │ │ SQLite (GORM) │ │
│ │ Live │ │ PortScan │ └──────────────────────┘ │
│ │ PCAP │ │ BruteForce │ │
│ └──────────┘ │ DoS │ ┌──────────────────────┐ │
│ │ ARPSpoof │──▶│ PDF Report │ │
│ │ DNSAnomaly │ │ (maroto v2) │ │
│ └──────────────┘ └──────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
---
## REST API
### Алерты
| Метод | Путь | Описание |
|----------|--------------------|-------------------------------------|
| `GET` | `/api/alerts` | Список алертов (фильтрация, пагинация) |
| `DELETE` | `/api/alerts` | Удалить все алерты |
| `DELETE` | `/api/alerts/:id` | Удалить алерт по ID |
**Параметры GET `/api/alerts`:**
| Параметр | Тип | Описание |
|-------------|--------|---------------------------------------|
| `type` | string | Тип атаки (port_scan, brute_force...) |
| `severity` | string | Уровень (critical, high, medium, low) |
| `src_ip` | string | IP-адрес источника |
| `page` | int | Номер страницы (по умолч. 1) |
| `page_size` | int | Размер страницы (по умолч. 50) |
### Статистика
| Метод | Путь | Описание |
|-------|-------------------------|----------------------------------|
| `GET` | `/api/stats/summary` | Сводка: total, by_severity, by_type |
| `GET` | `/api/stats/timeline` | Алерты по часам (`?hours=24`) |
| `GET` | `/api/stats/top-ips` | Топ источников (`?limit=10`) |
### Захват
| Метод | Путь | Описание |
|--------|----------------------------|---------------------------------|
| `GET` | `/api/capture/interfaces` | Список сетевых интерфейсов |
| `GET` | `/api/capture/status` | Статус текущей сессии |
| `GET` | `/api/capture/sessions` | История сессий |
| `POST` | `/api/capture/start` | Начать live-захват |
| `POST` | `/api/capture/stop` | Остановить захват |
| `POST` | `/api/capture/upload` | Загрузить и анализировать PCAP |
**POST `/api/capture/start` body:**
```json
{ "interface": "eth0" }
```
### Отчёты
| Метод | Путь | Описание |
|-------|-------------------------|---------------------|
| `GET` | `/api/reports/generate` | Скачать PDF-отчёт |
---
## Конфигурация
Файл `configs/config.yaml`:
```yaml
server:
host: "0.0.0.0"
port: 8080
database:
path: "nids.db" # путь к SQLite файлу
capture:
default_interface: "eth0"
snaplen: 65535
promiscuous: true
detectors:
port_scan:
enabled: true
threshold: 15 # уникальных портов за window_seconds
window_seconds: 10
severity: "high"
brute_force:
enabled: true
threshold: 10 # попыток подключения за window_seconds
window_seconds: 30
severity: "critical"
ports: [22, 21, 23, 3389, 5900, 25, 110, 143]
dos:
enabled: true
pps_threshold: 1000 # пакетов в секунду
window_seconds: 1
severity: "critical"
arp_spoof:
enabled: true
severity: "critical"
dns_anomaly:
enabled: true
max_label_length: 50 # макс. длина DNS-метки
query_threshold: 200 # запросов за window_seconds
window_seconds: 60
severity: "medium"
```
---
## Алгоритмы обнаружения
### Port Scan (Сканирование портов)
Детектор ведёт таблицу `srcIP → {dstPort → []timestamp}`. Для каждого входящего TCP SYN-пакета регистрируется новое обращение к порту назначения. Устаревшие записи (старше `window_seconds`) удаляются (sliding window). Если количество уникальных портов назначения превышает `threshold` — генерируется алерт.
### Brute Force (Перебор паролей)
Мониторятся только соединения к портам из списка `ports` (SSH, FTP, RDP и др.). Счётчик SYN-пакетов от одного источника к одному порту назначения ведётся в скользящем окне. Превышение `threshold` за `window_seconds` — алерт.
### DoS/DDoS
Считается общий поток пакетов от каждого источника в окне `window_seconds`. При превышении `pps_threshold` пакетов в секунду генерируется алерт категории `critical`.
### ARP Spoofing
Ведётся таблица соответствия `IP → MAC`. При получении ARP Reply проверяется, не изменился ли MAC для уже известного IP-адреса. Изменение MAC — признак подмены ARP-записей (Man-in-the-Middle).
### DNS Anomaly
Два сценария:
1. **DNS-туннелирование** — проверяется длина каждой метки в имени запроса. Метка длиннее `max_label_length` символов — подозрение на туннелирование данных через DNS.
2. **Высокая частота** — если один хост генерирует более `query_threshold` DNS-запросов за `window_seconds` — алерт.
---
## Структура проекта
```
dip-ids/
├── cmd/ids/main.go # точка входа
├── internal/
│ ├── config/config.go # загрузка конфигурации
│ ├── storage/
│ │ ├── models.go # GORM модели
│ │ └── db.go # CRUD операции
│ ├── capture/capture.go # live и pcap захват
│ ├── analyzer/
│ │ ├── engine.go # основной движок
│ │ ├── detector.go # интерфейс детектора
│ │ ├── portscan.go
│ │ ├── bruteforce.go
│ │ ├── dos.go
│ │ ├── arp.go
│ │ └── dns.go
│ ├── api/
│ │ ├── server.go # Gin HTTP сервер
│ │ └── handlers/ # обработчики маршрутов
│ └── report/pdf.go # генерация PDF (maroto v2)
├── frontend/ # Nuxt 3 + Nuxt UI
│ ├── pages/
│ │ ├── index.vue # дашборд
│ │ ├── alerts.vue # журнал алертов
│ │ ├── capture.vue # управление захватом
│ │ └── stats.vue # статистика
│ ├── components/
│ ├── composables/
│ ├── layouts/default.vue
│ ├── nuxt.config.ts
│ └── package.json
├── configs/config.yaml
└── README.md
```
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"log"
"os"
"dip-ids/internal/analyzer"
"dip-ids/internal/api"
"dip-ids/internal/config"
"dip-ids/internal/storage"
)
func main() {
cfgPath := "configs/config.yaml"
if len(os.Args) > 1 {
cfgPath = os.Args[1]
}
cfg, err := config.Load(cfgPath)
if err != nil {
log.Fatalf("load config: %v", err)
}
db, err := storage.New(cfg.Database.Path)
if err != nil {
log.Fatalf("init database: %v", err)
}
engine := analyzer.New(db, cfg.Detectors)
srv := api.New(cfg.Server, db, engine, cfg.Capture)
log.Printf("NIDS API запущен на http://%s:%d", cfg.Server.Host, cfg.Server.Port)
if err := srv.Run(); err != nil {
log.Fatalf("server: %v", err)
}
}
+42
View File
@@ -0,0 +1,42 @@
server:
host: "0.0.0.0"
port: 8080
database:
path: "nids.db"
capture:
default_interface: "eth0"
snaplen: 65535
promiscuous: true
detectors:
port_scan:
enabled: true
threshold: 15
window_seconds: 10
severity: "high"
brute_force:
enabled: true
threshold: 10
window_seconds: 30
severity: "critical"
ports: [22, 21, 23, 3389, 5900, 25, 110, 143]
dos:
enabled: true
pps_threshold: 1000
window_seconds: 1
severity: "critical"
arp_spoof:
enabled: true
severity: "critical"
dns_anomaly:
enabled: true
max_label_length: 50
query_threshold: 200
window_seconds: 60
severity: "medium"
+131
View File
@@ -0,0 +1,131 @@
# Архитектура системы DIP-IDS
## 1. Общая схема
```
┌─────────────────────────────────────────────────────────────────────┐
│ Пользователь │
│ браузер → localhost:3000 │
└───────────────────────────┬─────────────────────────────────────────┘
│ HTTP
┌───────────────────────────▼─────────────────────────────────────────┐
│ Nuxt 3 Frontend │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │Dashboard │ │ Alerts │ │ Capture │ │ Stats │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
│ Nuxt UI · vue-chartjs · Chart.js │
└───────────────────────────┬─────────────────────────────────────────┘
│ REST API (proxy /api/*)
┌───────────────────────────▼─────────────────────────────────────────┐
│ Go Backend (:8080) │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Gin HTTP Router │ │
│ │ /api/alerts /api/stats /api/capture /api/reports │ │
│ └──────────────────────────┬───────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────▼───────────────────────────────────┐ │
│ │ Analyzer Engine │ │
│ │ │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
│ │ │PortScan │ │BruteForce │ │ DoS │ │ │
│ │ │Detector │ │Detector │ │ Detector │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ │ │
│ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │ARPSpoof │ │DNS Anomaly │ │ │
│ │ │Detector │ │Detector │ │ │
│ │ └────────────┘ └────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ Capture │ │ Storage │ │ PDF Report │ │
│ │ (gopacket) │ │ (SQLite) │ │ (maroto v2) │ │
│ │ live / pcap │ │ GORM │ │ │ │
│ └──────────────┘ └─────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────┴──────────────┐
│ │
┌────────▼────────┐ ┌─────────▼────────┐
│ Сетевой │ │ PCAP файлы │
│ интерфейс │ │ (.pcap/.pcapng)│
│ (eth0, wlan0) │ │ │
└─────────────────┘ └──────────────────┘
```
## 2. Поток данных
```
Сетевой интерфейс / PCAP файл
pcap.Handle (gopacket)
│ chan gopacket.Packet (буфер 1024)
Analyzer Engine.Run()
│ fan-out: каждый пакет → все детекторы
├──▶ PortScanDetector.Analyze(pkt)
├──▶ BruteForceDetector.Analyze(pkt)
├──▶ DoSDetector.Analyze(pkt)
├──▶ ARPSpoofDetector.Analyze(pkt)
└──▶ DNSAnomalyDetector.Analyze(pkt)
│ []storage.Alert
storage.DB.CreateAlert()
SQLite (nids.db)
┌─────┴──────┐
▼ ▼
REST API PDF Report
(Gin) (maroto v2)
Nuxt Frontend
```
## 3. Компоненты и их ответственность
| Компонент | Пакет | Ответственность |
|---|---|---|
| `Capturer` | `internal/capture` | Открывает pcap handle (live или файл), читает пакеты в канал |
| `Engine` | `internal/analyzer` | Принимает пакеты из канала, фанаутит детекторам, сохраняет алерты |
| `Detector` (интерфейс) | `internal/analyzer` | Контракт: `Analyze(pkt) []Alert`, `Reset()` |
| `DB` | `internal/storage` | CRUD алертов, статистики, сессий через GORM + SQLite |
| `Server` | `internal/api` | Gin HTTP сервер, CORS, маршрутизация |
| `handlers/*` | `internal/api/handlers` | Обработчики REST эндпоинтов |
| `report.Generate` | `internal/report` | Генерация структурированного PDF через maroto v2 |
## 4. Модель конкурентности
```
main goroutine
├── srv.Run() ← HTTP сервер (Gin, внутренние goroutines)
└── при старте захвата:
├── capture goroutine ← читает пакеты → chan
└── engine goroutine ← engine.Run(ctx, pkts)
└── обрабатывает пакеты синхронно (детекторы потокобезопасны через sync.Mutex)
```
Остановка захвата — через отмену `context.Context` (cancel функция хранится в `CaptureState`).
## 5. Выбор технологий
| Технология | Обоснование |
|---|---|
| **Go** | Системный язык, оптимален для сетевых инструментов; компилируется в один бинарник |
| **gopacket** | Стандарт де-факто для захвата пакетов в Go; оборачивает libpcap |
| **Gin** | Минималистичный HTTP фреймворк с высокой производительностью |
| **GORM + SQLite** | Не требует отдельного сервера БД; достаточно для демо и небольших сетей |
| **maroto v2** | Grid-based PDF генератор; создаёт структурированные деловые отчёты |
| **Nuxt 3** | SSR/SPA фреймворк на Vue 3; удобная файловая маршрутизация |
| **Nuxt UI** | Официальная UI библиотека Nuxt; Tailwind CSS, тёмная тема из коробки |
| **Chart.js + vue-chartjs** | Зрелая библиотека графиков; хорошая интеграция с Vue 3 |
| **Bun** | Быстрый JavaScript runtime и пакетный менеджер; замена npm/yarn |
+194
View File
@@ -0,0 +1,194 @@
# Алгоритмы обнаружения угроз
## Общие принципы
Все детекторы реализуют **сигнатурный метод** обнаружения на основе скользящего временного окна (sliding window). Каждый детектор:
- Потокобезопасен (`sync.Mutex` на внутреннее состояние)
- Поддерживает дедупликацию — повторный алерт от одного источника не генерируется чаще заданного интервала
- Настраивается через `configs/config.yaml` без перекомпиляции
---
## 1. Port Scan (Сканирование портов)
### Метод обнаружения
Злоумышленник обычно последовательно обращается к множеству портов на одном или нескольких хостах для выявления открытых сервисов. Детектор отслеживает количество **уникальных портов назначения**, к которым обращается один источник за фиксированное время.
### Алгоритм
```
Для каждого TCP SYN-пакета (SYN=1, ACK=0):
key = srcIP
ports[key][dstPort] += timestamp
Удалить записи старше window_seconds
uniquePorts = count(ports[key])
if uniquePorts >= threshold:
если последний алерт был > 30 сек назад:
создать Alert(type=port_scan, severity=high)
```
### Параметры
| Параметр | По умолчанию | Описание |
|----------|-------------|----------|
| `threshold` | 15 | Уникальных портов за период |
| `window_seconds` | 10 | Длина окна в секундах |
### Пример (Nmap):
```
Nmap SYN scan (-sS) → пакеты SYN к портам 22, 80, 443, 3389...
После 15-го уникального порта за 10 секунд → алерт
```
---
## 2. Brute Force (Перебор паролей)
### Метод обнаружения
При переборе паролей атакующий многократно устанавливает TCP-соединение на один и тот же порт аутентификации. Детектор считает количество SYN-пакетов от одного источника к одному порту/хосту назначения.
### Мониторируемые порты
| Порт | Сервис |
|------|--------|
| 22 | SSH |
| 21 | FTP |
| 23 | Telnet |
| 3389 | RDP (Windows Remote Desktop) |
| 5900 | VNC |
| 25 | SMTP |
| 110 | POP3 |
| 143 | IMAP |
### Алгоритм
```
Для каждого TCP SYN-пакета на порт из списка:
key = srcIP + ":" + dstIP + ":" + dstPort
timestamps[key] += now
Удалить записи старше window_seconds
count = len(timestamps[key])
if count >= threshold:
если последний алерт был > 30 сек назад:
создать Alert(type=brute_force, severity=critical)
```
### Параметры
| Параметр | По умолчанию | Описание |
|----------|-------------|----------|
| `threshold` | 10 | Попыток подключения за период |
| `window_seconds` | 30 | Длина окна в секундах |
---
## 3. DoS / DDoS
### Метод обнаружения
Атака отказа в обслуживании характеризуется аномально высоким потоком пакетов от одного (DoS) или множества (DDoS) источников. Детектор измеряет скорость пакетов (PPS — Packets Per Second) от каждого IP в скользящем окне.
### Алгоритм
```
Для каждого IP-пакета:
timestamps[srcIP] += now
Удалить записи старше window_seconds (1 сек по умолчанию)
pps = len(timestamps[srcIP]) / window_seconds
if pps >= pps_threshold:
если последний алерт был > 10 сек назад:
создать Alert(type=dos, severity=critical)
```
### Параметры
| Параметр | По умолчанию | Описание |
|----------|-------------|----------|
| `pps_threshold` | 1000 | Пакетов в секунду |
| `window_seconds` | 1 | Длина окна измерения |
### Ограничение
Детектор срабатывает на отдельные источники. Для распределённого DDoS с тысячами источников необходим дополнительный анализ суммарного трафика к жертве (не реализован в текущей версии).
---
## 4. ARP Spoofing (Подмена ARP)
### Метод обнаружения
Протокол ARP не предусматривает аутентификации, что позволяет злоумышленнику отправить поддельные ARP Reply и связать чужой IP с собственным MAC-адресом (Man-in-the-Middle атака).
Детектор ведёт доверенную таблицу соответствия `IP → MAC`. При получении ARP Reply проверяется, не изменился ли MAC для уже известного IP.
### Алгоритм
```
Для каждого ARP Reply (operation=2):
srcIP = arp.SourceProtAddress
srcMAC = arp.SourceHwAddress
if srcIP in table:
if table[srcIP] != srcMAC:
создать Alert(type=arp_spoof, severity=critical,
desc="IP X сменил MAC с A на B")
table[srcIP] = srcMAC // обновляем
else:
table[srcIP] = srcMAC // первичная запись
```
### Ограничения
- Таблица очищается при `Reset()` (смене сессии). При долгом мониторинге DHCP-обновления MAC не вызывают ложных срабатываний только если DHCP lease истёк.
- Не различает легитимное изменение MAC (замена сетевой карты) от атаки.
---
## 5. DNS Anomaly (Аномалии DNS)
### Сценарий A: DNS-туннелирование
DNS-туннелирование — метод передачи данных через DNS-запросы для обхода фаерволов. Данные кодируются в имени запроса, что приводит к аномально длинным меткам.
Нормальная метка (label): `www`, `mail`, `api` — 315 символов.
Подозрительная метка: `aGVsbG8gd29ybGQhIHRoaXMgaXMgYSB0ZXN0` — 38+ символов (base64-данные).
```
Для каждого DNS запроса (QR=0):
for label in split(queryName, "."):
if len(label) > max_label_length (50):
создать Alert(type=dns_anomaly, desc="DNS tunneling: label length N")
```
### Сценарий B: Высокая частота запросов
Аномально высокое число DNS-запросов от одного хоста может указывать на:
- DNS-туннелирование с разбивкой данных на короткие запросы
- Активность вредоносного ПО (C2 beacon через DNS)
- DGA (Domain Generation Algorithm) — малварь генерирует случайные домены
```
Для каждого DNS запроса:
timestamps[srcIP] += now
Удалить записи старше window_seconds
if len(timestamps[srcIP]) >= query_threshold:
создать Alert(type=dns_anomaly, desc="High DNS query rate: N queries/Xs")
```
### Параметры
| Параметр | По умолчанию | Описание |
|----------|-------------|----------|
| `max_label_length` | 50 | Максимальная длина DNS-метки |
| `query_threshold` | 200 | Запросов за период |
| `window_seconds` | 60 | Длина окна |
---
## Сводная таблица детекторов
| Детектор | Протокол | Метод | Ключ дедупл. | Severity |
|----------|----------|-------|--------------|----------|
| Port Scan | TCP | Sliding window, уникальные порты | srcIP | high |
| Brute Force | TCP | Sliding window, частота SYN | srcIP:dstIP:dstPort | critical |
| DoS | IP | Sliding window, PPS | srcIP | critical |
| ARP Spoof | ARP | Таблица IP→MAC | srcIP | critical |
| DNS Anomaly | DNS/UDP | Длина метки + частота | srcIP | medium |
+110
View File
@@ -0,0 +1,110 @@
# Руководство по установке
## Требования
| Компонент | Версия | Назначение |
|-----------|--------|-----------|
| Linux | любой дистрибутив | ОС (raw sockets доступны только на Linux) |
| Go | ≥ 1.22 | Компиляция бэкенда |
| GCC | любая | Компиляция CGO (go-sqlite3) |
| libpcap-dev | любая | Захват пакетов |
| Bun | ≥ 1.1 | Сборка фронтенда |
## Установка зависимостей
### Debian / Ubuntu
```bash
sudo apt update
sudo apt install -y golang gcc libpcap-dev
# Bun
curl -fsSL https://bun.sh/install | bash
```
### Arch Linux / CachyOS
```bash
sudo pacman -S go gcc libpcap
# Bun
yay -S bun-bin
# или
curl -fsSL https://bun.sh/install | bash
```
### Fedora / RHEL
```bash
sudo dnf install golang gcc libpcap-devel
curl -fsSL https://bun.sh/install | bash
```
## Сборка и запуск
### 1. Клонирование репозитория
```bash
git clone <repo-url> dip-ids
cd dip-ids
```
### 2. Бэкенд
```bash
# Загрузить зависимости
go mod tidy
# Собрать бинарник
go build -o nids ./cmd/ids
# Запустить (sudo обязателен для live-захвата)
sudo ./nids
# API доступен на http://0.0.0.0:8080
```
Конфигурация загружается из `configs/config.yaml`. Альтернативный путь:
```bash
sudo ./nids /path/to/custom-config.yaml
```
### 3. Фронтенд (dev-режим)
```bash
cd frontend
bun install
bun run dev
# Панель доступна на http://localhost:3000
```
### 4. Фронтенд (production-сборка)
```bash
cd frontend
bun run generate
# Статические файлы в frontend/.output/public/
```
## Настройка интерфейса
Узнать имя сетевого интерфейса:
```bash
ip link show
# или
sudo tcpdump -D
```
Обновить в `configs/config.yaml`:
```yaml
capture:
default_interface: "enp3s0" # ← ваш интерфейс
```
## Права доступа
Live-захват требует доступа к raw sockets. Варианты:
**Вариант 1 (рекомендуется для разработки):**
```bash
sudo ./nids
```
**Вариант 2 — capabilities (без sudo):**
```bash
sudo setcap cap_net_raw,cap_net_admin=eip ./nids
./nids
```
**Вариант 3 — только PCAP анализ** (без специальных прав):
Загружайте готовые `.pcap` файлы через веб-панель, бэкенд можно запускать без sudo.
+172
View File
@@ -0,0 +1,172 @@
# Описание модулей DIP-IDS
## 1. Модуль захвата трафика (`internal/capture`)
### Назначение
Обеспечивает получение сетевых пакетов из двух источников: живой захват с сетевого интерфейса и чтение ранее записанных PCAP-файлов.
### Файлы
| Файл | Описание |
|------|----------|
| `capture.go` | Структура `Capturer`, функции `New`, `Start`, `Stop`, `ListInterfaces` |
### Ключевые типы
```go
type Mode string // "live" или "pcap"
type Config struct {
Interface string // имя интерфейса (для live)
PCAPFile string // путь к файлу (для pcap)
Mode Mode
Snaplen int32 // максимальный размер захватываемого пакета
Promiscuous bool // неразборчивый режим
}
```
### Принцип работы
`Start(ctx)` открывает `pcap.Handle` нужного типа (live или offline) и запускает горутину, которая читает пакеты в буферизованный канал `chan gopacket.Packet` ёмкостью 1024. При отмене контекста или достижении конца файла канал закрывается.
---
## 2. Движок анализа (`internal/analyzer`)
### Назначение
Центральный компонент, который принимает пакеты и распределяет их по детекторам.
### Файлы
| Файл | Описание |
|------|----------|
| `engine.go` | Структура `Engine`, методы `Run`, `Reset`, `Stats` |
| `detector.go` | Интерфейс `Detector`, вспомогательная структура `timeWindow` |
| `portscan.go` | Детектор сканирования портов |
| `bruteforce.go` | Детектор перебора паролей |
| `dos.go` | Детектор DoS/DDoS атак |
| `arp.go` | Детектор ARP-спуфинга |
| `dns.go` | Детектор DNS-аномалий |
### Интерфейс детектора
```go
type Detector interface {
Name() string // уникальное имя
Analyze(pkt gopacket.Packet) []Alert // анализ пакета, возврат алертов
Reset() // сброс внутреннего состояния
}
```
### Sliding Window
Все детекторы используют скользящее временное окно (`timeWindow`) для хранения событий. При каждом обращении устаревшие записи автоматически удаляются:
```
Время: t-10s ... t-5s ... t-3s ... now
События: [old] [old] [keep] [keep] [keep]
cutoff = now - window_size
```
### Дедупликация алертов
Каждый детектор хранит `alerted map[string]time.Time` — время последнего алерта по ключу (IP, IP:port и т.д.). Повторный алерт генерируется не чаще одного раза в 30–60 секунд на один источник.
---
## 3. Модуль хранения данных (`internal/storage`)
### Назначение
Персистентное хранение алертов, статистики пакетов и сессий захвата.
### Файлы
| Файл | Описание |
|------|----------|
| `models.go` | GORM-модели: `Alert`, `PacketStat`, `CaptureSession` |
| `db.go` | Структура `DB`, CRUD-методы, агрегирующие запросы |
### Модели данных
**Alert** — запись об обнаруженной угрозе:
```
ID | CreatedAt | Type | Severity | SrcIP | DstIP | SrcPort | DstPort | Protocol | Description | Count
```
**PacketStat** — статистика по пакетам (сохраняется каждый 10-й пакет):
```
ID | CreatedAt | Protocol | SrcIP | DstIP | Bytes
```
**CaptureSession** — сессия захвата трафика:
```
ID | CreatedAt | EndedAt | Source | Mode | Status | TotalPkts | TotalBytes
```
### Методы агрегации
| Метод | SQL-эквивалент |
|-------|---------------|
| `AlertsBySeverity()` | `SELECT severity, COUNT(*) GROUP BY severity` |
| `AlertsByType()` | `SELECT type, COUNT(*) GROUP BY type` |
| `TopSourceIPs(n)` | `SELECT src_ip, COUNT(*) GROUP BY src_ip ORDER BY count DESC LIMIT n` |
| `AlertsTimeline(h)` | `SELECT strftime('%H:00', created_at), COUNT(*) GROUP BY hour` |
---
## 4. API сервер (`internal/api`)
### Назначение
HTTP сервер на базе Gin, предоставляет REST API для фронтенда.
### Файлы
| Файл | Описание |
|------|----------|
| `server.go` | Инициализация Gin, регистрация маршрутов, CORS middleware |
| `handlers/alerts.go` | GET /api/alerts, DELETE /api/alerts, DELETE /api/alerts/:id |
| `handlers/stats.go` | GET /api/stats/summary, /timeline, /top-ips |
| `handlers/capture.go` | Управление захватом: start, stop, upload, status, sessions |
| `handlers/reports.go` | GET /api/reports/generate — скачать PDF |
### CORS
Сервер разрешает запросы с любого origin (`*`) для работы Nuxt dev-сервера на порту 3000.
### Управление состоянием захвата
`CaptureHandler` хранит указатель `active *CaptureState`, который содержит `context.CancelFunc` для остановки текущей сессии. Одновременно может быть только одна активная сессия.
---
## 5. Генератор отчётов (`internal/report`)
### Назначение
Формирование PDF-отчёта по всем накопленным данным.
### Файлы
| Файл | Описание |
|------|----------|
| `pdf.go` | Структура `Data`, функция `Generate(data) ([]byte, error)` |
### Структура отчёта
1. **Заголовок** — название системы, дата формирования, версия
2. **Метаданные сессии** — источник трафика, режим, длительность, объём
3. **Сводка по критичности** — таблица с цветовой маркировкой (критический / высокий / средний / низкий)
4. **Типы угроз** — таблица по типам обнаруженных атак
5. **Топ источников** — 10 наиболее активных IP-адресов
6. **Журнал инцидентов** — детальная таблица до 200 алертов
### Библиотека maroto v2
Grid-based компоновка: страница делится на 12 колонок, строки (`row`) содержат колонки (`col`), в колонках размещаются компоненты (`text`, `line`).
---
## 6. Фронтенд (`frontend/`)
### Назначение
Веб-панель управления для мониторинга, управления захватом и просмотра статистики.
### Структура
| Путь | Назначение |
|------|-----------|
| `pages/index.vue` | Дашборд: карточки метрик, графики, топ IP, последние алерты |
| `pages/alerts.vue` | Журнал алертов с фильтрацией и пагинацией |
| `pages/capture.vue` | Управление захватом (live + PCAP upload), история сессий |
| `pages/stats.vue` | Статистика: timeline, распределение по типам и критичности |
| `layouts/default.vue` | Боковое меню, заголовок, индикатор статуса захвата |
| `composables/useApi.ts` | Базовый HTTP клиент (get, post, del, upload) |
| `composables/useCaptureStatus.ts` | Глобальный polling статуса захвата (каждые 3 сек) |
| `components/MetricCard.vue` | Карточка числовой метрики с иконкой |
| `components/SeverityBadge.vue` | Цветной бейдж уровня критичности |
+356
View File
@@ -0,0 +1,356 @@
# Руководство по тестированию
## Подготовка окружения
Для тестирования нужны две машины в одной сети (или две VM), либо одна машина с loopback-интерфейсом.
```
[Машина A — NIDS] [Машина B — Атакующий]
192.168.1.100 192.168.1.200
sudo ./nids nmap / hping3 / hydra
http://localhost:3000
```
**Запуск системы перед каждым тестом:**
```bash
# Терминал 1 — бэкенд
sudo ./nids
# Терминал 2 — фронтенд
cd frontend && bun run dev
# Открыть в браузере: http://localhost:3000
# Перейти на страницу «Захват» → выбрать интерфейс → «Начать захват»
```
---
## Тест 1: Сканирование портов (Port Scan)
**Инструмент:** `nmap`
**Установка:**
```bash
# Debian/Ubuntu
sudo apt install nmap
# Arch
sudo pacman -S nmap
```
**Запуск атаки (с машины B):**
```bash
# SYN-scan — самый распространённый метод сканирования
sudo nmap -sS 192.168.1.100
# Агрессивное сканирование всех портов
sudo nmap -sS -p- --min-rate 1000 192.168.1.100
# Сканирование конкретного диапазона
sudo nmap -sS -p 1-1000 192.168.1.100
```
**Ожидаемый результат:**
```
Тип алерта: port_scan
Критичность: high
Описание: Port scan detected: 15 unique ports probed in 10s window
SrcIP: 192.168.1.200
```
**Что проверить:**
- Алерт появляется в журнале (~10 секунд после начала сканирования)
- SrcIP совпадает с адресом машины B
- Счётчик `Count` отражает количество уникальных портов
**Порог срабатывания:** 15 уникальных портов за 10 секунд (настраивается в `config.yaml`).
---
## Тест 2: Перебор паролей (Brute Force)
**Инструмент:** `hydra` или `medusa`
**Установка:**
```bash
# Debian/Ubuntu
sudo apt install hydra
# Arch
sudo pacman -S hydra
```
**Требование:** на машине A должен быть запущен SSH-сервер:
```bash
sudo systemctl start sshd
```
**Запуск атаки (с машины B):**
```bash
# Brute-force SSH с небольшим словарём
hydra -l root -P /usr/share/wordlists/rockyou.txt \
-t 4 -V ssh://192.168.1.100
# Быстрый тест с inline паролями
hydra -l admin -p pass1,pass2,pass3,pass4,pass5,pass6,pass7,pass8,pass9,pass10,pass11 \
ssh://192.168.1.100
# Только SYN-флуд на порт 22 (без hydra)
sudo hping3 -S -p 22 --flood 192.168.1.100
```
**Ожидаемый результат:**
```
Тип алерта: brute_force
Критичность: critical
Описание: Brute-force detected on port 22: 10 connection attempts in 30s
SrcIP: 192.168.1.200
DstPort: 22
```
**Другие мониторируемые порты:**
```bash
# FTP (21)
hydra -l admin -P wordlist.txt ftp://192.168.1.100
# RDP (3389) — если запущен xrdp
hydra -l administrator -P wordlist.txt rdp://192.168.1.100
# Telnet (23)
hydra -l root -P wordlist.txt telnet://192.168.1.100
```
**Порог срабатывания:** 10 попыток за 30 секунд на один порт.
---
## Тест 3: DoS / DDoS атака
**Инструмент:** `hping3`
**Установка:**
```bash
# Debian/Ubuntu
sudo apt install hping3
# Arch
sudo pacman -S hping3
```
**Запуск атаки (с машины B):**
```bash
# SYN flood — классический DoS
sudo hping3 -S --flood -V -p 80 192.168.1.100
# ICMP flood
sudo hping3 --icmp --flood 192.168.1.100
# UDP flood
sudo hping3 --udp --flood -p 53 192.168.1.100
# С заданной скоростью (1500 пакетов/сек)
sudo hping3 -S -p 80 --faster -c 10000 192.168.1.100
```
**Ожидаемый результат:**
```
Тип алерта: dos
Критичность: critical
Описание: DoS attack detected from 192.168.1.200: 1234 pkt/s (threshold: 1000)
SrcIP: 192.168.1.200
Count: 1234
```
**Проверка без второй машины (loopback):**
```bash
# Флуд на локальный интерфейс (lo)
sudo hping3 -S --flood -V 127.0.0.1
# Бэкенд при этом нужно запустить с захватом на интерфейсе lo
```
**Порог срабатывания:** 1000 пакетов/сек от одного источника.
---
## Тест 4: ARP Spoofing
**Инструмент:** `arpspoof` (пакет `dsniff`) или `ettercap`
**Установка:**
```bash
# Debian/Ubuntu
sudo apt install dsniff
# Arch
sudo pacman -S dsniff
```
**Запуск атаки (с машины B):**
```bash
# Подмена ARP: сообщаем машине A, что мы — шлюз (192.168.1.1)
sudo arpspoof -i eth0 -t 192.168.1.100 192.168.1.1
# Двусторонняя подмена (полный MITM)
# Терминал 1:
sudo arpspoof -i eth0 -t 192.168.1.100 192.168.1.1
# Терминал 2:
sudo arpspoof -i eth0 -t 192.168.1.1 192.168.1.100
```
**Альтернатива — вручную через `arping`:**
```bash
# Отправить ARP Reply с поддельным MAC
sudo arping -c 5 -A -I eth0 192.168.1.100
```
**Ожидаемый результат:**
```
Тип алерта: arp_spoof
Критичность: critical
Описание: ARP spoofing: IP 192.168.1.1 changed MAC from aa:bb:cc:dd:ee:ff to 11:22:33:44:55:66
SrcIP: 192.168.1.1
Protocol: ARP
```
**Важно:** детектор срабатывает только если IP уже известен из предыдущих ARP-пакетов. Первая запись в таблицу — штатная, второй пакет с другим MAC — алерт.
---
## Тест 5: DNS Аномалии
### 5A. DNS-туннелирование
**Инструмент:** `iodine` или ручная генерация
**Установка:**
```bash
# Debian/Ubuntu
sudo apt install iodine
# Arch
sudo pacman -S iodine
```
**Имитация туннелирования вручную (через `dig`):**
```bash
# Запрос с очень длинной меткой (>50 символов) — триггер детектора
dig "aGVsbG8td29ybGQtdGhpcy1pcy1hLXZlcnktbG9uZy1zdWJkb21haW4.example.com" @8.8.8.8
# Скрипт для многократной отправки
for i in $(seq 1 20); do
dig "$(head -c 60 /dev/urandom | base64 | tr -d '\n/' | head -c 55).test.local" &
done
```
**Ожидаемый результат:**
```
Тип алерта: dns_anomaly
Критичность: medium
Описание: DNS tunneling suspected: label length 55 in query 'aGVsbG8t...test.local'
```
### 5B. Высокая частота DNS-запросов
**Инструмент:** `dnsperf` или bash-скрипт
```bash
# Установка dnsperf
sudo apt install dnsperf # Debian/Ubuntu
# Генерация файла запросов
for i in $(seq 1 500); do
echo "host$i.example.com A"
done > /tmp/dns-queries.txt
# Flood (200+ запросов за 60 секунд)
dnsperf -s 8.8.8.8 -d /tmp/dns-queries.txt -Q 50 -l 30
# Простой bash-вариант без dnsperf
for i in $(seq 1 250); do
dig "sub$i.example.com" @8.8.8.8 +short &
done
```
**Ожидаемый результат:**
```
Тип алерта: dns_anomaly
Критичность: medium
Описание: High DNS query rate from 192.168.1.200: 200 queries in 60s
```
---
## Тест через готовые PCAP файлы (без второй машины)
Это самый простой способ протестировать все детекторы одновременно.
**Рекомендуемые источники PCAP:**
| Источник | Описание |
|----------|----------|
| [Wireshark Sample Captures](https://wiki.wireshark.org/SampleCaptures) | Коллекция тестовых захватов |
| [Malware Traffic Analysis](https://www.malware-traffic-analysis.net/) | Реальный вредоносный трафик |
| [NETRESEC](https://www.netresec.com/?page=PcapFiles) | Тематические PCAP с атаками |
| [CTF captures](https://github.com/ctfs) | Захваты с соревнований |
**Скачать конкретные файлы:**
```bash
# Nmap scan capture
wget https://wiki.wireshark.org/SampleCaptures?action=AttachFile&do=get&target=nmap.pcap \
-O test-portscan.pcap
# ARP примеры
wget https://wiki.wireshark.org/SampleCaptures?action=AttachFile&do=get&target=arp-storm.pcap \
-O test-arp.pcap
```
**Использование:**
1. Открыть `http://localhost:3000/capture`
2. Перейти на вкладку **PCAP файл**
3. Перетащить файл → нажать **Анализировать**
4. Алерты появятся в журнале через несколько секунд
---
## Проверка API напрямую (без браузера)
```bash
# Список интерфейсов
curl http://localhost:8080/api/capture/interfaces
# Статус захвата
curl http://localhost:8080/api/capture/status
# Начать захват на eth0
curl -X POST http://localhost:8080/api/capture/start \
-H "Content-Type: application/json" \
-d '{"interface": "eth0"}'
# Все алерты
curl http://localhost:8080/api/alerts | python3 -m json.tool
# Алерты по типу
curl "http://localhost:8080/api/alerts?type=port_scan&page_size=10"
# Сводка статистики
curl http://localhost:8080/api/stats/summary | python3 -m json.tool
# Остановить захват
curl -X POST http://localhost:8080/api/capture/stop
# Скачать PDF отчёт
curl http://localhost:8080/api/reports/generate -o report.pdf
```
---
## Таблица ожидаемых результатов
| Тест | Инструмент | Алерт | Критичность | Время до срабатывания |
|------|------------|-------|-------------|----------------------|
| Port Scan | `nmap -sS` | `port_scan` | high | ~10 сек |
| Brute Force SSH | `hydra` | `brute_force` | critical | ~30 сек |
| SYN Flood | `hping3 --flood` | `dos` | critical | ~1 сек |
| ARP Spoof | `arpspoof` | `arp_spoof` | critical | мгновенно |
| DNS Tunnel | `dig` длинный | `dns_anomaly` | medium | мгновенно |
| DNS Flood | `dnsperf` | `dns_anomaly` | medium | ~60 сек |
+108
View File
@@ -0,0 +1,108 @@
# Руководство пользователя
## Интерфейс веб-панели
Панель управления доступна по адресу `http://localhost:3000` после запуска фронтенда.
### Боковое меню
| Пункт | Страница | Описание |
|-------|----------|----------|
| Обзор | `/` | Дашборд с метриками и графиками |
| Алерты | `/alerts` | Журнал всех обнаруженных угроз |
| Захват | `/capture` | Управление захватом трафика |
| Статистика | `/stats` | Детальные графики и сводные таблицы |
В нижней части меню — индикатор состояния захвата (зелёный пульс = активен).
---
## Страница «Обзор» (Dashboard)
Главная страница отображает:
**Карточки метрик** — количество алертов по уровню критичности:
- Критический (красный)
- Высокий (оранжевый)
- Средний (жёлтый)
- Низкий (зелёный)
**График «Алерты за 24 часа»** — временная шкала событий с часовой детализацией.
**Диаграмма «Типы атак»** — donut-чарт с распределением по типам угроз.
**Топ источников** — 5 наиболее активных IP-адресов с прогресс-барами.
**Последние инциденты** — 8 последних алертов с описанием. Данные обновляются автоматически каждые 10 секунд.
---
## Страница «Алерты»
### Фильтрация
- **Тип атаки** — Port Scan, Brute Force, DoS/DDoS, ARP Spoof, DNS Anomaly
- **Критичность** — критический, высокий, средний, низкий
- **IP источника** — фильтр по точному совпадению
Нажмите **Найти** для применения фильтров, **Сбросить** — для очистки.
### Таблица алертов
Колонки: Время · Критичность · Тип · Источник · Назначение · Описание
- **Страница** — 25 записей, навигация внизу таблицы
- **Удалить алерт** — кнопка с иконкой корзины в строке
- **Очистить всё** — удаляет все алерты из БД (необратимо)
---
## Страница «Захват»
### Live захват
1. Выберите сетевой интерфейс из выпадающего списка (кнопка ↺ обновляет список)
2. Нажмите **Начать захват**
3. Бэкенд должен быть запущен с правами root (`sudo ./nids`)
4. Для остановки — кнопка **Остановить захват** (в заголовке или на странице)
### Анализ PCAP файла
1. Перейдите на вкладку **PCAP файл**
2. Перетащите `.pcap` или `.pcapng` файл в зону загрузки (или нажмите «Выбрать файл»)
3. Нажмите **Анализировать**
4. После завершения алерты появятся в журнале
### История сессий
Таблица последних 20 сессий захвата: источник, режим, статус, количество пакетов, объём трафика.
---
## Страница «Статистика»
### Период наблюдения
Кнопки: **6ч · 24ч · 2д · 7д** — переключают временной диапазон для всех графиков.
### Графики
- **Динамика алертов** — столбчатая диаграмма по часам
- **По типам атак** — donut с легендой
- **По критичности** — donut с легендой
- **Топ-10 источников** — горизонтальная столбчатая диаграмма
### Сводная таблица
Итоговые числа по критичности и типам атак, общее количество.
---
## Генерация PDF-отчёта
Кнопка **PDF отчёт** доступна в заголовке на любой странице.
Отчёт содержит:
1. Заголовок с датой формирования
2. Метаданные последней сессии
3. Сводку по критичности
4. Таблицу типов угроз
5. Топ-10 источников атак
6. Журнал инцидентов (до 200 записей)
Файл сохраняется как `nids-report-YYYY-MM-DD_HH-MM.pdf`.
+6
View File
@@ -0,0 +1,6 @@
export default defineAppConfig({
ui: {
primary: 'blue',
gray: 'neutral',
},
})
+5
View File
@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
+1822
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
<template>
<UCard :ui="cardUi">
<div class="flex items-center gap-4">
<div
class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="bgClass"
>
<UIcon :name="icon" class="w-5 h-5" :class="iconClass" />
</div>
<div>
<p class="text-2xl font-bold text-white leading-none">{{ value.toLocaleString() }}</p>
<p class="text-xs text-gray-400 mt-1">{{ label }}</p>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
const props = defineProps<{
label: string
value: number
icon: string
color: 'red' | 'orange' | 'yellow' | 'green' | 'blue'
}>()
const cardUi = {
base: 'bg-gray-900 border border-gray-800',
body: { base: 'px-4 py-4' },
}
const bgClass = computed(() => ({
red: 'bg-red-500/15',
orange: 'bg-orange-500/15',
yellow: 'bg-yellow-500/15',
green: 'bg-green-500/15',
blue: 'bg-blue-500/15',
}[props.color]))
const iconClass = computed(() => ({
red: 'text-red-400',
orange: 'text-orange-400',
yellow: 'text-yellow-400',
green: 'text-green-400',
blue: 'text-blue-400',
}[props.color]))
</script>
+26
View File
@@ -0,0 +1,26 @@
<template>
<UBadge :color="badgeColor" variant="soft" size="xs" class="uppercase tracking-wide font-semibold">
{{ label }}
</UBadge>
</template>
<script setup lang="ts">
const props = defineProps<{ severity: string }>()
const LABELS: Record<string, string> = {
critical: 'Крит',
high: 'Высок',
medium: 'Средн',
low: 'Низк',
}
const COLORS: Record<string, 'red' | 'orange' | 'yellow' | 'green' | 'gray'> = {
critical: 'red',
high: 'orange',
medium: 'yellow',
low: 'green',
}
const label = computed(() => LABELS[props.severity] ?? props.severity)
const badgeColor = computed(() => COLORS[props.severity] ?? 'gray')
</script>
+38
View File
@@ -0,0 +1,38 @@
// Базовый fetch к Go API
export function useApi() {
async function get<T>(path: string, params?: Record<string, string | number>) {
const url = new URL('/api' + path, window.location.origin)
if (params) {
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, String(v)))
}
const res = await fetch(url.toString())
if (!res.ok) throw new Error(await res.text())
return res.json() as Promise<T>
}
async function post<T>(path: string, body?: unknown) {
const res = await fetch('/api' + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) throw new Error(await res.text())
return res.json() as Promise<T>
}
async function del(path: string) {
const res = await fetch('/api' + path, { method: 'DELETE' })
if (!res.ok) throw new Error(await res.text())
return res.json()
}
async function upload(path: string, file: File) {
const form = new FormData()
form.append('file', file)
const res = await fetch('/api' + path, { method: 'POST', body: form })
if (!res.ok) throw new Error(await res.text())
return res.json()
}
return { get, post, del, upload }
}
+59
View File
@@ -0,0 +1,59 @@
interface CaptureStatus {
running: boolean
session_id?: number
source?: string
mode?: string
pkts?: number
bytes?: number
last_session?: {
id: number
source: string
mode: string
status: string
total_pkts: number
total_bytes: number
created_at: string
ended_at?: string
}
}
// Синглтон — один интервал на всё приложение
let interval: number | null = null
const status = ref<CaptureStatus>({ running: false })
export function useCaptureStatus() {
const { get, post } = useApi()
async function fetchStatus() {
try {
status.value = await get<CaptureStatus>('/capture/status')
} catch {}
}
function startPolling() {
if (interval || !import.meta.client) return
fetchStatus()
interval = window.setInterval(fetchStatus, 3000)
}
function stopPolling() {
if (interval) {
clearInterval(interval)
interval = null
}
}
async function stopCapture() {
try {
await post('/capture/stop')
await fetchStatus()
} catch {}
}
onMounted(startPolling)
onUnmounted(() => {
// Не останавливаем polling при размонтировании layout'а
})
return { status, fetchStatus, stopCapture, startPolling, stopPolling }
}
+116
View File
@@ -0,0 +1,116 @@
<template>
<div class="min-h-screen bg-gray-950 text-gray-100 flex">
<!-- Sidebar -->
<aside class="w-60 shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
<!-- Logo -->
<div class="px-5 py-5 border-b border-gray-800">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center shrink-0">
<UIcon name="i-heroicons-shield-exclamation" class="text-white w-5 h-5" />
</div>
<div>
<p class="text-sm font-bold text-white leading-tight">DIP-IDS</p>
<p class="text-[10px] text-gray-400 leading-tight">Network IDS</p>
</div>
</div>
</div>
<!-- Navigation -->
<nav class="flex-1 px-3 py-4 space-y-0.5">
<NuxtLink
v-for="item in navigation"
:key="item.to"
:to="item.to"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors"
:class="isActive(item.to)
? 'bg-blue-600/20 text-blue-400 font-medium'
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'"
>
<UIcon :name="item.icon" class="w-4.5 h-4.5 shrink-0" />
{{ item.label }}
</NuxtLink>
</nav>
<!-- Status indicator -->
<div class="px-4 py-4 border-t border-gray-800">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="captureRunning ? 'bg-green-400 animate-pulse' : 'bg-gray-600'"
/>
<span class="text-xs text-gray-400">
{{ captureRunning ? 'Захват активен' : 'Захват остановлен' }}
</span>
</div>
</div>
</aside>
<!-- Main content -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Top bar -->
<header class="h-14 border-b border-gray-800 bg-gray-900/50 flex items-center px-6 gap-4 shrink-0">
<h1 class="text-sm font-semibold text-gray-200 flex-1">{{ pageTitle }}</h1>
<UButton
v-if="captureRunning"
color="red"
variant="soft"
size="xs"
icon="i-heroicons-stop-circle"
@click="stopCapture"
>
Остановить захват
</UButton>
<UButton
color="blue"
variant="soft"
size="xs"
icon="i-heroicons-document-arrow-down"
@click="downloadReport"
>
PDF отчёт
</UButton>
</header>
<!-- Page content -->
<main class="flex-1 overflow-auto p-6">
<slot />
</main>
</div>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const { status, stopCapture } = useCaptureStatus()
const captureRunning = computed(() => status.value?.running ?? false)
const navigation = [
{ to: '/', label: 'Дашборд', icon: 'i-heroicons-chart-bar-square' },
{ to: '/alerts', label: 'Алерты', icon: 'i-heroicons-bell-alert' },
{ to: '/capture', label: 'Захват', icon: 'i-heroicons-radio' },
{ to: '/stats', label: 'Статистика', icon: 'i-heroicons-chart-pie' },
]
const pageTitles: Record<string, string> = {
'/': 'Обзор',
'/alerts': 'Журнал алертов',
'/capture': 'Управление захватом',
'/stats': 'Статистика трафика',
}
const pageTitle = computed(() => pageTitles[route.path] ?? 'DIP-IDS')
function isActive(to: string) {
return to === '/' ? route.path === '/' : route.path.startsWith(to)
}
function downloadReport() {
const a = document.createElement('a')
a.href = '/api/reports/generate'
a.download = `nids-report-${new Date().toISOString().slice(0, 10)}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
</script>
+31
View File
@@ -0,0 +1,31 @@
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
ui: {
global: true,
},
colorMode: {
preference: 'dark',
},
// Проксируем только маршруты Go бэкенда (не трогаем /api/_nuxt_icon и др. внутренние)
routeRules: {
'/api/alerts/**': { proxy: 'http://localhost:8080/api/alerts/**' },
'/api/alerts': { proxy: 'http://localhost:8080/api/alerts' },
'/api/stats/**': { proxy: 'http://localhost:8080/api/stats/**' },
'/api/capture/**': { proxy: 'http://localhost:8080/api/capture/**' },
'/api/reports/**': { proxy: 'http://localhost:8080/api/reports/**' },
},
app: {
head: {
title: 'DIP-IDS — Network Intrusion Detection System',
meta: [
{ name: 'description', content: 'Система обнаружения вторжений на основе анализа сетевого трафика' },
],
},
},
compatibilityDate: '2024-10-01',
})
+17
View File
@@ -0,0 +1,17 @@
{
"name": "dip-ids-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview"
},
"dependencies": {
"@nuxt/ui": "^2.18.4",
"chart.js": "^4.4.4",
"vue-chartjs": "^5.3.1",
"nuxt": "^3.13.2"
}
}
+266
View File
@@ -0,0 +1,266 @@
<template>
<div class="space-y-4">
<!-- Filters -->
<UCard :ui="cardUi">
<div class="flex flex-wrap items-end gap-3">
<div class="flex-1 min-w-36">
<label class="text-xs text-gray-400 mb-1 block">Тип атаки</label>
<USelect
v-model="filters.type"
:options="typeOptions"
size="sm"
placeholder="Все типы"
:ui="selectUi"
/>
</div>
<div class="flex-1 min-w-36">
<label class="text-xs text-gray-400 mb-1 block">Критичность</label>
<USelect
v-model="filters.severity"
:options="severityOptions"
size="sm"
placeholder="Все уровни"
:ui="selectUi"
/>
</div>
<div class="flex-1 min-w-36">
<label class="text-xs text-gray-400 mb-1 block">IP источника</label>
<UInput
v-model="filters.src_ip"
size="sm"
placeholder="192.168.0.1"
:ui="inputUi"
/>
</div>
<div class="flex gap-2">
<UButton size="sm" color="blue" icon="i-heroicons-magnifying-glass" @click="loadAlerts">
Найти
</UButton>
<UButton size="sm" color="gray" variant="soft" icon="i-heroicons-x-mark" @click="resetFilters">
Сбросить
</UButton>
<UButton
size="sm"
color="red"
variant="soft"
icon="i-heroicons-trash"
:loading="clearing"
@click="clearAll"
>
Очистить всё
</UButton>
</div>
</div>
</UCard>
<!-- Table -->
<UCard :ui="cardUi">
<template #header>
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-gray-200">
Инциденты
<span class="text-gray-500 font-normal ml-1">({{ total }})</span>
</p>
<div class="flex gap-2">
<UBadge
v-for="s in activeSeverities"
:key="s.severity"
:color="severityColor(s.severity)"
variant="soft"
size="xs"
>
{{ s.label }}: {{ s.count }}
</UBadge>
</div>
</div>
</template>
<UTable
:rows="alerts"
:columns="columns"
:loading="loading"
:ui="tableUi"
>
<template #severity-data="{ row }">
<SeverityBadge :severity="row.severity" />
</template>
<template #attack_type-data="{ row }">
<span class="text-xs font-mono text-blue-300">{{ typeLabel(row.attack_type) }}</span>
</template>
<template #src_ip-data="{ row }">
<span class="font-mono text-xs text-gray-300">{{ row.src_ip }}</span>
</template>
<template #dst_ip-data="{ row }">
<span class="font-mono text-xs text-gray-400">
{{ row.dst_ip }}{{ row.dst_port ? ':' + row.dst_port : '' }}
</span>
</template>
<template #created_at-data="{ row }">
<span class="text-xs text-gray-400">{{ formatTime(row.created_at) }}</span>
</template>
<template #description-data="{ row }">
<span class="text-xs text-gray-300">{{ row.description }}</span>
</template>
<template #actions-data="{ row }">
<UButton
icon="i-heroicons-trash"
size="xs"
color="red"
variant="ghost"
@click="deleteAlert(row.id)"
/>
</template>
</UTable>
<!-- Pagination -->
<div class="flex justify-between items-center mt-4 pt-4 border-t border-gray-800">
<p class="text-xs text-gray-500">
Показано {{ alerts.length }} из {{ total }}
</p>
<UPagination
v-model="page"
:page-count="pageSize"
:total="total"
size="xs"
:ui="{ wrapper: 'gap-1' }"
@update:model-value="loadAlerts"
/>
</div>
</UCard>
</div>
</template>
<script setup lang="ts">
const { get, del } = useApi()
const alerts = ref<any[]>([])
const total = ref(0)
const loading = ref(false)
const clearing = ref(false)
const page = ref(1)
const pageSize = 25
const filters = reactive({
type: '',
severity: '',
src_ip: '',
})
const columns = [
{ key: 'created_at', label: 'Время', sortable: false },
{ key: 'severity', label: 'Критичность', sortable: false },
{ key: 'attack_type', label: 'Тип', sortable: false },
{ key: 'src_ip', label: 'Источник', sortable: false },
{ key: 'dst_ip', label: 'Назначение', sortable: false },
{ key: 'description', label: 'Описание', sortable: false },
{ key: 'actions', label: '', sortable: false },
]
const typeOptions = [
{ label: 'Все типы', value: '' },
{ label: 'Port Scan', value: 'port_scan' },
{ label: 'Brute Force', value: 'brute_force' },
{ label: 'DoS/DDoS', value: 'dos' },
{ label: 'ARP Spoof', value: 'arp_spoof' },
{ label: 'DNS Anomaly', value: 'dns_anomaly' },
]
const severityOptions = [
{ label: 'Все уровни', value: '' },
{ label: 'Критический', value: 'critical' },
{ label: 'Высокий', value: 'high' },
{ label: 'Средний', value: 'medium' },
{ label: 'Низкий', value: 'low' },
]
async function loadAlerts() {
loading.value = true
try {
const params: Record<string, string | number> = {
page: page.value,
page_size: pageSize,
}
if (filters.type) params.type = filters.type
if (filters.severity) params.severity = filters.severity
if (filters.src_ip) params.src_ip = filters.src_ip
const res = await get<any>('/alerts', params)
// Копируем type → attack_type, т.к. Vue 3 перехватывает .type на реактивных объектах
alerts.value = (res.data ?? []).map((a: any) => ({ ...a, attack_type: a.type }))
total.value = res.total ?? 0
} finally {
loading.value = false
}
}
async function deleteAlert(id: number) {
await del(`/alerts/${id}`)
loadAlerts()
}
async function clearAll() {
clearing.value = true
try {
await del('/alerts')
await loadAlerts()
} finally {
clearing.value = false
}
}
function resetFilters() {
filters.type = ''
filters.severity = ''
filters.src_ip = ''
page.value = 1
loadAlerts()
}
const activeSeverities = computed(() => {
const counts: Record<string, number> = {}
alerts.value.forEach(a => { counts[a.severity] = (counts[a.severity] ?? 0) + 1 })
return Object.entries(counts).map(([severity, count]) => ({
severity,
count,
label: { critical: 'Крит', high: 'Выс', medium: 'Ср', low: 'Низ' }[severity] ?? severity,
}))
})
function severityColor(s: string): 'red' | 'orange' | 'yellow' | 'green' | 'gray' {
return ({ critical: 'red', high: 'orange', medium: 'yellow', low: 'green' } as any)[s] ?? 'gray'
}
const TYPE_LABELS: Record<string, string> = {
port_scan: 'Port Scan',
brute_force: 'Brute Force',
dos: 'DoS/DDoS',
arp_spoof: 'ARP Spoof',
dns_anomaly: 'DNS Anomaly',
}
function typeLabel(t: string | undefined): string {
if (!t) return '—'
return TYPE_LABELS[t] ?? t
}
function formatTime(iso: string) {
return new Date(iso).toLocaleString('ru-RU', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
}
onMounted(loadAlerts)
// UI tokens
const cardUi = { base: 'bg-gray-900 border border-gray-800', header: { base: 'border-b border-gray-800 px-4 py-3' }, body: { base: 'px-4 py-3' } }
const tableUi = { wrapper: 'overflow-x-auto', td: { base: 'py-2' }, th: { base: 'text-gray-400 text-xs font-medium' } }
const selectUi = { base: 'bg-gray-800 border-gray-700 text-gray-200 text-sm' }
const inputUi = { base: 'bg-gray-800 border-gray-700 text-gray-200 text-sm' }
</script>
+251
View File
@@ -0,0 +1,251 @@
<template>
<div class="space-y-4 max-w-4xl">
<!-- Status banner -->
<UAlert
v-if="status.running"
icon="i-heroicons-radio"
color="green"
variant="soft"
:title="`Захват активен — ${status.source} (${modeLabel(status.mode)})`"
:description="`Пакетов: ${status.pkts?.toLocaleString() ?? 0} · Трафик: ${humanBytes(status.bytes ?? 0)}`"
>
<template #actions>
<UButton color="red" variant="soft" size="xs" @click="doStop">Остановить</UButton>
</template>
</UAlert>
<!-- Tabs: Live / PCAP -->
<UCard :ui="cardUi">
<template #header>
<p class="text-sm font-semibold text-gray-200">Источник трафика</p>
</template>
<UTabs :items="tabs" :ui="tabsUi">
<!-- Live capture tab -->
<template #live>
<div class="space-y-4 pt-4">
<div>
<label class="text-xs text-gray-400 mb-1.5 block">Сетевой интерфейс</label>
<div class="flex gap-2">
<USelect
v-model="liveIface"
:options="ifaceOptions"
:disabled="status.running"
class="flex-1"
:ui="selectUi"
/>
<UButton
color="gray"
variant="soft"
size="sm"
icon="i-heroicons-arrow-path"
:loading="loadingIfaces"
@click="loadInterfaces"
/>
</div>
</div>
<UAlert
icon="i-heroicons-information-circle"
color="blue"
variant="soft"
title="Требуются права суперпользователя"
description="Live-захват использует raw sockets. Запустите бэкенд через sudo."
/>
<UButton
:color="status.running ? 'red' : 'green'"
:icon="status.running ? 'i-heroicons-stop-circle' : 'i-heroicons-play-circle'"
:loading="actionLoading"
size="sm"
@click="status.running ? doStop() : doStartLive()"
>
{{ status.running ? 'Остановить захват' : 'Начать захват' }}
</UButton>
</div>
</template>
<!-- PCAP upload tab -->
<template #pcap>
<div class="space-y-4 pt-4">
<div
class="border-2 border-dashed border-gray-700 rounded-xl p-8 text-center transition-colors"
:class="dragging ? 'border-blue-500 bg-blue-500/5' : 'hover:border-gray-600'"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDrop"
>
<UIcon name="i-heroicons-document-arrow-up" class="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p class="text-sm text-gray-400 mb-1">
{{ pcapFile ? pcapFile.name : 'Перетащите .pcap файл или выберите' }}
</p>
<p class="text-xs text-gray-600 mb-3">Поддерживается формат libpcap / pcapng</p>
<label class="cursor-pointer">
<UButton color="gray" variant="soft" size="sm" as="span">
Выбрать файл
</UButton>
<input type="file" accept=".pcap,.pcapng" class="hidden" @change="onFileSelect" />
</label>
</div>
<UButton
color="blue"
icon="i-heroicons-arrow-up-tray"
:disabled="!pcapFile || status.running"
:loading="actionLoading"
size="sm"
@click="doUploadPCAP"
>
{{ pcapFile ? `Анализировать ${pcapFile.name}` : 'Выберите файл' }}
</UButton>
</div>
</template>
</UTabs>
</UCard>
<!-- Sessions history -->
<UCard :ui="cardUi">
<template #header>
<p class="text-sm font-semibold text-gray-200">История сессий</p>
</template>
<UTable :rows="sessions" :columns="sessionColumns" :ui="tableUi">
<template #mode-data="{ row }">
<UBadge :color="row.mode === 'live' ? 'blue' : 'purple'" variant="soft" size="xs">
{{ modeLabel(row.mode) }}
</UBadge>
</template>
<template #status-data="{ row }">
<UBadge :color="row.status === 'running' ? 'green' : 'gray'" variant="soft" size="xs">
{{ statusLabel(row.status) }}
</UBadge>
</template>
<template #total_pkts-data="{ row }">
<span class="text-xs font-mono">{{ row.total_pkts.toLocaleString() }}</span>
</template>
<template #total_bytes-data="{ row }">
<span class="text-xs font-mono">{{ humanBytes(row.total_bytes) }}</span>
</template>
<template #created_at-data="{ row }">
<span class="text-xs text-gray-400">{{ formatTime(row.created_at) }}</span>
</template>
</UTable>
</UCard>
</div>
</template>
<script setup lang="ts">
const { post, upload, get } = useApi()
const { status, fetchStatus, stopCapture } = useCaptureStatus()
const liveIface = ref('')
const pcapFile = ref<File | null>(null)
const dragging = ref(false)
const actionLoading = ref(false)
const loadingIfaces = ref(false)
const sessions = ref<any[]>([])
const ifaceOptions = ref<{ label: string, value: string }[]>([])
const tabs = [
{ slot: 'live', label: 'Live захват', icon: 'i-heroicons-radio' },
{ slot: 'pcap', label: 'PCAP файл', icon: 'i-heroicons-document' },
]
const sessionColumns = [
{ key: 'created_at', label: 'Время' },
{ key: 'source', label: 'Источник' },
{ key: 'mode', label: 'Режим' },
{ key: 'status', label: 'Статус' },
{ key: 'total_pkts', label: 'Пакетов' },
{ key: 'total_bytes', label: 'Трафик' },
]
async function loadInterfaces() {
loadingIfaces.value = true
try {
const res = await get<any>('/capture/interfaces')
ifaceOptions.value = (res.interfaces ?? []).map((i: string) => ({ label: i, value: i }))
if (!liveIface.value && ifaceOptions.value.length) {
liveIface.value = ifaceOptions.value[0].value
}
} finally {
loadingIfaces.value = false
}
}
async function loadSessions() {
const res = await get<any>('/capture/sessions')
sessions.value = res.data ?? []
}
async function doStartLive() {
actionLoading.value = true
try {
await post('/capture/start', { interface: liveIface.value })
await fetchStatus()
await loadSessions()
} finally {
actionLoading.value = false
}
}
async function doStop() {
actionLoading.value = true
try {
await stopCapture()
await loadSessions()
} finally {
actionLoading.value = false
}
}
async function doUploadPCAP() {
if (!pcapFile.value) return
actionLoading.value = true
try {
await upload('/capture/upload', pcapFile.value)
await fetchStatus()
await loadSessions()
pcapFile.value = null
} finally {
actionLoading.value = false
}
}
function onFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (input.files?.[0]) pcapFile.value = input.files[0]
}
function onDrop(e: DragEvent) {
dragging.value = false
const file = e.dataTransfer?.files[0]
if (file) pcapFile.value = file
}
function modeLabel(m?: string) {
return m === 'live' ? 'Live' : m === 'pcap' ? 'PCAP' : m ?? ''
}
function statusLabel(s: string) {
return ({ running: 'Активна', stopped: 'Остановлена', completed: 'Завершена' } as any)[s] ?? s
}
function humanBytes(b: number) {
if (b >= 1 << 30) return `${(b / (1 << 30)).toFixed(2)} GB`
if (b >= 1 << 20) return `${(b / (1 << 20)).toFixed(2)} MB`
if (b >= 1 << 10) return `${(b / (1 << 10)).toFixed(2)} KB`
return `${b} B`
}
function formatTime(iso: string) {
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })
}
onMounted(() => { loadInterfaces(); loadSessions() })
const cardUi = { base: 'bg-gray-900 border border-gray-800', header: { base: 'border-b border-gray-800 px-4 py-3' }, body: { base: 'px-4 py-4' } }
const tableUi = { wrapper: 'overflow-x-auto', td: { base: 'py-2' }, th: { base: 'text-gray-400 text-xs font-medium' } }
const selectUi = { base: 'bg-gray-800 border-gray-700 text-gray-200' }
const tabsUi = { list: { background: 'bg-gray-800', tab: { active: 'bg-gray-700' } } }
</script>
+252
View File
@@ -0,0 +1,252 @@
<template>
<div class="space-y-6">
<!-- Metric cards -->
<div class="grid grid-cols-4 gap-4">
<MetricCard
label="Критических"
:value="summary.by_severity?.critical ?? 0"
icon="i-heroicons-fire"
color="red"
/>
<MetricCard
label="Высоких"
:value="summary.by_severity?.high ?? 0"
icon="i-heroicons-exclamation-triangle"
color="orange"
/>
<MetricCard
label="Средних"
:value="summary.by_severity?.medium ?? 0"
icon="i-heroicons-exclamation-circle"
color="yellow"
/>
<MetricCard
label="Низких"
:value="summary.by_severity?.low ?? 0"
icon="i-heroicons-information-circle"
color="green"
/>
</div>
<!-- Charts row -->
<div class="grid grid-cols-3 gap-4">
<!-- Timeline chart (2/3 width) -->
<UCard class="col-span-2" :ui="cardUi">
<template #header>
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-gray-200">Алерты за последние 24 часа</p>
<UBadge color="blue" variant="soft" size="xs">{{ summary.total ?? 0 }} всего</UBadge>
</div>
</template>
<div class="h-52">
<LineChart v-if="timelineReady" :data="timelineData" :options="lineOptions" />
<div v-else class="h-full flex items-center justify-center text-gray-600 text-sm">
Нет данных
</div>
</div>
</UCard>
<!-- Types donut (1/3 width) -->
<UCard :ui="cardUi">
<template #header>
<p class="text-sm font-semibold text-gray-200">Типы атак</p>
</template>
<div class="h-52 flex items-center justify-center">
<DoughnutChart v-if="typesReady" :data="typesData" :options="doughnutOptions" />
<div v-else class="text-gray-600 text-sm">Нет данных</div>
</div>
</UCard>
</div>
<!-- Bottom row -->
<div class="grid grid-cols-3 gap-4">
<!-- Top IPs -->
<UCard :ui="cardUi">
<template #header>
<p class="text-sm font-semibold text-gray-200">Топ источников атак</p>
</template>
<div class="space-y-2">
<div
v-for="(ip, i) in topIPs"
:key="ip.src_ip"
class="flex items-center gap-3"
>
<span class="text-xs text-gray-500 w-4 shrink-0">{{ i + 1 }}</span>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-0.5">
<span class="text-xs font-mono text-gray-300 truncate">{{ ip.src_ip }}</span>
<span class="text-xs text-gray-400 ml-2 shrink-0">{{ ip.count }}</span>
</div>
<div class="h-1 bg-gray-800 rounded-full overflow-hidden">
<div
class="h-full bg-blue-500 rounded-full"
:style="{ width: `${(ip.count / maxIPCount) * 100}%` }"
/>
</div>
</div>
</div>
<div v-if="!topIPs.length" class="text-gray-600 text-sm py-4 text-center">
Нет данных
</div>
</div>
</UCard>
<!-- Recent alerts -->
<UCard class="col-span-2" :ui="cardUi">
<template #header>
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-gray-200">Последние инциденты</p>
<UButton to="/alerts" variant="ghost" size="xs" color="blue" trailing-icon="i-heroicons-arrow-right">
Все алерты
</UButton>
</div>
</template>
<div class="space-y-2">
<div
v-for="alert in recentAlerts"
:key="alert.id"
class="flex items-start gap-3 py-1.5 border-b border-gray-800/60 last:border-0"
>
<SeverityBadge :severity="alert.severity" class="mt-0.5 shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-xs text-gray-300 truncate">{{ alert.description }}</p>
<p class="text-[10px] text-gray-500 mt-0.5">
{{ alert.src_ip }} · {{ formatTime(alert.created_at) }}
</p>
</div>
</div>
<div v-if="!recentAlerts.length" class="text-gray-600 text-sm py-4 text-center">
Инцидентов не зафиксировано
</div>
</div>
</UCard>
</div>
</div>
</template>
<script setup lang="ts">
import { Line as LineChart, Doughnut as DoughnutChart } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale, LinearScale, PointElement, LineElement,
ArcElement, Tooltip, Legend, Filler,
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Tooltip, Legend, Filler)
const { get } = useApi()
const summary = ref<any>({})
const timeline = ref<any[]>([])
const topIPs = ref<any[]>([])
const recentAlerts = ref<any[]>([])
const cardUi = {
base: 'bg-gray-900 border border-gray-800',
header: { base: 'border-b border-gray-800 px-4 py-3' },
body: { base: 'px-4 py-3' },
}
async function loadAll() {
const [sum, tl, ips, alerts] = await Promise.all([
get<any>('/stats/summary'),
get<any>('/stats/timeline', { hours: 24 }),
get<any>('/stats/top-ips', { limit: 5 }),
get<any>('/alerts', { page_size: 8 }),
])
summary.value = sum
timeline.value = tl.data ?? []
topIPs.value = ips.data ?? []
recentAlerts.value = alerts.data ?? []
}
onMounted(() => {
loadAll()
// Обновляем каждые 10 секунд только на клиенте
const id = setInterval(loadAll, 10_000)
onUnmounted(() => clearInterval(id))
})
// ── Charts ────────────────────────────────────────────────────────────────────
const timelineReady = computed(() => timeline.value.length > 0)
const timelineData = computed(() => ({
labels: timeline.value.map(p => p.hour?.slice(11, 16) ?? p.hour),
datasets: [{
label: 'Алерты',
data: timeline.value.map(p => p.count),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,0.12)',
tension: 0.4,
fill: true,
pointRadius: 3,
pointBackgroundColor: '#3b82f6',
}],
}))
const lineOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#6b7280', font: { size: 10 } } },
y: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#6b7280', font: { size: 10 } }, beginAtZero: true },
},
}
const TYPE_COLORS: Record<string, string> = {
port_scan: '#3b82f6',
brute_force: '#ef4444',
dos: '#f97316',
arp_spoof: '#a855f7',
dns_anomaly: '#22c55e',
}
const TYPE_LABELS: Record<string, string> = {
port_scan: 'Port Scan',
brute_force: 'Brute Force',
dos: 'DoS/DDoS',
arp_spoof: 'ARP Spoof',
dns_anomaly: 'DNS Anomaly',
}
const typesReady = computed(() => summary.value.by_type?.length > 0)
const typesData = computed(() => {
const types: any[] = summary.value.by_type ?? []
return {
labels: types.map(t => TYPE_LABELS[t.type] ?? t.type ?? '—'),
datasets: [{
data: types.map(t => t.count),
backgroundColor: types.map(t => TYPE_COLORS[t.type] ?? '#6b7280'),
borderWidth: 0,
}],
}
})
const doughnutOptions = {
responsive: true,
maintainAspectRatio: false,
cutout: '68%',
plugins: {
legend: {
position: 'bottom' as const,
labels: { color: '#9ca3af', font: { size: 10 }, padding: 8, boxWidth: 10 },
},
},
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const maxIPCount = computed(() =>
topIPs.value.reduce((max, ip) => Math.max(max, ip.count), 1)
)
function formatTime(iso: string) {
return new Date(iso).toLocaleString('ru-RU', {
day: '2-digit', month: '2-digit',
hour: '2-digit', minute: '2-digit',
})
}
</script>
+233
View File
@@ -0,0 +1,233 @@
<template>
<div class="space-y-4">
<!-- Period selector -->
<div class="flex gap-2">
<UButton
v-for="h in periodOptions"
:key="h"
:color="hours === h ? 'blue' : 'gray'"
:variant="hours === h ? 'solid' : 'soft'"
size="xs"
@click="hours = h; loadAll()"
>
{{ periodLabel(h) }}
</UButton>
</div>
<!-- Timeline chart -->
<UCard :ui="cardUi">
<template #header>
<p class="text-sm font-semibold text-gray-200">Динамика алертов</p>
</template>
<div class="h-64">
<BarChart v-if="timelineReady" :data="timelineData" :options="barOptions" />
<div v-else class="h-full flex items-center justify-center text-gray-600 text-sm">Нет данных</div>
</div>
</UCard>
<!-- Second row: Types + Severity -->
<div class="grid grid-cols-2 gap-4">
<UCard :ui="cardUi">
<template #header>
<p class="text-sm font-semibold text-gray-200">По типам атак</p>
</template>
<div class="h-56 flex items-center justify-center">
<DoughnutChart v-if="typesReady" :data="typesChartData" :options="doughnutOptions" />
<div v-else class="text-gray-600 text-sm">Нет данных</div>
</div>
</UCard>
<UCard :ui="cardUi">
<template #header>
<p class="text-sm font-semibold text-gray-200">По уровню критичности</p>
</template>
<div class="h-56 flex items-center justify-center">
<DoughnutChart v-if="severityReady" :data="severityChartData" :options="doughnutOptions" />
<div v-else class="text-gray-600 text-sm">Нет данных</div>
</div>
</UCard>
</div>
<!-- Top IPs bar -->
<UCard :ui="cardUi">
<template #header>
<p class="text-sm font-semibold text-gray-200">Топ-10 источников атак</p>
</template>
<div class="h-56">
<BarChart v-if="ipsReady" :data="ipsChartData" :options="ipsBarOptions" />
<div v-else class="h-full flex items-center justify-center text-gray-600 text-sm">Нет данных</div>
</div>
</UCard>
<!-- Summary table -->
<UCard :ui="cardUi">
<template #header>
<p class="text-sm font-semibold text-gray-200">Сводная статистика</p>
</template>
<div class="grid grid-cols-3 gap-6">
<div>
<p class="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wide">По критичности</p>
<div class="space-y-1.5">
<div v-for="s in severityRows" :key="s.severity" class="flex items-center justify-between">
<SeverityBadge :severity="s.severity" />
<span class="text-sm font-mono text-gray-300">{{ s.count }}</span>
</div>
</div>
</div>
<div>
<p class="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wide">По типу</p>
<div class="space-y-1.5">
<div v-for="t in typeRows" :key="t.type" class="flex items-center justify-between">
<span class="text-xs text-blue-300 font-mono">{{ typeLabel(t.type) }}</span>
<span class="text-sm font-mono text-gray-300">{{ t.count }}</span>
</div>
</div>
</div>
<div>
<p class="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wide">Итого</p>
<p class="text-3xl font-bold text-white">{{ summary.total ?? 0 }}</p>
<p class="text-xs text-gray-500 mt-1">алертов зафиксировано</p>
</div>
</div>
</UCard>
</div>
</template>
<script setup lang="ts">
import {
Chart as ChartJS, CategoryScale, LinearScale,
BarElement, ArcElement, Tooltip, Legend,
} from 'chart.js'
import { Bar as BarChart, Doughnut as DoughnutChart } from 'vue-chartjs'
ChartJS.register(CategoryScale, LinearScale, BarElement, ArcElement, Tooltip, Legend)
const { get } = useApi()
const hours = ref(24)
const periodOptions = [6, 24, 48, 168]
const summary = ref<any>({})
const timeline = ref<any[]>([])
const topIPs = ref<any[]>([])
async function loadAll() {
const [sum, tl, ips] = await Promise.all([
get<any>('/stats/summary'),
get<any>('/stats/timeline', { hours: hours.value }),
get<any>('/stats/top-ips', { limit: 10 }),
])
summary.value = sum
timeline.value = tl.data ?? []
topIPs.value = ips.data ?? []
}
onMounted(loadAll)
function periodLabel(h: number) {
return h < 24 ? `${h}ч` : h === 24 ? '24ч' : h === 48 ? '2д' : '7д'
}
// ── Timeline chart ────────────────────────────────────────────────────────────
const timelineReady = computed(() => timeline.value.length > 0)
const timelineData = computed(() => ({
labels: timeline.value.map(p => p.hour?.slice(11, 16) ?? p.hour),
datasets: [{
label: 'Алерты',
data: timeline.value.map(p => p.count),
backgroundColor: 'rgba(59,130,246,0.7)',
borderRadius: 4,
}],
}))
const barOptions = {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#6b7280', font: { size: 10 } } },
y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#6b7280', font: { size: 10 } }, beginAtZero: true },
},
}
// ── Types chart ───────────────────────────────────────────────────────────────
const TYPE_COLORS: Record<string, string> = {
port_scan: '#3b82f6', brute_force: '#ef4444',
dos: '#f97316', arp_spoof: '#a855f7', dns_anomaly: '#22c55e',
}
const TYPE_LABELS: Record<string, string> = {
port_scan: 'Port Scan', brute_force: 'Brute Force',
dos: 'DoS/DDoS', arp_spoof: 'ARP Spoof', dns_anomaly: 'DNS Anomaly',
}
const typesReady = computed(() => (summary.value.by_type ?? []).length > 0)
const typesChartData = computed(() => {
const types = summary.value.by_type ?? []
return {
labels: types.map((t: any) => TYPE_LABELS[t.type] ?? t.type),
datasets: [{ data: types.map((t: any) => t.count), backgroundColor: types.map((t: any) => TYPE_COLORS[t.type] ?? '#6b7280'), borderWidth: 0 }],
}
})
// ── Severity chart ────────────────────────────────────────────────────────────
const SEV_COLORS: Record<string, string> = {
critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#22c55e',
}
const SEV_LABELS: Record<string, string> = {
critical: 'Критический', high: 'Высокий', medium: 'Средний', low: 'Низкий',
}
const severityReady = computed(() => Object.values(summary.value.by_severity ?? {}).some(v => v))
const severityChartData = computed(() => {
const sev = summary.value.by_severity ?? {}
const order = ['critical', 'high', 'medium', 'low']
return {
labels: order.map(s => SEV_LABELS[s]),
datasets: [{ data: order.map(s => sev[s] ?? 0), backgroundColor: order.map(s => SEV_COLORS[s]), borderWidth: 0 }],
}
})
const doughnutOptions = {
responsive: true, maintainAspectRatio: false, cutout: '65%',
plugins: {
legend: { position: 'right' as const, labels: { color: '#9ca3af', font: { size: 10 }, padding: 10, boxWidth: 10 } },
},
}
// ── IPs bar chart ─────────────────────────────────────────────────────────────
const ipsReady = computed(() => topIPs.value.length > 0)
const ipsChartData = computed(() => ({
labels: topIPs.value.map(ip => ip.src_ip),
datasets: [{
label: 'Алертов',
data: topIPs.value.map(ip => ip.count),
backgroundColor: 'rgba(168,85,247,0.7)',
borderRadius: 4,
}],
}))
const ipsBarOptions = {
indexAxis: 'y' as const,
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#6b7280', font: { size: 10 } }, beginAtZero: true },
y: { grid: { display: false }, ticks: { color: '#d1d5db', font: { size: 10, family: 'monospace' } } },
},
}
// ── Tables ────────────────────────────────────────────────────────────────────
const SEV_ORDER = ['critical', 'high', 'medium', 'low']
const severityRows = computed(() => {
const sev = summary.value.by_severity ?? {}
return SEV_ORDER.map(s => ({ severity: s, count: sev[s] ?? 0 }))
})
const typeRows = computed(() => summary.value.by_type ?? [])
function typeLabel(t: string | undefined): string {
if (!t) return '—'
return TYPE_LABELS[t] ?? t
}
const cardUi = { base: 'bg-gray-900 border border-gray-800', header: { base: 'border-b border-gray-800 px-4 py-3' }, body: { base: 'px-4 py-4' } }
</script>
+54
View File
@@ -0,0 +1,54 @@
module dip-ids
go 1.22
require (
github.com/gin-gonic/gin v1.9.1
github.com/google/gopacket v1.1.19
github.com/johnfercher/maroto/v2 v2.2.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.5.5
gorm.io/gorm v1.25.10
)
require (
github.com/boombuler/barcode v1.0.1 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/f-amaral/go-async v0.3.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/tiff v1.0.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/johnfercher/go-tree v1.0.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/jung-kurt/gofpdf v1.16.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pdfcpu/pdfcpu v0.6.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.16.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
+149
View File
@@ -0,0 +1,149 @@
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/f-amaral/go-async v0.3.0 h1:h4kLsX7aKfdWaHvV0lf+/EE3OIeCzyeDYJDb/vDZUyg=
github.com/f-amaral/go-async v0.3.0/go.mod h1:Hz5Qr6DAWpbTTUjytnrg1WIsDgS7NtOei5y8SipYS7U=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0=
github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/johnfercher/go-tree v1.0.5 h1:zpgVhJsChavzhKdxhQiCJJzcSY3VCT9oal2JoA2ZevY=
github.com/johnfercher/go-tree v1.0.5/go.mod h1:DUO6QkXIFh1K7jeGBIkLCZaeUgnkdQAsB64FDSoHswg=
github.com/johnfercher/maroto/v2 v2.2.0 h1:e+isvEIifODJs2eqatnfGDKC7JiorRbK0RxY9mlOP7E=
github.com/johnfercher/maroto/v2 v2.2.0/go.mod h1:/LfW6AQGZzsG6xUixcfyxkKztDoszdwC+G2jNRl8bss=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pdfcpu/pdfcpu v0.6.0 h1:z4kARP5bcWa39TTYMcN/kjBnm7MvhTWjXgeYmkdAGMI=
github.com/pdfcpu/pdfcpu v0.6.0/go.mod h1:kmpD0rk8YnZj0l3qSeGBlAB+XszHUgNv//ORH/E7EYo=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0=
github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+101
View File
@@ -0,0 +1,101 @@
package analyzer
import (
"fmt"
"sync"
"time"
"dip-ids/internal/config"
"dip-ids/internal/storage"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
)
// arpSpoofDetector обнаруживает ARP-спуфинг.
// Алгоритм: ведёт таблицу соответствия IP→MAC; при обнаружении
// нового MAC для уже известного IP генерирует алерт.
type arpSpoofDetector struct {
mu sync.Mutex
cfg config.ARPSpoofConfig
ipToMAC map[string]string // IP -> MAC
alerted map[string]time.Time
}
func NewARPSpoofDetector(cfg config.ARPSpoofConfig) Detector {
return &arpSpoofDetector{
cfg: cfg,
ipToMAC: make(map[string]string),
alerted: make(map[string]time.Time),
}
}
func (d *arpSpoofDetector) Name() string { return "arp_spoof" }
func (d *arpSpoofDetector) Analyze(pkt gopacket.Packet) []storage.Alert {
if !d.cfg.Enabled {
return nil
}
arpLayer := pkt.Layer(layers.LayerTypeARP)
if arpLayer == nil {
return nil
}
arp := arpLayer.(*layers.ARP)
// Обрабатываем только ARP Reply (op=2) и Gratuitous ARP
if arp.Operation != layers.ARPReply {
return nil
}
srcIP := fmt.Sprintf("%d.%d.%d.%d", arp.SourceProtAddress[0], arp.SourceProtAddress[1],
arp.SourceProtAddress[2], arp.SourceProtAddress[3])
srcMAC := fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
arp.SourceHwAddress[0], arp.SourceHwAddress[1], arp.SourceHwAddress[2],
arp.SourceHwAddress[3], arp.SourceHwAddress[4], arp.SourceHwAddress[5])
now := pkt.Metadata().Timestamp
if now.IsZero() {
now = time.Now()
}
d.mu.Lock()
defer d.mu.Unlock()
knownMAC, exists := d.ipToMAC[srcIP]
if !exists {
d.ipToMAC[srcIP] = srcMAC
return nil
}
if knownMAC == srcMAC {
return nil
}
// MAC изменился — ARP спуфинг
key := srcIP
if last, ok := d.alerted[key]; ok && now.Sub(last) < 60*time.Second {
return nil
}
d.alerted[key] = now
// Обновляем таблицу
d.ipToMAC[srcIP] = srcMAC
return []storage.Alert{{
CreatedAt: now,
Type: "arp_spoof",
Severity: d.cfg.Severity,
SrcIP: srcIP,
Protocol: "ARP",
Description: fmt.Sprintf("ARP spoofing: IP %s changed MAC from %s to %s", srcIP, knownMAC, srcMAC),
Count: 1,
}}
}
func (d *arpSpoofDetector) Reset() {
d.mu.Lock()
defer d.mu.Unlock()
d.ipToMAC = make(map[string]string)
d.alerted = make(map[string]time.Time)
}
+109
View File
@@ -0,0 +1,109 @@
package analyzer
import (
"fmt"
"sync"
"time"
"dip-ids/internal/config"
"dip-ids/internal/storage"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
)
// bruteForceDetector обнаруживает перебор паролей (brute-force).
// Алгоритм: отслеживает количество SYN-пакетов от одного источника
// к одному порту назначения за скользящее временное окно.
type bruteForceDetector struct {
mu sync.Mutex
cfg config.BruteForceConfig
ports map[int32]struct{}
// ключ: "srcIP:dstIP:dstPort"
tracker map[string][]time.Time
alerted map[string]time.Time
}
func NewBruteForceDetector(cfg config.BruteForceConfig) Detector {
ports := make(map[int32]struct{}, len(cfg.Ports))
for _, p := range cfg.Ports {
ports[p] = struct{}{}
}
return &bruteForceDetector{
cfg: cfg,
ports: ports,
tracker: make(map[string][]time.Time),
alerted: make(map[string]time.Time),
}
}
func (d *bruteForceDetector) Name() string { return "brute_force" }
func (d *bruteForceDetector) Analyze(pkt gopacket.Packet) []storage.Alert {
if !d.cfg.Enabled {
return nil
}
netLayer := pkt.NetworkLayer()
tcpLayer := pkt.Layer(layers.LayerTypeTCP)
if netLayer == nil || tcpLayer == nil {
return nil
}
tcp := tcpLayer.(*layers.TCP)
if !tcp.SYN || tcp.ACK {
return nil
}
dstPort := int32(tcp.DstPort)
if _, monitored := d.ports[dstPort]; !monitored {
return nil
}
srcIP := netLayer.NetworkFlow().Src().String()
dstIP := netLayer.NetworkFlow().Dst().String()
now := pkt.Metadata().Timestamp
if now.IsZero() {
now = time.Now()
}
key := fmt.Sprintf("%s:%s:%d", srcIP, dstIP, dstPort)
d.mu.Lock()
defer d.mu.Unlock()
window := time.Duration(d.cfg.WindowSeconds) * time.Second
cutoff := now.Add(-window)
d.tracker[key] = append(d.tracker[key], now)
d.tracker[key] = filterTimes(d.tracker[key], cutoff)
count := len(d.tracker[key])
if count < d.cfg.Threshold {
return nil
}
if last, ok := d.alerted[key]; ok && now.Sub(last) < 30*time.Second {
return nil
}
d.alerted[key] = now
return []storage.Alert{{
CreatedAt: now,
Type: "brute_force",
Severity: d.cfg.Severity,
SrcIP: srcIP,
DstIP: dstIP,
DstPort: uint16(dstPort),
Protocol: "TCP",
Description: fmt.Sprintf("Brute-force detected on port %d: %d connection attempts in %ds", dstPort, count, d.cfg.WindowSeconds),
Count: count,
}}
}
func (d *bruteForceDetector) Reset() {
d.mu.Lock()
defer d.mu.Unlock()
d.tracker = make(map[string][]time.Time)
d.alerted = make(map[string]time.Time)
}
+45
View File
@@ -0,0 +1,45 @@
package analyzer
import (
"time"
"dip-ids/internal/storage"
"github.com/google/gopacket"
)
// Detector — интерфейс детектора атак
type Detector interface {
Name() string
Analyze(pkt gopacket.Packet) []storage.Alert
Reset()
}
// timeWindow скользящее временное окно для хранения меток времени
type timeWindow struct {
entries []time.Time
size time.Duration
}
func newTimeWindow(seconds int) *timeWindow {
return &timeWindow{size: time.Duration(seconds) * time.Second}
}
func (w *timeWindow) add(t time.Time) {
w.entries = append(w.entries, t)
w.evict(t)
}
func (w *timeWindow) evict(now time.Time) {
cutoff := now.Add(-w.size)
i := 0
for i < len(w.entries) && w.entries[i].Before(cutoff) {
i++
}
w.entries = w.entries[i:]
}
func (w *timeWindow) count(now time.Time) int {
w.evict(now)
return len(w.entries)
}
+147
View File
@@ -0,0 +1,147 @@
package analyzer
import (
"fmt"
"strings"
"sync"
"time"
"dip-ids/internal/config"
"dip-ids/internal/storage"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
)
// dnsAnomalyDetector обнаруживает аномалии DNS:
// 1. Слишком длинные метки (признак DNS-туннелирования)
// 2. Аномально высокая частота запросов от одного источника
type dnsAnomalyDetector struct {
mu sync.Mutex
cfg config.DNSAnomalyConfig
tracker map[string][]time.Time // srcIP -> query timestamps
alerted map[string]time.Time
}
func NewDNSAnomalyDetector(cfg config.DNSAnomalyConfig) Detector {
return &dnsAnomalyDetector{
cfg: cfg,
tracker: make(map[string][]time.Time),
alerted: make(map[string]time.Time),
}
}
func (d *dnsAnomalyDetector) Name() string { return "dns_anomaly" }
func (d *dnsAnomalyDetector) Analyze(pkt gopacket.Packet) []storage.Alert {
if !d.cfg.Enabled {
return nil
}
netLayer := pkt.NetworkLayer()
dnsLayer := pkt.Layer(layers.LayerTypeDNS)
if netLayer == nil || dnsLayer == nil {
return nil
}
dns := dnsLayer.(*layers.DNS)
if !dns.QR { // QR=0 — это запрос, QR=1 — ответ; анализируем запросы
return nil
}
// Анализируем только запросы (QR=false в gopacket означает запрос)
if len(dns.Questions) == 0 {
return nil
}
srcIP := netLayer.NetworkFlow().Src().String()
now := pkt.Metadata().Timestamp
if now.IsZero() {
now = time.Now()
}
var alerts []storage.Alert
for _, q := range dns.Questions {
name := string(q.Name)
// Проверка 1: DNS-туннелирование по длине метки
if alert := d.checkTunneling(name, srcIP, now); alert != nil {
alerts = append(alerts, *alert)
}
}
// Проверка 2: высокая частота запросов
if alert := d.checkFrequency(srcIP, now); alert != nil {
alerts = append(alerts, *alert)
}
return alerts
}
func (d *dnsAnomalyDetector) checkTunneling(name, srcIP string, now time.Time) *storage.Alert {
labels := strings.Split(name, ".")
for _, label := range labels {
if len(label) > d.cfg.MaxLabelLength {
key := "tunnel:" + srcIP
d.mu.Lock()
last, exists := d.alerted[key]
if exists && now.Sub(last) < 60*time.Second {
d.mu.Unlock()
return nil
}
d.alerted[key] = now
d.mu.Unlock()
return &storage.Alert{
CreatedAt: now,
Type: "dns_anomaly",
Severity: d.cfg.Severity,
SrcIP: srcIP,
Protocol: "DNS",
Description: fmt.Sprintf("DNS tunneling suspected: label length %d in query '%s'", len(label), name),
Count: 1,
}
}
}
return nil
}
func (d *dnsAnomalyDetector) checkFrequency(srcIP string, now time.Time) *storage.Alert {
d.mu.Lock()
defer d.mu.Unlock()
window := time.Duration(d.cfg.WindowSeconds) * time.Second
cutoff := now.Add(-window)
d.tracker[srcIP] = append(d.tracker[srcIP], now)
d.tracker[srcIP] = filterTimes(d.tracker[srcIP], cutoff)
count := len(d.tracker[srcIP])
if count < d.cfg.QueryThreshold {
return nil
}
key := "freq:" + srcIP
if last, ok := d.alerted[key]; ok && now.Sub(last) < 30*time.Second {
return nil
}
d.alerted[key] = now
return &storage.Alert{
CreatedAt: now,
Type: "dns_anomaly",
Severity: d.cfg.Severity,
SrcIP: srcIP,
Protocol: "DNS",
Description: fmt.Sprintf("High DNS query rate from %s: %d queries in %ds", srcIP, count, d.cfg.WindowSeconds),
Count: count,
}
}
func (d *dnsAnomalyDetector) Reset() {
d.mu.Lock()
defer d.mu.Unlock()
d.tracker = make(map[string][]time.Time)
d.alerted = make(map[string]time.Time)
}
+87
View File
@@ -0,0 +1,87 @@
package analyzer
import (
"fmt"
"sync"
"time"
"dip-ids/internal/config"
"dip-ids/internal/storage"
"github.com/google/gopacket"
)
// dosDetector обнаруживает DoS/DDoS атаки по превышению порога PPS.
// Алгоритм: считает количество пакетов от каждого источника
// за скользящее временное окно; при превышении порога — алерт.
type dosDetector struct {
mu sync.Mutex
cfg config.DoSConfig
tracker map[string][]time.Time // srcIP -> timestamps
alerted map[string]time.Time
}
func NewDoSDetector(cfg config.DoSConfig) Detector {
return &dosDetector{
cfg: cfg,
tracker: make(map[string][]time.Time),
alerted: make(map[string]time.Time),
}
}
func (d *dosDetector) Name() string { return "dos" }
func (d *dosDetector) Analyze(pkt gopacket.Packet) []storage.Alert {
if !d.cfg.Enabled {
return nil
}
netLayer := pkt.NetworkLayer()
if netLayer == nil {
return nil
}
srcIP := netLayer.NetworkFlow().Src().String()
dstIP := netLayer.NetworkFlow().Dst().String()
now := pkt.Metadata().Timestamp
if now.IsZero() {
now = time.Now()
}
d.mu.Lock()
defer d.mu.Unlock()
window := time.Duration(d.cfg.WindowSeconds) * time.Second
cutoff := now.Add(-window)
d.tracker[srcIP] = append(d.tracker[srcIP], now)
d.tracker[srcIP] = filterTimes(d.tracker[srcIP], cutoff)
pps := len(d.tracker[srcIP])
if pps < d.cfg.PPSThreshold {
return nil
}
if last, ok := d.alerted[srcIP]; ok && now.Sub(last) < 10*time.Second {
return nil
}
d.alerted[srcIP] = now
return []storage.Alert{{
CreatedAt: now,
Type: "dos",
Severity: d.cfg.Severity,
SrcIP: srcIP,
DstIP: dstIP,
Protocol: "IP",
Description: fmt.Sprintf("DoS attack detected from %s: %d pkt/s (threshold: %d)", srcIP, pps, d.cfg.PPSThreshold),
Count: pps,
}}
}
func (d *dosDetector) Reset() {
d.mu.Lock()
defer d.mu.Unlock()
d.tracker = make(map[string][]time.Time)
d.alerted = make(map[string]time.Time)
}
+124
View File
@@ -0,0 +1,124 @@
package analyzer
import (
"context"
"log"
"sync/atomic"
"time"
"dip-ids/internal/config"
"dip-ids/internal/storage"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
)
// Engine — основной движок анализа трафика.
// Получает пакеты из канала, передаёт каждый всем детекторам,
// собирает алерты и передаёт их в хранилище.
type Engine struct {
db *storage.DB
detectors []Detector
totalPkts atomic.Int64
totalBytes atomic.Int64
}
// New создаёт движок с детекторами, сконфигурированными из cfg
func New(db *storage.DB, cfg config.DetectorsConfig) *Engine {
return &Engine{
db: db,
detectors: []Detector{
NewPortScanDetector(cfg.PortScan),
NewBruteForceDetector(cfg.BruteForce),
NewDoSDetector(cfg.DoS),
NewARPSpoofDetector(cfg.ARPSpoof),
NewDNSAnomalyDetector(cfg.DNSAnomaly),
},
}
}
// Run запускает обработку пакетов из канала pkts до отмены ctx.
func (e *Engine) Run(ctx context.Context, pkts <-chan gopacket.Packet) {
for {
select {
case <-ctx.Done():
return
case pkt, ok := <-pkts:
if !ok {
return
}
e.process(pkt)
}
}
}
func (e *Engine) process(pkt gopacket.Packet) {
e.totalPkts.Add(1)
if meta := pkt.Metadata(); meta != nil {
e.totalBytes.Add(int64(meta.CaptureLength))
}
// Сохраняем статистику пакета
e.recordStat(pkt)
for _, det := range e.detectors {
alerts := det.Analyze(pkt)
for i := range alerts {
if err := e.db.CreateAlert(&alerts[i]); err != nil {
log.Printf("[engine] save alert (%s): %v", det.Name(), err)
}
}
}
}
func (e *Engine) recordStat(pkt gopacket.Packet) {
stat := &storage.PacketStat{
CreatedAt: time.Now(),
}
if netLayer := pkt.NetworkLayer(); netLayer != nil {
stat.SrcIP = netLayer.NetworkFlow().Src().String()
stat.DstIP = netLayer.NetworkFlow().Dst().String()
}
// Определяем протокол
switch {
case pkt.Layer(layers.LayerTypeTCP) != nil:
stat.Protocol = "TCP"
case pkt.Layer(layers.LayerTypeUDP) != nil:
stat.Protocol = "UDP"
case pkt.Layer(layers.LayerTypeICMPv4) != nil:
stat.Protocol = "ICMP"
case pkt.Layer(layers.LayerTypeARP) != nil:
stat.Protocol = "ARP"
case pkt.Layer(layers.LayerTypeDNS) != nil:
stat.Protocol = "DNS"
default:
stat.Protocol = "OTHER"
}
if meta := pkt.Metadata(); meta != nil {
stat.Bytes = int64(meta.CaptureLength)
}
// Сохраняем не каждый пакет (каждый 10-й), чтобы не раздувать БД
if e.totalPkts.Load()%10 == 0 {
if err := e.db.CreatePacketStat(stat); err != nil {
log.Printf("[engine] save stat: %v", err)
}
}
}
// Stats возвращает счётчики текущей сессии
func (e *Engine) Stats() (pkts, bytes int64) {
return e.totalPkts.Load(), e.totalBytes.Load()
}
// Reset сбрасывает состояние всех детекторов и счётчики
func (e *Engine) Reset() {
for _, det := range e.detectors {
det.Reset()
}
e.totalPkts.Store(0)
e.totalBytes.Store(0)
}
+121
View File
@@ -0,0 +1,121 @@
package analyzer
import (
"fmt"
"sync"
"time"
"dip-ids/internal/config"
"dip-ids/internal/storage"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
)
// portScanDetector обнаруживает сканирование портов методом sliding window.
// Алгоритм: если один источник обращается к N уникальным портам назначения
// за период T — это потенциальное сканирование.
type portScanDetector struct {
mu sync.Mutex
cfg config.PortScanConfig
// srcIP -> map[dstPort][]time.Time
tracker map[string]map[uint16][]time.Time
alerted map[string]time.Time // последний алерт по srcIP
}
func NewPortScanDetector(cfg config.PortScanConfig) Detector {
return &portScanDetector{
cfg: cfg,
tracker: make(map[string]map[uint16][]time.Time),
alerted: make(map[string]time.Time),
}
}
func (d *portScanDetector) Name() string { return "port_scan" }
func (d *portScanDetector) Analyze(pkt gopacket.Packet) []storage.Alert {
if !d.cfg.Enabled {
return nil
}
netLayer := pkt.NetworkLayer()
tcpLayer := pkt.Layer(layers.LayerTypeTCP)
if netLayer == nil || tcpLayer == nil {
return nil
}
tcp := tcpLayer.(*layers.TCP)
// Интересуют только SYN-пакеты (начало соединения)
if !tcp.SYN || tcp.ACK {
return nil
}
srcIP := netLayer.NetworkFlow().Src().String()
dstIP := netLayer.NetworkFlow().Dst().String()
dstPort := uint16(tcp.DstPort)
now := pkt.Metadata().Timestamp
if now.IsZero() {
now = time.Now()
}
d.mu.Lock()
defer d.mu.Unlock()
if _, ok := d.tracker[srcIP]; !ok {
d.tracker[srcIP] = make(map[uint16][]time.Time)
}
window := time.Duration(d.cfg.WindowSeconds) * time.Second
cutoff := now.Add(-window)
// Добавляем текущий порт и очищаем устаревшие записи
d.tracker[srcIP][dstPort] = append(d.tracker[srcIP][dstPort], now)
uniquePorts := 0
for port, times := range d.tracker[srcIP] {
filtered := filterTimes(times, cutoff)
if len(filtered) == 0 {
delete(d.tracker[srcIP], port)
} else {
d.tracker[srcIP][port] = filtered
uniquePorts++
}
}
if uniquePorts < d.cfg.Threshold {
return nil
}
// Дедупликация: не спамить алертами чаще чем раз в 30 сек на один IP
if last, ok := d.alerted[srcIP]; ok && now.Sub(last) < 30*time.Second {
return nil
}
d.alerted[srcIP] = now
return []storage.Alert{{
CreatedAt: now,
Type: "port_scan",
Severity: d.cfg.Severity,
SrcIP: srcIP,
DstIP: dstIP,
DstPort: dstPort,
Protocol: "TCP",
Description: fmt.Sprintf("Port scan detected: %d unique ports probed in %ds window", uniquePorts, d.cfg.WindowSeconds),
Count: uniquePorts,
}}
}
func (d *portScanDetector) Reset() {
d.mu.Lock()
defer d.mu.Unlock()
d.tracker = make(map[string]map[uint16][]time.Time)
d.alerted = make(map[string]time.Time)
}
func filterTimes(times []time.Time, cutoff time.Time) []time.Time {
i := 0
for i < len(times) && times[i].Before(cutoff) {
i++
}
return times[i:]
}
+84
View File
@@ -0,0 +1,84 @@
package handlers
import (
"net/http"
"strconv"
"time"
"dip-ids/internal/storage"
"github.com/gin-gonic/gin"
)
type AlertsHandler struct {
db *storage.DB
}
func NewAlertsHandler(db *storage.DB) *AlertsHandler {
return &AlertsHandler{db: db}
}
// GetAlerts GET /api/alerts
func (h *AlertsHandler) GetAlerts(c *gin.Context) {
filter := storage.AlertFilter{
Type: c.Query("type"),
Severity: c.Query("severity"),
SrcIP: c.Query("src_ip"),
}
if p := c.Query("page"); p != "" {
if v, err := strconv.Atoi(p); err == nil {
filter.Page = v
}
}
if ps := c.Query("page_size"); ps != "" {
if v, err := strconv.Atoi(ps); err == nil {
filter.PageSize = v
}
}
if from := c.Query("date_from"); from != "" {
if t, err := time.Parse(time.RFC3339, from); err == nil {
filter.DateFrom = &t
}
}
if to := c.Query("date_to"); to != "" {
if t, err := time.Parse(time.RFC3339, to); err == nil {
filter.DateTo = &t
}
}
alerts, total, err := h.db.GetAlerts(filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"data": alerts,
"total": total,
"page": filter.Page,
})
}
// DeleteAlert DELETE /api/alerts/:id
func (h *AlertsHandler) DeleteAlert(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.db.DeleteAlert(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ClearAlerts DELETE /api/alerts
func (h *AlertsHandler) ClearAlerts(c *gin.Context) {
if err := h.db.DeleteAllAlerts(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+253
View File
@@ -0,0 +1,253 @@
package handlers
import (
"context"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
"dip-ids/internal/analyzer"
"dip-ids/internal/capture"
"dip-ids/internal/storage"
"github.com/gin-gonic/gin"
)
// CaptureState хранит состояние текущей сессии захвата
type CaptureState struct {
cancel context.CancelFunc
session *storage.CaptureSession
engine *analyzer.Engine
}
type CaptureHandler struct {
db *storage.DB
engine *analyzer.Engine
defaultIface string
active *CaptureState
}
func NewCaptureHandler(db *storage.DB, engine *analyzer.Engine, defaultIface string) *CaptureHandler {
return &CaptureHandler{
db: db,
engine: engine,
defaultIface: defaultIface,
}
}
// StartLive POST /api/capture/start
func (h *CaptureHandler) StartLive(c *gin.Context) {
if h.active != nil {
c.JSON(http.StatusConflict, gin.H{"error": "capture already running"})
return
}
var req struct {
Interface string `json:"interface"`
}
_ = c.ShouldBindJSON(&req)
if req.Interface == "" {
req.Interface = h.defaultIface
}
sess := &storage.CaptureSession{
CreatedAt: time.Now(),
Source: req.Interface,
Mode: "live",
Status: "running",
}
if err := h.db.CreateSession(sess); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx, cancel := context.WithCancel(context.Background())
cap, err := capture.New(capture.Config{
Interface: req.Interface,
Mode: capture.ModeLive,
Snaplen: 65535,
Promiscuous: true,
})
if err != nil {
cancel()
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
pkts, err := cap.Start(ctx)
if err != nil {
cancel()
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
h.engine.Reset()
h.active = &CaptureState{cancel: cancel, session: sess, engine: h.engine}
go func() {
h.engine.Run(ctx, pkts)
// Обновляем сессию после завершения
now := time.Now()
pktsCount, bytesCount := h.engine.Stats()
sess.EndedAt = &now
sess.Status = "stopped"
sess.TotalPkts = pktsCount
sess.TotalBytes = bytesCount
if err := h.db.UpdateSession(sess); err != nil {
log.Printf("[capture] update session: %v", err)
}
h.active = nil
}()
c.JSON(http.StatusOK, gin.H{"session_id": sess.ID, "interface": req.Interface})
}
// Stop POST /api/capture/stop
func (h *CaptureHandler) Stop(c *gin.Context) {
if h.active == nil {
c.JSON(http.StatusConflict, gin.H{"error": "no active capture"})
return
}
h.active.cancel()
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// UploadPCAP POST /api/capture/upload
func (h *CaptureHandler) UploadPCAP(c *gin.Context) {
if h.active != nil {
c.JSON(http.StatusConflict, gin.H{"error": "capture already running"})
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
return
}
tmpPath, err := saveTempFile(file)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
sess := &storage.CaptureSession{
CreatedAt: time.Now(),
Source: file.Filename,
Mode: "pcap",
Status: "running",
}
if err := h.db.CreateSession(sess); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx, cancel := context.WithCancel(context.Background())
cap, err := capture.New(capture.Config{
PCAPFile: tmpPath,
Mode: capture.ModePCAP,
Snaplen: 65535,
})
if err != nil {
cancel()
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
pkts, err := cap.Start(ctx)
if err != nil {
cancel()
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
h.engine.Reset()
h.active = &CaptureState{cancel: cancel, session: sess, engine: h.engine}
go func() {
defer os.Remove(tmpPath)
h.engine.Run(ctx, pkts)
now := time.Now()
pktsCount, bytesCount := h.engine.Stats()
sess.EndedAt = &now
sess.Status = "completed"
sess.TotalPkts = pktsCount
sess.TotalBytes = bytesCount
if err := h.db.UpdateSession(sess); err != nil {
log.Printf("[capture] update session: %v", err)
}
h.active = nil
}()
c.JSON(http.StatusOK, gin.H{"session_id": sess.ID, "filename": file.Filename})
}
// Status GET /api/capture/status
func (h *CaptureHandler) Status(c *gin.Context) {
if h.active == nil {
last, _ := h.db.GetLastSession()
c.JSON(http.StatusOK, gin.H{"running": false, "last_session": last})
return
}
pkts, bytes := h.engine.Stats()
c.JSON(http.StatusOK, gin.H{
"running": true,
"session_id": h.active.session.ID,
"source": h.active.session.Source,
"mode": h.active.session.Mode,
"pkts": pkts,
"bytes": bytes,
})
}
// GetInterfaces GET /api/capture/interfaces
func (h *CaptureHandler) GetInterfaces(c *gin.Context) {
ifaces, err := capture.ListInterfaces()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"interfaces": ifaces})
}
// GetSessions GET /api/capture/sessions
func (h *CaptureHandler) GetSessions(c *gin.Context) {
sessions, err := h.db.GetAllSessions()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": sessions})
}
func saveTempFile(fh *multipart.FileHeader) (string, error) {
src, err := fh.Open()
if err != nil {
return "", err
}
defer src.Close()
tmp, err := os.CreateTemp("", "nids-*.pcap")
if err != nil {
return "", err
}
defer tmp.Close()
buf := make([]byte, 32*1024)
for {
n, err := src.Read(buf)
if n > 0 {
tmp.Write(buf[:n])
}
if err != nil {
break
}
}
return filepath.Abs(tmp.Name())
}
+84
View File
@@ -0,0 +1,84 @@
package handlers
import (
"fmt"
"net/http"
"time"
"dip-ids/internal/report"
"dip-ids/internal/storage"
"github.com/gin-gonic/gin"
)
type ReportsHandler struct {
db *storage.DB
}
func NewReportsHandler(db *storage.DB) *ReportsHandler {
return &ReportsHandler{db: db}
}
// Generate GET /api/reports/generate
func (h *ReportsHandler) Generate(c *gin.Context) {
// Собираем данные для отчёта
alerts, _, err := h.db.GetAlerts(storage.AlertFilter{PageSize: 500})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
bySeverity, err := h.db.AlertsBySeverity()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
byType, err := h.db.AlertsByType()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
topIPs, err := h.db.TopSourceIPs(10)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
trafficByProto, err := h.db.TrafficByProtocol()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
recentStats, err := h.db.GetRecentPacketStats(100)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
session, _ := h.db.GetLastSession()
data := report.Data{
GeneratedAt: time.Now(),
Session: session,
Alerts: alerts,
BySeverity: bySeverity,
ByType: byType,
TopIPs: topIPs,
TrafficByProto: trafficByProto,
RecentStats: recentStats,
}
pdf, err := report.Generate(data)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filename := fmt.Sprintf("nids-report-%s.pdf", time.Now().Format("2006-01-02_15-04"))
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
c.Header("Content-Type", "application/pdf")
c.Data(http.StatusOK, "application/pdf", pdf)
}
+89
View File
@@ -0,0 +1,89 @@
package handlers
import (
"net/http"
"strconv"
"dip-ids/internal/storage"
"github.com/gin-gonic/gin"
)
type StatsHandler struct {
db *storage.DB
}
func NewStatsHandler(db *storage.DB) *StatsHandler {
return &StatsHandler{db: db}
}
// GetSummary GET /api/stats/summary
func (h *StatsHandler) GetSummary(c *gin.Context) {
total, err := h.db.TotalAlerts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
bySeverity, err := h.db.AlertsBySeverity()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
byType, err := h.db.AlertsByType()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Нормализуем данные по severity в удобный map
severityMap := map[string]int64{
"critical": 0, "high": 0, "medium": 0, "low": 0,
}
for _, s := range bySeverity {
severityMap[s.Severity] = s.Count
}
c.JSON(http.StatusOK, gin.H{
"total": total,
"by_severity": severityMap,
"by_type": byType,
})
}
// GetTimeline GET /api/stats/timeline?hours=24
func (h *StatsHandler) GetTimeline(c *gin.Context) {
hours := 24
if h := c.Query("hours"); h != "" {
if v, err := strconv.Atoi(h); err == nil && v > 0 {
hours = v
}
}
timeline, err := h.db.AlertsTimeline(hours)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": timeline, "hours": hours})
}
// GetTopIPs GET /api/stats/top-ips?limit=10
func (h *StatsHandler) GetTopIPs(c *gin.Context) {
limit := 10
if l := c.Query("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 {
limit = v
}
}
ips, err := h.db.TopSourceIPs(limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": ips})
}
+77
View File
@@ -0,0 +1,77 @@
package api
import (
"fmt"
"dip-ids/internal/analyzer"
"dip-ids/internal/api/handlers"
"dip-ids/internal/config"
"dip-ids/internal/storage"
"github.com/gin-gonic/gin"
)
type Server struct {
cfg config.ServerConfig
router *gin.Engine
}
func New(
cfg config.ServerConfig,
db *storage.DB,
engine *analyzer.Engine,
captureCfg config.CaptureConfig,
) *Server {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
r.Use(corsMiddleware())
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{
SkipPaths: []string{"/api/capture/status"},
}))
alertsH := handlers.NewAlertsHandler(db)
statsH := handlers.NewStatsHandler(db)
captureH := handlers.NewCaptureHandler(db, engine, captureCfg.DefaultInterface)
reportsH := handlers.NewReportsHandler(db)
api := r.Group("/api")
api.GET("/alerts", alertsH.GetAlerts)
api.DELETE("/alerts", alertsH.ClearAlerts)
api.DELETE("/alerts/:id", alertsH.DeleteAlert)
api.GET("/stats/summary", statsH.GetSummary)
api.GET("/stats/timeline", statsH.GetTimeline)
api.GET("/stats/top-ips", statsH.GetTopIPs)
api.GET("/capture/interfaces", captureH.GetInterfaces)
api.GET("/capture/status", captureH.Status)
api.GET("/capture/sessions", captureH.GetSessions)
api.POST("/capture/start", captureH.StartLive)
api.POST("/capture/stop", captureH.Stop)
api.POST("/capture/upload", captureH.UploadPCAP)
api.GET("/reports/generate", reportsH.Generate)
return &Server{cfg: cfg, router: r}
}
func (s *Server) Run() error {
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
return s.router.Run(addr)
}
// corsMiddleware разрешает запросы от Nuxt dev-сервера (localhost:3000)
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
+122
View File
@@ -0,0 +1,122 @@
package capture
import (
"context"
"fmt"
"io"
"log"
"github.com/google/gopacket"
"github.com/google/gopacket/pcap"
)
// Mode определяет режим захвата трафика
type Mode string
const (
ModeLive Mode = "live"
ModePCAP Mode = "pcap"
)
// Config параметры захвата
type Config struct {
Interface string
PCAPFile string
Mode Mode
Snaplen int32
Promiscuous bool
}
// Capturer захватывает пакеты и отправляет их в канал
type Capturer struct {
cfg Config
handle *pcap.Handle
}
// New создаёт новый захватчик трафика
func New(cfg Config) (*Capturer, error) {
return &Capturer{cfg: cfg}, nil
}
// Start открывает источник пакетов и запускает захват.
// Пакеты отправляются в возвращаемый канал до отмены ctx.
func (c *Capturer) Start(ctx context.Context) (<-chan gopacket.Packet, error) {
var handle *pcap.Handle
var err error
switch c.cfg.Mode {
case ModeLive:
handle, err = pcap.OpenLive(
c.cfg.Interface,
c.cfg.Snaplen,
c.cfg.Promiscuous,
pcap.BlockForever,
)
if err != nil {
return nil, fmt.Errorf("open live capture on %s: %w", c.cfg.Interface, err)
}
case ModePCAP:
handle, err = pcap.OpenOffline(c.cfg.PCAPFile)
if err != nil {
return nil, fmt.Errorf("open pcap file %s: %w", c.cfg.PCAPFile, err)
}
default:
return nil, fmt.Errorf("unknown capture mode: %s", c.cfg.Mode)
}
c.handle = handle
pkts := make(chan gopacket.Packet, 1024)
go func() {
defer close(pkts)
defer handle.Close()
src := gopacket.NewPacketSource(handle, handle.LinkType())
src.NoCopy = true
for {
select {
case <-ctx.Done():
return
default:
}
pkt, err := src.NextPacket()
if err != nil {
if err == io.EOF {
return
}
// pcap timeout или другая recoverable ошибка
log.Printf("[capture] packet read error: %v", err)
continue
}
select {
case pkts <- pkt:
case <-ctx.Done():
return
}
}
}()
return pkts, nil
}
// Stop принудительно закрывает handle (используется при остановке из API)
func (c *Capturer) Stop() {
if c.handle != nil {
c.handle.Close()
}
}
// ListInterfaces возвращает доступные сетевые интерфейсы
func ListInterfaces() ([]string, error) {
devs, err := pcap.FindAllDevs()
if err != nil {
return nil, err
}
names := make([]string, 0, len(devs))
for _, d := range devs {
names = append(names, d.Name)
}
return names, nil
}
+84
View File
@@ -0,0 +1,84 @@
package config
import (
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
Capture CaptureConfig `yaml:"capture"`
Detectors DetectorsConfig `yaml:"detectors"`
}
type ServerConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
}
type DatabaseConfig struct {
Path string `yaml:"path"`
}
type CaptureConfig struct {
DefaultInterface string `yaml:"default_interface"`
Snaplen int32 `yaml:"snaplen"`
Promiscuous bool `yaml:"promiscuous"`
}
type DetectorsConfig struct {
PortScan PortScanConfig `yaml:"port_scan"`
BruteForce BruteForceConfig `yaml:"brute_force"`
DoS DoSConfig `yaml:"dos"`
ARPSpoof ARPSpoofConfig `yaml:"arp_spoof"`
DNSAnomaly DNSAnomalyConfig `yaml:"dns_anomaly"`
}
type PortScanConfig struct {
Enabled bool `yaml:"enabled"`
Threshold int `yaml:"threshold"`
WindowSeconds int `yaml:"window_seconds"`
Severity string `yaml:"severity"`
}
type BruteForceConfig struct {
Enabled bool `yaml:"enabled"`
Threshold int `yaml:"threshold"`
WindowSeconds int `yaml:"window_seconds"`
Severity string `yaml:"severity"`
Ports []int32 `yaml:"ports"`
}
type DoSConfig struct {
Enabled bool `yaml:"enabled"`
PPSThreshold int `yaml:"pps_threshold"`
WindowSeconds int `yaml:"window_seconds"`
Severity string `yaml:"severity"`
}
type ARPSpoofConfig struct {
Enabled bool `yaml:"enabled"`
Severity string `yaml:"severity"`
}
type DNSAnomalyConfig struct {
Enabled bool `yaml:"enabled"`
MaxLabelLength int `yaml:"max_label_length"`
QueryThreshold int `yaml:"query_threshold"`
WindowSeconds int `yaml:"window_seconds"`
Severity string `yaml:"severity"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
+440
View File
@@ -0,0 +1,440 @@
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/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
}
// Пути к шрифту DejaVu Sans с поддержкой кириллицы
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"
// 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 != "" {
builder = builder.
WithCustomFonts([]*entity.CustomFont{
{Family: fontFamily, Style: fontstyle.Normal, File: normalFont},
{Family: fontFamily, Style: fontstyle.Bold, File: boldFont},
{Family: fontFamily, Style: fontstyle.Italic, File: normalFont},
{Family: fontFamily, Style: fontstyle.BoldItalic, File: boldFont},
}).
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(3, line.NewCol(12, props.Line{Color: &props.Color{Red: 37, Green: 99, Blue: 235}}))
m.AddRows(buildMeta(d)...)
m.AddRow(5)
// ── Сводка по критичности ─────────────────────────────────────────────────
m.AddRows(buildSectionTitle("1. Сводка по уровням критичности")...)
m.AddRows(buildSeverityTable(d.BySeverity)...)
m.AddRow(4)
// ── Типы угроз ────────────────────────────────────────────────────────────
m.AddRows(buildSectionTitle("2. Типы обнаруженных угроз")...)
m.AddRows(buildTypeTable(d.ByType)...)
m.AddRow(4)
// ── Топ источников ────────────────────────────────────────────────────────
if len(d.TopIPs) > 0 {
m.AddRows(buildSectionTitle("3. Топ источников атак")...)
m.AddRows(buildTopIPsTable(d.TopIPs)...)
m.AddRow(4)
}
// ── Статистика трафика по протоколам ──────────────────────────────────────
if len(d.TrafficByProto) > 0 {
m.AddRows(buildSectionTitle("4. Статистика трафика по протоколам")...)
m.AddRows(buildTrafficProtoTable(d.TrafficByProto)...)
m.AddRow(4)
}
// ── Лог трафика (последние записи) ────────────────────────────────────────
if len(d.RecentStats) > 0 {
m.AddRows(buildSectionTitle(fmt.Sprintf("5. Журнал трафика (последние %d записей)", len(d.RecentStats)))...)
m.AddRows(buildTrafficLogHeader()...)
for i, s := range d.RecentStats {
if i >= 100 {
break
}
m.AddRows(buildTrafficLogRow(s)...)
}
m.AddRow(4)
}
// ── Журнал инцидентов ─────────────────────────────────────────────────────
sectionNum := 6
if len(d.TrafficByProto) == 0 {
sectionNum = 4
}
m.AddRows(buildSectionTitle(fmt.Sprintf("%d. Журнал инцидентов", sectionNum))...)
m.AddRows(buildAlertsTableHeader()...)
for i, a := range d.Alerts {
if i >= 200 {
break
}
m.AddRows(buildAlertRow(a)...)
}
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(14).Add(
col.New(8).Add(
text.New("СИСТЕМА ОБНАРУЖЕНИЯ ВТОРЖЕНИЙ", props.Text{
Size: 16,
Style: fontstyle.Bold,
Color: &props.Color{Red: 37, Green: 99, Blue: 235},
}),
text.New("Отчёт об инцидентах сетевой безопасности", props.Text{
Size: 9,
Top: 8,
Color: &props.Color{Red: 100, Green: 100, Blue: 100},
}),
),
col.New(4).Add(
text.New("DIP-IDS v1.0", props.Text{
Size: 9,
Align: align.Right,
Style: fontstyle.Bold,
Color: &props.Color{Red: 37, Green: 99, Blue: 235},
}),
text.New(fmt.Sprintf("Дата: %s", d.GeneratedAt.Format("02.01.2006 15:04:05")), props.Text{
Size: 8,
Top: 5,
Align: align.Right,
Color: &props.Color{Red: 120, Green: 120, Blue: 120},
}),
),
),
}
}
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 = "активна"
}
}
return []core.Row{
row.New(5).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)),
),
}
}
func metaCell(label, value string) core.Component {
return text.New(label+": "+value, props.Text{
Size: 8,
Color: &props.Color{Red: 80, Green: 80, Blue: 80},
})
}
func buildSectionTitle(title string) []core.Row {
return []core.Row{
row.New(8).Add(
col.New(12).Add(
text.New(title, props.Text{
Size: 11,
Style: fontstyle.Bold,
Color: &props.Color{Red: 37, Green: 99, Blue: 235},
}),
),
),
}
}
// ── Таблицы сводки ────────────────────────────────────────────────────────────
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 _, sev := range []string{"critical", "high", "medium", "low"} {
rows = append(rows, row.New(6).Add(
col.New(6).Add(text.New(severityLabel(sev), props.Text{
Size: 9, Style: fontstyle.Bold, Color: severityColor(sev),
})),
col.New(6).Add(text.New(fmt.Sprintf("%d", counts[sev]), props.Text{Size: 9})),
))
}
return rows
}
func buildTypeTable(data []storage.TypeCount) []core.Row {
rows := []core.Row{tableHeaderRow([]colDef{
{w: 6, label: "Тип атаки"},
{w: 6, label: "Количество"},
})}
for _, t := range data {
rows = append(rows, row.New(6).Add(
col.New(6).Add(text.New(typeLabel(t.Type), props.Text{Size: 9})),
col.New(6).Add(text.New(fmt.Sprintf("%d", t.Count), props.Text{Size: 9})),
))
}
return rows
}
func buildTopIPsTable(data []storage.IPCount) []core.Row {
rows := []core.Row{tableHeaderRow([]colDef{
{w: 1, label: "#"},
{w: 7, label: "IP-адрес источника"},
{w: 4, label: "Алертов"},
})}
for i, ip := range data {
rows = append(rows, row.New(6).Add(
col.New(1).Add(text.New(fmt.Sprintf("%d", i+1), props.Text{Size: 8})),
col.New(7).Add(text.New(ip.SrcIP, props.Text{Size: 8})),
col.New(4).Add(text.New(fmt.Sprintf("%d", ip.Count), props.Text{Size: 8})),
))
}
return rows
}
// ── Таблица трафика по протоколам ─────────────────────────────────────────────
func buildTrafficProtoTable(data []storage.ProtocolCount) []core.Row {
rows := []core.Row{tableHeaderRow([]colDef{
{w: 4, label: "Протокол"},
{w: 4, label: "Пакетов"},
{w: 4, label: "Объём трафика"},
})}
for _, p := range data {
rows = append(rows, row.New(6).Add(
col.New(4).Add(text.New(p.Protocol, props.Text{Size: 9, Style: fontstyle.Bold})),
col.New(4).Add(text.New(fmt.Sprintf("%d", p.Count), props.Text{Size: 9})),
col.New(4).Add(text.New(humanBytes(p.Bytes), props.Text{Size: 9})),
))
}
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) []core.Row {
return []core.Row{
row.New(5).Add(
col.New(3).Add(text.New(s.CreatedAt.Format("02.01 15:04:05"), props.Text{Size: 7})),
col.New(2).Add(text.New(s.Protocol, props.Text{Size: 7, Style: fontstyle.Bold})),
col.New(4).Add(text.New(s.SrcIP, props.Text{Size: 7})),
col.New(3).Add(text.New(s.DstIP+" "+humanBytes(s.Bytes), props.Text{Size: 7})),
),
}
}
// ── Журнал инцидентов ─────────────────────────────────────────────────────────
func buildAlertsTableHeader() []core.Row {
return []core.Row{tableHeaderRow([]colDef{
{w: 3, label: "Дата/Время"},
{w: 2, label: "Тип"},
{w: 2, label: "Критичность"},
{w: 2, label: "Источник"},
{w: 3, label: "Описание"},
})}
}
func buildAlertRow(a storage.Alert) []core.Row {
return []core.Row{
row.New(6).Add(
col.New(3).Add(text.New(a.CreatedAt.Format("02.01.06 15:04:05"), props.Text{Size: 7})),
col.New(2).Add(text.New(typeLabel(a.Type), props.Text{Size: 7})),
col.New(2).Add(text.New(severityLabel(a.Severity), props.Text{
Size: 7, Style: fontstyle.Bold, Color: severityColor(a.Severity),
})),
col.New(2).Add(text.New(a.SrcIP, props.Text{Size: 7})),
col.New(3).Add(text.New(truncate(a.Description, 60), props.Text{Size: 7})),
),
}
}
// ── Вспомогательные типы и функции ───────────────────────────────────────────
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: &props.Color{Red: 255, Green: 255, Blue: 255},
}),
))
}
return row.New(7).Add(cells...)
}
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 {
if len([]rune(s)) <= n {
return s
}
return string([]rune(s)[:n-3]) + "..."
}
+210
View File
@@ -0,0 +1,210 @@
package storage
import (
"fmt"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type DB struct {
db *gorm.DB
}
func New(path string) (*DB, error) {
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if err := db.AutoMigrate(&Alert{}, &PacketStat{}, &CaptureSession{}); err != nil {
return nil, fmt.Errorf("migrate: %w", err)
}
return &DB{db: db}, nil
}
// ── Alerts ──────────────────────────────────────────────────────────────────
func (d *DB) CreateAlert(a *Alert) error {
return d.db.Create(a).Error
}
func (d *DB) GetAlerts(f AlertFilter) ([]Alert, int64, error) {
q := d.db.Model(&Alert{})
if f.Type != "" {
q = q.Where("type = ?", f.Type)
}
if f.Severity != "" {
q = q.Where("severity = ?", f.Severity)
}
if f.SrcIP != "" {
q = q.Where("src_ip = ?", f.SrcIP)
}
if f.DateFrom != nil {
q = q.Where("created_at >= ?", f.DateFrom)
}
if f.DateTo != nil {
q = q.Where("created_at <= ?", f.DateTo)
}
var total int64
q.Count(&total)
if f.PageSize <= 0 {
f.PageSize = 50
}
if f.Page <= 0 {
f.Page = 1
}
var alerts []Alert
err := q.Order("created_at desc").
Offset((f.Page - 1) * f.PageSize).
Limit(f.PageSize).
Find(&alerts).Error
return alerts, total, err
}
func (d *DB) DeleteAlert(id uint) error {
return d.db.Delete(&Alert{}, id).Error
}
func (d *DB) DeleteAllAlerts() error {
return d.db.Where("1 = 1").Delete(&Alert{}).Error
}
// ── Stats ────────────────────────────────────────────────────────────────────
func (d *DB) CreatePacketStat(s *PacketStat) error {
return d.db.Create(s).Error
}
type SeverityCount struct {
Severity string
Count int64
}
func (d *DB) AlertsBySeverity() ([]SeverityCount, error) {
var result []SeverityCount
err := d.db.Model(&Alert{}).
Select("severity, count(*) as count").
Group("severity").
Find(&result).Error
return result, err
}
type TypeCount struct {
Type string
Count int64
}
func (d *DB) AlertsByType() ([]TypeCount, error) {
var result []TypeCount
err := d.db.Model(&Alert{}).
Select("type, count(*) as count").
Group("type").
Find(&result).Error
return result, err
}
type IPCount struct {
SrcIP string
Count int64
}
func (d *DB) TopSourceIPs(limit int) ([]IPCount, error) {
var result []IPCount
err := d.db.Model(&Alert{}).
Select("src_ip, count(*) as count").
Group("src_ip").
Order("count desc").
Limit(limit).
Find(&result).Error
return result, err
}
type TimelinePoint struct {
Hour string
Count int64
}
func (d *DB) AlertsTimeline(hours int) ([]TimelinePoint, error) {
since := time.Now().Add(-time.Duration(hours) * time.Hour)
var result []TimelinePoint
err := d.db.Model(&Alert{}).
Select("strftime('%Y-%m-%dT%H:00:00', created_at) as hour, count(*) as count").
Where("created_at >= ?", since).
Group("hour").
Order("hour asc").
Find(&result).Error
return result, err
}
func (d *DB) TotalAlerts() (int64, error) {
var count int64
err := d.db.Model(&Alert{}).Count(&count).Error
return count, err
}
type ProtocolCount struct {
Protocol string
Count int64
Bytes int64
}
func (d *DB) TrafficByProtocol() ([]ProtocolCount, error) {
var result []ProtocolCount
err := d.db.Model(&PacketStat{}).
Select("protocol, count(*) as count, sum(bytes) as bytes").
Group("protocol").
Order("count desc").
Find(&result).Error
return result, err
}
func (d *DB) GetRecentPacketStats(limit int) ([]PacketStat, error) {
var stats []PacketStat
err := d.db.Order("created_at desc").Limit(limit).Find(&stats).Error
return stats, err
}
// ── Sessions ─────────────────────────────────────────────────────────────────
func (d *DB) CreateSession(s *CaptureSession) error {
return d.db.Create(s).Error
}
func (d *DB) UpdateSession(s *CaptureSession) error {
return d.db.Save(s).Error
}
func (d *DB) GetActiveSession() (*CaptureSession, error) {
var s CaptureSession
err := d.db.Where("status = ?", "running").First(&s).Error
if err != nil {
return nil, err
}
return &s, nil
}
func (d *DB) GetLastSession() (*CaptureSession, error) {
var s CaptureSession
err := d.db.Order("created_at desc").First(&s).Error
if err != nil {
return nil, err
}
return &s, nil
}
func (d *DB) GetAllSessions() ([]CaptureSession, error) {
var sessions []CaptureSession
err := d.db.Order("created_at desc").Limit(20).Find(&sessions).Error
return sessions, err
}
+64
View File
@@ -0,0 +1,64 @@
package storage
import "time"
type Alert struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
Type string `gorm:"index" json:"type"`
Severity string `gorm:"index" json:"severity"`
SrcIP string `gorm:"index" json:"src_ip"`
DstIP string `json:"dst_ip"`
SrcPort uint16 `json:"src_port"`
DstPort uint16 `json:"dst_port"`
Protocol string `json:"protocol"`
Description string `json:"description"`
Count int `json:"count"`
}
type PacketStat struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
Protocol string `json:"protocol"`
SrcIP string `json:"src_ip"`
DstIP string `json:"dst_ip"`
Bytes int64 `json:"bytes"`
}
type CaptureSession struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
EndedAt *time.Time `json:"ended_at"`
Source string `json:"source"`
Mode string `json:"mode"`
Status string `json:"status"`
TotalPkts int64 `json:"total_pkts"`
TotalBytes int64 `json:"total_bytes"`
}
// AlertFilter используется для фильтрации алертов в запросах
type AlertFilter struct {
Type string
Severity string
SrcIP string
DateFrom *time.Time
DateTo *time.Time
Page int
PageSize int
}
// SeverityOrder возвращает числовой приоритет для сортировки
func SeverityOrder(s string) int {
switch s {
case "critical":
return 4
case "high":
return 3
case "medium":
return 2
case "low":
return 1
default:
return 0
}
}
BIN
View File
Binary file not shown.