10 Commits

45 changed files with 3794 additions and 134 deletions

250
README.md
View File

@@ -32,7 +32,7 @@ ssh root@192.168.0.10
```bash
apt-get update
apt-get install -y curl ca-certificates tar smartmontools iproute2 iputils-ping
apt-get install -y curl ca-certificates tar smartmontools iproute2 iputils-ping ethtool
cd /tmp
curl -fL \
@@ -401,3 +401,251 @@ curl -fsSL https://git.myown.center/maxim/ProxmoxDash/raw/branch/main/scripts/un
go test ./...
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o proxmox-cpu-dashboard .
```
## Сетевые ошибки и температура CPU (v0.19.3)
RX/TX drops отображаются отдельно как информационные счётчики. Их рост сам по
себе не вызывает аварийный алерт: в том числе LLDP на Linux bridge. Настройки
Keenetic, LLDP и group_fwd_mask менять не требуется. Для VM учитываются tap,
для LXC — veth; fwbr/fwpr/fwln остаются в группе интерфейсов хоста.
Новые rx_errors/tx_errors, CRC/frame/FIFO/missed, carrier/link-down вызывают
критический алерт. Sysfs позволяет отделить rx_missed_errors от RX drops,
которые в /proc/net/dev объединены. Для Atlantic дополнительно читаются
InErrors, InDroppedDma, AllocFails, SkbAllocFails и Restarts через ethtool -S.
Установите `ethtool` на существующем хосте для этой дополнительной диагностики.
При отсутствии утилиты остаются стандартные счётчики sysfs. Счётчики ошибок
могут пересекаться: их сумма — прирост счётчиков, а не число уникальных пакетов.
После перезапуска и сброса счётчиков старые значения не создают новые алерты.
Карточка CPU и новые точки истории показывают максимум доступных датчиков CPU,
а не среднее Tctl/Tdie/CCD или ядер. На AMD Tctl может включать управляющее
смещение; подписи и значения всех датчиков доступны в подробностях CPU.
История снимается раз в минуту, а на диапазоне 7 дней усредняется по 10 минутам,
поэтому короткие пики между измерениями не гарантированно попадут на график.
Старые точки до обновления остаются средними: исходные значения датчиков в них
не сохранены. Масштаб графика расширяется для значений выше 100 °C.
Релиз Linux amd64 собирается с номером версии и без CGO:
```bash
./scripts/build-release.sh v0.19.3
```
Скрипт запускает тесты и создаёт в dist бинарник, SHA-256 и установочный архив,
совместимые со встроенным обновлением из Gitea Releases.
## Карта трафика NPM + CrowdSec (v0.20.0)
С v0.20.1 страница помещается в высоту окна: карта и лента в центре,
счётчики сверху, график и рейтинги снизу. На телефоне доступны вкладки
«Карта / События / Статистика». Последние 100 событий листаются по страницам;
нажатие на строку открывает полные подробности.
Откройте **Настройки → Карта трафика**, включите страницу и выберите
агент VM/LXC, где установлены Nginx Proxy Manager и CrowdSec. Например,
`nginxproxymanager · VMID 102`. Необязательно выберите страну сервера для
конечной точки линий на карте. Сохраните настройки: в шапке появится ссылка
«Карта трафика», страница также доступна по `/traffic`.
Функция **по умолчанию выключена**. Только выбранный агент получает команду
включить сбор. При выключении или смене источника старый агент останавливает
сбор после следующего успешного обычного отчёта; сервер сразу прекращает
приём его событий. Выключенная страница и её API недоступны. Это глобальная
настройка экземпляра Dashboard. Карта не создаёт общих алертов или писем.
Агент автоматически обновляется вместе с Dashboard; для карты требуется
v0.20.0 или новее. Он читает стандартные NPM access-логи из
`/opt/npm/data/logs/*_access.log`, включая несжатые файлы ротации. Другой каталог
задаётся переменной окружения службы `DASHBOARD_NPM_LOG_DIR`; значение `off`
полностью запрещает сбор на конкретном агенте. Порт CrowdSec наружу открывать
и новые API-ключи создавать не нужно: используется локальная команда
`cscli alerts list --since 24h --limit 50 -o json` с существующими учётными
данными машины. Права на логи и запуск cscli нужны службе агента.
- Голубой: все записанные HTTP-запросы, включая обычные и локальные обращения.
- Оранжевый: реальные (не simulated) срабатывания сценариев CrowdSec.
- Розовый: решения CrowdSec, например ban или captcha. Это **не** счётчик
фактически отброшенных пакетов и не список действующих сейчас банов.
- HTTP 401/403/502 сами по себе не считаются атакой или блокировкой.
Карта показывает страны источников, живую ленту последних 100 событий,
счётчики за последние 24 часа, уникальные IP HTTP-клиентов, график по минутам
и средний req/s за последние 60 секунд. Есть фильтры типа события, домена и
внешних IP, пауза и полный экран. Локальные адреса видны в ленте без страны.
Все счётчики следуют выбранным фильтрам. Уникальный IP не равен человеку.
Первое включение начинает чтение с конца логов, без импорта старых HTTP-запросов.
Обычно обновление занимает несколько секунд; Nginx записывает запрос после
его завершения. Это не карта открытых TCP-соединений. Позиции чтения и очередь
до 500 событий сохраняются в `traffic-state.json` рядом с конфигом агента;
повторы после обрыва связи устраняются на сервере. При переполнении очереди
чтение ждёт отправки. Если за время длительного простоя логи уже сжаты или
удалены, восстановить эти строки нельзя. Размер очереди чтения и ошибки
парсинга отображаются на самой странице, без глобальных уведомлений.
CrowdSec опрашивается каждые 15 секунд, до 50 последних alerts за 24 часа;
при достижении лимита страница предупреждает о возможной неполноте истории.
Связанный домен берётся из метаданных события, только если он однозначен.
Новый ban не рисуется как новый запрос. Для наблюдения реально отброшенных
firewall пакетов нужен отдельный источник: в этой версии его нет.
В SQLite хранится до 24 часов и 200 000 событий; старые строки удаляются при
приёме новых данных. Данные каждого выбранного агента фильтруются отдельно.
Query string и fragment URL отбрасываются на агенте и повторно на сервере;
заголовки, cookie и тела запросов не передаются. IP, домен и путь сохраняются.
GeoIP работает локально: DB-IP Country Lite за октябрь 2026 встроена в бинарник,
исходящие запросы с IP посетителей не выполняются. Координаты на карте — условные
точки стран Natural Earth, а не точное местоположение посетителя. Дата базы
видна внизу карты. Обновление базы требует нового релиза; автоматического
скачивания нет. Лицензии и происхождение данных: `assets/NOTICE.md`.
Локальный предпросмотр с явно обозначенными искусственными данными:
```bash
DASHBOARD_TRAFFIC_PREVIEW=1 go test -run '^TestTrafficPreview$' -timeout 30m
# http://127.0.0.1:19105/traffic
```
## Сетевая активность TCP/UDP/ICMP (v0.21.0)
В **Настройки → Карта трафика** включите дополнительный сбор TCP/UDP/ICMP.
Он выполняется только выбранным агентом v0.21.0+; на странице карты появится
кнопка **TCP / UDP**, открывающая `/network`. Основной выключатель карты
отключает оба сборщика. Сетевой режим по умолчанию выключен и не создаёт
общих алертов/писем.
На Debian/Ubuntu агент **сам проверяет и устанавливает conntrack**, когда
включён сетевой режим. Выполняются `apt-get update` и установка только
`conntrack` с зависимостями (`--no-install-recommends --no-remove`), без
обновления всей системы. Установка выполняется от root через отдельную
systemd-службу, как существующие операции обновления агента, и не требует
отключать его read-only sandbox. Начатая пакетная операция завершается даже
при выключении сбора. Ход/ошибка видны в диагностике; повтор после ошибки —
не чаще раза в 30 минут работы процесса. После перезапуска агента проверка
выполняется заново. Если conntrack уже установлен, apt-get не запускается.
На других системах, без root или systemd/apt-get, выводится инструкция для
ручной установки. Для счётчиков CrowdSec требуется доступный `nft`; он
обычно уже установлен вместе с nftables-bouncer и автоматически не ставится.
Агенту нужны root/CAP_NET_ADMIN в сетевом namespace хоста. Установщик агента
запускает службу от root. Чтение: `/proc/net/nf_conntrack`, если доступен,
иначе `conntrack -L -f ipv4/ipv6 -o extended`. Ничего устанавливать на хост
самого Dashboard не требуется. Firewall, NAT и sysctl сборщик не меняет.
Страница показывает состояния TCP, UDP/ICMP-потоки, обе пары адресов до/после
NAT (в том числе Docker), локальную GeoIP, топ инициаторов и портов, фильтры,
поиск и детали потока. Список листается по страницам; переход на следующую
фиксирует снимок, «К свежим» возвращает live. Для телефона — вкладки.
Это **снимки таблицы примерно каждые 5 секунд**, не полный журнал соединений:
короткие потоки между снимками могут быть пропущены. До 5 000 записей на
снимок, 8 MiB вывода команды и таймаут 4 секунды; ограничения/ошибки видны в
диагностике. Сохраняется только последний снимок каждого агента. Инициатор
не обязательно внешний клиент: NPM → upstream — отдельный поток. Порт не
доказывает протокол приложения; UDP ASSURED не означает TCP handshake.
Байты/пакеты доступны только при включённом учёте conntrack для конкретных
потоков. Это накопленные значения за жизнь потока, а не трафик за 24 часа.
При отсутствии учёта интерфейс показывает «нет учёта». При необходимости
администратор может включить `net.netfilter.nf_conntrack_acct=1`; изменение
применяется к новым потокам и автоматически не выполняется.
Firewall читается раз в 30 секунд командой `nft -j list ruleset`. Поддержаны
анонимные счётчики непосредственно перед финальным `drop/reject` в таблице
или цепочке с `crowdsec` в имени. Остальные счётчики не выдаются за блокировки.
Это сумма пакетов/байтов выбранных правил с момента их создания, независимо
от фильтров потоков; при пересоздании правил она может уменьшиться.
Для iptables/ipset-only, именованных счётчиков и правил без счётчиков выводится
«недоступно», а не ноль. Отброшенный пакет до подтверждения conntrack может
отсутствовать в таблице потоков. Содержимое пакетов не перехватывается.
## Сетевой слой карты и фильтр мониторинга (v0.22.0)
При включённом сборе TCP/UDP/ICMP переключатель **Сеть на карте** показывает
текущие потоки отдельным слоем: TCP — зелёный, UDP — розовый, ICMP/ICMPv6 —
жёлтый. Импульсы означают новый снимок (~5 секунд), не отдельные пакеты или
новые подключения. Входящие и исходящие потоки имеют разное направление.
Локальные адреса и потоки без страны учитываются отдельно; при отсутствии
свежего снимка 25 секунд сетевой слой гаснет. HTTP-статистика за 24 часа с
текущими потоками не складывается. Фильтры домена и HTTP/CrowdSec относятся
только к HTTP-слою; фильтры внешних IP и мониторинга действуют на оба слоя.
В **Настройки → Карта трафика → Правила мониторинга** задайте по одному правилу
на строку: `IP | домен | префикс пути`. IP точный, включая IPv6; домен и путь
необязательны. Пример: `192.168.0.10` или `192.168.0.10 | example.org | /health`.
Для сетевых потоков применяются только правила без домена/пути, к IP инициатора.
При первом обновлении добавляется правило `192.168.0.10`; его можно изменить
или полностью удалить. Сам фильтр по умолчанию выключен.
**Скрыть мониторинг** на карте и `/network` исключает совпадения из отображения
и соответствующей статистики. Настройка переключателя сохраняется в браузере.
HTTP-проверки остаются в истории и возвращаются при выключении фильтра;
новые правила сразу применяются и к старым записям. Срабатывания и решения
CrowdSec, а также общие счётчики firewall, никогда не скрываются этим фильтром.
Сетевые потоки по-прежнему хранятся только последним снимком, без истории.
## 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 часа с текущими фильтрами. Примеры — до 30
совпадений из последних 100 событий, что явно отмечено в интерфейсе. Открытое
окно фиксирует данные на момент открытия; временная отметка показана внутри.
Сетевые потоки остаются отдельным текущим снимком, не суточной статистикой.

125
agent.go
View File

@@ -41,18 +41,22 @@ type agentConfig struct {
}
type AgentContainer struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
State string `json:"state"`
Status string `json:"status"`
Health string `json:"health,omitempty"`
Restarts int64 `json:"restarts"`
CPUPercent float64 `json:"cpuPercent"`
MemoryUsage uint64 `json:"memoryUsage"`
Ports []string `json:"ports,omitempty"`
Compose bool `json:"compose"`
UpdateAvailable bool `json:"updateAvailable"`
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
State string `json:"state"`
Status string `json:"status"`
Health string `json:"health,omitempty"`
Restarts int64 `json:"restarts"`
CPUPercent float64 `json:"cpuPercent"`
MemoryUsage uint64 `json:"memoryUsage"`
Ports []string `json:"ports,omitempty"`
Compose bool `json:"compose"`
UpdateAvailable bool `json:"updateAvailable"`
CurrentVersion string `json:"currentVersion,omitempty"`
CurrentDigest string `json:"currentDigest,omitempty"`
AvailableVersion string `json:"availableVersion,omitempty"`
AvailableDigest string `json:"availableDigest,omitempty"`
}
type AgentInterface struct {
@@ -99,6 +103,7 @@ type AgentReport struct {
UptimeSeconds int64 `json:"uptimeSeconds"`
Load1 float64 `json:"load1"`
CPUPercent float64 `json:"cpuPercent"`
CPUCount int `json:"cpuCount"`
MemoryTotal uint64 `json:"memoryTotal"`
MemoryUsed uint64 `json:"memoryUsed"`
SwapTotal uint64 `json:"swapTotal"`
@@ -139,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 {
@@ -334,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()
@@ -351,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
@@ -367,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
@@ -394,7 +431,9 @@ func runGuestAgent(opts agentOptions) error {
if requestErr != nil {
fmt.Fprintf(os.Stderr, "agent: %v\n", requestErr)
}
time.Sleep(10 * time.Second)
// Короткий интервал нужен для интерактивных команд из панели. Тяжёлый
// системный инвентарь отдельно кэшируется и не запускается каждые 3 секунды.
time.Sleep(3 * time.Second)
}
}
@@ -530,7 +569,7 @@ func saveAgentConfig(path string, c agentConfig) error {
func collectAgentReport() AgentReport {
hostname, _ := os.Hostname()
machineID, _ := os.ReadFile("/etc/machine-id")
report := AgentReport{Hostname: hostname, MachineID: strings.TrimSpace(string(machineID)), Version: version, OS: runtime.GOOS, Arch: runtime.GOARCH, CollectedAt: time.Now().Unix()}
report := AgentReport{Hostname: hostname, MachineID: strings.TrimSpace(string(machineID)), Version: version, OS: runtime.GOOS, Arch: runtime.GOARCH, CPUCount: runtime.NumCPU(), CollectedAt: time.Now().Unix()}
if data, err := os.ReadFile("/proc/uptime"); err == nil {
fields := strings.Fields(string(data))
if len(fields) > 0 {
@@ -929,14 +968,10 @@ func collectDockerContainers() ([]AgentContainer, string) {
item.Health = detail.State.Health.Status
}
item.Compose = detail.Config.Labels["com.docker.compose.service"] != ""
imageResponse, imageErr := client.Get("http://docker/images/" + url.PathEscape(v.Image) + "/json")
if imageErr == nil {
var imageDetail struct{ ID string }
if json.NewDecoder(imageResponse.Body).Decode(&imageDetail) == nil && detail.Image != "" && imageDetail.ID != "" {
item.UpdateAvailable = detail.Image != imageDetail.ID
}
imageResponse.Body.Close()
}
item.CurrentVersion, item.CurrentDigest, _ = inspectDockerImage(client, detail.Image, v.Image)
var availableID string
item.AvailableVersion, item.AvailableDigest, availableID = inspectDockerImage(client, v.Image, v.Image)
item.UpdateAvailable = detail.Image != "" && availableID != "" && detail.Image != availableID
}
inspect.Body.Close()
}
@@ -987,6 +1022,46 @@ func collectDockerContainers() ([]AgentContainer, string) {
}
return result, ""
}
func inspectDockerImage(client *http.Client, reference, fallback string) (string, string, string) {
if reference == "" {
return "", "", ""
}
response, err := client.Get("http://docker/images/" + url.PathEscape(reference) + "/json")
if err != nil {
return "", "", ""
}
defer response.Body.Close()
var detail struct {
ID string
RepoTags []string
RepoDigests []string
Config struct{ Labels map[string]string }
}
if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&detail) != nil {
return "", "", ""
}
version := firstNonEmpty(detail.Config.Labels["org.opencontainers.image.version"], detail.Config.Labels["version"], firstString(detail.RepoTags), fallback, shortDockerDigest(detail.ID))
digest := ""
if len(detail.RepoDigests) > 0 {
digest = detail.RepoDigests[0]
if _, value, found := strings.Cut(digest, "@"); found {
digest = value
}
}
if digest == "" {
digest = detail.ID
}
return version, shortDockerDigest(digest), detail.ID
}
func shortDockerDigest(value string) string {
value = strings.TrimPrefix(value, "sha256:")
if len(value) > 12 {
value = value[:12]
}
return value
}
func firstString(v []string) string {
if len(v) > 0 {
return v[0]

View File

@@ -8,7 +8,6 @@ import (
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
@@ -63,16 +62,16 @@ func (s *Store) QueueAgentAction(agentID, action, target string) (AgentAction, e
}
}
}
case "docker-restart", "docker-check", "docker-update":
case "docker-restart", "docker-check", "docker-update", "docker-remove":
if safeContainerName.MatchString(target) {
for _, container := range report.Containers {
if container.ID == target || container.Name == target {
if (container.ID == target || container.Name == target) && (action != "docker-remove" || container.State != "running") {
valid = true
break
}
}
}
case "updates-refresh", "system-update":
case "updates-refresh", "system-update", "system-reboot":
valid = target == ""
}
if !valid {
@@ -168,8 +167,12 @@ func executeAgentCommand(command AgentCommand) AgentActionResult {
output = updateAgentContainer(command.Target, true)
case "docker-check":
output = updateAgentContainer(command.Target, false)
case "docker-remove":
output = removeAgentContainer(command.Target)
case "system-update":
output = updateAgentSystem()
case "system-reboot":
output = scheduleAgentReboot()
case "updates-refresh":
guestInventory.Lock()
guestInventory.value.CollectedAt = time.Time{}
@@ -189,26 +192,78 @@ func executeAgentCommand(command AgentCommand) AgentActionResult {
return result
}
func updateAgentSystem() string {
if output := runAgentCombinedCommand(3*time.Minute, "apt-get", "update"); strings.HasPrefix(output, "ERROR: ") {
return output
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
command := exec.CommandContext(ctx, "apt-get", "-y", "upgrade")
command.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
output, err := command.CombinedOutput()
message := strings.TrimSpace(string(output))
if len(message) > 3500 {
message = message[len(message)-3500:]
}
func removeAgentContainer(target string) string {
transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock")
}}
defer transport.CloseIdleConnections()
request, _ := http.NewRequest(http.MethodDelete, "http://docker/containers/"+target+"?v=false&force=false", nil)
response, err := (&http.Client{Transport: transport, Timeout: 20 * time.Second}).Do(request)
if err != nil {
return "ERROR: " + err.Error() + ": " + message
return "ERROR: " + err.Error()
}
defer response.Body.Close()
if response.StatusCode != http.StatusNoContent {
message, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
return fmt.Sprintf("ERROR: Docker вернул %s: %s", response.Status, strings.TrimSpace(string(message)))
}
return "Остановленный контейнер удалён; volumes сохранены"
}
func updateAgentSystem() string {
aptGet, err := exec.LookPath("apt-get")
if err != nil {
return "ERROR: apt-get не найден; обновление поддерживается для Debian/Ubuntu"
}
if output := runPrivilegedTransient(5*time.Minute, "update-lists", aptGet, "update"); strings.HasPrefix(output, "ERROR: ") {
return explainSystemUpdateError(output)
}
message := runPrivilegedTransient(30*time.Minute, "upgrade", aptGet, "-y", "upgrade")
if strings.HasPrefix(message, "ERROR: ") {
return explainSystemUpdateError(message)
}
guestInventory.Lock()
guestInventory.value.CollectedAt = time.Time{}
guestInventory.Unlock()
return firstNonEmpty(message, "Обновления системы установлены")
return "Обновления системы установлены; список пакетов будет обновлён в следующем отчёте агента"
}
func explainSystemUpdateError(message string) string {
if strings.Contains(strings.ToLower(message), "read-only file system") {
return "ERROR: файловая система агента доступна только для чтения. Обновите Dashboard и агент до v0.19.2 или новее, затем повторите операцию."
}
if len(message) > 1800 {
message = message[len(message)-1800:]
}
return message
}
func runPrivilegedTransient(timeout time.Duration, suffix, name string, args ...string) string {
systemdRun, err := exec.LookPath("systemd-run")
if err != nil {
return "ERROR: systemd-run не найден; невозможно безопасно выйти из read-only sandbox агента"
}
unit := fmt.Sprintf("proxmox-dashboard-%s-%d", suffix, time.Now().UnixNano())
commandArgs := []string{"--quiet", "--wait", "--collect", "--pipe", "--unit=" + unit, "--setenv=DEBIAN_FRONTEND=noninteractive", name}
commandArgs = append(commandArgs, args...)
return runAgentCombinedCommand(timeout, systemdRun, commandArgs...)
}
func scheduleAgentReboot() string {
systemdRun, err := exec.LookPath("systemd-run")
if err != nil {
return "ERROR: systemd-run не найден"
}
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return "ERROR: systemctl не найден"
}
unit := fmt.Sprintf("proxmox-dashboard-reboot-%d", time.Now().UnixNano())
output := runAgentCombinedCommand(15*time.Second, systemdRun, "--quiet", "--collect", "--unit="+unit, "--on-active=20s", systemctl, "reboot")
if strings.HasPrefix(output, "ERROR: ") {
return output
}
return "Перезагрузка запланирована через 20 секунд; кратковременный offline агента ожидаем"
}
func updateAgentContainer(target string, recreate bool) string {

View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"strings"
"testing"
"time"
)
@@ -12,7 +13,7 @@ func TestAgentActionAllowlistAndLifecycle(t *testing.T) {
t.Fatal(err)
}
defer store.Close()
report, _ := json.Marshal(AgentReport{Services: []AgentService{{Name: "gitea.service"}}, Containers: []AgentContainer{{ID: "abc123", Name: "gitea"}}})
report, _ := json.Marshal(AgentReport{Services: []AgentService{{Name: "gitea.service"}}, Containers: []AgentContainer{{ID: "abc123", Name: "gitea", State: "running"}, {ID: "old123", Name: "old", State: "exited"}}})
_, err = store.db.Exec(`INSERT INTO agents(id,name,secret_hash,enrolled_at,last_seen,report_json) VALUES('a','agent','x',?,?,?)`, time.Now().Unix(), time.Now().Unix(), string(report))
if err != nil {
t.Fatal(err)
@@ -26,9 +27,12 @@ func TestAgentActionAllowlistAndLifecycle(t *testing.T) {
if _, err = store.QueueAgentAction("a", "system-update", ""); err != nil {
t.Fatalf("system update rejected: %v", err)
}
action, err := store.QueueAgentAction("a", "systemd-restart", "gitea.service")
if _, err = store.QueueAgentAction("a", "docker-remove", "gitea"); err == nil {
t.Fatal("running/unknown-state container accepted for removal")
}
action, err := store.QueueAgentAction("a", "docker-remove", "old")
if err != nil {
t.Fatal(err)
t.Fatalf("stopped container rejected for removal: %v", err)
}
commands, err := store.DispatchAgentActions("a")
if err != nil || len(commands) != 3 || commands[2].ID != action.ID {
@@ -39,6 +43,12 @@ func TestAgentActionAllowlistAndLifecycle(t *testing.T) {
if err != nil || len(items) != 3 || items[0].Status != "completed" {
t.Fatalf("items=%#v err=%v", items, err)
}
if _, err = store.QueueAgentAction("a", "system-reboot", ""); err != nil {
t.Fatalf("system reboot rejected: %v", err)
}
if _, err = store.QueueAgentAction("a", "system-reboot", "now"); err == nil {
t.Fatal("system reboot with unexpected target accepted")
}
}
func TestManualServiceLinkGroupsOfflineAlerts(t *testing.T) {
@@ -58,3 +68,10 @@ func TestManualServiceLinkGroupsOfflineAlerts(t *testing.T) {
t.Fatalf("group alert missing: %#v", grouped)
}
}
func TestExplainReadOnlySystemUpdateError(t *testing.T) {
message := explainSystemUpdateError("ERROR: apt failed: Read-only file system")
if !strings.Contains(message, "v0.19.2") {
t.Fatalf("read-only failure is not actionable: %s", message)
}
}

View File

@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
@@ -25,6 +26,8 @@ type AgentProcess struct {
Name string `json:"name"`
State string `json:"state"`
CPUPercent float64 `json:"cpuPercent"`
CPUCores float64 `json:"cpuCores"`
HostPercent float64 `json:"hostPercent"`
MemoryBytes uint64 `json:"memoryBytes"`
ReadBytes uint64 `json:"readBytes"`
WriteBytes uint64 `json:"writeBytes"`
@@ -174,7 +177,11 @@ func collectAgentProcesses() []AgentProcess {
cpu, _ := strconv.ParseFloat(fields[3], 64)
rss, _ := strconv.ParseUint(fields[4], 10, 64)
elapsed, _ := strconv.ParseInt(fields[5], 10, 64)
item := AgentProcess{PID: pid, Name: fields[1], State: fields[2], CPUPercent: cpu, MemoryBytes: rss * 1024, Elapsed: elapsed, Command: strings.Join(fields[6:], " ")}
cpuCount := runtime.NumCPU()
if cpuCount < 1 {
cpuCount = 1
}
item := AgentProcess{PID: pid, Name: fields[1], State: fields[2], CPUPercent: cpu, CPUCores: cpu / 100, HostPercent: cpu / float64(cpuCount), MemoryBytes: rss * 1024, Elapsed: elapsed, Command: strings.Join(fields[6:], " ")}
item.ReadBytes, item.WriteBytes = readAgentProcessIO(pid)
result = append(result, item)
added++

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
}

View File

@@ -53,6 +53,12 @@ func TestAgentOnlyInstallsNewerVersion(t *testing.T) {
}
}
func TestShortDockerDigest(t *testing.T) {
if got := shortDockerDigest("sha256:1234567890abcdef"); got != "1234567890ab" {
t.Fatalf("unexpected short digest: %q", got)
}
}
func TestParseAgentIssues(t *testing.T) {
issues := parseAgentIssues(`{"MESSAGE":"disk error","_SYSTEMD_UNIT":"worker.service","__REALTIME_TIMESTAMP":"1722960000000000"}` + "\n" + `{"MESSAGE":"network error","__REALTIME_TIMESTAMP":"1722960001000000"}`)
if len(issues) != 2 || issues[0].Unit != "worker.service" || issues[1].Unit != "system" || issues[0].Time != 1722960000 {

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)
}
}
}

View File

@@ -263,10 +263,13 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
}
var networkErrors uint64
for _, iface := range metrics.Network.Interfaces {
networkErrors += iface.NewErrors + iface.NewDrops
networkErrors += iface.NewErrors + iface.NewFaults
if iface.LinkDown {
add("network-link-"+iface.Name, "critical", "Сеть", iface.Name+": нет линка", "Используемый интерфейс потерял carrier или перешёл в down.")
}
}
if networkErrors > 0 {
add("network-errors", "warning", "Сеть", "Появились новые сетевые ошибки или потери", fmt.Sprintf("За последний интервал добавилось: %d. Старые счётчики не создают активный алерт.", networkErrors))
add("network-errors", "critical", "Сеть", "Появились новые сетевые ошибки", fmt.Sprintf("Прирост счётчиков ошибок: %d (счётчики могут пересекаться). Software RX/TX drops показаны отдельно и не создают этот алерт.", networkErrors))
}
if !metrics.Network.Health.CheckedAt.IsZero() && !metrics.Network.Health.GatewayUp {
add("network-gateway", "critical", "Сеть", "Недоступен локальный шлюз", firstNonEmpty(metrics.Network.Health.Gateway, "Default gateway не определён."))
@@ -286,14 +289,7 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
for _, name := range metrics.Network.Health.DisappearedInterfaces {
add("network-interface-missing-"+name, "warning", "Сеть", "Исчез сетевой интерфейс", name+" больше не присутствует в /proc/net/dev.")
}
for _, iface := range metrics.Network.Interfaces {
if iface.Kind == "Physical" && !iface.Carrier && (iface.Master != "" || iface.ReceiveBytes+iface.TransmitBytes > 0) {
add("network-link-"+iface.Name, "warning", "Сеть", iface.Name+": нет carrier", "Физический линк не поднят.")
}
if (iface.Kind == "Bridge" || iface.Kind == "Bond" || iface.Kind == "VLAN") && iface.State == "down" {
add("network-state-"+iface.Name, "warning", "Сеть", iface.Name+": интерфейс выключен", iface.Kind+" находится в состоянии down.")
}
}
if !metrics.Monitor.LastSuccessfulCollection.IsZero() && time.Since(metrics.Monitor.LastSuccessfulCollection) > 30*time.Second {
add("monitor-stale", "critical", "Dashboard", "Данные мониторинга устарели", "Последний успешный сбор был "+time.Since(metrics.Monitor.LastSuccessfulCollection).Round(time.Second).String()+" назад.")
}

14
assets/NOTICE.md Normal file
View File

@@ -0,0 +1,14 @@
# Map data
`world.json` is derived from Natural Earth 1:110m admin-0 countries (coordinates
rounded to two decimal places; only country codes, labels and boundaries kept).
Natural Earth data is public domain: https://www.naturalearthdata.com/about/terms-of-use/
Source: https://github.com/nvkelso/natural-earth-vector/blob/master/geojson/ne_110m_admin_0_countries.geojson
`dbip-country-lite-2026-10.mmdb.gz`: IP Geolocation by DB-IP, October 2026.
Source: https://download.db-ip.com/free/dbip-country-lite-2026-10.mmdb.gz
License: Creative Commons Attribution 4.0 International (CC BY 4.0).
https://creativecommons.org/licenses/by/4.0/
https://db-ip.com/db/download/ip-to-country-lite
The database is unmodified. The traffic page displays the required DB-IP link.
Countries are approximate; plotted positions are country labels, not exact IP locations.

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>

122
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

39
cpu.go
View File

@@ -3,6 +3,7 @@ package main
import (
"bufio"
"fmt"
"math"
"os"
"path/filepath"
"strconv"
@@ -33,15 +34,16 @@ type CPUStatic struct {
type CPUMetrics struct {
CPUStatic
UsagePercent float64 `json:"usagePercent"`
FrequencyMHz float64 `json:"frequencyMhz"`
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
Uptime uint64 `json:"uptimeSeconds"`
Temperatures []Temperature `json:"temperatures"`
TopProcesses []AgentProcess `json:"topProcesses"`
CollectedAt time.Time `json:"collectedAt"`
UsagePercent float64 `json:"usagePercent"`
FrequencyMHz float64 `json:"frequencyMhz"`
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
Uptime uint64 `json:"uptimeSeconds"`
TemperatureCelsius *float64 `json:"temperatureCelsius"`
Temperatures []Temperature `json:"temperatures"`
TopProcesses []AgentProcess `json:"topProcesses"`
CollectedAt time.Time `json:"collectedAt"`
}
type Temperature struct {
@@ -90,10 +92,11 @@ func (c *cpuCollector) collect() (CPUMetrics, error) {
load1, load5, load15, _ := readLoadAverage()
uptime, _ := readUptime()
temperatures := readTemperatures()
return CPUMetrics{
CPUStatic: c.static, UsagePercent: usage, FrequencyMHz: frequency,
Load1: load1, Load5: load5, Load15: load15, Uptime: uptime,
Temperatures: readTemperatures(), TopProcesses: collectAgentProcesses(), CollectedAt: time.Now(),
Temperatures: temperatures, TemperatureCelsius: cpuTemperature(temperatures), TopProcesses: collectAgentProcesses(), CollectedAt: time.Now(),
}, nil
}
@@ -285,3 +288,19 @@ func isCPUTemperatureChip(name string) bool {
return false
}
}
// cpuTemperature is the hottest CPU sensor, shared by live and historical views.
// Do not average Tctl/Tdie with cooler cores or CCDs.
func cpuTemperature(temperatures []Temperature) *float64 {
var hottest *float64
for _, t := range temperatures {
if math.IsNaN(t.Celsius) || math.IsInf(t.Celsius, 0) || t.Celsius <= 0 {
continue
}
if hottest == nil || t.Celsius > *hottest {
value := t.Celsius
hottest = &value
}
}
return hottest
}

24
cpu_temperature_test.go Normal file
View File

@@ -0,0 +1,24 @@
package main
import (
"math"
"testing"
)
func TestCPUTemperatureSharedWithHistory(t *testing.T) {
temperatures := []Temperature{{Label: "k10temp · Tctl", Celsius: 62}, {Label: "k10temp · Tccd1", Celsius: 38}, {Label: "k10temp · Tccd2", Celsius: 41}}
live := cpuTemperature(temperatures)
point := historyPointFromMetrics(dashboardMetrics{CPU: CPUMetrics{Temperatures: temperatures}})
if live == nil || *live != 62 || point.CPUTemperature != *live {
t.Fatalf("live/history disagree: %v, %+v", live, point)
}
}
func TestCPUTemperatureInvalidSensors(t *testing.T) {
if cpuTemperature([]Temperature{{Celsius: math.NaN()}, {Celsius: math.Inf(1)}, {Celsius: 0}}) != nil {
t.Fatal("invalid temperature selected")
}
if cpuTemperature(nil) != nil {
t.Fatal("missing sensor should stay missing")
}
}

5
go.mod
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=

View File

@@ -99,12 +99,10 @@ func historyPointFromMetrics(metrics dashboardMetrics) HistoryPoint {
NetworkTransmit: metrics.Network.TransmitPerSec, UPSCharge: metrics.UPS.ChargePercent,
UPSLoad: metrics.UPS.LoadPercent,
}
if len(metrics.CPU.Temperatures) > 0 {
for _, temperature := range metrics.CPU.Temperatures {
point.CPUTemperature += temperature.Celsius
}
point.CPUTemperature /= float64(len(metrics.CPU.Temperatures))
if temperature := cpuTemperature(metrics.CPU.Temperatures); temperature != nil {
point.CPUTemperature = *temperature
}
var zfsSize, zfsUsed uint64
for _, pool := range metrics.ZFS.Pools {
zfsSize += pool.SizeBytes

View File

@@ -8,7 +8,7 @@ func TestHistoryPointFromMetrics(t *testing.T) {
Guests: GuestsMetrics{Guests: []GuestMetrics{{Status: "running", CPU: .5, MaxCPU: 4, MemoryBytes: 50, MaxMemory: 100}}},
}
point := historyPointFromMetrics(metrics)
if point.CPUTemperature != 50 || point.GuestsCPU != 50 || point.GuestsMemory != 50 {
if point.CPUTemperature != 60 || point.GuestsCPU != 50 || point.GuestsMemory != 50 {
t.Fatalf("неожиданная историческая точка: %+v", point)
}
}

View File

@@ -43,26 +43,31 @@ type NetworkHealth struct {
}
type NetworkInterface struct {
Name string `json:"name"`
ReceiveBytes uint64 `json:"receiveBytes"`
TransmitBytes uint64 `json:"transmitBytes"`
ReceiveErrors uint64 `json:"receiveErrors"`
TransmitErrors uint64 `json:"transmitErrors"`
ReceiveDrops uint64 `json:"receiveDrops"`
TransmitDrops uint64 `json:"transmitDrops"`
NewErrors uint64 `json:"newErrors"`
NewDrops uint64 `json:"newDrops"`
ReceivePerSec float64 `json:"receivePerSec"`
TransmitPerSec float64 `json:"transmitPerSec"`
VMID int `json:"vmid,omitempty"`
GuestName string `json:"guestName,omitempty"`
GuestType string `json:"guestType,omitempty"`
Kind string `json:"kind"`
State string `json:"state"`
Carrier bool `json:"carrier"`
SpeedMbps int `json:"speedMbps,omitempty"`
Duplex string `json:"duplex,omitempty"`
Master string `json:"master,omitempty"`
Name string `json:"name"`
ReceiveBytes uint64 `json:"receiveBytes"`
TransmitBytes uint64 `json:"transmitBytes"`
ReceiveErrors uint64 `json:"receiveErrors"`
TransmitErrors uint64 `json:"transmitErrors"`
ReceiveDrops uint64 `json:"receiveDrops"`
TransmitDrops uint64 `json:"transmitDrops"`
NewErrors uint64 `json:"newErrors"`
NewReceiveDrops uint64 `json:"newReceiveDrops"`
NewTransmitDrops uint64 `json:"newTransmitDrops"`
FaultCounters map[string]uint64 `json:"faultCounters,omitempty"`
NewFaults uint64 `json:"newFaults"`
LinkDown bool `json:"linkDown"`
NewDrops uint64 `json:"newDrops"`
ReceivePerSec float64 `json:"receivePerSec"`
TransmitPerSec float64 `json:"transmitPerSec"`
VMID int `json:"vmid,omitempty"`
GuestName string `json:"guestName,omitempty"`
GuestType string `json:"guestType,omitempty"`
Kind string `json:"kind"`
State string `json:"state"`
Carrier bool `json:"carrier"`
SpeedMbps int `json:"speedMbps,omitempty"`
Duplex string `json:"duplex,omitempty"`
Master string `json:"master,omitempty"`
}
type networkSnapshot struct {
@@ -83,13 +88,14 @@ type networkCollector struct {
}
func newNetworkCollector(path string) (*networkCollector, error) {
snapshot, _, _, err := readNetworkSnapshot(path)
snapshot, _, details, err := readNetworkSnapshot(path)
if err != nil {
return nil, err
}
_, _, details, _ := readNetworkSnapshot(path)
previous := map[string]NetworkInterface{}
for _, v := range details {
decorateInterface(&v)
collectInterfaceFaults(&v)
previous[v.Name] = v
}
known := map[string]bool{}
@@ -116,16 +122,11 @@ func (c *networkCollector) collect() (NetworkMetrics, error) {
for i := range details {
v := &details[i]
decorateInterface(v)
if old, ok := c.previousInterfaces[v.Name]; ok && elapsed > 0 && v.ReceiveBytes >= old.ReceiveBytes && v.TransmitBytes >= old.TransmitBytes {
v.ReceivePerSec = float64(v.ReceiveBytes-old.ReceiveBytes) / elapsed
v.TransmitPerSec = float64(v.TransmitBytes-old.TransmitBytes) / elapsed
if v.ReceiveErrors >= old.ReceiveErrors && v.TransmitErrors >= old.TransmitErrors {
v.NewErrors = v.ReceiveErrors - old.ReceiveErrors + v.TransmitErrors - old.TransmitErrors
}
if v.ReceiveDrops >= old.ReceiveDrops && v.TransmitDrops >= old.TransmitDrops {
v.NewDrops = v.ReceiveDrops - old.ReceiveDrops + v.TransmitDrops - old.TransmitDrops
}
collectInterfaceFaults(v)
if old, ok := c.previousInterfaces[v.Name]; ok {
updateInterfaceDeltas(v, old, elapsed)
}
v.LinkDown = interfaceLinkDown(*v)
}
c.previousInterfaces = map[string]NetworkInterface{}
for _, v := range details {
@@ -166,19 +167,19 @@ func explainNetworkHealth(health *NetworkHealth, interfaces []NetworkInterface)
health.Summary, health.LikelyCause, health.RecommendedAction = "Хост не видит локальный шлюз", "Проблема между Proxmox и роутером: линк, bridge, bond или VLAN", "Проверьте кабель, carrier и настройки vmbr/bond/VLAN"
case health.PacketLoss > 0:
health.Summary = fmt.Sprintf("Нестабильная локальная сеть: потеряно %.1f%% пакетов", health.PacketLoss)
health.LikelyCause, health.RecommendedAction = "Перегрузка или ошибки линка между Proxmox и шлюзом", "Проверьте errors/drops, кабель, порт коммутатора и duplex"
health.LikelyCause, health.RecommendedAction = "Перегрузка или ошибки линка между Proxmox и шлюзом", "Проверьте аппаратные ошибки, кабель, порт коммутатора и duplex"
case !health.DNSUp && health.InternetUp:
health.Summary, health.LikelyCause, health.RecommendedAction = "Интернет доступен, но DNS не отвечает", "Проблема DNS-сервера или /etc/resolv.conf", "Проверьте настроенные DNS и попробуйте разрешить домен вручную"
case health.DNSUp && !health.InternetUp:
health.Summary, health.LikelyCause, health.RecommendedAction = "Локальная сеть и DNS работают, но внешняя проверка недоступна", "Проблема выше роутера, блокировка HTTPS или недоступность провайдера", "Проверьте маршрут наружу, firewall и доступ к внешним адресам"
}
for _, v := range interfaces {
if v.Kind == "Physical" && (!v.Carrier || v.State == "down") {
health.Summary, health.LikelyCause, health.RecommendedAction = v.Name+": физический линк не поднят", "Нет carrier либо интерфейс административно выключен", "Проверьте кабель, порт коммутатора и состояние интерфейса"
if v.LinkDown {
health.Summary, health.LikelyCause, health.RecommendedAction = v.Name+": линк не поднят", "Нет carrier либо интерфейс административно выключен", "Проверьте кабель, порт коммутатора и состояние интерфейса"
return
}
if v.NewErrors+v.NewDrops > 0 {
health.Summary = fmt.Sprintf("%s: за последний интервал появилось %d сетевых ошибок/потерь", v.Name, v.NewErrors+v.NewDrops)
if v.NewErrors+v.NewFaults > 0 {
health.Summary = fmt.Sprintf("%s: за последний интервал появилось %d изменений счётчиков сетевых ошибок", v.Name, v.NewErrors+v.NewFaults)
health.LikelyCause, health.RecommendedAction = "Счётчики интерфейса сейчас растут из-за перегрузки, кабеля, MTU или duplex", "Проверьте скорость и duplex, кабель, порт коммутатора и MTU на обоих концах"
return
}
@@ -195,6 +196,10 @@ func decorateInterface(v *NetworkInterface) {
v.Master = filepath.Base(target)
}
switch {
case strings.HasPrefix(v.Name, "fwbr"):
v.Kind = "Firewall bridge"
case strings.HasPrefix(v.Name, "fwpr") || strings.HasPrefix(v.Name, "fwln"):
v.Kind = "Firewall link"
case strings.HasPrefix(v.Name, "vmbr"):
v.Kind = "Bridge"
case strings.HasPrefix(v.Name, "bond"):
@@ -273,7 +278,7 @@ func readNetworkSnapshot(path string) (networkSnapshot, []string, []NetworkInter
continue
}
fields := strings.Fields(values)
if len(fields) < 9 {
if len(fields) < 16 {
continue
}
rx, errRX := strconv.ParseUint(fields[0], 10, 64)

103
network_faults.go Normal file
View File

@@ -0,0 +1,103 @@
package main
import (
"context"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
func counterDelta(current, previous uint64) uint64 {
if current < previous {
return 0
}
return current - previous
}
func updateInterfaceDeltas(v *NetworkInterface, old NetworkInterface, elapsed float64) {
if elapsed <= 0 {
return
}
v.ReceivePerSec = float64(counterDelta(v.ReceiveBytes, old.ReceiveBytes)) / elapsed
v.TransmitPerSec = float64(counterDelta(v.TransmitBytes, old.TransmitBytes)) / elapsed
v.NewErrors = counterDelta(v.ReceiveErrors, old.ReceiveErrors) + counterDelta(v.TransmitErrors, old.TransmitErrors)
v.NewReceiveDrops = counterDelta(v.ReceiveDrops, old.ReceiveDrops)
v.NewTransmitDrops = counterDelta(v.TransmitDrops, old.TransmitDrops)
v.NewDrops = v.NewReceiveDrops + v.NewTransmitDrops
for name, value := range v.FaultCounters {
if previous, ok := old.FaultCounters[name]; ok {
v.NewFaults += counterDelta(value, previous)
}
}
// A transition matters even if the interface has not carried traffic yet.
v.LinkDown = old.Carrier && !v.Carrier
}
func interfaceLinkDown(v NetworkInterface) bool {
if v.Kind == "Firewall bridge" || v.Kind == "Firewall link" {
return false
}
if v.LinkDown {
return true
}
used := v.Master != "" || v.ReceiveBytes+v.TransmitBytes > 0
if v.Kind == "Physical" {
return used && (!v.Carrier || v.State == "down")
}
return used && v.State == "down"
}
func collectInterfaceFaults(v *NetworkInterface) {
base := filepath.Join("/sys/class/net", v.Name)
// /proc/net/dev folds missed packets into RX drop. Sysfs keeps software
// drops separate from rx_missed_errors, which must still trigger an alert.
if n, err := strconv.ParseUint(readTrimmed(filepath.Join(base, "statistics/rx_dropped")), 10, 64); err == nil {
v.ReceiveDrops = n
}
v.FaultCounters = map[string]uint64{}
for _, name := range []string{"rx_crc_errors", "rx_frame_errors", "rx_fifo_errors", "rx_missed_errors", "tx_fifo_errors", "tx_carrier_errors", "carrier_down_count"} {
path := filepath.Join(base, "statistics", name)
if name == "carrier_down_count" {
path = filepath.Join(base, name)
}
if n, err := strconv.ParseUint(readTrimmed(path), 10, 64); err == nil {
v.FaultCounters[name] = n
}
}
// Only Atlantic exposes the driver-specific names below. No configuration
// changes, and a missing ethtool is not itself a network failure.
driver, err := filepath.EvalSymlinks(filepath.Join(base, "device/driver"))
if err != nil || filepath.Base(driver) != "atlantic" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if out, err := exec.CommandContext(ctx, "ethtool", "-S", v.Name).Output(); err == nil {
for k, n := range parseAtlanticFaults(string(out)) {
v.FaultCounters["atlantic:"+k] = n
}
}
}
func parseAtlanticFaults(output string) map[string]uint64 {
result := map[string]uint64{}
for _, line := range strings.Split(output, "\n") {
key, value, ok := strings.Cut(strings.TrimSpace(line), ":")
if !ok {
continue
}
fields := strings.Fields(key)
if len(fields) == 0 {
continue
}
switch fields[len(fields)-1] {
case "InErrors", "InDroppedDma", "AllocFails", "SkbAllocFails", "Restarts":
if n, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64); err == nil {
result[key] = n
}
}
}
return result
}

67
network_faults_test.go Normal file
View File

@@ -0,0 +1,67 @@
package main
import (
"strings"
"testing"
)
func TestNetworkDropsDoNotAlert(t *testing.T) {
for _, name := range []string{"enp6s0", "vmbr0", "fwbr100i0", "tap100i0"} {
v := NetworkInterface{Name: name, Carrier: true, State: "up", NewDrops: 15, NewReceiveDrops: 15}
metrics := dashboardMetrics{Network: NetworkMetrics{Interfaces: []NetworkInterface{v}}}
for _, a := range evaluateAlerts(metrics, AlertThresholds{}) {
if a.ID == "network-errors" {
t.Fatalf("%s: drops triggered alert", name)
}
}
v.NewErrors = 1
metrics.Network.Interfaces[0] = v
found := false
for _, a := range evaluateAlerts(metrics, AlertThresholds{}) {
if a.ID == "network-errors" {
found = a.Severity == "critical"
}
}
if !found {
t.Fatalf("%s: errors did not trigger critical alert", name)
}
}
}
func TestNetworkHardwareFaultAndLinkAlerts(t *testing.T) {
for _, v := range []NetworkInterface{{Name: "eth0", NewFaults: 1}, {Name: "eth0", LinkDown: true}} {
found := false
for _, a := range evaluateAlerts(dashboardMetrics{Network: NetworkMetrics{Interfaces: []NetworkInterface{v}}}, AlertThresholds{}) {
if strings.HasPrefix(a.ID, "network-") && a.Severity == "critical" {
found = true
}
}
if !found {
t.Fatalf("no critical alert for %+v", v)
}
}
}
func TestNetworkCounterResetsAndFaultBaselines(t *testing.T) {
old := NetworkInterface{ReceiveBytes: 100, ReceiveErrors: 20, TransmitErrors: 4, ReceiveDrops: 8, FaultCounters: map[string]uint64{"rx_missed_errors": 10, "atlantic:InErrors": 9}, Carrier: true}
next := NetworkInterface{ReceiveBytes: 1, ReceiveErrors: 1, TransmitErrors: 6, ReceiveDrops: 10, FaultCounters: map[string]uint64{"rx_missed_errors": 12, "atlantic:InErrors": 1, "new": 100}, Carrier: false}
updateInterfaceDeltas(&next, old, 2)
if next.NewErrors != 2 || next.NewFaults != 2 || next.NewReceiveDrops != 2 || next.ReceivePerSec != 0 || !next.LinkDown {
t.Fatalf("bad deltas: %+v", next)
}
}
func TestAtlanticFaults(t *testing.T) {
got := parseAtlanticFaults("NIC statistics:\n InErrors: 2\n InDroppedDma: 3\n Queue[0] AllocFails: 4\n Queue[1] SkbAllocFails: 5\n Queue[0] Restarts: 6\n Queue[0] InErrors: 7\n InPackets: 100\n Queue[0] XdpDrop: 50\n InErrorsBad: nope")
if len(got) != 6 || got["InDroppedDma"] != 3 || got["Queue[0] Restarts"] != 6 {
t.Fatalf("bad counters: %v", got)
}
}
func TestFirewallNotAttributedToGuest(t *testing.T) {
n := NetworkMetrics{Interfaces: []NetworkInterface{{Name: "fwbr100i0", ReceiveDrops: 100}, {Name: "tap100i0"}}}
decorateNetworkInterfaces(&n, GuestsMetrics{})
if n.Interfaces[0].VMID != 0 || n.Interfaces[1].VMID != 100 {
t.Fatalf("bad VM attribution: %+v", n)
}
}

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

@@ -49,13 +49,13 @@ func TestExplainNetworkHealthIgnoresOldInterfaceCounters(t *testing.T) {
t.Fatalf("старый накопленный счётчик вызвал текущую тревогу: %+v", health)
}
explainNetworkHealth(&health, []NetworkInterface{{Name: "eno1", Carrier: true, State: "up", ReceiveDrops: 43, NewDrops: 1}})
if !strings.Contains(health.Summary, "последний интервал") {
t.Fatalf("рост счётчика не объяснён: %+v", health)
if health.Summary != "Сеть работает штатно" {
t.Fatalf("software drops вызвали тревогу: %+v", health)
}
}
func TestInterfaceVMID(t *testing.T) {
for name, want := range map[string]int{"tap100i0": 100, "veth108i0": 108, "fwbr113i0": 113, "vmbr0": 0, "enp6s0": 0} {
for name, want := range map[string]int{"tap100i0": 100, "veth108i0": 108, "fwbr113i0": 0, "vmbr0": 0, "enp6s0": 0} {
if got := interfaceVMID(name); got != want {
t.Fatalf("%s: got %d want %d", name, got, want)
}

17
scripts/build-release.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
version=${1:?Usage: build-release.sh vX.Y.Z}
cd "$(dirname "$0")/.."
go test ./...
mkdir -p dist
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$version" -o dist/proxmox-dashboard-linux-amd64 .
(cd dist && shasum -a 256 proxmox-dashboard-linux-amd64 > proxmox-dashboard-linux-amd64.sha256)
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT INT TERM
package="$stage/proxmox-dashboard-install"
mkdir -p "$package/dist" "$package/scripts"
cp dist/proxmox-dashboard-linux-amd64 dist/proxmox-dashboard-linux-amd64.sha256 "$package/dist/"
cp scripts/install.sh scripts/install-agent.sh scripts/uninstall-agent.sh scripts/proxmox-dashboard-update "$package/scripts/"
cp assets/NOTICE.md "$package/MAP-DATA-NOTICE.md"
cp ./*.service ./*.timer ./*.path README.md "$package/"
tar -czf dist/proxmox-dashboard-install-amd64.tar.gz -C "$stage" proxmox-dashboard-install

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

346
traffic.go Normal file
View File

@@ -0,0 +1,346 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
type TrafficEvent struct {
Monitoring bool `json:"monitoring"`
ID string `json:"id"`
Sequence int64 `json:"sequence,omitempty"`
Time int64 `json:"time"`
Kind string `json:"kind"`
IP string `json:"ip"`
Domain string `json:"domain,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Status int `json:"status,omitempty"`
Reason string `json:"reason,omitempty"`
Country string `json:"country,omitempty"`
Agent string `json:"agent,omitempty"`
}
type TrafficBatch struct {
Events []TrafficEvent `json:"events"`
Files int `json:"files"`
BacklogBytes int64 `json:"backlogBytes"`
CrowdSecLimited bool `json:"crowdsecLimited"`
ParseErrors uint64 `json:"parseErrors"`
LogError string `json:"logError,omitempty"`
CrowdSecError string `json:"crowdsecError,omitempty"`
CrowdSecAt int64 `json:"crowdsecAt"`
StartedAt int64 `json:"startedAt"`
}
type TrafficSource struct {
Agent string `json:"agent"`
Name string `json:"name"`
LastSeen int64 `json:"lastSeen"`
Status TrafficBatch `json:"status"`
}
type TrafficCount struct {
Name string `json:"name"`
Count int64 `json:"count"`
}
type 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 {
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
}
where := ` WHERE ts>=? AND agent=?`
args := []any{now - 86400, v.Settings.AgentID}
if hide {
clause, params := monitoringSQL(v.Settings.MonitoringRules)
where += clause
args = append(args, params...)
}
if external {
where += ` AND country!='LAN'`
}
if domain != "" {
where += ` AND domain=?`
args = append(args, domain)
}
if kind != "" {
where += ` AND kind=?`
args = append(args, kind)
}
err := s.db.QueryRow(`SELECT COALESCE(SUM(kind='request'),0),COUNT(DISTINCT CASE WHEN kind='request' THEN ip END),COALESCE(SUM(kind='alert'),0),COALESCE(SUM(kind='decision'),0),COALESCE(SUM(kind='request' AND ts>=?),0)/60.0 FROM traffic_events`+where, append([]any{now - 60}, args...)...).Scan(&v.Requests, &v.UniqueIPs, &v.Alerts, &v.Decisions, &v.QPS)
if err != nil {
return v, err
}
rows, err := s.db.Query(`SELECT seq,event_id,ts,kind,ip,domain,method,path,status,reason,country,agent FROM traffic_events`+where+` ORDER BY ts DESC,seq DESC LIMIT 100`, args...)
if err != nil {
return v, err
}
for rows.Next() {
var e TrafficEvent
if err = rows.Scan(&e.Sequence, &e.ID, &e.Time, &e.Kind, &e.IP, &e.Domain, &e.Method, &e.Path, &e.Status, &e.Reason, &e.Country, &e.Agent); err != nil {
rows.Close()
return v, err
}
e.Monitoring = isMonitoringRequest(e, v.Settings.MonitoringRules)
v.Events = append(v.Events, e)
}
err = rows.Err()
rows.Close()
if err != nil {
return v, err
}
for _, q := range []struct {
column string
out *[]TrafficCount
}{{"country", &v.Countries}, {"domain", &v.Domains}, {"ip", &v.IPs}} {
r, e := s.db.Query(`SELECT `+q.column+`,COUNT(*) FROM traffic_events`+where+` GROUP BY `+q.column+` ORDER BY COUNT(*) DESC LIMIT 50`, args...)
if e != nil {
return v, e
}
for r.Next() {
var c TrafficCount
if e = r.Scan(&c.Name, &c.Count); e != nil {
r.Close()
return v, e
}
*q.out = append(*q.out, c)
}
e = r.Err()
r.Close()
if e != nil {
return v, e
}
}
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
}
r, err := s.db.Query(`SELECT CAST((ts/60)*60 AS TEXT),COUNT(*) FROM traffic_events`+where+` AND kind='request' AND ts>=? GROUP BY ts/60 ORDER BY ts/60`, append(args, now-3600)...)
if err != nil {
return v, err
}
for r.Next() {
var c TrafficCount
if err = r.Scan(&c.Name, &c.Count); err != nil {
r.Close()
return v, err
}
v.Series = append(v.Series, c)
}
err = r.Err()
r.Close()
if err != nil {
return v, err
}
r, err = s.db.Query(`SELECT t.agent,COALESCE(a.name,t.agent),t.last_seen,t.status_json FROM traffic_sources t LEFT JOIN agents a ON a.id=t.agent WHERE t.agent=? ORDER BY t.last_seen DESC`, v.Settings.AgentID)
if err != nil {
return v, err
}
for r.Next() {
var src TrafficSource
var raw string
if err = r.Scan(&src.Agent, &src.Name, &src.LastSeen, &raw); err != nil {
r.Close()
return v, err
}
json.Unmarshal([]byte(raw), &src.Status)
v.Sources = append(v.Sources, src)
}
err = r.Err()
r.Close()
var count int64
s.db.QueryRow(`SELECT COUNT(*) FROM traffic_events`).Scan(&count)
v.Limited = count >= 200000
return v, err
}
func trafficRoutes(mux *http.ServeMux, s *Store) {
trafficSettingsRoute(mux, s)
networkRoutes(mux, s)
mux.HandleFunc("/traffic", func(w http.ResponseWriter, r *http.Request) {
if !s.TrafficSettings().Enabled {
http.Error(w, "Карта трафика отключена. Включите её в настройках Dashboard.", 404)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(trafficHTML))
})
mux.HandleFunc("/api/traffic", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
w.WriteHeader(405)
return
}
if !s.TrafficSettings().Enabled {
http.Error(w, "Карта отключена", 404)
return
}
view, err := s.TrafficView(r.URL.Query().Get("domain"), r.URL.Query().Get("kind"), r.URL.Query().Get("external") == "1", r.URL.Query().Get("hideMonitoring") == "1")
writeJSON(w, view, err)
})
mux.HandleFunc("/api/traffic/world", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(trafficWorld)
})
mux.HandleFunc("/api/agent/traffic", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
w.WriteHeader(405)
return
}
id, secret, ok := agentCredentials(r)
if !ok || !s.AuthenticateAgent(id, secret) {
http.Error(w, "Unauthorized", 401)
return
}
settings := s.TrafficSettings()
if !settings.Enabled || settings.AgentID != id {
http.Error(w, "Источник отключён", 403)
return
}
var batch TrafficBatch
if json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&batch) != nil {
http.Error(w, "Invalid traffic batch", 400)
return
}
if err := s.SaveTraffic(id, batch); err != nil {
http.Error(w, fmt.Sprint(err), 500)
return
}
writeJSON(w, map[string]bool{"accepted": true}, nil)
})
}

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
}

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

65
web.go

File diff suppressed because one or more lines are too long