Add opt-in NPM and CrowdSec traffic map with selectable agent source
This commit is contained in:
68
README.md
68
README.md
@@ -434,3 +434,71 @@ InErrors, InDroppedDma, AllocFails, SkbAllocFails и Restarts через ethtool
|
|||||||
|
|
||||||
Скрипт запускает тесты и создаёт в dist бинарник, SHA-256 и установочный архив,
|
Скрипт запускает тесты и создаёт в dist бинарник, SHA-256 и установочный архив,
|
||||||
совместимые со встроенным обновлением из Gitea Releases.
|
совместимые со встроенным обновлением из 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
|
||||||
|
```
|
||||||
|
|||||||
23
agent.go
23
agent.go
@@ -144,9 +144,10 @@ type AgentUpdateManifest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type agentReportResponse struct {
|
type agentReportResponse struct {
|
||||||
Accepted bool `json:"accepted"`
|
Accepted bool `json:"accepted"`
|
||||||
Update *AgentUpdateManifest `json:"update,omitempty"`
|
TrafficEnabled bool `json:"trafficEnabled"`
|
||||||
Commands []AgentCommand `json:"commands,omitempty"`
|
Update *AgentUpdateManifest `json:"update,omitempty"`
|
||||||
|
Commands []AgentCommand `json:"commands,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var guestInventory struct {
|
var guestInventory struct {
|
||||||
@@ -339,6 +340,15 @@ func runGuestAgent(opts agentOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var trafficStop func()
|
||||||
|
stopTraffic := func() {
|
||||||
|
if trafficStop != nil {
|
||||||
|
trafficStop()
|
||||||
|
trafficStop = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer stopTraffic()
|
||||||
|
|
||||||
client := &http.Client{Timeout: 12 * time.Second}
|
client := &http.Client{Timeout: 12 * time.Second}
|
||||||
for {
|
for {
|
||||||
report := collectAgentReport()
|
report := collectAgentReport()
|
||||||
@@ -356,6 +366,7 @@ func runGuestAgent(opts agentOptions) error {
|
|||||||
if response.StatusCode == http.StatusUnauthorized && opts.EnrollmentToken != "" {
|
if response.StatusCode == http.StatusUnauthorized && opts.EnrollmentToken != "" {
|
||||||
fresh, enrollmentErr := enrollRemote(config, opts.EnrollmentToken)
|
fresh, enrollmentErr := enrollRemote(config, opts.EnrollmentToken)
|
||||||
if enrollmentErr == nil {
|
if enrollmentErr == nil {
|
||||||
|
stopTraffic()
|
||||||
config = fresh
|
config = fresh
|
||||||
if saveErr := saveAgentConfig(opts.ConfigPath, config); saveErr != nil {
|
if saveErr := saveAgentConfig(opts.ConfigPath, config); saveErr != nil {
|
||||||
return saveErr
|
return saveErr
|
||||||
@@ -372,6 +383,12 @@ func runGuestAgent(opts agentOptions) error {
|
|||||||
if response.StatusCode == http.StatusOK {
|
if response.StatusCode == http.StatusOK {
|
||||||
var result agentReportResponse
|
var result agentReportResponse
|
||||||
if json.Unmarshal(responseData, &result) == nil {
|
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 {
|
if len(report.ActionResults) > 0 {
|
||||||
guestActionResults.Lock()
|
guestActionResults.Lock()
|
||||||
guestActionResults.value = nil
|
guestActionResults.value = nil
|
||||||
|
|||||||
387
agent_traffic.go
Normal file
387
agent_traffic.go
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var npmTrafficPattern = regexp.MustCompile(`^\[([^]]+)\] .*? (\d{3}) - ([A-Z]+) (?:https?|HTTP) (\S+) "([^"]*)" \[Client ([^]]+)\]`)
|
||||||
|
|
||||||
|
func parseNPMTraffic(line string) (TrafficEvent, bool) {
|
||||||
|
m := npmTrafficPattern.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return TrafficEvent{}, false
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(m[6])
|
||||||
|
if ip == nil {
|
||||||
|
return TrafficEvent{}, false
|
||||||
|
}
|
||||||
|
ts, err := time.Parse("02/Jan/2006:15:04:05 -0700", m[1])
|
||||||
|
if err != nil {
|
||||||
|
return TrafficEvent{}, false
|
||||||
|
}
|
||||||
|
status, _ := strconv.Atoi(m[2])
|
||||||
|
return TrafficEvent{Time: ts.Unix(), Kind: "request", IP: ip.String(), Domain: trafficText(m[4], 253), Method: m[3], Path: trafficPath(m[5]), Status: status}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type trafficCursor struct {
|
||||||
|
Offset int64 `json:"offset"`
|
||||||
|
Generation int64 `json:"generation"`
|
||||||
|
Seen int64 `json:"seen"`
|
||||||
|
}
|
||||||
|
type trafficAgentState struct {
|
||||||
|
BacklogBytes int64 `json:"backlogBytes"`
|
||||||
|
Initialized bool `json:"initialized"`
|
||||||
|
StartedAt int64 `json:"startedAt"`
|
||||||
|
Cursors map[string]trafficCursor `json:"cursors"`
|
||||||
|
Pending []TrafficEvent `json:"pending"`
|
||||||
|
ParseErrors uint64 `json:"parseErrors"`
|
||||||
|
CrowdSeen map[string]int64 `json:"crowdSeen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *trafficAgentState) readLogs(dir string) (int, error) {
|
||||||
|
state.BacklogBytes = 0
|
||||||
|
paths, err := filepath.Glob(filepath.Join(dir, "*_access.log*"))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
sort.Strings(paths)
|
||||||
|
files := 0
|
||||||
|
budget := int64(1 << 20)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
var firstErr error
|
||||||
|
for _, path := range paths {
|
||||||
|
if strings.HasSuffix(path, ".gz") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
firstErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil || !info.Mode().IsRegular() {
|
||||||
|
f.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
f.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
active := strings.HasSuffix(path, "_access.log")
|
||||||
|
if active {
|
||||||
|
files++
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("%d:%d", st.Dev, st.Ino)
|
||||||
|
cursor, known := state.Cursors[key]
|
||||||
|
if !known {
|
||||||
|
if !state.Initialized || !active {
|
||||||
|
cursor.Offset = info.Size()
|
||||||
|
}
|
||||||
|
cursor.Generation = now
|
||||||
|
}
|
||||||
|
cursor.Seen = now
|
||||||
|
if info.Size() < cursor.Offset {
|
||||||
|
cursor.Offset = 0
|
||||||
|
cursor.Generation++
|
||||||
|
}
|
||||||
|
if len(state.Pending) < 400 && budget > 0 {
|
||||||
|
_, err = f.Seek(cursor.Offset, io.SeekStart)
|
||||||
|
if err == nil {
|
||||||
|
reader := bufio.NewReaderSize(io.LimitReader(f, budget), 64<<10)
|
||||||
|
for len(state.Pending) < 400 && budget > 0 {
|
||||||
|
line, readErr := reader.ReadString('\n')
|
||||||
|
// Keep an unfinished line for the next poll. Bound malformed huge lines.
|
||||||
|
if readErr != nil {
|
||||||
|
if len(line) >= 64<<10 {
|
||||||
|
cursor.Offset += int64(len(line))
|
||||||
|
state.ParseErrors++
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset := cursor.Offset
|
||||||
|
cursor.Offset += int64(len(line))
|
||||||
|
budget -= int64(len(line))
|
||||||
|
if len(line) > 64<<10 {
|
||||||
|
state.ParseErrors++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e, ok := parseNPMTraffic(line)
|
||||||
|
if !ok {
|
||||||
|
state.ParseErrors++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.Time < now-86400 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e.ID = fmt.Sprintf("npm:%s:%d:%d", key, cursor.Generation, offset)
|
||||||
|
state.Pending = append(state.Pending, e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info.Size() > cursor.Offset {
|
||||||
|
state.BacklogBytes += info.Size() - cursor.Offset
|
||||||
|
}
|
||||||
|
state.Cursors[key] = cursor
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
for key, cursor := range state.Cursors {
|
||||||
|
if cursor.Seen < now-7*86400 {
|
||||||
|
delete(state.Cursors, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if files > 0 {
|
||||||
|
state.Initialized = true
|
||||||
|
}
|
||||||
|
if firstErr != nil {
|
||||||
|
return files, errors.New("Не удалось прочитать часть access-логов NPM")
|
||||||
|
}
|
||||||
|
if files == 0 {
|
||||||
|
return files, errors.New("Access-логи NPM не найдены")
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type crowdTrafficAlert struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Scenario string `json:"scenario"`
|
||||||
|
Simulated bool `json:"simulated"`
|
||||||
|
Source struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
} `json:"source"`
|
||||||
|
Events []struct {
|
||||||
|
Meta []struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
} `json:"meta"`
|
||||||
|
} `json:"events"`
|
||||||
|
Decisions []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Simulated bool `json:"simulated"`
|
||||||
|
} `json:"decisions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCrowdTraffic(data []byte) ([]TrafficEvent, error) {
|
||||||
|
var alerts []crowdTrafficAlert
|
||||||
|
if err := json.Unmarshal(data, &alerts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []TrafficEvent{}
|
||||||
|
for _, a := range alerts {
|
||||||
|
if a.Simulated || net.ParseIP(a.Source.IP) == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ts, err := time.Parse(time.RFC3339Nano, a.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := a.UUID
|
||||||
|
if id == "" {
|
||||||
|
id = fmt.Sprintf("%d:%d", a.ID, ts.Unix())
|
||||||
|
}
|
||||||
|
// An alert may span multiple domains. Do not invent a one-to-one link.
|
||||||
|
domains := map[string]bool{}
|
||||||
|
for _, event := range a.Events {
|
||||||
|
for _, m := range event.Meta {
|
||||||
|
if m.Key == "target_fqdn" {
|
||||||
|
domains[m.Value] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domain := ""
|
||||||
|
if len(domains) == 1 {
|
||||||
|
for d := range domains {
|
||||||
|
domain = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, TrafficEvent{ID: "cs-alert:" + id, Time: ts.Unix(), Kind: "alert", IP: a.Source.IP, Domain: domain, Reason: a.Scenario})
|
||||||
|
for _, d := range a.Decisions {
|
||||||
|
if !d.Simulated && net.ParseIP(d.Value) != nil {
|
||||||
|
out = append(out, TrafficEvent{ID: fmt.Sprintf("cs-decision:%s:%d", id, d.ID), Time: ts.Unix(), Kind: "decision", IP: d.Value, Domain: domain, Reason: d.Type + ": " + a.Scenario})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cscli uses the existing local machine credentials. No new bouncer key or
|
||||||
|
// public LAPI port is needed, and only the read-only alerts list is executed.
|
||||||
|
func collectCrowdTraffic(ctx context.Context) ([]TrafficEvent, bool, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "cscli", "alerts", "list", "--since", "24h", "--limit", "50", "-o", "json")
|
||||||
|
var output limitedTrafficOutput
|
||||||
|
cmd.Stdout = &output
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, false, errors.New("cscli alerts list недоступен; проверьте права агента и локальную LAPI")
|
||||||
|
}
|
||||||
|
events, err := parseCrowdTraffic(output.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, errors.New("Не удалось разобрать JSON CrowdSec")
|
||||||
|
}
|
||||||
|
var raw []json.RawMessage
|
||||||
|
json.Unmarshal(output.Bytes(), &raw)
|
||||||
|
return events, len(raw) >= 50, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitedTrafficOutput struct{ bytes.Buffer }
|
||||||
|
|
||||||
|
func (b *limitedTrafficOutput) Write(p []byte) (int, error) {
|
||||||
|
if b.Len()+len(p) > 4<<20 {
|
||||||
|
return 0, errors.New("CrowdSec response too large")
|
||||||
|
}
|
||||||
|
return b.Buffer.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveTrafficAgentState(path string, state trafficAgentState) error {
|
||||||
|
data, err := json.Marshal(state)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.CreateTemp(filepath.Dir(path), ".traffic-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := f.Name()
|
||||||
|
defer os.Remove(name)
|
||||||
|
if err = f.Chmod(0600); err == nil {
|
||||||
|
_, err = f.Write(data)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = f.Sync()
|
||||||
|
}
|
||||||
|
closeErr := f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return closeErr
|
||||||
|
}
|
||||||
|
return os.Rename(name, path)
|
||||||
|
}
|
||||||
|
func runAgentTraffic(ctx context.Context, config agentConfig, stateDir string) {
|
||||||
|
dir := os.Getenv("DASHBOARD_NPM_LOG_DIR")
|
||||||
|
if dir == "off" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dir == "" {
|
||||||
|
dir = "/opt/npm/data/logs"
|
||||||
|
}
|
||||||
|
|
||||||
|
statePath := filepath.Join(stateDir, "traffic-state.json")
|
||||||
|
state := trafficAgentState{StartedAt: time.Now().Unix(), Cursors: map[string]trafficCursor{}, CrowdSeen: map[string]int64{}}
|
||||||
|
if data, err := os.ReadFile(statePath); err == nil {
|
||||||
|
if json.Unmarshal(data, &state) != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "traffic: invalid checkpoint; collector stopped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
fmt.Fprintln(os.Stderr, "traffic: checkpoint unreadable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state.Cursors == nil {
|
||||||
|
state.Cursors = map[string]trafficCursor{}
|
||||||
|
}
|
||||||
|
if state.CrowdSeen == nil {
|
||||||
|
state.CrowdSeen = map[string]int64{}
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
lastCrowd := time.Time{}
|
||||||
|
crowdAt := int64(0)
|
||||||
|
crowdError := ""
|
||||||
|
crowdLimited := false
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
files, logErr := state.readLogs(dir)
|
||||||
|
if len(state.Pending) < 400 && time.Since(lastCrowd) >= 15*time.Second {
|
||||||
|
events, limited, err := collectCrowdTraffic(ctx)
|
||||||
|
crowdLimited = limited
|
||||||
|
lastCrowd = time.Now()
|
||||||
|
if err != nil {
|
||||||
|
crowdError = err.Error()
|
||||||
|
} else {
|
||||||
|
crowdError = ""
|
||||||
|
crowdAt = time.Now().Unix()
|
||||||
|
for _, e := range events {
|
||||||
|
if _, seen := state.CrowdSeen[e.ID]; !seen && len(state.Pending) < 500 {
|
||||||
|
state.Pending = append(state.Pending, e)
|
||||||
|
state.CrowdSeen[e.ID] = e.Time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id, ts := range state.CrowdSeen {
|
||||||
|
if ts < time.Now().Unix()-86400 {
|
||||||
|
delete(state.CrowdSeen, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Persist the pending batch AND offsets before sending. A retry/restart may
|
||||||
|
// resend events; the server's (agent,event_id) unique key makes it idempotent.
|
||||||
|
if err := saveTrafficAgentState(statePath, state); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "traffic: checkpoint write failed; collector stopped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batch := TrafficBatch{BacklogBytes: state.BacklogBytes, CrowdSecLimited: crowdLimited, Events: state.Pending, Files: files, ParseErrors: state.ParseErrors, CrowdSecAt: crowdAt, CrowdSecError: crowdError, StartedAt: state.StartedAt}
|
||||||
|
if logErr != nil {
|
||||||
|
batch.LogError = logErr.Error()
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(batch)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", config.Server+"/api/agent/traffic", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+config.ID+"."+config.Secret)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if res, err := client.Do(req); err == nil {
|
||||||
|
io.Copy(io.Discard, io.LimitReader(res.Body, 4096))
|
||||||
|
res.Body.Close()
|
||||||
|
if res.StatusCode == 200 {
|
||||||
|
state.Pending = nil
|
||||||
|
if saveTrafficAgentState(statePath, state) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAgentTraffic(config agentConfig, stateDir string) func() {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { defer close(done); runAgentTraffic(ctx, config, stateDir) }()
|
||||||
|
return func() { cancel(); <-done }
|
||||||
|
}
|
||||||
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.
47
assets/traffic.html
Normal file
47
assets/traffic.html
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
1
assets/world.json
Normal file
File diff suppressed because one or more lines are too long
5
go.mod
5
go.mod
@@ -2,7 +2,10 @@ module proxmox-host-info
|
|||||||
|
|
||||||
go 1.25.0
|
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 (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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=
|
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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
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/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 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
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 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ package="$stage/proxmox-dashboard-install"
|
|||||||
mkdir -p "$package/dist" "$package/scripts"
|
mkdir -p "$package/dist" "$package/scripts"
|
||||||
cp dist/proxmox-dashboard-linux-amd64 dist/proxmox-dashboard-linux-amd64.sha256 "$package/dist/"
|
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 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/"
|
cp ./*.service ./*.timer ./*.path README.md "$package/"
|
||||||
tar -czf dist/proxmox-dashboard-install-amd64.tar.gz -C "$stage" proxmox-dashboard-install
|
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)
|
db.SetMaxOpenConns(1)
|
||||||
statements := []string{
|
statements := []string{
|
||||||
|
trafficSchema,
|
||||||
`PRAGMA journal_mode=WAL`,
|
`PRAGMA journal_mode=WAL`,
|
||||||
`PRAGMA busy_timeout=5000`,
|
`PRAGMA busy_timeout=5000`,
|
||||||
`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
|
`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, "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
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
|
||||||
32
web.go
32
web.go
@@ -11,6 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type dashboardMetrics struct {
|
type dashboardMetrics struct {
|
||||||
|
TrafficSettings TrafficSettings `json:"trafficSettings"`
|
||||||
CPU CPUMetrics `json:"cpu"`
|
CPU CPUMetrics `json:"cpu"`
|
||||||
Memory MemoryMetrics `json:"memory"`
|
Memory MemoryMetrics `json:"memory"`
|
||||||
Storage StorageMetrics `json:"storage"`
|
Storage StorageMetrics `json:"storage"`
|
||||||
@@ -55,7 +56,7 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle
|
|||||||
}
|
}
|
||||||
thresholds := store.Thresholds()
|
thresholds := store.Thresholds()
|
||||||
metrics := dashboardMetrics{
|
metrics := dashboardMetrics{
|
||||||
CPU: cpu, Memory: memory, Storage: storage,
|
CPU: cpu, Memory: memory, Storage: storage, TrafficSettings: store.TrafficSettings(),
|
||||||
Disks: diskCollector.collect(thresholds), Network: network,
|
Disks: diskCollector.collect(thresholds), Network: network,
|
||||||
ZFS: zfsCollector.collect(), UPS: upsCollector.collect(),
|
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(),
|
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 {
|
func routes(collector *cpuCollector, store *Store) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
trafficRoutes(mux, store)
|
||||||
network, networkErr := newNetworkCollector("/proc/net/dev")
|
network, networkErr := newNetworkCollector("/proc/net/dev")
|
||||||
disks := &diskCollector{}
|
disks := &diskCollector{}
|
||||||
zfs := &zfsCollector{}
|
zfs := &zfsCollector{}
|
||||||
@@ -281,7 +283,8 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
|
|||||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result := agentReportResponse{Accepted: true}
|
trafficSettings := store.TrafficSettings()
|
||||||
|
result := agentReportResponse{Accepted: true, TrafficEnabled: trafficSettings.Enabled && trafficSettings.AgentID == id}
|
||||||
result.Commands, _ = store.DispatchAgentActions(id)
|
result.Commands, _ = store.DispatchAgentActions(id)
|
||||||
if report.Version != version && version != "dev" {
|
if report.Version != version && version != "dev" {
|
||||||
manifest, _, manifestErr := currentAgentUpdate()
|
manifest, _, manifestErr := currentAgentUpdate()
|
||||||
@@ -585,7 +588,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body><main>
|
<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>
|
<div class="maintenance-banner" id="maintenanceBanner" hidden><div><strong>Плановые работы</strong><small id="maintenanceBannerReason"></small></div><span id="maintenanceBannerEnds"></span></div>
|
||||||
<section class="cards">
|
<section class="cards">
|
||||||
<button class="card alert-center" data-card="alerts" data-open="alertsDialog" aria-label="Открыть центр уведомлений">
|
<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="Открыть подробности процессора">
|
<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="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="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>
|
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
|
||||||
</button>
|
</button>
|
||||||
<button class="card memory" data-card="memory" data-open="memoryDialog" aria-label="Открыть подробности оперативной памяти">
|
<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">
|
<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="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-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="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">
|
<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>
|
</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>
|
<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>
|
||||||
|
<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="settings-body settings-panel" data-settings-panel="updates" hidden>
|
||||||
<div class="detail-grid">
|
<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>
|
<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 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'}}
|
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 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||[];
|
currentServiceLinks=x.serviceLinks||[];
|
||||||
lastDashboardEvent=Date.now();
|
lastDashboardEvent=Date.now();
|
||||||
maintenanceGuests=(g.guests||[]).filter(v=>!v.template);applyMaintenanceState(x.maintenanceWindow||{});
|
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)}});
|
['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));
|
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)});
|
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('agentServerURL').value=location.origin;
|
||||||
el('createAgentToken').onclick=async()=>{const button=el('createAgentToken'),server=el('agentServerURL').value.trim().replace(/\/$/,''),vmid=Number(el('agentVMID').value)||0;if(!/^https?:\/\//.test(server)){alert('Укажите полный адрес Dashboard, доступный из VM/LXC.');return}button.disabled=true;button.textContent='Создаём токен…';try{const response=await fetch('/api/agents/enrollment-token',{method:'POST'});if(!response.ok)throw new Error(await response.text());const value=await response.json(),quote=v=>"'"+String(v).replaceAll("'","'\"'\"'")+"'";el('agentInstallCommand').textContent='curl -fsSL https://git.myown.center/maxim/ProxmoxDash/raw/branch/main/scripts/install-agent.sh | DASHBOARD_URL='+quote(server)+' ENROLL_TOKEN='+quote(value.token)+' VMID='+quote(vmid)+' sh';el('agentInstallBlock').hidden=false}catch(e){alert('Не удалось создать токен: '+e.message)}finally{button.disabled=false;button.textContent='Создать команду установки или обновления'}};
|
el('createAgentToken').onclick=async()=>{const button=el('createAgentToken'),server=el('agentServerURL').value.trim().replace(/\/$/,''),vmid=Number(el('agentVMID').value)||0;if(!/^https?:\/\//.test(server)){alert('Укажите полный адрес Dashboard, доступный из VM/LXC.');return}button.disabled=true;button.textContent='Создаём токен…';try{const response=await fetch('/api/agents/enrollment-token',{method:'POST'});if(!response.ok)throw new Error(await response.text());const value=await response.json(),quote=v=>"'"+String(v).replaceAll("'","'\"'\"'")+"'";el('agentInstallCommand').textContent='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())}};
|
el('agentSettingsList').onclick=async event=>{const detail=event.target.closest('[data-agent-detail]');if(detail){const agent=currentAgents.find(v=>v.id===detail.dataset.agentDetail);if(agent)openAgentDetail(agent);return}const button=event.target.closest('[data-agent-delete]');if(!button||!confirm('Удалить агент? Для повторного подключения потребуется новый токен.'))return;button.disabled=true;const response=await fetch('/api/agents?id='+encodeURIComponent(button.dataset.agentDelete),{method:'DELETE'});if(response.ok)button.closest('.disk-item').remove();else{button.disabled=false;alert(await response.text())}};
|
||||||
|
|||||||
Reference in New Issue
Block a user