Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea890c01b1 | ||
|
|
f7b347212e | ||
|
|
72869dcf5f | ||
|
|
1296d052a5 |
108
README.md
108
README.md
@@ -32,7 +32,7 @@ ssh root@192.168.0.10
|
||||
|
||||
```bash
|
||||
apt-get update
|
||||
apt-get install -y curl ca-certificates tar smartmontools iproute2 iputils-ping
|
||||
apt-get install -y curl ca-certificates tar smartmontools iproute2 iputils-ping ethtool
|
||||
|
||||
cd /tmp
|
||||
curl -fL \
|
||||
@@ -401,3 +401,109 @@ 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
|
||||
```
|
||||
|
||||
23
agent.go
23
agent.go
@@ -144,9 +144,10 @@ type AgentUpdateManifest struct {
|
||||
}
|
||||
|
||||
type agentReportResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Update *AgentUpdateManifest `json:"update,omitempty"`
|
||||
Commands []AgentCommand `json:"commands,omitempty"`
|
||||
Accepted bool `json:"accepted"`
|
||||
TrafficEnabled bool `json:"trafficEnabled"`
|
||||
Update *AgentUpdateManifest `json:"update,omitempty"`
|
||||
Commands []AgentCommand `json:"commands,omitempty"`
|
||||
}
|
||||
|
||||
var guestInventory struct {
|
||||
@@ -339,6 +340,15 @@ func runGuestAgent(opts agentOptions) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var trafficStop func()
|
||||
stopTraffic := func() {
|
||||
if trafficStop != nil {
|
||||
trafficStop()
|
||||
trafficStop = nil
|
||||
}
|
||||
}
|
||||
defer stopTraffic()
|
||||
|
||||
client := &http.Client{Timeout: 12 * time.Second}
|
||||
for {
|
||||
report := collectAgentReport()
|
||||
@@ -356,6 +366,7 @@ func runGuestAgent(opts agentOptions) error {
|
||||
if response.StatusCode == http.StatusUnauthorized && opts.EnrollmentToken != "" {
|
||||
fresh, enrollmentErr := enrollRemote(config, opts.EnrollmentToken)
|
||||
if enrollmentErr == nil {
|
||||
stopTraffic()
|
||||
config = fresh
|
||||
if saveErr := saveAgentConfig(opts.ConfigPath, config); saveErr != nil {
|
||||
return saveErr
|
||||
@@ -372,6 +383,12 @@ func runGuestAgent(opts agentOptions) error {
|
||||
if response.StatusCode == http.StatusOK {
|
||||
var result agentReportResponse
|
||||
if json.Unmarshal(responseData, &result) == nil {
|
||||
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
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -72,7 +71,7 @@ func (s *Store) QueueAgentAction(agentID, action, target string) (AgentAction, e
|
||||
}
|
||||
}
|
||||
}
|
||||
case "updates-refresh", "system-update":
|
||||
case "updates-refresh", "system-update", "system-reboot":
|
||||
valid = target == ""
|
||||
}
|
||||
if !valid {
|
||||
@@ -172,6 +171,8 @@ func executeAgentCommand(command AgentCommand) AgentActionResult {
|
||||
output = removeAgentContainer(command.Target)
|
||||
case "system-update":
|
||||
output = updateAgentSystem()
|
||||
case "system-reboot":
|
||||
output = scheduleAgentReboot()
|
||||
case "updates-refresh":
|
||||
guestInventory.Lock()
|
||||
guestInventory.value.CollectedAt = time.Time{}
|
||||
@@ -210,25 +211,59 @@ func removeAgentContainer(target string) string {
|
||||
}
|
||||
|
||||
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:]
|
||||
}
|
||||
aptGet, err := exec.LookPath("apt-get")
|
||||
if err != nil {
|
||||
return "ERROR: " + err.Error() + ": " + message
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -42,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) {
|
||||
@@ -61,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)
|
||||
}
|
||||
}
|
||||
|
||||
387
agent_traffic.go
Normal file
387
agent_traffic.go
Normal file
@@ -0,0 +1,387 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var npmTrafficPattern = regexp.MustCompile(`^\[([^]]+)\] .*? (\d{3}) - ([A-Z]+) (?:https?|HTTP) (\S+) "([^"]*)" \[Client ([^]]+)\]`)
|
||||
|
||||
func parseNPMTraffic(line string) (TrafficEvent, bool) {
|
||||
m := npmTrafficPattern.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return TrafficEvent{}, false
|
||||
}
|
||||
ip := net.ParseIP(m[6])
|
||||
if ip == nil {
|
||||
return TrafficEvent{}, false
|
||||
}
|
||||
ts, err := time.Parse("02/Jan/2006:15:04:05 -0700", m[1])
|
||||
if err != nil {
|
||||
return TrafficEvent{}, false
|
||||
}
|
||||
status, _ := strconv.Atoi(m[2])
|
||||
return TrafficEvent{Time: ts.Unix(), Kind: "request", IP: ip.String(), Domain: trafficText(m[4], 253), Method: m[3], Path: trafficPath(m[5]), Status: status}, true
|
||||
}
|
||||
|
||||
type trafficCursor struct {
|
||||
Offset int64 `json:"offset"`
|
||||
Generation int64 `json:"generation"`
|
||||
Seen int64 `json:"seen"`
|
||||
}
|
||||
type trafficAgentState struct {
|
||||
BacklogBytes int64 `json:"backlogBytes"`
|
||||
Initialized bool `json:"initialized"`
|
||||
StartedAt int64 `json:"startedAt"`
|
||||
Cursors map[string]trafficCursor `json:"cursors"`
|
||||
Pending []TrafficEvent `json:"pending"`
|
||||
ParseErrors uint64 `json:"parseErrors"`
|
||||
CrowdSeen map[string]int64 `json:"crowdSeen"`
|
||||
}
|
||||
|
||||
func (state *trafficAgentState) readLogs(dir string) (int, error) {
|
||||
state.BacklogBytes = 0
|
||||
paths, err := filepath.Glob(filepath.Join(dir, "*_access.log*"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
sort.Strings(paths)
|
||||
files := 0
|
||||
budget := int64(1 << 20)
|
||||
now := time.Now().Unix()
|
||||
var firstErr error
|
||||
for _, path := range paths {
|
||||
if strings.HasSuffix(path, ".gz") {
|
||||
continue
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
firstErr = err
|
||||
continue
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
active := strings.HasSuffix(path, "_access.log")
|
||||
if active {
|
||||
files++
|
||||
}
|
||||
key := fmt.Sprintf("%d:%d", st.Dev, st.Ino)
|
||||
cursor, known := state.Cursors[key]
|
||||
if !known {
|
||||
if !state.Initialized || !active {
|
||||
cursor.Offset = info.Size()
|
||||
}
|
||||
cursor.Generation = now
|
||||
}
|
||||
cursor.Seen = now
|
||||
if info.Size() < cursor.Offset {
|
||||
cursor.Offset = 0
|
||||
cursor.Generation++
|
||||
}
|
||||
if len(state.Pending) < 400 && budget > 0 {
|
||||
_, err = f.Seek(cursor.Offset, io.SeekStart)
|
||||
if err == nil {
|
||||
reader := bufio.NewReaderSize(io.LimitReader(f, budget), 64<<10)
|
||||
for len(state.Pending) < 400 && budget > 0 {
|
||||
line, readErr := reader.ReadString('\n')
|
||||
// Keep an unfinished line for the next poll. Bound malformed huge lines.
|
||||
if readErr != nil {
|
||||
if len(line) >= 64<<10 {
|
||||
cursor.Offset += int64(len(line))
|
||||
state.ParseErrors++
|
||||
}
|
||||
break
|
||||
}
|
||||
offset := cursor.Offset
|
||||
cursor.Offset += int64(len(line))
|
||||
budget -= int64(len(line))
|
||||
if len(line) > 64<<10 {
|
||||
state.ParseErrors++
|
||||
continue
|
||||
}
|
||||
e, ok := parseNPMTraffic(line)
|
||||
if !ok {
|
||||
state.ParseErrors++
|
||||
continue
|
||||
}
|
||||
if e.Time < now-86400 {
|
||||
continue
|
||||
}
|
||||
e.ID = fmt.Sprintf("npm:%s:%d:%d", key, cursor.Generation, offset)
|
||||
state.Pending = append(state.Pending, e)
|
||||
}
|
||||
} else {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if info.Size() > cursor.Offset {
|
||||
state.BacklogBytes += info.Size() - cursor.Offset
|
||||
}
|
||||
state.Cursors[key] = cursor
|
||||
f.Close()
|
||||
}
|
||||
for key, cursor := range state.Cursors {
|
||||
if cursor.Seen < now-7*86400 {
|
||||
delete(state.Cursors, key)
|
||||
}
|
||||
}
|
||||
if files > 0 {
|
||||
state.Initialized = true
|
||||
}
|
||||
if firstErr != nil {
|
||||
return files, errors.New("Не удалось прочитать часть access-логов NPM")
|
||||
}
|
||||
if files == 0 {
|
||||
return files, errors.New("Access-логи NPM не найдены")
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
type crowdTrafficAlert struct {
|
||||
ID int64 `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Scenario string `json:"scenario"`
|
||||
Simulated bool `json:"simulated"`
|
||||
Source struct {
|
||||
IP string `json:"ip"`
|
||||
} `json:"source"`
|
||||
Events []struct {
|
||||
Meta []struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
} `json:"meta"`
|
||||
} `json:"events"`
|
||||
Decisions []struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
Simulated bool `json:"simulated"`
|
||||
} `json:"decisions"`
|
||||
}
|
||||
|
||||
func parseCrowdTraffic(data []byte) ([]TrafficEvent, error) {
|
||||
var alerts []crowdTrafficAlert
|
||||
if err := json.Unmarshal(data, &alerts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []TrafficEvent{}
|
||||
for _, a := range alerts {
|
||||
if a.Simulated || net.ParseIP(a.Source.IP) == nil {
|
||||
continue
|
||||
}
|
||||
ts, err := time.Parse(time.RFC3339Nano, a.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
id := a.UUID
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("%d:%d", a.ID, ts.Unix())
|
||||
}
|
||||
// An alert may span multiple domains. Do not invent a one-to-one link.
|
||||
domains := map[string]bool{}
|
||||
for _, event := range a.Events {
|
||||
for _, m := range event.Meta {
|
||||
if m.Key == "target_fqdn" {
|
||||
domains[m.Value] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
domain := ""
|
||||
if len(domains) == 1 {
|
||||
for d := range domains {
|
||||
domain = d
|
||||
}
|
||||
}
|
||||
out = append(out, TrafficEvent{ID: "cs-alert:" + id, Time: ts.Unix(), Kind: "alert", IP: a.Source.IP, Domain: domain, Reason: a.Scenario})
|
||||
for _, d := range a.Decisions {
|
||||
if !d.Simulated && net.ParseIP(d.Value) != nil {
|
||||
out = append(out, TrafficEvent{ID: fmt.Sprintf("cs-decision:%s:%d", id, d.ID), Time: ts.Unix(), Kind: "decision", IP: d.Value, Domain: domain, Reason: d.Type + ": " + a.Scenario})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// cscli uses the existing local machine credentials. No new bouncer key or
|
||||
// public LAPI port is needed, and only the read-only alerts list is executed.
|
||||
func collectCrowdTraffic(ctx context.Context) ([]TrafficEvent, bool, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "cscli", "alerts", "list", "--since", "24h", "--limit", "50", "-o", "json")
|
||||
var output limitedTrafficOutput
|
||||
cmd.Stdout = &output
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, false, errors.New("cscli alerts list недоступен; проверьте права агента и локальную LAPI")
|
||||
}
|
||||
events, err := parseCrowdTraffic(output.Bytes())
|
||||
if err != nil {
|
||||
return nil, false, errors.New("Не удалось разобрать JSON CrowdSec")
|
||||
}
|
||||
var raw []json.RawMessage
|
||||
json.Unmarshal(output.Bytes(), &raw)
|
||||
return events, len(raw) >= 50, nil
|
||||
}
|
||||
|
||||
type limitedTrafficOutput struct{ bytes.Buffer }
|
||||
|
||||
func (b *limitedTrafficOutput) Write(p []byte) (int, error) {
|
||||
if b.Len()+len(p) > 4<<20 {
|
||||
return 0, errors.New("CrowdSec response too large")
|
||||
}
|
||||
return b.Buffer.Write(p)
|
||||
}
|
||||
|
||||
func saveTrafficAgentState(path string, state trafficAgentState) error {
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".traffic-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := f.Name()
|
||||
defer os.Remove(name)
|
||||
if err = f.Chmod(0600); err == nil {
|
||||
_, err = f.Write(data)
|
||||
}
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
closeErr := f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
return os.Rename(name, path)
|
||||
}
|
||||
func runAgentTraffic(ctx context.Context, config agentConfig, stateDir string) {
|
||||
dir := os.Getenv("DASHBOARD_NPM_LOG_DIR")
|
||||
if dir == "off" {
|
||||
return
|
||||
}
|
||||
if dir == "" {
|
||||
dir = "/opt/npm/data/logs"
|
||||
}
|
||||
|
||||
statePath := filepath.Join(stateDir, "traffic-state.json")
|
||||
state := trafficAgentState{StartedAt: time.Now().Unix(), Cursors: map[string]trafficCursor{}, CrowdSeen: map[string]int64{}}
|
||||
if data, err := os.ReadFile(statePath); err == nil {
|
||||
if json.Unmarshal(data, &state) != nil {
|
||||
fmt.Fprintln(os.Stderr, "traffic: invalid checkpoint; collector stopped")
|
||||
return
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
fmt.Fprintln(os.Stderr, "traffic: checkpoint unreadable")
|
||||
return
|
||||
}
|
||||
if state.Cursors == nil {
|
||||
state.Cursors = map[string]trafficCursor{}
|
||||
}
|
||||
if state.CrowdSeen == nil {
|
||||
state.CrowdSeen = map[string]int64{}
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
lastCrowd := time.Time{}
|
||||
crowdAt := int64(0)
|
||||
crowdError := ""
|
||||
crowdLimited := false
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
files, logErr := state.readLogs(dir)
|
||||
if len(state.Pending) < 400 && time.Since(lastCrowd) >= 15*time.Second {
|
||||
events, limited, err := collectCrowdTraffic(ctx)
|
||||
crowdLimited = limited
|
||||
lastCrowd = time.Now()
|
||||
if err != nil {
|
||||
crowdError = err.Error()
|
||||
} else {
|
||||
crowdError = ""
|
||||
crowdAt = time.Now().Unix()
|
||||
for _, e := range events {
|
||||
if _, seen := state.CrowdSeen[e.ID]; !seen && len(state.Pending) < 500 {
|
||||
state.Pending = append(state.Pending, e)
|
||||
state.CrowdSeen[e.ID] = e.Time
|
||||
}
|
||||
}
|
||||
for id, ts := range state.CrowdSeen {
|
||||
if ts < time.Now().Unix()-86400 {
|
||||
delete(state.CrowdSeen, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Persist the pending batch AND offsets before sending. A retry/restart may
|
||||
// resend events; the server's (agent,event_id) unique key makes it idempotent.
|
||||
if err := saveTrafficAgentState(statePath, state); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "traffic: checkpoint write failed; collector stopped")
|
||||
return
|
||||
}
|
||||
batch := TrafficBatch{BacklogBytes: state.BacklogBytes, CrowdSecLimited: crowdLimited, Events: state.Pending, Files: files, ParseErrors: state.ParseErrors, CrowdSecAt: crowdAt, CrowdSecError: crowdError, StartedAt: state.StartedAt}
|
||||
if logErr != nil {
|
||||
batch.LogError = logErr.Error()
|
||||
}
|
||||
data, _ := json.Marshal(batch)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", config.Server+"/api/agent/traffic", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+config.ID+"."+config.Secret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if res, err := client.Do(req); err == nil {
|
||||
io.Copy(io.Discard, io.LimitReader(res.Body, 4096))
|
||||
res.Body.Close()
|
||||
if res.StatusCode == 200 {
|
||||
state.Pending = nil
|
||||
if saveTrafficAgentState(statePath, state) != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startAgentTraffic(config agentConfig, stateDir string) func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); runAgentTraffic(ctx, config, stateDir) }()
|
||||
return func() { cancel(); <-done }
|
||||
}
|
||||
16
alerts.go
16
alerts.go
@@ -263,10 +263,13 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
|
||||
}
|
||||
var networkErrors uint64
|
||||
for _, iface := range metrics.Network.Interfaces {
|
||||
networkErrors += iface.NewErrors + iface.NewDrops
|
||||
networkErrors += iface.NewErrors + iface.NewFaults
|
||||
if iface.LinkDown {
|
||||
add("network-link-"+iface.Name, "critical", "Сеть", iface.Name+": нет линка", "Используемый интерфейс потерял carrier или перешёл в down.")
|
||||
}
|
||||
}
|
||||
if networkErrors > 0 {
|
||||
add("network-errors", "warning", "Сеть", "Появились новые сетевые ошибки или потери", fmt.Sprintf("За последний интервал добавилось: %d. Старые счётчики не создают активный алерт.", networkErrors))
|
||||
add("network-errors", "critical", "Сеть", "Появились новые сетевые ошибки", fmt.Sprintf("Прирост счётчиков ошибок: %d (счётчики могут пересекаться). Software RX/TX drops показаны отдельно и не создают этот алерт.", networkErrors))
|
||||
}
|
||||
if !metrics.Network.Health.CheckedAt.IsZero() && !metrics.Network.Health.GatewayUp {
|
||||
add("network-gateway", "critical", "Сеть", "Недоступен локальный шлюз", firstNonEmpty(metrics.Network.Health.Gateway, "Default gateway не определён."))
|
||||
@@ -286,14 +289,7 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
|
||||
for _, name := range metrics.Network.Health.DisappearedInterfaces {
|
||||
add("network-interface-missing-"+name, "warning", "Сеть", "Исчез сетевой интерфейс", name+" больше не присутствует в /proc/net/dev.")
|
||||
}
|
||||
for _, iface := range metrics.Network.Interfaces {
|
||||
if iface.Kind == "Physical" && !iface.Carrier && (iface.Master != "" || iface.ReceiveBytes+iface.TransmitBytes > 0) {
|
||||
add("network-link-"+iface.Name, "warning", "Сеть", iface.Name+": нет carrier", "Физический линк не поднят.")
|
||||
}
|
||||
if (iface.Kind == "Bridge" || iface.Kind == "Bond" || iface.Kind == "VLAN") && iface.State == "down" {
|
||||
add("network-state-"+iface.Name, "warning", "Сеть", iface.Name+": интерфейс выключен", iface.Kind+" находится в состоянии down.")
|
||||
}
|
||||
}
|
||||
|
||||
if !metrics.Monitor.LastSuccessfulCollection.IsZero() && time.Since(metrics.Monitor.LastSuccessfulCollection) > 30*time.Second {
|
||||
add("monitor-stale", "critical", "Dashboard", "Данные мониторинга устарели", "Последний успешный сбор был "+time.Since(metrics.Monitor.LastSuccessfulCollection).Round(time.Second).String()+" назад.")
|
||||
}
|
||||
|
||||
14
assets/NOTICE.md
Normal file
14
assets/NOTICE.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Map data
|
||||
|
||||
`world.json` is derived from Natural Earth 1:110m admin-0 countries (coordinates
|
||||
rounded to two decimal places; only country codes, labels and boundaries kept).
|
||||
Natural Earth data is public domain: https://www.naturalearthdata.com/about/terms-of-use/
|
||||
Source: https://github.com/nvkelso/natural-earth-vector/blob/master/geojson/ne_110m_admin_0_countries.geojson
|
||||
|
||||
`dbip-country-lite-2026-10.mmdb.gz`: IP Geolocation by DB-IP, October 2026.
|
||||
Source: https://download.db-ip.com/free/dbip-country-lite-2026-10.mmdb.gz
|
||||
License: Creative Commons Attribution 4.0 International (CC BY 4.0).
|
||||
https://creativecommons.org/licenses/by/4.0/
|
||||
https://db-ip.com/db/download/ip-to-country-lite
|
||||
The database is unmodified. The traffic page displays the required DB-IP link.
|
||||
Countries are approximate; plotted positions are country labels, not exact IP locations.
|
||||
BIN
assets/dbip-country-lite-2026-10.mmdb.gz
Normal file
BIN
assets/dbip-country-lite-2026-10.mmdb.gz
Normal file
Binary file not shown.
64
assets/traffic.html
Normal file
64
assets/traffic.html
Normal file
File diff suppressed because one or more lines are too long
1
assets/world.json
Normal file
1
assets/world.json
Normal file
File diff suppressed because one or more lines are too long
39
cpu.go
39
cpu.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -33,15 +34,16 @@ type CPUStatic struct {
|
||||
|
||||
type CPUMetrics struct {
|
||||
CPUStatic
|
||||
UsagePercent float64 `json:"usagePercent"`
|
||||
FrequencyMHz float64 `json:"frequencyMhz"`
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
Uptime uint64 `json:"uptimeSeconds"`
|
||||
Temperatures []Temperature `json:"temperatures"`
|
||||
TopProcesses []AgentProcess `json:"topProcesses"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
UsagePercent float64 `json:"usagePercent"`
|
||||
FrequencyMHz float64 `json:"frequencyMhz"`
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
Uptime uint64 `json:"uptimeSeconds"`
|
||||
TemperatureCelsius *float64 `json:"temperatureCelsius"`
|
||||
Temperatures []Temperature `json:"temperatures"`
|
||||
TopProcesses []AgentProcess `json:"topProcesses"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
}
|
||||
|
||||
type Temperature struct {
|
||||
@@ -90,10 +92,11 @@ func (c *cpuCollector) collect() (CPUMetrics, error) {
|
||||
load1, load5, load15, _ := readLoadAverage()
|
||||
uptime, _ := readUptime()
|
||||
|
||||
temperatures := readTemperatures()
|
||||
return CPUMetrics{
|
||||
CPUStatic: c.static, UsagePercent: usage, FrequencyMHz: frequency,
|
||||
Load1: load1, Load5: load5, Load15: load15, Uptime: uptime,
|
||||
Temperatures: readTemperatures(), TopProcesses: collectAgentProcesses(), CollectedAt: time.Now(),
|
||||
Temperatures: temperatures, TemperatureCelsius: cpuTemperature(temperatures), TopProcesses: collectAgentProcesses(), CollectedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -285,3 +288,19 @@ func isCPUTemperatureChip(name string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// cpuTemperature is the hottest CPU sensor, shared by live and historical views.
|
||||
// Do not average Tctl/Tdie with cooler cores or CCDs.
|
||||
func cpuTemperature(temperatures []Temperature) *float64 {
|
||||
var hottest *float64
|
||||
for _, t := range temperatures {
|
||||
if math.IsNaN(t.Celsius) || math.IsInf(t.Celsius, 0) || t.Celsius <= 0 {
|
||||
continue
|
||||
}
|
||||
if hottest == nil || t.Celsius > *hottest {
|
||||
value := t.Celsius
|
||||
hottest = &value
|
||||
}
|
||||
}
|
||||
return hottest
|
||||
}
|
||||
|
||||
24
cpu_temperature_test.go
Normal file
24
cpu_temperature_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCPUTemperatureSharedWithHistory(t *testing.T) {
|
||||
temperatures := []Temperature{{Label: "k10temp · Tctl", Celsius: 62}, {Label: "k10temp · Tccd1", Celsius: 38}, {Label: "k10temp · Tccd2", Celsius: 41}}
|
||||
live := cpuTemperature(temperatures)
|
||||
point := historyPointFromMetrics(dashboardMetrics{CPU: CPUMetrics{Temperatures: temperatures}})
|
||||
if live == nil || *live != 62 || point.CPUTemperature != *live {
|
||||
t.Fatalf("live/history disagree: %v, %+v", live, point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUTemperatureInvalidSensors(t *testing.T) {
|
||||
if cpuTemperature([]Temperature{{Celsius: math.NaN()}, {Celsius: math.Inf(1)}, {Celsius: 0}}) != nil {
|
||||
t.Fatal("invalid temperature selected")
|
||||
}
|
||||
if cpuTemperature(nil) != nil {
|
||||
t.Fatal("missing sensor should stay missing")
|
||||
}
|
||||
}
|
||||
5
go.mod
5
go.mod
@@ -2,7 +2,10 @@ module proxmox-host-info
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require modernc.org/sqlite v1.54.0
|
||||
require (
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
modernc.org/sqlite v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
|
||||
10
go.sum
10
go.sum
@@ -1,3 +1,5 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
@@ -10,8 +12,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
@@ -21,6 +29,8 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
|
||||
@@ -99,12 +99,10 @@ func historyPointFromMetrics(metrics dashboardMetrics) HistoryPoint {
|
||||
NetworkTransmit: metrics.Network.TransmitPerSec, UPSCharge: metrics.UPS.ChargePercent,
|
||||
UPSLoad: metrics.UPS.LoadPercent,
|
||||
}
|
||||
if len(metrics.CPU.Temperatures) > 0 {
|
||||
for _, temperature := range metrics.CPU.Temperatures {
|
||||
point.CPUTemperature += temperature.Celsius
|
||||
}
|
||||
point.CPUTemperature /= float64(len(metrics.CPU.Temperatures))
|
||||
if temperature := cpuTemperature(metrics.CPU.Temperatures); temperature != nil {
|
||||
point.CPUTemperature = *temperature
|
||||
}
|
||||
|
||||
var zfsSize, zfsUsed uint64
|
||||
for _, pool := range metrics.ZFS.Pools {
|
||||
zfsSize += pool.SizeBytes
|
||||
|
||||
@@ -8,7 +8,7 @@ func TestHistoryPointFromMetrics(t *testing.T) {
|
||||
Guests: GuestsMetrics{Guests: []GuestMetrics{{Status: "running", CPU: .5, MaxCPU: 4, MemoryBytes: 50, MaxMemory: 100}}},
|
||||
}
|
||||
point := historyPointFromMetrics(metrics)
|
||||
if point.CPUTemperature != 50 || point.GuestsCPU != 50 || point.GuestsMemory != 50 {
|
||||
if point.CPUTemperature != 60 || point.GuestsCPU != 50 || point.GuestsMemory != 50 {
|
||||
t.Fatalf("неожиданная историческая точка: %+v", point)
|
||||
}
|
||||
}
|
||||
|
||||
79
network.go
79
network.go
@@ -43,26 +43,31 @@ type NetworkHealth struct {
|
||||
}
|
||||
|
||||
type NetworkInterface struct {
|
||||
Name string `json:"name"`
|
||||
ReceiveBytes uint64 `json:"receiveBytes"`
|
||||
TransmitBytes uint64 `json:"transmitBytes"`
|
||||
ReceiveErrors uint64 `json:"receiveErrors"`
|
||||
TransmitErrors uint64 `json:"transmitErrors"`
|
||||
ReceiveDrops uint64 `json:"receiveDrops"`
|
||||
TransmitDrops uint64 `json:"transmitDrops"`
|
||||
NewErrors uint64 `json:"newErrors"`
|
||||
NewDrops uint64 `json:"newDrops"`
|
||||
ReceivePerSec float64 `json:"receivePerSec"`
|
||||
TransmitPerSec float64 `json:"transmitPerSec"`
|
||||
VMID int `json:"vmid,omitempty"`
|
||||
GuestName string `json:"guestName,omitempty"`
|
||||
GuestType string `json:"guestType,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
State string `json:"state"`
|
||||
Carrier bool `json:"carrier"`
|
||||
SpeedMbps int `json:"speedMbps,omitempty"`
|
||||
Duplex string `json:"duplex,omitempty"`
|
||||
Master string `json:"master,omitempty"`
|
||||
Name string `json:"name"`
|
||||
ReceiveBytes uint64 `json:"receiveBytes"`
|
||||
TransmitBytes uint64 `json:"transmitBytes"`
|
||||
ReceiveErrors uint64 `json:"receiveErrors"`
|
||||
TransmitErrors uint64 `json:"transmitErrors"`
|
||||
ReceiveDrops uint64 `json:"receiveDrops"`
|
||||
TransmitDrops uint64 `json:"transmitDrops"`
|
||||
NewErrors uint64 `json:"newErrors"`
|
||||
NewReceiveDrops uint64 `json:"newReceiveDrops"`
|
||||
NewTransmitDrops uint64 `json:"newTransmitDrops"`
|
||||
FaultCounters map[string]uint64 `json:"faultCounters,omitempty"`
|
||||
NewFaults uint64 `json:"newFaults"`
|
||||
LinkDown bool `json:"linkDown"`
|
||||
NewDrops uint64 `json:"newDrops"`
|
||||
ReceivePerSec float64 `json:"receivePerSec"`
|
||||
TransmitPerSec float64 `json:"transmitPerSec"`
|
||||
VMID int `json:"vmid,omitempty"`
|
||||
GuestName string `json:"guestName,omitempty"`
|
||||
GuestType string `json:"guestType,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
State string `json:"state"`
|
||||
Carrier bool `json:"carrier"`
|
||||
SpeedMbps int `json:"speedMbps,omitempty"`
|
||||
Duplex string `json:"duplex,omitempty"`
|
||||
Master string `json:"master,omitempty"`
|
||||
}
|
||||
|
||||
type networkSnapshot struct {
|
||||
@@ -83,13 +88,14 @@ type networkCollector struct {
|
||||
}
|
||||
|
||||
func newNetworkCollector(path string) (*networkCollector, error) {
|
||||
snapshot, _, _, err := readNetworkSnapshot(path)
|
||||
snapshot, _, details, err := readNetworkSnapshot(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _, details, _ := readNetworkSnapshot(path)
|
||||
previous := map[string]NetworkInterface{}
|
||||
for _, v := range details {
|
||||
decorateInterface(&v)
|
||||
collectInterfaceFaults(&v)
|
||||
previous[v.Name] = v
|
||||
}
|
||||
known := map[string]bool{}
|
||||
@@ -116,16 +122,11 @@ func (c *networkCollector) collect() (NetworkMetrics, error) {
|
||||
for i := range details {
|
||||
v := &details[i]
|
||||
decorateInterface(v)
|
||||
if old, ok := c.previousInterfaces[v.Name]; ok && elapsed > 0 && v.ReceiveBytes >= old.ReceiveBytes && v.TransmitBytes >= old.TransmitBytes {
|
||||
v.ReceivePerSec = float64(v.ReceiveBytes-old.ReceiveBytes) / elapsed
|
||||
v.TransmitPerSec = float64(v.TransmitBytes-old.TransmitBytes) / elapsed
|
||||
if v.ReceiveErrors >= old.ReceiveErrors && v.TransmitErrors >= old.TransmitErrors {
|
||||
v.NewErrors = v.ReceiveErrors - old.ReceiveErrors + v.TransmitErrors - old.TransmitErrors
|
||||
}
|
||||
if v.ReceiveDrops >= old.ReceiveDrops && v.TransmitDrops >= old.TransmitDrops {
|
||||
v.NewDrops = v.ReceiveDrops - old.ReceiveDrops + v.TransmitDrops - old.TransmitDrops
|
||||
}
|
||||
collectInterfaceFaults(v)
|
||||
if old, ok := c.previousInterfaces[v.Name]; ok {
|
||||
updateInterfaceDeltas(v, old, elapsed)
|
||||
}
|
||||
v.LinkDown = interfaceLinkDown(*v)
|
||||
}
|
||||
c.previousInterfaces = map[string]NetworkInterface{}
|
||||
for _, v := range details {
|
||||
@@ -166,19 +167,19 @@ func explainNetworkHealth(health *NetworkHealth, interfaces []NetworkInterface)
|
||||
health.Summary, health.LikelyCause, health.RecommendedAction = "Хост не видит локальный шлюз", "Проблема между Proxmox и роутером: линк, bridge, bond или VLAN", "Проверьте кабель, carrier и настройки vmbr/bond/VLAN"
|
||||
case health.PacketLoss > 0:
|
||||
health.Summary = fmt.Sprintf("Нестабильная локальная сеть: потеряно %.1f%% пакетов", health.PacketLoss)
|
||||
health.LikelyCause, health.RecommendedAction = "Перегрузка или ошибки линка между Proxmox и шлюзом", "Проверьте errors/drops, кабель, порт коммутатора и duplex"
|
||||
health.LikelyCause, health.RecommendedAction = "Перегрузка или ошибки линка между Proxmox и шлюзом", "Проверьте аппаратные ошибки, кабель, порт коммутатора и duplex"
|
||||
case !health.DNSUp && health.InternetUp:
|
||||
health.Summary, health.LikelyCause, health.RecommendedAction = "Интернет доступен, но DNS не отвечает", "Проблема DNS-сервера или /etc/resolv.conf", "Проверьте настроенные DNS и попробуйте разрешить домен вручную"
|
||||
case health.DNSUp && !health.InternetUp:
|
||||
health.Summary, health.LikelyCause, health.RecommendedAction = "Локальная сеть и DNS работают, но внешняя проверка недоступна", "Проблема выше роутера, блокировка HTTPS или недоступность провайдера", "Проверьте маршрут наружу, firewall и доступ к внешним адресам"
|
||||
}
|
||||
for _, v := range interfaces {
|
||||
if v.Kind == "Physical" && (!v.Carrier || v.State == "down") {
|
||||
health.Summary, health.LikelyCause, health.RecommendedAction = v.Name+": физический линк не поднят", "Нет carrier либо интерфейс административно выключен", "Проверьте кабель, порт коммутатора и состояние интерфейса"
|
||||
if v.LinkDown {
|
||||
health.Summary, health.LikelyCause, health.RecommendedAction = v.Name+": линк не поднят", "Нет carrier либо интерфейс административно выключен", "Проверьте кабель, порт коммутатора и состояние интерфейса"
|
||||
return
|
||||
}
|
||||
if v.NewErrors+v.NewDrops > 0 {
|
||||
health.Summary = fmt.Sprintf("%s: за последний интервал появилось %d сетевых ошибок/потерь", v.Name, v.NewErrors+v.NewDrops)
|
||||
if v.NewErrors+v.NewFaults > 0 {
|
||||
health.Summary = fmt.Sprintf("%s: за последний интервал появилось %d изменений счётчиков сетевых ошибок", v.Name, v.NewErrors+v.NewFaults)
|
||||
health.LikelyCause, health.RecommendedAction = "Счётчики интерфейса сейчас растут из-за перегрузки, кабеля, MTU или duplex", "Проверьте скорость и duplex, кабель, порт коммутатора и MTU на обоих концах"
|
||||
return
|
||||
}
|
||||
@@ -195,6 +196,10 @@ func decorateInterface(v *NetworkInterface) {
|
||||
v.Master = filepath.Base(target)
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(v.Name, "fwbr"):
|
||||
v.Kind = "Firewall bridge"
|
||||
case strings.HasPrefix(v.Name, "fwpr") || strings.HasPrefix(v.Name, "fwln"):
|
||||
v.Kind = "Firewall link"
|
||||
case strings.HasPrefix(v.Name, "vmbr"):
|
||||
v.Kind = "Bridge"
|
||||
case strings.HasPrefix(v.Name, "bond"):
|
||||
@@ -273,7 +278,7 @@ func readNetworkSnapshot(path string) (networkSnapshot, []string, []NetworkInter
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(values)
|
||||
if len(fields) < 9 {
|
||||
if len(fields) < 16 {
|
||||
continue
|
||||
}
|
||||
rx, errRX := strconv.ParseUint(fields[0], 10, 64)
|
||||
|
||||
103
network_faults.go
Normal file
103
network_faults.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func counterDelta(current, previous uint64) uint64 {
|
||||
if current < previous {
|
||||
return 0
|
||||
}
|
||||
return current - previous
|
||||
}
|
||||
|
||||
func updateInterfaceDeltas(v *NetworkInterface, old NetworkInterface, elapsed float64) {
|
||||
if elapsed <= 0 {
|
||||
return
|
||||
}
|
||||
v.ReceivePerSec = float64(counterDelta(v.ReceiveBytes, old.ReceiveBytes)) / elapsed
|
||||
v.TransmitPerSec = float64(counterDelta(v.TransmitBytes, old.TransmitBytes)) / elapsed
|
||||
v.NewErrors = counterDelta(v.ReceiveErrors, old.ReceiveErrors) + counterDelta(v.TransmitErrors, old.TransmitErrors)
|
||||
v.NewReceiveDrops = counterDelta(v.ReceiveDrops, old.ReceiveDrops)
|
||||
v.NewTransmitDrops = counterDelta(v.TransmitDrops, old.TransmitDrops)
|
||||
v.NewDrops = v.NewReceiveDrops + v.NewTransmitDrops
|
||||
for name, value := range v.FaultCounters {
|
||||
if previous, ok := old.FaultCounters[name]; ok {
|
||||
v.NewFaults += counterDelta(value, previous)
|
||||
}
|
||||
}
|
||||
// A transition matters even if the interface has not carried traffic yet.
|
||||
v.LinkDown = old.Carrier && !v.Carrier
|
||||
}
|
||||
|
||||
func interfaceLinkDown(v NetworkInterface) bool {
|
||||
if v.Kind == "Firewall bridge" || v.Kind == "Firewall link" {
|
||||
return false
|
||||
}
|
||||
if v.LinkDown {
|
||||
return true
|
||||
}
|
||||
used := v.Master != "" || v.ReceiveBytes+v.TransmitBytes > 0
|
||||
if v.Kind == "Physical" {
|
||||
return used && (!v.Carrier || v.State == "down")
|
||||
}
|
||||
return used && v.State == "down"
|
||||
}
|
||||
|
||||
func collectInterfaceFaults(v *NetworkInterface) {
|
||||
base := filepath.Join("/sys/class/net", v.Name)
|
||||
// /proc/net/dev folds missed packets into RX drop. Sysfs keeps software
|
||||
// drops separate from rx_missed_errors, which must still trigger an alert.
|
||||
if n, err := strconv.ParseUint(readTrimmed(filepath.Join(base, "statistics/rx_dropped")), 10, 64); err == nil {
|
||||
v.ReceiveDrops = n
|
||||
}
|
||||
v.FaultCounters = map[string]uint64{}
|
||||
for _, name := range []string{"rx_crc_errors", "rx_frame_errors", "rx_fifo_errors", "rx_missed_errors", "tx_fifo_errors", "tx_carrier_errors", "carrier_down_count"} {
|
||||
path := filepath.Join(base, "statistics", name)
|
||||
if name == "carrier_down_count" {
|
||||
path = filepath.Join(base, name)
|
||||
}
|
||||
if n, err := strconv.ParseUint(readTrimmed(path), 10, 64); err == nil {
|
||||
v.FaultCounters[name] = n
|
||||
}
|
||||
}
|
||||
// Only Atlantic exposes the driver-specific names below. No configuration
|
||||
// changes, and a missing ethtool is not itself a network failure.
|
||||
driver, err := filepath.EvalSymlinks(filepath.Join(base, "device/driver"))
|
||||
if err != nil || filepath.Base(driver) != "atlantic" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if out, err := exec.CommandContext(ctx, "ethtool", "-S", v.Name).Output(); err == nil {
|
||||
for k, n := range parseAtlanticFaults(string(out)) {
|
||||
v.FaultCounters["atlantic:"+k] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseAtlanticFaults(output string) map[string]uint64 {
|
||||
result := map[string]uint64{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(line), ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(key)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
switch fields[len(fields)-1] {
|
||||
case "InErrors", "InDroppedDma", "AllocFails", "SkbAllocFails", "Restarts":
|
||||
if n, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64); err == nil {
|
||||
result[key] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
67
network_faults_test.go
Normal file
67
network_faults_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNetworkDropsDoNotAlert(t *testing.T) {
|
||||
for _, name := range []string{"enp6s0", "vmbr0", "fwbr100i0", "tap100i0"} {
|
||||
v := NetworkInterface{Name: name, Carrier: true, State: "up", NewDrops: 15, NewReceiveDrops: 15}
|
||||
metrics := dashboardMetrics{Network: NetworkMetrics{Interfaces: []NetworkInterface{v}}}
|
||||
for _, a := range evaluateAlerts(metrics, AlertThresholds{}) {
|
||||
if a.ID == "network-errors" {
|
||||
t.Fatalf("%s: drops triggered alert", name)
|
||||
}
|
||||
}
|
||||
v.NewErrors = 1
|
||||
metrics.Network.Interfaces[0] = v
|
||||
found := false
|
||||
for _, a := range evaluateAlerts(metrics, AlertThresholds{}) {
|
||||
if a.ID == "network-errors" {
|
||||
found = a.Severity == "critical"
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s: errors did not trigger critical alert", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkHardwareFaultAndLinkAlerts(t *testing.T) {
|
||||
for _, v := range []NetworkInterface{{Name: "eth0", NewFaults: 1}, {Name: "eth0", LinkDown: true}} {
|
||||
found := false
|
||||
for _, a := range evaluateAlerts(dashboardMetrics{Network: NetworkMetrics{Interfaces: []NetworkInterface{v}}}, AlertThresholds{}) {
|
||||
if strings.HasPrefix(a.ID, "network-") && a.Severity == "critical" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no critical alert for %+v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkCounterResetsAndFaultBaselines(t *testing.T) {
|
||||
old := NetworkInterface{ReceiveBytes: 100, ReceiveErrors: 20, TransmitErrors: 4, ReceiveDrops: 8, FaultCounters: map[string]uint64{"rx_missed_errors": 10, "atlantic:InErrors": 9}, Carrier: true}
|
||||
next := NetworkInterface{ReceiveBytes: 1, ReceiveErrors: 1, TransmitErrors: 6, ReceiveDrops: 10, FaultCounters: map[string]uint64{"rx_missed_errors": 12, "atlantic:InErrors": 1, "new": 100}, Carrier: false}
|
||||
updateInterfaceDeltas(&next, old, 2)
|
||||
if next.NewErrors != 2 || next.NewFaults != 2 || next.NewReceiveDrops != 2 || next.ReceivePerSec != 0 || !next.LinkDown {
|
||||
t.Fatalf("bad deltas: %+v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlanticFaults(t *testing.T) {
|
||||
got := parseAtlanticFaults("NIC statistics:\n InErrors: 2\n InDroppedDma: 3\n Queue[0] AllocFails: 4\n Queue[1] SkbAllocFails: 5\n Queue[0] Restarts: 6\n Queue[0] InErrors: 7\n InPackets: 100\n Queue[0] XdpDrop: 50\n InErrorsBad: nope")
|
||||
if len(got) != 6 || got["InDroppedDma"] != 3 || got["Queue[0] Restarts"] != 6 {
|
||||
t.Fatalf("bad counters: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewallNotAttributedToGuest(t *testing.T) {
|
||||
n := NetworkMetrics{Interfaces: []NetworkInterface{{Name: "fwbr100i0", ReceiveDrops: 100}, {Name: "tap100i0"}}}
|
||||
decorateNetworkInterfaces(&n, GuestsMetrics{})
|
||||
if n.Interfaces[0].VMID != 0 || n.Interfaces[1].VMID != 100 {
|
||||
t.Fatalf("bad VM attribution: %+v", n)
|
||||
}
|
||||
}
|
||||
@@ -49,13 +49,13 @@ func TestExplainNetworkHealthIgnoresOldInterfaceCounters(t *testing.T) {
|
||||
t.Fatalf("старый накопленный счётчик вызвал текущую тревогу: %+v", health)
|
||||
}
|
||||
explainNetworkHealth(&health, []NetworkInterface{{Name: "eno1", Carrier: true, State: "up", ReceiveDrops: 43, NewDrops: 1}})
|
||||
if !strings.Contains(health.Summary, "последний интервал") {
|
||||
t.Fatalf("рост счётчика не объяснён: %+v", health)
|
||||
if health.Summary != "Сеть работает штатно" {
|
||||
t.Fatalf("software drops вызвали тревогу: %+v", health)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceVMID(t *testing.T) {
|
||||
for name, want := range map[string]int{"tap100i0": 100, "veth108i0": 108, "fwbr113i0": 113, "vmbr0": 0, "enp6s0": 0} {
|
||||
for name, want := range map[string]int{"tap100i0": 100, "veth108i0": 108, "fwbr113i0": 0, "vmbr0": 0, "enp6s0": 0} {
|
||||
if got := interfaceVMID(name); got != want {
|
||||
t.Fatalf("%s: got %d want %d", name, got, want)
|
||||
}
|
||||
|
||||
17
scripts/build-release.sh
Executable file
17
scripts/build-release.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
version=${1:?Usage: build-release.sh vX.Y.Z}
|
||||
cd "$(dirname "$0")/.."
|
||||
go test ./...
|
||||
mkdir -p dist
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$version" -o dist/proxmox-dashboard-linux-amd64 .
|
||||
(cd dist && shasum -a 256 proxmox-dashboard-linux-amd64 > proxmox-dashboard-linux-amd64.sha256)
|
||||
stage=$(mktemp -d)
|
||||
trap 'rm -rf "$stage"' EXIT INT TERM
|
||||
package="$stage/proxmox-dashboard-install"
|
||||
mkdir -p "$package/dist" "$package/scripts"
|
||||
cp dist/proxmox-dashboard-linux-amd64 dist/proxmox-dashboard-linux-amd64.sha256 "$package/dist/"
|
||||
cp scripts/install.sh scripts/install-agent.sh scripts/uninstall-agent.sh scripts/proxmox-dashboard-update "$package/scripts/"
|
||||
cp assets/NOTICE.md "$package/MAP-DATA-NOTICE.md"
|
||||
cp ./*.service ./*.timer ./*.path README.md "$package/"
|
||||
tar -czf dist/proxmox-dashboard-install-amd64.tar.gz -C "$stage" proxmox-dashboard-install
|
||||
1
store.go
1
store.go
@@ -106,6 +106,7 @@ func openStore(path string) (*Store, error) {
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
statements := []string{
|
||||
trafficSchema,
|
||||
`PRAGMA journal_mode=WAL`,
|
||||
`PRAGMA busy_timeout=5000`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
|
||||
|
||||
298
traffic.go
Normal file
298
traffic.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TrafficEvent struct {
|
||||
ID string `json:"id"`
|
||||
Sequence int64 `json:"sequence,omitempty"`
|
||||
Time int64 `json:"time"`
|
||||
Kind string `json:"kind"`
|
||||
IP string `json:"ip"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
}
|
||||
|
||||
type TrafficBatch struct {
|
||||
Events []TrafficEvent `json:"events"`
|
||||
Files int `json:"files"`
|
||||
BacklogBytes int64 `json:"backlogBytes"`
|
||||
CrowdSecLimited bool `json:"crowdsecLimited"`
|
||||
ParseErrors uint64 `json:"parseErrors"`
|
||||
LogError string `json:"logError,omitempty"`
|
||||
CrowdSecError string `json:"crowdsecError,omitempty"`
|
||||
CrowdSecAt int64 `json:"crowdsecAt"`
|
||||
StartedAt int64 `json:"startedAt"`
|
||||
}
|
||||
|
||||
type TrafficSource struct {
|
||||
Agent string `json:"agent"`
|
||||
Name string `json:"name"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
Status TrafficBatch `json:"status"`
|
||||
}
|
||||
|
||||
type TrafficCount struct {
|
||||
Name string `json:"name"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type TrafficView struct {
|
||||
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 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
|
||||
}
|
||||
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) (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()
|
||||
where := ` WHERE ts>=? AND agent=?`
|
||||
args := []any{now - 86400, v.Settings.AgentID}
|
||||
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
|
||||
}
|
||||
v.Events = append(v.Events, e)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
for _, q := range []struct {
|
||||
column string
|
||||
out *[]TrafficCount
|
||||
}{{"country", &v.Countries}, {"domain", &v.Domains}, {"ip", &v.IPs}} {
|
||||
r, e := s.db.Query(`SELECT `+q.column+`,COUNT(*) FROM traffic_events`+where+` GROUP BY `+q.column+` ORDER BY COUNT(*) DESC LIMIT 50`, args...)
|
||||
if e != nil {
|
||||
return v, e
|
||||
}
|
||||
for r.Next() {
|
||||
var c TrafficCount
|
||||
if e = r.Scan(&c.Name, &c.Count); e != nil {
|
||||
r.Close()
|
||||
return v, e
|
||||
}
|
||||
*q.out = append(*q.out, c)
|
||||
}
|
||||
e = r.Err()
|
||||
r.Close()
|
||||
if e != nil {
|
||||
return v, e
|
||||
}
|
||||
}
|
||||
r, err := s.db.Query(`SELECT CAST((ts/60)*60 AS TEXT),COUNT(*) FROM traffic_events`+where+` AND kind='request' AND ts>=? GROUP BY ts/60 ORDER BY ts/60`, append(args, now-3600)...)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
for r.Next() {
|
||||
var c TrafficCount
|
||||
if err = r.Scan(&c.Name, &c.Count); err != nil {
|
||||
r.Close()
|
||||
return v, err
|
||||
}
|
||||
v.Series = append(v.Series, c)
|
||||
}
|
||||
err = r.Err()
|
||||
r.Close()
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
r, err = s.db.Query(`SELECT t.agent,COALESCE(a.name,t.agent),t.last_seen,t.status_json FROM traffic_sources t LEFT JOIN agents a ON a.id=t.agent WHERE t.agent=? ORDER BY t.last_seen DESC`, v.Settings.AgentID)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
for r.Next() {
|
||||
var src TrafficSource
|
||||
var raw string
|
||||
if err = r.Scan(&src.Agent, &src.Name, &src.LastSeen, &raw); err != nil {
|
||||
r.Close()
|
||||
return v, err
|
||||
}
|
||||
json.Unmarshal([]byte(raw), &src.Status)
|
||||
v.Sources = append(v.Sources, src)
|
||||
}
|
||||
err = r.Err()
|
||||
r.Close()
|
||||
var count int64
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM traffic_events`).Scan(&count)
|
||||
v.Limited = count >= 200000
|
||||
return v, err
|
||||
}
|
||||
|
||||
func trafficRoutes(mux *http.ServeMux, s *Store) {
|
||||
trafficSettingsRoute(mux, s)
|
||||
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")
|
||||
writeJSON(w, view, err)
|
||||
})
|
||||
mux.HandleFunc("/api/traffic/world", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Write(trafficWorld)
|
||||
})
|
||||
mux.HandleFunc("/api/agent/traffic", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
}
|
||||
id, secret, ok := agentCredentials(r)
|
||||
if !ok || !s.AuthenticateAgent(id, secret) {
|
||||
http.Error(w, "Unauthorized", 401)
|
||||
return
|
||||
}
|
||||
settings := s.TrafficSettings()
|
||||
if !settings.Enabled || settings.AgentID != id {
|
||||
http.Error(w, "Источник отключён", 403)
|
||||
return
|
||||
}
|
||||
var batch TrafficBatch
|
||||
if json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&batch) != nil {
|
||||
http.Error(w, "Invalid traffic batch", 400)
|
||||
return
|
||||
}
|
||||
if err := s.SaveTraffic(id, batch); err != nil {
|
||||
http.Error(w, fmt.Sprint(err), 500)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]bool{"accepted": true}, nil)
|
||||
})
|
||||
}
|
||||
65
traffic_geo.go
Normal file
65
traffic_geo.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
_ "embed"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
)
|
||||
|
||||
//go:embed assets/world.json
|
||||
var trafficWorld []byte
|
||||
|
||||
//go:embed assets/dbip-country-lite-2026-10.mmdb.gz
|
||||
var trafficGeoCompressed []byte
|
||||
|
||||
const trafficGeoDate = "2026-10"
|
||||
|
||||
var trafficGeo struct {
|
||||
sync.Once
|
||||
db *maxminddb.Reader
|
||||
}
|
||||
|
||||
func trafficGeoReady() bool {
|
||||
trafficGeo.Do(func() {
|
||||
r, err := gzip.NewReader(bytes.NewReader(trafficGeoCompressed))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(r, 32<<20))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
trafficGeo.db, _ = maxminddb.FromBytes(data)
|
||||
})
|
||||
return trafficGeo.db != nil
|
||||
}
|
||||
func lookupTrafficCountry(ipText string) string {
|
||||
ip := net.ParseIP(ipText)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
|
||||
return "LAN"
|
||||
}
|
||||
if !ip.IsGlobalUnicast() {
|
||||
return ""
|
||||
}
|
||||
if !trafficGeoReady() {
|
||||
return ""
|
||||
}
|
||||
var record struct {
|
||||
Country struct {
|
||||
Code string `maxminddb:"iso_code"`
|
||||
} `maxminddb:"country"`
|
||||
}
|
||||
if trafficGeo.db.Lookup(ip, &record) != nil {
|
||||
return ""
|
||||
}
|
||||
return record.Country.Code
|
||||
}
|
||||
54
traffic_preview_test.go
Normal file
54
traffic_preview_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
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, 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"})
|
||||
}
|
||||
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 == "/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))
|
||||
}
|
||||
70
traffic_settings.go
Normal file
70
traffic_settings.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type TrafficSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AgentID string `json:"agentId"`
|
||||
ServerCountry string `json:"serverCountry"`
|
||||
}
|
||||
|
||||
func (s *Store) TrafficSettings() TrafficSettings {
|
||||
var c TrafficSettings
|
||||
s.loadJSONSetting("traffic", &c)
|
||||
return c
|
||||
}
|
||||
func (s *Store) SaveTrafficSettings(c TrafficSettings) (TrafficSettings, error) {
|
||||
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, 4096)).Decode(&c) != nil {
|
||||
http.Error(w, "Некорректные настройки", 400)
|
||||
return
|
||||
}
|
||||
value, err := s.SaveTrafficSettings(c)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
writeJSON(w, value, nil)
|
||||
default:
|
||||
w.WriteHeader(405)
|
||||
}
|
||||
})
|
||||
}
|
||||
217
traffic_test.go
Normal file
217
traffic_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func trafficFixture(t *testing.T) (*Store, agentConfig) {
|
||||
t.Helper()
|
||||
s, err := openStore(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
token, err := s.CreateAgentEnrollmentToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := s.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "NPM test", VMID: 102})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, a
|
||||
}
|
||||
func npmTestLine(ip, path string) string {
|
||||
return fmt.Sprintf("[%s] - - 301 - GET https example.test \"%s\" [Client %s] [Length 166] [Gzip -] [Sent-to 192.168.0.9] \"-\" \"-\"\n", time.Now().UTC().Format("02/Jan/2006:15:04:05 -0700"), path, ip)
|
||||
}
|
||||
func TestNPMTrafficFormats(t *testing.T) {
|
||||
for _, status := range []string{"- 200 200", "- - 301", "- 502 502", "- - 401"} {
|
||||
line := strings.Replace(npmTestLine("2001:4860:4860::8888", "/login?token=secret#fragment"), "- - 301", status, 1)
|
||||
e, ok := parseNPMTraffic(line)
|
||||
if !ok || e.Kind != "request" || e.Path != "/login" || e.Domain != "example.test" || e.IP != "2001:4860:4860::8888" {
|
||||
t.Fatalf("bad parse: %+v %v", e, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := parseNPMTraffic("broken"); ok {
|
||||
t.Fatal("accepted invalid log")
|
||||
}
|
||||
}
|
||||
func TestCrowdTrafficDoesNotInventBlockedRequests(t *testing.T) {
|
||||
raw := `[{"id":12,"uuid":"alert-uuid","created_at":"2026-10-09T16:16:43Z","source":{"ip":"20.194.96.114"},"scenario":"crowdsecurity/http-admin-interface-probing","events":[{"meta":[{"key":"target_fqdn","value":"example.test"}]}],"decisions":[{"id":42,"value":"20.194.96.114","type":"ban","simulated":false},{"id":43,"value":"1.1.1.1","type":"ban","simulated":true}]}]`
|
||||
events, err := parseCrowdTraffic([]byte(raw))
|
||||
if err != nil || len(events) != 2 {
|
||||
t.Fatalf("%+v %v", events, err)
|
||||
}
|
||||
if events[0].Kind != "alert" || events[1].Kind != "decision" || events[1].Domain != "example.test" {
|
||||
t.Fatalf("bad categories %+v", events)
|
||||
}
|
||||
simulated := strings.Replace(raw, `"id":12`, `"simulated":true,"id":12`, 1)
|
||||
events, err = parseCrowdTraffic([]byte(simulated))
|
||||
if err != nil || len(events) != 0 {
|
||||
t.Fatal("simulation counted as live")
|
||||
}
|
||||
}
|
||||
func TestTrafficLogCheckpointRotationAndPartialLines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "proxy-host-1_access.log")
|
||||
os.WriteFile(path, []byte(npmTestLine("1.1.1.1", "/old")), 0600)
|
||||
state := trafficAgentState{Cursors: map[string]trafficCursor{}}
|
||||
if _, err := state.readLogs(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.Pending) != 0 {
|
||||
t.Fatal("imported old logs on first activation")
|
||||
}
|
||||
appendFile := func(path, s string) {
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.WriteString(s)
|
||||
f.Close()
|
||||
}
|
||||
line := npmTestLine("8.8.8.8", "/new")
|
||||
appendFile(path, line[:len(line)/2])
|
||||
state.readLogs(dir)
|
||||
if len(state.Pending) != 0 {
|
||||
t.Fatal("partial line emitted")
|
||||
}
|
||||
appendFile(path, line[len(line)/2:])
|
||||
state.readLogs(dir)
|
||||
if len(state.Pending) != 1 {
|
||||
t.Fatal("complete line missing")
|
||||
}
|
||||
id := state.Pending[0].ID
|
||||
state.Pending = nil
|
||||
checkpoint := filepath.Join(dir, "state.json")
|
||||
if err := saveTrafficAgentState(checkpoint, state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(checkpoint)
|
||||
var restored trafficAgentState
|
||||
json.Unmarshal(raw, &restored)
|
||||
os.Rename(path, path+".1")
|
||||
appendFile(path+".1", npmTestLine("9.9.9.9", "/rotated"))
|
||||
os.WriteFile(path, []byte(npmTestLine("1.0.0.1", "/fresh")), 0600)
|
||||
restored.readLogs(dir)
|
||||
if len(restored.Pending) != 2 {
|
||||
t.Fatalf("rotation lost events %+v", restored)
|
||||
}
|
||||
for _, e := range restored.Pending {
|
||||
if e.ID == id {
|
||||
t.Fatal("duplicate id")
|
||||
}
|
||||
}
|
||||
restored.Pending = nil
|
||||
restored.readLogs(dir)
|
||||
if len(restored.Pending) != 0 {
|
||||
t.Fatal("re-read same lines")
|
||||
}
|
||||
}
|
||||
func TestTrafficStoreDedupFiltersAndSanitization(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
if s.TrafficSettings().Enabled {
|
||||
t.Fatal("enabled by default")
|
||||
}
|
||||
if _, err := s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
batch := TrafficBatch{Files: 22, Events: []TrafficEvent{{ID: "1", Time: now, Kind: "request", IP: "1.1.1.1", Domain: "example.test", Path: "/login?secret=x", Status: 502}, {ID: "2", Time: now, Kind: "request", IP: "192.168.0.10", Domain: "example.test", Status: 200}, {ID: "3", Time: now, Kind: "alert", IP: "1.1.1.1", Reason: "probe"}, {ID: "4", Time: now, Kind: "decision", IP: "1.1.1.1", Reason: "ban"}}}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := s.SaveTraffic(a.ID, batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
v, err := s.TrafficView("", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Requests != 2 || v.UniqueIPs != 2 || v.Alerts != 1 || v.Decisions != 1 || len(v.Events) != 4 {
|
||||
t.Fatalf("wrong counts: %+v", v)
|
||||
}
|
||||
for _, e := range v.Events {
|
||||
if strings.Contains(e.Path, "secret") {
|
||||
t.Fatal("secret saved")
|
||||
}
|
||||
}
|
||||
v, err = s.TrafficView("example.test", "request", true)
|
||||
if err != nil || v.Requests != 1 {
|
||||
t.Fatalf("bad filter %+v %v", v, err)
|
||||
}
|
||||
batch.Events = []TrafficEvent{{ID: "invalid", Kind: "blocked", IP: "1.1.1.1", Time: now}}
|
||||
if s.SaveTraffic(a.ID, batch) == nil {
|
||||
t.Fatal("accepted invented block event")
|
||||
}
|
||||
}
|
||||
func TestTrafficAPIOptInAndSourceAuthorization(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
mux := http.NewServeMux()
|
||||
trafficRoutes(mux, s)
|
||||
get := func(path string) int {
|
||||
r := httptest.NewRecorder()
|
||||
mux.ServeHTTP(r, httptest.NewRequest("GET", path, nil))
|
||||
return r.Code
|
||||
}
|
||||
if get("/traffic") != 404 || get("/api/traffic") != 404 {
|
||||
t.Fatal("disabled page exposed")
|
||||
}
|
||||
send := func(secret string) int {
|
||||
r := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/agent/traffic", strings.NewReader(`{"events":[]}`))
|
||||
req.Header.Set("Authorization", "Bearer "+a.ID+"."+secret)
|
||||
mux.ServeHTTP(r, req)
|
||||
return r.Code
|
||||
}
|
||||
if send("wrong") != 401 || send(a.Secret) != 403 {
|
||||
t.Fatal("disabled/unauthorized ingestion accepted")
|
||||
}
|
||||
if _, err := s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: "missing"}); err == nil {
|
||||
t.Fatal("unknown agent accepted")
|
||||
}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, ServerCountry: "RU"})
|
||||
if send(a.Secret) != 200 || get("/traffic") != 200 {
|
||||
t.Fatal("selected agent not working")
|
||||
}
|
||||
token, _ := s.CreateAgentEnrollmentToken()
|
||||
other, _ := s.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "Other"})
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: other.ID})
|
||||
if send(a.Secret) != 403 {
|
||||
t.Fatal("old source still accepted")
|
||||
}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: false, AgentID: other.ID})
|
||||
if get("/api/traffic") != 404 {
|
||||
t.Fatal("disable failed")
|
||||
}
|
||||
}
|
||||
func TestTrafficGeoDatabase(t *testing.T) {
|
||||
if !trafficGeoReady() {
|
||||
t.Fatal("embedded database invalid")
|
||||
}
|
||||
if lookupTrafficCountry("192.168.0.10") != "LAN" {
|
||||
t.Fatal("LAN mapped to country")
|
||||
}
|
||||
if c := lookupTrafficCountry("8.8.8.8"); len(c) != 2 {
|
||||
t.Fatalf("bad public IP geo: %q", c)
|
||||
}
|
||||
}
|
||||
func TestTrafficCheckpointKeepsPendingUntilAcknowledged(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
s := trafficAgentState{Pending: []TrafficEvent{{ID: "retry-me", IP: "1.1.1.1"}}, Cursors: map[string]trafficCursor{"file": {Offset: 100}}}
|
||||
if err := saveTrafficAgentState(path, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if !bytes.Contains(raw, []byte("retry-me")) {
|
||||
t.Fatal("pending batch lost")
|
||||
}
|
||||
}
|
||||
6
traffic_web.go
Normal file
6
traffic_web.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package main
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed assets/traffic.html
|
||||
var trafficHTML string
|
||||
Reference in New Issue
Block a user