Initial commit
This commit is contained in:
+123
@@ -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/
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 |
|
||||
@@ -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` — 3–15 символов.
|
||||
Подозрительная метка: `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
@@ -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
@@ -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
@@ -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 сек |
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,6 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
primary: 'blue',
|
||||
gray: 'neutral',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
+1822
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]) + "..."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user