10 Commits

37 changed files with 3987 additions and 13 deletions

257
README.md
View File

@@ -434,3 +434,260 @@ InErrors, InDroppedDma, AllocFails, SkbAllocFails и Restarts через ethtool
Скрипт запускает тесты и создаёт в 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, никогда не скрываются этим фильтром.
Сетевые потоки по-прежнему хранятся только последним снимком, без истории.
## Alpine LXC и Docker NPM/NPM+ (v0.23.0)
Установщик агента поддерживает Linux **x86_64/amd64** с systemd или OpenRC
(Alpine LXC). Для Alpine команда из настроек сначала устанавливает curl и
CA-сертификаты, если curl отсутствует. Бинарник статический, без glibc;
скачивание проверяется по SHA-256. OpenRC-служба включается в default runlevel
и использует supervise-daemon: перезапускает агент после ошибки и обновления.
Команды: `rc-service proxmox-dashboard-agent status/restart` (выберите одно
действие). Журнал: `/var/log/proxmox-dashboard-agent.log`; его ротацию настройте
средствами ОС. Удаление — штатным `scripts/uninstall-agent.sh`.
Обновление пакетов ОС и управление systemd-службами из интерфейса остаются
функциями Debian/Ubuntu/systemd; этот релиз добавляет установку и работу агента
на OpenRC, а не эмуляцию systemd. Включённый сетевой сбор на Alpine ставит
`conntrack-tools` через `apk add --no-cache`. Ограничения CAP_NET_ADMIN и
сетевого namespace LXC сохраняются; никаких изменений конфигурации Proxmox,
privileged/nesting, firewall или sysctl агент не делает.
После выбора агента Docker-хоста и включения карты трафика агент автоматически:
- проверяет стандартные каталоги NPM и NPM+;
- находит запущенные контейнеры с nginx-proxy-manager/npmplus в имени образа
и определяет каталоги логов по mounts `/data`, включая именованные volumes
и вложенные bind mounts;
- читает обычный формат NPM и формат NPM+ `access.log`, включая ротацию;
- находит единственный контейнер образа `crowdsecurity/crowdsec` и выполняет
только `cscli alerts list` через локальный Docker socket. Docker CLI, новый
bouncer key и опубликованный LAPI не нужны. Если контейнера нет, используется
прежний локальный `cscli`.
Поиск повторяется раз в минуту, поэтому пересоздание контейнеров подхватывается
автоматически. При нескольких CrowdSec задайте точный контейнер вручную:
`DASHBOARD_CROWDSEC_CONTAINER=имя` в `/etc/proxmox-dashboard-agent.env` и
перезапустите агент. Каталог логов можно переопределить переменной
`DASHBOARD_NPM_LOG_DIR=/путь/к/логам`; `off` выключает сбор трафика этого агента.
Используется локальный `/var/run/docker.sock`; rootless/удалённые Docker engines
и логи только в stdout этим обнаружением не поддерживаются.
NPM+ должен действительно писать access-логи: включите `LOGROTATE=true` в его
Compose и пересоздайте контейнер, если логирование выключено. Агент показывает
подсказку при отсутствии логов, но не меняет Compose приложений. Это настройка
[NPM+](https://github.com/ZoeyVid/NPMplus/blob/develop/README.md), а не Dashboard.
Stream-логи NPM+ не считаются HTTP-запросами; сетевые потоки собираются conntrack.
Как и раньше, первый запуск чтения начинается с конца существующих логов.
## Интерактивная карта (v0.24.0)
Карта трафика использует ту же настройку `proxmox-theme`, что и основной
Dashboard. Кнопка солнца/луны меняет тему; выбор сохраняется в браузере.
Светлая тема распространяется на карту, график, карточки и модальные окна.
Масштаб карты — от 100% до 600%: кнопки +/−, колесо мыши или жест двумя
пальцами. Увеличенную карту можно перетаскивать; ↺ возвращает весь мир.
При фокусе на карте доступны клавиши +/−, 0 и стрелки. Наведение подсвечивает
контур страны и показывает суточные счётчики, а нажатие открывает подробности.
Четыре карточки статистики открывают окна с итогом, объяснением метрики,
разбивкой по странам и примерами событий. Счётчики стран считаются сервером
по всей доступной истории за 24 часа с текущими фильтрами. Начиная с v0.25.0
история загружается целиком по страницам, без ограничения последними 100 событиями. Открытое
окно фиксирует данные на момент открытия; временная отметка показана внутри.
Сетевые потоки остаются отдельным текущим снимком, не суточной статистикой.
## Карта и полная история (v0.25.0)
Карта использует векторные границы стран: при зуме 100–600% они перерисовываются,
а маркеры и подписи сохраняют размер. Это обзорная карта стран, не уличная карта.
Карточки и окна стран загружают всю сохранённую историю за последние 24 часа
по 50 записей; фильтры учитываются сервером. В карточке IP показаны все уникальные
HTTP IP с количеством запросов. Переходы между страницами фиксируют снимок истории.
Для городов откройте настройки карты и нажмите «Установить базу городов».
Dashboard скачает DB-IP City Lite 2026-10 (~58 МБ сжатая, ~121 МБ на диске),
проверит контрольную сумму и сохранит `dbip-city.mmdb` рядом с `DASHBOARD_DB_PATH`.
IP посетителей не отправляются во внешние сервисы. Город и регион отображаются
в истории и списке IP; местоположение приблизительное и может относиться к VPN
или провайдеру. Для части IP город неизвестен. База устанавливается отдельно от
релиза, автоматически каждый месяц не обновляется.
## Фильтр истории и зеркало GeoIP (v0.25.1)
В окнах стран и событий есть локальный переключатель «Все события / HTTP /
Срабатывания / Решения CrowdSec». Он меняет тип событий только внутри окна,
сохраняя страну, домен, остальные фильтры и снимок времени; пагинация начинается
с первой страницы. Сводные карточки над историей сохраняют исходные итоги.
DB-IP City Lite загружается сначала из вложения релиза v0.25.1 в Gitea,
при ошибке — с официального сервера DB-IP. Для собственного адреса Gitea можно
задать `GITEA_URL` в окружении сервиса Dashboard. База хранится отдельно от Git-истории
и бинарника приложения; контрольная сумма проверяется независимо от источника.
Официальный файл: https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz
## Города на карте (v0.26.0)
При установленной базе городов и масштабе от 200% на карте появляются города
с HTTP/CrowdSec-активностью. Маркеры используют приблизительные координаты DB-IP,
подписи не увеличиваются вместе с картой. Наведение показывает статистику,
нажатие открывает карточку города с итогами и ссылкой на всю историю страны.
HTTP/CrowdSec-импульсы также начинаются в городе, если он определён.
Счётчики городов учитывают выбранного агента и фильтры за 24 часа. На карте
показываются до 500 наиболее активных городов; остальные события и IP без города
остаются в точках стран. TCP/UDP/ICMP пока отображаются на уровне стран.
Город может относиться к провайдеру/VPN, а не фактическому местоположению человека.

View File

@@ -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

82
agent_installer_test.go Normal file
View File

@@ -0,0 +1,82 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// Run the real installer in a temporary tree with package/service/download
// commands stubbed. This must never touch the host's init system or packages.
func TestAgentInstallerOpenRC(t *testing.T) {
for _, init := range []string{"openrc", "systemd"} {
t.Run(init, func(t *testing.T) {
root := t.TempDir()
bin := filepath.Join(root, "bin")
os.MkdirAll(bin, 0755)
write := func(name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nset -eu\n"+body), 0755); err != nil {
t.Fatal(err)
}
}
write("id", "echo 0\n")
write("uname", "echo x86_64\n")
hash := strings.Repeat("a", 64)
write("curl", `case "$1" in -fsSL) ;; *) exit 4 ;; esac
if [ "$#" = 2 ]; then printf '%s' '{"browser_download_url":"https://example.test/proxmox-dashboard-linux-amd64"}'; exit; fi
case "$2" in *.sha256) printf '%s\n' '`+hash+` proxmox-dashboard-linux-amd64' > "$4" ;; *) printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "$CAPTURE"\n' > "$4" ;; esac
`)
write("sha256sum", "printf '%s\\n' '"+hash+" binary'\n")
for _, name := range []string{"systemctl", "rc-service", "rc-update", "supervise-daemon"} {
write(name, `printf '%s %s\n' "$(basename "$0")" "$*" >> "$CALLS"`+"\n")
}
raw, err := os.ReadFile("scripts/install-agent.sh")
if err != nil {
t.Fatal(err)
}
// Redirect every persistent absolute path, including those in generated scripts.
s := string(raw)
for _, prefix := range []string{"/etc/", "/var/lib/", "/usr/local/", "/run/", "/var/log/"} {
s = strings.ReplaceAll(s, prefix, root+prefix)
}
for _, dir := range []string{"etc/systemd/system", "etc/init.d", "var/log"} {
os.MkdirAll(filepath.Join(root, dir), 0755)
}
if init == "systemd" {
os.MkdirAll(filepath.Join(root, "run/systemd/system"), 0755)
}
script := filepath.Join(root, "install.sh")
os.WriteFile(script, []byte(s), 0700)
env := append(os.Environ(), "PATH="+bin+":"+os.Getenv("PATH"), "DASHBOARD_URL=https://dashboard.test", "ENROLL_TOKEN=token", "AGENT_NAME=Alpine $(touch should-not-exist)", "VMID=102", "CALLS="+root+"/calls", "CAPTURE="+root+"/args")
cmd := exec.Command("sh", script)
cmd.Env = env
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%v: %s", err, out)
}
calls, _ := os.ReadFile(root + "/calls")
if init == "openrc" {
if strings.Contains(string(calls), "systemctl") || !strings.Contains(string(calls), "rc-update add proxmox-dashboard-agent default") || !strings.Contains(string(calls), "rc-service proxmox-dashboard-agent restart") {
t.Fatalf("%s", calls)
}
cmd = exec.Command("sh", root+"/usr/local/sbin/proxmox-dashboard-agent-run")
cmd.Env = env
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("launcher: %v %s", err, out)
}
args, _ := os.ReadFile(root + "/args")
if !strings.Contains(string(args), "Alpine $(touch should-not-exist)\n") {
t.Fatalf("name was not passed literally: %s", args)
}
} else if !strings.Contains(string(calls), "systemctl restart proxmox-dashboard-agent.service") {
t.Fatalf("%s", calls)
}
info, _ := os.Stat(root + "/etc/proxmox-dashboard-agent.env")
if info.Mode().Perm() != 0600 {
t.Fatalf("credentials permissions: %v", info.Mode())
}
})
}
}

271
agent_network.go Normal file
View 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 }
}

99
agent_network_setup.go Normal file
View File

@@ -0,0 +1,99 @@
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")
}
if apk, err := exec.LookPath("apk"); err == nil {
out := runAgentCombinedCommand(10*time.Minute, apk, "add", "--no-cache", "conntrack-tools")
if strings.HasPrefix(out, "ERROR: ") {
return fmt.Errorf("%s", out)
}
if _, err := exec.LookPath("conntrack"); err != nil {
return fmt.Errorf("apk завершился, но conntrack не найден")
}
return nil
}
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
}

420
agent_traffic.go Normal file
View File

@@ -0,0 +1,420 @@
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 parseNPMPlusTraffic(line)
}
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(dirs ...string) (int, error) {
state.BacklogBytes = 0
var paths []string
seenPaths := map[string]bool{}
for _, dir := range dirs {
for _, pattern := range []string{"*_access.log*", "access.log*"} {
matches, err := filepath.Glob(filepath.Join(dir, pattern))
if err != nil {
return 0, err
}
for _, path := range matches {
if !seenPaths[path] {
paths = append(paths, path)
seenPaths[path] = true
}
}
}
}
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") || filepath.Base(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, container ...string) ([]TrafficEvent, bool, error) {
ctx, cancel := context.WithTimeout(ctx, 6*time.Second)
defer cancel()
var data []byte
if len(container) > 0 && container[0] != "" {
var err error
data, err = dockerCrowdOutput(ctx, container[0])
if err != nil {
return nil, false, errors.New("CrowdSec в Docker недоступен; проверьте контейнер и Docker socket")
}
} else {
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 недоступен на хосте; контейнер CrowdSec не обнаружен")
}
data = output.Bytes()
}
events, err := parseCrowdTraffic(data)
if err != nil {
return nil, false, errors.New("Не удалось разобрать JSON CrowdSec")
}
var raw []json.RawMessage
json.Unmarshal(data, &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
}
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{}
lastDiscovery := time.Time{}
discovery := trafficDiscovery{}
crowdAt := int64(0)
crowdError := ""
crowdLimited := false
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
default:
}
if time.Since(lastDiscovery) >= time.Minute {
discovery = discoverTraffic(ctx)
lastDiscovery = time.Now()
}
files, logErr := state.readLogs(discovery.Dirs...)
if logErr != nil {
logErr = fmt.Errorf("%s; проверьте access-логи и LOGROTATE=true у NPM+", logErr)
}
if len(state.Pending) < 400 && time.Since(lastCrowd) >= 15*time.Second {
events, limited, err := collectCrowdTraffic(ctx, discovery.CrowdContainer)
if discovery.Error != "" {
if _, localErr := exec.LookPath("cscli"); localErr != nil {
err = errors.New(discovery.Error)
}
}
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 }
}

209
agent_traffic_discovery.go Normal file
View File

@@ -0,0 +1,209 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
type trafficDockerContainer struct {
ID string `json:"Id"`
Image string
Names []string
Mounts []struct{ Source, Destination string }
}
type trafficDiscovery struct {
Dirs []string
CrowdContainer string
Error string
}
func trafficDockerClient() *http.Client {
return &http.Client{Timeout: 8 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock")
}}}
}
func discoverTraffic(ctx context.Context) trafficDiscovery {
d := trafficDiscovery{Dirs: []string{"/opt/npm/data/logs", "/opt/npmplus/nginx/logs", "/opt/npmplus/nginx"}}
client := trafficDockerClient()
defer client.CloseIdleConnections()
req, _ := http.NewRequestWithContext(ctx, "GET", "http://docker/containers/json", nil)
res, err := client.Do(req)
if err == nil {
defer res.Body.Close()
var containers []trafficDockerContainer
if res.StatusCode == 200 && json.NewDecoder(io.LimitReader(res.Body, 2<<20)).Decode(&containers) == nil {
d = trafficSources(containers, d.Dirs)
} else {
d.Error = "Не удалось прочитать список Docker-контейнеров"
}
} else if _, e := os.Stat("/var/run/docker.sock"); e == nil {
d.Error = "Docker socket недоступен агенту"
}
if dir := os.Getenv("DASHBOARD_NPM_LOG_DIR"); dir != "" {
d.Dirs = []string{dir}
}
if container := os.Getenv("DASHBOARD_CROWDSEC_CONTAINER"); container != "" {
d.CrowdContainer = container
d.Error = ""
}
return d
}
func trafficSources(containers []trafficDockerContainer, defaults []string) trafficDiscovery {
d := trafficDiscovery{Dirs: append([]string{}, defaults...)}
var crowd []string
for _, c := range containers {
img := strings.ToLower(c.Image)
if strings.Contains(img, "crowdsecurity/crowdsec:") || strings.HasSuffix(img, "crowdsecurity/crowdsec") || strings.Contains(img, "crowdsecurity/crowdsec@") {
crowd = append(crowd, c.ID)
}
if !strings.Contains(img, "nginx-proxy-manager") && !strings.Contains(img, "npmplus") {
continue
}
for _, target := range []string{"/data/logs", "/data/nginx/logs", "/data/nginx"} {
// Use the most specific mount, including a nested bind or named volume.
best, source := "", ""
for _, m := range c.Mounts {
dst := filepath.Clean(m.Destination)
if filepath.IsAbs(m.Source) && (target == dst || strings.HasPrefix(target, dst+"/")) && len(dst) > len(best) {
best = dst
source = filepath.Join(m.Source, strings.TrimPrefix(target, dst))
}
}
if source != "" {
d.Dirs = append(d.Dirs, source)
}
}
}
if len(crowd) == 1 {
d.CrowdContainer = crowd[0]
} else if len(crowd) > 1 {
d.Error = "Найдено несколько CrowdSec: задайте DASHBOARD_CROWDSEC_CONTAINER"
}
unique := map[string]bool{}
dirs := []string{}
for _, dir := range d.Dirs {
dir = filepath.Clean(dir)
if !unique[dir] {
unique[dir] = true
dirs = append(dirs, dir)
}
}
d.Dirs = dirs
return d
}
// Docker exec uses a fixed read-only command; no shell, credential extraction or published LAPI.
func dockerCrowdOutput(ctx context.Context, container string) ([]byte, error) {
client := trafficDockerClient()
defer client.CloseIdleConnections()
return dockerCrowdWithClient(ctx, container, client)
}
func dockerCrowdWithClient(ctx context.Context, container string, client *http.Client) ([]byte, error) {
if !safeContainerName.MatchString(container) || strings.HasPrefix(container, "-") {
return nil, fmt.Errorf("некорректный контейнер CrowdSec")
}
call := func(path string, payload any) (*http.Response, error) {
raw, _ := json.Marshal(payload)
req, _ := http.NewRequestWithContext(ctx, "POST", "http://docker"+path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
return client.Do(req)
}
res, err := call("/containers/"+container+"/exec", map[string]any{"AttachStdout": true, "AttachStderr": true, "Tty": false, "Cmd": []string{"cscli", "alerts", "list", "--since", "24h", "--limit", "50", "-o", "json"}})
if err != nil {
return nil, err
}
var created struct{ ID string }
err = json.NewDecoder(io.LimitReader(res.Body, 4096)).Decode(&created)
res.Body.Close()
if err != nil || res.StatusCode != 201 || !safeContainerName.MatchString(created.ID) {
return nil, fmt.Errorf("Docker не создал cscli exec")
}
res, err = call("/exec/"+created.ID+"/start", map[string]any{"Detach": false, "Tty": false})
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return nil, fmt.Errorf("Docker cscli exec: %s", res.Status)
}
output, err := readDockerTrafficOutput(res.Body)
if err != nil {
return nil, err
}
req, _ := http.NewRequestWithContext(ctx, "GET", "http://docker/exec/"+created.ID+"/json", nil)
check, err := client.Do(req)
if err != nil {
return nil, err
}
defer check.Body.Close()
var status struct {
Running bool
ExitCode int
}
if check.StatusCode != 200 || json.NewDecoder(io.LimitReader(check.Body, 4096)).Decode(&status) != nil || status.Running || status.ExitCode != 0 {
return nil, fmt.Errorf("cscli внутри Docker завершился с ошибкой")
}
return output, nil
}
func readDockerTrafficOutput(r io.Reader) ([]byte, error) {
var out bytes.Buffer
total := uint32(0)
for {
var header [8]byte
_, err := io.ReadFull(r, header[:])
if err == io.EOF {
return out.Bytes(), nil
}
if err != nil {
return nil, err
}
n := binary.BigEndian.Uint32(header[4:])
if n > 4<<20 || total+n > 4<<20 {
return nil, fmt.Errorf("Docker output too large")
}
total += n
if header[0] != 1 && header[0] != 2 {
return nil, fmt.Errorf("invalid Docker stream")
}
if header[0] == 1 {
_, err = io.CopyN(&out, r, int64(n))
} else {
_, err = io.CopyN(io.Discard, r, int64(n))
}
if err != nil {
return nil, err
}
}
}
var npmPlusTrafficPattern = regexp.MustCompile(`^\[([^]]+)\] (\S+) (\S+) [0-9.]+ "([A-Z]+) ([^" ]+) HTTP/[0-9.]+" (\d{3}) `)
func parseNPMPlusTraffic(line string) (TrafficEvent, bool) {
m := npmPlusTrafficPattern.FindStringSubmatch(line)
if m == nil {
return TrafficEvent{}, false
}
ip := net.ParseIP(m[3])
ts, err := time.Parse("02/Jan/2006:15:04:05 -0700", m[1])
if ip == nil || err != nil {
return TrafficEvent{}, false
}
host := m[2]
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
status, _ := strconv.Atoi(m[6])
return TrafficEvent{Time: ts.Unix(), Kind: "request", IP: ip.String(), Domain: trafficText(host, 253), Method: m[4], Path: trafficPath(m[5]), Status: status}, true
}

View File

@@ -0,0 +1,134 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestNPMPlusTraffic(t *testing.T) {
for _, ip := range []string{"8.8.8.8", "2001:4860:4860::8888"} {
line := `[14/Oct/2026:16:24:17 +0000] example.test:8443 ` + ip + ` 0.001 "GET /health?token=secret HTTP/3.0" 200 123 "-" "test" "-" "text/html" TLSv1.3 - - -`
e, ok := parseNPMTraffic(line)
if !ok || e.IP != ip || e.Domain != "example.test" || e.Path != "/health" || e.Status != 200 {
t.Fatalf("%+v %v", e, ok)
}
}
if _, ok := parseNPMTraffic(`[14/Oct/2026:16:24:17 +0000] 443 8.8.8.8 UDP - 200 10 20 0.001`); ok {
t.Fatal("stream counted as HTTP")
}
}
func TestDockerTrafficDiscovery(t *testing.T) {
var containers []trafficDockerContainer
json.Unmarshal([]byte(`[{"Id":"aaa","Image":"ghcr.io/zoeyvid/npmplus:latest","Mounts":[{"Source":"/var/lib/docker/volumes/npm/_data","Destination":"/data"},{"Source":"/srv/logs","Destination":"/data/nginx/logs"}]},{"Id":"bbb","Image":"crowdsecurity/crowdsec:latest"}]`), &containers)
d := trafficSources(containers, nil)
if d.CrowdContainer != "bbb" || d.Error != "" || strings.Join(d.Dirs, ",") != "/var/lib/docker/volumes/npm/_data/logs,/srv/logs,/var/lib/docker/volumes/npm/_data/nginx" {
t.Fatalf("%+v", d)
}
containers = append(containers, containers[1])
d = trafficSources(containers, nil)
if d.CrowdContainer != "" || d.Error == "" {
t.Fatal("ambiguous CrowdSec silently chosen")
}
}
func TestDockerTrafficDemux(t *testing.T) {
var b bytes.Buffer
for _, frame := range []struct {
stream byte
text string
}{{2, "diagnostic"}, {1, "["}, {1, "]"}} {
h := []byte{frame.stream, 0, 0, 0, 0, 0, 0, 0}
binary.BigEndian.PutUint32(h[4:], uint32(len(frame.text)))
b.Write(h)
b.WriteString(frame.text)
}
out, err := readDockerTrafficOutput(&b)
if err != nil || string(out) != "[]" {
t.Fatalf("%q %v", out, err)
}
if _, err := readDockerTrafficOutput(bytes.NewReader([]byte{1, 0, 0})); err == nil {
t.Fatal("truncation accepted")
}
if _, err := readDockerTrafficOutput(bytes.NewReader([]byte{1, 0, 0, 0, 255, 255, 255, 255})); err == nil {
t.Fatal("oversized frame accepted")
}
}
func TestTrafficMultipleDirectoriesAndNPMPlusRotation(t *testing.T) {
a, b := t.TempDir(), t.TempDir()
p := filepath.Join(b, "access.log")
os.WriteFile(filepath.Join(a, "proxy-host-1_access.log"), nil, 0600)
os.WriteFile(p, nil, 0600)
s := trafficAgentState{Cursors: map[string]trafficCursor{}}
if n, err := s.readLogs(a, b, a); err != nil || n != 2 {
t.Fatalf("files %d: %v", n, err)
}
line := "[" + time.Now().UTC().Format("02/Jan/2006:15:04:05 -0700") + `] example.test 8.8.8.8 0.1 "GET / HTTP/2.0" 200 123 "-"` + "\n"
os.WriteFile(p, []byte(line), 0600)
s.readLogs(a, b)
if len(s.Pending) != 1 {
t.Fatal("NPM+ event missing")
}
s.Pending = nil
os.Rename(p, p+".1")
os.WriteFile(p, []byte(line), 0600)
s.readLogs(a, b)
if len(s.Pending) != 1 {
t.Fatalf("rotation duplicated/lost events: %d", len(s.Pending))
}
}
type trafficRoundTrip func(*http.Request) (*http.Response, error)
func (f trafficRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestDockerCrowdExec(t *testing.T) {
for _, exitCode := range []int{0, 1} {
calls := 0
client := &http.Client{Transport: trafficRoundTrip(func(r *http.Request) (*http.Response, error) {
calls++
body := ""
status := 200
switch calls {
case 1:
if r.Method != "POST" || r.URL.Path != "/containers/crowdsec/exec" {
t.Fatalf("unexpected %s %s", r.Method, r.URL)
}
var payload struct {
Cmd []string
Tty bool
}
json.NewDecoder(r.Body).Decode(&payload)
if strings.Join(payload.Cmd, " ") != "cscli alerts list --since 24h --limit 50 -o json" || payload.Tty {
t.Fatalf("unexpected command %+v", payload)
}
status = 201
body = `{"Id":"exec123"}`
case 2:
if r.URL.Path != "/exec/exec123/start" {
t.Fatal(r.URL)
}
body = string([]byte{1, 0, 0, 0, 0, 0, 0, 2}) + "[]"
case 3:
if r.Method != "GET" || r.URL.Path != "/exec/exec123/json" {
t.Fatal(r.URL)
}
body = fmt.Sprintf(`{"Running":false,"ExitCode":%d}`, exitCode)
default:
t.Fatal("extra Docker request")
}
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})}
out, err := dockerCrowdWithClient(context.Background(), "crowdsec", client)
if calls != 3 || (exitCode == 0 && (err != nil || string(out) != "[]")) || (exitCode != 0 && err == nil) {
t.Fatalf("calls=%d output=%s err=%v", calls, out, err)
}
}
}

20
assets/NOTICE.md Normal file
View File

@@ -0,0 +1,20 @@
# 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.
Optional DB-IP City Lite October 2026 is downloaded separately through Settings.
Source: https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz
License: CC BY 4.0, https://creativecommons.org/licenses/by/4.0/
https://db-ip.com/db/download/ip-to-city-lite
The database is unmodified; city and region are approximate.

Binary file not shown.

40
assets/network.html Normal file
View 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>

136
assets/traffic.html Normal file

File diff suppressed because one or more lines are too long

1
assets/world.json Normal file

File diff suppressed because one or more lines are too long

5
go.mod
View File

@@ -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
View File

@@ -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=

162
network_flows.go Normal file
View 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
View 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)
}
}

View File

@@ -12,5 +12,6 @@ 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

View File

@@ -10,11 +10,28 @@ if [ -z "${DASHBOARD_URL:-}" ] || [ -z "${ENROLL_TOKEN:-}" ]; then
exit 1
fi
case "$(uname -m)" in x86_64|amd64) ;; *) echo "Поддерживается Linux amd64 (x86_64)." >&2; exit 1 ;; esac
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
agent_init=systemd
elif command -v rc-service >/dev/null 2>&1 && command -v supervise-daemon >/dev/null 2>&1; then
agent_init=openrc
else
echo "Нужен работающий systemd или OpenRC с supervise-daemon." >&2; exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
if command -v apk >/dev/null 2>&1; then apk add --no-cache curl ca-certificates; else echo "Установите curl и ca-certificates." >&2; exit 1; fi
fi
GITEA_URL=${GITEA_URL:-https://git.myown.center}
GITEA_REPOSITORY=${GITEA_REPOSITORY:-maxim/ProxmoxDash}
VMID=${VMID:-0}
AGENT_NAME=${AGENT_NAME:-$(hostname)}
case "$DASHBOARD_URL$ENROLL_TOKEN$AGENT_NAME$VMID" in
*'
'*) echo "Параметры установки не должны содержать переносы строк." >&2; exit 1 ;;
esac
release_json=$(curl -fsSL "$GITEA_URL/api/v1/repos/$GITEA_REPOSITORY/releases/latest")
download_url=$(printf '%s' "$release_json" | sed -n 's/.*"browser_download_url":"\([^"]*\/proxmox-dashboard-linux-amd64\)".*/\1/p' | head -n 1)
if [ -z "$download_url" ]; then
@@ -24,8 +41,12 @@ fi
install -d -m 0700 /var/lib/proxmox-dashboard-agent
temporary_binary=$(mktemp /tmp/proxmox-dashboard-agent.XXXXXX)
trap 'rm -f "$temporary_binary"' EXIT
trap 'rm -f "$temporary_binary" "$temporary_binary.sha256"' EXIT
curl -fsSL "$download_url" -o "$temporary_binary"
curl -fsSL "$download_url.sha256" -o "$temporary_binary.sha256"
expected_hash=$(awk 'NR==1 {print $1}' "$temporary_binary.sha256")
actual_hash=$(sha256sum "$temporary_binary" | awk '{print $1}')
if [ "${#expected_hash}" -ne 64 ] || [ "$expected_hash" != "$actual_hash" ]; then echo "SHA-256 загрузки не совпал." >&2; exit 1; fi
install -m 0755 "$temporary_binary" /var/lib/proxmox-dashboard-agent/proxmox-dashboard-agent
umask 077
@@ -36,6 +57,7 @@ umask 077
printf 'VMID=%s\n' "$VMID"
} > /etc/proxmox-dashboard-agent.env
if [ "$agent_init" = systemd ]; then
cat > /etc/systemd/system/proxmox-dashboard-agent.service <<'UNIT'
[Unit]
Description=Proxmox Dashboard read-only guest agent
@@ -61,5 +83,38 @@ UNIT
systemctl daemon-reload
systemctl enable proxmox-dashboard-agent.service
systemctl restart proxmox-dashboard-agent.service
else
mkdir -p /usr/local/sbin /etc/init.d
cat > /usr/local/sbin/proxmox-dashboard-agent-run <<'RUN'
#!/bin/sh
set -eu
# Parse literal key=value records, never evaluate configuration as shell code.
while IFS='=' read -r key value; do
case "$key" in DASHBOARD_URL|ENROLL_TOKEN|AGENT_NAME|VMID|DASHBOARD_NPM_LOG_DIR|DASHBOARD_CROWDSEC_CONTAINER) export "$key=$value" ;; esac
done < /etc/proxmox-dashboard-agent.env
exec /var/lib/proxmox-dashboard-agent/proxmox-dashboard-agent --agent --agent-server "$DASHBOARD_URL" --agent-enroll-token "$ENROLL_TOKEN" --agent-name "$AGENT_NAME" --agent-vmid "$VMID"
RUN
chmod 0755 /usr/local/sbin/proxmox-dashboard-agent-run
cat > /etc/init.d/proxmox-dashboard-agent <<'OPENRC'
#!/sbin/openrc-run
description="Proxmox Dashboard guest agent"
command="/usr/local/sbin/proxmox-dashboard-agent-run"
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
output_log="/var/log/proxmox-dashboard-agent.log"
error_log="/var/log/proxmox-dashboard-agent.log"
depend() {
need net
after docker
}
start_pre() {
checkpath --file --mode 0600 /var/log/proxmox-dashboard-agent.log
}
OPENRC
chmod 0755 /etc/init.d/proxmox-dashboard-agent
rc-update add proxmox-dashboard-agent default
rc-service proxmox-dashboard-agent restart
fi
rm -f /usr/local/bin/proxmox-dashboard-agent
echo "Агент установлен. Через несколько секунд он появится в Dashboard."

View File

@@ -6,11 +6,16 @@ if [ "$(id -u)" -ne 0 ]; then
exit 1
fi
if command -v rc-service >/dev/null 2>&1 && [ -f /etc/init.d/proxmox-dashboard-agent ]; then
rc-service proxmox-dashboard-agent stop || true
rc-update del proxmox-dashboard-agent default || true
fi
systemctl disable --now proxmox-dashboard-agent.service 2>/dev/null || true
rm -f /etc/init.d/proxmox-dashboard-agent /usr/local/sbin/proxmox-dashboard-agent-run
rm -f /etc/systemd/system/proxmox-dashboard-agent.service
rm -f /etc/proxmox-dashboard-agent.env
rm -f /usr/local/bin/proxmox-dashboard-agent
rm -rf /var/lib/proxmox-dashboard-agent
systemctl daemon-reload
systemctl daemon-reload 2>/dev/null || true
systemctl reset-failed proxmox-dashboard-agent.service 2>/dev/null || true
echo "Агент и его локальные учётные данные удалены."

View File

@@ -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)`,

371
traffic.go Normal file
View File

@@ -0,0 +1,371 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
type TrafficEvent struct {
Location *TrafficCityLocation `json:"location,omitempty"`
City string `json:"city,omitempty"`
Region string `json:"region,omitempty"`
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 TrafficCountryDetail struct {
Country string `json:"country"`
Requests int64 `json:"requests"`
UniqueIPs int64 `json:"uniqueIps"`
Alerts int64 `json:"alerts"`
Decisions int64 `json:"decisions"`
LastSeen int64 `json:"lastSeen"`
}
type TrafficView struct {
Cities []TrafficMapCity `json:"cities"`
CitiesLimited bool `json:"citiesLimited"`
CityReady bool `json:"cityReady"`
MaxSequence int64 `json:"maxSequence"`
CountryDetails []TrafficCountryDetail `json:"countryDetails"`
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
}
if err := s.db.QueryRow(`SELECT COALESCE(MAX(seq),0) FROM traffic_events WHERE agent=?`, v.Settings.AgentID).Scan(&v.MaxSequence); err != nil {
return v, err
}
where := ` WHERE ts>=? AND agent=? AND ts<=? AND seq<=?`
args := []any{now - 86400, v.Settings.AgentID, now, v.MaxSequence}
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)
g := lookupTrafficCityLocation(e.IP)
e.City, e.Region = g.City, g.Region
if g.Located {
e.Location = &g
}
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
}
}
detailRows, detailErr := s.db.Query(`SELECT country,SUM(kind='request'),COUNT(DISTINCT CASE WHEN kind='request' THEN ip END),SUM(kind='alert'),SUM(kind='decision'),MAX(ts) FROM traffic_events`+where+` GROUP BY country`, args...)
if detailErr != nil {
return v, detailErr
}
v.CountryDetails = []TrafficCountryDetail{}
for detailRows.Next() {
var d TrafficCountryDetail
if err = detailRows.Scan(&d.Country, &d.Requests, &d.UniqueIPs, &d.Alerts, &d.Decisions, &d.LastSeen); err != nil {
detailRows.Close()
return v, err
}
v.CountryDetails = append(v.CountryDetails, d)
}
detailErr = detailRows.Err()
detailRows.Close()
if detailErr != nil {
return v, detailErr
}
v.CityReady = trafficCityReady()
if v.CityReady {
var cityErr error
v.Cities, v.CitiesLimited, cityErr = s.trafficMapCities(where, args)
if cityErr != nil {
return v, cityErr
}
}
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)
s.trafficHistoryRoute(mux)
trafficCityRoutes(mux)
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)
})
}

216
traffic_city.go Normal file
View File

@@ -0,0 +1,216 @@
package main
import (
"compress/gzip"
"crypto/sha1"
"encoding/hex"
"fmt"
"io"
"math"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/oschwald/maxminddb-golang"
)
const cityDownloadURL = "https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz"
const cityDatabaseSHA1 = "df17bd24390108ed1d24dcb7718dc60f6b9abd4f"
var trafficCity struct {
sync.Mutex
once sync.Once
db *maxminddb.Reader
running bool
message string
cache map[string]TrafficCityLocation
}
func trafficCityPath() string {
db := os.Getenv("DASHBOARD_DB_PATH")
if db == "" {
db = defaultDatabasePath
}
return filepath.Join(filepath.Dir(db), "dbip-city.mmdb")
}
func trafficCityReady() bool {
trafficCity.once.Do(func() {
trafficCity.Lock()
defer trafficCity.Unlock()
trafficCity.db, _ = maxminddb.Open(trafficCityPath())
if trafficCity.db != nil && !strings.Contains(strings.ToLower(trafficCity.db.Metadata.DatabaseType), "city") {
trafficCity.db.Close()
trafficCity.db = nil
}
})
trafficCity.Lock()
defer trafficCity.Unlock()
return trafficCity.db != nil
}
type TrafficCityLocation struct {
City string `json:"city"`
Region string `json:"region,omitempty"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Located bool `json:"located"`
}
func lookupTrafficCity(text string) (string, string) {
g := lookupTrafficCityLocation(text)
return g.City, g.Region
}
func lookupTrafficCityLocation(text string) TrafficCityLocation {
ip := net.ParseIP(text)
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || !ip.IsGlobalUnicast() || !trafficCityReady() {
return TrafficCityLocation{}
}
trafficCity.Lock()
defer trafficCity.Unlock()
if g, ok := trafficCity.cache[text]; ok {
return g
}
var r struct {
Location struct {
Latitude *float64 `maxminddb:"latitude"`
Longitude *float64 `maxminddb:"longitude"`
} `maxminddb:"location"`
City struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"city"`
Subdivisions []struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"subdivisions"`
}
if trafficCity.db.Lookup(ip, &r) != nil {
return TrafficCityLocation{}
}
region := ""
if len(r.Subdivisions) > 0 {
region = cityName(r.Subdivisions[0].Names)
}
g := TrafficCityLocation{City: cityName(r.City.Names), Region: region}
if g.City != "" && r.Location.Latitude != nil && r.Location.Longitude != nil {
g.Latitude, g.Longitude = *r.Location.Latitude, *r.Location.Longitude
g.Located = !math.IsNaN(g.Latitude) && !math.IsNaN(g.Longitude) && math.Abs(g.Latitude) <= 90 && math.Abs(g.Longitude) <= 180
}
if trafficCity.cache == nil {
trafficCity.cache = map[string]TrafficCityLocation{}
}
if len(trafficCity.cache) < 32768 {
trafficCity.cache[text] = g
}
return g
}
func trafficCityRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/settings/traffic/city-database", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" && r.Method != "POST" {
w.WriteHeader(405)
return
}
trafficCityReady()
trafficCity.Lock()
if r.Method == "POST" && !trafficCity.running && trafficCity.db == nil {
trafficCity.running = true
trafficCity.message = "Загрузка базы городов (около 58 МБ)…"
go func() {
err := installTrafficCity()
trafficCity.Lock()
defer trafficCity.Unlock()
trafficCity.running = false
if err != nil {
trafficCity.message = "Не удалось установить базу: " + err.Error()
} else {
trafficCity.message = "База городов DB-IP City Lite 2026-10 установлена"
}
}()
}
status := map[string]any{"ready": trafficCity.db != nil, "running": trafficCity.running, "message": trafficCity.message, "date": "2026-10"}
trafficCity.Unlock()
writeJSON(w, status, nil)
})
}
func cityMirrorURL() string {
base := strings.TrimRight(os.Getenv("GITEA_URL"), "/")
if base == "" {
base = "https://git.myown.center"
}
return base + "/maxim/ProxmoxDash/releases/download/v0.25.1/dbip-city-lite-2026-10.mmdb.gz"
}
func installTrafficCity() error {
if err := installTrafficCityFrom(cityMirrorURL(), cityDatabaseSHA1); err == nil {
return nil
}
return installTrafficCityFrom(cityDownloadURL, cityDatabaseSHA1)
}
func installTrafficCityFrom(source, expectedHash string) error {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.ResponseHeaderTimeout = 20 * time.Second
transport.DialContext = (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext
defer transport.CloseIdleConnections()
client := &http.Client{Timeout: 15 * time.Minute, Transport: transport}
response, err := client.Get(source)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != 200 {
return fmt.Errorf("HTTP %d", response.StatusCode)
}
reader, err := gzip.NewReader(io.LimitReader(response.Body, 70<<20))
if err != nil {
return err
}
defer reader.Close()
path := trafficCityPath()
if err = os.MkdirAll(filepath.Dir(path), 0750); err != nil {
return err
}
file, err := os.CreateTemp(filepath.Dir(path), ".city-*.mmdb")
if err != nil {
return err
}
defer os.Remove(file.Name())
defer file.Close()
hash := sha1.New()
n, err := io.Copy(io.MultiWriter(file, hash), io.LimitReader(reader, (160<<20)+1))
if err != nil {
return err
}
if n > 160<<20 {
return fmt.Errorf("база превышает лимит размера")
}
if hex.EncodeToString(hash.Sum(nil)) != expectedHash {
return fmt.Errorf("контрольная сумма базы не совпала")
}
if err = file.Sync(); err != nil {
return err
}
if err = file.Close(); err != nil {
return err
}
db, err := maxminddb.Open(file.Name())
if err != nil {
return err
}
if !strings.Contains(strings.ToLower(db.Metadata.DatabaseType), "city") {
db.Close()
return fmt.Errorf("неверный тип GeoIP-базы")
}
if err = os.Rename(file.Name(), path); err != nil {
db.Close()
return err
}
trafficCity.Lock()
defer trafficCity.Unlock()
if trafficCity.db != nil {
trafficCity.db.Close()
}
trafficCity.db = db
trafficCity.cache = nil
return nil
}

82
traffic_city_test.go Normal file
View File

@@ -0,0 +1,82 @@
package main
import (
"compress/gzip"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestCityInstallRejectsBadDownload(t *testing.T) {
dir := t.TempDir()
t.Setenv("DASHBOARD_DB_PATH", filepath.Join(dir, "dashboard.db"))
existing := []byte("existing database")
if err := os.WriteFile(trafficCityPath(), existing, 0600); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
g := gzip.NewWriter(w)
g.Write([]byte("corrupt database"))
g.Close()
}))
defer server.Close()
if err := installTrafficCityFrom(server.URL, cityDatabaseSHA1); err == nil {
t.Fatal("bad checksum accepted")
}
got, err := os.ReadFile(trafficCityPath())
if err != nil || string(got) != string(existing) {
t.Fatal("existing database replaced on failure")
}
files, _ := filepath.Glob(filepath.Join(dir, ".city-*"))
if len(files) != 0 {
t.Fatal("partial file left behind")
}
}
func TestCityDatabaseDownloaded(t *testing.T) {
source := os.Getenv("DASHBOARD_CITY_TEST_GZIP")
if source == "" {
t.Skip("optional official database integration test")
}
t.Setenv("DASHBOARD_DB_PATH", filepath.Join(t.TempDir(), "dashboard.db"))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, source) }))
defer server.Close()
trafficCityReady()
if err := installTrafficCityFrom(server.URL, cityDatabaseSHA1); err != nil {
t.Fatal(err)
}
g := lookupTrafficCityLocation("20.194.96.114")
if !g.Located || g.Latitude < 30 || g.Latitude > 40 || g.Longitude < 120 || g.Longitude > 130 {
t.Fatalf("invalid Seoul coordinates: %+v", g)
}
s, a := trafficFixture(t)
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID})
now := time.Now().Unix()
if err := s.SaveTraffic(a.ID, TrafficBatch{Events: []TrafficEvent{
{ID: "1", Time: now, Kind: "request", IP: "20.194.96.114", Domain: "a.test"},
{ID: "2", Time: now, Kind: "request", IP: "20.194.96.114", Domain: "a.test"},
{ID: "3", Time: now, Kind: "alert", IP: "20.194.96.114", Domain: "b.test"},
{ID: "4", Time: now, Kind: "request", IP: "192.168.0.10"},
}}); err != nil {
t.Fatal(err)
}
view, err := s.TrafficView("", "", false)
if err != nil || len(view.Cities) != 1 {
t.Fatalf("cities %+v err %v", view.Cities, err)
}
c := view.Cities[0]
if c.Requests != 2 || c.UniqueIPs != 1 || c.Alerts != 1 || c.City != g.City {
t.Fatalf("bad aggregation %+v", c)
}
filtered, err := s.TrafficView("b.test", "alert", false)
if err != nil || len(filtered.Cities) != 1 || filtered.Cities[0].Requests != 0 || filtered.Cities[0].Alerts != 1 {
t.Fatalf("city filter %+v %v", filtered.Cities, err)
}
city, region := lookupTrafficCity("20.194.96.114")
t.Logf("city=%q region=%q", city, region)
if city == "" {
t.Fatal("public IP city not decoded")
}
}

44
traffic_country_test.go Normal file
View File

@@ -0,0 +1,44 @@
package main
import (
"fmt"
"testing"
"time"
)
func TestTrafficCountryDetailsUseHistoryAndFilters(t *testing.T) {
s, a := trafficFixture(t)
if _, err := s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, MonitoringRules: []MonitoringRule{{IP: "8.8.8.8"}}}); err != nil {
t.Fatal(err)
}
events := []TrafficEvent{}
for i := 0; i < 120; i++ {
events = append(events, TrafficEvent{ID: fmt.Sprint("http-", i), Time: time.Now().Unix(), Kind: "request", IP: "8.8.8.8", Domain: "example.test", Path: "/", Status: 200})
}
events = append(events, TrafficEvent{ID: "alert", Time: time.Now().Unix(), Kind: "alert", IP: "8.8.8.8", Domain: "example.test", Reason: "test"})
if err := s.SaveTraffic(a.ID, TrafficBatch{Events: events}); err != nil {
t.Fatal(err)
}
v, err := s.TrafficView("", "", false)
if err != nil {
t.Fatal(err)
}
if len(v.Events) != 100 || len(v.CountryDetails) != 1 {
t.Fatalf("events=%d countries=%+v", len(v.Events), v.CountryDetails)
}
d := v.CountryDetails[0]
if d.Requests != 120 || d.UniqueIPs != 1 || d.Alerts != 1 || d.LastSeen == 0 {
t.Fatalf("%+v", d)
}
v, err = s.TrafficView("example.test", "", false, true)
if err != nil {
t.Fatal(err)
}
if len(v.CountryDetails) != 1 || v.CountryDetails[0].Requests != 0 || v.CountryDetails[0].Alerts != 1 {
t.Fatalf("filter: %+v", v.CountryDetails)
}
v, err = s.TrafficView("other.test", "", false)
if err != nil || len(v.CountryDetails) != 0 {
t.Fatalf("domain: %+v %v", v.CountryDetails, err)
}
}

65
traffic_geo.go Normal file
View 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
}

144
traffic_history.go Normal file
View File

@@ -0,0 +1,144 @@
package main
import (
"net/http"
"strconv"
"strings"
"time"
)
type TrafficIPDetail struct {
IP string `json:"ip"`
Country string `json:"country"`
Requests int64 `json:"requests"`
LastSeen int64 `json:"lastSeen"`
City string `json:"city,omitempty"`
Region string `json:"region,omitempty"`
}
type TrafficHistory struct {
Events []TrafficEvent `json:"events"`
IPs []TrafficIPDetail `json:"ips"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Snapshot int64 `json:"snapshot"`
MaxSequence int64 `json:"maxSequence"`
CityReady bool `json:"cityReady"`
}
func (s *Store) trafficHistoryRoute(mux *http.ServeMux) {
mux.HandleFunc("/api/traffic/history", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
w.WriteHeader(405)
return
}
if !s.TrafficSettings().Enabled {
http.Error(w, "Карта отключена", 404)
return
}
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
snapshot, _ := strconv.ParseInt(q.Get("snapshot"), 10, 64)
maxSeq, _ := strconv.ParseInt(q.Get("maxSequence"), 10, 64)
if q.Get("maxSequence") == "0" {
maxSeq = -1
}
if page < 0 || page > 10000 {
http.Error(w, "Invalid page", 400)
return
}
kind := q.Get("kind")
metricType := q.Get("type")
if metricType != "" && metricType != "request" && metricType != "alert" && metricType != "decision" {
http.Error(w, "Invalid type", 400)
return
}
if kind != "" && kind != "request" && kind != "alert" && kind != "decision" {
http.Error(w, "Invalid kind", 400)
return
}
result, err := s.TrafficHistory(q.Get("domain"), kind, q.Get("country"), q.Get("ip"), q.Get("type"), q.Get("external") == "1", q.Get("hideMonitoring") == "1", q.Get("group") == "ip", page, snapshot, maxSeq)
writeJSON(w, result, err)
})
}
func (s *Store) TrafficHistory(domain, kind, country, ip, metricType string, external, hide, group bool, page int, snapshot, maxSeq int64) (TrafficHistory, error) {
now := time.Now().Unix()
if snapshot <= 0 || snapshot > now {
snapshot = now
}
if snapshot < now-86400 {
snapshot = now
}
settings := s.TrafficSettings()
v := TrafficHistory{Events: []TrafficEvent{}, IPs: []TrafficIPDetail{}, Page: page, PageSize: 50, Snapshot: snapshot, CityReady: trafficCityReady()}
if maxSeq == 0 {
if err := s.db.QueryRow(`SELECT COALESCE(MAX(seq),0) FROM traffic_events WHERE agent=?`, settings.AgentID).Scan(&maxSeq); err != nil {
return v, err
}
}
v.MaxSequence = maxSeq
where := ` WHERE agent=? AND ts>=? AND ts<=? AND seq<=?`
args := []any{settings.AgentID, snapshot - 86400, snapshot, maxSeq}
if hide {
clause, values := monitoringSQL(settings.MonitoringRules)
where += clause
args = append(args, values...)
}
if external {
where += ` AND country!='LAN'`
}
for _, filter := range []struct{ column, value string }{{"domain", domain}, {"kind", kind}, {"kind", metricType}, {"country", country}, {"ip", ip}} {
if filter.value != "" {
where += " AND " + filter.column + "=?"
args = append(args, filter.value)
}
}
count := "COUNT(*)"
if group {
where += ` AND kind='request'`
count = "COUNT(DISTINCT ip)"
}
if err := s.db.QueryRow("SELECT "+count+" FROM traffic_events"+where, args...).Scan(&v.Total); err != nil {
return v, err
}
paged := append(append([]any{}, args...), v.PageSize, page*v.PageSize)
if group {
rows, err := s.db.Query(`SELECT ip,MIN(country),COUNT(*),MAX(ts) FROM traffic_events`+where+` GROUP BY ip ORDER BY COUNT(*) DESC,ip LIMIT ? OFFSET ?`, paged...)
if err != nil {
return v, err
}
defer rows.Close()
for rows.Next() {
var item TrafficIPDetail
if err = rows.Scan(&item.IP, &item.Country, &item.Requests, &item.LastSeen); err != nil {
return v, err
}
item.City, item.Region = lookupTrafficCity(item.IP)
v.IPs = append(v.IPs, item)
}
return v, rows.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 ? OFFSET ?`, paged...)
if err != nil {
return v, err
}
defer rows.Close()
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 {
return v, err
}
e.Monitoring = isMonitoringRequest(e, settings.MonitoringRules)
e.City, e.Region = lookupTrafficCity(e.IP)
v.Events = append(v.Events, e)
}
return v, rows.Err()
}
func cityName(names map[string]string) string {
for _, lang := range []string{"ru", "en"} {
if s := strings.TrimSpace(names[lang]); s != "" {
return s
}
}
return ""
}

109
traffic_history_test.go Normal file
View File

@@ -0,0 +1,109 @@
package main
import (
"fmt"
"testing"
"time"
)
func TestTrafficHistoryFullPaginationAndSnapshot(t *testing.T) {
s, a := trafficFixture(t)
if _, err := s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID}); err != nil {
t.Fatal(err)
}
now := time.Now().Unix()
batch := TrafficBatch{}
for i := 0; i < 137; i++ {
batch.Events = append(batch.Events, TrafficEvent{ID: fmt.Sprint(i), Time: now - 60, Kind: "request", IP: "8.8.8.8", Domain: "example.test"})
}
batch.Events = append(batch.Events, TrafficEvent{ID: "old-alert", Time: now - 3600, Kind: "alert", IP: "1.1.1.1", Domain: "other.test"})
if err := s.SaveTraffic(a.ID, batch); err != nil {
t.Fatal(err)
}
first, err := s.TrafficHistory("", "request", "", "", "", false, false, false, 0, now, 0)
if err != nil || first.Total != 137 || len(first.Events) != 50 {
t.Fatalf("first: %+v %v", first, err)
}
if err := s.SaveTraffic(a.ID, TrafficBatch{Events: []TrafficEvent{{ID: "new", Time: now - 60, Kind: "request", IP: "8.8.8.8"}}}); err != nil {
t.Fatal(err)
}
seen := map[int64]bool{}
for page := 0; page < 3; page++ {
h, err := s.TrafficHistory("", "request", "", "", "", false, false, false, page, first.Snapshot, first.MaxSequence)
if err != nil || h.Total != 137 {
t.Fatalf("page %d: %v total %d", page, err, h.Total)
}
for _, e := range h.Events {
if seen[e.Sequence] {
t.Fatal("duplicate page event")
}
seen[e.Sequence] = true
}
}
if len(seen) != 137 {
t.Fatalf("missing history: %d", len(seen))
}
h, err := s.TrafficHistory("other.test", "alert", "", "", "", false, false, false, 0, now, 0)
if err != nil || h.Total != 1 || h.Events[0].ID != "old-alert" {
t.Fatalf("old alert missing: %+v %v", h, err)
}
h, err = s.TrafficHistory("", "alert", "", "", "request", false, false, false, 0, now, 0)
if err != nil || h.Total != 0 {
t.Fatal("metric must respect global kind filter")
}
h, err = s.TrafficHistory("example.test", "", "", "", "", false, false, true, 0, now, 0)
if err != nil || h.Total != 1 || len(h.IPs) != 1 || h.IPs[0].Requests != 137 {
t.Fatalf("IP summary %+v %v", h, err)
}
h, err = s.TrafficHistory("", "", "ZZ", "", "", false, false, false, 0, now, 0)
if err != nil || h.Total != 0 {
t.Fatal("country filter ignored")
}
h, err = s.TrafficHistory("", "", "", "1.1.1.1", "", false, false, false, 0, now, 0)
if err != nil || h.Total != 1 {
t.Fatal("IP filter ignored")
}
}
func TestCityNamesAndLocalAddresses(t *testing.T) {
if cityName(map[string]string{"ru": "Москва", "en": "Moscow"}) != "Москва" || cityName(map[string]string{"en": "Amsterdam"}) != "Amsterdam" {
t.Fatal("city localization")
}
for _, ip := range []string{"192.168.0.10", "127.0.0.1", "::1", "fe80::1", "invalid"} {
c, r := lookupTrafficCity(ip)
if c != "" || r != "" {
t.Fatal("local IP geolocated")
}
}
}
func TestTrafficHistoryMonitoringAndAgentIsolation(t *testing.T) {
s, a := trafficFixture(t)
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, MonitoringRules: []MonitoringRule{{IP: "192.168.0.10"}}})
now := time.Now().Unix()
batch := TrafficBatch{Events: []TrafficEvent{{ID: "check", Time: now, Kind: "request", IP: "192.168.0.10"}, {ID: "alert", Time: now, Kind: "alert", IP: "192.168.0.10"}, {ID: "public", Time: now, Kind: "request", IP: "8.8.8.8"}}}
if err := s.SaveTraffic(a.ID, batch); err != nil {
t.Fatal(err)
}
token, _ := s.CreateAgentEnrollmentToken()
other, err := s.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "other", VMID: 103})
if err != nil {
t.Fatal(err)
}
if err = s.SaveTraffic(other.ID, batch); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
external, hide bool
want int64
}{{false, false, 3}, {false, true, 2}, {true, false, 1}, {true, true, 1}} {
h, err := s.TrafficHistory("", "", "", "", "", tc.external, tc.hide, false, 0, now, 0)
if err != nil || h.Total != tc.want {
t.Fatalf("filters %+v total=%d err=%v", tc, h.Total, err)
}
for _, e := range h.Events {
if e.Agent != a.ID {
t.Fatal("foreign agent leaked")
}
}
}
}

71
traffic_map_cities.go Normal file
View File

@@ -0,0 +1,71 @@
package main
import (
"fmt"
"sort"
)
type TrafficMapCity struct {
TrafficCityLocation
Country string `json:"country"`
Requests int64 `json:"requests"`
UniqueIPs int64 `json:"uniqueIps"`
Alerts int64 `json:"alerts"`
Decisions int64 `json:"decisions"`
LastSeen int64 `json:"lastSeen"`
}
func (s *Store) trafficMapCities(where string, args []any) ([]TrafficMapCity, bool, error) {
rows, err := s.db.Query(`SELECT ip,country,SUM(kind='request'),SUM(kind='alert'),SUM(kind='decision'),MAX(ts) FROM traffic_events`+where+` GROUP BY ip,country`, args...)
if err != nil {
return nil, false, err
}
defer rows.Close()
groups := map[string]*TrafficMapCity{}
for rows.Next() {
var ip, country string
var requests, alerts, decisions, last int64
if err = rows.Scan(&ip, &country, &requests, &alerts, &decisions, &last); err != nil {
return nil, false, err
}
g := lookupTrafficCityLocation(ip)
if !g.Located {
continue
}
key := fmt.Sprintf("%s/%s/%s/%.5f/%.5f", country, g.Region, g.City, g.Latitude, g.Longitude)
c := groups[key]
if c == nil {
c = &TrafficMapCity{TrafficCityLocation: g, Country: country}
groups[key] = c
}
c.Requests += requests
c.Alerts += alerts
c.Decisions += decisions
if requests > 0 {
c.UniqueIPs++
}
if last > c.LastSeen {
c.LastSeen = last
}
}
if err = rows.Err(); err != nil {
return nil, false, err
}
result := make([]TrafficMapCity, 0, len(groups))
for _, c := range groups {
result = append(result, *c)
}
sort.Slice(result, func(i, j int) bool {
a, b := result[i], result[j]
x, y := a.Requests+a.Alerts+a.Decisions, b.Requests+b.Alerts+b.Decisions
if x != y {
return x > y
}
return fmt.Sprint(a.Country, a.Region, a.City, a.Latitude, a.Longitude) < fmt.Sprint(b.Country, b.Region, b.City, b.Latitude, b.Longitude)
})
limited := len(result) > 500
if limited {
result = result[:500]
}
return result, limited, nil
}

76
traffic_monitoring.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
package main
import _ "embed"
//go:embed assets/traffic.html
var trafficHTML string

41
web.go
View File

@@ -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(),
@@ -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,19 @@ 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 class="settings-group"><button class="theme-option" id="installCityDatabase">Установить базу городов (~58 МБ)</button><div class="settings-note" id="cityDatabaseStatus">DB-IP City Lite: локальная приблизительная геолокация, без отправки IP внешним сервисам. Устанавливается на сервер Dashboard.</div></div>
</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>
@@ -946,7 +962,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.temperatureCelsius??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||{});
@@ -1016,8 +1032,19 @@ 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);
fetch('/api/settings/traffic/city-database').then(r=>r.ok?r.json():null).then(d=>{if(!d)return;const b=el('installCityDatabase');if(d.ready){b.disabled=true;b.textContent='База городов установлена'}if(d.message)el('cityDatabaseStatus').textContent=d.message}).catch(()=>{});
el('installCityDatabase').onclick=async()=>{const b=el('installCityDatabase'),status=el('cityDatabaseStatus');b.disabled=true;try{let r=await fetch('/api/settings/traffic/city-database',{method:'POST'});if(!r.ok)throw Error('Не удалось начать загрузку');let result=await r.json();status.textContent=result.message||'База уже установлена';while(result.running){await new Promise(resolve=>setTimeout(resolve,2000));r=await fetch('/api/settings/traffic/city-database');if(!r.ok)throw Error('Не удалось прочитать состояние');result=await r.json();status.textContent=result.message}b.textContent=result.ready?'База городов установлена':'Повторить установку базы';b.disabled=result.ready}catch(e){status.textContent=e.message;b.disabled=false}};
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('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='(command -v curl >/dev/null 2>&1 || apk add --no-cache curl ca-certificates) && 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())}};
async function loadAgentActions(){const response=await fetch('/api/agent-actions');if(!response.ok)return;const items=await response.json()||[];el('agentActionLog').innerHTML=items.length?items.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.agentName)+' · '+safe(v.action)+'</div><div class="disk-meta">'+safe(v.target||'без цели')+' · '+new Date(v.requestedAt*1000).toLocaleString('ru-RU')+'</div></div><span class="badge '+(v.status==='failed'?'bad':v.status==='completed'?'':'warn')+'">'+safe(v.status)+'</span></div>'+(v.result?'<div class="settings-note">'+safe(v.result)+'</div>':'')+'</article>').join(''):'<span class="empty">Действий пока не было.</span>'}loadAgentActions().catch(()=>{});
async function loadThresholds(){const response=await fetch('/api/settings/alerts');if(!response.ok)return;alertSettings=await response.json();document.querySelectorAll('[data-threshold]').forEach(input=>input.value=alertSettings[input.dataset.threshold]??'')}