Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a940772f0 | ||
|
|
f198cc47d9 | ||
|
|
49de0b1f49 | ||
|
|
ea890c01b1 | ||
|
|
f7b347212e | ||
|
|
72869dcf5f |
187
README.md
187
README.md
@@ -32,7 +32,7 @@ ssh root@192.168.0.10
|
||||
|
||||
```bash
|
||||
apt-get update
|
||||
apt-get install -y curl ca-certificates tar smartmontools iproute2 iputils-ping
|
||||
apt-get install -y curl ca-certificates tar smartmontools iproute2 iputils-ping ethtool
|
||||
|
||||
cd /tmp
|
||||
curl -fL \
|
||||
@@ -401,3 +401,188 @@ curl -fsSL https://git.myown.center/maxim/ProxmoxDash/raw/branch/main/scripts/un
|
||||
go test ./...
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o proxmox-cpu-dashboard .
|
||||
```
|
||||
|
||||
## Сетевые ошибки и температура CPU (v0.19.3)
|
||||
|
||||
RX/TX drops отображаются отдельно как информационные счётчики. Их рост сам по
|
||||
себе не вызывает аварийный алерт: в том числе LLDP на Linux bridge. Настройки
|
||||
Keenetic, LLDP и group_fwd_mask менять не требуется. Для VM учитываются tap,
|
||||
для LXC — veth; fwbr/fwpr/fwln остаются в группе интерфейсов хоста.
|
||||
|
||||
Новые rx_errors/tx_errors, CRC/frame/FIFO/missed, carrier/link-down вызывают
|
||||
критический алерт. Sysfs позволяет отделить rx_missed_errors от RX drops,
|
||||
которые в /proc/net/dev объединены. Для Atlantic дополнительно читаются
|
||||
InErrors, InDroppedDma, AllocFails, SkbAllocFails и Restarts через ethtool -S.
|
||||
Установите `ethtool` на существующем хосте для этой дополнительной диагностики.
|
||||
При отсутствии утилиты остаются стандартные счётчики sysfs. Счётчики ошибок
|
||||
могут пересекаться: их сумма — прирост счётчиков, а не число уникальных пакетов.
|
||||
После перезапуска и сброса счётчиков старые значения не создают новые алерты.
|
||||
|
||||
Карточка CPU и новые точки истории показывают максимум доступных датчиков CPU,
|
||||
а не среднее Tctl/Tdie/CCD или ядер. На AMD Tctl может включать управляющее
|
||||
смещение; подписи и значения всех датчиков доступны в подробностях CPU.
|
||||
История снимается раз в минуту, а на диапазоне 7 дней усредняется по 10 минутам,
|
||||
поэтому короткие пики между измерениями не гарантированно попадут на график.
|
||||
Старые точки до обновления остаются средними: исходные значения датчиков в них
|
||||
не сохранены. Масштаб графика расширяется для значений выше 100 °C.
|
||||
|
||||
Релиз Linux amd64 собирается с номером версии и без CGO:
|
||||
|
||||
```bash
|
||||
./scripts/build-release.sh v0.19.3
|
||||
```
|
||||
|
||||
Скрипт запускает тесты и создаёт в dist бинарник, SHA-256 и установочный архив,
|
||||
совместимые со встроенным обновлением из Gitea Releases.
|
||||
|
||||
## Карта трафика NPM + CrowdSec (v0.20.0)
|
||||
|
||||
С v0.20.1 страница помещается в высоту окна: карта и лента в центре,
|
||||
счётчики сверху, график и рейтинги снизу. На телефоне доступны вкладки
|
||||
«Карта / События / Статистика». Последние 100 событий листаются по страницам;
|
||||
нажатие на строку открывает полные подробности.
|
||||
|
||||
Откройте **Настройки → Карта трафика**, включите страницу и выберите
|
||||
агент VM/LXC, где установлены Nginx Proxy Manager и CrowdSec. Например,
|
||||
`nginxproxymanager · VMID 102`. Необязательно выберите страну сервера для
|
||||
конечной точки линий на карте. Сохраните настройки: в шапке появится ссылка
|
||||
«Карта трафика», страница также доступна по `/traffic`.
|
||||
|
||||
Функция **по умолчанию выключена**. Только выбранный агент получает команду
|
||||
включить сбор. При выключении или смене источника старый агент останавливает
|
||||
сбор после следующего успешного обычного отчёта; сервер сразу прекращает
|
||||
приём его событий. Выключенная страница и её API недоступны. Это глобальная
|
||||
настройка экземпляра Dashboard. Карта не создаёт общих алертов или писем.
|
||||
|
||||
Агент автоматически обновляется вместе с Dashboard; для карты требуется
|
||||
v0.20.0 или новее. Он читает стандартные NPM access-логи из
|
||||
`/opt/npm/data/logs/*_access.log`, включая несжатые файлы ротации. Другой каталог
|
||||
задаётся переменной окружения службы `DASHBOARD_NPM_LOG_DIR`; значение `off`
|
||||
полностью запрещает сбор на конкретном агенте. Порт CrowdSec наружу открывать
|
||||
и новые API-ключи создавать не нужно: используется локальная команда
|
||||
`cscli alerts list --since 24h --limit 50 -o json` с существующими учётными
|
||||
данными машины. Права на логи и запуск cscli нужны службе агента.
|
||||
|
||||
- Голубой: все записанные HTTP-запросы, включая обычные и локальные обращения.
|
||||
- Оранжевый: реальные (не simulated) срабатывания сценариев CrowdSec.
|
||||
- Розовый: решения CrowdSec, например ban или captcha. Это **не** счётчик
|
||||
фактически отброшенных пакетов и не список действующих сейчас банов.
|
||||
- HTTP 401/403/502 сами по себе не считаются атакой или блокировкой.
|
||||
|
||||
Карта показывает страны источников, живую ленту последних 100 событий,
|
||||
счётчики за последние 24 часа, уникальные IP HTTP-клиентов, график по минутам
|
||||
и средний req/s за последние 60 секунд. Есть фильтры типа события, домена и
|
||||
внешних IP, пауза и полный экран. Локальные адреса видны в ленте без страны.
|
||||
Все счётчики следуют выбранным фильтрам. Уникальный IP не равен человеку.
|
||||
|
||||
Первое включение начинает чтение с конца логов, без импорта старых HTTP-запросов.
|
||||
Обычно обновление занимает несколько секунд; Nginx записывает запрос после
|
||||
его завершения. Это не карта открытых TCP-соединений. Позиции чтения и очередь
|
||||
до 500 событий сохраняются в `traffic-state.json` рядом с конфигом агента;
|
||||
повторы после обрыва связи устраняются на сервере. При переполнении очереди
|
||||
чтение ждёт отправки. Если за время длительного простоя логи уже сжаты или
|
||||
удалены, восстановить эти строки нельзя. Размер очереди чтения и ошибки
|
||||
парсинга отображаются на самой странице, без глобальных уведомлений.
|
||||
|
||||
CrowdSec опрашивается каждые 15 секунд, до 50 последних alerts за 24 часа;
|
||||
при достижении лимита страница предупреждает о возможной неполноте истории.
|
||||
Связанный домен берётся из метаданных события, только если он однозначен.
|
||||
Новый ban не рисуется как новый запрос. Для наблюдения реально отброшенных
|
||||
firewall пакетов нужен отдельный источник: в этой версии его нет.
|
||||
|
||||
В SQLite хранится до 24 часов и 200 000 событий; старые строки удаляются при
|
||||
приёме новых данных. Данные каждого выбранного агента фильтруются отдельно.
|
||||
Query string и fragment URL отбрасываются на агенте и повторно на сервере;
|
||||
заголовки, cookie и тела запросов не передаются. IP, домен и путь сохраняются.
|
||||
|
||||
GeoIP работает локально: DB-IP Country Lite за октябрь 2026 встроена в бинарник,
|
||||
исходящие запросы с IP посетителей не выполняются. Координаты на карте — условные
|
||||
точки стран Natural Earth, а не точное местоположение посетителя. Дата базы
|
||||
видна внизу карты. Обновление базы требует нового релиза; автоматического
|
||||
скачивания нет. Лицензии и происхождение данных: `assets/NOTICE.md`.
|
||||
|
||||
Локальный предпросмотр с явно обозначенными искусственными данными:
|
||||
|
||||
```bash
|
||||
DASHBOARD_TRAFFIC_PREVIEW=1 go test -run '^TestTrafficPreview$' -timeout 30m
|
||||
# http://127.0.0.1:19105/traffic
|
||||
```
|
||||
|
||||
## Сетевая активность TCP/UDP/ICMP (v0.21.0)
|
||||
|
||||
В **Настройки → Карта трафика** включите дополнительный сбор TCP/UDP/ICMP.
|
||||
Он выполняется только выбранным агентом v0.21.0+; на странице карты появится
|
||||
кнопка **TCP / UDP**, открывающая `/network`. Основной выключатель карты
|
||||
отключает оба сборщика. Сетевой режим по умолчанию выключен и не создаёт
|
||||
общих алертов/писем.
|
||||
|
||||
На Debian/Ubuntu агент **сам проверяет и устанавливает conntrack**, когда
|
||||
включён сетевой режим. Выполняются `apt-get update` и установка только
|
||||
`conntrack` с зависимостями (`--no-install-recommends --no-remove`), без
|
||||
обновления всей системы. Установка выполняется от root через отдельную
|
||||
systemd-службу, как существующие операции обновления агента, и не требует
|
||||
отключать его read-only sandbox. Начатая пакетная операция завершается даже
|
||||
при выключении сбора. Ход/ошибка видны в диагностике; повтор после ошибки —
|
||||
не чаще раза в 30 минут работы процесса. После перезапуска агента проверка
|
||||
выполняется заново. Если conntrack уже установлен, apt-get не запускается.
|
||||
|
||||
На других системах, без root или systemd/apt-get, выводится инструкция для
|
||||
ручной установки. Для счётчиков CrowdSec требуется доступный `nft`; он
|
||||
обычно уже установлен вместе с nftables-bouncer и автоматически не ставится.
|
||||
|
||||
Агенту нужны root/CAP_NET_ADMIN в сетевом namespace хоста. Установщик агента
|
||||
запускает службу от root. Чтение: `/proc/net/nf_conntrack`, если доступен,
|
||||
иначе `conntrack -L -f ipv4/ipv6 -o extended`. Ничего устанавливать на хост
|
||||
самого Dashboard не требуется. Firewall, NAT и sysctl сборщик не меняет.
|
||||
|
||||
Страница показывает состояния TCP, UDP/ICMP-потоки, обе пары адресов до/после
|
||||
NAT (в том числе Docker), локальную GeoIP, топ инициаторов и портов, фильтры,
|
||||
поиск и детали потока. Список листается по страницам; переход на следующую
|
||||
фиксирует снимок, «К свежим» возвращает live. Для телефона — вкладки.
|
||||
|
||||
Это **снимки таблицы примерно каждые 5 секунд**, не полный журнал соединений:
|
||||
короткие потоки между снимками могут быть пропущены. До 5 000 записей на
|
||||
снимок, 8 MiB вывода команды и таймаут 4 секунды; ограничения/ошибки видны в
|
||||
диагностике. Сохраняется только последний снимок каждого агента. Инициатор
|
||||
не обязательно внешний клиент: NPM → upstream — отдельный поток. Порт не
|
||||
доказывает протокол приложения; UDP ASSURED не означает TCP handshake.
|
||||
|
||||
Байты/пакеты доступны только при включённом учёте conntrack для конкретных
|
||||
потоков. Это накопленные значения за жизнь потока, а не трафик за 24 часа.
|
||||
При отсутствии учёта интерфейс показывает «нет учёта». При необходимости
|
||||
администратор может включить `net.netfilter.nf_conntrack_acct=1`; изменение
|
||||
применяется к новым потокам и автоматически не выполняется.
|
||||
|
||||
Firewall читается раз в 30 секунд командой `nft -j list ruleset`. Поддержаны
|
||||
анонимные счётчики непосредственно перед финальным `drop/reject` в таблице
|
||||
или цепочке с `crowdsec` в имени. Остальные счётчики не выдаются за блокировки.
|
||||
Это сумма пакетов/байтов выбранных правил с момента их создания, независимо
|
||||
от фильтров потоков; при пересоздании правил она может уменьшиться.
|
||||
Для iptables/ipset-only, именованных счётчиков и правил без счётчиков выводится
|
||||
«недоступно», а не ноль. Отброшенный пакет до подтверждения conntrack может
|
||||
отсутствовать в таблице потоков. Содержимое пакетов не перехватывается.
|
||||
|
||||
## Сетевой слой карты и фильтр мониторинга (v0.22.0)
|
||||
|
||||
При включённом сборе TCP/UDP/ICMP переключатель **Сеть на карте** показывает
|
||||
текущие потоки отдельным слоем: TCP — зелёный, UDP — розовый, ICMP/ICMPv6 —
|
||||
жёлтый. Импульсы означают новый снимок (~5 секунд), не отдельные пакеты или
|
||||
новые подключения. Входящие и исходящие потоки имеют разное направление.
|
||||
Локальные адреса и потоки без страны учитываются отдельно; при отсутствии
|
||||
свежего снимка 25 секунд сетевой слой гаснет. HTTP-статистика за 24 часа с
|
||||
текущими потоками не складывается. Фильтры домена и HTTP/CrowdSec относятся
|
||||
только к HTTP-слою; фильтры внешних IP и мониторинга действуют на оба слоя.
|
||||
|
||||
В **Настройки → Карта трафика → Правила мониторинга** задайте по одному правилу
|
||||
на строку: `IP | домен | префикс пути`. IP точный, включая IPv6; домен и путь
|
||||
необязательны. Пример: `192.168.0.10` или `192.168.0.10 | example.org | /health`.
|
||||
Для сетевых потоков применяются только правила без домена/пути, к IP инициатора.
|
||||
При первом обновлении добавляется правило `192.168.0.10`; его можно изменить
|
||||
или полностью удалить. Сам фильтр по умолчанию выключен.
|
||||
|
||||
**Скрыть мониторинг** на карте и `/network` исключает совпадения из отображения
|
||||
и соответствующей статистики. Настройка переключателя сохраняется в браузере.
|
||||
HTTP-проверки остаются в истории и возвращаются при выключении фильтра;
|
||||
новые правила сразу применяются и к старым записям. Срабатывания и решения
|
||||
CrowdSec, а также общие счётчики firewall, никогда не скрываются этим фильтром.
|
||||
Сетевые потоки по-прежнему хранятся только последним снимком, без истории.
|
||||
|
||||
38
agent.go
38
agent.go
@@ -144,9 +144,11 @@ type AgentUpdateManifest struct {
|
||||
}
|
||||
|
||||
type agentReportResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Update *AgentUpdateManifest `json:"update,omitempty"`
|
||||
Commands []AgentCommand `json:"commands,omitempty"`
|
||||
Accepted bool `json:"accepted"`
|
||||
NetworkEnabled bool `json:"networkEnabled"`
|
||||
TrafficEnabled bool `json:"trafficEnabled"`
|
||||
Update *AgentUpdateManifest `json:"update,omitempty"`
|
||||
Commands []AgentCommand `json:"commands,omitempty"`
|
||||
}
|
||||
|
||||
var guestInventory struct {
|
||||
@@ -339,6 +341,23 @@ func runGuestAgent(opts agentOptions) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var networkStop func()
|
||||
stopNetwork := func() {
|
||||
if networkStop != nil {
|
||||
networkStop()
|
||||
networkStop = nil
|
||||
}
|
||||
}
|
||||
defer stopNetwork()
|
||||
var trafficStop func()
|
||||
stopTraffic := func() {
|
||||
if trafficStop != nil {
|
||||
trafficStop()
|
||||
trafficStop = nil
|
||||
}
|
||||
}
|
||||
defer stopTraffic()
|
||||
|
||||
client := &http.Client{Timeout: 12 * time.Second}
|
||||
for {
|
||||
report := collectAgentReport()
|
||||
@@ -356,6 +375,8 @@ func runGuestAgent(opts agentOptions) error {
|
||||
if response.StatusCode == http.StatusUnauthorized && opts.EnrollmentToken != "" {
|
||||
fresh, enrollmentErr := enrollRemote(config, opts.EnrollmentToken)
|
||||
if enrollmentErr == nil {
|
||||
stopTraffic()
|
||||
stopNetwork()
|
||||
config = fresh
|
||||
if saveErr := saveAgentConfig(opts.ConfigPath, config); saveErr != nil {
|
||||
return saveErr
|
||||
@@ -372,6 +393,17 @@ func runGuestAgent(opts agentOptions) error {
|
||||
if response.StatusCode == http.StatusOK {
|
||||
var result agentReportResponse
|
||||
if json.Unmarshal(responseData, &result) == nil {
|
||||
if !result.NetworkEnabled {
|
||||
stopNetwork()
|
||||
} else if networkStop == nil {
|
||||
networkStop = startAgentNetwork(config)
|
||||
}
|
||||
if !result.TrafficEnabled {
|
||||
stopTraffic()
|
||||
} else if trafficStop == nil {
|
||||
trafficStop = startAgentTraffic(config, filepath.Dir(opts.ConfigPath))
|
||||
}
|
||||
|
||||
if len(report.ActionResults) > 0 {
|
||||
guestActionResults.Lock()
|
||||
guestActionResults.value = nil
|
||||
|
||||
271
agent_network.go
Normal file
271
agent_network.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounded command output and deadline keep large conntrack tables from exhausting the agent.
|
||||
type networkBuffer struct {
|
||||
bytes.Buffer
|
||||
limit int
|
||||
}
|
||||
|
||||
func (b *networkBuffer) Write(p []byte) (int, error) {
|
||||
if len(p) > b.limit-b.Len() {
|
||||
return 0, errors.New("output limit exceeded")
|
||||
}
|
||||
return b.Buffer.Write(p)
|
||||
}
|
||||
func networkCommand(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
out := &networkBuffer{limit: 8 << 20}
|
||||
stderr := &networkBuffer{limit: 4096}
|
||||
cmd.Stdout = out
|
||||
cmd.Stderr = stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("%s: %v %s", name, err, trafficText(stderr.String(), 240))
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
func parseNetworkFlow(line string) (NetworkFlow, bool) {
|
||||
var f NetworkFlow
|
||||
fields := strings.Fields(line)
|
||||
protoIndex := -1
|
||||
for i, v := range fields {
|
||||
if v == "tcp" || v == "udp" || v == "icmp" || v == "icmpv6" {
|
||||
f.Protocol = v
|
||||
protoIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if protoIndex < 0 {
|
||||
return f, false
|
||||
}
|
||||
values := map[string][]string{}
|
||||
for _, v := range fields[protoIndex+1:] {
|
||||
if k, val, ok := strings.Cut(v, "="); ok {
|
||||
values[k] = append(values[k], val)
|
||||
}
|
||||
}
|
||||
get := func(k string, i int) string {
|
||||
if len(values[k]) > i {
|
||||
return values[k][i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
integer := func(k string, i int) int { n, _ := strconv.Atoi(get(k, i)); return n }
|
||||
number := func(k string, i int) uint64 { n, _ := strconv.ParseUint(get(k, i), 10, 64); return n }
|
||||
f.Source = get("src", 0)
|
||||
f.Destination = get("dst", 0)
|
||||
f.ReplySource = get("src", 1)
|
||||
f.ReplyDestination = get("dst", 1)
|
||||
if net.ParseIP(f.Source) == nil || net.ParseIP(f.Destination) == nil || net.ParseIP(f.ReplySource) == nil || net.ParseIP(f.ReplyDestination) == nil {
|
||||
return f, false
|
||||
}
|
||||
f.SourcePort = integer("sport", 0)
|
||||
f.DestinationPort = integer("dport", 0)
|
||||
f.ReplySourcePort = integer("sport", 1)
|
||||
f.ReplyDestinationPort = integer("dport", 1)
|
||||
f.State = "TRACKED"
|
||||
if f.Protocol == "tcp" && len(fields) > protoIndex+3 && !strings.Contains(fields[protoIndex+3], "=") {
|
||||
f.State = fields[protoIndex+3]
|
||||
}
|
||||
if strings.Contains(line, "[UNREPLIED]") {
|
||||
f.State = "UNREPLIED"
|
||||
if f.Protocol == "tcp" && len(fields) > protoIndex+3 {
|
||||
f.State = fields[protoIndex+3]
|
||||
}
|
||||
}
|
||||
if f.Protocol == "udp" && strings.Contains(line, "[ASSURED]") {
|
||||
f.State = "ASSURED"
|
||||
}
|
||||
f.Accounting = len(values["bytes"]) == 2 && len(values["packets"]) == 2
|
||||
f.Bytes = number("bytes", 0)
|
||||
f.ReplyBytes = number("bytes", 1)
|
||||
f.Packets = number("packets", 0)
|
||||
f.ReplyPackets = number("packets", 1)
|
||||
f.Zone = get("zone", 0)
|
||||
return f, true
|
||||
}
|
||||
func appendNetworkFlows(snapshot *NetworkSnapshot, raw []byte) {
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
f, ok := parseNetworkFlow(line)
|
||||
if !ok {
|
||||
if strings.Contains(line, "src=") {
|
||||
snapshot.ParseErrors++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(snapshot.Flows) >= 5000 {
|
||||
snapshot.Limited = true
|
||||
continue
|
||||
}
|
||||
snapshot.Flows = append(snapshot.Flows, f)
|
||||
}
|
||||
}
|
||||
|
||||
// Only counters immediately before a terminal drop/reject in a CrowdSec table/chain
|
||||
// are reported as blocked packets. General chain and set counters are not summed.
|
||||
func parseFirewallCounters(raw []byte) ([]FirewallCounter, error) {
|
||||
var doc struct {
|
||||
NFTables []struct {
|
||||
Rule *struct {
|
||||
Family string `json:"family"`
|
||||
Table string `json:"table"`
|
||||
Chain string `json:"chain"`
|
||||
Handle int `json:"handle"`
|
||||
Expr []map[string]json.RawMessage `json:"expr"`
|
||||
} `json:"rule"`
|
||||
} `json:"nftables"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := []FirewallCounter{}
|
||||
for _, item := range doc.NFTables {
|
||||
r := item.Rule
|
||||
if r == nil || !strings.Contains(strings.ToLower(r.Table+" "+r.Chain), "crowdsec") {
|
||||
continue
|
||||
}
|
||||
if len(r.Expr) < 2 {
|
||||
continue
|
||||
}
|
||||
last := r.Expr[len(r.Expr)-1]
|
||||
_, drop := last["drop"]
|
||||
_, reject := last["reject"]
|
||||
if !drop && !reject {
|
||||
continue
|
||||
}
|
||||
counter, ok := r.Expr[len(r.Expr)-2]["counter"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var c struct {
|
||||
Packets uint64 `json:"packets"`
|
||||
Bytes uint64 `json:"bytes"`
|
||||
}
|
||||
if json.Unmarshal(counter, &c) != nil || len(counter) == 0 || counter[0] != '{' {
|
||||
continue
|
||||
}
|
||||
result = append(result, FirewallCounter{Name: fmt.Sprintf("%s/%s/%s #%d", r.Family, r.Table, r.Chain, r.Handle), Packets: c.Packets, Bytes: c.Bytes})
|
||||
if len(result) == 128 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func collectNetwork(ctx context.Context) NetworkSnapshot {
|
||||
s := NetworkSnapshot{At: time.Now().Unix(), Flows: []NetworkFlow{}}
|
||||
if runtime.GOOS != "linux" {
|
||||
s.Error = "Сбор conntrack поддерживается только на Linux"
|
||||
return s
|
||||
}
|
||||
// Proc contains both address families when available. Modern kernels often expose only netlink.
|
||||
for _, path := range []string{"/proc/net/nf_conntrack", "/proc/net/ip_conntrack"} {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(f, (8<<20)+1))
|
||||
f.Close()
|
||||
if err != nil {
|
||||
s.Error = err.Error()
|
||||
return s
|
||||
}
|
||||
if len(raw) > 8<<20 {
|
||||
s.Error = "Таблица conntrack превышает лимит чтения 8 MiB"
|
||||
return s
|
||||
}
|
||||
appendNetworkFlows(&s, raw)
|
||||
return s
|
||||
}
|
||||
for _, family := range []string{"ipv4", "ipv6"} {
|
||||
raw, err := networkCommand(ctx, "conntrack", "-L", "-f", family, "-o", "extended")
|
||||
if err != nil {
|
||||
s.Error += "Conntrack " + family + ": нужен пакет conntrack и права root/CAP_NET_ADMIN. " + err.Error() + "; "
|
||||
continue
|
||||
}
|
||||
appendNetworkFlows(&s, raw)
|
||||
}
|
||||
return s
|
||||
}
|
||||
func startAgentNetwork(config agentConfig) func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
var firewall []FirewallCounter
|
||||
var firewallAt int64
|
||||
var firewallErr string
|
||||
lastFirewall := time.Time{}
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
snapshot := NetworkSnapshot{At: time.Now().Unix(), Flows: []NetworkFlow{}}
|
||||
if message := ensureNetworkDependency(); message != "" {
|
||||
snapshot.Error = message
|
||||
} else {
|
||||
snapshot = collectNetwork(ctx)
|
||||
}
|
||||
if time.Since(lastFirewall) >= 30*time.Second {
|
||||
lastFirewall = time.Now()
|
||||
raw, err := networkCommand(ctx, "nft", "-j", "list", "ruleset")
|
||||
firewall = nil
|
||||
firewallAt = 0
|
||||
firewallErr = ""
|
||||
if err == nil {
|
||||
firewall, err = parseFirewallCounters(raw)
|
||||
}
|
||||
if err != nil {
|
||||
firewallErr = "Счётчики nftables недоступны: " + err.Error()
|
||||
} else if len(firewall) == 0 {
|
||||
firewallErr = "В правилах CrowdSec нет поддерживаемых счётчиков перед drop/reject. Это не означает отсутствие блокировок."
|
||||
} else {
|
||||
firewallAt = time.Now().Unix()
|
||||
}
|
||||
}
|
||||
snapshot.Firewall = firewall
|
||||
snapshot.FirewallAt = firewallAt
|
||||
snapshot.FirewallError = firewallErr
|
||||
data, _ := json.Marshal(snapshot)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", config.Server+"/api/agent/network", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+config.ID+"."+config.Secret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if res, err := client.Do(req); err == nil {
|
||||
io.Copy(io.Discard, io.LimitReader(res.Body, 4096))
|
||||
res.Body.Close()
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { cancel(); <-done }
|
||||
}
|
||||
89
agent_network_setup.go
Normal file
89
agent_network_setup.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Package work runs in a transient systemd unit, as do existing agent update
|
||||
// actions. It must not inherit ProtectSystem=strict from the reporting agent.
|
||||
// A started package transaction is allowed to finish if collection is disabled.
|
||||
type networkDependencyState struct {
|
||||
sync.Mutex
|
||||
running bool
|
||||
nextAttempt time.Time
|
||||
message string
|
||||
}
|
||||
|
||||
var networkDependency networkDependencyState
|
||||
|
||||
func (d *networkDependencyState) ensure(now time.Time, present bool, install func() error) string {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
if present {
|
||||
return ""
|
||||
}
|
||||
if d.running {
|
||||
return "Автоустановка conntrack… Сбор начнётся после установки пакета."
|
||||
}
|
||||
if now.Before(d.nextAttempt) {
|
||||
return d.message + " Повторная попытка не раньше " + d.nextAttempt.Format("15:04:05") + " (время агента)."
|
||||
}
|
||||
d.running = true
|
||||
d.nextAttempt = now.Add(30 * time.Minute)
|
||||
go func() {
|
||||
err := install()
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
d.running = false
|
||||
if err != nil {
|
||||
d.message = "Не удалось установить conntrack: " + trafficText(err.Error(), 400)
|
||||
} else {
|
||||
d.message = "Установка завершена; ожидается появление conntrack в PATH"
|
||||
}
|
||||
}()
|
||||
return "Автоустановка conntrack… Сбор начнётся после установки пакета."
|
||||
}
|
||||
func ensureNetworkDependency() string {
|
||||
if runtime.GOOS != "linux" {
|
||||
return "Сбор conntrack поддерживается только на Linux"
|
||||
}
|
||||
_, err := exec.LookPath("conntrack")
|
||||
return networkDependency.ensure(time.Now(), err == nil, installNetworkDependency)
|
||||
}
|
||||
func installNetworkDependency() error {
|
||||
if os.Geteuid() != 0 {
|
||||
return fmt.Errorf("для автоустановки нужны права root")
|
||||
}
|
||||
apt, err := exec.LookPath("apt-get")
|
||||
if err != nil {
|
||||
return fmt.Errorf("автоустановка поддерживается на Debian/Ubuntu с apt-get; установите conntrack вручную")
|
||||
}
|
||||
run := func(suffix string, args ...string) error {
|
||||
out := runPrivilegedTransient(10*time.Minute, suffix, apt, args...)
|
||||
if strings.HasPrefix(out, "ERROR: ") {
|
||||
return fmt.Errorf("%s", explainSystemUpdateError(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return installConntrackPackage(run, func() bool { _, err := exec.LookPath("conntrack"); return err == nil })
|
||||
}
|
||||
func installConntrackPackage(run func(string, ...string) error, present func() bool) error {
|
||||
// No upgrade, autoremove, firewall changes, or runtime sysctl changes.
|
||||
options := []string{"-o", "DPkg::Lock::Timeout=30", "-o", "Acquire::Retries=1", "-o", "Acquire::http::Timeout=20", "-o", "Acquire::https::Timeout=20"}
|
||||
if err := run("conntrack-lists", append(append([]string{}, options...), "update")...); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := run("conntrack-install", append(append([]string{}, options...), "-y", "--no-install-recommends", "--no-remove", "install", "conntrack")...); err != nil {
|
||||
return err
|
||||
}
|
||||
if !present() {
|
||||
return fmt.Errorf("apt-get завершился, но conntrack не найден в PATH")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
387
agent_traffic.go
Normal file
387
agent_traffic.go
Normal file
@@ -0,0 +1,387 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var npmTrafficPattern = regexp.MustCompile(`^\[([^]]+)\] .*? (\d{3}) - ([A-Z]+) (?:https?|HTTP) (\S+) "([^"]*)" \[Client ([^]]+)\]`)
|
||||
|
||||
func parseNPMTraffic(line string) (TrafficEvent, bool) {
|
||||
m := npmTrafficPattern.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return TrafficEvent{}, false
|
||||
}
|
||||
ip := net.ParseIP(m[6])
|
||||
if ip == nil {
|
||||
return TrafficEvent{}, false
|
||||
}
|
||||
ts, err := time.Parse("02/Jan/2006:15:04:05 -0700", m[1])
|
||||
if err != nil {
|
||||
return TrafficEvent{}, false
|
||||
}
|
||||
status, _ := strconv.Atoi(m[2])
|
||||
return TrafficEvent{Time: ts.Unix(), Kind: "request", IP: ip.String(), Domain: trafficText(m[4], 253), Method: m[3], Path: trafficPath(m[5]), Status: status}, true
|
||||
}
|
||||
|
||||
type trafficCursor struct {
|
||||
Offset int64 `json:"offset"`
|
||||
Generation int64 `json:"generation"`
|
||||
Seen int64 `json:"seen"`
|
||||
}
|
||||
type trafficAgentState struct {
|
||||
BacklogBytes int64 `json:"backlogBytes"`
|
||||
Initialized bool `json:"initialized"`
|
||||
StartedAt int64 `json:"startedAt"`
|
||||
Cursors map[string]trafficCursor `json:"cursors"`
|
||||
Pending []TrafficEvent `json:"pending"`
|
||||
ParseErrors uint64 `json:"parseErrors"`
|
||||
CrowdSeen map[string]int64 `json:"crowdSeen"`
|
||||
}
|
||||
|
||||
func (state *trafficAgentState) readLogs(dir string) (int, error) {
|
||||
state.BacklogBytes = 0
|
||||
paths, err := filepath.Glob(filepath.Join(dir, "*_access.log*"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
sort.Strings(paths)
|
||||
files := 0
|
||||
budget := int64(1 << 20)
|
||||
now := time.Now().Unix()
|
||||
var firstErr error
|
||||
for _, path := range paths {
|
||||
if strings.HasSuffix(path, ".gz") {
|
||||
continue
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
firstErr = err
|
||||
continue
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
active := strings.HasSuffix(path, "_access.log")
|
||||
if active {
|
||||
files++
|
||||
}
|
||||
key := fmt.Sprintf("%d:%d", st.Dev, st.Ino)
|
||||
cursor, known := state.Cursors[key]
|
||||
if !known {
|
||||
if !state.Initialized || !active {
|
||||
cursor.Offset = info.Size()
|
||||
}
|
||||
cursor.Generation = now
|
||||
}
|
||||
cursor.Seen = now
|
||||
if info.Size() < cursor.Offset {
|
||||
cursor.Offset = 0
|
||||
cursor.Generation++
|
||||
}
|
||||
if len(state.Pending) < 400 && budget > 0 {
|
||||
_, err = f.Seek(cursor.Offset, io.SeekStart)
|
||||
if err == nil {
|
||||
reader := bufio.NewReaderSize(io.LimitReader(f, budget), 64<<10)
|
||||
for len(state.Pending) < 400 && budget > 0 {
|
||||
line, readErr := reader.ReadString('\n')
|
||||
// Keep an unfinished line for the next poll. Bound malformed huge lines.
|
||||
if readErr != nil {
|
||||
if len(line) >= 64<<10 {
|
||||
cursor.Offset += int64(len(line))
|
||||
state.ParseErrors++
|
||||
}
|
||||
break
|
||||
}
|
||||
offset := cursor.Offset
|
||||
cursor.Offset += int64(len(line))
|
||||
budget -= int64(len(line))
|
||||
if len(line) > 64<<10 {
|
||||
state.ParseErrors++
|
||||
continue
|
||||
}
|
||||
e, ok := parseNPMTraffic(line)
|
||||
if !ok {
|
||||
state.ParseErrors++
|
||||
continue
|
||||
}
|
||||
if e.Time < now-86400 {
|
||||
continue
|
||||
}
|
||||
e.ID = fmt.Sprintf("npm:%s:%d:%d", key, cursor.Generation, offset)
|
||||
state.Pending = append(state.Pending, e)
|
||||
}
|
||||
} else {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if info.Size() > cursor.Offset {
|
||||
state.BacklogBytes += info.Size() - cursor.Offset
|
||||
}
|
||||
state.Cursors[key] = cursor
|
||||
f.Close()
|
||||
}
|
||||
for key, cursor := range state.Cursors {
|
||||
if cursor.Seen < now-7*86400 {
|
||||
delete(state.Cursors, key)
|
||||
}
|
||||
}
|
||||
if files > 0 {
|
||||
state.Initialized = true
|
||||
}
|
||||
if firstErr != nil {
|
||||
return files, errors.New("Не удалось прочитать часть access-логов NPM")
|
||||
}
|
||||
if files == 0 {
|
||||
return files, errors.New("Access-логи NPM не найдены")
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
type crowdTrafficAlert struct {
|
||||
ID int64 `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Scenario string `json:"scenario"`
|
||||
Simulated bool `json:"simulated"`
|
||||
Source struct {
|
||||
IP string `json:"ip"`
|
||||
} `json:"source"`
|
||||
Events []struct {
|
||||
Meta []struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
} `json:"meta"`
|
||||
} `json:"events"`
|
||||
Decisions []struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
Simulated bool `json:"simulated"`
|
||||
} `json:"decisions"`
|
||||
}
|
||||
|
||||
func parseCrowdTraffic(data []byte) ([]TrafficEvent, error) {
|
||||
var alerts []crowdTrafficAlert
|
||||
if err := json.Unmarshal(data, &alerts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []TrafficEvent{}
|
||||
for _, a := range alerts {
|
||||
if a.Simulated || net.ParseIP(a.Source.IP) == nil {
|
||||
continue
|
||||
}
|
||||
ts, err := time.Parse(time.RFC3339Nano, a.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
id := a.UUID
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("%d:%d", a.ID, ts.Unix())
|
||||
}
|
||||
// An alert may span multiple domains. Do not invent a one-to-one link.
|
||||
domains := map[string]bool{}
|
||||
for _, event := range a.Events {
|
||||
for _, m := range event.Meta {
|
||||
if m.Key == "target_fqdn" {
|
||||
domains[m.Value] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
domain := ""
|
||||
if len(domains) == 1 {
|
||||
for d := range domains {
|
||||
domain = d
|
||||
}
|
||||
}
|
||||
out = append(out, TrafficEvent{ID: "cs-alert:" + id, Time: ts.Unix(), Kind: "alert", IP: a.Source.IP, Domain: domain, Reason: a.Scenario})
|
||||
for _, d := range a.Decisions {
|
||||
if !d.Simulated && net.ParseIP(d.Value) != nil {
|
||||
out = append(out, TrafficEvent{ID: fmt.Sprintf("cs-decision:%s:%d", id, d.ID), Time: ts.Unix(), Kind: "decision", IP: d.Value, Domain: domain, Reason: d.Type + ": " + a.Scenario})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// cscli uses the existing local machine credentials. No new bouncer key or
|
||||
// public LAPI port is needed, and only the read-only alerts list is executed.
|
||||
func collectCrowdTraffic(ctx context.Context) ([]TrafficEvent, bool, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "cscli", "alerts", "list", "--since", "24h", "--limit", "50", "-o", "json")
|
||||
var output limitedTrafficOutput
|
||||
cmd.Stdout = &output
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, false, errors.New("cscli alerts list недоступен; проверьте права агента и локальную LAPI")
|
||||
}
|
||||
events, err := parseCrowdTraffic(output.Bytes())
|
||||
if err != nil {
|
||||
return nil, false, errors.New("Не удалось разобрать JSON CrowdSec")
|
||||
}
|
||||
var raw []json.RawMessage
|
||||
json.Unmarshal(output.Bytes(), &raw)
|
||||
return events, len(raw) >= 50, nil
|
||||
}
|
||||
|
||||
type limitedTrafficOutput struct{ bytes.Buffer }
|
||||
|
||||
func (b *limitedTrafficOutput) Write(p []byte) (int, error) {
|
||||
if b.Len()+len(p) > 4<<20 {
|
||||
return 0, errors.New("CrowdSec response too large")
|
||||
}
|
||||
return b.Buffer.Write(p)
|
||||
}
|
||||
|
||||
func saveTrafficAgentState(path string, state trafficAgentState) error {
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".traffic-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := f.Name()
|
||||
defer os.Remove(name)
|
||||
if err = f.Chmod(0600); err == nil {
|
||||
_, err = f.Write(data)
|
||||
}
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
closeErr := f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
return os.Rename(name, path)
|
||||
}
|
||||
func runAgentTraffic(ctx context.Context, config agentConfig, stateDir string) {
|
||||
dir := os.Getenv("DASHBOARD_NPM_LOG_DIR")
|
||||
if dir == "off" {
|
||||
return
|
||||
}
|
||||
if dir == "" {
|
||||
dir = "/opt/npm/data/logs"
|
||||
}
|
||||
|
||||
statePath := filepath.Join(stateDir, "traffic-state.json")
|
||||
state := trafficAgentState{StartedAt: time.Now().Unix(), Cursors: map[string]trafficCursor{}, CrowdSeen: map[string]int64{}}
|
||||
if data, err := os.ReadFile(statePath); err == nil {
|
||||
if json.Unmarshal(data, &state) != nil {
|
||||
fmt.Fprintln(os.Stderr, "traffic: invalid checkpoint; collector stopped")
|
||||
return
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
fmt.Fprintln(os.Stderr, "traffic: checkpoint unreadable")
|
||||
return
|
||||
}
|
||||
if state.Cursors == nil {
|
||||
state.Cursors = map[string]trafficCursor{}
|
||||
}
|
||||
if state.CrowdSeen == nil {
|
||||
state.CrowdSeen = map[string]int64{}
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
lastCrowd := time.Time{}
|
||||
crowdAt := int64(0)
|
||||
crowdError := ""
|
||||
crowdLimited := false
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
files, logErr := state.readLogs(dir)
|
||||
if len(state.Pending) < 400 && time.Since(lastCrowd) >= 15*time.Second {
|
||||
events, limited, err := collectCrowdTraffic(ctx)
|
||||
crowdLimited = limited
|
||||
lastCrowd = time.Now()
|
||||
if err != nil {
|
||||
crowdError = err.Error()
|
||||
} else {
|
||||
crowdError = ""
|
||||
crowdAt = time.Now().Unix()
|
||||
for _, e := range events {
|
||||
if _, seen := state.CrowdSeen[e.ID]; !seen && len(state.Pending) < 500 {
|
||||
state.Pending = append(state.Pending, e)
|
||||
state.CrowdSeen[e.ID] = e.Time
|
||||
}
|
||||
}
|
||||
for id, ts := range state.CrowdSeen {
|
||||
if ts < time.Now().Unix()-86400 {
|
||||
delete(state.CrowdSeen, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Persist the pending batch AND offsets before sending. A retry/restart may
|
||||
// resend events; the server's (agent,event_id) unique key makes it idempotent.
|
||||
if err := saveTrafficAgentState(statePath, state); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "traffic: checkpoint write failed; collector stopped")
|
||||
return
|
||||
}
|
||||
batch := TrafficBatch{BacklogBytes: state.BacklogBytes, CrowdSecLimited: crowdLimited, Events: state.Pending, Files: files, ParseErrors: state.ParseErrors, CrowdSecAt: crowdAt, CrowdSecError: crowdError, StartedAt: state.StartedAt}
|
||||
if logErr != nil {
|
||||
batch.LogError = logErr.Error()
|
||||
}
|
||||
data, _ := json.Marshal(batch)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", config.Server+"/api/agent/traffic", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+config.ID+"."+config.Secret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if res, err := client.Do(req); err == nil {
|
||||
io.Copy(io.Discard, io.LimitReader(res.Body, 4096))
|
||||
res.Body.Close()
|
||||
if res.StatusCode == 200 {
|
||||
state.Pending = nil
|
||||
if saveTrafficAgentState(statePath, state) != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startAgentTraffic(config agentConfig, stateDir string) func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); runAgentTraffic(ctx, config, stateDir) }()
|
||||
return func() { cancel(); <-done }
|
||||
}
|
||||
16
alerts.go
16
alerts.go
@@ -263,10 +263,13 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
|
||||
}
|
||||
var networkErrors uint64
|
||||
for _, iface := range metrics.Network.Interfaces {
|
||||
networkErrors += iface.NewErrors + iface.NewDrops
|
||||
networkErrors += iface.NewErrors + iface.NewFaults
|
||||
if iface.LinkDown {
|
||||
add("network-link-"+iface.Name, "critical", "Сеть", iface.Name+": нет линка", "Используемый интерфейс потерял carrier или перешёл в down.")
|
||||
}
|
||||
}
|
||||
if networkErrors > 0 {
|
||||
add("network-errors", "warning", "Сеть", "Появились новые сетевые ошибки или потери", fmt.Sprintf("За последний интервал добавилось: %d. Старые счётчики не создают активный алерт.", networkErrors))
|
||||
add("network-errors", "critical", "Сеть", "Появились новые сетевые ошибки", fmt.Sprintf("Прирост счётчиков ошибок: %d (счётчики могут пересекаться). Software RX/TX drops показаны отдельно и не создают этот алерт.", networkErrors))
|
||||
}
|
||||
if !metrics.Network.Health.CheckedAt.IsZero() && !metrics.Network.Health.GatewayUp {
|
||||
add("network-gateway", "critical", "Сеть", "Недоступен локальный шлюз", firstNonEmpty(metrics.Network.Health.Gateway, "Default gateway не определён."))
|
||||
@@ -286,14 +289,7 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
|
||||
for _, name := range metrics.Network.Health.DisappearedInterfaces {
|
||||
add("network-interface-missing-"+name, "warning", "Сеть", "Исчез сетевой интерфейс", name+" больше не присутствует в /proc/net/dev.")
|
||||
}
|
||||
for _, iface := range metrics.Network.Interfaces {
|
||||
if iface.Kind == "Physical" && !iface.Carrier && (iface.Master != "" || iface.ReceiveBytes+iface.TransmitBytes > 0) {
|
||||
add("network-link-"+iface.Name, "warning", "Сеть", iface.Name+": нет carrier", "Физический линк не поднят.")
|
||||
}
|
||||
if (iface.Kind == "Bridge" || iface.Kind == "Bond" || iface.Kind == "VLAN") && iface.State == "down" {
|
||||
add("network-state-"+iface.Name, "warning", "Сеть", iface.Name+": интерфейс выключен", iface.Kind+" находится в состоянии down.")
|
||||
}
|
||||
}
|
||||
|
||||
if !metrics.Monitor.LastSuccessfulCollection.IsZero() && time.Since(metrics.Monitor.LastSuccessfulCollection) > 30*time.Second {
|
||||
add("monitor-stale", "critical", "Dashboard", "Данные мониторинга устарели", "Последний успешный сбор был "+time.Since(metrics.Monitor.LastSuccessfulCollection).Round(time.Second).String()+" назад.")
|
||||
}
|
||||
|
||||
14
assets/NOTICE.md
Normal file
14
assets/NOTICE.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Map data
|
||||
|
||||
`world.json` is derived from Natural Earth 1:110m admin-0 countries (coordinates
|
||||
rounded to two decimal places; only country codes, labels and boundaries kept).
|
||||
Natural Earth data is public domain: https://www.naturalearthdata.com/about/terms-of-use/
|
||||
Source: https://github.com/nvkelso/natural-earth-vector/blob/master/geojson/ne_110m_admin_0_countries.geojson
|
||||
|
||||
`dbip-country-lite-2026-10.mmdb.gz`: IP Geolocation by DB-IP, October 2026.
|
||||
Source: https://download.db-ip.com/free/dbip-country-lite-2026-10.mmdb.gz
|
||||
License: Creative Commons Attribution 4.0 International (CC BY 4.0).
|
||||
https://creativecommons.org/licenses/by/4.0/
|
||||
https://db-ip.com/db/download/ip-to-country-lite
|
||||
The database is unmodified. The traffic page displays the required DB-IP link.
|
||||
Countries are approximate; plotted positions are country labels, not exact IP locations.
|
||||
BIN
assets/dbip-country-lite-2026-10.mmdb.gz
Normal file
BIN
assets/dbip-country-lite-2026-10.mmdb.gz
Normal file
Binary file not shown.
40
assets/network.html
Normal file
40
assets/network.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Сетевая активность · Proxmox Dashboard</title><style>
|
||||
:root{color-scheme:dark;--bg:#090e15;--panel:#111b26;--line:#293846;--muted:#92a8ba;--lime:#c0ee86;--blue:#70dcec}*{box-sizing:border-box}html,body{margin:0;height:100%;overflow:hidden}body{background:radial-gradient(ellipse at 30% 30%,#142738,transparent 65%),var(--bg);color:#e9f0f6;font:13px system-ui,sans-serif;padding:22px;display:grid;grid-template-rows:54px 32px 82px 36px minmax(0,1fr) 22px;gap:12px}header{display:flex;align-items:center;justify-content:space-between;gap:12px}h1{font-size:26px;letter-spacing:-1px;margin:0}small,.muted{color:var(--muted)}.eyebrow{font-size:9px;letter-spacing:2px;color:var(--lime)}nav{display:flex;gap:15px;align-items:center}a{color:var(--blue);text-decoration:none}button,input,select{font:inherit;color:inherit;background:#182430;border:1px solid var(--line);padding:7px 10px;border-radius:8px;min-width:0}button{cursor:pointer}button:disabled{opacity:.35;cursor:default}button:hover{border-color:var(--blue)}:focus-visible{outline:2px solid var(--blue);outline-offset:2px}.status{display:flex;align-items:center;gap:10px;color:var(--muted);min-width:0}.status span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.badge{font-size:10px;color:var(--lime);border:1px solid #496036;padding:5px 8px;border-radius:20px;white-space:nowrap}.badge.warn{color:#ffbd80;border-color:#875d36}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.metric,.panel{border:1px solid var(--line);background:#101a24d9;border-radius:14px;min-width:0;min-height:0;overflow:hidden}.metric{padding:12px 16px}.metric small{font-size:11px}.metric b{display:block;font-size:28px;line-height:1.3;letter-spacing:-1px}.filters{display:flex;gap:8px}.filters input{flex:1}.filters label{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)}.filters label input{accent-color:var(--lime);flex:none;width:13px;height:13px;margin:0;grid-column:auto;grid-row:auto}main{display:grid;grid-template-columns:minmax(0,1fr) 290px;gap:12px;min-height:0}.flows{display:grid;grid-template-rows:40px 27px minmax(0,1fr) 38px;padding:0 14px}.heading{display:flex;align-items:center;justify-content:space-between;gap:8px}h2{font-size:12px;font-weight:600;margin:0}.table-head,.flow{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1.25fr) 80px 108px 95px;gap:12px;align-items:center}.table-head{color:var(--muted);font-size:10px}.flow{height:55px;border-top:1px solid #21303d;cursor:pointer;font:12px ui-monospace,monospace}.flow:hover{background:#20344566}.flow>div{min-width:0}.flow strong{font-weight:500;color:var(--blue);display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.flow small{font:10px system-ui;display:block;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.state{font-size:10px}.state.ok{color:var(--lime)}.state.pending{color:#ffc085}#rows{min-height:0;overflow:hidden}.pager{display:flex;align-items:center;gap:12px;color:var(--muted);font-size:11px;border-top:1px solid var(--line)}.pager button{padding:3px 8px}.pager #resume{margin-left:auto;color:var(--lime)}aside{display:grid;grid-template-rows:1fr 1fr 1fr;gap:12px;min-height:0}aside .panel{padding:12px 14px}aside h2{margin-bottom:9px}.rank{display:flex;justify-content:space-between;gap:10px;font-size:11px;line-height:22px}.rank span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rank b{font-weight:500;color:var(--blue)}.block-value{font-size:24px;color:#c4a9ff;margin:8px 0}.help{font-size:10px;color:var(--muted);line-height:1.5}.empty{color:var(--muted);padding:20px 0;line-height:1.7;font-size:12px}.footer{display:flex;justify-content:space-between;font-size:10px;color:var(--muted);gap:10px}.link-button{background:none;border:0;padding:0;color:var(--blue);font-size:11px}dialog{width:min(650px,calc(100vw - 24px));max-height:85dvh;overflow:auto;border:1px solid #455969;border-radius:18px;background:#141f2b;color:#e9f0f6;padding:22px}dialog::backdrop{background:#03080ccc;backdrop-filter:blur(5px)}dialog .heading{margin-bottom:16px}dialog h2{font-size:19px}.detail{padding:10px 0;border-top:1px solid var(--line);overflow-wrap:anywhere}.detail small{display:block;margin-bottom:4px}dialog p{line-height:1.7}#tabs{display:none}.filters #port{width:110px}
|
||||
@media(max-width:1100px){main{grid-template-columns:minmax(0,1fr) 220px}.table-head,.flow{grid-template-columns:1fr 1fr 55px 83px;gap:8px}.flow .volume,.table-head .volume{display:none}.flow{font-size:11px}nav{gap:9px}body{padding:14px}aside .panel{padding:9px}aside .rank{line-height:19px}}
|
||||
@media(max-height:700px) and (min-width:761px){body{padding:12px;gap:8px;grid-template-rows:40px 26px 64px 32px minmax(0,1fr) 18px}.metric{padding:8px 12px}.metric b{font-size:22px}.flow{height:46px}aside{grid-template-rows:1fr 1fr 1fr;gap:6px}aside .rank{line-height:17px}.block-value{font-size:20px;margin:3px 0}aside h2{margin-bottom:4px}.help{font-size:9px}}
|
||||
@media(max-width:760px){body{padding:10px;gap:8px;grid-template-rows:45px 30px 104px auto 28px minmax(0,1fr) 18px}h1{font-size:18px;white-space:nowrap}.eyebrow{font-size:7px}nav a{font-size:0}nav a:before{content:"HTTP ↗";font-size:11px}nav .dashboard{display:none}.metrics{grid-template-columns:1fr 1fr;gap:6px}.metric{padding:6px 10px}.metric small{font-size:9px}.metric b{font-size:22px}.filters{display:grid;grid-template-columns:1fr 100px;gap:5px}.filters label{grid-row:3;white-space:nowrap;font-size:10px}#netMonitoringControl{grid-column:1}#netExternalControl{grid-column:2}.filters #search{grid-column:1/-1;grid-row:2}.filters select{font-size:11px;padding:6px}.filters #port{width:100%}#tabs{display:flex;gap:6px}#tabs button{flex:1;padding:4px;font-size:11px}#tabs button.active{border-color:var(--blue)}main{display:block}.flows{height:100%;padding:0 10px;grid-template-rows:34px 24px minmax(0,1fr) 36px}aside{display:none;height:100%;grid-template-columns:1fr 1fr;grid-template-rows:minmax(0,1fr) 105px}aside .firewall{grid-column:1/-1}body[data-tab=stats] .flows{display:none}body[data-tab=stats] aside{display:grid}.flow,.table-head{grid-template-columns:1fr 1fr 60px}.flow .protocol,.table-head .protocol{display:none}.flow{height:59px;font-size:10px}.state{font-size:9px}.flow small{font-size:9px}.status{font-size:10px}.status button{font-size:10px}.footer{font-size:8px}.footer .link-button{font-size:9px}aside .rank{line-height:19px}.block-value{display:inline-block;font-size:20px;margin:0 10px 0 0}.help{font-size:9px}.firewall h2{margin-bottom:4px}}
|
||||
</style></head><body data-tab="flows">
|
||||
<header><div><div class="eyebrow">PROXMOX / NETWORK OBSERVATORY</div><h1>Сетевая активность<span style="color:var(--lime)">.</span></h1></div><nav><a href="/traffic">HTTP / CrowdSec ↗</a><a class="dashboard" href="/">Dashboard</a><button id="pause">Пауза</button></nav></header>
|
||||
<div class="status"><b id="live" class="badge warn">ОЖИДАНИЕ</b><span id="source">Подключение к агенту…</span><button class="link-button" id="statusDetails">Диагностика ⓘ</button></div>
|
||||
<section class="metrics"><div class="metric"><small>TCP · ESTABLISHED</small><b id="established">—</b></div><div class="metric"><small>TCP · SYN_SENT / SYN_RECV</small><b id="pending">—</b></div><div class="metric"><small>UDP · отслеживаемые потоки</small><b id="udp">—</b></div><div class="metric"><small>ICMP / ICMPv6 · записи</small><b id="icmp">—</b></div></section>
|
||||
<div class="filters"><select id="protocol" aria-label="Протокол"><option value="">Все протоколы</option><option value="tcp">TCP</option><option value="udp">UDP</option><option value="icmp">ICMP / ICMPv6</option></select><select id="port" aria-label="Порт назначения"><option value="">Все порты</option></select><input id="search" placeholder="Найти IP или состояние…" aria-label="Поиск IP или состояния"><label id="netMonitoringControl"><input id="hideMonitoring" type="checkbox">Скрыть мониторинг</label><label id="netExternalControl"><input id="external" type="checkbox">Внешние IP</label></div>
|
||||
<nav id="tabs"><button class="active" data-tab="flows">Потоки</button><button data-tab="stats">Статистика</button></nav>
|
||||
<main><section class="panel flows"><div class="heading"><h2>Текущая таблица conntrack</h2><small id="count">—</small></div><div class="table-head"><span>Инициатор</span><span>Назначение / NAT</span><span class="protocol">Протокол</span><span>Состояние</span><span class="volume">Байты → / ←</span></div><div id="rows"><div class="empty">Ожидание первого снимка…</div></div><div class="pager"><button id="prev" aria-label="Предыдущие потоки">←</button><span id="page">—</span><button id="next" aria-label="Следующие потоки">→</button><button id="resume">К свежим ↗</button></div></section>
|
||||
<aside><section class="panel"><h2>Топ инициаторов · потоки</h2><div id="topIPs"></div></section><section class="panel"><h2>Топ портов назначения</h2><div id="topPorts"></div></section><section class="panel firewall"><div class="heading"><h2>CrowdSec · firewall</h2><button class="link-button" id="firewallDetails">ⓘ</button></div><div id="blocked" class="block-value">—</div><div class="help" id="firewallNote">Ожидание счётчиков</div></section></aside></main>
|
||||
<footer class="footer"><span>Снимки ~5 с · короткие потоки могут быть пропущены</span><button class="link-button" id="about">Как читать данные ⓘ</button><a href="/#traffic-settings">Настройки ↗</a></footer>
|
||||
<dialog id="dialog"><div class="heading"><h2 id="title"></h2><button id="close" aria-label="Закрыть">×</button></div><div id="details"></div></dialog>
|
||||
<script>
|
||||
const el=id=>document.getElementById(id),safe=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])),num=v=>Number(v||0).toLocaleString('ru-RU'),bytes=n=>{n=Number(n||0);return n>=1073741824?(n/1073741824).toFixed(1)+' GiB':n>=1048576?(n/1048576).toFixed(1)+' MiB':n>=1024?(n/1024).toFixed(1)+' KiB':n+' B'},endpoint=(ip,p)=>p?(ip.includes(':')?'['+ip+']':ip)+':'+p:ip;
|
||||
const names=new Intl.DisplayNames(['ru'],{type:'region'}),country=c=>c==='LAN'?'Локальная сеть':c&&/^[A-Z]{2}$/.test(c)?names.of(c):'Без геолокации';
|
||||
try{el('hideMonitoring').checked=localStorage.getItem('proxmox-hide-monitoring')==='1'}catch{}
|
||||
let snapshot=null,frozen=null,page=0,visible=[],paused=false,busy=false,networkError='';
|
||||
const show=(title,html)=>{el('title').textContent=title;el('details').innerHTML=html;el('dialog').showModal()},fields=rows=>rows.map(([k,v])=>'<div class="detail"><small>'+safe(k)+'</small>'+safe(v)+'</div>').join('');
|
||||
function filterFlows(){const p=el('protocol').value,port=el('port').value,q=el('search').value.trim().toLowerCase();return ((frozen||snapshot)?.flows||[]).filter(f=>(!p||(p==='icmp'?f.protocol.startsWith('icmp'):f.protocol===p))&&(!port||String(f.destinationPort)===port)&&(!el('external').checked||f.external)&&(!q||[f.source,f.destination,f.replySource,f.replyDestination,f.state].join(' ').toLowerCase().includes(q)))}
|
||||
function rank(id,items){const counts=new Map();items.forEach(v=>counts.set(v,(counts.get(v)||0)+1));const h=el(id).parentElement.clientHeight;const limit=Math.max(1,Math.min(5,Math.floor((h-44)/22)));el(id).innerHTML=[...counts].sort((a,b)=>b[1]-a[1]||a[0].localeCompare(b[0])).slice(0,limit).map(([k,v])=>'<div class="rank"><span title="'+safe(k)+'">'+safe(k)+'</span><b>'+num(v)+'</b></div>').join('')||'<div class="help">Нет потоков</div>'}
|
||||
function render(){if(!snapshot)return;const data=frozen||snapshot,items=filterFlows();el('established').textContent=num(items.filter(f=>f.protocol==='tcp'&&f.state==='ESTABLISHED').length);el('pending').textContent=num(items.filter(f=>f.protocol==='tcp'&&['SYN_SENT','SYN_RECV'].includes(f.state)).length);el('udp').textContent=num(items.filter(f=>f.protocol==='udp').length);el('icmp').textContent=num(items.filter(f=>f.protocol.startsWith('icmp')).length);
|
||||
if(!data.receivedAt||(data.error&&!(data.flows||[]).length)){['established','pending','udp','icmp'].forEach(id=>el(id).textContent='—')}
|
||||
const fresh=snapshot.receivedAt&&Date.now()/1000-snapshot.receivedAt<25;el('live').textContent=networkError?'НЕТ СВЯЗИ':paused?'ПАУЗА':frozen?'СНИМОК':!fresh?'НЕТ ДАННЫХ':snapshot.error||snapshot.limited||snapshot.parseErrors?'НЕПОЛНЫЕ ДАННЫЕ':'● LIVE';el('live').classList.toggle('warn',!!networkError||!fresh||paused||!!snapshot.error||snapshot.limited||!!snapshot.parseErrors);el('source').textContent=(snapshot.agentName||'Агент')+' · '+(snapshot.at?new Date(data.at*1000).toLocaleTimeString('ru-RU'):'ожидание')+(snapshot.error?' · '+snapshot.error:snapshot.limited?' · лимит 5 000 потоков':'');
|
||||
const rowHeight=innerWidth<=760?59:innerHeight<=700?46:55,count=Math.max(1,Math.floor(el('rows').clientHeight/rowHeight)),pages=Math.max(1,Math.ceil(items.length/count));page=Math.min(page,pages-1);visible=items.slice(page*count,(page+1)*count);el('count').textContent=num(items.length)+' / '+num(data.flows?.length)+(data.monitoringHidden?' · скрыто '+num(data.monitoringHidden):'')+' · '+(frozen?'снимок':'live');
|
||||
el('rows').innerHTML=visible.map((f,i)=>{const nat=f.destination!==f.replySource||f.destinationPort!==f.replySourcePort;return '<article class="flow" role="button" tabindex="0" data-flow="'+i+'" aria-label="Поток '+safe(endpoint(f.source,f.sourcePort))+'"><div><strong>'+safe(endpoint(f.source,f.sourcePort))+'</strong><small>'+safe(country(f.country))+(f.monitoring?' · мониторинг':'')+'</small></div><div><strong>'+safe(endpoint(f.destination,f.destinationPort))+'</strong><small>'+safe(nat?'NAT → '+endpoint(f.replySource,f.replySourcePort):country(f.destinationCountry))+'</small></div><div class="protocol">'+safe(f.protocol.toUpperCase())+'</div><div class="state '+(f.state==='ESTABLISHED'?'ok':f.state.includes('SYN')?'pending':'')+'">'+safe(f.state)+'<small>'+safe(f.protocol.toUpperCase())+'</small></div><div class="volume">'+(f.accounting?bytes(f.bytes)+'<small>'+bytes(f.replyBytes)+'</small>':'<small>нет учёта</small>')+'</div></article>'}).join('')||'<div class="empty">'+safe(snapshot.error||'Нет потоков по выбранным фильтрам')+'</div>';
|
||||
el('page').textContent=(page+1)+' / '+pages;el('prev').disabled=page===0;el('next').disabled=page>=pages-1;rank('topIPs',items.map(f=>f.source));rank('topPorts',items.filter(f=>f.destinationPort).map(f=>f.protocol.toUpperCase()+' / '+f.destinationPort));
|
||||
const counters=snapshot.firewall||[];el('blocked').textContent=counters.length?num(counters.reduce((a,c)=>a+c.packets,0))+' пак.':'—';el('firewallNote').textContent=counters.length?'С начала жизни счётчиков · '+bytes(counters.reduce((a,c)=>a+c.bytes,0))+' · '+new Date(snapshot.firewallAt*1000).toLocaleTimeString('ru-RU'):snapshot.firewallError||'Счётчики ещё не получены';
|
||||
}
|
||||
function openFlow(target){const row=target.closest('[data-flow]');if(!row)return;const f=visible[+row.dataset.flow];if(!f)return;show('Поток '+f.protocol.toUpperCase(),fields([['Инициатор',endpoint(f.source,f.sourcePort)+' · '+country(f.country)],['Исходное назначение',endpoint(f.destination,f.destinationPort)],['Обратное направление (NAT)',endpoint(f.replySource,f.replySourcePort)+' → '+endpoint(f.replyDestination,f.replyDestinationPort)],['Мониторинг',f.monitoring?'Да — правило по IP инициатора':'Нет'],['Состояние / зона',f.state+' / '+(f.zone||'0')],['Инициатор → назначение',f.accounting?bytes(f.bytes)+' · '+num(f.packets)+' пакетов':'Учёт пакетов/байтов не включён для этого потока'],['Ответное направление',f.accounting?bytes(f.replyBytes)+' · '+num(f.replyPackets)+' пакетов':'Нет данных'],['Время снимка',new Date((frozen||snapshot).at*1000).toLocaleString('ru-RU')]]))}
|
||||
el('rows').onclick=e=>openFlow(e.target);el('rows').onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();openFlow(e.target)}};el('close').onclick=()=>el('dialog').close();
|
||||
el('statusDetails').onclick=()=>show('Диагностика агента',fields([['Сбор',snapshot?.error||'Ошибок conntrack не сообщено'],['Связь',networkError||'Последний ответ получен'],['Получен сервером',snapshot?.receivedAt?new Date(snapshot.receivedAt*1000).toLocaleString('ru-RU'):'Ещё не получен'],['Ограничения',snapshot?.limited?'Достигнут лимит 5 000 записей; статистика частичная':'До 5 000 записей в снимке'],['Нераспознанные строки',num(snapshot?.parseErrors)],['Firewall',snapshot?.firewallError||'Поддерживаемые счётчики получены']])+ '<p>На Debian/Ubuntu агент автоматически устанавливает отсутствующий <code>conntrack</code> через apt-get и systemd. Службе нужны root; для чтения таблицы — root/CAP_NET_ADMIN. Ошибка установки повторяется не чаще раза в 30 минут. Для firewall нужен <code>nft</code>. При выключенном учёте conntrack байты недоступны; sysctl и правила автоматически не изменяются.</p>');
|
||||
el('firewallDetails').onclick=()=>show('Счётчики блокировок',fields((snapshot?.firewall||[]).map(c=>[c.name,num(c.packets)+' пакетов · '+bytes(c.bytes)]))+'<p>'+safe(snapshot?.firewallError||'Накопительные счётчики правил CrowdSec непосредственно перед drop/reject. Сбрасываются при пересоздании правил. Не зависят от фильтров списка потоков; не показывают количество людей или HTTP-запросов.')+'</p>');
|
||||
el('about').onclick=()=>show('Как читать сетевые данные','<p>Это текущие отслеживаемые потоки TCP, UDP, ICMP и ICMPv6, а не журнал всех пакетов. Снимки делаются примерно раз в 5 секунд: короткие потоки могут быть пропущены. TIME_WAIT и другие состояния закрытия остаются в таблице до истечения таймера.</p><p>Инициатор и назначение — исходное направление conntrack. Обратная пара адресов показывает трансляцию NAT, включая Docker. Соединение NPM с upstream — отдельный поток; оно не считается ещё одним посетителем.</p><p>UDP ASSURED — отметка conntrack, не TCP-соединение. Порт 443 сам по себе не доказывает HTTPS или QUIC. SYN_SENT/SYN_RECV не означают атаку.</p><p>Байты и пакеты накоплены за жизнь конкретного потока, если учёт был включён при его создании. Длительность соединения и точный поток новых подключений по этим снимкам не вычисляются.</p><p>Фильтры влияют на карточки и топы. Firewall показывает отдельные накопительные счётчики без фильтров. Приватные и локальные IP не геолокируются. «Скрыть мониторинг» применяет правила по IP инициатора; домен/путь относятся только к HTTP. Геолокация: <a href="https://db-ip.com">DB-IP</a>, CC BY 4.0.</p>');
|
||||
function reset(){frozen=null;page=0;render()};['protocol','port','external'].forEach(id=>el(id).onchange=reset);el('search').oninput=reset;el('hideMonitoring').onchange=()=>{try{localStorage.setItem('proxmox-hide-monitoring',el('hideMonitoring').checked?'1':'0')}catch{}frozen=null;page=0;poll(true)};el('next').onclick=()=>{if(!frozen)frozen=snapshot;page++;render()};el('prev').onclick=()=>{page--;render()};el('resume').onclick=reset;el('pause').onclick=()=>{paused=!paused;el('pause').textContent=paused?'Продолжить':'Пауза';render();if(!paused)poll()};document.querySelectorAll('[data-tab]').forEach(b=>{if(b.tagName==='BUTTON')b.onclick=()=>{document.body.dataset.tab=b.dataset.tab;document.querySelectorAll('#tabs button').forEach(t=>t.classList.toggle('active',t===b));render()}});window.addEventListener('resize',render);
|
||||
async function poll(force=false){if((paused&&!force)||busy||document.hidden)return;busy=true;try{const response=await fetch('/api/network?hideMonitoring='+(el('hideMonitoring').checked?'1':'0'));if(response.status===404){location.replace('/traffic');return}if(!response.ok)throw Error('HTTP '+response.status);snapshot=await response.json();networkError='';const current=el('port').value,ports=[...new Set((snapshot.flows||[]).map(f=>f.destinationPort).filter(Boolean))];if(current&&!ports.includes(+current))ports.push(+current);ports.sort((a,b)=>a-b);el('port').innerHTML='<option value="">Все порты</option>'+ports.map(p=>'<option value="'+p+'">'+p+'</option>').join('');el('port').value=current;render()}catch(e){networkError='Не удалось обновить: '+e.message;el('source').textContent=networkError;el('live').textContent='НЕТ СВЯЗИ';el('live').classList.add('warn')}finally{busy=false}}
|
||||
poll();setInterval(poll,5000);document.addEventListener('visibilitychange',()=>{if(!document.hidden)poll()});
|
||||
</script></body></html>
|
||||
75
assets/traffic.html
Normal file
75
assets/traffic.html
Normal file
File diff suppressed because one or more lines are too long
1
assets/world.json
Normal file
1
assets/world.json
Normal file
File diff suppressed because one or more lines are too long
39
cpu.go
39
cpu.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -33,15 +34,16 @@ type CPUStatic struct {
|
||||
|
||||
type CPUMetrics struct {
|
||||
CPUStatic
|
||||
UsagePercent float64 `json:"usagePercent"`
|
||||
FrequencyMHz float64 `json:"frequencyMhz"`
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
Uptime uint64 `json:"uptimeSeconds"`
|
||||
Temperatures []Temperature `json:"temperatures"`
|
||||
TopProcesses []AgentProcess `json:"topProcesses"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
UsagePercent float64 `json:"usagePercent"`
|
||||
FrequencyMHz float64 `json:"frequencyMhz"`
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
Uptime uint64 `json:"uptimeSeconds"`
|
||||
TemperatureCelsius *float64 `json:"temperatureCelsius"`
|
||||
Temperatures []Temperature `json:"temperatures"`
|
||||
TopProcesses []AgentProcess `json:"topProcesses"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
}
|
||||
|
||||
type Temperature struct {
|
||||
@@ -90,10 +92,11 @@ func (c *cpuCollector) collect() (CPUMetrics, error) {
|
||||
load1, load5, load15, _ := readLoadAverage()
|
||||
uptime, _ := readUptime()
|
||||
|
||||
temperatures := readTemperatures()
|
||||
return CPUMetrics{
|
||||
CPUStatic: c.static, UsagePercent: usage, FrequencyMHz: frequency,
|
||||
Load1: load1, Load5: load5, Load15: load15, Uptime: uptime,
|
||||
Temperatures: readTemperatures(), TopProcesses: collectAgentProcesses(), CollectedAt: time.Now(),
|
||||
Temperatures: temperatures, TemperatureCelsius: cpuTemperature(temperatures), TopProcesses: collectAgentProcesses(), CollectedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -285,3 +288,19 @@ func isCPUTemperatureChip(name string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// cpuTemperature is the hottest CPU sensor, shared by live and historical views.
|
||||
// Do not average Tctl/Tdie with cooler cores or CCDs.
|
||||
func cpuTemperature(temperatures []Temperature) *float64 {
|
||||
var hottest *float64
|
||||
for _, t := range temperatures {
|
||||
if math.IsNaN(t.Celsius) || math.IsInf(t.Celsius, 0) || t.Celsius <= 0 {
|
||||
continue
|
||||
}
|
||||
if hottest == nil || t.Celsius > *hottest {
|
||||
value := t.Celsius
|
||||
hottest = &value
|
||||
}
|
||||
}
|
||||
return hottest
|
||||
}
|
||||
|
||||
24
cpu_temperature_test.go
Normal file
24
cpu_temperature_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCPUTemperatureSharedWithHistory(t *testing.T) {
|
||||
temperatures := []Temperature{{Label: "k10temp · Tctl", Celsius: 62}, {Label: "k10temp · Tccd1", Celsius: 38}, {Label: "k10temp · Tccd2", Celsius: 41}}
|
||||
live := cpuTemperature(temperatures)
|
||||
point := historyPointFromMetrics(dashboardMetrics{CPU: CPUMetrics{Temperatures: temperatures}})
|
||||
if live == nil || *live != 62 || point.CPUTemperature != *live {
|
||||
t.Fatalf("live/history disagree: %v, %+v", live, point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUTemperatureInvalidSensors(t *testing.T) {
|
||||
if cpuTemperature([]Temperature{{Celsius: math.NaN()}, {Celsius: math.Inf(1)}, {Celsius: 0}}) != nil {
|
||||
t.Fatal("invalid temperature selected")
|
||||
}
|
||||
if cpuTemperature(nil) != nil {
|
||||
t.Fatal("missing sensor should stay missing")
|
||||
}
|
||||
}
|
||||
5
go.mod
5
go.mod
@@ -2,7 +2,10 @@ module proxmox-host-info
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require modernc.org/sqlite v1.54.0
|
||||
require (
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
modernc.org/sqlite v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
|
||||
10
go.sum
10
go.sum
@@ -1,3 +1,5 @@
|
||||
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
@@ -10,8 +12,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
@@ -21,6 +29,8 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
|
||||
@@ -99,12 +99,10 @@ func historyPointFromMetrics(metrics dashboardMetrics) HistoryPoint {
|
||||
NetworkTransmit: metrics.Network.TransmitPerSec, UPSCharge: metrics.UPS.ChargePercent,
|
||||
UPSLoad: metrics.UPS.LoadPercent,
|
||||
}
|
||||
if len(metrics.CPU.Temperatures) > 0 {
|
||||
for _, temperature := range metrics.CPU.Temperatures {
|
||||
point.CPUTemperature += temperature.Celsius
|
||||
}
|
||||
point.CPUTemperature /= float64(len(metrics.CPU.Temperatures))
|
||||
if temperature := cpuTemperature(metrics.CPU.Temperatures); temperature != nil {
|
||||
point.CPUTemperature = *temperature
|
||||
}
|
||||
|
||||
var zfsSize, zfsUsed uint64
|
||||
for _, pool := range metrics.ZFS.Pools {
|
||||
zfsSize += pool.SizeBytes
|
||||
|
||||
@@ -8,7 +8,7 @@ func TestHistoryPointFromMetrics(t *testing.T) {
|
||||
Guests: GuestsMetrics{Guests: []GuestMetrics{{Status: "running", CPU: .5, MaxCPU: 4, MemoryBytes: 50, MaxMemory: 100}}},
|
||||
}
|
||||
point := historyPointFromMetrics(metrics)
|
||||
if point.CPUTemperature != 50 || point.GuestsCPU != 50 || point.GuestsMemory != 50 {
|
||||
if point.CPUTemperature != 60 || point.GuestsCPU != 50 || point.GuestsMemory != 50 {
|
||||
t.Fatalf("неожиданная историческая точка: %+v", point)
|
||||
}
|
||||
}
|
||||
|
||||
79
network.go
79
network.go
@@ -43,26 +43,31 @@ type NetworkHealth struct {
|
||||
}
|
||||
|
||||
type NetworkInterface struct {
|
||||
Name string `json:"name"`
|
||||
ReceiveBytes uint64 `json:"receiveBytes"`
|
||||
TransmitBytes uint64 `json:"transmitBytes"`
|
||||
ReceiveErrors uint64 `json:"receiveErrors"`
|
||||
TransmitErrors uint64 `json:"transmitErrors"`
|
||||
ReceiveDrops uint64 `json:"receiveDrops"`
|
||||
TransmitDrops uint64 `json:"transmitDrops"`
|
||||
NewErrors uint64 `json:"newErrors"`
|
||||
NewDrops uint64 `json:"newDrops"`
|
||||
ReceivePerSec float64 `json:"receivePerSec"`
|
||||
TransmitPerSec float64 `json:"transmitPerSec"`
|
||||
VMID int `json:"vmid,omitempty"`
|
||||
GuestName string `json:"guestName,omitempty"`
|
||||
GuestType string `json:"guestType,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
State string `json:"state"`
|
||||
Carrier bool `json:"carrier"`
|
||||
SpeedMbps int `json:"speedMbps,omitempty"`
|
||||
Duplex string `json:"duplex,omitempty"`
|
||||
Master string `json:"master,omitempty"`
|
||||
Name string `json:"name"`
|
||||
ReceiveBytes uint64 `json:"receiveBytes"`
|
||||
TransmitBytes uint64 `json:"transmitBytes"`
|
||||
ReceiveErrors uint64 `json:"receiveErrors"`
|
||||
TransmitErrors uint64 `json:"transmitErrors"`
|
||||
ReceiveDrops uint64 `json:"receiveDrops"`
|
||||
TransmitDrops uint64 `json:"transmitDrops"`
|
||||
NewErrors uint64 `json:"newErrors"`
|
||||
NewReceiveDrops uint64 `json:"newReceiveDrops"`
|
||||
NewTransmitDrops uint64 `json:"newTransmitDrops"`
|
||||
FaultCounters map[string]uint64 `json:"faultCounters,omitempty"`
|
||||
NewFaults uint64 `json:"newFaults"`
|
||||
LinkDown bool `json:"linkDown"`
|
||||
NewDrops uint64 `json:"newDrops"`
|
||||
ReceivePerSec float64 `json:"receivePerSec"`
|
||||
TransmitPerSec float64 `json:"transmitPerSec"`
|
||||
VMID int `json:"vmid,omitempty"`
|
||||
GuestName string `json:"guestName,omitempty"`
|
||||
GuestType string `json:"guestType,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
State string `json:"state"`
|
||||
Carrier bool `json:"carrier"`
|
||||
SpeedMbps int `json:"speedMbps,omitempty"`
|
||||
Duplex string `json:"duplex,omitempty"`
|
||||
Master string `json:"master,omitempty"`
|
||||
}
|
||||
|
||||
type networkSnapshot struct {
|
||||
@@ -83,13 +88,14 @@ type networkCollector struct {
|
||||
}
|
||||
|
||||
func newNetworkCollector(path string) (*networkCollector, error) {
|
||||
snapshot, _, _, err := readNetworkSnapshot(path)
|
||||
snapshot, _, details, err := readNetworkSnapshot(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _, details, _ := readNetworkSnapshot(path)
|
||||
previous := map[string]NetworkInterface{}
|
||||
for _, v := range details {
|
||||
decorateInterface(&v)
|
||||
collectInterfaceFaults(&v)
|
||||
previous[v.Name] = v
|
||||
}
|
||||
known := map[string]bool{}
|
||||
@@ -116,16 +122,11 @@ func (c *networkCollector) collect() (NetworkMetrics, error) {
|
||||
for i := range details {
|
||||
v := &details[i]
|
||||
decorateInterface(v)
|
||||
if old, ok := c.previousInterfaces[v.Name]; ok && elapsed > 0 && v.ReceiveBytes >= old.ReceiveBytes && v.TransmitBytes >= old.TransmitBytes {
|
||||
v.ReceivePerSec = float64(v.ReceiveBytes-old.ReceiveBytes) / elapsed
|
||||
v.TransmitPerSec = float64(v.TransmitBytes-old.TransmitBytes) / elapsed
|
||||
if v.ReceiveErrors >= old.ReceiveErrors && v.TransmitErrors >= old.TransmitErrors {
|
||||
v.NewErrors = v.ReceiveErrors - old.ReceiveErrors + v.TransmitErrors - old.TransmitErrors
|
||||
}
|
||||
if v.ReceiveDrops >= old.ReceiveDrops && v.TransmitDrops >= old.TransmitDrops {
|
||||
v.NewDrops = v.ReceiveDrops - old.ReceiveDrops + v.TransmitDrops - old.TransmitDrops
|
||||
}
|
||||
collectInterfaceFaults(v)
|
||||
if old, ok := c.previousInterfaces[v.Name]; ok {
|
||||
updateInterfaceDeltas(v, old, elapsed)
|
||||
}
|
||||
v.LinkDown = interfaceLinkDown(*v)
|
||||
}
|
||||
c.previousInterfaces = map[string]NetworkInterface{}
|
||||
for _, v := range details {
|
||||
@@ -166,19 +167,19 @@ func explainNetworkHealth(health *NetworkHealth, interfaces []NetworkInterface)
|
||||
health.Summary, health.LikelyCause, health.RecommendedAction = "Хост не видит локальный шлюз", "Проблема между Proxmox и роутером: линк, bridge, bond или VLAN", "Проверьте кабель, carrier и настройки vmbr/bond/VLAN"
|
||||
case health.PacketLoss > 0:
|
||||
health.Summary = fmt.Sprintf("Нестабильная локальная сеть: потеряно %.1f%% пакетов", health.PacketLoss)
|
||||
health.LikelyCause, health.RecommendedAction = "Перегрузка или ошибки линка между Proxmox и шлюзом", "Проверьте errors/drops, кабель, порт коммутатора и duplex"
|
||||
health.LikelyCause, health.RecommendedAction = "Перегрузка или ошибки линка между Proxmox и шлюзом", "Проверьте аппаратные ошибки, кабель, порт коммутатора и duplex"
|
||||
case !health.DNSUp && health.InternetUp:
|
||||
health.Summary, health.LikelyCause, health.RecommendedAction = "Интернет доступен, но DNS не отвечает", "Проблема DNS-сервера или /etc/resolv.conf", "Проверьте настроенные DNS и попробуйте разрешить домен вручную"
|
||||
case health.DNSUp && !health.InternetUp:
|
||||
health.Summary, health.LikelyCause, health.RecommendedAction = "Локальная сеть и DNS работают, но внешняя проверка недоступна", "Проблема выше роутера, блокировка HTTPS или недоступность провайдера", "Проверьте маршрут наружу, firewall и доступ к внешним адресам"
|
||||
}
|
||||
for _, v := range interfaces {
|
||||
if v.Kind == "Physical" && (!v.Carrier || v.State == "down") {
|
||||
health.Summary, health.LikelyCause, health.RecommendedAction = v.Name+": физический линк не поднят", "Нет carrier либо интерфейс административно выключен", "Проверьте кабель, порт коммутатора и состояние интерфейса"
|
||||
if v.LinkDown {
|
||||
health.Summary, health.LikelyCause, health.RecommendedAction = v.Name+": линк не поднят", "Нет carrier либо интерфейс административно выключен", "Проверьте кабель, порт коммутатора и состояние интерфейса"
|
||||
return
|
||||
}
|
||||
if v.NewErrors+v.NewDrops > 0 {
|
||||
health.Summary = fmt.Sprintf("%s: за последний интервал появилось %d сетевых ошибок/потерь", v.Name, v.NewErrors+v.NewDrops)
|
||||
if v.NewErrors+v.NewFaults > 0 {
|
||||
health.Summary = fmt.Sprintf("%s: за последний интервал появилось %d изменений счётчиков сетевых ошибок", v.Name, v.NewErrors+v.NewFaults)
|
||||
health.LikelyCause, health.RecommendedAction = "Счётчики интерфейса сейчас растут из-за перегрузки, кабеля, MTU или duplex", "Проверьте скорость и duplex, кабель, порт коммутатора и MTU на обоих концах"
|
||||
return
|
||||
}
|
||||
@@ -195,6 +196,10 @@ func decorateInterface(v *NetworkInterface) {
|
||||
v.Master = filepath.Base(target)
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(v.Name, "fwbr"):
|
||||
v.Kind = "Firewall bridge"
|
||||
case strings.HasPrefix(v.Name, "fwpr") || strings.HasPrefix(v.Name, "fwln"):
|
||||
v.Kind = "Firewall link"
|
||||
case strings.HasPrefix(v.Name, "vmbr"):
|
||||
v.Kind = "Bridge"
|
||||
case strings.HasPrefix(v.Name, "bond"):
|
||||
@@ -273,7 +278,7 @@ func readNetworkSnapshot(path string) (networkSnapshot, []string, []NetworkInter
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(values)
|
||||
if len(fields) < 9 {
|
||||
if len(fields) < 16 {
|
||||
continue
|
||||
}
|
||||
rx, errRX := strconv.ParseUint(fields[0], 10, 64)
|
||||
|
||||
103
network_faults.go
Normal file
103
network_faults.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func counterDelta(current, previous uint64) uint64 {
|
||||
if current < previous {
|
||||
return 0
|
||||
}
|
||||
return current - previous
|
||||
}
|
||||
|
||||
func updateInterfaceDeltas(v *NetworkInterface, old NetworkInterface, elapsed float64) {
|
||||
if elapsed <= 0 {
|
||||
return
|
||||
}
|
||||
v.ReceivePerSec = float64(counterDelta(v.ReceiveBytes, old.ReceiveBytes)) / elapsed
|
||||
v.TransmitPerSec = float64(counterDelta(v.TransmitBytes, old.TransmitBytes)) / elapsed
|
||||
v.NewErrors = counterDelta(v.ReceiveErrors, old.ReceiveErrors) + counterDelta(v.TransmitErrors, old.TransmitErrors)
|
||||
v.NewReceiveDrops = counterDelta(v.ReceiveDrops, old.ReceiveDrops)
|
||||
v.NewTransmitDrops = counterDelta(v.TransmitDrops, old.TransmitDrops)
|
||||
v.NewDrops = v.NewReceiveDrops + v.NewTransmitDrops
|
||||
for name, value := range v.FaultCounters {
|
||||
if previous, ok := old.FaultCounters[name]; ok {
|
||||
v.NewFaults += counterDelta(value, previous)
|
||||
}
|
||||
}
|
||||
// A transition matters even if the interface has not carried traffic yet.
|
||||
v.LinkDown = old.Carrier && !v.Carrier
|
||||
}
|
||||
|
||||
func interfaceLinkDown(v NetworkInterface) bool {
|
||||
if v.Kind == "Firewall bridge" || v.Kind == "Firewall link" {
|
||||
return false
|
||||
}
|
||||
if v.LinkDown {
|
||||
return true
|
||||
}
|
||||
used := v.Master != "" || v.ReceiveBytes+v.TransmitBytes > 0
|
||||
if v.Kind == "Physical" {
|
||||
return used && (!v.Carrier || v.State == "down")
|
||||
}
|
||||
return used && v.State == "down"
|
||||
}
|
||||
|
||||
func collectInterfaceFaults(v *NetworkInterface) {
|
||||
base := filepath.Join("/sys/class/net", v.Name)
|
||||
// /proc/net/dev folds missed packets into RX drop. Sysfs keeps software
|
||||
// drops separate from rx_missed_errors, which must still trigger an alert.
|
||||
if n, err := strconv.ParseUint(readTrimmed(filepath.Join(base, "statistics/rx_dropped")), 10, 64); err == nil {
|
||||
v.ReceiveDrops = n
|
||||
}
|
||||
v.FaultCounters = map[string]uint64{}
|
||||
for _, name := range []string{"rx_crc_errors", "rx_frame_errors", "rx_fifo_errors", "rx_missed_errors", "tx_fifo_errors", "tx_carrier_errors", "carrier_down_count"} {
|
||||
path := filepath.Join(base, "statistics", name)
|
||||
if name == "carrier_down_count" {
|
||||
path = filepath.Join(base, name)
|
||||
}
|
||||
if n, err := strconv.ParseUint(readTrimmed(path), 10, 64); err == nil {
|
||||
v.FaultCounters[name] = n
|
||||
}
|
||||
}
|
||||
// Only Atlantic exposes the driver-specific names below. No configuration
|
||||
// changes, and a missing ethtool is not itself a network failure.
|
||||
driver, err := filepath.EvalSymlinks(filepath.Join(base, "device/driver"))
|
||||
if err != nil || filepath.Base(driver) != "atlantic" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if out, err := exec.CommandContext(ctx, "ethtool", "-S", v.Name).Output(); err == nil {
|
||||
for k, n := range parseAtlanticFaults(string(out)) {
|
||||
v.FaultCounters["atlantic:"+k] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseAtlanticFaults(output string) map[string]uint64 {
|
||||
result := map[string]uint64{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(line), ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(key)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
switch fields[len(fields)-1] {
|
||||
case "InErrors", "InDroppedDma", "AllocFails", "SkbAllocFails", "Restarts":
|
||||
if n, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64); err == nil {
|
||||
result[key] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
67
network_faults_test.go
Normal file
67
network_faults_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNetworkDropsDoNotAlert(t *testing.T) {
|
||||
for _, name := range []string{"enp6s0", "vmbr0", "fwbr100i0", "tap100i0"} {
|
||||
v := NetworkInterface{Name: name, Carrier: true, State: "up", NewDrops: 15, NewReceiveDrops: 15}
|
||||
metrics := dashboardMetrics{Network: NetworkMetrics{Interfaces: []NetworkInterface{v}}}
|
||||
for _, a := range evaluateAlerts(metrics, AlertThresholds{}) {
|
||||
if a.ID == "network-errors" {
|
||||
t.Fatalf("%s: drops triggered alert", name)
|
||||
}
|
||||
}
|
||||
v.NewErrors = 1
|
||||
metrics.Network.Interfaces[0] = v
|
||||
found := false
|
||||
for _, a := range evaluateAlerts(metrics, AlertThresholds{}) {
|
||||
if a.ID == "network-errors" {
|
||||
found = a.Severity == "critical"
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s: errors did not trigger critical alert", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkHardwareFaultAndLinkAlerts(t *testing.T) {
|
||||
for _, v := range []NetworkInterface{{Name: "eth0", NewFaults: 1}, {Name: "eth0", LinkDown: true}} {
|
||||
found := false
|
||||
for _, a := range evaluateAlerts(dashboardMetrics{Network: NetworkMetrics{Interfaces: []NetworkInterface{v}}}, AlertThresholds{}) {
|
||||
if strings.HasPrefix(a.ID, "network-") && a.Severity == "critical" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no critical alert for %+v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkCounterResetsAndFaultBaselines(t *testing.T) {
|
||||
old := NetworkInterface{ReceiveBytes: 100, ReceiveErrors: 20, TransmitErrors: 4, ReceiveDrops: 8, FaultCounters: map[string]uint64{"rx_missed_errors": 10, "atlantic:InErrors": 9}, Carrier: true}
|
||||
next := NetworkInterface{ReceiveBytes: 1, ReceiveErrors: 1, TransmitErrors: 6, ReceiveDrops: 10, FaultCounters: map[string]uint64{"rx_missed_errors": 12, "atlantic:InErrors": 1, "new": 100}, Carrier: false}
|
||||
updateInterfaceDeltas(&next, old, 2)
|
||||
if next.NewErrors != 2 || next.NewFaults != 2 || next.NewReceiveDrops != 2 || next.ReceivePerSec != 0 || !next.LinkDown {
|
||||
t.Fatalf("bad deltas: %+v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlanticFaults(t *testing.T) {
|
||||
got := parseAtlanticFaults("NIC statistics:\n InErrors: 2\n InDroppedDma: 3\n Queue[0] AllocFails: 4\n Queue[1] SkbAllocFails: 5\n Queue[0] Restarts: 6\n Queue[0] InErrors: 7\n InPackets: 100\n Queue[0] XdpDrop: 50\n InErrorsBad: nope")
|
||||
if len(got) != 6 || got["InDroppedDma"] != 3 || got["Queue[0] Restarts"] != 6 {
|
||||
t.Fatalf("bad counters: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewallNotAttributedToGuest(t *testing.T) {
|
||||
n := NetworkMetrics{Interfaces: []NetworkInterface{{Name: "fwbr100i0", ReceiveDrops: 100}, {Name: "tap100i0"}}}
|
||||
decorateNetworkInterfaces(&n, GuestsMetrics{})
|
||||
if n.Interfaces[0].VMID != 0 || n.Interfaces[1].VMID != 100 {
|
||||
t.Fatalf("bad VM attribution: %+v", n)
|
||||
}
|
||||
}
|
||||
162
network_flows.go
Normal file
162
network_flows.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed assets/network.html
|
||||
var networkHTML string
|
||||
|
||||
// Original and reply tuples describe one conntrack entry, not two connections.
|
||||
type NetworkFlow struct {
|
||||
Monitoring bool `json:"monitoring"`
|
||||
External bool `json:"external"`
|
||||
Protocol string `json:"protocol"`
|
||||
Source string `json:"source"`
|
||||
Destination string `json:"destination"`
|
||||
SourcePort int `json:"sourcePort"`
|
||||
DestinationPort int `json:"destinationPort"`
|
||||
ReplySource string `json:"replySource"`
|
||||
ReplyDestination string `json:"replyDestination"`
|
||||
ReplySourcePort int `json:"replySourcePort"`
|
||||
ReplyDestinationPort int `json:"replyDestinationPort"`
|
||||
State string `json:"state"`
|
||||
Country string `json:"country"`
|
||||
DestinationCountry string `json:"destinationCountry"`
|
||||
Packets uint64 `json:"packets"`
|
||||
ReplyPackets uint64 `json:"replyPackets"`
|
||||
Bytes uint64 `json:"bytes"`
|
||||
ReplyBytes uint64 `json:"replyBytes"`
|
||||
Accounting bool `json:"accounting"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
}
|
||||
type FirewallCounter struct {
|
||||
Name string `json:"name"`
|
||||
Packets uint64 `json:"packets"`
|
||||
Bytes uint64 `json:"bytes"`
|
||||
}
|
||||
type NetworkSnapshot struct {
|
||||
MonitoringHidden int `json:"monitoringHidden"`
|
||||
At int64 `json:"at"`
|
||||
ReceivedAt int64 `json:"receivedAt"`
|
||||
Flows []NetworkFlow `json:"flows"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Limited bool `json:"limited"`
|
||||
ParseErrors int `json:"parseErrors"`
|
||||
Firewall []FirewallCounter `json:"firewall"`
|
||||
FirewallAt int64 `json:"firewallAt"`
|
||||
FirewallError string `json:"firewallError,omitempty"`
|
||||
AgentName string `json:"agentName"`
|
||||
}
|
||||
|
||||
func networkExternalIP(value string) bool {
|
||||
ip := net.ParseIP(value)
|
||||
if ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
|
||||
return false
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (s *Store) SaveNetwork(agent string, snapshot NetworkSnapshot) error {
|
||||
if len(snapshot.Flows) > 5000 || len(snapshot.Firewall) > 128 {
|
||||
return errors.New("network snapshot too large")
|
||||
}
|
||||
if snapshot.At < time.Now().Unix()-120 || snapshot.At > time.Now().Unix()+60 {
|
||||
return errors.New("invalid snapshot time")
|
||||
}
|
||||
for i := range snapshot.Flows {
|
||||
f := &snapshot.Flows[i]
|
||||
if net.ParseIP(f.Source) == nil || net.ParseIP(f.Destination) == nil || net.ParseIP(f.ReplySource) == nil || net.ParseIP(f.ReplyDestination) == nil {
|
||||
return errors.New("invalid network address")
|
||||
}
|
||||
for _, p := range []int{f.SourcePort, f.DestinationPort, f.ReplySourcePort, f.ReplyDestinationPort} {
|
||||
if p < 0 || p > 65535 {
|
||||
return errors.New("invalid network port")
|
||||
}
|
||||
}
|
||||
if f.Protocol != "tcp" && f.Protocol != "udp" && f.Protocol != "icmp" && f.Protocol != "icmpv6" {
|
||||
return errors.New("invalid protocol")
|
||||
}
|
||||
f.State = trafficText(f.State, 32)
|
||||
f.Zone = trafficText(f.Zone, 16)
|
||||
f.External = networkExternalIP(f.Source) || networkExternalIP(f.Destination)
|
||||
f.Country = lookupTrafficCountry(f.Source)
|
||||
f.DestinationCountry = lookupTrafficCountry(f.Destination)
|
||||
}
|
||||
snapshot.Error = trafficText(snapshot.Error, 600)
|
||||
snapshot.FirewallError = trafficText(snapshot.FirewallError, 600)
|
||||
for i := range snapshot.Firewall {
|
||||
snapshot.Firewall[i].Name = trafficText(snapshot.Firewall[i].Name, 200)
|
||||
}
|
||||
snapshot.MonitoringHidden = 0
|
||||
snapshot.ReceivedAt = time.Now().Unix()
|
||||
snapshot.AgentName = ""
|
||||
raw, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO network_sources(agent,snapshot) VALUES(?,?) ON CONFLICT(agent) DO UPDATE SET snapshot=excluded.snapshot`, agent, string(raw))
|
||||
return err
|
||||
}
|
||||
func networkRoutes(mux *http.ServeMux, s *Store) {
|
||||
enabled := func() bool { c := s.TrafficSettings(); return c.Enabled && c.NetworkEnabled }
|
||||
mux.HandleFunc("/network", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !enabled() {
|
||||
http.Error(w, "Включите TCP/UDP в настройках карты трафика", 404)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(networkHTML))
|
||||
})
|
||||
mux.HandleFunc("/api/network", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
}
|
||||
if !enabled() {
|
||||
http.Error(w, "Сбор сети отключён", 404)
|
||||
return
|
||||
}
|
||||
settings := s.TrafficSettings()
|
||||
snapshot, err := s.ReadNetworkSnapshot(settings.AgentID, settings.MonitoringRules, r.URL.Query().Get("hideMonitoring") == "1")
|
||||
if err != nil {
|
||||
writeJSON(w, nil, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot, nil)
|
||||
})
|
||||
mux.HandleFunc("/api/agent/network", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
}
|
||||
id, secret, ok := agentCredentials(r)
|
||||
if !ok || !s.AuthenticateAgent(id, secret) {
|
||||
http.Error(w, "Unauthorized", 401)
|
||||
return
|
||||
}
|
||||
c := s.TrafficSettings()
|
||||
if !c.Enabled || !c.NetworkEnabled || c.AgentID != id {
|
||||
http.Error(w, "Источник отключён", 403)
|
||||
return
|
||||
}
|
||||
var snapshot NetworkSnapshot
|
||||
if json.NewDecoder(io.LimitReader(r.Body, 4<<20)).Decode(&snapshot) != nil {
|
||||
http.Error(w, "Invalid snapshot", 400)
|
||||
return
|
||||
}
|
||||
if err := s.SaveNetwork(id, snapshot); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]bool{"accepted": true}, nil)
|
||||
})
|
||||
}
|
||||
189
network_flows_test.go
Normal file
189
network_flows_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const networkNATLine = "ipv4 2 tcp 6 431999 ESTABLISHED src=8.8.8.8 dst=192.168.0.12 sport=51000 dport=443 packets=12 bytes=1500 src=172.18.0.2 dst=8.8.8.8 sport=443 dport=51000 packets=16 bytes=2500 [ASSURED] mark=0 zone=2 use=1"
|
||||
|
||||
func TestNetworkConntrackTuples(t *testing.T) {
|
||||
f, ok := parseNetworkFlow(networkNATLine)
|
||||
if !ok || f.Source != "8.8.8.8" || f.Destination != "192.168.0.12" || f.ReplySource != "172.18.0.2" || f.SourcePort != 51000 || f.ReplySourcePort != 443 || f.State != "ESTABLISHED" || !f.Accounting || f.Bytes != 1500 || f.ReplyBytes != 2500 || f.Zone != "2" {
|
||||
t.Fatalf("NAT tuple corrupted: %+v", f)
|
||||
}
|
||||
udp, ok := parseNetworkFlow("ipv6 10 udp 17 29 src=2001:4860:4860::8888 dst=fd00::2 sport=51000 dport=53 [UNREPLIED] src=fd00::2 dst=2001:4860:4860::8888 sport=53 dport=51000 mark=0 use=1")
|
||||
if !ok || udp.State != "UNREPLIED" || udp.Accounting || udp.Protocol != "udp" {
|
||||
t.Fatalf("UDP: %+v", udp)
|
||||
}
|
||||
icmp, ok := parseNetworkFlow("icmp 1 29 src=8.8.8.8 dst=192.168.0.12 type=8 code=0 id=3 src=192.168.0.12 dst=8.8.8.8 type=0 code=0 id=3 use=1")
|
||||
if !ok || icmp.SourcePort != 0 || icmp.State != "TRACKED" {
|
||||
t.Fatalf("ICMP: %+v", icmp)
|
||||
}
|
||||
if _, ok := parseNetworkFlow("tcp 6 20 SYN_SENT src=not-an-ip dst=192.168.0.12"); ok {
|
||||
t.Fatal("invalid accepted")
|
||||
}
|
||||
}
|
||||
func TestNetworkSnapshotLimit(t *testing.T) {
|
||||
var s NetworkSnapshot
|
||||
appendNetworkFlows(&s, []byte(strings.Repeat(networkNATLine+"\n", 5001)))
|
||||
if len(s.Flows) != 5000 || !s.Limited {
|
||||
t.Fatalf("limit not enforced %d", len(s.Flows))
|
||||
}
|
||||
b := networkBuffer{limit: 2}
|
||||
if _, err := b.Write([]byte("123")); err == nil || b.Len() != 0 {
|
||||
t.Fatal("output unbounded")
|
||||
}
|
||||
}
|
||||
func TestFirewallOnlyConfirmedDropCounters(t *testing.T) {
|
||||
raw := `{"nftables":[{"rule":{"family":"ip","table":"crowdsec","chain":"crowdsec-chain","handle":3,"expr":[{"match":{}},{"counter":{"packets":10,"bytes":1000}},{"drop":null}]}},{"rule":{"table":"crowdsec","expr":[{"counter":{"packets":99}},{"accept":null}]}},{"rule":{"table":"other","expr":[{"counter":{"packets":99}},{"drop":null}]}},{"rule":{"table":"crowdsec","expr":[{"counter":{"packets":99}},{"match":{}},{"drop":null}]}},{"rule":{"table":"crowdsec","expr":[{"counter":"named-counter"},{"drop":null}]}}]}`
|
||||
counters, err := parseFirewallCounters([]byte(raw))
|
||||
if err != nil || len(counters) != 1 || counters[0].Packets != 10 || counters[0].Bytes != 1000 {
|
||||
t.Fatalf("invented blocks: %+v %v", counters, err)
|
||||
}
|
||||
if _, err := parseFirewallCounters([]byte("invalid")); err == nil {
|
||||
t.Fatal("invalid nft JSON accepted")
|
||||
}
|
||||
}
|
||||
func TestNetworkRouteAuthorizationAndSourceSelection(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID})
|
||||
mux := http.NewServeMux()
|
||||
trafficRoutes(mux, s)
|
||||
flow, _ := parseNetworkFlow(networkNATLine)
|
||||
snapshot := NetworkSnapshot{At: time.Now().Unix(), Flows: []NetworkFlow{flow}}
|
||||
raw, _ := json.Marshal(snapshot)
|
||||
post := func(token string) int {
|
||||
r := httptest.NewRequest("POST", "/api/agent/network", bytes.NewReader(raw))
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
if got := post(a.ID + "." + a.Secret); got != 403 {
|
||||
t.Fatalf("disabled accepts data: %d", got)
|
||||
}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, NetworkEnabled: true, AgentID: a.ID})
|
||||
if got := post("wrong.token"); got != 401 {
|
||||
t.Fatalf("unauthenticated: %d", got)
|
||||
}
|
||||
if got := post(a.ID + "." + a.Secret); got != 200 {
|
||||
t.Fatalf("valid: %d", got)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/network", nil))
|
||||
var got NetworkSnapshot
|
||||
json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if w.Code != 200 || len(got.Flows) != 1 || got.Flows[0].Country == "" || got.ReceivedAt == 0 {
|
||||
t.Fatalf("bad snapshot: %s", w.Body.String())
|
||||
}
|
||||
token, _ := s.CreateAgentEnrollmentToken()
|
||||
other, _ := s.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "other"})
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, NetworkEnabled: true, AgentID: other.ID})
|
||||
if code := post(a.ID + "." + a.Secret); code != 403 {
|
||||
t.Fatalf("old source accepted: %d", code)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/network", nil))
|
||||
json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if len(got.Flows) != 0 {
|
||||
t.Fatal("old source data leaked")
|
||||
}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: false, NetworkEnabled: true, AgentID: a.ID})
|
||||
for _, path := range []string{"/network", "/api/network"} {
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("disabled page: %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestNetworkRejectBadSnapshots(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
if err := s.SaveNetwork(a.ID, NetworkSnapshot{At: time.Now().Unix() - 300}); err == nil {
|
||||
t.Fatal("stale timestamp")
|
||||
}
|
||||
f, _ := parseNetworkFlow(networkNATLine)
|
||||
f.SourcePort = 99999
|
||||
if err := s.SaveNetwork(a.ID, NetworkSnapshot{At: time.Now().Unix(), Flows: []NetworkFlow{f}}); err == nil {
|
||||
t.Fatal("invalid port")
|
||||
}
|
||||
}
|
||||
func TestNetworkCommandCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := networkCommand(ctx, "conntrack", "-L"); err == nil {
|
||||
t.Fatal("cancelled command ran")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkExternalAddresses(t *testing.T) {
|
||||
for _, ip := range []string{"192.168.1.1", "127.0.0.1", "100.64.0.1", "224.0.0.1", "ff02::1", "fe80::1", "fd00::1", "::"} {
|
||||
if networkExternalIP(ip) {
|
||||
t.Fatalf("not external: %s", ip)
|
||||
}
|
||||
}
|
||||
for _, ip := range []string{"8.8.8.8", "2001:4860:4860::8888"} {
|
||||
if !networkExternalIP(ip) {
|
||||
t.Fatalf("external: %s", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConntrackInstallerOnlyInstallsRequestedPackage(t *testing.T) {
|
||||
var calls [][]string
|
||||
err := installConntrackPackage(func(name string, args ...string) error {
|
||||
calls = append(calls, append([]string{name}, args...))
|
||||
return nil
|
||||
}, func() bool { return true })
|
||||
if err != nil || len(calls) != 2 {
|
||||
t.Fatalf("installer: %v %v", calls, err)
|
||||
}
|
||||
if calls[0][len(calls[0])-1] != "update" || strings.Join(calls[1][len(calls[1])-5:], " ") != "-y --no-install-recommends --no-remove install conntrack" {
|
||||
t.Fatalf("unexpected apt operation: %v", calls)
|
||||
}
|
||||
calls = nil
|
||||
err = installConntrackPackage(func(name string, args ...string) error { calls = append(calls, args); return fmt.Errorf("apt locked") }, func() bool { return true })
|
||||
if err == nil || len(calls) != 1 {
|
||||
t.Fatal("continued after failed apt update")
|
||||
}
|
||||
}
|
||||
func TestConntrackDependencySingleInstallAndBackoff(t *testing.T) {
|
||||
var d networkDependencyState
|
||||
now := time.Now()
|
||||
started := make(chan struct{})
|
||||
finish := make(chan struct{})
|
||||
installer := func() error { close(started); <-finish; return fmt.Errorf("offline") }
|
||||
if got := d.ensure(now, true, installer); got != "" {
|
||||
t.Fatal("installed tool should be skipped")
|
||||
}
|
||||
d.ensure(now, false, installer)
|
||||
<-started
|
||||
// A second call must not start a second installer (which would panic closing started).
|
||||
d.ensure(now, false, installer)
|
||||
close(finish)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
d.Lock()
|
||||
running := d.running
|
||||
d.Unlock()
|
||||
if !running {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("installer stuck")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
got := d.ensure(now.Add(time.Minute), false, installer)
|
||||
if !strings.Contains(got, "offline") || !strings.Contains(got, "Повторная") {
|
||||
t.Fatalf("backoff missing: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -49,13 +49,13 @@ func TestExplainNetworkHealthIgnoresOldInterfaceCounters(t *testing.T) {
|
||||
t.Fatalf("старый накопленный счётчик вызвал текущую тревогу: %+v", health)
|
||||
}
|
||||
explainNetworkHealth(&health, []NetworkInterface{{Name: "eno1", Carrier: true, State: "up", ReceiveDrops: 43, NewDrops: 1}})
|
||||
if !strings.Contains(health.Summary, "последний интервал") {
|
||||
t.Fatalf("рост счётчика не объяснён: %+v", health)
|
||||
if health.Summary != "Сеть работает штатно" {
|
||||
t.Fatalf("software drops вызвали тревогу: %+v", health)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceVMID(t *testing.T) {
|
||||
for name, want := range map[string]int{"tap100i0": 100, "veth108i0": 108, "fwbr113i0": 113, "vmbr0": 0, "enp6s0": 0} {
|
||||
for name, want := range map[string]int{"tap100i0": 100, "veth108i0": 108, "fwbr113i0": 0, "vmbr0": 0, "enp6s0": 0} {
|
||||
if got := interfaceVMID(name); got != want {
|
||||
t.Fatalf("%s: got %d want %d", name, got, want)
|
||||
}
|
||||
|
||||
17
scripts/build-release.sh
Executable file
17
scripts/build-release.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
version=${1:?Usage: build-release.sh vX.Y.Z}
|
||||
cd "$(dirname "$0")/.."
|
||||
go test ./...
|
||||
mkdir -p dist
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$version" -o dist/proxmox-dashboard-linux-amd64 .
|
||||
(cd dist && shasum -a 256 proxmox-dashboard-linux-amd64 > proxmox-dashboard-linux-amd64.sha256)
|
||||
stage=$(mktemp -d)
|
||||
trap 'rm -rf "$stage"' EXIT INT TERM
|
||||
package="$stage/proxmox-dashboard-install"
|
||||
mkdir -p "$package/dist" "$package/scripts"
|
||||
cp dist/proxmox-dashboard-linux-amd64 dist/proxmox-dashboard-linux-amd64.sha256 "$package/dist/"
|
||||
cp scripts/install.sh scripts/install-agent.sh scripts/uninstall-agent.sh scripts/proxmox-dashboard-update "$package/scripts/"
|
||||
cp assets/NOTICE.md "$package/MAP-DATA-NOTICE.md"
|
||||
cp ./*.service ./*.timer ./*.path README.md "$package/"
|
||||
tar -czf dist/proxmox-dashboard-install-amd64.tar.gz -C "$stage" proxmox-dashboard-install
|
||||
1
store.go
1
store.go
@@ -106,6 +106,7 @@ func openStore(path string) (*Store, error) {
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
statements := []string{
|
||||
trafficSchema,
|
||||
`PRAGMA journal_mode=WAL`,
|
||||
`PRAGMA busy_timeout=5000`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
|
||||
|
||||
318
traffic.go
Normal file
318
traffic.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TrafficEvent struct {
|
||||
Monitoring bool `json:"monitoring"`
|
||||
ID string `json:"id"`
|
||||
Sequence int64 `json:"sequence,omitempty"`
|
||||
Time int64 `json:"time"`
|
||||
Kind string `json:"kind"`
|
||||
IP string `json:"ip"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
}
|
||||
|
||||
type TrafficBatch struct {
|
||||
Events []TrafficEvent `json:"events"`
|
||||
Files int `json:"files"`
|
||||
BacklogBytes int64 `json:"backlogBytes"`
|
||||
CrowdSecLimited bool `json:"crowdsecLimited"`
|
||||
ParseErrors uint64 `json:"parseErrors"`
|
||||
LogError string `json:"logError,omitempty"`
|
||||
CrowdSecError string `json:"crowdsecError,omitempty"`
|
||||
CrowdSecAt int64 `json:"crowdsecAt"`
|
||||
StartedAt int64 `json:"startedAt"`
|
||||
}
|
||||
|
||||
type TrafficSource struct {
|
||||
Agent string `json:"agent"`
|
||||
Name string `json:"name"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
Status TrafficBatch `json:"status"`
|
||||
}
|
||||
|
||||
type TrafficCount struct {
|
||||
Name string `json:"name"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type TrafficView struct {
|
||||
Network *NetworkMapView `json:"network,omitempty"`
|
||||
Events []TrafficEvent `json:"events"`
|
||||
Sources []TrafficSource `json:"sources"`
|
||||
Countries []TrafficCount `json:"countries"`
|
||||
Domains []TrafficCount `json:"domains"`
|
||||
IPs []TrafficCount `json:"ips"`
|
||||
Requests int64 `json:"requests"`
|
||||
UniqueIPs int64 `json:"uniqueIps"`
|
||||
Alerts int64 `json:"alerts"`
|
||||
Decisions int64 `json:"decisions"`
|
||||
QPS float64 `json:"qps"`
|
||||
Series []TrafficCount `json:"series"`
|
||||
GeoReady bool `json:"geoReady"`
|
||||
GeoDate string `json:"geoDate"`
|
||||
Now int64 `json:"now"`
|
||||
Settings TrafficSettings `json:"settings"`
|
||||
Limited bool `json:"limited"`
|
||||
}
|
||||
|
||||
const trafficSchema = `CREATE TABLE IF NOT EXISTS traffic_events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT, agent TEXT NOT NULL, event_id TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL, kind TEXT NOT NULL, ip TEXT NOT NULL, domain TEXT NOT NULL,
|
||||
method TEXT NOT NULL, path TEXT NOT NULL, status INTEGER NOT NULL, reason TEXT NOT NULL,
|
||||
country TEXT NOT NULL, UNIQUE(agent,event_id));
|
||||
CREATE INDEX IF NOT EXISTS traffic_ts ON traffic_events(ts);
|
||||
CREATE INDEX IF NOT EXISTS traffic_filter ON traffic_events(domain,kind,ts);
|
||||
CREATE TABLE IF NOT EXISTS network_sources(agent TEXT PRIMARY KEY, snapshot TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS traffic_sources(agent TEXT PRIMARY KEY, last_seen INTEGER NOT NULL, status_json TEXT NOT NULL);`
|
||||
|
||||
func (s *Store) SaveTraffic(agent string, batch TrafficBatch) error {
|
||||
if len(batch.Events) > 500 {
|
||||
return errors.New("too many traffic events")
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stmt, err := tx.Prepare(`INSERT OR IGNORE INTO traffic_events(agent,event_id,ts,kind,ip,domain,method,path,status,reason,country) VALUES(?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, e := range batch.Events {
|
||||
if e.Kind != "request" && e.Kind != "alert" && e.Kind != "decision" {
|
||||
return errors.New("invalid traffic kind")
|
||||
}
|
||||
if net.ParseIP(e.IP) == nil || len(e.ID) == 0 || len(e.ID) > 160 {
|
||||
return errors.New("invalid traffic event")
|
||||
}
|
||||
if e.Time < now-86400 || e.Time > now+300 {
|
||||
continue
|
||||
}
|
||||
e.IP = net.ParseIP(e.IP).String()
|
||||
country := lookupTrafficCountry(e.IP)
|
||||
_, err = stmt.Exec(agent, e.ID, e.Time, e.Kind, e.IP, trafficText(e.Domain, 253), trafficText(e.Method, 16), trafficPath(e.Path), e.Status, trafficText(e.Reason, 256), country)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
batch.Events = nil
|
||||
raw, err := json.Marshal(batch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(`INSERT INTO traffic_sources(agent,last_seen,status_json) VALUES(?,?,?) ON CONFLICT(agent) DO UPDATE SET last_seen=excluded.last_seen,status_json=excluded.status_json`, agent, now, string(raw)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Bound both age and total rows, including a high-traffic installation.
|
||||
if _, err = tx.Exec(`DELETE FROM traffic_events WHERE ts<? OR seq <= COALESCE((SELECT seq FROM traffic_events ORDER BY seq DESC LIMIT 1 OFFSET 200000),0)`, now-86400); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func trafficText(s string, max int) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
r := []rune(s)
|
||||
if len(r) > max {
|
||||
return string(r[:max])
|
||||
}
|
||||
return s
|
||||
}
|
||||
func trafficPath(s string) string {
|
||||
s = strings.SplitN(s, "?", 2)[0]
|
||||
s = strings.SplitN(s, "#", 2)[0]
|
||||
return trafficText(s, 256)
|
||||
}
|
||||
|
||||
func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring ...bool) (TrafficView, error) {
|
||||
now := time.Now().Unix()
|
||||
v := TrafficView{Events: []TrafficEvent{}, Sources: []TrafficSource{}, Countries: []TrafficCount{}, Domains: []TrafficCount{}, IPs: []TrafficCount{}, Series: []TrafficCount{}, Now: now, GeoReady: trafficGeoReady(), GeoDate: trafficGeoDate}
|
||||
v.Settings = s.TrafficSettings()
|
||||
hide := len(hideMonitoring) > 0 && hideMonitoring[0]
|
||||
if v.Settings.NetworkEnabled {
|
||||
snapshot, err := s.ReadNetworkSnapshot(v.Settings.AgentID, v.Settings.MonitoringRules, hide)
|
||||
if err != nil {
|
||||
snapshot.Error = "Не удалось прочитать сетевой снимок"
|
||||
}
|
||||
network := makeNetworkMap(snapshot, external)
|
||||
v.Network = &network
|
||||
}
|
||||
where := ` WHERE ts>=? AND agent=?`
|
||||
args := []any{now - 86400, v.Settings.AgentID}
|
||||
if hide {
|
||||
clause, params := monitoringSQL(v.Settings.MonitoringRules)
|
||||
where += clause
|
||||
args = append(args, params...)
|
||||
}
|
||||
if external {
|
||||
where += ` AND country!='LAN'`
|
||||
}
|
||||
if domain != "" {
|
||||
where += ` AND domain=?`
|
||||
args = append(args, domain)
|
||||
}
|
||||
if kind != "" {
|
||||
where += ` AND kind=?`
|
||||
args = append(args, kind)
|
||||
}
|
||||
err := s.db.QueryRow(`SELECT COALESCE(SUM(kind='request'),0),COUNT(DISTINCT CASE WHEN kind='request' THEN ip END),COALESCE(SUM(kind='alert'),0),COALESCE(SUM(kind='decision'),0),COALESCE(SUM(kind='request' AND ts>=?),0)/60.0 FROM traffic_events`+where, append([]any{now - 60}, args...)...).Scan(&v.Requests, &v.UniqueIPs, &v.Alerts, &v.Decisions, &v.QPS)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT seq,event_id,ts,kind,ip,domain,method,path,status,reason,country,agent FROM traffic_events`+where+` ORDER BY ts DESC,seq DESC LIMIT 100`, args...)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var e TrafficEvent
|
||||
if err = rows.Scan(&e.Sequence, &e.ID, &e.Time, &e.Kind, &e.IP, &e.Domain, &e.Method, &e.Path, &e.Status, &e.Reason, &e.Country, &e.Agent); err != nil {
|
||||
rows.Close()
|
||||
return v, err
|
||||
}
|
||||
e.Monitoring = isMonitoringRequest(e, v.Settings.MonitoringRules)
|
||||
v.Events = append(v.Events, e)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
for _, q := range []struct {
|
||||
column string
|
||||
out *[]TrafficCount
|
||||
}{{"country", &v.Countries}, {"domain", &v.Domains}, {"ip", &v.IPs}} {
|
||||
r, e := s.db.Query(`SELECT `+q.column+`,COUNT(*) FROM traffic_events`+where+` GROUP BY `+q.column+` ORDER BY COUNT(*) DESC LIMIT 50`, args...)
|
||||
if e != nil {
|
||||
return v, e
|
||||
}
|
||||
for r.Next() {
|
||||
var c TrafficCount
|
||||
if e = r.Scan(&c.Name, &c.Count); e != nil {
|
||||
r.Close()
|
||||
return v, e
|
||||
}
|
||||
*q.out = append(*q.out, c)
|
||||
}
|
||||
e = r.Err()
|
||||
r.Close()
|
||||
if e != nil {
|
||||
return v, e
|
||||
}
|
||||
}
|
||||
r, err := s.db.Query(`SELECT CAST((ts/60)*60 AS TEXT),COUNT(*) FROM traffic_events`+where+` AND kind='request' AND ts>=? GROUP BY ts/60 ORDER BY ts/60`, append(args, now-3600)...)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
for r.Next() {
|
||||
var c TrafficCount
|
||||
if err = r.Scan(&c.Name, &c.Count); err != nil {
|
||||
r.Close()
|
||||
return v, err
|
||||
}
|
||||
v.Series = append(v.Series, c)
|
||||
}
|
||||
err = r.Err()
|
||||
r.Close()
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
r, err = s.db.Query(`SELECT t.agent,COALESCE(a.name,t.agent),t.last_seen,t.status_json FROM traffic_sources t LEFT JOIN agents a ON a.id=t.agent WHERE t.agent=? ORDER BY t.last_seen DESC`, v.Settings.AgentID)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
for r.Next() {
|
||||
var src TrafficSource
|
||||
var raw string
|
||||
if err = r.Scan(&src.Agent, &src.Name, &src.LastSeen, &raw); err != nil {
|
||||
r.Close()
|
||||
return v, err
|
||||
}
|
||||
json.Unmarshal([]byte(raw), &src.Status)
|
||||
v.Sources = append(v.Sources, src)
|
||||
}
|
||||
err = r.Err()
|
||||
r.Close()
|
||||
var count int64
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM traffic_events`).Scan(&count)
|
||||
v.Limited = count >= 200000
|
||||
return v, err
|
||||
}
|
||||
|
||||
func trafficRoutes(mux *http.ServeMux, s *Store) {
|
||||
trafficSettingsRoute(mux, s)
|
||||
networkRoutes(mux, s)
|
||||
mux.HandleFunc("/traffic", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.TrafficSettings().Enabled {
|
||||
http.Error(w, "Карта трафика отключена. Включите её в настройках Dashboard.", 404)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(trafficHTML))
|
||||
})
|
||||
mux.HandleFunc("/api/traffic", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
}
|
||||
if !s.TrafficSettings().Enabled {
|
||||
http.Error(w, "Карта отключена", 404)
|
||||
return
|
||||
}
|
||||
view, err := s.TrafficView(r.URL.Query().Get("domain"), r.URL.Query().Get("kind"), r.URL.Query().Get("external") == "1", r.URL.Query().Get("hideMonitoring") == "1")
|
||||
writeJSON(w, view, err)
|
||||
})
|
||||
mux.HandleFunc("/api/traffic/world", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Write(trafficWorld)
|
||||
})
|
||||
mux.HandleFunc("/api/agent/traffic", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
}
|
||||
id, secret, ok := agentCredentials(r)
|
||||
if !ok || !s.AuthenticateAgent(id, secret) {
|
||||
http.Error(w, "Unauthorized", 401)
|
||||
return
|
||||
}
|
||||
settings := s.TrafficSettings()
|
||||
if !settings.Enabled || settings.AgentID != id {
|
||||
http.Error(w, "Источник отключён", 403)
|
||||
return
|
||||
}
|
||||
var batch TrafficBatch
|
||||
if json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&batch) != nil {
|
||||
http.Error(w, "Invalid traffic batch", 400)
|
||||
return
|
||||
}
|
||||
if err := s.SaveTraffic(id, batch); err != nil {
|
||||
http.Error(w, fmt.Sprint(err), 500)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]bool{"accepted": true}, nil)
|
||||
})
|
||||
}
|
||||
65
traffic_geo.go
Normal file
65
traffic_geo.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
_ "embed"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
)
|
||||
|
||||
//go:embed assets/world.json
|
||||
var trafficWorld []byte
|
||||
|
||||
//go:embed assets/dbip-country-lite-2026-10.mmdb.gz
|
||||
var trafficGeoCompressed []byte
|
||||
|
||||
const trafficGeoDate = "2026-10"
|
||||
|
||||
var trafficGeo struct {
|
||||
sync.Once
|
||||
db *maxminddb.Reader
|
||||
}
|
||||
|
||||
func trafficGeoReady() bool {
|
||||
trafficGeo.Do(func() {
|
||||
r, err := gzip.NewReader(bytes.NewReader(trafficGeoCompressed))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(r, 32<<20))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
trafficGeo.db, _ = maxminddb.FromBytes(data)
|
||||
})
|
||||
return trafficGeo.db != nil
|
||||
}
|
||||
func lookupTrafficCountry(ipText string) string {
|
||||
ip := net.ParseIP(ipText)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
|
||||
return "LAN"
|
||||
}
|
||||
if !ip.IsGlobalUnicast() {
|
||||
return ""
|
||||
}
|
||||
if !trafficGeoReady() {
|
||||
return ""
|
||||
}
|
||||
var record struct {
|
||||
Country struct {
|
||||
Code string `maxminddb:"iso_code"`
|
||||
} `maxminddb:"country"`
|
||||
}
|
||||
if trafficGeo.db.Lookup(ip, &record) != nil {
|
||||
return ""
|
||||
}
|
||||
return record.Country.Code
|
||||
}
|
||||
76
traffic_monitoring.go
Normal file
76
traffic_monitoring.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Domain/path restrictions describe HTTP probes only. An IP-only rule also
|
||||
// matches conntrack initiators; a destination match never hides other clients.
|
||||
type MonitoringRule struct {
|
||||
IP string `json:"ip"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
PathPrefix string `json:"pathPrefix,omitempty"`
|
||||
}
|
||||
|
||||
func validateMonitoringRules(rules []MonitoringRule) ([]MonitoringRule, error) {
|
||||
if len(rules) > 32 {
|
||||
return nil, fmt.Errorf("Не более 32 правил мониторинга")
|
||||
}
|
||||
result := make([]MonitoringRule, 0, len(rules))
|
||||
for i, r := range rules {
|
||||
ip := net.ParseIP(strings.TrimSpace(r.IP))
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("Правило %d: нужен точный IPv4/IPv6-адрес", i+1)
|
||||
}
|
||||
r.IP = ip.String()
|
||||
r.Domain = strings.ToLower(strings.TrimSpace(r.Domain))
|
||||
r.PathPrefix = strings.TrimSpace(r.PathPrefix)
|
||||
if len(r.Domain) > 253 || strings.ContainsAny(r.Domain, " /|\t\r\n") || len(r.PathPrefix) > 256 || strings.ContainsAny(r.PathPrefix, "|\r\n") || (r.PathPrefix != "" && !strings.HasPrefix(r.PathPrefix, "/")) {
|
||||
return nil, fmt.Errorf("Правило %d: проверьте домен и префикс пути (начинается с /)", i+1)
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func isMonitoringRequest(e TrafficEvent, rules []MonitoringRule) bool {
|
||||
if e.Kind != "request" {
|
||||
return false
|
||||
}
|
||||
for _, r := range rules {
|
||||
if e.IP == r.IP && (r.Domain == "" || strings.EqualFold(e.Domain, r.Domain)) && strings.HasPrefix(e.Path, r.PathPrefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func isMonitoringFlow(f NetworkFlow, rules []MonitoringRule) bool {
|
||||
for _, r := range rules {
|
||||
if net.ParseIP(f.Source).Equal(net.ParseIP(r.IP)) && r.Domain == "" && r.PathPrefix == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func monitoringSQL(rules []MonitoringRule) (string, []any) {
|
||||
var parts []string
|
||||
var args []any
|
||||
for _, r := range rules {
|
||||
p := "(ip=?"
|
||||
args = append(args, r.IP)
|
||||
if r.Domain != "" {
|
||||
p += " AND lower(domain)=?"
|
||||
args = append(args, r.Domain)
|
||||
}
|
||||
if r.PathPrefix != "" {
|
||||
p += " AND substr(path,1,length(?))=?"
|
||||
args = append(args, r.PathPrefix, r.PathPrefix)
|
||||
}
|
||||
parts = append(parts, p+")")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return " AND NOT (kind='request' AND (" + strings.Join(parts, " OR ") + "))", args
|
||||
}
|
||||
140
traffic_monitoring_test.go
Normal file
140
traffic_monitoring_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMonitoringRulesValidation(t *testing.T) {
|
||||
rules, err := validateMonitoringRules([]MonitoringRule{{IP: "2001:db8:0:0::1", Domain: "EXAMPLE.org", PathPrefix: "/health"}})
|
||||
if err != nil || rules[0].IP != "2001:db8::1" || rules[0].Domain != "example.org" {
|
||||
t.Fatalf("normalization: %+v %v", rules, err)
|
||||
}
|
||||
for _, r := range []MonitoringRule{{IP: "192.168.0.0/24"}, {IP: "8.8.8.8", Domain: "https://example.org"}, {IP: "8.8.8.8", PathPrefix: "health"}} {
|
||||
if _, err := validateMonitoringRules([]MonitoringRule{r}); err == nil {
|
||||
t.Fatalf("bad rule accepted: %+v", r)
|
||||
}
|
||||
}
|
||||
if _, err := validateMonitoringRules(make([]MonitoringRule, 33)); err == nil {
|
||||
t.Fatal("unbounded rules")
|
||||
}
|
||||
}
|
||||
func TestMonitoringFiltersKeepHistoryAndSecurityEvents(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
rules := []MonitoringRule{{IP: "192.168.0.10"}, {IP: "8.8.8.8", Domain: "example.org", PathPrefix: "/health"}}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, MonitoringRules: rules})
|
||||
now := time.Now().Unix()
|
||||
batch := TrafficBatch{Events: []TrafficEvent{
|
||||
{ID: "probe", Time: now, Kind: "request", IP: "192.168.0.10", Domain: "other.org", Path: "/"},
|
||||
{ID: "health", Time: now, Kind: "request", IP: "8.8.8.8", Domain: "EXAMPLE.org", Path: "/health/live"},
|
||||
{ID: "visitor", Time: now, Kind: "request", IP: "8.8.8.8", Domain: "example.org", Path: "/login"},
|
||||
{ID: "other-domain", Time: now, Kind: "request", IP: "8.8.8.8", Domain: "other.org", Path: "/health"},
|
||||
{ID: "alert", Time: now, Kind: "alert", IP: "192.168.0.10"},
|
||||
{ID: "ban", Time: now, Kind: "decision", IP: "192.168.0.10"},
|
||||
}}
|
||||
if err := s.SaveTraffic(a.ID, batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, err := s.TrafficView("", "", false)
|
||||
if err != nil || all.Requests != 4 {
|
||||
t.Fatalf("all: %+v %v", all, err)
|
||||
}
|
||||
tagged := 0
|
||||
for _, e := range all.Events {
|
||||
if e.Monitoring {
|
||||
tagged++
|
||||
}
|
||||
}
|
||||
if tagged != 2 {
|
||||
t.Fatalf("tags %d", tagged)
|
||||
}
|
||||
filtered, err := s.TrafficView("", "", false, true)
|
||||
if err != nil || filtered.Requests != 2 || filtered.Alerts != 1 || filtered.Decisions != 1 || len(filtered.Events) != 4 {
|
||||
t.Fatalf("filter: %+v %v", filtered, err)
|
||||
}
|
||||
if len(filtered.Series) != 1 || filtered.Series[0].Count != 2 {
|
||||
t.Fatalf("graph leaks probes: %+v", filtered.Series)
|
||||
}
|
||||
var count int
|
||||
s.db.QueryRow("SELECT COUNT(*) FROM traffic_events").Scan(&count)
|
||||
if count != 6 {
|
||||
t.Fatal("history deleted")
|
||||
}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, MonitoringRules: []MonitoringRule{}})
|
||||
filtered, err = s.TrafficView("", "", false, true)
|
||||
if err != nil || filtered.Requests != 4 {
|
||||
t.Fatal("cleared rules not applied retroactively")
|
||||
}
|
||||
}
|
||||
func TestMonitoringPrefixIsLiteralAndDefaultCanBeCleared(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
if len(s.TrafficSettings().MonitoringRules) != 1 {
|
||||
t.Fatal("missing suggested rule")
|
||||
}
|
||||
rules := []MonitoringRule{{IP: "8.8.8.8", PathPrefix: "/health_%"}}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, MonitoringRules: rules})
|
||||
s.SaveTraffic(a.ID, TrafficBatch{Events: []TrafficEvent{{ID: "x", Time: time.Now().Unix(), Kind: "request", IP: "8.8.8.8", Path: "/health_ok"}}})
|
||||
v, err := s.TrafficView("", "", false, true)
|
||||
if err != nil || v.Requests != 1 {
|
||||
t.Fatal("prefix used as wildcard")
|
||||
}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID})
|
||||
if len(s.TrafficSettings().MonitoringRules) != 0 {
|
||||
t.Fatal("cannot clear default")
|
||||
}
|
||||
}
|
||||
func TestNetworkMapAndMonitoringSourceOnly(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
rules := []MonitoringRule{{IP: "192.168.0.10"}, {IP: "8.8.8.8", Domain: "example.org"}}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, NetworkEnabled: true, AgentID: a.ID, MonitoringRules: rules})
|
||||
incoming, _ := parseNetworkFlow(networkNATLine)
|
||||
probe := incoming
|
||||
probe.Source = "192.168.0.10"
|
||||
outgoing := incoming
|
||||
outgoing.Source = "192.168.0.12"
|
||||
outgoing.Destination = "1.1.1.1"
|
||||
outgoing.Protocol = "udp"
|
||||
local := incoming
|
||||
local.Source = "192.168.0.15"
|
||||
local.Destination = "192.168.0.10"
|
||||
local.Protocol = "icmpv6"
|
||||
s.SaveNetwork(a.ID, NetworkSnapshot{At: time.Now().Unix(), Flows: []NetworkFlow{incoming, probe, outgoing, local}})
|
||||
snapshot, err := s.ReadNetworkSnapshot(a.ID, rules, true)
|
||||
if err != nil || len(snapshot.Flows) != 3 || snapshot.MonitoringHidden != 1 {
|
||||
t.Fatalf("source-only: %+v %v", snapshot, err)
|
||||
}
|
||||
mapped := makeNetworkMap(snapshot, false)
|
||||
if mapped.TCP != 1 || mapped.UDP != 1 || mapped.ICMP != 1 || mapped.Unmapped != 1 || len(mapped.Points) != 2 {
|
||||
t.Fatalf("map: %+v", mapped)
|
||||
}
|
||||
for _, p := range mapped.Points {
|
||||
if p.Protocol == "udp" && p.Direction != "out" {
|
||||
t.Fatal("outbound direction lost")
|
||||
}
|
||||
if p.Protocol == "tcp" && p.Direction != "in" {
|
||||
t.Fatal("inbound direction lost")
|
||||
}
|
||||
}
|
||||
mapped = makeNetworkMap(snapshot, true)
|
||||
if mapped.ICMP != 0 {
|
||||
t.Fatal("LAN flow leaked")
|
||||
}
|
||||
view, err := s.TrafficView("example.org", "alert", false, true)
|
||||
if err != nil || view.Network == nil || view.Network.TCP != 1 {
|
||||
t.Fatal("HTTP filters incorrectly hide network layer")
|
||||
}
|
||||
encoded, _ := json.Marshal(view)
|
||||
if len(encoded) == 0 {
|
||||
t.Fatal("map not serializable")
|
||||
}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID})
|
||||
view, _ = s.TrafficView("", "", false)
|
||||
if view.Network != nil {
|
||||
t.Fatal("disabled network leaked")
|
||||
}
|
||||
v6 := NetworkFlow{Source: "2001:db8:0:0::1"}
|
||||
if !isMonitoringFlow(v6, []MonitoringRule{{IP: "2001:db8::1"}}) {
|
||||
t.Fatal("IPv6 representation mismatch")
|
||||
}
|
||||
}
|
||||
107
traffic_network_map.go
Normal file
107
traffic_network_map.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type NetworkMapPoint struct {
|
||||
Country string `json:"country"`
|
||||
Protocol string `json:"protocol"`
|
||||
Direction string `json:"direction"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
type NetworkMapView struct {
|
||||
At int64 `json:"at"`
|
||||
ReceivedAt int64 `json:"receivedAt"`
|
||||
TCP int `json:"tcp"`
|
||||
UDP int `json:"udp"`
|
||||
ICMP int `json:"icmp"`
|
||||
Unmapped int `json:"unmapped"`
|
||||
MonitoringHidden int `json:"monitoringHidden"`
|
||||
Limited bool `json:"limited"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Points []NetworkMapPoint `json:"points"`
|
||||
}
|
||||
|
||||
func (s *Store) ReadNetworkSnapshot(agent string, rules []MonitoringRule, hide bool) (NetworkSnapshot, error) {
|
||||
var raw, name string
|
||||
err := s.db.QueryRow(`SELECT n.snapshot,a.name FROM network_sources n JOIN agents a ON a.id=n.agent WHERE n.agent=?`, agent).Scan(&raw, &name)
|
||||
if err == sql.ErrNoRows {
|
||||
return NetworkSnapshot{Flows: []NetworkFlow{}, Error: "Ожидание агента v0.21.0 или новее"}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return NetworkSnapshot{}, err
|
||||
}
|
||||
var snapshot NetworkSnapshot
|
||||
if err = json.Unmarshal([]byte(raw), &snapshot); err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
snapshot.AgentName = name
|
||||
flows := make([]NetworkFlow, 0, len(snapshot.Flows))
|
||||
for _, f := range snapshot.Flows {
|
||||
f.Monitoring = isMonitoringFlow(f, rules)
|
||||
if hide && f.Monitoring {
|
||||
snapshot.MonitoringHidden++
|
||||
continue
|
||||
}
|
||||
flows = append(flows, f)
|
||||
}
|
||||
snapshot.Flows = flows
|
||||
return snapshot, nil
|
||||
}
|
||||
func makeNetworkMap(snapshot NetworkSnapshot, external bool) NetworkMapView {
|
||||
v := NetworkMapView{At: snapshot.At, ReceivedAt: snapshot.ReceivedAt, Limited: snapshot.Limited, Error: snapshot.Error, MonitoringHidden: snapshot.MonitoringHidden, Points: []NetworkMapPoint{}}
|
||||
if snapshot.ParseErrors > 0 {
|
||||
v.Error += fmt.Sprintf(" Нераспознанных строк: %d", snapshot.ParseErrors)
|
||||
}
|
||||
points := map[string]*NetworkMapPoint{}
|
||||
for _, f := range snapshot.Flows {
|
||||
if external && !f.External {
|
||||
continue
|
||||
}
|
||||
protocol := f.Protocol
|
||||
if protocol == "icmpv6" {
|
||||
protocol = "icmp"
|
||||
}
|
||||
switch protocol {
|
||||
case "tcp":
|
||||
v.TCP++
|
||||
case "udp":
|
||||
v.UDP++
|
||||
case "icmp":
|
||||
v.ICMP++
|
||||
}
|
||||
country, direction := f.Country, "in"
|
||||
if !networkExternalIP(f.Source) {
|
||||
if networkExternalIP(f.Destination) {
|
||||
country, direction = f.DestinationCountry, "out"
|
||||
} else {
|
||||
v.Unmapped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
if country == "" || country == "LAN" {
|
||||
v.Unmapped++
|
||||
continue
|
||||
}
|
||||
key := country + "/" + protocol + "/" + direction
|
||||
if points[key] == nil {
|
||||
points[key] = &NetworkMapPoint{Country: country, Protocol: protocol, Direction: direction}
|
||||
}
|
||||
points[key].Count++
|
||||
}
|
||||
for _, p := range points {
|
||||
v.Points = append(v.Points, *p)
|
||||
}
|
||||
sort.Slice(v.Points, func(i, j int) bool {
|
||||
a, b := v.Points[i], v.Points[j]
|
||||
if a.Count != b.Count {
|
||||
return a.Count > b.Count
|
||||
}
|
||||
return a.Country+a.Protocol+a.Direction < b.Country+b.Protocol+b.Direction
|
||||
})
|
||||
return v
|
||||
}
|
||||
92
traffic_preview_test.go
Normal file
92
traffic_preview_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Opt-in local visual fixture; never included in production binaries.
|
||||
func TestTrafficPreview(t *testing.T) {
|
||||
if os.Getenv("DASHBOARD_TRAFFIC_PREVIEW") != "1" {
|
||||
t.Skip("manual visual preview")
|
||||
}
|
||||
s, a := trafficFixture(t)
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, NetworkEnabled: true, MonitoringRules: []MonitoringRule{{IP: "192.168.0.10"}}, AgentID: a.ID, ServerCountry: "RU"})
|
||||
mux := http.NewServeMux()
|
||||
trafficRoutes(mux, s)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(dashboardHTML))
|
||||
})
|
||||
mux.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) { items, _ := s.Agents(); writeJSON(w, items, nil) })
|
||||
go func() {
|
||||
i := 0
|
||||
for {
|
||||
i++
|
||||
events := []TrafficEvent{}
|
||||
for j, ip := range []string{"8.8.8.8", "1.1.1.1", "20.194.96.114", "192.168.0.10", "77.83.39.94", "51.158.145.102", "123.56.127.23"} {
|
||||
kind := "request"
|
||||
if j == 4 {
|
||||
kind = "alert"
|
||||
}
|
||||
if j == 5 {
|
||||
kind = "decision"
|
||||
}
|
||||
events = append(events, TrafficEvent{ID: fmt.Sprintf("preview-%d-%d", i, j), Time: time.Now().Unix(), Kind: kind, IP: ip, Domain: "demo.example", Method: "GET", Path: "/demo", Status: 200, Reason: "ДЕМО · crowdsecurity/http-probing"})
|
||||
}
|
||||
flows := []NetworkFlow{}
|
||||
for k := 0; k < 32; k++ {
|
||||
f, _ := parseNetworkFlow(networkNATLine)
|
||||
f.SourcePort = 51000 + k
|
||||
f.ReplyDestinationPort = f.SourcePort
|
||||
f.Source = []string{"8.8.8.8", "1.1.1.1", "20.194.96.114", "192.168.0.10", "51.158.145.102", "2001:4860:4860::8888"}[k%6]
|
||||
f.ReplyDestination = f.Source
|
||||
if k%3 == 0 {
|
||||
f.Protocol = "udp"
|
||||
f.State = "ASSURED"
|
||||
f.DestinationPort = 53
|
||||
f.ReplySourcePort = 53
|
||||
}
|
||||
if k%5 == 0 && f.Protocol == "tcp" {
|
||||
f.State = "SYN_SENT"
|
||||
}
|
||||
if k%7 == 0 {
|
||||
f.Protocol = "icmp"
|
||||
f.State = "TRACKED"
|
||||
f.SourcePort = 0
|
||||
f.DestinationPort = 0
|
||||
f.ReplySourcePort = 0
|
||||
f.ReplyDestinationPort = 0
|
||||
}
|
||||
if k == 11 {
|
||||
f.Source = "192.168.0.12"
|
||||
f.Destination = "1.1.1.1"
|
||||
f.ReplySource = "1.1.1.1"
|
||||
f.ReplyDestination = "192.168.0.12"
|
||||
}
|
||||
flows = append(flows, f)
|
||||
}
|
||||
s.SaveNetwork(a.ID, NetworkSnapshot{At: time.Now().Unix(), Flows: flows, FirewallAt: time.Now().Unix(), Firewall: []FirewallCounter{{Name: "DEMO ip/crowdsec/drop #3", Packets: 12345, Bytes: 654321}}})
|
||||
s.SaveTraffic(a.ID, TrafficBatch{Events: events, Files: 22, StartedAt: time.Now().Add(-time.Hour).Unix(), CrowdSecAt: time.Now().Unix()})
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
}()
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/network" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(strings.Replace(networkHTML, "PROXMOX / NETWORK OBSERVATORY", "ДЕМО / ИСКУССТВЕННЫЕ ДАННЫЕ", 1)))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/traffic" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(strings.Replace(trafficHTML, "PROXMOX / LIVE OBSERVATORY", "ДЕМО / ИСКУССТВЕННЫЕ ДАННЫЕ", 1)))
|
||||
return
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
t.Fatal(http.ListenAndServe("127.0.0.1:19105", handler))
|
||||
}
|
||||
80
traffic_settings.go
Normal file
80
traffic_settings.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type TrafficSettings struct {
|
||||
MonitoringRules []MonitoringRule `json:"monitoringRules"`
|
||||
NetworkEnabled bool `json:"networkEnabled"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AgentID string `json:"agentId"`
|
||||
ServerCountry string `json:"serverCountry"`
|
||||
}
|
||||
|
||||
func (s *Store) TrafficSettings() TrafficSettings {
|
||||
var c TrafficSettings
|
||||
s.loadJSONSetting("traffic", &c)
|
||||
if c.MonitoringRules == nil {
|
||||
c.MonitoringRules = []MonitoringRule{{IP: "192.168.0.10"}}
|
||||
}
|
||||
return c
|
||||
}
|
||||
func (s *Store) SaveTrafficSettings(c TrafficSettings) (TrafficSettings, error) {
|
||||
rules, err := validateMonitoringRules(c.MonitoringRules)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.MonitoringRules = rules
|
||||
c.AgentID = strings.TrimSpace(c.AgentID)
|
||||
c.ServerCountry = strings.ToUpper(strings.TrimSpace(c.ServerCountry))
|
||||
if c.Enabled {
|
||||
var count int
|
||||
if s.db.QueryRow(`SELECT COUNT(*) FROM agents WHERE id=?`, c.AgentID).Scan(&count) != nil || count != 1 {
|
||||
return c, errors.New("Выберите зарегистрированный агент")
|
||||
}
|
||||
}
|
||||
if c.ServerCountry != "" {
|
||||
var countries []struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
json.Unmarshal(trafficWorld, &countries)
|
||||
found := false
|
||||
for _, country := range countries {
|
||||
if country.Code == c.ServerCountry {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return c, errors.New("Неизвестная страна сервера")
|
||||
}
|
||||
}
|
||||
return c, s.saveJSONSetting("traffic", c)
|
||||
}
|
||||
func trafficSettingsRoute(mux *http.ServeMux, s *Store) {
|
||||
mux.HandleFunc("/api/settings/traffic", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
writeJSON(w, s.TrafficSettings(), nil)
|
||||
case "PUT":
|
||||
var c TrafficSettings
|
||||
if json.NewDecoder(io.LimitReader(r.Body, 32<<10)).Decode(&c) != nil {
|
||||
http.Error(w, "Некорректные настройки", 400)
|
||||
return
|
||||
}
|
||||
value, err := s.SaveTrafficSettings(c)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
writeJSON(w, value, nil)
|
||||
default:
|
||||
w.WriteHeader(405)
|
||||
}
|
||||
})
|
||||
}
|
||||
217
traffic_test.go
Normal file
217
traffic_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func trafficFixture(t *testing.T) (*Store, agentConfig) {
|
||||
t.Helper()
|
||||
s, err := openStore(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
token, err := s.CreateAgentEnrollmentToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := s.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "NPM test", VMID: 102})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, a
|
||||
}
|
||||
func npmTestLine(ip, path string) string {
|
||||
return fmt.Sprintf("[%s] - - 301 - GET https example.test \"%s\" [Client %s] [Length 166] [Gzip -] [Sent-to 192.168.0.9] \"-\" \"-\"\n", time.Now().UTC().Format("02/Jan/2006:15:04:05 -0700"), path, ip)
|
||||
}
|
||||
func TestNPMTrafficFormats(t *testing.T) {
|
||||
for _, status := range []string{"- 200 200", "- - 301", "- 502 502", "- - 401"} {
|
||||
line := strings.Replace(npmTestLine("2001:4860:4860::8888", "/login?token=secret#fragment"), "- - 301", status, 1)
|
||||
e, ok := parseNPMTraffic(line)
|
||||
if !ok || e.Kind != "request" || e.Path != "/login" || e.Domain != "example.test" || e.IP != "2001:4860:4860::8888" {
|
||||
t.Fatalf("bad parse: %+v %v", e, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := parseNPMTraffic("broken"); ok {
|
||||
t.Fatal("accepted invalid log")
|
||||
}
|
||||
}
|
||||
func TestCrowdTrafficDoesNotInventBlockedRequests(t *testing.T) {
|
||||
raw := `[{"id":12,"uuid":"alert-uuid","created_at":"2026-10-09T16:16:43Z","source":{"ip":"20.194.96.114"},"scenario":"crowdsecurity/http-admin-interface-probing","events":[{"meta":[{"key":"target_fqdn","value":"example.test"}]}],"decisions":[{"id":42,"value":"20.194.96.114","type":"ban","simulated":false},{"id":43,"value":"1.1.1.1","type":"ban","simulated":true}]}]`
|
||||
events, err := parseCrowdTraffic([]byte(raw))
|
||||
if err != nil || len(events) != 2 {
|
||||
t.Fatalf("%+v %v", events, err)
|
||||
}
|
||||
if events[0].Kind != "alert" || events[1].Kind != "decision" || events[1].Domain != "example.test" {
|
||||
t.Fatalf("bad categories %+v", events)
|
||||
}
|
||||
simulated := strings.Replace(raw, `"id":12`, `"simulated":true,"id":12`, 1)
|
||||
events, err = parseCrowdTraffic([]byte(simulated))
|
||||
if err != nil || len(events) != 0 {
|
||||
t.Fatal("simulation counted as live")
|
||||
}
|
||||
}
|
||||
func TestTrafficLogCheckpointRotationAndPartialLines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "proxy-host-1_access.log")
|
||||
os.WriteFile(path, []byte(npmTestLine("1.1.1.1", "/old")), 0600)
|
||||
state := trafficAgentState{Cursors: map[string]trafficCursor{}}
|
||||
if _, err := state.readLogs(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.Pending) != 0 {
|
||||
t.Fatal("imported old logs on first activation")
|
||||
}
|
||||
appendFile := func(path, s string) {
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.WriteString(s)
|
||||
f.Close()
|
||||
}
|
||||
line := npmTestLine("8.8.8.8", "/new")
|
||||
appendFile(path, line[:len(line)/2])
|
||||
state.readLogs(dir)
|
||||
if len(state.Pending) != 0 {
|
||||
t.Fatal("partial line emitted")
|
||||
}
|
||||
appendFile(path, line[len(line)/2:])
|
||||
state.readLogs(dir)
|
||||
if len(state.Pending) != 1 {
|
||||
t.Fatal("complete line missing")
|
||||
}
|
||||
id := state.Pending[0].ID
|
||||
state.Pending = nil
|
||||
checkpoint := filepath.Join(dir, "state.json")
|
||||
if err := saveTrafficAgentState(checkpoint, state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(checkpoint)
|
||||
var restored trafficAgentState
|
||||
json.Unmarshal(raw, &restored)
|
||||
os.Rename(path, path+".1")
|
||||
appendFile(path+".1", npmTestLine("9.9.9.9", "/rotated"))
|
||||
os.WriteFile(path, []byte(npmTestLine("1.0.0.1", "/fresh")), 0600)
|
||||
restored.readLogs(dir)
|
||||
if len(restored.Pending) != 2 {
|
||||
t.Fatalf("rotation lost events %+v", restored)
|
||||
}
|
||||
for _, e := range restored.Pending {
|
||||
if e.ID == id {
|
||||
t.Fatal("duplicate id")
|
||||
}
|
||||
}
|
||||
restored.Pending = nil
|
||||
restored.readLogs(dir)
|
||||
if len(restored.Pending) != 0 {
|
||||
t.Fatal("re-read same lines")
|
||||
}
|
||||
}
|
||||
func TestTrafficStoreDedupFiltersAndSanitization(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
if s.TrafficSettings().Enabled {
|
||||
t.Fatal("enabled by default")
|
||||
}
|
||||
if _, err := s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
batch := TrafficBatch{Files: 22, Events: []TrafficEvent{{ID: "1", Time: now, Kind: "request", IP: "1.1.1.1", Domain: "example.test", Path: "/login?secret=x", Status: 502}, {ID: "2", Time: now, Kind: "request", IP: "192.168.0.10", Domain: "example.test", Status: 200}, {ID: "3", Time: now, Kind: "alert", IP: "1.1.1.1", Reason: "probe"}, {ID: "4", Time: now, Kind: "decision", IP: "1.1.1.1", Reason: "ban"}}}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := s.SaveTraffic(a.ID, batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
v, err := s.TrafficView("", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Requests != 2 || v.UniqueIPs != 2 || v.Alerts != 1 || v.Decisions != 1 || len(v.Events) != 4 {
|
||||
t.Fatalf("wrong counts: %+v", v)
|
||||
}
|
||||
for _, e := range v.Events {
|
||||
if strings.Contains(e.Path, "secret") {
|
||||
t.Fatal("secret saved")
|
||||
}
|
||||
}
|
||||
v, err = s.TrafficView("example.test", "request", true)
|
||||
if err != nil || v.Requests != 1 {
|
||||
t.Fatalf("bad filter %+v %v", v, err)
|
||||
}
|
||||
batch.Events = []TrafficEvent{{ID: "invalid", Kind: "blocked", IP: "1.1.1.1", Time: now}}
|
||||
if s.SaveTraffic(a.ID, batch) == nil {
|
||||
t.Fatal("accepted invented block event")
|
||||
}
|
||||
}
|
||||
func TestTrafficAPIOptInAndSourceAuthorization(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
mux := http.NewServeMux()
|
||||
trafficRoutes(mux, s)
|
||||
get := func(path string) int {
|
||||
r := httptest.NewRecorder()
|
||||
mux.ServeHTTP(r, httptest.NewRequest("GET", path, nil))
|
||||
return r.Code
|
||||
}
|
||||
if get("/traffic") != 404 || get("/api/traffic") != 404 {
|
||||
t.Fatal("disabled page exposed")
|
||||
}
|
||||
send := func(secret string) int {
|
||||
r := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/agent/traffic", strings.NewReader(`{"events":[]}`))
|
||||
req.Header.Set("Authorization", "Bearer "+a.ID+"."+secret)
|
||||
mux.ServeHTTP(r, req)
|
||||
return r.Code
|
||||
}
|
||||
if send("wrong") != 401 || send(a.Secret) != 403 {
|
||||
t.Fatal("disabled/unauthorized ingestion accepted")
|
||||
}
|
||||
if _, err := s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: "missing"}); err == nil {
|
||||
t.Fatal("unknown agent accepted")
|
||||
}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, ServerCountry: "RU"})
|
||||
if send(a.Secret) != 200 || get("/traffic") != 200 {
|
||||
t.Fatal("selected agent not working")
|
||||
}
|
||||
token, _ := s.CreateAgentEnrollmentToken()
|
||||
other, _ := s.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "Other"})
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: other.ID})
|
||||
if send(a.Secret) != 403 {
|
||||
t.Fatal("old source still accepted")
|
||||
}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: false, AgentID: other.ID})
|
||||
if get("/api/traffic") != 404 {
|
||||
t.Fatal("disable failed")
|
||||
}
|
||||
}
|
||||
func TestTrafficGeoDatabase(t *testing.T) {
|
||||
if !trafficGeoReady() {
|
||||
t.Fatal("embedded database invalid")
|
||||
}
|
||||
if lookupTrafficCountry("192.168.0.10") != "LAN" {
|
||||
t.Fatal("LAN mapped to country")
|
||||
}
|
||||
if c := lookupTrafficCountry("8.8.8.8"); len(c) != 2 {
|
||||
t.Fatalf("bad public IP geo: %q", c)
|
||||
}
|
||||
}
|
||||
func TestTrafficCheckpointKeepsPendingUntilAcknowledged(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
s := trafficAgentState{Pending: []TrafficEvent{{ID: "retry-me", IP: "1.1.1.1"}}, Cursors: map[string]trafficCursor{"file": {Offset: 100}}}
|
||||
if err := saveTrafficAgentState(path, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if !bytes.Contains(raw, []byte("retry-me")) {
|
||||
t.Fatal("pending batch lost")
|
||||
}
|
||||
}
|
||||
6
traffic_web.go
Normal file
6
traffic_web.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package main
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed assets/traffic.html
|
||||
var trafficHTML string
|
||||
53
web.go
53
web.go
@@ -11,6 +11,7 @@ import (
|
||||
)
|
||||
|
||||
type dashboardMetrics struct {
|
||||
TrafficSettings TrafficSettings `json:"trafficSettings"`
|
||||
CPU CPUMetrics `json:"cpu"`
|
||||
Memory MemoryMetrics `json:"memory"`
|
||||
Storage StorageMetrics `json:"storage"`
|
||||
@@ -55,7 +56,7 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle
|
||||
}
|
||||
thresholds := store.Thresholds()
|
||||
metrics := dashboardMetrics{
|
||||
CPU: cpu, Memory: memory, Storage: storage,
|
||||
CPU: cpu, Memory: memory, Storage: storage, TrafficSettings: store.TrafficSettings(),
|
||||
Disks: diskCollector.collect(thresholds), Network: network,
|
||||
ZFS: zfsCollector.collect(), UPS: upsCollector.collect(),
|
||||
Guests: guestCollector.collect(), Backups: backupCollector.collect(), Maintenance: maintenanceCollector.collect(), Services: serviceCollector.collect(), SystemHealth: systemHealth.collect(), Update: readUpdateStatus(), Timestamp: time.Now(),
|
||||
@@ -105,7 +106,7 @@ func decorateNetworkInterfaces(network *NetworkMetrics, guests GuestsMetrics) {
|
||||
}
|
||||
}
|
||||
func interfaceVMID(name string) int {
|
||||
prefixes := []string{"tap", "veth", "fwbr", "fwpr", "fwln"}
|
||||
prefixes := []string{"tap", "veth"}
|
||||
for _, prefix := range prefixes {
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
continue
|
||||
@@ -125,6 +126,7 @@ func interfaceVMID(name string) int {
|
||||
|
||||
func routes(collector *cpuCollector, store *Store) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
trafficRoutes(mux, store)
|
||||
network, networkErr := newNetworkCollector("/proc/net/dev")
|
||||
disks := &diskCollector{}
|
||||
zfs := &zfsCollector{}
|
||||
@@ -281,7 +283,8 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
result := agentReportResponse{Accepted: true}
|
||||
trafficSettings := store.TrafficSettings()
|
||||
result := agentReportResponse{Accepted: true, NetworkEnabled: trafficSettings.Enabled && trafficSettings.NetworkEnabled && trafficSettings.AgentID == id, TrafficEnabled: trafficSettings.Enabled && trafficSettings.AgentID == id}
|
||||
result.Commands, _ = store.DispatchAgentActions(id)
|
||||
if report.Version != version && version != "dev" {
|
||||
manifest, _, manifestErr := currentAgentUpdate()
|
||||
@@ -585,7 +588,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<header><div><div class="eyebrow">Proxmox host telemetry</div><h1>Host overview</h1></div><div class="header-actions"><div class="status"><i class="dot" id="dot"></i><span id="status">Подключение…</span></div><button class="settings-button" id="managementButton" aria-label="Управление" title="Сервисы, агенты и плановые работы">⌘</button><button class="settings-button" id="settingsButton" aria-label="Настройки" title="Настройки Dashboard">⚙</button></div></header>
|
||||
<header><div><div class="eyebrow">Proxmox host telemetry</div><h1>Host overview</h1></div><div class="header-actions"><div class="status"><i class="dot" id="dot"></i><span id="status">Подключение…</span></div><a class="badge" id="trafficLink" hidden href="/traffic" style="text-decoration:none">◎ Карта трафика</a><button class="settings-button" id="managementButton" aria-label="Управление" title="Сервисы, агенты и плановые работы">⌘</button><button class="settings-button" id="settingsButton" aria-label="Настройки" title="Настройки Dashboard">⚙</button></div></header>
|
||||
<div class="maintenance-banner" id="maintenanceBanner" hidden><div><strong>Плановые работы</strong><small id="maintenanceBannerReason"></small></div><span id="maintenanceBannerEnds"></span></div>
|
||||
<section class="cards">
|
||||
<button class="card alert-center" data-card="alerts" data-open="alertsDialog" aria-label="Открыть центр уведомлений">
|
||||
@@ -597,7 +600,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
||||
<button class="card" data-card="cpu" data-open="cpuDialog" aria-label="Открыть подробности процессора">
|
||||
<div class="card-head"><div><div class="label">CPU</div><div class="name" id="cpuName">Загрузка данных…</div></div><div class="percent"><span id="cpuUsage">—</span><small>%</small></div></div>
|
||||
<div class="bar"><div class="fill" id="cpuBar"></div></div>
|
||||
<div class="quick"><div><span class="label">Средняя температура</span><strong id="cpuTemp">—</strong></div><div><span class="label">Ядра / потоки</span><strong id="cpuTopology">—</strong></div></div>
|
||||
<div class="quick"><div><span class="label">Макс. температура</span><strong id="cpuTemp">—</strong></div><div><span class="label">Ядра / потоки</span><strong id="cpuTopology">—</strong></div></div>
|
||||
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
|
||||
</button>
|
||||
<button class="card memory" data-card="memory" data-open="memoryDialog" aria-label="Открыть подробности оперативной памяти">
|
||||
@@ -809,7 +812,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
||||
|
||||
<dialog id="settingsDialog">
|
||||
<div class="modal-head"><div><div class="label">Dashboard</div><h2>Настройки</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||||
<div class="settings-tabs"><button class="settings-tab active" data-settings-tab="appearance">Интерфейс</button><button class="settings-tab" data-settings-tab="alerts">Пороги</button><button class="settings-tab" data-settings-tab="email">Почта</button><button class="settings-tab" data-settings-tab="updates">Обновления</button></div>
|
||||
<div class="settings-tabs"><button class="settings-tab active" data-settings-tab="appearance">Интерфейс</button><button class="settings-tab" data-settings-tab="alerts">Пороги</button><button class="settings-tab" data-settings-tab="email">Почта</button><button class="settings-tab" data-settings-tab="traffic" id="trafficSettingsTab">Карта трафика</button><button class="settings-tab" data-settings-tab="updates">Обновления</button></div>
|
||||
<div class="settings-body settings-panel" data-settings-panel="appearance">
|
||||
<div class="settings-group"><div class="label">Тема оформления</div><div class="theme-options"><button class="theme-option" data-theme-choice="dark">Тёмная</button><button class="theme-option" data-theme-choice="light">Светлая</button></div></div>
|
||||
<div class="settings-group"><div class="label">Видимые карточки</div><div class="setting-list">
|
||||
@@ -876,6 +879,18 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
||||
</div></div>
|
||||
<button class="save-settings" id="saveEmail">Сохранить настройки почты</button><button class="theme-option" id="testEmail" style="width:100%;margin-top:9px">Отправить тестовое письмо</button><div class="settings-note" id="emailStatus">Пароль хранится в локальной базе сервера и не возвращается в браузер.</div>
|
||||
</div>
|
||||
<div class="settings-body settings-panel" data-settings-panel="traffic" hidden>
|
||||
<div class="settings-group"><div class="label">Карта трафика · Nginx Proxy Manager + CrowdSec</div>
|
||||
<label class="threshold-item"><span>Включить страницу и сбор трафика</span><input type="checkbox" id="trafficEnabled"></label>
|
||||
<label class="threshold-item"><span>TCP/UDP/ICMP и счётчики блокировок (агент v0.21.0+)</span><input type="checkbox" id="networkEnabled"></label>
|
||||
<p class="settings-note">Сетевой режим: текущая таблица conntrack, обновление ~5 с. Агент сам установит отсутствующий conntrack через apt-get на Debian/Ubuntu (root + systemd). nft нужен для счётчиков CrowdSec. Настройки firewall и ядра автоматически не меняются. Короткие потоки между снимками могут быть пропущены.</p>
|
||||
<label class="field"><span>Агент-источник</span><select id="trafficAgent"><option value="">Выберите агент</option></select></label>
|
||||
<label class="field"><span>Страна сервера на карте (необязательно)</span><select id="trafficCountry"><option value="">Не задана</option></select></label>
|
||||
<p class="settings-note">Сбор выполняется только выбранным агентом: access-логи /opt/npm/data/logs и локальный cscli. Изменить путь можно через DASHBOARD_NPM_LOG_DIR в окружении службы агента. При выключении сбор прекращается и ссылка на страницу скрывается. События карты не создают общих алертов и писем. История: до 24 часов и 200 000 событий.</p>
|
||||
<label class="field"><span>Правила мониторинга: IP | домен | префикс пути</span><textarea id="monitoringRules" rows="4" placeholder="192.168.0.10" style="width:100%;font:12px monospace;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:10px"></textarea></label>
|
||||
<p class="settings-note">Одно правило на строку, до 32. Домен и путь необязательны; внутри правила условия объединяются. IP без уточнений помечает HTTP-проверки и сетевые потоки этого инициатора. Домен/путь применяются только к HTTP. Скрытие — фильтр просмотра: история сохраняется, срабатывания и решения CrowdSec не скрываются. Пример: 192.168.0.10 | example.org | /health</p>
|
||||
<button class="save-settings" id="saveTrafficSettings">Сохранить</button><span id="trafficSettingsStatus" role="status"></span>
|
||||
</div></div>
|
||||
<div class="settings-body settings-panel" data-settings-panel="updates" hidden>
|
||||
<div class="detail-grid">
|
||||
<div class="detail"><span class="label">Установлена</span><strong id="updateCurrent">—</strong></div><div class="detail"><span class="label">Доступна</span><strong id="updateAvailable">—</strong></div><div class="detail"><span class="label">Режим</span><strong id="updateMode">—</strong></div>
|
||||
@@ -907,7 +922,14 @@ function renderServices(svc){const items=svc.services||[];currentServiceStatuses
|
||||
function renderMaintenanceTargets(){const signature=JSON.stringify([configuredServices.map(v=>[v.id,v.name]),maintenanceGuests.map(v=>[v.vmid,v.name,v.type]),maintenanceWindow.serviceIds||[],maintenanceWindow.vmids||[]]);if(signature===maintenanceTargetSignature)return;maintenanceTargetSignature=signature;const serviceIDs=new Set(maintenanceWindow.serviceIds||[]),vmids=new Set(maintenanceWindow.vmids||[]);el('maintenanceServices').innerHTML=configuredServices.length?configuredServices.map(v=>'<label class="setting-item"><input type="checkbox" data-maintenance-service="'+v.id+'" '+(serviceIDs.has(v.id)?'checked':'')+'> '+safe(v.name)+'</label>').join(''):'<span class="empty">Сервисы не добавлены.</span>';el('maintenanceGuests').innerHTML=maintenanceGuests.length?maintenanceGuests.map(v=>'<label class="setting-item"><input type="checkbox" data-maintenance-vm="'+v.vmid+'" '+(vmids.has(v.vmid)?'checked':'')+'> '+safe(v.name||('VMID '+v.vmid))+' · '+(v.type==='qemu'?'VM':'LXC')+'</label>').join(''):'<span class="empty">VM/LXC не найдены.</span>';toggleMaintenanceTargets()}
|
||||
function toggleMaintenanceTargets(){const disabled=el('maintenanceAll').checked;document.querySelectorAll('[data-maintenance-service],[data-maintenance-vm]').forEach(v=>v.disabled=disabled)}
|
||||
function applyMaintenanceState(window){maintenanceWindow=window||{};const active=!!maintenanceWindow.active,automatic=!!maintenanceWindow.automatic;el('maintenanceBanner').hidden=!active;el('maintenanceBannerReason').textContent=(automatic?'Автоматический режим · ':'')+(maintenanceWindow.reason||'');el('maintenanceBannerEnds').textContent=active?(automatic?'до завершения backup':'до '+new Date(maintenanceWindow.endsAt*1000).toLocaleString('ru-RU')):'';el('stopMaintenance').disabled=!active||automatic;el('maintenanceStatus').textContent=active?(automatic?'Автоматически включён на время backup выбранных VM/LXC и связанных сервисов.':'Активен до '+new Date(maintenanceWindow.endsAt*1000).toLocaleString('ru-RU')+' · '+maintenanceWindow.reason):'Режим выключен.';if(active&&!automatic){el('maintenanceReason').value=maintenanceWindow.reason||'';el('maintenanceDuration').value=Math.max(5,Math.ceil((maintenanceWindow.endsAt-Date.now()/1000)/60));el('maintenanceAll').checked=!!maintenanceWindow.allAlerts}renderMaintenanceTargets()}
|
||||
function renderNetworkGroups(interfaces){const groups=new Map();interfaces.forEach(v=>{const key=v.vmid?'guest-'+v.vmid:'host',group=groups.get(key)||{vmid:v.vmid,name:v.vmid?(v.guestName||('VMID '+v.vmid)):'Интерфейсы хоста',type:v.guestType||'Host',items:[]};group.items.push(v);groups.set(key,group)});el('networkInterfacesList').innerHTML=[...groups.values()].map(g=>{const primary=g.vmid?g.items.filter(v=>v.name.startsWith('tap')||v.name.startsWith('veth')):g.items,rx=primary.reduce((a,v)=>a+v.receivePerSec,0),tx=primary.reduce((a,v)=>a+v.transmitPerSec,0),total=g.items.reduce((a,v)=>a+v.receiveErrors+v.transmitErrors+v.receiveDrops+v.transmitDrops,0),fresh=g.items.reduce((a,v)=>a+(v.newErrors||0)+(v.newDrops||0),0),down=primary.some(v=>v.kind==='Physical'&&!v.carrier&&(v.master||v.receiveBytes+v.transmitBytes>0));return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(g.name)+'</div><div class="disk-meta">'+(g.vmid?'VMID '+g.vmid+' · ':'')+safe(g.type)+' · '+safe(g.items.map(v=>v.name+' ['+(v.kind||'Interface')+(v.speedMbps?' · '+v.speedMbps+'Mb/s':'')+(v.duplex?' · '+v.duplex:'')+(v.master?' → '+v.master:'')+']').join(', '))+'</div></div><span class="badge '+(fresh||down?'warn':'')+'"'+(fresh||down?' tabindex="0" data-reason="'+safe(down?'Нет carrier на используемом физическом интерфейсе':'За последний цикл счётчики выросли на '+fresh+'. Общий счётчик '+total+' включает старые события и сам по себе не означает текущую проблему.')+'"':'')+'>'+(down?'Нет линка':fresh?'Новые потери: '+fresh:'Сейчас без потерь')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Сейчас ↓</span><b>'+rate(rx)+'</b></div><div class="disk-stat"><span>Сейчас ↑</span><b>'+rate(tx)+'</b></div><div class="disk-stat"><span>Получено</span><b>'+size(primary.reduce((a,v)=>a+v.receiveBytes,0))+'</b></div><div class="disk-stat"><span>Передано</span><b>'+size(primary.reduce((a,v)=>a+v.transmitBytes,0))+'</b></div><div class="disk-stat"><span>Новые / всего errors+drops</span><b>'+fresh+' / '+total+'</b></div></div></article>'}).join('')}
|
||||
function renderNetworkGroups(interfaces){
|
||||
const groups=new Map();interfaces.forEach(v=>{const key=v.vmid?'guest-'+v.vmid:'host',group=groups.get(key)||{vmid:v.vmid,name:v.vmid?(v.guestName||('VMID '+v.vmid)):'Интерфейсы хоста',type:v.guestType||'Host',items:[]};group.items.push(v);groups.set(key,group)});
|
||||
el('networkInterfacesList').innerHTML=[...groups.values()].map(g=>{
|
||||
const primary=g.vmid?g.items.filter(v=>v.name.startsWith('tap')||v.name.startsWith('veth')):g.items,rx=primary.reduce((a,v)=>a+v.receivePerSec,0),tx=primary.reduce((a,v)=>a+v.transmitPerSec,0),fresh=primary.reduce((a,v)=>a+(v.newErrors||0)+(v.newFaults||0),0),down=primary.some(v=>v.linkDown);
|
||||
const counters=primary.map(v=>'<div class="disk-meta">'+safe(v.name)+': errors RX/TX '+v.receiveErrors+'/'+v.transmitErrors+' (+'+(v.newErrors||0)+'); RX drops '+v.receiveDrops+' (+'+(v.newReceiveDrops||0)+'); TX drops '+v.transmitDrops+' (+'+(v.newTransmitDrops||0)+')'+Object.entries(v.faultCounters||{}).map(([k,n])=>' · '+safe(k)+': '+n).join('')+'</div>').join('');
|
||||
return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(g.name)+'</div><div class="disk-meta">'+(g.vmid?'VMID '+g.vmid+' · ':'')+safe(g.type)+' · '+safe(g.items.map(v=>v.name+' ['+(v.kind||'Interface')+(v.speedMbps?' · '+v.speedMbps+'Mb/s':'')+(v.duplex?' · '+v.duplex:'')+(v.master?' → '+v.master:'')+']').join(', '))+'</div></div><span class="badge '+(fresh||down?'bad':'')+'">'+(down?'Нет линка':fresh?'Новые ошибки':'Нет новых ошибок')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Сейчас ↓</span><b>'+rate(rx)+'</b></div><div class="disk-stat"><span>Сейчас ↑</span><b>'+rate(tx)+'</b></div><div class="disk-stat"><span>Получено</span><b>'+size(primary.reduce((a,v)=>a+v.receiveBytes,0))+'</b></div><div class="disk-stat"><span>Передано</span><b>'+size(primary.reduce((a,v)=>a+v.transmitBytes,0))+'</b></div></div>'+counters+'<div class="disk-meta">RX/TX drops — информационные счётчики, сами по себе не означают сетевую аварию.'+(g.vmid?' Состояние гостя определяется по tap/veth.':' fwbr/fwpr/fwln относятся к инфраструктуре хоста.')+'</div></article>'
|
||||
}).join('')
|
||||
}
|
||||
function renderIOAttribution(disks){const active=disks.filter(d=>d.ioCause||(d.topConsumers||[]).length);el('ioAttributionList').innerHTML=active.length?active.map(d=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(d.name)+' · '+safe(d.ioCause||'Источник не определён')+'</div><div class="disk-meta">'+safe(d.ioCauseDetail||'Собираем статистику процессов')+'</div></div><span class="badge '+(d.ioConfidence>=80?'':'warn')+'" tabindex="0" data-reason="Уверенность учитывает совпадение активной задачи Proxmox и долю I/O процессов">'+(d.ioConfidence||0)+'%</span></div><div class="topology-items">'+(d.topConsumers||[]).slice(0,5).map(v=>'<span class="topology-chip">'+safe(v.kind)+' · '+safe(v.name)+' · '+fixed(v.sharePercent)+'% · ↓'+rate(v.readBytesPerSec)+' ↑'+rate(v.writeBytesPerSec)+'</span>').join('')+'</div></article>').join(''):'<span class="empty">Для атрибуции нужно несколько realtime-циклов.</span>'}
|
||||
const taskLabel=type=>({vzdump:'Backup',qmstart:'Запуск VM',qmstop:'Остановка VM',qmshutdown:'Выключение VM',qmsnapshot:'Snapshot VM',vzstart:'Запуск LXC',vzstop:'Остановка LXC',vzsnapshot:'Snapshot LXC',qmigrate:'Миграция',aptupdate:'Обновление пакетов'})[type]||type;
|
||||
function explainWarningBadges(){el('snapshotList').querySelectorAll('.badge.warn').forEach(v=>{v.tabIndex=0;v.dataset.reason='Snapshot старше 30 дней. Проверьте, нужен ли он: старые снимки могут занимать место в storage.'});el('taskList').querySelectorAll('.badge.bad').forEach(v=>{v.tabIndex=0;v.dataset.reason='Задача Proxmox завершилась не со статусом OK. Откройте её журнал в интерфейсе Proxmox для подробностей.'})}
|
||||
@@ -939,7 +961,7 @@ async function queueAgentAction(action,target){const labels={'system-update':'у
|
||||
async function watchAgentAction(id,status){for(let attempt=0;attempt<180;attempt++){await new Promise(resolve=>setTimeout(resolve,1000));const response=await fetch('/api/agent-actions');if(!response.ok)continue;const items=await response.json()||[],item=items.find(v=>v.id===id);if(!item)continue;status.textContent=item.status==='queued'?'Ожидаем агента…':item.status==='running'?'Выполняется на агенте…':item.status==='completed'?'Готово: '+(item.result||'успешно'):'Ошибка: '+(item.result||'команда не выполнена');if(item.status==='completed'||item.status==='failed'){status.className='settings-note '+(item.status==='failed'?'status-down':'status-up');document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=false);await loadAgentActions();return}}status.className='settings-note status-down';status.textContent='Агент не вернул результат за 3 минуты. Проверьте его состояние в истории действий.';document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=false)}
|
||||
async function loadAgentHistory(agent,hours=24){const kinds=['agent-resource','agent-swap'],responses=await Promise.all(kinds.map(kind=>fetch('/api/entity-history?kind='+kind+'&hours='+hours).then(r=>r.json()))),key=agent.id+' · ',resource=(responses[0]||[]).find(v=>v.name.startsWith(key)),swap=(responses[1]||[]).find(v=>v.name.startsWith(key)),points=new Map();(resource?.points||[]).forEach(p=>points.set(p.timestamp,{timestamp:p.timestamp,cpu:p.value1,ram:p.value2}));(swap?.points||[]).forEach(p=>{const item=points.get(p.timestamp)||{timestamp:p.timestamp};item.swap=p.value1;points.set(p.timestamp,item)});chartConfigs.agentHistoryChart={series:[{label:'CPU',color:'#55d58a',value:p=>p.cpu,format:percent},{label:'RAM',color:'#9d8cff',value:p=>p.ram,format:percent},{label:'Swap',color:'#ff78b8',value:p=>p.swap,format:percent}]};const canvas=el('agentHistoryChart');drawChart('agentHistoryChart',[...points.values()].sort((a,b)=>a.timestamp-b.timestamp));if(!canvas.dataset.agentHover){setupAgentHistoryHover(canvas);canvas.dataset.agentHover='1'}}
|
||||
function setupAgentHistoryHover(canvas){canvas.addEventListener('mousemove',event=>{const state=canvas._chartState,tooltip=el('agentHistoryDetails').querySelector('.chart-tooltip');if(!state||!tooltip)return;const rect=canvas.getBoundingClientRect(),x=event.clientX-rect.left,index=Math.max(0,Math.min(state.points.length-1,Math.round((x-state.pad.l)/state.pw*(state.points.length-1)))),point=state.points[index];tooltip.innerHTML='<div class="chart-tooltip-time">'+new Date(point.timestamp*1000).toLocaleString('ru-RU')+'</div>'+state.series.map(s=>'<div class="chart-tooltip-value"><span><i style="background:'+s.color+'"></i>'+s.label+'</span><strong>'+(s.value(point)==null?'—':s.format(s.value(point)))+'</strong></div>').join('');tooltip.classList.add('visible');tooltip.style.left=Math.min(x+12,canvas.clientWidth-tooltip.offsetWidth-10)+'px';tooltip.style.top='55px'});canvas.addEventListener('mouseleave',()=>el('agentHistoryDetails').querySelector('.chart-tooltip').classList.remove('visible'))}
|
||||
function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{available:false,pools:[]},u=x.ups||{available:false},g=x.guests||{available:false,guests:[]},b=x.backups||{available:false,items:[]},mt=x.maintenance||{},alerts=x.alerts||[],disks=x.disks||[],avg=c.temperatures.length?c.temperatures.reduce((a,t)=>a+t.celsius,0)/c.temperatures.length:null;
|
||||
function render(x){el('trafficLink').hidden=!x.trafficSettings?.enabled;const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{available:false,pools:[]},u=x.ups||{available:false},g=x.guests||{available:false,guests:[]},b=x.backups||{available:false,items:[]},mt=x.maintenance||{},alerts=x.alerts||[],disks=x.disks||[],avg=c.temperatureCelsius??null;
|
||||
currentServiceLinks=x.serviceLinks||[];
|
||||
lastDashboardEvent=Date.now();
|
||||
maintenanceGuests=(g.guests||[]).filter(v=>!v.template);applyMaintenanceState(x.maintenanceWindow||{});
|
||||
@@ -947,7 +969,7 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av
|
||||
const update=x.update||{},updating=['queued','installing'].includes(update.state);el('updateCurrent').textContent=update.currentVersion||'—';el('updateAvailable').textContent=update.availableVersion||'—';el('updateMode').textContent=update.mode==='auto'?'Автоматически':'Уведомлять';el('updateMessage').textContent=update.message||'Проверка ещё не выполнялась';el('updateChecked').textContent=update.checkedAt?'Последняя проверка: '+new Date(update.checkedAt).toLocaleString('ru-RU'):'Проверка запускается автоматически раз в сутки.';el('installUpdate').disabled=updating;el('installUpdate').textContent=updating?'Обновление выполняется…':'Проверить и установить обновление';
|
||||
const critical=alerts.filter(a=>a.severity==='critical').length,warnings=alerts.filter(a=>a.severity==='warning').length,infos=alerts.filter(a=>a.severity==='info').length;el('alertCount').textContent=alerts.length;el('alertCritical').textContent=critical;el('alertWarnings').textContent=warnings;el('alertHeadline').textContent=critical?'Требуется срочное внимание':warnings?'Есть предупреждения':infos?'Есть информационные события':'Система в норме';el('alertBar').style.width=(critical?100:warnings?65:infos?30:0)+'%';
|
||||
el('alertList').innerHTML=alerts.length?alerts.map(a=>'<article class="alert-item '+safe(a.severity)+'"><i class="alert-icon"></i><div><div class="alert-source">'+safe(a.source)+' · '+(a.severity==='critical'?'Критическое':a.severity==='warning'?'Предупреждение':'Информация')+(a.firstSeen?' · С '+new Date(a.firstSeen*1000).toLocaleString('ru-RU'):'')+'</div><div class="alert-title">'+safe(a.title)+'</div><div class="alert-message">'+safe(a.message)+'</div><button class="ack-alert" data-ack="'+a.eventId+'">Принято</button></div></article>').join(''):'<div class="all-good"><strong>Всё в порядке</strong><span>Новых неподтверждённых событий нет.</span></div>';
|
||||
el('cpuName').textContent=el('cpuDetailName').textContent=c.model;el('cpuUsage').textContent=fixed(c.usagePercent);el('cpuBar').style.width=Math.min(c.usagePercent,100)+'%';el('cpuTemp').textContent=avg===null?'Нет датчика':fixed(avg)+' °C';el('cpuTopology').textContent=c.physicalCores+' / '+c.logicalCpus;
|
||||
el('cpuName').textContent=el('cpuDetailName').textContent=c.model;el('cpuUsage').textContent=fixed(c.usagePercent);el('cpuBar').style.width=Math.min(c.usagePercent,100)+'%';el('cpuTemp').title='Максимум датчиков CPU; тот же показатель записывается в историю';el('cpuTemp').textContent=avg===null?'Нет датчика':fixed(avg)+' °C';el('cpuTopology').textContent=c.physicalCores+' / '+c.logicalCpus;
|
||||
el('dCpuUsage').textContent=fixed(c.usagePercent)+'%';el('dSockets').textContent=c.sockets;el('dCores').textContent=c.physicalCores;el('dThreads').textContent=c.logicalCpus;el('dFreq').textContent=(c.frequencyMhz/1000).toFixed(2)+' GHz';el('dUptime').textContent=uptime(c.uptimeSeconds);el('dLoad1').textContent=fixed(c.load1);el('dLoad5').textContent=fixed(c.load5);el('dLoad15').textContent=fixed(c.load15);el('dTemps').innerHTML=c.temperatures.length?c.temperatures.map(t=>'<span class="temp">'+safe(t.label)+'<b>'+fixed(t.celsius)+'°C</b></span>').join(''):'<span class="empty">Датчики температуры не найдены</span>';const cpuProcesses=(c.topProcesses||[]).slice().sort((a,b)=>b.cpuPercent-a.cpuPercent).slice(0,12);el('cpuProcessList').innerHTML=cpuProcesses.length?cpuProcesses.map(v=>'<div class="process-row"><div><strong>'+safe(v.name)+'</strong><small>PID '+v.pid+' · '+safe(v.command)+'</small></div><span>CPU '+processCPU(v)+'</span><span>RAM '+size(v.memoryBytes)+'</span><span>Read '+size(v.readBytes)+'</span><span>Write '+size(v.writeBytes)+'</span></div>').join(''):'<span class="empty">Процессы не получены.</span>';
|
||||
el('memUsage').textContent=fixed(m.usagePercent);el('memBar').style.width=Math.min(m.usagePercent,100)+'%';el('memUsed').textContent=size(m.usedBytes);el('memAvailable').textContent=size(m.availableBytes);el('dMemUsed').textContent=size(m.usedBytes);el('dMemAvailable').textContent=size(m.availableBytes);el('dMemTotal').textContent=size(m.totalBytes);el('dMemCache').textContent=size(m.cachedBytes);el('dMemBuffers').textContent=size(m.buffersBytes);el('dMemPercent').textContent=fixed(m.usagePercent)+'%';el('dSwapUsed').textContent=size(m.swapUsedBytes);el('dSwapTotal').textContent=size(m.swapTotalBytes);el('dSwapPercent').textContent=fixed(m.swapPercent)+'%';
|
||||
const failed=disks.filter(d=>d.smartStatus==='Ошибка').length,attention=disks.filter(d=>d.smartStatus==='Требует внимания').length,available=disks.filter(d=>d.smartAvailable).length;let health=failed?'Ошибка':attention?'Внимание':available===disks.length&&disks.length?'Исправны':available?'Частично':'Нет данных';
|
||||
@@ -971,12 +993,12 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av
|
||||
el('dot').classList.add('live');el('status').textContent='Данные поступают';
|
||||
}
|
||||
const percent=v=>fixed(v)+'%',temperature=v=>fixed(v)+' °C',megabytes=v=>fixed(v)+' MB/s';
|
||||
const chartConfigs={cpuChart:{series:[{label:'CPU',color:'#55d58a',value:p=>p.cpuUsage,format:percent},{label:'Температура',color:'#ff9b5c',value:p=>p.cpuTemperature,format:temperature}]},memoryChart:{series:[{label:'RAM',color:'#9d8cff',value:p=>p.memoryUsage,format:percent},{label:'Swap',color:'#ff78b8',value:p=>p.swapUsage,format:percent}]},storageChart:{series:[{label:'Раздел /',color:'#ff9b5c',value:p=>p.rootUsage,format:percent}]},networkChart:{max:0,series:[{label:'Приём',color:'#43c6d9',value:p=>p.networkReceive/1048576,format:megabytes},{label:'Передача',color:'#65ddaa',value:p=>p.networkTransmit/1048576,format:megabytes}]},zfsChart:{series:[{label:'ZFS',color:'#6577ff',value:p=>p.zfsUsage,format:percent}]},upsChart:{series:[{label:'Заряд',color:'#55d58a',value:p=>p.upsCharge,format:percent},{label:'Нагрузка',color:'#d5e85a',value:p=>p.upsLoad,format:percent}]},guestsChart:{series:[{label:'CPU',color:'#ff78b8',value:p=>p.guestsCpu,format:percent},{label:'RAM',color:'#9d8cff',value:p=>p.guestsMemory,format:percent}]}};
|
||||
const chartConfigs={cpuChart:{floorMax:100,series:[{label:'CPU',color:'#55d58a',value:p=>p.cpuUsage,format:percent},{label:'Макс. °C CPU',color:'#ff9b5c',value:p=>p.cpuTemperature,format:temperature}]},memoryChart:{series:[{label:'RAM',color:'#9d8cff',value:p=>p.memoryUsage,format:percent},{label:'Swap',color:'#ff78b8',value:p=>p.swapUsage,format:percent}]},storageChart:{series:[{label:'Раздел /',color:'#ff9b5c',value:p=>p.rootUsage,format:percent}]},networkChart:{max:0,series:[{label:'Приём',color:'#43c6d9',value:p=>p.networkReceive/1048576,format:megabytes},{label:'Передача',color:'#65ddaa',value:p=>p.networkTransmit/1048576,format:megabytes}]},zfsChart:{series:[{label:'ZFS',color:'#6577ff',value:p=>p.zfsUsage,format:percent}]},upsChart:{series:[{label:'Заряд',color:'#55d58a',value:p=>p.upsCharge,format:percent},{label:'Нагрузка',color:'#d5e85a',value:p=>p.upsLoad,format:percent}]},guestsChart:{series:[{label:'CPU',color:'#ff78b8',value:p=>p.guestsCpu,format:percent},{label:'RAM',color:'#9d8cff',value:p=>p.guestsMemory,format:percent}]}};
|
||||
const historyCache=new Map();
|
||||
async function loadHistory(hours){const cached=historyCache.get(hours);if(cached&&Date.now()-cached.loadedAt<60000)return cached.points;const response=await fetch('/api/history?hours='+hours);if(!response.ok)throw new Error('history');const points=await response.json()||[];historyCache.set(hours,{points,loadedAt:Date.now()});return points}
|
||||
const entityChartKinds={storageChart:{kind:'disk',format:temperature},diskIOChart:{kind:'disk-io',format:v=>fixed(v)+' IOPS',dynamic:true},diskUtilChart:{kind:'disk-io',format:percent,value:'value2'},zfsChart:{kind:'zfs',format:percent},guestsChart:{kind:'guest',format:percent}},chartColors=['#55d58a','#6ba8ff','#ff9b5c','#9d8cff','#ff78b8','#43c6d9','#d5e85a','#ff665c','#6577ff','#65ddaa'];
|
||||
async function loadEntityChart(id,hours){const definition=entityChartKinds[id],response=await fetch('/api/entity-history?kind='+definition.kind+'&hours='+hours);if(!response.ok)throw new Error('entity history');const data=await response.json()||[],timestamps=new Map(),series=data.map((item,index)=>{const key='entity'+index;item.points.forEach(p=>{const point=timestamps.get(p.timestamp)||{timestamp:p.timestamp};point[key]=p[definition.value||'value1'];timestamps.set(p.timestamp,point)});return {label:item.name,color:chartColors[index%chartColors.length],value:p=>p[key],format:definition.format}});chartConfigs[id]={series,max:definition.dynamic?0:undefined};return [...timestamps.values()].sort((a,b)=>a.timestamp-b.timestamp)}
|
||||
function drawChart(id,points){const canvas=el(id),config=chartConfigs[id];if(!canvas||!config||!canvas.offsetWidth)return;const series=config.series,dpr=window.devicePixelRatio||1,w=canvas.offsetWidth,h=200;canvas.width=w*dpr;canvas.height=h*dpr;const ctx=canvas.getContext('2d');ctx.scale(dpr,dpr);const styles=getComputedStyle(document.documentElement),text=styles.getPropertyValue('--muted').trim(),line=styles.getPropertyValue('--line').trim();ctx.clearRect(0,0,w,h);ctx.font='11px system-ui';ctx.fillStyle=text;if(points.length<2){ctx.textAlign='center';ctx.fillText('История появится после нескольких минут работы',w/2,h/2);canvas._chartState=null;return}const values=series.flatMap(s=>points.map(p=>s.value(p)).filter(v=>v!=null&&Number.isFinite(v))),max=config.max===0?Math.max(...values,1)*1.1:100,pad={l:38,r:12,t:30,b:25},pw=w-pad.l-pad.r,ph=h-pad.t-pad.b;ctx.strokeStyle=line;ctx.lineWidth=1;for(let i=0;i<=4;i++){const y=pad.t+ph*i/4;ctx.beginPath();ctx.moveTo(pad.l,y);ctx.lineTo(w-pad.r,y);ctx.stroke();ctx.fillStyle=text;ctx.textAlign='right';ctx.fillText((max*(1-i/4)).toFixed(max<10?1:0),pad.l-7,y+4)}series.forEach((s,index)=>{ctx.strokeStyle=s.color;ctx.lineWidth=2;ctx.beginPath();let started=false;points.forEach((p,i)=>{const v=s.value(p);if(v==null||!Number.isFinite(v))return;const x=pad.l+pw*i/(points.length-1),y=pad.t+ph*(1-v/(max||1));if(!started){ctx.moveTo(x,y);started=true}else ctx.lineTo(x,y)});ctx.stroke();ctx.fillStyle=s.color;ctx.textAlign='left';ctx.fillText(s.label,pad.l+index*130,15)});const first=new Date(points[0].timestamp*1000),last=new Date(points[points.length-1].timestamp*1000),timeOptions={hour:'2-digit',minute:'2-digit'};ctx.fillStyle=text;ctx.textAlign='left';ctx.fillText(first.toLocaleString('ru-RU',timeOptions),pad.l,h-6);ctx.textAlign='right';ctx.fillText(last.toLocaleString('ru-RU',timeOptions),w-pad.r,h-6);canvas._chartState={points,series,pad,pw,w}}
|
||||
function drawChart(id,points){const canvas=el(id),config=chartConfigs[id];if(!canvas||!config||!canvas.offsetWidth)return;const series=config.series,dpr=window.devicePixelRatio||1,w=canvas.offsetWidth,h=200;canvas.width=w*dpr;canvas.height=h*dpr;const ctx=canvas.getContext('2d');ctx.scale(dpr,dpr);const styles=getComputedStyle(document.documentElement),text=styles.getPropertyValue('--muted').trim(),line=styles.getPropertyValue('--line').trim();ctx.clearRect(0,0,w,h);ctx.font='11px system-ui';ctx.fillStyle=text;if(points.length<2){ctx.textAlign='center';ctx.fillText('История появится после нескольких минут работы',w/2,h/2);canvas._chartState=null;return}const values=series.flatMap(s=>points.map(p=>s.value(p)).filter(v=>v!=null&&Number.isFinite(v))),max=config.floorMax?Math.max(config.floorMax,...values)*1.05:config.max===0?Math.max(...values,1)*1.1:100,pad={l:38,r:12,t:30,b:25},pw=w-pad.l-pad.r,ph=h-pad.t-pad.b;ctx.strokeStyle=line;ctx.lineWidth=1;for(let i=0;i<=4;i++){const y=pad.t+ph*i/4;ctx.beginPath();ctx.moveTo(pad.l,y);ctx.lineTo(w-pad.r,y);ctx.stroke();ctx.fillStyle=text;ctx.textAlign='right';ctx.fillText((max*(1-i/4)).toFixed(max<10?1:0),pad.l-7,y+4)}series.forEach((s,index)=>{ctx.strokeStyle=s.color;ctx.lineWidth=2;ctx.beginPath();let started=false;points.forEach((p,i)=>{const v=s.value(p);if(v==null||!Number.isFinite(v))return;const x=pad.l+pw*i/(points.length-1),y=pad.t+ph*(1-v/(max||1));if(!started){ctx.moveTo(x,y);started=true}else ctx.lineTo(x,y)});ctx.stroke();ctx.fillStyle=s.color;ctx.textAlign='left';ctx.fillText(s.label,pad.l+index*130,15)});const first=new Date(points[0].timestamp*1000),last=new Date(points[points.length-1].timestamp*1000),timeOptions={hour:'2-digit',minute:'2-digit'};ctx.fillStyle=text;ctx.textAlign='left';ctx.fillText(first.toLocaleString('ru-RU',timeOptions),pad.l,h-6);ctx.textAlign='right';ctx.fillText(last.toLocaleString('ru-RU',timeOptions),w-pad.r,h-6);canvas._chartState={points,series,pad,pw,w}}
|
||||
function setupChartHover(canvas){canvas.addEventListener('mousemove',event=>{const state=canvas._chartState,section=canvas.closest('.chart-section'),tooltip=section&§ion.querySelector('.chart-tooltip');if(!state||!tooltip)return;const rect=canvas.getBoundingClientRect(),x=event.clientX-rect.left,index=Math.max(0,Math.min(state.points.length-1,Math.round((x-state.pad.l)/state.pw*(state.points.length-1)))),point=state.points[index],date=new Date(point.timestamp*1000);tooltip.innerHTML='<div class="chart-tooltip-time">'+date.toLocaleString('ru-RU')+'</div>'+state.series.map(s=>{const value=s.value(point),shown=value==null||!Number.isFinite(value)?'—':s.format(value);return '<div class="chart-tooltip-value"><span><i style="background:'+s.color+'"></i>'+s.label+'</span><strong>'+shown+'</strong></div>'}).join('');tooltip.classList.add('visible');const sectionRect=section.getBoundingClientRect(),left=Math.min(event.clientX-sectionRect.left+12,section.clientWidth-tooltip.offsetWidth-10);tooltip.style.left=Math.max(10,left)+'px';tooltip.style.top=Math.max(55,event.clientY-sectionRect.top-tooltip.offsetHeight-12)+'px'});canvas.addEventListener('mouseleave',()=>{const tooltip=canvas.closest('.chart-section').querySelector('.chart-tooltip');tooltip.classList.remove('visible')})}
|
||||
async function showHistory(section){const hours=Number(section.dataset.hours||1);await Promise.all([...section.querySelectorAll('canvas')].map(async canvas=>{try{drawChart(canvas.id,entityChartKinds[canvas.id]?await loadEntityChart(canvas.id,hours):await loadHistory(hours))}catch(e){drawChart(canvas.id,[])}}))}
|
||||
function setupDetailTabs(){document.querySelectorAll('dialog').forEach(dialog=>{const chart=dialog.querySelector('.chart-section');if(!chart)return;const head=dialog.querySelector('.modal-head'),tabs=document.createElement('div'),realtime=document.createElement('div');tabs.className='detail-tabs';tabs.innerHTML='<button class="detail-tab active" data-detail-tab="realtime">Реальное время</button><button class="detail-tab" data-detail-tab="history">История</button>';realtime.className='detail-panel';realtime.dataset.detailPanel='realtime';while(head.nextElementSibling&&head.nextElementSibling!==chart)realtime.appendChild(head.nextElementSibling);head.after(tabs);tabs.after(realtime);chart.classList.add('detail-panel');chart.dataset.detailPanel='history';chart.dataset.hours='1';chart.hidden=true;const title=chart.querySelector('.chart-head strong'),chartHead=chart.querySelector('.chart-head');chartHead.innerHTML='';chartHead.appendChild(title);const ranges=document.createElement('div');ranges.className='range-switch';ranges.innerHTML='<button class="range-button active" data-hours="1">1 час</button><button class="range-button" data-hours="24">24 часа</button><button class="range-button" data-hours="168">7 дней</button>';chartHead.appendChild(ranges);const tooltip=document.createElement('div');tooltip.className='chart-tooltip';chart.appendChild(tooltip);chart.querySelectorAll('canvas').forEach(setupChartHover);tabs.querySelectorAll('[data-detail-tab]').forEach(tab=>tab.onclick=()=>{tabs.querySelectorAll('.detail-tab').forEach(t=>t.classList.toggle('active',t===tab));realtime.hidden=tab.dataset.detailTab!=='realtime';chart.hidden=tab.dataset.detailTab!=='history';if(!chart.hidden)setTimeout(()=>showHistory(chart),20)});ranges.querySelectorAll('[data-hours]').forEach(button=>button.onclick=()=>{chart.dataset.hours=button.dataset.hours;ranges.querySelectorAll('.range-button').forEach(b=>b.classList.toggle('active',b===button));showHistory(chart)})})}
|
||||
@@ -1009,6 +1031,15 @@ function setTheme(theme){document.documentElement.dataset.theme=theme;localStora
|
||||
['services','agents','maintenance-mode','ups'].forEach((name,index)=>{const panel=document.querySelector('[data-settings-panel="'+name+'"]');if(panel){panel.dataset.managementPanel=name;panel.hidden=index!==0;el('managementPanels').appendChild(panel)}});
|
||||
function selectManagement(tab){document.querySelectorAll('[data-management-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-management-panel]').forEach(p=>p.hidden=p.dataset.managementPanel!==tab.dataset.managementTab)}el('managementButton').onclick=()=>{selectManagement(document.querySelector('[data-management-tab].active')||document.querySelector('[data-management-tab]'));el('managementDialog').showModal()};document.querySelectorAll('[data-management-tab]').forEach(tab=>tab.onclick=()=>selectManagement(tab));
|
||||
document.querySelectorAll('[data-settings-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-settings-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-settings-panel]').forEach(p=>p.hidden=p.dataset.settingsPanel!==tab.dataset.settingsTab)});
|
||||
async function loadTrafficSettings(){try{
|
||||
const [settingsResponse,agentsResponse,worldResponse]=await Promise.all([fetch('/api/settings/traffic'),fetch('/api/agents'),fetch('/api/traffic/world')]);if(!settingsResponse.ok||!agentsResponse.ok||!worldResponse.ok)throw Error();
|
||||
const settings=await settingsResponse.json(),agents=await agentsResponse.json(),world=await worldResponse.json();
|
||||
el('monitoringRules').value=(settings.monitoringRules||[]).map(r=>[r.ip,r.domain||'',r.pathPrefix||''].join(' | ').replace(/(?: \| )+$/,'')).join('\n');el('trafficEnabled').checked=settings.enabled;el('networkEnabled').checked=!!settings.networkEnabled;el('trafficAgent').innerHTML='<option value="">Выберите агент</option>'+(agents||[]).map(a=>'<option value="'+safe(a.id)+'">'+safe(a.name)+' · VMID '+a.vmid+' · '+safe(a.version)+(a.online?' · онлайн':' · офлайн')+'</option>').join('');el('trafficAgent').value=settings.agentId;
|
||||
el('trafficCountry').innerHTML='<option value="">Не задана</option>'+world.filter(c=>c.code&&c.code!=='-99').sort((a,b)=>a.name.localeCompare(b.name,'ru')).map(c=>'<option value="'+safe(c.code)+'">'+safe(c.name)+'</option>').join('');el('trafficCountry').value=settings.serverCountry;el('trafficSettingsStatus').textContent='';
|
||||
}catch(e){el('trafficSettingsStatus').textContent='Не удалось загрузить настройки';}}
|
||||
el('trafficSettingsTab').addEventListener('click',loadTrafficSettings);
|
||||
el('saveTrafficSettings').onclick=async()=>{const button=el('saveTrafficSettings');button.disabled=true;try{const response=await fetch('/api/settings/traffic',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({monitoringRules:el('monitoringRules').value.split('\n').filter(line=>line.trim()).map(line=>{const parts=line.split('|').map(v=>v.trim());if(parts.length>3)throw Error('В правиле допускается три поля: IP | домен | путь');return {ip:parts[0],domain:parts[1]||'',pathPrefix:parts[2]||''}}),enabled:el('trafficEnabled').checked,networkEnabled:el('networkEnabled').checked,agentId:el('trafficAgent').value,serverCountry:el('trafficCountry').value})});if(!response.ok)throw Error(await response.text());el('trafficLink').hidden=!el('trafficEnabled').checked;el('trafficSettingsStatus').textContent='Сохранено. Фильтры применяются сразу, настройки сбора — при следующем отчёте агента.';}catch(e){el('trafficSettingsStatus').textContent=e.message||'Ошибка сохранения';}finally{button.disabled=false;}};
|
||||
if(location.hash==='#traffic-settings'){el('settingsDialog').showModal();el('trafficSettingsTab').click();}
|
||||
el('agentServerURL').value=location.origin;
|
||||
el('createAgentToken').onclick=async()=>{const button=el('createAgentToken'),server=el('agentServerURL').value.trim().replace(/\/$/,''),vmid=Number(el('agentVMID').value)||0;if(!/^https?:\/\//.test(server)){alert('Укажите полный адрес Dashboard, доступный из VM/LXC.');return}button.disabled=true;button.textContent='Создаём токен…';try{const response=await fetch('/api/agents/enrollment-token',{method:'POST'});if(!response.ok)throw new Error(await response.text());const value=await response.json(),quote=v=>"'"+String(v).replaceAll("'","'\"'\"'")+"'";el('agentInstallCommand').textContent='curl -fsSL https://git.myown.center/maxim/ProxmoxDash/raw/branch/main/scripts/install-agent.sh | DASHBOARD_URL='+quote(server)+' ENROLL_TOKEN='+quote(value.token)+' VMID='+quote(vmid)+' sh';el('agentInstallBlock').hidden=false}catch(e){alert('Не удалось создать токен: '+e.message)}finally{button.disabled=false;button.textContent='Создать команду установки или обновления'}};
|
||||
el('agentSettingsList').onclick=async event=>{const detail=event.target.closest('[data-agent-detail]');if(detail){const agent=currentAgents.find(v=>v.id===detail.dataset.agentDetail);if(agent)openAgentDetail(agent);return}const button=event.target.closest('[data-agent-delete]');if(!button||!confirm('Удалить агент? Для повторного подключения потребуется новый токен.'))return;button.disabled=true;const response=await fetch('/api/agents?id='+encodeURIComponent(button.dataset.agentDelete),{method:'DELETE'});if(response.ok)button.closest('.disk-item').remove();else{button.disabled=false;alert(await response.text())}};
|
||||
|
||||
Reference in New Issue
Block a user