Add opt-in NPM and CrowdSec traffic map with selectable agent source

This commit is contained in:
Maxim
2026-10-09 19:39:13 +03:00
parent 72869dcf5f
commit f7b347212e
18 changed files with 1289 additions and 10 deletions

View File

@@ -434,3 +434,71 @@ InErrors, InDroppedDma, AllocFails, SkbAllocFails и Restarts через ethtool
Скрипт запускает тесты и создаёт в dist бинарник, SHA-256 и установочный архив,
совместимые со встроенным обновлением из Gitea Releases.
## Карта трафика NPM + CrowdSec (v0.20.0)
Откройте **Настройки → Карта трафика**, включите страницу и выберите
агент 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
```

View File

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

387
agent_traffic.go Normal file
View 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 }
}

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.

47
assets/traffic.html Normal file
View File

@@ -0,0 +1,47 @@
<!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:#070a13;--panel:#101525;--line:#222a40;--text:#eef2ff;--muted:#8490ab;--blue:#55c9ff;--orange:#ffb05f;--pink:#fa79b7}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 48% 40%,#10172a 0,transparent 62%),var(--bg);color:var(--text);font:14px/1.5 system-ui,sans-serif}a{color:var(--blue);text-decoration:none}button,select{font:inherit;color:var(--text);background:#141b2d;border:1px solid #303c59;border-radius:9px;padding:9px 12px}button{cursor:pointer}button:hover,select:hover{border-color:var(--blue)}button.active{background:#18334c;border-color:#55c9ff}header{padding:28px 32px 14px;display:flex;justify-content:space-between;align-items:center;gap:20px}.eyebrow{color:var(--blue);font-size:10px;letter-spacing:.2em;font-weight:700}h1{font-size:29px;letter-spacing:-.04em;margin:5px 0}header nav{display:flex;gap:15px;align-items:center}.live{font-size:11px;padding:5px 10px;border:1px solid #294c4a;border-radius:30px;color:#73ddb2}.live.stale{color:var(--orange);border-color:#6a4a2a}.toolbar{padding:12px 32px;display:flex;align-items:center;flex-wrap:wrap;gap:8px}.toolbar select{max-width:270px}.toolbar label{margin-left:8px;color:var(--muted)}.dashboard{display:grid;grid-template-columns:205px minmax(0,1fr) 330px;gap:18px;margin:12px 32px}.panel{background:#101525d9;border:1px solid var(--line);border-radius:16px;padding:18px;min-width:0}.panel h2{font-size:14px;font-weight:550;margin:0 0 16px}.metric{padding:17px 0;border-bottom:1px solid var(--line)}.metric:first-child{padding-top:0}.metric:last-child{border:0}.metric small{color:var(--muted);display:block;font-size:11px}.metric b{display:block;font-size:32px;letter-spacing:-.04em;margin:4px 0}.metric.request b{color:var(--blue)}.metric.alert b{color:var(--orange)}.metric.decision b{color:var(--pink)}.aside{display:flex;flex-direction:column;gap:16px}.ranking{display:flex;justify-content:space-between;gap:8px;padding:7px 0;color:#bac7de;font-size:12px}.ranking span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ranking b{font:12px ui-monospace,monospace;color:var(--blue)}.map-panel{position:relative;min-height:490px;display:flex;align-items:center;flex-direction:column;justify-content:center}.map-panel canvas{display:block;width:100%;aspect-ratio:1.85;touch-action:pan-y}.map-heading{position:absolute;top:8px;left:16px;right:16px;display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:11px}.map-foot{width:100%;padding:16px;color:var(--muted);font-size:11px;display:flex;justify-content:space-between;gap:12px}.legend{display:flex;flex-wrap:wrap;gap:14px}.dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--blue);margin-right:6px}.dot.alert{background:var(--orange)}.dot.decision{background:var(--pink)}.feed{max-height:670px;overflow:auto}.event{padding:14px 0;border-top:1px solid var(--line)}.event-head{display:flex;justify-content:space-between;gap:9px;align-items:baseline}.event-head b{font:12px ui-monospace,monospace}.event-head time{font-size:10px;color:var(--muted)}.event .meta{color:#b0bdd4;font-size:11px;margin-top:5px;overflow-wrap:anywhere}.event .path{font:11px/1.6 ui-monospace,monospace;color:var(--muted);margin-top:5px;overflow-wrap:anywhere}.kind{font-size:10px;color:var(--blue)}.kind.alert{color:var(--orange)}.kind.decision{color:var(--pink)}.empty{color:var(--muted);font-size:13px;padding:24px 0}.bottom{margin:18px 32px;display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:18px}.chart{width:100%;height:100px;display:block}.note{font-size:11px;color:var(--muted);overflow-wrap:anywhere}.status-box{margin:0 32px;padding:10px 0;color:var(--muted);font-size:12px}.status-box.error{color:var(--orange)}footer{margin:25px 32px;font-size:11px;color:var(--muted);display:flex;gap:18px;flex-wrap:wrap}.tooltip{position:fixed;pointer-events:none;display:none;padding:10px 14px;border:1px solid #475578;border-radius:9px;background:#101725f5;font-size:12px;z-index:3}.now{font:13px ui-monospace,monospace;color:var(--muted)}@media(min-width:1700px){.dashboard{grid-template-columns:230px minmax(0,1fr) 360px}.map-panel{min-height:680px}.feed{max-height:780px}}@media(max-width:1100px){.dashboard{grid-template-columns:165px minmax(0,1fr)}.feed-panel{grid-column:1/-1}.feed{max-height:330px}.map-panel{min-height:380px}.bottom{grid-template-columns:1fr}}@media(max-width:650px){header{padding:20px 16px;align-items:flex-start;flex-direction:column}h1{font-size:25px}.toolbar{padding:0 16px}.dashboard{display:flex;flex-direction:column;margin:18px 16px}.aside{display:contents}.metrics{display:grid;grid-template-columns:1fr 1fr;gap:12px}.metric,.metric:first-child{padding:0;border:0}.metric b{font-size:27px}.rank-panel{order:3}.map-panel{min-height:260px;order:1}.feed-panel{order:2}.bottom,footer{margin:16px}.status-box{margin:0 16px}.map-heading{position:static;padding:0 0 10px}.map-foot{padding:8px 0;font-size:10px}.legend{gap:8px}.now{display:none}}
</style></head><body>
<header><div><div class="eyebrow">PROXMOX DASHBOARD / TRAFFIC OBSERVATORY</div><h1>Мир обращается к вашим сервисам</h1><span class="note">HTTP-запросы и события CrowdSec · последние 24 часа</span></div><nav><span id="live" class="live stale">Подключение…</span><span class="now" id="clock"></span><a href="/">← Dashboard</a><button id="fullscreen" aria-label="Полный экран">⛶</button></nav></header>
<div class="toolbar"><button class="active" data-kind="">Весь трафик</button><button data-kind="request">HTTP-запросы</button><button data-kind="alert">Срабатывания</button><button data-kind="decision">Решения CrowdSec</button><select id="domain" aria-label="Фильтр по домену"><option value="">Все домены</option></select><label><input type="checkbox" id="external"> Только внешние IP</label><button id="pause">Пауза</button></div>
<div class="status-box" id="status" role="status">Получаем данные выбранного агента…</div>
<section class="dashboard">
<aside class="aside"><div class="panel metrics"><div class="metric"><small>УНИКАЛЬНЫХ IP · HTTP</small><b id="unique">—</b></div><div class="metric request"><small>HTTP-ЗАПРОСОВ</small><b id="requests">—</b></div><div class="metric alert"><small>СРАБАТЫВАНИЙ CROWDSEC</small><b id="alerts">—</b></div><div class="metric decision"><small>РЕШЕНИЙ CROWDSEC</small><b id="decisions">—</b></div></div><div class="panel rank-panel"><h2>Страны · события</h2><div id="countries"></div></div></aside>
<div class="map-panel"><div class="map-heading"><span>ИСТОЧНИКИ ЗАПРОСОВ</span><span id="sourceName">—</span></div><canvas id="world" role="img" aria-label="Карта стран источников; те же данные доступны в списке стран и ленте событий"></canvas><div class="map-foot"><div class="legend"><span><i class="dot"></i>Запрос</span><span><i class="dot alert"></i>Срабатывание</span><span><i class="dot decision"></i>Решение</span></div><span id="unmapped">Без геолокации: —</span></div><div class="note">Точки обозначают страны, а не точное местоположение людей.</div></div>
<aside class="panel feed-panel"><h2>Лента событий <span class="note">· последние 100</span></h2><div class="feed" id="feed"><div class="empty">Ожидание первого события…</div></div></aside>
</section>
<section class="bottom"><div class="panel"><div style="display:flex;justify-content:space-between"><h2>HTTP-запросы в минуту · последний час</h2><span class="note"><b id="qps">—</b> req/s · среднее за 60 сек.</span></div><canvas class="chart" id="chart" role="img" aria-label="Количество HTTP-запросов за каждую минуту последнего часа"></canvas></div><div class="panel"><h2>IP · события</h2><div id="ips"></div></div></section>
<footer><span>Решение о бане ≠ подтверждённый заблокированный запрос. HTTP 401/403/502 не означают блокировку CrowdSec.</span><span>GeoIP: <a href="https://db-ip.com" target="_blank" rel="noopener">IP Geolocation by DB-IP</a> · <span id="geoDate"></span> · <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a></span><span>Карта: <a href="https://www.naturalearthdata.com/">Natural Earth</a></span><a href="/#traffic-settings">Настройки источника</a></footer>
<div id="tooltip" class="tooltip"></div>
<script>
const el=id=>document.getElementById(id),safe=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])),num=n=>Number(n||0).toLocaleString('ru-RU');
let world=[],countryMap=new Map(),view=null,kind='',paused=false,busy=false,pulses=[],seen=new Set(),hitPoints=[],baseMap=null,lastPoll=0;
const colors={request:'#55c9ff',alert:'#ffb05f',decision:'#fa79b7'},kindLabels={request:'HTTP-запрос',alert:'Срабатывание',decision:'Решение CrowdSec'};
const countryNames=new Intl.DisplayNames(['ru'],{type:'region'});const countryName=code=>code==='LAN'?'Локальная сеть':countryMap.get(code)?.name||(code&&/^[A-Z]{2}$/.test(code)?countryNames.of(code):'Не определена');
function project(lon,lat,w,h){return [(lon+180)/360*w,(85-lat)/170*h]}
function mapBackground(w,h){const off=document.createElement('canvas');off.width=w;off.height=h;const ctx=off.getContext('2d'),mask=document.createElement('canvas');mask.width=w;mask.height=h;const m=mask.getContext('2d');m.fillStyle='#fff';world.forEach(c=>{const polygons=c.geometry.type==='Polygon'?[c.geometry.coordinates]:c.geometry.coordinates;polygons.forEach(poly=>{m.beginPath();poly.forEach(ring=>{ring.forEach(([lon,lat],i)=>{const [x,y]=project(lon,lat,w,h);if(i===0)m.moveTo(x,y);else m.lineTo(x,y)});m.closePath()});m.fill('evenodd')})});const pixels=m.getImageData(0,0,w,h).data;const step=Math.max(4,Math.round(w/210));ctx.fillStyle='#2a3355';for(let y=0,row=0;y<h;y+=step,row++){for(let x=(row%2)*step/2;x<w;x+=step){if(pixels[(Math.floor(y)*w+Math.floor(x))*4+3]){ctx.beginPath();ctx.arc(x,y,step*.32,0,Math.PI*2);ctx.fill()}}}return off}
function drawMap(now){const canvas=el('world'),w=Math.max(1,Math.floor(canvas.clientWidth)),h=Math.round(w/1.85),dpr=Math.min(devicePixelRatio||1,2);if(canvas.width!==Math.round(w*dpr)||canvas.height!==Math.round(h*dpr)){canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr);baseMap=mapBackground(w,h)}const ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,w,h);if(baseMap)ctx.drawImage(baseMap,0,0);hitPoints=[];
if(view){const destination=countryMap.get(view.settings.serverCountry),end=destination?project(destination.lon,destination.lat,w,h):[w*.82,h*.88];
for(const c of view.countries){const country=countryMap.get(c.name);if(!country)continue;const [x,y]=project(country.lon,country.lat,w,h);ctx.fillStyle='#55c9ff';ctx.globalAlpha=.75;ctx.beginPath();ctx.arc(x,y,2+Math.min(5,Math.log2(c.count+1)),0,Math.PI*2);ctx.fill();hitPoints.push({x,y,text:country.name+' · '+num(c.count)+' событий'})}ctx.globalAlpha=1;
pulses=pulses.filter(p=>now-p.start<3200);for(const p of pulses){const c=countryMap.get(p.country);if(!c)continue;const start=project(c.lon,c.lat,w,h),t=Math.min(1,(now-p.start)/2400),control=[(start[0]+end[0])/2,(start[1]+end[1])/2-Math.min(h*.3,Math.abs(start[0]-end[0])*.3+30)],fade=Math.min(1,(3200-(now-p.start))/700);ctx.strokeStyle=colors[p.kind];ctx.globalAlpha=.25*fade;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(...start);ctx.quadraticCurveTo(...control,...end);ctx.stroke();const x=(1-t)**2*start[0]+2*(1-t)*t*control[0]+t*t*end[0],y=(1-t)**2*start[1]+2*(1-t)*t*control[1]+t*t*end[1];ctx.fillStyle=colors[p.kind];ctx.globalAlpha=fade;ctx.shadowColor=colors[p.kind];ctx.shadowBlur=12;ctx.beginPath();ctx.arc(x,y,2.5,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0}
ctx.globalAlpha=1;ctx.strokeStyle='#c8b5ff';ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(...end,7,0,Math.PI*2);ctx.stroke();ctx.fillStyle='#e7dcff';ctx.beginPath();ctx.arc(...end,3,0,Math.PI*2);ctx.fill();ctx.font='11px system-ui';ctx.textAlign='center';ctx.fillText(destination?'NPM · '+destination.name:'NPM · геопозиция не задана',end[0],end[1]+22);
}
if(!document.hidden&&!paused)requestAnimationFrame(drawMap)
}
function ranking(id,items,label){el(id).innerHTML=items.length?items.slice(0,7).map(c=>'<div class="ranking"><span>'+safe(label(c.name))+'</span><b>'+num(c.count)+'</b></div>').join(''):'<div class="note">Пока нет событий</div>'}
function drawChart(){const c=el('chart'),w=c.clientWidth,h=100,dpr=devicePixelRatio||1;c.width=w*dpr;c.height=h*dpr;const ctx=c.getContext('2d');ctx.scale(dpr,dpr);if(!view)return;const buckets=new Map(view.series.map(v=>[Number(v.name),v.count])),end=Math.floor(view.now/60)*60,values=Array.from({length:60},(_,i)=>buckets.get(end-(59-i)*60)||0),max=Math.max(1,...values);ctx.strokeStyle='#26314b';ctx.beginPath();ctx.moveTo(0,82);ctx.lineTo(w,82);ctx.stroke();ctx.fillStyle='#55c9ff';values.forEach((v,i)=>{const x=i*w/60,height=v/max*65;ctx.fillRect(x,82-height,Math.max(1,w/60-3),height)});ctx.fillStyle='#8490ab';ctx.font='10px system-ui';ctx.fillText('−60 мин',0,98);ctx.textAlign='right';ctx.fillText('сейчас',w,98);ctx.fillText('макс. '+num(max)+'/мин',w,12)}
function render(data){view=data;el('unique').textContent=num(data.uniqueIps);el('requests').textContent=num(data.requests);el('alerts').textContent=num(data.alerts);el('decisions').textContent=num(data.decisions);el('qps').textContent=Number(data.qps).toFixed(2);el('geoDate').textContent=data.geoDate;ranking('countries',data.countries,countryName);ranking('ips',data.ips,v=>v);
const src=data.sources[0],fresh=src&&data.now-src.lastSeen<20;el('live').textContent=paused?'ПАУЗА':fresh?'● В ЭФИРЕ':'ОЖИДАНИЕ АГЕНТА';el('live').classList.toggle('stale',!fresh||paused);el('sourceName').textContent=src?src.name:'Источник ещё не прислал данные';
const messages=[];if(!src)messages.push('Выбранный агент ещё не прислал данные. Нужен агент v0.20.0 или новее.');else{messages.push(src.name+' · '+src.status.files+' логов · сбор с '+new Date(src.status.startedAt*1000).toLocaleString('ru-RU'));if(!fresh)messages.push('Источник не обновляется; показана сохранённая история');if(src.status.logError)messages.push(src.status.logError);if(src.status.crowdsecError)messages.push('CrowdSec: '+src.status.crowdsecError);if(src.status.backlogBytes)messages.push('Ожидает чтения: '+num(src.status.backlogBytes)+' байт');if(src.status.crowdsecLimited)messages.push('CrowdSec: получены последние 50 срабатываний; более ранняя история может быть неполной');if(src.status.parseErrors)messages.push('Нераспознанных строк: '+src.status.parseErrors)}if(!data.geoReady)messages.push('База GeoIP недоступна');if(data.limited)messages.push('Достигнут лимит 200 000 событий: часть суточной истории удалена');el('status').textContent=messages.join(' · ');
el('status').classList.toggle('error',!fresh||!!src?.status.logError||!!src?.status.crowdsecError||data.limited);
const current=el('domain').value;if(!current&&!kind){el('domain').innerHTML='<option value="">Все домены</option>'+data.domains.filter(c=>c.name).map(c=>'<option value="'+safe(c.name)+'">'+safe(c.name)+'</option>').join('')}
const unmapped=data.countries.filter(c=>!countryMap.has(c.name)).reduce((n,c)=>n+c.count,0);el('unmapped').textContent='Локальные / без геолокации: '+num(unmapped);
el('feed').innerHTML=data.events.length?data.events.map(e=>'<article class="event"><div class="event-head"><b>'+safe(e.ip)+'</b><time>'+new Date(e.time*1000).toLocaleTimeString('ru-RU')+'</time></div><div class="meta">'+safe(countryName(e.country))+' · <span class="kind '+safe(e.kind)+'">'+kindLabels[e.kind]+'</span></div><div class="path">'+safe(e.kind==='request'?e.method+' '+e.domain+e.path+' · HTTP '+e.status:(e.domain?e.domain+' · ':'')+e.reason)+'</div></article>').join(''):'<div class="empty">Нет событий по выбранному фильтру. Новые HTTP-запросы появятся после записи в access-лог.</div>';
for(const e of data.events){if(!seen.has(e.sequence)&&e.time>=data.now-15&&pulses.length<70)pulses.push({country:e.country,kind:e.kind,start:performance.now()});seen.add(e.sequence)}if(seen.size>5000)seen=new Set(data.events.map(e=>e.sequence));drawChart();
}
async function poll(){if(paused||busy||document.hidden)return;busy=true;try{const q=new URLSearchParams({domain:el('domain').value,kind,external:el('external').checked?'1':'0'}),r=await fetch('/api/traffic?'+q);if(!r.ok){if(r.status===404){view=null;pulses=[];location.replace('/');return;}throw Error(r.status===404?'Карта отключена':'Не удалось обновить данные')}render(await r.json());lastPoll=Date.now()}catch(e){el('live').textContent='НЕТ ОБНОВЛЕНИЙ';el('live').classList.add('stale');el('status').textContent=e.message;el('status').classList.add('error')}finally{busy=false}}
document.querySelectorAll('[data-kind]').forEach(b=>b.onclick=()=>{kind=b.dataset.kind;document.querySelectorAll('[data-kind]').forEach(x=>x.classList.toggle('active',x===b));pulses=[];poll()});el('domain').onchange=()=>{pulses=[];poll()};el('external').onchange=()=>{pulses=[];poll()};el('pause').onclick=()=>{paused=!paused;el('pause').textContent=paused?'Продолжить':'Пауза';el('live').textContent=paused?'ПАУЗА':'Подключение…';if(!paused){poll();requestAnimationFrame(drawMap)}};el('fullscreen').onclick=()=>{if(document.fullscreenElement)document.exitFullscreen();else document.documentElement.requestFullscreen().catch(()=>{})};window.addEventListener('resize',drawChart);document.addEventListener('visibilitychange',()=>{if(!document.hidden&&!paused){poll();requestAnimationFrame(drawMap)}});
el('world').addEventListener('pointermove',event=>{const rect=event.currentTarget.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,point=hitPoints.find(p=>Math.hypot(x-p.x,y-p.y)<14),tip=el('tooltip');tip.style.display=point?'block':'none';if(point){tip.textContent=point.text;tip.style.left=Math.min(innerWidth-240,event.clientX+12)+'px';tip.style.top=event.clientY+15+'px'}});el('world').addEventListener('pointerleave',()=>el('tooltip').style.display='none');
(async()=>{try{const r=await fetch('/api/traffic/world');if(!r.ok)throw Error();world=await r.json();countryMap=new Map(world.map(c=>[c.code,c]));requestAnimationFrame(drawMap);await poll();setInterval(poll,2000)}catch(e){el('status').textContent='Не удалось загрузить карту. Обновите страницу.'}})();setInterval(()=>{el('clock').textContent=new Date().toLocaleTimeString('ru-RU')},1000);
</script></body></html>

1
assets/world.json Normal file

File diff suppressed because one or more lines are too long

5
go.mod
View File

@@ -2,7 +2,10 @@ module proxmox-host-info
go 1.25.0
require modernc.org/sqlite v1.54.0
require (
github.com/oschwald/maxminddb-golang v1.13.1
modernc.org/sqlite v1.54.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect

10
go.sum
View File

@@ -1,3 +1,5 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@@ -10,8 +12,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
@@ -21,6 +29,8 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=

View File

@@ -12,5 +12,6 @@ package="$stage/proxmox-dashboard-install"
mkdir -p "$package/dist" "$package/scripts"
cp dist/proxmox-dashboard-linux-amd64 dist/proxmox-dashboard-linux-amd64.sha256 "$package/dist/"
cp scripts/install.sh scripts/install-agent.sh scripts/uninstall-agent.sh scripts/proxmox-dashboard-update "$package/scripts/"
cp assets/NOTICE.md "$package/MAP-DATA-NOTICE.md"
cp ./*.service ./*.timer ./*.path README.md "$package/"
tar -czf dist/proxmox-dashboard-install-amd64.tar.gz -C "$stage" proxmox-dashboard-install

View File

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

54
traffic_preview_test.go Normal file
View 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, "HTTP-запросы и события CrowdSec · последние 24 часа", "ДЕМОНСТРАЦИЯ · искусственные данные для проверки интерфейса", 1)))
return
}
mux.ServeHTTP(w, r)
})
t.Fatal(http.ListenAndServe("127.0.0.1:19105", handler))
}

70
traffic_settings.go Normal file
View 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
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

32
web.go
View File

@@ -11,6 +11,7 @@ import (
)
type dashboardMetrics struct {
TrafficSettings TrafficSettings `json:"trafficSettings"`
CPU CPUMetrics `json:"cpu"`
Memory MemoryMetrics `json:"memory"`
Storage StorageMetrics `json:"storage"`
@@ -55,7 +56,7 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle
}
thresholds := store.Thresholds()
metrics := dashboardMetrics{
CPU: cpu, Memory: memory, Storage: storage,
CPU: cpu, Memory: memory, Storage: storage, TrafficSettings: store.TrafficSettings(),
Disks: diskCollector.collect(thresholds), Network: network,
ZFS: zfsCollector.collect(), UPS: upsCollector.collect(),
Guests: guestCollector.collect(), Backups: backupCollector.collect(), Maintenance: maintenanceCollector.collect(), Services: serviceCollector.collect(), SystemHealth: systemHealth.collect(), Update: readUpdateStatus(), Timestamp: time.Now(),
@@ -125,6 +126,7 @@ func interfaceVMID(name string) int {
func routes(collector *cpuCollector, store *Store) http.Handler {
mux := http.NewServeMux()
trafficRoutes(mux, store)
network, networkErr := newNetworkCollector("/proc/net/dev")
disks := &diskCollector{}
zfs := &zfsCollector{}
@@ -281,7 +283,8 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
result := agentReportResponse{Accepted: true}
trafficSettings := store.TrafficSettings()
result := agentReportResponse{Accepted: true, TrafficEnabled: trafficSettings.Enabled && trafficSettings.AgentID == id}
result.Commands, _ = store.DispatchAgentActions(id)
if report.Version != version && version != "dev" {
manifest, _, manifestErr := currentAgentUpdate()
@@ -585,7 +588,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
</style>
</head>
<body><main>
<header><div><div class="eyebrow">Proxmox host telemetry</div><h1>Host overview</h1></div><div class="header-actions"><div class="status"><i class="dot" id="dot"></i><span id="status">Подключение…</span></div><button class="settings-button" id="managementButton" aria-label="Управление" title="Сервисы, агенты и плановые работы">⌘</button><button class="settings-button" id="settingsButton" aria-label="Настройки" title="Настройки Dashboard">⚙</button></div></header>
<header><div><div class="eyebrow">Proxmox host telemetry</div><h1>Host overview</h1></div><div class="header-actions"><div class="status"><i class="dot" id="dot"></i><span id="status">Подключение…</span></div><a class="badge" id="trafficLink" hidden href="/traffic" style="text-decoration:none">◎ Карта трафика</a><button class="settings-button" id="managementButton" aria-label="Управление" title="Сервисы, агенты и плановые работы">⌘</button><button class="settings-button" id="settingsButton" aria-label="Настройки" title="Настройки Dashboard">⚙</button></div></header>
<div class="maintenance-banner" id="maintenanceBanner" hidden><div><strong>Плановые работы</strong><small id="maintenanceBannerReason"></small></div><span id="maintenanceBannerEnds"></span></div>
<section class="cards">
<button class="card alert-center" data-card="alerts" data-open="alertsDialog" aria-label="Открыть центр уведомлений">
@@ -597,7 +600,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<button class="card" data-card="cpu" data-open="cpuDialog" aria-label="Открыть подробности процессора">
<div class="card-head"><div><div class="label">CPU</div><div class="name" id="cpuName">Загрузка данных…</div></div><div class="percent"><span id="cpuUsage">—</span><small>%</small></div></div>
<div class="bar"><div class="fill" id="cpuBar"></div></div>
<div class="quick"><div><span class="label">Средняя температура</span><strong id="cpuTemp">—</strong></div><div><span class="label">Ядра / потоки</span><strong id="cpuTopology">—</strong></div></div>
<div class="quick"><div><span class="label">Макс. температура</span><strong id="cpuTemp">—</strong></div><div><span class="label">Ядра / потоки</span><strong id="cpuTopology">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
</button>
<button class="card memory" data-card="memory" data-open="memoryDialog" aria-label="Открыть подробности оперативной памяти">
@@ -809,7 +812,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<dialog id="settingsDialog">
<div class="modal-head"><div><div class="label">Dashboard</div><h2>Настройки</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="settings-tabs"><button class="settings-tab active" data-settings-tab="appearance">Интерфейс</button><button class="settings-tab" data-settings-tab="alerts">Пороги</button><button class="settings-tab" data-settings-tab="email">Почта</button><button class="settings-tab" data-settings-tab="updates">Обновления</button></div>
<div class="settings-tabs"><button class="settings-tab active" data-settings-tab="appearance">Интерфейс</button><button class="settings-tab" data-settings-tab="alerts">Пороги</button><button class="settings-tab" data-settings-tab="email">Почта</button><button class="settings-tab" data-settings-tab="traffic" id="trafficSettingsTab">Карта трафика</button><button class="settings-tab" data-settings-tab="updates">Обновления</button></div>
<div class="settings-body settings-panel" data-settings-panel="appearance">
<div class="settings-group"><div class="label">Тема оформления</div><div class="theme-options"><button class="theme-option" data-theme-choice="dark">Тёмная</button><button class="theme-option" data-theme-choice="light">Светлая</button></div></div>
<div class="settings-group"><div class="label">Видимые карточки</div><div class="setting-list">
@@ -876,6 +879,14 @@ footer{color:#657581;font-size:12px;margin-top:17px}
</div></div>
<button class="save-settings" id="saveEmail">Сохранить настройки почты</button><button class="theme-option" id="testEmail" style="width:100%;margin-top:9px">Отправить тестовое письмо</button><div class="settings-note" id="emailStatus">Пароль хранится в локальной базе сервера и не возвращается в браузер.</div>
</div>
<div class="settings-body settings-panel" data-settings-panel="traffic" hidden>
<div class="settings-group"><div class="label">Карта трафика · Nginx Proxy Manager + CrowdSec</div>
<label class="threshold-item"><span>Включить страницу и сбор трафика</span><input type="checkbox" id="trafficEnabled"></label>
<label class="field"><span>Агент-источник</span><select id="trafficAgent"><option value="">Выберите агент</option></select></label>
<label class="field"><span>Страна сервера на карте (необязательно)</span><select id="trafficCountry"><option value="">Не задана</option></select></label>
<p class="settings-note">Сбор выполняется только выбранным агентом: access-логи /opt/npm/data/logs и локальный cscli. Изменить путь можно через DASHBOARD_NPM_LOG_DIR в окружении службы агента. При выключении сбор прекращается и ссылка на страницу скрывается. События карты не создают общих алертов и писем. История: до 24 часов и 200 000 событий.</p>
<button class="save-settings" id="saveTrafficSettings">Сохранить</button><span id="trafficSettingsStatus" role="status"></span>
</div></div>
<div class="settings-body settings-panel" data-settings-panel="updates" hidden>
<div class="detail-grid">
<div class="detail"><span class="label">Установлена</span><strong id="updateCurrent">—</strong></div><div class="detail"><span class="label">Доступна</span><strong id="updateAvailable">—</strong></div><div class="detail"><span class="label">Режим</span><strong id="updateMode">—</strong></div>
@@ -946,7 +957,7 @@ async function queueAgentAction(action,target){const labels={'system-update':'у
async function watchAgentAction(id,status){for(let attempt=0;attempt<180;attempt++){await new Promise(resolve=>setTimeout(resolve,1000));const response=await fetch('/api/agent-actions');if(!response.ok)continue;const items=await response.json()||[],item=items.find(v=>v.id===id);if(!item)continue;status.textContent=item.status==='queued'?'Ожидаем агента…':item.status==='running'?'Выполняется на агенте…':item.status==='completed'?'Готово: '+(item.result||'успешно'):'Ошибка: '+(item.result||'команда не выполнена');if(item.status==='completed'||item.status==='failed'){status.className='settings-note '+(item.status==='failed'?'status-down':'status-up');document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=false);await loadAgentActions();return}}status.className='settings-note status-down';status.textContent='Агент не вернул результат за 3 минуты. Проверьте его состояние в истории действий.';document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=false)}
async function loadAgentHistory(agent,hours=24){const kinds=['agent-resource','agent-swap'],responses=await Promise.all(kinds.map(kind=>fetch('/api/entity-history?kind='+kind+'&hours='+hours).then(r=>r.json()))),key=agent.id+' · ',resource=(responses[0]||[]).find(v=>v.name.startsWith(key)),swap=(responses[1]||[]).find(v=>v.name.startsWith(key)),points=new Map();(resource?.points||[]).forEach(p=>points.set(p.timestamp,{timestamp:p.timestamp,cpu:p.value1,ram:p.value2}));(swap?.points||[]).forEach(p=>{const item=points.get(p.timestamp)||{timestamp:p.timestamp};item.swap=p.value1;points.set(p.timestamp,item)});chartConfigs.agentHistoryChart={series:[{label:'CPU',color:'#55d58a',value:p=>p.cpu,format:percent},{label:'RAM',color:'#9d8cff',value:p=>p.ram,format:percent},{label:'Swap',color:'#ff78b8',value:p=>p.swap,format:percent}]};const canvas=el('agentHistoryChart');drawChart('agentHistoryChart',[...points.values()].sort((a,b)=>a.timestamp-b.timestamp));if(!canvas.dataset.agentHover){setupAgentHistoryHover(canvas);canvas.dataset.agentHover='1'}}
function setupAgentHistoryHover(canvas){canvas.addEventListener('mousemove',event=>{const state=canvas._chartState,tooltip=el('agentHistoryDetails').querySelector('.chart-tooltip');if(!state||!tooltip)return;const rect=canvas.getBoundingClientRect(),x=event.clientX-rect.left,index=Math.max(0,Math.min(state.points.length-1,Math.round((x-state.pad.l)/state.pw*(state.points.length-1)))),point=state.points[index];tooltip.innerHTML='<div class="chart-tooltip-time">'+new Date(point.timestamp*1000).toLocaleString('ru-RU')+'</div>'+state.series.map(s=>'<div class="chart-tooltip-value"><span><i style="background:'+s.color+'"></i>'+s.label+'</span><strong>'+(s.value(point)==null?'—':s.format(s.value(point)))+'</strong></div>').join('');tooltip.classList.add('visible');tooltip.style.left=Math.min(x+12,canvas.clientWidth-tooltip.offsetWidth-10)+'px';tooltip.style.top='55px'});canvas.addEventListener('mouseleave',()=>el('agentHistoryDetails').querySelector('.chart-tooltip').classList.remove('visible'))}
function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{available:false,pools:[]},u=x.ups||{available:false},g=x.guests||{available:false,guests:[]},b=x.backups||{available:false,items:[]},mt=x.maintenance||{},alerts=x.alerts||[],disks=x.disks||[],avg=c.temperatureCelsius??null;
function render(x){el('trafficLink').hidden=!x.trafficSettings?.enabled;const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{available:false,pools:[]},u=x.ups||{available:false},g=x.guests||{available:false,guests:[]},b=x.backups||{available:false,items:[]},mt=x.maintenance||{},alerts=x.alerts||[],disks=x.disks||[],avg=c.temperatureCelsius??null;
currentServiceLinks=x.serviceLinks||[];
lastDashboardEvent=Date.now();
maintenanceGuests=(g.guests||[]).filter(v=>!v.template);applyMaintenanceState(x.maintenanceWindow||{});
@@ -1016,6 +1027,15 @@ function setTheme(theme){document.documentElement.dataset.theme=theme;localStora
['services','agents','maintenance-mode','ups'].forEach((name,index)=>{const panel=document.querySelector('[data-settings-panel="'+name+'"]');if(panel){panel.dataset.managementPanel=name;panel.hidden=index!==0;el('managementPanels').appendChild(panel)}});
function selectManagement(tab){document.querySelectorAll('[data-management-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-management-panel]').forEach(p=>p.hidden=p.dataset.managementPanel!==tab.dataset.managementTab)}el('managementButton').onclick=()=>{selectManagement(document.querySelector('[data-management-tab].active')||document.querySelector('[data-management-tab]'));el('managementDialog').showModal()};document.querySelectorAll('[data-management-tab]').forEach(tab=>tab.onclick=()=>selectManagement(tab));
document.querySelectorAll('[data-settings-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-settings-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-settings-panel]').forEach(p=>p.hidden=p.dataset.settingsPanel!==tab.dataset.settingsTab)});
async function loadTrafficSettings(){try{
const [settingsResponse,agentsResponse,worldResponse]=await Promise.all([fetch('/api/settings/traffic'),fetch('/api/agents'),fetch('/api/traffic/world')]);if(!settingsResponse.ok||!agentsResponse.ok||!worldResponse.ok)throw Error();
const settings=await settingsResponse.json(),agents=await agentsResponse.json(),world=await worldResponse.json();
el('trafficEnabled').checked=settings.enabled;el('trafficAgent').innerHTML='<option value="">Выберите агент</option>'+(agents||[]).map(a=>'<option value="'+safe(a.id)+'">'+safe(a.name)+' · VMID '+a.vmid+' · '+safe(a.version)+(a.online?' · онлайн':' · офлайн')+'</option>').join('');el('trafficAgent').value=settings.agentId;
el('trafficCountry').innerHTML='<option value="">Не задана</option>'+world.filter(c=>c.code&&c.code!=='-99').sort((a,b)=>a.name.localeCompare(b.name,'ru')).map(c=>'<option value="'+safe(c.code)+'">'+safe(c.name)+'</option>').join('');el('trafficCountry').value=settings.serverCountry;el('trafficSettingsStatus').textContent='';
}catch(e){el('trafficSettingsStatus').textContent='Не удалось загрузить настройки';}}
el('trafficSettingsTab').addEventListener('click',loadTrafficSettings);
el('saveTrafficSettings').onclick=async()=>{const button=el('saveTrafficSettings');button.disabled=true;try{const response=await fetch('/api/settings/traffic',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:el('trafficEnabled').checked,agentId:el('trafficAgent').value,serverCountry:el('trafficCountry').value})});if(!response.ok)throw Error(await response.text());el('trafficLink').hidden=!el('trafficEnabled').checked;el('trafficSettingsStatus').textContent='Сохранено. Агент применит настройки при следующем отчёте.';}catch(e){el('trafficSettingsStatus').textContent=e.message||'Ошибка сохранения';}finally{button.disabled=false;}};
if(location.hash==='#traffic-settings'){el('settingsDialog').showModal();el('trafficSettingsTab').click();}
el('agentServerURL').value=location.origin;
el('createAgentToken').onclick=async()=>{const button=el('createAgentToken'),server=el('agentServerURL').value.trim().replace(/\/$/,''),vmid=Number(el('agentVMID').value)||0;if(!/^https?:\/\//.test(server)){alert('Укажите полный адрес Dashboard, доступный из VM/LXC.');return}button.disabled=true;button.textContent='Создаём токен…';try{const response=await fetch('/api/agents/enrollment-token',{method:'POST'});if(!response.ok)throw new Error(await response.text());const value=await response.json(),quote=v=>"'"+String(v).replaceAll("'","'\"'\"'")+"'";el('agentInstallCommand').textContent='curl -fsSL https://git.myown.center/maxim/ProxmoxDash/raw/branch/main/scripts/install-agent.sh | DASHBOARD_URL='+quote(server)+' ENROLL_TOKEN='+quote(value.token)+' VMID='+quote(vmid)+' sh';el('agentInstallBlock').hidden=false}catch(e){alert('Не удалось создать токен: '+e.message)}finally{button.disabled=false;button.textContent='Создать команду установки или обновления'}};
el('agentSettingsList').onclick=async event=>{const detail=event.target.closest('[data-agent-detail]');if(detail){const agent=currentAgents.find(v=>v.id===detail.dataset.agentDetail);if(agent)openAgentDetail(agent);return}const button=event.target.closest('[data-agent-delete]');if(!button||!confirm('Удалить агент? Для повторного подключения потребуется новый токен.'))return;button.disabled=true;const response=await fetch('/api/agents?id='+encodeURIComponent(button.dataset.agentDelete),{method:'DELETE'});if(response.ok)button.closest('.disk-item').remove();else{button.disabled=false;alert(await response.text())}};