Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46bd6e8be6 | ||
|
|
03b6c1f289 | ||
|
|
14d57120cd | ||
|
|
c3b2a10d2f | ||
|
|
72bebdc2eb | ||
|
|
6a940772f0 |
130
README.md
130
README.md
@@ -561,3 +561,133 @@ Firewall читается раз в 30 секунд командой `nft -j lis
|
||||
Для iptables/ipset-only, именованных счётчиков и правил без счётчиков выводится
|
||||
«недоступно», а не ноль. Отброшенный пакет до подтверждения conntrack может
|
||||
отсутствовать в таблице потоков. Содержимое пакетов не перехватывается.
|
||||
|
||||
## Сетевой слой карты и фильтр мониторинга (v0.22.0)
|
||||
|
||||
При включённом сборе TCP/UDP/ICMP переключатель **Сеть на карте** показывает
|
||||
текущие потоки отдельным слоем: TCP — зелёный, UDP — розовый, ICMP/ICMPv6 —
|
||||
жёлтый. Импульсы означают новый снимок (~5 секунд), не отдельные пакеты или
|
||||
новые подключения. Входящие и исходящие потоки имеют разное направление.
|
||||
Локальные адреса и потоки без страны учитываются отдельно; при отсутствии
|
||||
свежего снимка 25 секунд сетевой слой гаснет. HTTP-статистика за 24 часа с
|
||||
текущими потоками не складывается. Фильтры домена и HTTP/CrowdSec относятся
|
||||
только к HTTP-слою; фильтры внешних IP и мониторинга действуют на оба слоя.
|
||||
|
||||
В **Настройки → Карта трафика → Правила мониторинга** задайте по одному правилу
|
||||
на строку: `IP | домен | префикс пути`. IP точный, включая IPv6; домен и путь
|
||||
необязательны. Пример: `192.168.0.10` или `192.168.0.10 | example.org | /health`.
|
||||
Для сетевых потоков применяются только правила без домена/пути, к IP инициатора.
|
||||
При первом обновлении добавляется правило `192.168.0.10`; его можно изменить
|
||||
или полностью удалить. Сам фильтр по умолчанию выключен.
|
||||
|
||||
**Скрыть мониторинг** на карте и `/network` исключает совпадения из отображения
|
||||
и соответствующей статистики. Настройка переключателя сохраняется в браузере.
|
||||
HTTP-проверки остаются в истории и возвращаются при выключении фильтра;
|
||||
новые правила сразу применяются и к старым записям. Срабатывания и решения
|
||||
CrowdSec, а также общие счётчики firewall, никогда не скрываются этим фильтром.
|
||||
Сетевые потоки по-прежнему хранятся только последним снимком, без истории.
|
||||
|
||||
## Alpine LXC и Docker NPM/NPM+ (v0.23.0)
|
||||
|
||||
Установщик агента поддерживает Linux **x86_64/amd64** с systemd или OpenRC
|
||||
(Alpine LXC). Для Alpine команда из настроек сначала устанавливает curl и
|
||||
CA-сертификаты, если curl отсутствует. Бинарник статический, без glibc;
|
||||
скачивание проверяется по SHA-256. OpenRC-служба включается в default runlevel
|
||||
и использует supervise-daemon: перезапускает агент после ошибки и обновления.
|
||||
Команды: `rc-service proxmox-dashboard-agent status/restart` (выберите одно
|
||||
действие). Журнал: `/var/log/proxmox-dashboard-agent.log`; его ротацию настройте
|
||||
средствами ОС. Удаление — штатным `scripts/uninstall-agent.sh`.
|
||||
|
||||
Обновление пакетов ОС и управление systemd-службами из интерфейса остаются
|
||||
функциями Debian/Ubuntu/systemd; этот релиз добавляет установку и работу агента
|
||||
на OpenRC, а не эмуляцию systemd. Включённый сетевой сбор на Alpine ставит
|
||||
`conntrack-tools` через `apk add --no-cache`. Ограничения CAP_NET_ADMIN и
|
||||
сетевого namespace LXC сохраняются; никаких изменений конфигурации Proxmox,
|
||||
privileged/nesting, firewall или sysctl агент не делает.
|
||||
|
||||
После выбора агента Docker-хоста и включения карты трафика агент автоматически:
|
||||
|
||||
- проверяет стандартные каталоги NPM и NPM+;
|
||||
- находит запущенные контейнеры с nginx-proxy-manager/npmplus в имени образа
|
||||
и определяет каталоги логов по mounts `/data`, включая именованные volumes
|
||||
и вложенные bind mounts;
|
||||
- читает обычный формат NPM и формат NPM+ `access.log`, включая ротацию;
|
||||
- находит единственный контейнер образа `crowdsecurity/crowdsec` и выполняет
|
||||
только `cscli alerts list` через локальный Docker socket. Docker CLI, новый
|
||||
bouncer key и опубликованный LAPI не нужны. Если контейнера нет, используется
|
||||
прежний локальный `cscli`.
|
||||
|
||||
Поиск повторяется раз в минуту, поэтому пересоздание контейнеров подхватывается
|
||||
автоматически. При нескольких CrowdSec задайте точный контейнер вручную:
|
||||
`DASHBOARD_CROWDSEC_CONTAINER=имя` в `/etc/proxmox-dashboard-agent.env` и
|
||||
перезапустите агент. Каталог логов можно переопределить переменной
|
||||
`DASHBOARD_NPM_LOG_DIR=/путь/к/логам`; `off` выключает сбор трафика этого агента.
|
||||
Используется локальный `/var/run/docker.sock`; rootless/удалённые Docker engines
|
||||
и логи только в stdout этим обнаружением не поддерживаются.
|
||||
|
||||
NPM+ должен действительно писать access-логи: включите `LOGROTATE=true` в его
|
||||
Compose и пересоздайте контейнер, если логирование выключено. Агент показывает
|
||||
подсказку при отсутствии логов, но не меняет Compose приложений. Это настройка
|
||||
[NPM+](https://github.com/ZoeyVid/NPMplus/blob/develop/README.md), а не Dashboard.
|
||||
Stream-логи NPM+ не считаются HTTP-запросами; сетевые потоки собираются conntrack.
|
||||
Как и раньше, первый запуск чтения начинается с конца существующих логов.
|
||||
|
||||
## Интерактивная карта (v0.24.0)
|
||||
|
||||
Карта трафика использует ту же настройку `proxmox-theme`, что и основной
|
||||
Dashboard. Кнопка солнца/луны меняет тему; выбор сохраняется в браузере.
|
||||
Светлая тема распространяется на карту, график, карточки и модальные окна.
|
||||
|
||||
Масштаб карты — от 100% до 600%: кнопки +/−, колесо мыши или жест двумя
|
||||
пальцами. Увеличенную карту можно перетаскивать; ↺ возвращает весь мир.
|
||||
При фокусе на карте доступны клавиши +/−, 0 и стрелки. Наведение подсвечивает
|
||||
контур страны и показывает суточные счётчики, а нажатие открывает подробности.
|
||||
|
||||
Четыре карточки статистики открывают окна с итогом, объяснением метрики,
|
||||
разбивкой по странам и примерами событий. Счётчики стран считаются сервером
|
||||
по всей доступной истории за 24 часа с текущими фильтрами. Начиная с v0.25.0
|
||||
история загружается целиком по страницам, без ограничения последними 100 событиями. Открытое
|
||||
окно фиксирует данные на момент открытия; временная отметка показана внутри.
|
||||
Сетевые потоки остаются отдельным текущим снимком, не суточной статистикой.
|
||||
|
||||
## Карта и полная история (v0.25.0)
|
||||
|
||||
Карта использует векторные границы стран: при зуме 100–600% они перерисовываются,
|
||||
а маркеры и подписи сохраняют размер. Это обзорная карта стран, не уличная карта.
|
||||
Карточки и окна стран загружают всю сохранённую историю за последние 24 часа
|
||||
по 50 записей; фильтры учитываются сервером. В карточке IP показаны все уникальные
|
||||
HTTP IP с количеством запросов. Переходы между страницами фиксируют снимок истории.
|
||||
|
||||
Для городов откройте настройки карты и нажмите «Установить базу городов».
|
||||
Dashboard скачает DB-IP City Lite 2026-10 (~58 МБ сжатая, ~121 МБ на диске),
|
||||
проверит контрольную сумму и сохранит `dbip-city.mmdb` рядом с `DASHBOARD_DB_PATH`.
|
||||
IP посетителей не отправляются во внешние сервисы. Город и регион отображаются
|
||||
в истории и списке IP; местоположение приблизительное и может относиться к VPN
|
||||
или провайдеру. Для части IP город неизвестен. База устанавливается отдельно от
|
||||
релиза, автоматически каждый месяц не обновляется.
|
||||
|
||||
## Фильтр истории и зеркало GeoIP (v0.25.1)
|
||||
|
||||
В окнах стран и событий есть локальный переключатель «Все события / HTTP /
|
||||
Срабатывания / Решения CrowdSec». Он меняет тип событий только внутри окна,
|
||||
сохраняя страну, домен, остальные фильтры и снимок времени; пагинация начинается
|
||||
с первой страницы. Сводные карточки над историей сохраняют исходные итоги.
|
||||
|
||||
DB-IP City Lite загружается сначала из вложения релиза v0.25.1 в Gitea,
|
||||
при ошибке — с официального сервера DB-IP. Для собственного адреса Gitea можно
|
||||
задать `GITEA_URL` в окружении сервиса Dashboard. База хранится отдельно от Git-истории
|
||||
и бинарника приложения; контрольная сумма проверяется независимо от источника.
|
||||
Официальный файл: https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz
|
||||
|
||||
## Города на карте (v0.26.0)
|
||||
|
||||
При установленной базе городов и масштабе от 200% на карте появляются города
|
||||
с HTTP/CrowdSec-активностью. Маркеры используют приблизительные координаты DB-IP,
|
||||
подписи не увеличиваются вместе с картой. Наведение показывает статистику,
|
||||
нажатие открывает карточку города с итогами и ссылкой на всю историю страны.
|
||||
HTTP/CrowdSec-импульсы также начинаются в городе, если он определён.
|
||||
|
||||
Счётчики городов учитывают выбранного агента и фильтры за 24 часа. На карте
|
||||
показываются до 500 наиболее активных городов; остальные события и IP без города
|
||||
остаются в точках стран. TCP/UDP/ICMP пока отображаются на уровне стран.
|
||||
Город может относиться к провайдеру/VPN, а не фактическому местоположению человека.
|
||||
|
||||
82
agent_installer_test.go
Normal file
82
agent_installer_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Run the real installer in a temporary tree with package/service/download
|
||||
// commands stubbed. This must never touch the host's init system or packages.
|
||||
func TestAgentInstallerOpenRC(t *testing.T) {
|
||||
for _, init := range []string{"openrc", "systemd"} {
|
||||
t.Run(init, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
bin := filepath.Join(root, "bin")
|
||||
os.MkdirAll(bin, 0755)
|
||||
write := func(name, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nset -eu\n"+body), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("id", "echo 0\n")
|
||||
write("uname", "echo x86_64\n")
|
||||
hash := strings.Repeat("a", 64)
|
||||
write("curl", `case "$1" in -fsSL) ;; *) exit 4 ;; esac
|
||||
if [ "$#" = 2 ]; then printf '%s' '{"browser_download_url":"https://example.test/proxmox-dashboard-linux-amd64"}'; exit; fi
|
||||
case "$2" in *.sha256) printf '%s\n' '`+hash+` proxmox-dashboard-linux-amd64' > "$4" ;; *) printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "$CAPTURE"\n' > "$4" ;; esac
|
||||
`)
|
||||
write("sha256sum", "printf '%s\\n' '"+hash+" binary'\n")
|
||||
for _, name := range []string{"systemctl", "rc-service", "rc-update", "supervise-daemon"} {
|
||||
write(name, `printf '%s %s\n' "$(basename "$0")" "$*" >> "$CALLS"`+"\n")
|
||||
}
|
||||
raw, err := os.ReadFile("scripts/install-agent.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Redirect every persistent absolute path, including those in generated scripts.
|
||||
s := string(raw)
|
||||
for _, prefix := range []string{"/etc/", "/var/lib/", "/usr/local/", "/run/", "/var/log/"} {
|
||||
s = strings.ReplaceAll(s, prefix, root+prefix)
|
||||
}
|
||||
for _, dir := range []string{"etc/systemd/system", "etc/init.d", "var/log"} {
|
||||
os.MkdirAll(filepath.Join(root, dir), 0755)
|
||||
}
|
||||
if init == "systemd" {
|
||||
os.MkdirAll(filepath.Join(root, "run/systemd/system"), 0755)
|
||||
}
|
||||
script := filepath.Join(root, "install.sh")
|
||||
os.WriteFile(script, []byte(s), 0700)
|
||||
env := append(os.Environ(), "PATH="+bin+":"+os.Getenv("PATH"), "DASHBOARD_URL=https://dashboard.test", "ENROLL_TOKEN=token", "AGENT_NAME=Alpine $(touch should-not-exist)", "VMID=102", "CALLS="+root+"/calls", "CAPTURE="+root+"/args")
|
||||
cmd := exec.Command("sh", script)
|
||||
cmd.Env = env
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v: %s", err, out)
|
||||
}
|
||||
calls, _ := os.ReadFile(root + "/calls")
|
||||
if init == "openrc" {
|
||||
if strings.Contains(string(calls), "systemctl") || !strings.Contains(string(calls), "rc-update add proxmox-dashboard-agent default") || !strings.Contains(string(calls), "rc-service proxmox-dashboard-agent restart") {
|
||||
t.Fatalf("%s", calls)
|
||||
}
|
||||
cmd = exec.Command("sh", root+"/usr/local/sbin/proxmox-dashboard-agent-run")
|
||||
cmd.Env = env
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("launcher: %v %s", err, out)
|
||||
}
|
||||
args, _ := os.ReadFile(root + "/args")
|
||||
if !strings.Contains(string(args), "Alpine $(touch should-not-exist)\n") {
|
||||
t.Fatalf("name was not passed literally: %s", args)
|
||||
}
|
||||
} else if !strings.Contains(string(calls), "systemctl restart proxmox-dashboard-agent.service") {
|
||||
t.Fatalf("%s", calls)
|
||||
}
|
||||
info, _ := os.Stat(root + "/etc/proxmox-dashboard-agent.env")
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("credentials permissions: %v", info.Mode())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,16 @@ func installNetworkDependency() error {
|
||||
if os.Geteuid() != 0 {
|
||||
return fmt.Errorf("для автоустановки нужны права root")
|
||||
}
|
||||
if apk, err := exec.LookPath("apk"); err == nil {
|
||||
out := runAgentCombinedCommand(10*time.Minute, apk, "add", "--no-cache", "conntrack-tools")
|
||||
if strings.HasPrefix(out, "ERROR: ") {
|
||||
return fmt.Errorf("%s", out)
|
||||
}
|
||||
if _, err := exec.LookPath("conntrack"); err != nil {
|
||||
return fmt.Errorf("apk завершился, но conntrack не найден")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
apt, err := exec.LookPath("apt-get")
|
||||
if err != nil {
|
||||
return fmt.Errorf("автоустановка поддерживается на Debian/Ubuntu с apt-get; установите conntrack вручную")
|
||||
|
||||
@@ -26,7 +26,7 @@ var npmTrafficPattern = regexp.MustCompile(`^\[([^]]+)\] .*? (\d{3}) - ([A-Z]+)
|
||||
func parseNPMTraffic(line string) (TrafficEvent, bool) {
|
||||
m := npmTrafficPattern.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return TrafficEvent{}, false
|
||||
return parseNPMPlusTraffic(line)
|
||||
}
|
||||
ip := net.ParseIP(m[6])
|
||||
if ip == nil {
|
||||
@@ -55,11 +55,23 @@ type trafficAgentState struct {
|
||||
CrowdSeen map[string]int64 `json:"crowdSeen"`
|
||||
}
|
||||
|
||||
func (state *trafficAgentState) readLogs(dir string) (int, error) {
|
||||
func (state *trafficAgentState) readLogs(dirs ...string) (int, error) {
|
||||
state.BacklogBytes = 0
|
||||
paths, err := filepath.Glob(filepath.Join(dir, "*_access.log*"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
var paths []string
|
||||
seenPaths := map[string]bool{}
|
||||
for _, dir := range dirs {
|
||||
for _, pattern := range []string{"*_access.log*", "access.log*"} {
|
||||
matches, err := filepath.Glob(filepath.Join(dir, pattern))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, path := range matches {
|
||||
if !seenPaths[path] {
|
||||
paths = append(paths, path)
|
||||
seenPaths[path] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(paths)
|
||||
files := 0
|
||||
@@ -85,7 +97,7 @@ func (state *trafficAgentState) readLogs(dir string) (int, error) {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
active := strings.HasSuffix(path, "_access.log")
|
||||
active := strings.HasSuffix(path, "_access.log") || filepath.Base(path) == "access.log"
|
||||
if active {
|
||||
files++
|
||||
}
|
||||
@@ -229,21 +241,31 @@ func parseCrowdTraffic(data []byte) ([]TrafficEvent, error) {
|
||||
|
||||
// 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) {
|
||||
func collectCrowdTraffic(ctx context.Context, container ...string) ([]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")
|
||||
var data []byte
|
||||
if len(container) > 0 && container[0] != "" {
|
||||
var err error
|
||||
data, err = dockerCrowdOutput(ctx, container[0])
|
||||
if err != nil {
|
||||
return nil, false, errors.New("CrowdSec в Docker недоступен; проверьте контейнер и Docker socket")
|
||||
}
|
||||
} else {
|
||||
cmd := exec.CommandContext(ctx, "cscli", "alerts", "list", "--since", "24h", "--limit", "50", "-o", "json")
|
||||
var output limitedTrafficOutput
|
||||
cmd.Stdout = &output
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, false, errors.New("cscli недоступен на хосте; контейнер CrowdSec не обнаружен")
|
||||
}
|
||||
data = output.Bytes()
|
||||
}
|
||||
events, err := parseCrowdTraffic(output.Bytes())
|
||||
events, err := parseCrowdTraffic(data)
|
||||
if err != nil {
|
||||
return nil, false, errors.New("Не удалось разобрать JSON CrowdSec")
|
||||
}
|
||||
var raw []json.RawMessage
|
||||
json.Unmarshal(output.Bytes(), &raw)
|
||||
json.Unmarshal(data, &raw)
|
||||
return events, len(raw) >= 50, nil
|
||||
}
|
||||
|
||||
@@ -287,9 +309,6 @@ func runAgentTraffic(ctx context.Context, config agentConfig, stateDir string) {
|
||||
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{}}
|
||||
@@ -310,6 +329,8 @@ func runAgentTraffic(ctx context.Context, config agentConfig, stateDir string) {
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
lastCrowd := time.Time{}
|
||||
lastDiscovery := time.Time{}
|
||||
discovery := trafficDiscovery{}
|
||||
crowdAt := int64(0)
|
||||
crowdError := ""
|
||||
crowdLimited := false
|
||||
@@ -321,9 +342,21 @@ func runAgentTraffic(ctx context.Context, config agentConfig, stateDir string) {
|
||||
return
|
||||
default:
|
||||
}
|
||||
files, logErr := state.readLogs(dir)
|
||||
if time.Since(lastDiscovery) >= time.Minute {
|
||||
discovery = discoverTraffic(ctx)
|
||||
lastDiscovery = time.Now()
|
||||
}
|
||||
files, logErr := state.readLogs(discovery.Dirs...)
|
||||
if logErr != nil {
|
||||
logErr = fmt.Errorf("%s; проверьте access-логи и LOGROTATE=true у NPM+", logErr)
|
||||
}
|
||||
if len(state.Pending) < 400 && time.Since(lastCrowd) >= 15*time.Second {
|
||||
events, limited, err := collectCrowdTraffic(ctx)
|
||||
events, limited, err := collectCrowdTraffic(ctx, discovery.CrowdContainer)
|
||||
if discovery.Error != "" {
|
||||
if _, localErr := exec.LookPath("cscli"); localErr != nil {
|
||||
err = errors.New(discovery.Error)
|
||||
}
|
||||
}
|
||||
crowdLimited = limited
|
||||
lastCrowd = time.Now()
|
||||
if err != nil {
|
||||
|
||||
209
agent_traffic_discovery.go
Normal file
209
agent_traffic_discovery.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type trafficDockerContainer struct {
|
||||
ID string `json:"Id"`
|
||||
Image string
|
||||
Names []string
|
||||
Mounts []struct{ Source, Destination string }
|
||||
}
|
||||
type trafficDiscovery struct {
|
||||
Dirs []string
|
||||
CrowdContainer string
|
||||
Error string
|
||||
}
|
||||
|
||||
func trafficDockerClient() *http.Client {
|
||||
return &http.Client{Timeout: 8 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock")
|
||||
}}}
|
||||
}
|
||||
func discoverTraffic(ctx context.Context) trafficDiscovery {
|
||||
d := trafficDiscovery{Dirs: []string{"/opt/npm/data/logs", "/opt/npmplus/nginx/logs", "/opt/npmplus/nginx"}}
|
||||
client := trafficDockerClient()
|
||||
defer client.CloseIdleConnections()
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", "http://docker/containers/json", nil)
|
||||
res, err := client.Do(req)
|
||||
if err == nil {
|
||||
defer res.Body.Close()
|
||||
var containers []trafficDockerContainer
|
||||
if res.StatusCode == 200 && json.NewDecoder(io.LimitReader(res.Body, 2<<20)).Decode(&containers) == nil {
|
||||
d = trafficSources(containers, d.Dirs)
|
||||
} else {
|
||||
d.Error = "Не удалось прочитать список Docker-контейнеров"
|
||||
}
|
||||
} else if _, e := os.Stat("/var/run/docker.sock"); e == nil {
|
||||
d.Error = "Docker socket недоступен агенту"
|
||||
}
|
||||
if dir := os.Getenv("DASHBOARD_NPM_LOG_DIR"); dir != "" {
|
||||
d.Dirs = []string{dir}
|
||||
}
|
||||
if container := os.Getenv("DASHBOARD_CROWDSEC_CONTAINER"); container != "" {
|
||||
d.CrowdContainer = container
|
||||
d.Error = ""
|
||||
}
|
||||
return d
|
||||
}
|
||||
func trafficSources(containers []trafficDockerContainer, defaults []string) trafficDiscovery {
|
||||
d := trafficDiscovery{Dirs: append([]string{}, defaults...)}
|
||||
var crowd []string
|
||||
for _, c := range containers {
|
||||
img := strings.ToLower(c.Image)
|
||||
if strings.Contains(img, "crowdsecurity/crowdsec:") || strings.HasSuffix(img, "crowdsecurity/crowdsec") || strings.Contains(img, "crowdsecurity/crowdsec@") {
|
||||
crowd = append(crowd, c.ID)
|
||||
}
|
||||
if !strings.Contains(img, "nginx-proxy-manager") && !strings.Contains(img, "npmplus") {
|
||||
continue
|
||||
}
|
||||
for _, target := range []string{"/data/logs", "/data/nginx/logs", "/data/nginx"} {
|
||||
// Use the most specific mount, including a nested bind or named volume.
|
||||
best, source := "", ""
|
||||
for _, m := range c.Mounts {
|
||||
dst := filepath.Clean(m.Destination)
|
||||
if filepath.IsAbs(m.Source) && (target == dst || strings.HasPrefix(target, dst+"/")) && len(dst) > len(best) {
|
||||
best = dst
|
||||
source = filepath.Join(m.Source, strings.TrimPrefix(target, dst))
|
||||
}
|
||||
}
|
||||
if source != "" {
|
||||
d.Dirs = append(d.Dirs, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(crowd) == 1 {
|
||||
d.CrowdContainer = crowd[0]
|
||||
} else if len(crowd) > 1 {
|
||||
d.Error = "Найдено несколько CrowdSec: задайте DASHBOARD_CROWDSEC_CONTAINER"
|
||||
}
|
||||
unique := map[string]bool{}
|
||||
dirs := []string{}
|
||||
for _, dir := range d.Dirs {
|
||||
dir = filepath.Clean(dir)
|
||||
if !unique[dir] {
|
||||
unique[dir] = true
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
}
|
||||
d.Dirs = dirs
|
||||
return d
|
||||
}
|
||||
|
||||
// Docker exec uses a fixed read-only command; no shell, credential extraction or published LAPI.
|
||||
func dockerCrowdOutput(ctx context.Context, container string) ([]byte, error) {
|
||||
client := trafficDockerClient()
|
||||
defer client.CloseIdleConnections()
|
||||
return dockerCrowdWithClient(ctx, container, client)
|
||||
}
|
||||
func dockerCrowdWithClient(ctx context.Context, container string, client *http.Client) ([]byte, error) {
|
||||
if !safeContainerName.MatchString(container) || strings.HasPrefix(container, "-") {
|
||||
return nil, fmt.Errorf("некорректный контейнер CrowdSec")
|
||||
}
|
||||
call := func(path string, payload any) (*http.Response, error) {
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "http://docker"+path, bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return client.Do(req)
|
||||
}
|
||||
res, err := call("/containers/"+container+"/exec", map[string]any{"AttachStdout": true, "AttachStderr": true, "Tty": false, "Cmd": []string{"cscli", "alerts", "list", "--since", "24h", "--limit", "50", "-o", "json"}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var created struct{ ID string }
|
||||
err = json.NewDecoder(io.LimitReader(res.Body, 4096)).Decode(&created)
|
||||
res.Body.Close()
|
||||
if err != nil || res.StatusCode != 201 || !safeContainerName.MatchString(created.ID) {
|
||||
return nil, fmt.Errorf("Docker не создал cscli exec")
|
||||
}
|
||||
res, err = call("/exec/"+created.ID+"/start", map[string]any{"Detach": false, "Tty": false})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("Docker cscli exec: %s", res.Status)
|
||||
}
|
||||
output, err := readDockerTrafficOutput(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", "http://docker/exec/"+created.ID+"/json", nil)
|
||||
check, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer check.Body.Close()
|
||||
var status struct {
|
||||
Running bool
|
||||
ExitCode int
|
||||
}
|
||||
if check.StatusCode != 200 || json.NewDecoder(io.LimitReader(check.Body, 4096)).Decode(&status) != nil || status.Running || status.ExitCode != 0 {
|
||||
return nil, fmt.Errorf("cscli внутри Docker завершился с ошибкой")
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
func readDockerTrafficOutput(r io.Reader) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
total := uint32(0)
|
||||
for {
|
||||
var header [8]byte
|
||||
_, err := io.ReadFull(r, header[:])
|
||||
if err == io.EOF {
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := binary.BigEndian.Uint32(header[4:])
|
||||
if n > 4<<20 || total+n > 4<<20 {
|
||||
return nil, fmt.Errorf("Docker output too large")
|
||||
}
|
||||
total += n
|
||||
if header[0] != 1 && header[0] != 2 {
|
||||
return nil, fmt.Errorf("invalid Docker stream")
|
||||
}
|
||||
if header[0] == 1 {
|
||||
_, err = io.CopyN(&out, r, int64(n))
|
||||
} else {
|
||||
_, err = io.CopyN(io.Discard, r, int64(n))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var npmPlusTrafficPattern = regexp.MustCompile(`^\[([^]]+)\] (\S+) (\S+) [0-9.]+ "([A-Z]+) ([^" ]+) HTTP/[0-9.]+" (\d{3}) `)
|
||||
|
||||
func parseNPMPlusTraffic(line string) (TrafficEvent, bool) {
|
||||
m := npmPlusTrafficPattern.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return TrafficEvent{}, false
|
||||
}
|
||||
ip := net.ParseIP(m[3])
|
||||
ts, err := time.Parse("02/Jan/2006:15:04:05 -0700", m[1])
|
||||
if ip == nil || err != nil {
|
||||
return TrafficEvent{}, false
|
||||
}
|
||||
host := m[2]
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
status, _ := strconv.Atoi(m[6])
|
||||
return TrafficEvent{Time: ts.Unix(), Kind: "request", IP: ip.String(), Domain: trafficText(host, 253), Method: m[4], Path: trafficPath(m[5]), Status: status}, true
|
||||
}
|
||||
134
agent_traffic_discovery_test.go
Normal file
134
agent_traffic_discovery_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNPMPlusTraffic(t *testing.T) {
|
||||
for _, ip := range []string{"8.8.8.8", "2001:4860:4860::8888"} {
|
||||
line := `[14/Oct/2026:16:24:17 +0000] example.test:8443 ` + ip + ` 0.001 "GET /health?token=secret HTTP/3.0" 200 123 "-" "test" "-" "text/html" TLSv1.3 - - -`
|
||||
e, ok := parseNPMTraffic(line)
|
||||
if !ok || e.IP != ip || e.Domain != "example.test" || e.Path != "/health" || e.Status != 200 {
|
||||
t.Fatalf("%+v %v", e, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := parseNPMTraffic(`[14/Oct/2026:16:24:17 +0000] 443 8.8.8.8 UDP - 200 10 20 0.001`); ok {
|
||||
t.Fatal("stream counted as HTTP")
|
||||
}
|
||||
}
|
||||
func TestDockerTrafficDiscovery(t *testing.T) {
|
||||
var containers []trafficDockerContainer
|
||||
json.Unmarshal([]byte(`[{"Id":"aaa","Image":"ghcr.io/zoeyvid/npmplus:latest","Mounts":[{"Source":"/var/lib/docker/volumes/npm/_data","Destination":"/data"},{"Source":"/srv/logs","Destination":"/data/nginx/logs"}]},{"Id":"bbb","Image":"crowdsecurity/crowdsec:latest"}]`), &containers)
|
||||
d := trafficSources(containers, nil)
|
||||
if d.CrowdContainer != "bbb" || d.Error != "" || strings.Join(d.Dirs, ",") != "/var/lib/docker/volumes/npm/_data/logs,/srv/logs,/var/lib/docker/volumes/npm/_data/nginx" {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
containers = append(containers, containers[1])
|
||||
d = trafficSources(containers, nil)
|
||||
if d.CrowdContainer != "" || d.Error == "" {
|
||||
t.Fatal("ambiguous CrowdSec silently chosen")
|
||||
}
|
||||
}
|
||||
func TestDockerTrafficDemux(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
for _, frame := range []struct {
|
||||
stream byte
|
||||
text string
|
||||
}{{2, "diagnostic"}, {1, "["}, {1, "]"}} {
|
||||
h := []byte{frame.stream, 0, 0, 0, 0, 0, 0, 0}
|
||||
binary.BigEndian.PutUint32(h[4:], uint32(len(frame.text)))
|
||||
b.Write(h)
|
||||
b.WriteString(frame.text)
|
||||
}
|
||||
out, err := readDockerTrafficOutput(&b)
|
||||
if err != nil || string(out) != "[]" {
|
||||
t.Fatalf("%q %v", out, err)
|
||||
}
|
||||
if _, err := readDockerTrafficOutput(bytes.NewReader([]byte{1, 0, 0})); err == nil {
|
||||
t.Fatal("truncation accepted")
|
||||
}
|
||||
if _, err := readDockerTrafficOutput(bytes.NewReader([]byte{1, 0, 0, 0, 255, 255, 255, 255})); err == nil {
|
||||
t.Fatal("oversized frame accepted")
|
||||
}
|
||||
}
|
||||
func TestTrafficMultipleDirectoriesAndNPMPlusRotation(t *testing.T) {
|
||||
a, b := t.TempDir(), t.TempDir()
|
||||
p := filepath.Join(b, "access.log")
|
||||
os.WriteFile(filepath.Join(a, "proxy-host-1_access.log"), nil, 0600)
|
||||
os.WriteFile(p, nil, 0600)
|
||||
s := trafficAgentState{Cursors: map[string]trafficCursor{}}
|
||||
if n, err := s.readLogs(a, b, a); err != nil || n != 2 {
|
||||
t.Fatalf("files %d: %v", n, err)
|
||||
}
|
||||
line := "[" + time.Now().UTC().Format("02/Jan/2006:15:04:05 -0700") + `] example.test 8.8.8.8 0.1 "GET / HTTP/2.0" 200 123 "-"` + "\n"
|
||||
os.WriteFile(p, []byte(line), 0600)
|
||||
s.readLogs(a, b)
|
||||
if len(s.Pending) != 1 {
|
||||
t.Fatal("NPM+ event missing")
|
||||
}
|
||||
s.Pending = nil
|
||||
os.Rename(p, p+".1")
|
||||
os.WriteFile(p, []byte(line), 0600)
|
||||
s.readLogs(a, b)
|
||||
if len(s.Pending) != 1 {
|
||||
t.Fatalf("rotation duplicated/lost events: %d", len(s.Pending))
|
||||
}
|
||||
}
|
||||
|
||||
type trafficRoundTrip func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f trafficRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
func TestDockerCrowdExec(t *testing.T) {
|
||||
for _, exitCode := range []int{0, 1} {
|
||||
calls := 0
|
||||
client := &http.Client{Transport: trafficRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
body := ""
|
||||
status := 200
|
||||
switch calls {
|
||||
case 1:
|
||||
if r.Method != "POST" || r.URL.Path != "/containers/crowdsec/exec" {
|
||||
t.Fatalf("unexpected %s %s", r.Method, r.URL)
|
||||
}
|
||||
var payload struct {
|
||||
Cmd []string
|
||||
Tty bool
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
if strings.Join(payload.Cmd, " ") != "cscli alerts list --since 24h --limit 50 -o json" || payload.Tty {
|
||||
t.Fatalf("unexpected command %+v", payload)
|
||||
}
|
||||
status = 201
|
||||
body = `{"Id":"exec123"}`
|
||||
case 2:
|
||||
if r.URL.Path != "/exec/exec123/start" {
|
||||
t.Fatal(r.URL)
|
||||
}
|
||||
body = string([]byte{1, 0, 0, 0, 0, 0, 0, 2}) + "[]"
|
||||
case 3:
|
||||
if r.Method != "GET" || r.URL.Path != "/exec/exec123/json" {
|
||||
t.Fatal(r.URL)
|
||||
}
|
||||
body = fmt.Sprintf(`{"Running":false,"ExitCode":%d}`, exitCode)
|
||||
default:
|
||||
t.Fatal("extra Docker request")
|
||||
}
|
||||
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||
})}
|
||||
out, err := dockerCrowdWithClient(context.Background(), "crowdsec", client)
|
||||
if calls != 3 || (exitCode == 0 && (err != nil || string(out) != "[]")) || (exitCode != 0 && err == nil) {
|
||||
t.Fatalf("calls=%d output=%s err=%v", calls, out, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,9 @@ https://creativecommons.org/licenses/by/4.0/
|
||||
https://db-ip.com/db/download/ip-to-country-lite
|
||||
The database is unmodified. The traffic page displays the required DB-IP link.
|
||||
Countries are approximate; plotted positions are country labels, not exact IP locations.
|
||||
|
||||
Optional DB-IP City Lite October 2026 is downloaded separately through Settings.
|
||||
Source: https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz
|
||||
License: CC BY 4.0, https://creativecommons.org/licenses/by/4.0/
|
||||
https://db-ip.com/db/download/ip-to-city-lite
|
||||
The database is unmodified; city and region are approximate.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Сетевая активность · Proxmox Dashboard</title><style>
|
||||
:root{color-scheme:dark;--bg:#090e15;--panel:#111b26;--line:#293846;--muted:#92a8ba;--lime:#c0ee86;--blue:#70dcec}*{box-sizing:border-box}html,body{margin:0;height:100%;overflow:hidden}body{background:radial-gradient(ellipse at 30% 30%,#142738,transparent 65%),var(--bg);color:#e9f0f6;font:13px system-ui,sans-serif;padding:22px;display:grid;grid-template-rows:54px 32px 82px 36px minmax(0,1fr) 22px;gap:12px}header{display:flex;align-items:center;justify-content:space-between;gap:12px}h1{font-size:26px;letter-spacing:-1px;margin:0}small,.muted{color:var(--muted)}.eyebrow{font-size:9px;letter-spacing:2px;color:var(--lime)}nav{display:flex;gap:15px;align-items:center}a{color:var(--blue);text-decoration:none}button,input,select{font:inherit;color:inherit;background:#182430;border:1px solid var(--line);padding:7px 10px;border-radius:8px;min-width:0}button{cursor:pointer}button:disabled{opacity:.35;cursor:default}button:hover{border-color:var(--blue)}:focus-visible{outline:2px solid var(--blue);outline-offset:2px}.status{display:flex;align-items:center;gap:10px;color:var(--muted);min-width:0}.status span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.badge{font-size:10px;color:var(--lime);border:1px solid #496036;padding:5px 8px;border-radius:20px;white-space:nowrap}.badge.warn{color:#ffbd80;border-color:#875d36}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.metric,.panel{border:1px solid var(--line);background:#101a24d9;border-radius:14px;min-width:0;min-height:0;overflow:hidden}.metric{padding:12px 16px}.metric small{font-size:11px}.metric b{display:block;font-size:28px;line-height:1.3;letter-spacing:-1px}.filters{display:flex;gap:8px}.filters input{flex:1}.filters label{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)}.filters label input{accent-color:var(--lime)}main{display:grid;grid-template-columns:minmax(0,1fr) 290px;gap:12px;min-height:0}.flows{display:grid;grid-template-rows:40px 27px minmax(0,1fr) 38px;padding:0 14px}.heading{display:flex;align-items:center;justify-content:space-between;gap:8px}h2{font-size:12px;font-weight:600;margin:0}.table-head,.flow{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1.25fr) 80px 108px 95px;gap:12px;align-items:center}.table-head{color:var(--muted);font-size:10px}.flow{height:55px;border-top:1px solid #21303d;cursor:pointer;font:12px ui-monospace,monospace}.flow:hover{background:#20344566}.flow>div{min-width:0}.flow strong{font-weight:500;color:var(--blue);display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.flow small{font:10px system-ui;display:block;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.state{font-size:10px}.state.ok{color:var(--lime)}.state.pending{color:#ffc085}#rows{min-height:0;overflow:hidden}.pager{display:flex;align-items:center;gap:12px;color:var(--muted);font-size:11px;border-top:1px solid var(--line)}.pager button{padding:3px 8px}.pager #resume{margin-left:auto;color:var(--lime)}aside{display:grid;grid-template-rows:1fr 1fr 1fr;gap:12px;min-height:0}aside .panel{padding:12px 14px}aside h2{margin-bottom:9px}.rank{display:flex;justify-content:space-between;gap:10px;font-size:11px;line-height:22px}.rank span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rank b{font-weight:500;color:var(--blue)}.block-value{font-size:24px;color:#c4a9ff;margin:8px 0}.help{font-size:10px;color:var(--muted);line-height:1.5}.empty{color:var(--muted);padding:20px 0;line-height:1.7;font-size:12px}.footer{display:flex;justify-content:space-between;font-size:10px;color:var(--muted);gap:10px}.link-button{background:none;border:0;padding:0;color:var(--blue);font-size:11px}dialog{width:min(650px,calc(100vw - 24px));max-height:85dvh;overflow:auto;border:1px solid #455969;border-radius:18px;background:#141f2b;color:#e9f0f6;padding:22px}dialog::backdrop{background:#03080ccc;backdrop-filter:blur(5px)}dialog .heading{margin-bottom:16px}dialog h2{font-size:19px}.detail{padding:10px 0;border-top:1px solid var(--line);overflow-wrap:anywhere}.detail small{display:block;margin-bottom:4px}dialog p{line-height:1.7}#tabs{display:none}.filters #port{width:110px}
|
||||
:root{color-scheme:dark;--bg:#090e15;--panel:#111b26;--line:#293846;--muted:#92a8ba;--lime:#c0ee86;--blue:#70dcec}*{box-sizing:border-box}html,body{margin:0;height:100%;overflow:hidden}body{background:radial-gradient(ellipse at 30% 30%,#142738,transparent 65%),var(--bg);color:#e9f0f6;font:13px system-ui,sans-serif;padding:22px;display:grid;grid-template-rows:54px 32px 82px 36px minmax(0,1fr) 22px;gap:12px}header{display:flex;align-items:center;justify-content:space-between;gap:12px}h1{font-size:26px;letter-spacing:-1px;margin:0}small,.muted{color:var(--muted)}.eyebrow{font-size:9px;letter-spacing:2px;color:var(--lime)}nav{display:flex;gap:15px;align-items:center}a{color:var(--blue);text-decoration:none}button,input,select{font:inherit;color:inherit;background:#182430;border:1px solid var(--line);padding:7px 10px;border-radius:8px;min-width:0}button{cursor:pointer}button:disabled{opacity:.35;cursor:default}button:hover{border-color:var(--blue)}:focus-visible{outline:2px solid var(--blue);outline-offset:2px}.status{display:flex;align-items:center;gap:10px;color:var(--muted);min-width:0}.status span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.badge{font-size:10px;color:var(--lime);border:1px solid #496036;padding:5px 8px;border-radius:20px;white-space:nowrap}.badge.warn{color:#ffbd80;border-color:#875d36}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.metric,.panel{border:1px solid var(--line);background:#101a24d9;border-radius:14px;min-width:0;min-height:0;overflow:hidden}.metric{padding:12px 16px}.metric small{font-size:11px}.metric b{display:block;font-size:28px;line-height:1.3;letter-spacing:-1px}.filters{display:flex;gap:8px}.filters input{flex:1}.filters label{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)}.filters label input{accent-color:var(--lime);flex:none;width:13px;height:13px;margin:0;grid-column:auto;grid-row:auto}main{display:grid;grid-template-columns:minmax(0,1fr) 290px;gap:12px;min-height:0}.flows{display:grid;grid-template-rows:40px 27px minmax(0,1fr) 38px;padding:0 14px}.heading{display:flex;align-items:center;justify-content:space-between;gap:8px}h2{font-size:12px;font-weight:600;margin:0}.table-head,.flow{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1.25fr) 80px 108px 95px;gap:12px;align-items:center}.table-head{color:var(--muted);font-size:10px}.flow{height:55px;border-top:1px solid #21303d;cursor:pointer;font:12px ui-monospace,monospace}.flow:hover{background:#20344566}.flow>div{min-width:0}.flow strong{font-weight:500;color:var(--blue);display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.flow small{font:10px system-ui;display:block;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.state{font-size:10px}.state.ok{color:var(--lime)}.state.pending{color:#ffc085}#rows{min-height:0;overflow:hidden}.pager{display:flex;align-items:center;gap:12px;color:var(--muted);font-size:11px;border-top:1px solid var(--line)}.pager button{padding:3px 8px}.pager #resume{margin-left:auto;color:var(--lime)}aside{display:grid;grid-template-rows:1fr 1fr 1fr;gap:12px;min-height:0}aside .panel{padding:12px 14px}aside h2{margin-bottom:9px}.rank{display:flex;justify-content:space-between;gap:10px;font-size:11px;line-height:22px}.rank span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rank b{font-weight:500;color:var(--blue)}.block-value{font-size:24px;color:#c4a9ff;margin:8px 0}.help{font-size:10px;color:var(--muted);line-height:1.5}.empty{color:var(--muted);padding:20px 0;line-height:1.7;font-size:12px}.footer{display:flex;justify-content:space-between;font-size:10px;color:var(--muted);gap:10px}.link-button{background:none;border:0;padding:0;color:var(--blue);font-size:11px}dialog{width:min(650px,calc(100vw - 24px));max-height:85dvh;overflow:auto;border:1px solid #455969;border-radius:18px;background:#141f2b;color:#e9f0f6;padding:22px}dialog::backdrop{background:#03080ccc;backdrop-filter:blur(5px)}dialog .heading{margin-bottom:16px}dialog h2{font-size:19px}.detail{padding:10px 0;border-top:1px solid var(--line);overflow-wrap:anywhere}.detail small{display:block;margin-bottom:4px}dialog p{line-height:1.7}#tabs{display:none}.filters #port{width:110px}
|
||||
@media(max-width:1100px){main{grid-template-columns:minmax(0,1fr) 220px}.table-head,.flow{grid-template-columns:1fr 1fr 55px 83px;gap:8px}.flow .volume,.table-head .volume{display:none}.flow{font-size:11px}nav{gap:9px}body{padding:14px}aside .panel{padding:9px}aside .rank{line-height:19px}}
|
||||
@media(max-height:700px) and (min-width:761px){body{padding:12px;gap:8px;grid-template-rows:40px 26px 64px 32px minmax(0,1fr) 18px}.metric{padding:8px 12px}.metric b{font-size:22px}.flow{height:46px}aside{grid-template-rows:1fr 1fr 1fr;gap:6px}aside .rank{line-height:17px}.block-value{font-size:20px;margin:3px 0}aside h2{margin-bottom:4px}.help{font-size:9px}}
|
||||
@media(max-width:760px){body{padding:10px;gap:8px;grid-template-rows:45px 30px 104px auto 28px minmax(0,1fr) 18px}h1{font-size:18px;white-space:nowrap}.eyebrow{font-size:7px}nav a{font-size:0}nav a:before{content:"HTTP ↗";font-size:11px}nav .dashboard{display:none}.metrics{grid-template-columns:1fr 1fr;gap:6px}.metric{padding:6px 10px}.metric small{font-size:9px}.metric b{font-size:22px}.filters{display:grid;grid-template-columns:1fr 100px;gap:5px}.filters label{grid-column:2}.filters input{grid-column:1;grid-row:2}.filters select{font-size:11px;padding:6px}.filters #port{width:100%}#tabs{display:flex;gap:6px}#tabs button{flex:1;padding:4px;font-size:11px}#tabs button.active{border-color:var(--blue)}main{display:block}.flows{height:100%;padding:0 10px;grid-template-rows:34px 24px minmax(0,1fr) 36px}aside{display:none;height:100%;grid-template-columns:1fr 1fr;grid-template-rows:minmax(0,1fr) 105px}aside .firewall{grid-column:1/-1}body[data-tab=stats] .flows{display:none}body[data-tab=stats] aside{display:grid}.flow,.table-head{grid-template-columns:1fr 1fr 60px}.flow .protocol,.table-head .protocol{display:none}.flow{height:59px;font-size:10px}.state{font-size:9px}.flow small{font-size:9px}.status{font-size:10px}.status button{font-size:10px}.footer{font-size:8px}.footer .link-button{font-size:9px}aside .rank{line-height:19px}.block-value{display:inline-block;font-size:20px;margin:0 10px 0 0}.help{font-size:9px}.firewall h2{margin-bottom:4px}}
|
||||
@media(max-width:760px){body{padding:10px;gap:8px;grid-template-rows:45px 30px 104px auto 28px minmax(0,1fr) 18px}h1{font-size:18px;white-space:nowrap}.eyebrow{font-size:7px}nav a{font-size:0}nav a:before{content:"HTTP ↗";font-size:11px}nav .dashboard{display:none}.metrics{grid-template-columns:1fr 1fr;gap:6px}.metric{padding:6px 10px}.metric small{font-size:9px}.metric b{font-size:22px}.filters{display:grid;grid-template-columns:1fr 100px;gap:5px}.filters label{grid-row:3;white-space:nowrap;font-size:10px}#netMonitoringControl{grid-column:1}#netExternalControl{grid-column:2}.filters #search{grid-column:1/-1;grid-row:2}.filters select{font-size:11px;padding:6px}.filters #port{width:100%}#tabs{display:flex;gap:6px}#tabs button{flex:1;padding:4px;font-size:11px}#tabs button.active{border-color:var(--blue)}main{display:block}.flows{height:100%;padding:0 10px;grid-template-rows:34px 24px minmax(0,1fr) 36px}aside{display:none;height:100%;grid-template-columns:1fr 1fr;grid-template-rows:minmax(0,1fr) 105px}aside .firewall{grid-column:1/-1}body[data-tab=stats] .flows{display:none}body[data-tab=stats] aside{display:grid}.flow,.table-head{grid-template-columns:1fr 1fr 60px}.flow .protocol,.table-head .protocol{display:none}.flow{height:59px;font-size:10px}.state{font-size:9px}.flow small{font-size:9px}.status{font-size:10px}.status button{font-size:10px}.footer{font-size:8px}.footer .link-button{font-size:9px}aside .rank{line-height:19px}.block-value{display:inline-block;font-size:20px;margin:0 10px 0 0}.help{font-size:9px}.firewall h2{margin-bottom:4px}}
|
||||
</style></head><body data-tab="flows">
|
||||
<header><div><div class="eyebrow">PROXMOX / NETWORK OBSERVATORY</div><h1>Сетевая активность<span style="color:var(--lime)">.</span></h1></div><nav><a href="/traffic">HTTP / CrowdSec ↗</a><a class="dashboard" href="/">Dashboard</a><button id="pause">Пауза</button></nav></header>
|
||||
<div class="status"><b id="live" class="badge warn">ОЖИДАНИЕ</b><span id="source">Подключение к агенту…</span><button class="link-button" id="statusDetails">Диагностика ⓘ</button></div>
|
||||
<section class="metrics"><div class="metric"><small>TCP · ESTABLISHED</small><b id="established">—</b></div><div class="metric"><small>TCP · SYN_SENT / SYN_RECV</small><b id="pending">—</b></div><div class="metric"><small>UDP · отслеживаемые потоки</small><b id="udp">—</b></div><div class="metric"><small>ICMP / ICMPv6 · записи</small><b id="icmp">—</b></div></section>
|
||||
<div class="filters"><select id="protocol" aria-label="Протокол"><option value="">Все протоколы</option><option value="tcp">TCP</option><option value="udp">UDP</option><option value="icmp">ICMP / ICMPv6</option></select><select id="port" aria-label="Порт назначения"><option value="">Все порты</option></select><input id="search" placeholder="Найти IP или состояние…" aria-label="Поиск IP или состояния"><label><input id="external" type="checkbox">Внешние IP</label></div>
|
||||
<div class="filters"><select id="protocol" aria-label="Протокол"><option value="">Все протоколы</option><option value="tcp">TCP</option><option value="udp">UDP</option><option value="icmp">ICMP / ICMPv6</option></select><select id="port" aria-label="Порт назначения"><option value="">Все порты</option></select><input id="search" placeholder="Найти IP или состояние…" aria-label="Поиск IP или состояния"><label id="netMonitoringControl"><input id="hideMonitoring" type="checkbox">Скрыть мониторинг</label><label id="netExternalControl"><input id="external" type="checkbox">Внешние IP</label></div>
|
||||
<nav id="tabs"><button class="active" data-tab="flows">Потоки</button><button data-tab="stats">Статистика</button></nav>
|
||||
<main><section class="panel flows"><div class="heading"><h2>Текущая таблица conntrack</h2><small id="count">—</small></div><div class="table-head"><span>Инициатор</span><span>Назначение / NAT</span><span class="protocol">Протокол</span><span>Состояние</span><span class="volume">Байты → / ←</span></div><div id="rows"><div class="empty">Ожидание первого снимка…</div></div><div class="pager"><button id="prev" aria-label="Предыдущие потоки">←</button><span id="page">—</span><button id="next" aria-label="Следующие потоки">→</button><button id="resume">К свежим ↗</button></div></section>
|
||||
<aside><section class="panel"><h2>Топ инициаторов · потоки</h2><div id="topIPs"></div></section><section class="panel"><h2>Топ портов назначения</h2><div id="topPorts"></div></section><section class="panel firewall"><div class="heading"><h2>CrowdSec · firewall</h2><button class="link-button" id="firewallDetails">ⓘ</button></div><div id="blocked" class="block-value">—</div><div class="help" id="firewallNote">Ожидание счётчиков</div></section></aside></main>
|
||||
@@ -16,6 +16,7 @@
|
||||
<script>
|
||||
const el=id=>document.getElementById(id),safe=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])),num=v=>Number(v||0).toLocaleString('ru-RU'),bytes=n=>{n=Number(n||0);return n>=1073741824?(n/1073741824).toFixed(1)+' GiB':n>=1048576?(n/1048576).toFixed(1)+' MiB':n>=1024?(n/1024).toFixed(1)+' KiB':n+' B'},endpoint=(ip,p)=>p?(ip.includes(':')?'['+ip+']':ip)+':'+p:ip;
|
||||
const names=new Intl.DisplayNames(['ru'],{type:'region'}),country=c=>c==='LAN'?'Локальная сеть':c&&/^[A-Z]{2}$/.test(c)?names.of(c):'Без геолокации';
|
||||
try{el('hideMonitoring').checked=localStorage.getItem('proxmox-hide-monitoring')==='1'}catch{}
|
||||
let snapshot=null,frozen=null,page=0,visible=[],paused=false,busy=false,networkError='';
|
||||
const show=(title,html)=>{el('title').textContent=title;el('details').innerHTML=html;el('dialog').showModal()},fields=rows=>rows.map(([k,v])=>'<div class="detail"><small>'+safe(k)+'</small>'+safe(v)+'</div>').join('');
|
||||
function filterFlows(){const p=el('protocol').value,port=el('port').value,q=el('search').value.trim().toLowerCase();return ((frozen||snapshot)?.flows||[]).filter(f=>(!p||(p==='icmp'?f.protocol.startsWith('icmp'):f.protocol===p))&&(!port||String(f.destinationPort)===port)&&(!el('external').checked||f.external)&&(!q||[f.source,f.destination,f.replySource,f.replyDestination,f.state].join(' ').toLowerCase().includes(q)))}
|
||||
@@ -23,17 +24,17 @@ function rank(id,items){const counts=new Map();items.forEach(v=>counts.set(v,(co
|
||||
function render(){if(!snapshot)return;const data=frozen||snapshot,items=filterFlows();el('established').textContent=num(items.filter(f=>f.protocol==='tcp'&&f.state==='ESTABLISHED').length);el('pending').textContent=num(items.filter(f=>f.protocol==='tcp'&&['SYN_SENT','SYN_RECV'].includes(f.state)).length);el('udp').textContent=num(items.filter(f=>f.protocol==='udp').length);el('icmp').textContent=num(items.filter(f=>f.protocol.startsWith('icmp')).length);
|
||||
if(!data.receivedAt||(data.error&&!(data.flows||[]).length)){['established','pending','udp','icmp'].forEach(id=>el(id).textContent='—')}
|
||||
const fresh=snapshot.receivedAt&&Date.now()/1000-snapshot.receivedAt<25;el('live').textContent=networkError?'НЕТ СВЯЗИ':paused?'ПАУЗА':frozen?'СНИМОК':!fresh?'НЕТ ДАННЫХ':snapshot.error||snapshot.limited||snapshot.parseErrors?'НЕПОЛНЫЕ ДАННЫЕ':'● LIVE';el('live').classList.toggle('warn',!!networkError||!fresh||paused||!!snapshot.error||snapshot.limited||!!snapshot.parseErrors);el('source').textContent=(snapshot.agentName||'Агент')+' · '+(snapshot.at?new Date(data.at*1000).toLocaleTimeString('ru-RU'):'ожидание')+(snapshot.error?' · '+snapshot.error:snapshot.limited?' · лимит 5 000 потоков':'');
|
||||
const rowHeight=innerWidth<=760?59:innerHeight<=700?46:55,count=Math.max(1,Math.floor(el('rows').clientHeight/rowHeight)),pages=Math.max(1,Math.ceil(items.length/count));page=Math.min(page,pages-1);visible=items.slice(page*count,(page+1)*count);el('count').textContent=num(items.length)+' / '+num(data.flows?.length)+' · '+(frozen?'снимок':'live');
|
||||
el('rows').innerHTML=visible.map((f,i)=>{const nat=f.destination!==f.replySource||f.destinationPort!==f.replySourcePort;return '<article class="flow" role="button" tabindex="0" data-flow="'+i+'" aria-label="Поток '+safe(endpoint(f.source,f.sourcePort))+'"><div><strong>'+safe(endpoint(f.source,f.sourcePort))+'</strong><small>'+safe(country(f.country))+'</small></div><div><strong>'+safe(endpoint(f.destination,f.destinationPort))+'</strong><small>'+safe(nat?'NAT → '+endpoint(f.replySource,f.replySourcePort):country(f.destinationCountry))+'</small></div><div class="protocol">'+safe(f.protocol.toUpperCase())+'</div><div class="state '+(f.state==='ESTABLISHED'?'ok':f.state.includes('SYN')?'pending':'')+'">'+safe(f.state)+'<small>'+safe(f.protocol.toUpperCase())+'</small></div><div class="volume">'+(f.accounting?bytes(f.bytes)+'<small>'+bytes(f.replyBytes)+'</small>':'<small>нет учёта</small>')+'</div></article>'}).join('')||'<div class="empty">'+safe(snapshot.error||'Нет потоков по выбранным фильтрам')+'</div>';
|
||||
const rowHeight=innerWidth<=760?59:innerHeight<=700?46:55,count=Math.max(1,Math.floor(el('rows').clientHeight/rowHeight)),pages=Math.max(1,Math.ceil(items.length/count));page=Math.min(page,pages-1);visible=items.slice(page*count,(page+1)*count);el('count').textContent=num(items.length)+' / '+num(data.flows?.length)+(data.monitoringHidden?' · скрыто '+num(data.monitoringHidden):'')+' · '+(frozen?'снимок':'live');
|
||||
el('rows').innerHTML=visible.map((f,i)=>{const nat=f.destination!==f.replySource||f.destinationPort!==f.replySourcePort;return '<article class="flow" role="button" tabindex="0" data-flow="'+i+'" aria-label="Поток '+safe(endpoint(f.source,f.sourcePort))+'"><div><strong>'+safe(endpoint(f.source,f.sourcePort))+'</strong><small>'+safe(country(f.country))+(f.monitoring?' · мониторинг':'')+'</small></div><div><strong>'+safe(endpoint(f.destination,f.destinationPort))+'</strong><small>'+safe(nat?'NAT → '+endpoint(f.replySource,f.replySourcePort):country(f.destinationCountry))+'</small></div><div class="protocol">'+safe(f.protocol.toUpperCase())+'</div><div class="state '+(f.state==='ESTABLISHED'?'ok':f.state.includes('SYN')?'pending':'')+'">'+safe(f.state)+'<small>'+safe(f.protocol.toUpperCase())+'</small></div><div class="volume">'+(f.accounting?bytes(f.bytes)+'<small>'+bytes(f.replyBytes)+'</small>':'<small>нет учёта</small>')+'</div></article>'}).join('')||'<div class="empty">'+safe(snapshot.error||'Нет потоков по выбранным фильтрам')+'</div>';
|
||||
el('page').textContent=(page+1)+' / '+pages;el('prev').disabled=page===0;el('next').disabled=page>=pages-1;rank('topIPs',items.map(f=>f.source));rank('topPorts',items.filter(f=>f.destinationPort).map(f=>f.protocol.toUpperCase()+' / '+f.destinationPort));
|
||||
const counters=snapshot.firewall||[];el('blocked').textContent=counters.length?num(counters.reduce((a,c)=>a+c.packets,0))+' пак.':'—';el('firewallNote').textContent=counters.length?'С начала жизни счётчиков · '+bytes(counters.reduce((a,c)=>a+c.bytes,0))+' · '+new Date(snapshot.firewallAt*1000).toLocaleTimeString('ru-RU'):snapshot.firewallError||'Счётчики ещё не получены';
|
||||
}
|
||||
function openFlow(target){const row=target.closest('[data-flow]');if(!row)return;const f=visible[+row.dataset.flow];if(!f)return;show('Поток '+f.protocol.toUpperCase(),fields([['Инициатор',endpoint(f.source,f.sourcePort)+' · '+country(f.country)],['Исходное назначение',endpoint(f.destination,f.destinationPort)],['Обратное направление (NAT)',endpoint(f.replySource,f.replySourcePort)+' → '+endpoint(f.replyDestination,f.replyDestinationPort)],['Состояние / зона',f.state+' / '+(f.zone||'0')],['Инициатор → назначение',f.accounting?bytes(f.bytes)+' · '+num(f.packets)+' пакетов':'Учёт пакетов/байтов не включён для этого потока'],['Ответное направление',f.accounting?bytes(f.replyBytes)+' · '+num(f.replyPackets)+' пакетов':'Нет данных'],['Время снимка',new Date((frozen||snapshot).at*1000).toLocaleString('ru-RU')]]))}
|
||||
function openFlow(target){const row=target.closest('[data-flow]');if(!row)return;const f=visible[+row.dataset.flow];if(!f)return;show('Поток '+f.protocol.toUpperCase(),fields([['Инициатор',endpoint(f.source,f.sourcePort)+' · '+country(f.country)],['Исходное назначение',endpoint(f.destination,f.destinationPort)],['Обратное направление (NAT)',endpoint(f.replySource,f.replySourcePort)+' → '+endpoint(f.replyDestination,f.replyDestinationPort)],['Мониторинг',f.monitoring?'Да — правило по IP инициатора':'Нет'],['Состояние / зона',f.state+' / '+(f.zone||'0')],['Инициатор → назначение',f.accounting?bytes(f.bytes)+' · '+num(f.packets)+' пакетов':'Учёт пакетов/байтов не включён для этого потока'],['Ответное направление',f.accounting?bytes(f.replyBytes)+' · '+num(f.replyPackets)+' пакетов':'Нет данных'],['Время снимка',new Date((frozen||snapshot).at*1000).toLocaleString('ru-RU')]]))}
|
||||
el('rows').onclick=e=>openFlow(e.target);el('rows').onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();openFlow(e.target)}};el('close').onclick=()=>el('dialog').close();
|
||||
el('statusDetails').onclick=()=>show('Диагностика агента',fields([['Сбор',snapshot?.error||'Ошибок conntrack не сообщено'],['Связь',networkError||'Последний ответ получен'],['Получен сервером',snapshot?.receivedAt?new Date(snapshot.receivedAt*1000).toLocaleString('ru-RU'):'Ещё не получен'],['Ограничения',snapshot?.limited?'Достигнут лимит 5 000 записей; статистика частичная':'До 5 000 записей в снимке'],['Нераспознанные строки',num(snapshot?.parseErrors)],['Firewall',snapshot?.firewallError||'Поддерживаемые счётчики получены']])+ '<p>На Debian/Ubuntu агент автоматически устанавливает отсутствующий <code>conntrack</code> через apt-get и systemd. Службе нужны root; для чтения таблицы — root/CAP_NET_ADMIN. Ошибка установки повторяется не чаще раза в 30 минут. Для firewall нужен <code>nft</code>. При выключенном учёте conntrack байты недоступны; sysctl и правила автоматически не изменяются.</p>');
|
||||
el('firewallDetails').onclick=()=>show('Счётчики блокировок',fields((snapshot?.firewall||[]).map(c=>[c.name,num(c.packets)+' пакетов · '+bytes(c.bytes)]))+'<p>'+safe(snapshot?.firewallError||'Накопительные счётчики правил CrowdSec непосредственно перед drop/reject. Сбрасываются при пересоздании правил. Не зависят от фильтров списка потоков; не показывают количество людей или HTTP-запросов.')+'</p>');
|
||||
el('about').onclick=()=>show('Как читать сетевые данные','<p>Это текущие отслеживаемые потоки TCP, UDP, ICMP и ICMPv6, а не журнал всех пакетов. Снимки делаются примерно раз в 5 секунд: короткие потоки могут быть пропущены. TIME_WAIT и другие состояния закрытия остаются в таблице до истечения таймера.</p><p>Инициатор и назначение — исходное направление conntrack. Обратная пара адресов показывает трансляцию NAT, включая Docker. Соединение NPM с upstream — отдельный поток; оно не считается ещё одним посетителем.</p><p>UDP ASSURED — отметка conntrack, не TCP-соединение. Порт 443 сам по себе не доказывает HTTPS или QUIC. SYN_SENT/SYN_RECV не означают атаку.</p><p>Байты и пакеты накоплены за жизнь конкретного потока, если учёт был включён при его создании. Длительность соединения и точный поток новых подключений по этим снимкам не вычисляются.</p><p>Фильтры влияют на карточки и топы. Firewall показывает отдельные накопительные счётчики без фильтров. Приватные и локальные IP не геолокируются. Геолокация: <a href="https://db-ip.com">DB-IP</a>, CC BY 4.0.</p>');
|
||||
function reset(){frozen=null;page=0;render()};['protocol','port','external'].forEach(id=>el(id).onchange=reset);el('search').oninput=reset;el('next').onclick=()=>{if(!frozen)frozen=snapshot;page++;render()};el('prev').onclick=()=>{page--;render()};el('resume').onclick=reset;el('pause').onclick=()=>{paused=!paused;el('pause').textContent=paused?'Продолжить':'Пауза';render();if(!paused)poll()};document.querySelectorAll('[data-tab]').forEach(b=>{if(b.tagName==='BUTTON')b.onclick=()=>{document.body.dataset.tab=b.dataset.tab;document.querySelectorAll('#tabs button').forEach(t=>t.classList.toggle('active',t===b));render()}});window.addEventListener('resize',render);
|
||||
async function poll(){if(paused||busy||document.hidden)return;busy=true;try{const response=await fetch('/api/network');if(response.status===404){location.replace('/traffic');return}if(!response.ok)throw Error('HTTP '+response.status);snapshot=await response.json();networkError='';const current=el('port').value,ports=[...new Set((snapshot.flows||[]).map(f=>f.destinationPort).filter(Boolean))];if(current&&!ports.includes(+current))ports.push(+current);ports.sort((a,b)=>a-b);el('port').innerHTML='<option value="">Все порты</option>'+ports.map(p=>'<option value="'+p+'">'+p+'</option>').join('');el('port').value=current;render()}catch(e){networkError='Не удалось обновить: '+e.message;el('source').textContent=networkError;el('live').textContent='НЕТ СВЯЗИ';el('live').classList.add('warn')}finally{busy=false}}
|
||||
el('about').onclick=()=>show('Как читать сетевые данные','<p>Это текущие отслеживаемые потоки TCP, UDP, ICMP и ICMPv6, а не журнал всех пакетов. Снимки делаются примерно раз в 5 секунд: короткие потоки могут быть пропущены. TIME_WAIT и другие состояния закрытия остаются в таблице до истечения таймера.</p><p>Инициатор и назначение — исходное направление conntrack. Обратная пара адресов показывает трансляцию NAT, включая Docker. Соединение NPM с upstream — отдельный поток; оно не считается ещё одним посетителем.</p><p>UDP ASSURED — отметка conntrack, не TCP-соединение. Порт 443 сам по себе не доказывает HTTPS или QUIC. SYN_SENT/SYN_RECV не означают атаку.</p><p>Байты и пакеты накоплены за жизнь конкретного потока, если учёт был включён при его создании. Длительность соединения и точный поток новых подключений по этим снимкам не вычисляются.</p><p>Фильтры влияют на карточки и топы. Firewall показывает отдельные накопительные счётчики без фильтров. Приватные и локальные IP не геолокируются. «Скрыть мониторинг» применяет правила по IP инициатора; домен/путь относятся только к HTTP. Геолокация: <a href="https://db-ip.com">DB-IP</a>, CC BY 4.0.</p>');
|
||||
function reset(){frozen=null;page=0;render()};['protocol','port','external'].forEach(id=>el(id).onchange=reset);el('search').oninput=reset;el('hideMonitoring').onchange=()=>{try{localStorage.setItem('proxmox-hide-monitoring',el('hideMonitoring').checked?'1':'0')}catch{}frozen=null;page=0;poll(true)};el('next').onclick=()=>{if(!frozen)frozen=snapshot;page++;render()};el('prev').onclick=()=>{page--;render()};el('resume').onclick=reset;el('pause').onclick=()=>{paused=!paused;el('pause').textContent=paused?'Продолжить':'Пауза';render();if(!paused)poll()};document.querySelectorAll('[data-tab]').forEach(b=>{if(b.tagName==='BUTTON')b.onclick=()=>{document.body.dataset.tab=b.dataset.tab;document.querySelectorAll('#tabs button').forEach(t=>t.classList.toggle('active',t===b));render()}});window.addEventListener('resize',render);
|
||||
async function poll(force=false){if((paused&&!force)||busy||document.hidden)return;busy=true;try{const response=await fetch('/api/network?hideMonitoring='+(el('hideMonitoring').checked?'1':'0'));if(response.status===404){location.replace('/traffic');return}if(!response.ok)throw Error('HTTP '+response.status);snapshot=await response.json();networkError='';const current=el('port').value,ports=[...new Set((snapshot.flows||[]).map(f=>f.destinationPort).filter(Boolean))];if(current&&!ports.includes(+current))ports.push(+current);ports.sort((a,b)=>a-b);el('port').innerHTML='<option value="">Все порты</option>'+ports.map(p=>'<option value="'+p+'">'+p+'</option>').join('');el('port').value=current;render()}catch(e){networkError='Не удалось обновить: '+e.message;el('source').textContent=networkError;el('live').textContent='НЕТ СВЯЗИ';el('live').classList.add('warn')}finally{busy=false}}
|
||||
poll();setInterval(poll,5000);document.addEventListener('visibilitychange',()=>{if(!document.hidden)poll()});
|
||||
</script></body></html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -16,6 +15,7 @@ var networkHTML string
|
||||
|
||||
// Original and reply tuples describe one conntrack entry, not two connections.
|
||||
type NetworkFlow struct {
|
||||
Monitoring bool `json:"monitoring"`
|
||||
External bool `json:"external"`
|
||||
Protocol string `json:"protocol"`
|
||||
Source string `json:"source"`
|
||||
@@ -42,16 +42,17 @@ type FirewallCounter struct {
|
||||
Bytes uint64 `json:"bytes"`
|
||||
}
|
||||
type NetworkSnapshot struct {
|
||||
At int64 `json:"at"`
|
||||
ReceivedAt int64 `json:"receivedAt"`
|
||||
Flows []NetworkFlow `json:"flows"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Limited bool `json:"limited"`
|
||||
ParseErrors int `json:"parseErrors"`
|
||||
Firewall []FirewallCounter `json:"firewall"`
|
||||
FirewallAt int64 `json:"firewallAt"`
|
||||
FirewallError string `json:"firewallError,omitempty"`
|
||||
AgentName string `json:"agentName"`
|
||||
MonitoringHidden int `json:"monitoringHidden"`
|
||||
At int64 `json:"at"`
|
||||
ReceivedAt int64 `json:"receivedAt"`
|
||||
Flows []NetworkFlow `json:"flows"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Limited bool `json:"limited"`
|
||||
ParseErrors int `json:"parseErrors"`
|
||||
Firewall []FirewallCounter `json:"firewall"`
|
||||
FirewallAt int64 `json:"firewallAt"`
|
||||
FirewallError string `json:"firewallError,omitempty"`
|
||||
AgentName string `json:"agentName"`
|
||||
}
|
||||
|
||||
func networkExternalIP(value string) bool {
|
||||
@@ -95,6 +96,7 @@ func (s *Store) SaveNetwork(agent string, snapshot NetworkSnapshot) error {
|
||||
for i := range snapshot.Firewall {
|
||||
snapshot.Firewall[i].Name = trafficText(snapshot.Firewall[i].Name, 200)
|
||||
}
|
||||
snapshot.MonitoringHidden = 0
|
||||
snapshot.ReceivedAt = time.Now().Unix()
|
||||
snapshot.AgentName = ""
|
||||
raw, err := json.Marshal(snapshot)
|
||||
@@ -123,22 +125,12 @@ func networkRoutes(mux *http.ServeMux, s *Store) {
|
||||
http.Error(w, "Сбор сети отключён", 404)
|
||||
return
|
||||
}
|
||||
var raw, name string
|
||||
err := s.db.QueryRow(`SELECT n.snapshot,a.name FROM network_sources n JOIN agents a ON a.id=n.agent WHERE n.agent=?`, s.TrafficSettings().AgentID).Scan(&raw, &name)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
writeJSON(w, nil, err)
|
||||
return
|
||||
}
|
||||
settings := s.TrafficSettings()
|
||||
snapshot, err := s.ReadNetworkSnapshot(settings.AgentID, settings.MonitoringRules, r.URL.Query().Get("hideMonitoring") == "1")
|
||||
if err != nil {
|
||||
writeJSON(w, NetworkSnapshot{Flows: []NetworkFlow{}, Error: "Ожидание агента v0.21.0 или новее"}, nil)
|
||||
return
|
||||
}
|
||||
var snapshot NetworkSnapshot
|
||||
if err = json.Unmarshal([]byte(raw), &snapshot); err != nil {
|
||||
writeJSON(w, nil, err)
|
||||
return
|
||||
}
|
||||
snapshot.AgentName = name
|
||||
writeJSON(w, snapshot, nil)
|
||||
})
|
||||
mux.HandleFunc("/api/agent/network", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -10,11 +10,28 @@ if [ -z "${DASHBOARD_URL:-}" ] || [ -z "${ENROLL_TOKEN:-}" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$(uname -m)" in x86_64|amd64) ;; *) echo "Поддерживается Linux amd64 (x86_64)." >&2; exit 1 ;; esac
|
||||
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
|
||||
agent_init=systemd
|
||||
elif command -v rc-service >/dev/null 2>&1 && command -v supervise-daemon >/dev/null 2>&1; then
|
||||
agent_init=openrc
|
||||
else
|
||||
echo "Нужен работающий systemd или OpenRC с supervise-daemon." >&2; exit 1
|
||||
fi
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
if command -v apk >/dev/null 2>&1; then apk add --no-cache curl ca-certificates; else echo "Установите curl и ca-certificates." >&2; exit 1; fi
|
||||
fi
|
||||
|
||||
GITEA_URL=${GITEA_URL:-https://git.myown.center}
|
||||
GITEA_REPOSITORY=${GITEA_REPOSITORY:-maxim/ProxmoxDash}
|
||||
VMID=${VMID:-0}
|
||||
AGENT_NAME=${AGENT_NAME:-$(hostname)}
|
||||
|
||||
case "$DASHBOARD_URL$ENROLL_TOKEN$AGENT_NAME$VMID" in
|
||||
*'
|
||||
'*) echo "Параметры установки не должны содержать переносы строк." >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
release_json=$(curl -fsSL "$GITEA_URL/api/v1/repos/$GITEA_REPOSITORY/releases/latest")
|
||||
download_url=$(printf '%s' "$release_json" | sed -n 's/.*"browser_download_url":"\([^"]*\/proxmox-dashboard-linux-amd64\)".*/\1/p' | head -n 1)
|
||||
if [ -z "$download_url" ]; then
|
||||
@@ -24,8 +41,12 @@ fi
|
||||
|
||||
install -d -m 0700 /var/lib/proxmox-dashboard-agent
|
||||
temporary_binary=$(mktemp /tmp/proxmox-dashboard-agent.XXXXXX)
|
||||
trap 'rm -f "$temporary_binary"' EXIT
|
||||
trap 'rm -f "$temporary_binary" "$temporary_binary.sha256"' EXIT
|
||||
curl -fsSL "$download_url" -o "$temporary_binary"
|
||||
curl -fsSL "$download_url.sha256" -o "$temporary_binary.sha256"
|
||||
expected_hash=$(awk 'NR==1 {print $1}' "$temporary_binary.sha256")
|
||||
actual_hash=$(sha256sum "$temporary_binary" | awk '{print $1}')
|
||||
if [ "${#expected_hash}" -ne 64 ] || [ "$expected_hash" != "$actual_hash" ]; then echo "SHA-256 загрузки не совпал." >&2; exit 1; fi
|
||||
install -m 0755 "$temporary_binary" /var/lib/proxmox-dashboard-agent/proxmox-dashboard-agent
|
||||
|
||||
umask 077
|
||||
@@ -36,6 +57,7 @@ umask 077
|
||||
printf 'VMID=%s\n' "$VMID"
|
||||
} > /etc/proxmox-dashboard-agent.env
|
||||
|
||||
if [ "$agent_init" = systemd ]; then
|
||||
cat > /etc/systemd/system/proxmox-dashboard-agent.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=Proxmox Dashboard read-only guest agent
|
||||
@@ -61,5 +83,38 @@ UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl enable proxmox-dashboard-agent.service
|
||||
systemctl restart proxmox-dashboard-agent.service
|
||||
else
|
||||
mkdir -p /usr/local/sbin /etc/init.d
|
||||
cat > /usr/local/sbin/proxmox-dashboard-agent-run <<'RUN'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
# Parse literal key=value records, never evaluate configuration as shell code.
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in DASHBOARD_URL|ENROLL_TOKEN|AGENT_NAME|VMID|DASHBOARD_NPM_LOG_DIR|DASHBOARD_CROWDSEC_CONTAINER) export "$key=$value" ;; esac
|
||||
done < /etc/proxmox-dashboard-agent.env
|
||||
exec /var/lib/proxmox-dashboard-agent/proxmox-dashboard-agent --agent --agent-server "$DASHBOARD_URL" --agent-enroll-token "$ENROLL_TOKEN" --agent-name "$AGENT_NAME" --agent-vmid "$VMID"
|
||||
RUN
|
||||
chmod 0755 /usr/local/sbin/proxmox-dashboard-agent-run
|
||||
cat > /etc/init.d/proxmox-dashboard-agent <<'OPENRC'
|
||||
#!/sbin/openrc-run
|
||||
description="Proxmox Dashboard guest agent"
|
||||
command="/usr/local/sbin/proxmox-dashboard-agent-run"
|
||||
supervisor="supervise-daemon"
|
||||
respawn_delay=5
|
||||
respawn_max=0
|
||||
output_log="/var/log/proxmox-dashboard-agent.log"
|
||||
error_log="/var/log/proxmox-dashboard-agent.log"
|
||||
depend() {
|
||||
need net
|
||||
after docker
|
||||
}
|
||||
start_pre() {
|
||||
checkpath --file --mode 0600 /var/log/proxmox-dashboard-agent.log
|
||||
}
|
||||
OPENRC
|
||||
chmod 0755 /etc/init.d/proxmox-dashboard-agent
|
||||
rc-update add proxmox-dashboard-agent default
|
||||
rc-service proxmox-dashboard-agent restart
|
||||
fi
|
||||
rm -f /usr/local/bin/proxmox-dashboard-agent
|
||||
echo "Агент установлен. Через несколько секунд он появится в Dashboard."
|
||||
|
||||
@@ -6,11 +6,16 @@ if [ "$(id -u)" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v rc-service >/dev/null 2>&1 && [ -f /etc/init.d/proxmox-dashboard-agent ]; then
|
||||
rc-service proxmox-dashboard-agent stop || true
|
||||
rc-update del proxmox-dashboard-agent default || true
|
||||
fi
|
||||
systemctl disable --now proxmox-dashboard-agent.service 2>/dev/null || true
|
||||
rm -f /etc/init.d/proxmox-dashboard-agent /usr/local/sbin/proxmox-dashboard-agent-run
|
||||
rm -f /etc/systemd/system/proxmox-dashboard-agent.service
|
||||
rm -f /etc/proxmox-dashboard-agent.env
|
||||
rm -f /usr/local/bin/proxmox-dashboard-agent
|
||||
rm -rf /var/lib/proxmox-dashboard-agent
|
||||
systemctl daemon-reload
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
systemctl reset-failed proxmox-dashboard-agent.service 2>/dev/null || true
|
||||
echo "Агент и его локальные учётные данные удалены."
|
||||
|
||||
103
traffic.go
103
traffic.go
@@ -12,18 +12,22 @@ import (
|
||||
)
|
||||
|
||||
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"`
|
||||
Location *TrafficCityLocation `json:"location,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Monitoring bool `json:"monitoring"`
|
||||
ID string `json:"id"`
|
||||
Sequence int64 `json:"sequence,omitempty"`
|
||||
Time int64 `json:"time"`
|
||||
Kind string `json:"kind"`
|
||||
IP string `json:"ip"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
}
|
||||
|
||||
type TrafficBatch struct {
|
||||
@@ -50,7 +54,22 @@ type TrafficCount struct {
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type TrafficCountryDetail struct {
|
||||
Country string `json:"country"`
|
||||
Requests int64 `json:"requests"`
|
||||
UniqueIPs int64 `json:"uniqueIps"`
|
||||
Alerts int64 `json:"alerts"`
|
||||
Decisions int64 `json:"decisions"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
}
|
||||
type TrafficView struct {
|
||||
Cities []TrafficMapCity `json:"cities"`
|
||||
CitiesLimited bool `json:"citiesLimited"`
|
||||
CityReady bool `json:"cityReady"`
|
||||
MaxSequence int64 `json:"maxSequence"`
|
||||
CountryDetails []TrafficCountryDetail `json:"countryDetails"`
|
||||
|
||||
Network *NetworkMapView `json:"network,omitempty"`
|
||||
Events []TrafficEvent `json:"events"`
|
||||
Sources []TrafficSource `json:"sources"`
|
||||
Countries []TrafficCount `json:"countries"`
|
||||
@@ -104,6 +123,7 @@ func (s *Store) SaveTraffic(agent string, batch TrafficBatch) error {
|
||||
if e.Time < now-86400 || e.Time > now+300 {
|
||||
continue
|
||||
}
|
||||
e.IP = net.ParseIP(e.IP).String()
|
||||
country := lookupTrafficCountry(e.IP)
|
||||
_, err = stmt.Exec(agent, e.ID, e.Time, e.Kind, e.IP, trafficText(e.Domain, 253), trafficText(e.Method, 16), trafficPath(e.Path), e.Status, trafficText(e.Reason, 256), country)
|
||||
if err != nil {
|
||||
@@ -144,12 +164,29 @@ func trafficPath(s string) string {
|
||||
return trafficText(s, 256)
|
||||
}
|
||||
|
||||
func (s *Store) TrafficView(domain, kind string, external bool) (TrafficView, error) {
|
||||
func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring ...bool) (TrafficView, error) {
|
||||
now := time.Now().Unix()
|
||||
v := TrafficView{Events: []TrafficEvent{}, Sources: []TrafficSource{}, Countries: []TrafficCount{}, Domains: []TrafficCount{}, IPs: []TrafficCount{}, Series: []TrafficCount{}, Now: now, GeoReady: trafficGeoReady(), GeoDate: trafficGeoDate}
|
||||
v.Settings = s.TrafficSettings()
|
||||
where := ` WHERE ts>=? AND agent=?`
|
||||
args := []any{now - 86400, v.Settings.AgentID}
|
||||
hide := len(hideMonitoring) > 0 && hideMonitoring[0]
|
||||
if v.Settings.NetworkEnabled {
|
||||
snapshot, err := s.ReadNetworkSnapshot(v.Settings.AgentID, v.Settings.MonitoringRules, hide)
|
||||
if err != nil {
|
||||
snapshot.Error = "Не удалось прочитать сетевой снимок"
|
||||
}
|
||||
network := makeNetworkMap(snapshot, external)
|
||||
v.Network = &network
|
||||
}
|
||||
if err := s.db.QueryRow(`SELECT COALESCE(MAX(seq),0) FROM traffic_events WHERE agent=?`, v.Settings.AgentID).Scan(&v.MaxSequence); err != nil {
|
||||
return v, err
|
||||
}
|
||||
where := ` WHERE ts>=? AND agent=? AND ts<=? AND seq<=?`
|
||||
args := []any{now - 86400, v.Settings.AgentID, now, v.MaxSequence}
|
||||
if hide {
|
||||
clause, params := monitoringSQL(v.Settings.MonitoringRules)
|
||||
where += clause
|
||||
args = append(args, params...)
|
||||
}
|
||||
if external {
|
||||
where += ` AND country!='LAN'`
|
||||
}
|
||||
@@ -175,6 +212,12 @@ func (s *Store) TrafficView(domain, kind string, external bool) (TrafficView, er
|
||||
rows.Close()
|
||||
return v, err
|
||||
}
|
||||
e.Monitoring = isMonitoringRequest(e, v.Settings.MonitoringRules)
|
||||
g := lookupTrafficCityLocation(e.IP)
|
||||
e.City, e.Region = g.City, g.Region
|
||||
if g.Located {
|
||||
e.Location = &g
|
||||
}
|
||||
v.Events = append(v.Events, e)
|
||||
}
|
||||
err = rows.Err()
|
||||
@@ -204,6 +247,32 @@ func (s *Store) TrafficView(domain, kind string, external bool) (TrafficView, er
|
||||
return v, e
|
||||
}
|
||||
}
|
||||
detailRows, detailErr := s.db.Query(`SELECT country,SUM(kind='request'),COUNT(DISTINCT CASE WHEN kind='request' THEN ip END),SUM(kind='alert'),SUM(kind='decision'),MAX(ts) FROM traffic_events`+where+` GROUP BY country`, args...)
|
||||
if detailErr != nil {
|
||||
return v, detailErr
|
||||
}
|
||||
v.CountryDetails = []TrafficCountryDetail{}
|
||||
for detailRows.Next() {
|
||||
var d TrafficCountryDetail
|
||||
if err = detailRows.Scan(&d.Country, &d.Requests, &d.UniqueIPs, &d.Alerts, &d.Decisions, &d.LastSeen); err != nil {
|
||||
detailRows.Close()
|
||||
return v, err
|
||||
}
|
||||
v.CountryDetails = append(v.CountryDetails, d)
|
||||
}
|
||||
detailErr = detailRows.Err()
|
||||
detailRows.Close()
|
||||
if detailErr != nil {
|
||||
return v, detailErr
|
||||
}
|
||||
v.CityReady = trafficCityReady()
|
||||
if v.CityReady {
|
||||
var cityErr error
|
||||
v.Cities, v.CitiesLimited, cityErr = s.trafficMapCities(where, args)
|
||||
if cityErr != nil {
|
||||
return v, cityErr
|
||||
}
|
||||
}
|
||||
r, err := s.db.Query(`SELECT CAST((ts/60)*60 AS TEXT),COUNT(*) FROM traffic_events`+where+` AND kind='request' AND ts>=? GROUP BY ts/60 ORDER BY ts/60`, append(args, now-3600)...)
|
||||
if err != nil {
|
||||
return v, err
|
||||
@@ -245,6 +314,8 @@ func (s *Store) TrafficView(domain, kind string, external bool) (TrafficView, er
|
||||
|
||||
func trafficRoutes(mux *http.ServeMux, s *Store) {
|
||||
trafficSettingsRoute(mux, s)
|
||||
s.trafficHistoryRoute(mux)
|
||||
trafficCityRoutes(mux)
|
||||
networkRoutes(mux, s)
|
||||
mux.HandleFunc("/traffic", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.TrafficSettings().Enabled {
|
||||
@@ -263,7 +334,7 @@ func trafficRoutes(mux *http.ServeMux, s *Store) {
|
||||
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")
|
||||
view, err := s.TrafficView(r.URL.Query().Get("domain"), r.URL.Query().Get("kind"), r.URL.Query().Get("external") == "1", r.URL.Query().Get("hideMonitoring") == "1")
|
||||
writeJSON(w, view, err)
|
||||
})
|
||||
mux.HandleFunc("/api/traffic/world", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
216
traffic_city.go
Normal file
216
traffic_city.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
)
|
||||
|
||||
const cityDownloadURL = "https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz"
|
||||
const cityDatabaseSHA1 = "df17bd24390108ed1d24dcb7718dc60f6b9abd4f"
|
||||
|
||||
var trafficCity struct {
|
||||
sync.Mutex
|
||||
once sync.Once
|
||||
db *maxminddb.Reader
|
||||
running bool
|
||||
message string
|
||||
cache map[string]TrafficCityLocation
|
||||
}
|
||||
|
||||
func trafficCityPath() string {
|
||||
db := os.Getenv("DASHBOARD_DB_PATH")
|
||||
if db == "" {
|
||||
db = defaultDatabasePath
|
||||
}
|
||||
return filepath.Join(filepath.Dir(db), "dbip-city.mmdb")
|
||||
}
|
||||
func trafficCityReady() bool {
|
||||
trafficCity.once.Do(func() {
|
||||
trafficCity.Lock()
|
||||
defer trafficCity.Unlock()
|
||||
trafficCity.db, _ = maxminddb.Open(trafficCityPath())
|
||||
if trafficCity.db != nil && !strings.Contains(strings.ToLower(trafficCity.db.Metadata.DatabaseType), "city") {
|
||||
trafficCity.db.Close()
|
||||
trafficCity.db = nil
|
||||
}
|
||||
})
|
||||
trafficCity.Lock()
|
||||
defer trafficCity.Unlock()
|
||||
return trafficCity.db != nil
|
||||
}
|
||||
|
||||
type TrafficCityLocation struct {
|
||||
City string `json:"city"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Located bool `json:"located"`
|
||||
}
|
||||
|
||||
func lookupTrafficCity(text string) (string, string) {
|
||||
g := lookupTrafficCityLocation(text)
|
||||
return g.City, g.Region
|
||||
}
|
||||
func lookupTrafficCityLocation(text string) TrafficCityLocation {
|
||||
ip := net.ParseIP(text)
|
||||
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || !ip.IsGlobalUnicast() || !trafficCityReady() {
|
||||
return TrafficCityLocation{}
|
||||
}
|
||||
trafficCity.Lock()
|
||||
defer trafficCity.Unlock()
|
||||
if g, ok := trafficCity.cache[text]; ok {
|
||||
return g
|
||||
}
|
||||
var r struct {
|
||||
Location struct {
|
||||
Latitude *float64 `maxminddb:"latitude"`
|
||||
Longitude *float64 `maxminddb:"longitude"`
|
||||
} `maxminddb:"location"`
|
||||
City struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"city"`
|
||||
Subdivisions []struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"subdivisions"`
|
||||
}
|
||||
if trafficCity.db.Lookup(ip, &r) != nil {
|
||||
return TrafficCityLocation{}
|
||||
}
|
||||
region := ""
|
||||
if len(r.Subdivisions) > 0 {
|
||||
region = cityName(r.Subdivisions[0].Names)
|
||||
}
|
||||
g := TrafficCityLocation{City: cityName(r.City.Names), Region: region}
|
||||
if g.City != "" && r.Location.Latitude != nil && r.Location.Longitude != nil {
|
||||
g.Latitude, g.Longitude = *r.Location.Latitude, *r.Location.Longitude
|
||||
g.Located = !math.IsNaN(g.Latitude) && !math.IsNaN(g.Longitude) && math.Abs(g.Latitude) <= 90 && math.Abs(g.Longitude) <= 180
|
||||
}
|
||||
if trafficCity.cache == nil {
|
||||
trafficCity.cache = map[string]TrafficCityLocation{}
|
||||
}
|
||||
if len(trafficCity.cache) < 32768 {
|
||||
trafficCity.cache[text] = g
|
||||
}
|
||||
return g
|
||||
}
|
||||
func trafficCityRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/settings/traffic/city-database", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" && r.Method != "POST" {
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
}
|
||||
trafficCityReady()
|
||||
trafficCity.Lock()
|
||||
if r.Method == "POST" && !trafficCity.running && trafficCity.db == nil {
|
||||
trafficCity.running = true
|
||||
trafficCity.message = "Загрузка базы городов (около 58 МБ)…"
|
||||
go func() {
|
||||
err := installTrafficCity()
|
||||
trafficCity.Lock()
|
||||
defer trafficCity.Unlock()
|
||||
trafficCity.running = false
|
||||
if err != nil {
|
||||
trafficCity.message = "Не удалось установить базу: " + err.Error()
|
||||
} else {
|
||||
trafficCity.message = "База городов DB-IP City Lite 2026-10 установлена"
|
||||
}
|
||||
}()
|
||||
}
|
||||
status := map[string]any{"ready": trafficCity.db != nil, "running": trafficCity.running, "message": trafficCity.message, "date": "2026-10"}
|
||||
trafficCity.Unlock()
|
||||
writeJSON(w, status, nil)
|
||||
})
|
||||
}
|
||||
func cityMirrorURL() string {
|
||||
base := strings.TrimRight(os.Getenv("GITEA_URL"), "/")
|
||||
if base == "" {
|
||||
base = "https://git.myown.center"
|
||||
}
|
||||
return base + "/maxim/ProxmoxDash/releases/download/v0.25.1/dbip-city-lite-2026-10.mmdb.gz"
|
||||
}
|
||||
func installTrafficCity() error {
|
||||
if err := installTrafficCityFrom(cityMirrorURL(), cityDatabaseSHA1); err == nil {
|
||||
return nil
|
||||
}
|
||||
return installTrafficCityFrom(cityDownloadURL, cityDatabaseSHA1)
|
||||
}
|
||||
func installTrafficCityFrom(source, expectedHash string) error {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.ResponseHeaderTimeout = 20 * time.Second
|
||||
transport.DialContext = (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Timeout: 15 * time.Minute, Transport: transport}
|
||||
response, err := client.Get(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != 200 {
|
||||
return fmt.Errorf("HTTP %d", response.StatusCode)
|
||||
}
|
||||
reader, err := gzip.NewReader(io.LimitReader(response.Body, 70<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
path := trafficCityPath()
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0750); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.CreateTemp(filepath.Dir(path), ".city-*.mmdb")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(file.Name())
|
||||
defer file.Close()
|
||||
hash := sha1.New()
|
||||
n, err := io.Copy(io.MultiWriter(file, hash), io.LimitReader(reader, (160<<20)+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 160<<20 {
|
||||
return fmt.Errorf("база превышает лимит размера")
|
||||
}
|
||||
if hex.EncodeToString(hash.Sum(nil)) != expectedHash {
|
||||
return fmt.Errorf("контрольная сумма базы не совпала")
|
||||
}
|
||||
if err = file.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := maxminddb.Open(file.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(db.Metadata.DatabaseType), "city") {
|
||||
db.Close()
|
||||
return fmt.Errorf("неверный тип GeoIP-базы")
|
||||
}
|
||||
if err = os.Rename(file.Name(), path); err != nil {
|
||||
db.Close()
|
||||
return err
|
||||
}
|
||||
trafficCity.Lock()
|
||||
defer trafficCity.Unlock()
|
||||
if trafficCity.db != nil {
|
||||
trafficCity.db.Close()
|
||||
}
|
||||
trafficCity.db = db
|
||||
trafficCity.cache = nil
|
||||
return nil
|
||||
}
|
||||
82
traffic_city_test.go
Normal file
82
traffic_city_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCityInstallRejectsBadDownload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DASHBOARD_DB_PATH", filepath.Join(dir, "dashboard.db"))
|
||||
existing := []byte("existing database")
|
||||
if err := os.WriteFile(trafficCityPath(), existing, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
g := gzip.NewWriter(w)
|
||||
g.Write([]byte("corrupt database"))
|
||||
g.Close()
|
||||
}))
|
||||
defer server.Close()
|
||||
if err := installTrafficCityFrom(server.URL, cityDatabaseSHA1); err == nil {
|
||||
t.Fatal("bad checksum accepted")
|
||||
}
|
||||
got, err := os.ReadFile(trafficCityPath())
|
||||
if err != nil || string(got) != string(existing) {
|
||||
t.Fatal("existing database replaced on failure")
|
||||
}
|
||||
files, _ := filepath.Glob(filepath.Join(dir, ".city-*"))
|
||||
if len(files) != 0 {
|
||||
t.Fatal("partial file left behind")
|
||||
}
|
||||
}
|
||||
func TestCityDatabaseDownloaded(t *testing.T) {
|
||||
source := os.Getenv("DASHBOARD_CITY_TEST_GZIP")
|
||||
if source == "" {
|
||||
t.Skip("optional official database integration test")
|
||||
}
|
||||
t.Setenv("DASHBOARD_DB_PATH", filepath.Join(t.TempDir(), "dashboard.db"))
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, source) }))
|
||||
defer server.Close()
|
||||
trafficCityReady()
|
||||
if err := installTrafficCityFrom(server.URL, cityDatabaseSHA1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := lookupTrafficCityLocation("20.194.96.114")
|
||||
if !g.Located || g.Latitude < 30 || g.Latitude > 40 || g.Longitude < 120 || g.Longitude > 130 {
|
||||
t.Fatalf("invalid Seoul coordinates: %+v", g)
|
||||
}
|
||||
s, a := trafficFixture(t)
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID})
|
||||
now := time.Now().Unix()
|
||||
if err := s.SaveTraffic(a.ID, TrafficBatch{Events: []TrafficEvent{
|
||||
{ID: "1", Time: now, Kind: "request", IP: "20.194.96.114", Domain: "a.test"},
|
||||
{ID: "2", Time: now, Kind: "request", IP: "20.194.96.114", Domain: "a.test"},
|
||||
{ID: "3", Time: now, Kind: "alert", IP: "20.194.96.114", Domain: "b.test"},
|
||||
{ID: "4", Time: now, Kind: "request", IP: "192.168.0.10"},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
view, err := s.TrafficView("", "", false)
|
||||
if err != nil || len(view.Cities) != 1 {
|
||||
t.Fatalf("cities %+v err %v", view.Cities, err)
|
||||
}
|
||||
c := view.Cities[0]
|
||||
if c.Requests != 2 || c.UniqueIPs != 1 || c.Alerts != 1 || c.City != g.City {
|
||||
t.Fatalf("bad aggregation %+v", c)
|
||||
}
|
||||
filtered, err := s.TrafficView("b.test", "alert", false)
|
||||
if err != nil || len(filtered.Cities) != 1 || filtered.Cities[0].Requests != 0 || filtered.Cities[0].Alerts != 1 {
|
||||
t.Fatalf("city filter %+v %v", filtered.Cities, err)
|
||||
}
|
||||
city, region := lookupTrafficCity("20.194.96.114")
|
||||
t.Logf("city=%q region=%q", city, region)
|
||||
if city == "" {
|
||||
t.Fatal("public IP city not decoded")
|
||||
}
|
||||
}
|
||||
44
traffic_country_test.go
Normal file
44
traffic_country_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTrafficCountryDetailsUseHistoryAndFilters(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
if _, err := s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, MonitoringRules: []MonitoringRule{{IP: "8.8.8.8"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events := []TrafficEvent{}
|
||||
for i := 0; i < 120; i++ {
|
||||
events = append(events, TrafficEvent{ID: fmt.Sprint("http-", i), Time: time.Now().Unix(), Kind: "request", IP: "8.8.8.8", Domain: "example.test", Path: "/", Status: 200})
|
||||
}
|
||||
events = append(events, TrafficEvent{ID: "alert", Time: time.Now().Unix(), Kind: "alert", IP: "8.8.8.8", Domain: "example.test", Reason: "test"})
|
||||
if err := s.SaveTraffic(a.ID, TrafficBatch{Events: events}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := s.TrafficView("", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(v.Events) != 100 || len(v.CountryDetails) != 1 {
|
||||
t.Fatalf("events=%d countries=%+v", len(v.Events), v.CountryDetails)
|
||||
}
|
||||
d := v.CountryDetails[0]
|
||||
if d.Requests != 120 || d.UniqueIPs != 1 || d.Alerts != 1 || d.LastSeen == 0 {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
v, err = s.TrafficView("example.test", "", false, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(v.CountryDetails) != 1 || v.CountryDetails[0].Requests != 0 || v.CountryDetails[0].Alerts != 1 {
|
||||
t.Fatalf("filter: %+v", v.CountryDetails)
|
||||
}
|
||||
v, err = s.TrafficView("other.test", "", false)
|
||||
if err != nil || len(v.CountryDetails) != 0 {
|
||||
t.Fatalf("domain: %+v %v", v.CountryDetails, err)
|
||||
}
|
||||
}
|
||||
144
traffic_history.go
Normal file
144
traffic_history.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TrafficIPDetail struct {
|
||||
IP string `json:"ip"`
|
||||
Country string `json:"country"`
|
||||
Requests int64 `json:"requests"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
City string `json:"city,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
}
|
||||
type TrafficHistory struct {
|
||||
Events []TrafficEvent `json:"events"`
|
||||
IPs []TrafficIPDetail `json:"ips"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Snapshot int64 `json:"snapshot"`
|
||||
MaxSequence int64 `json:"maxSequence"`
|
||||
CityReady bool `json:"cityReady"`
|
||||
}
|
||||
|
||||
func (s *Store) trafficHistoryRoute(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/traffic/history", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
}
|
||||
if !s.TrafficSettings().Enabled {
|
||||
http.Error(w, "Карта отключена", 404)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
snapshot, _ := strconv.ParseInt(q.Get("snapshot"), 10, 64)
|
||||
maxSeq, _ := strconv.ParseInt(q.Get("maxSequence"), 10, 64)
|
||||
if q.Get("maxSequence") == "0" {
|
||||
maxSeq = -1
|
||||
}
|
||||
if page < 0 || page > 10000 {
|
||||
http.Error(w, "Invalid page", 400)
|
||||
return
|
||||
}
|
||||
kind := q.Get("kind")
|
||||
metricType := q.Get("type")
|
||||
if metricType != "" && metricType != "request" && metricType != "alert" && metricType != "decision" {
|
||||
http.Error(w, "Invalid type", 400)
|
||||
return
|
||||
}
|
||||
if kind != "" && kind != "request" && kind != "alert" && kind != "decision" {
|
||||
http.Error(w, "Invalid kind", 400)
|
||||
return
|
||||
}
|
||||
result, err := s.TrafficHistory(q.Get("domain"), kind, q.Get("country"), q.Get("ip"), q.Get("type"), q.Get("external") == "1", q.Get("hideMonitoring") == "1", q.Get("group") == "ip", page, snapshot, maxSeq)
|
||||
writeJSON(w, result, err)
|
||||
})
|
||||
}
|
||||
func (s *Store) TrafficHistory(domain, kind, country, ip, metricType string, external, hide, group bool, page int, snapshot, maxSeq int64) (TrafficHistory, error) {
|
||||
now := time.Now().Unix()
|
||||
if snapshot <= 0 || snapshot > now {
|
||||
snapshot = now
|
||||
}
|
||||
if snapshot < now-86400 {
|
||||
snapshot = now
|
||||
}
|
||||
settings := s.TrafficSettings()
|
||||
v := TrafficHistory{Events: []TrafficEvent{}, IPs: []TrafficIPDetail{}, Page: page, PageSize: 50, Snapshot: snapshot, CityReady: trafficCityReady()}
|
||||
if maxSeq == 0 {
|
||||
if err := s.db.QueryRow(`SELECT COALESCE(MAX(seq),0) FROM traffic_events WHERE agent=?`, settings.AgentID).Scan(&maxSeq); err != nil {
|
||||
return v, err
|
||||
}
|
||||
}
|
||||
v.MaxSequence = maxSeq
|
||||
where := ` WHERE agent=? AND ts>=? AND ts<=? AND seq<=?`
|
||||
args := []any{settings.AgentID, snapshot - 86400, snapshot, maxSeq}
|
||||
if hide {
|
||||
clause, values := monitoringSQL(settings.MonitoringRules)
|
||||
where += clause
|
||||
args = append(args, values...)
|
||||
}
|
||||
if external {
|
||||
where += ` AND country!='LAN'`
|
||||
}
|
||||
for _, filter := range []struct{ column, value string }{{"domain", domain}, {"kind", kind}, {"kind", metricType}, {"country", country}, {"ip", ip}} {
|
||||
if filter.value != "" {
|
||||
where += " AND " + filter.column + "=?"
|
||||
args = append(args, filter.value)
|
||||
}
|
||||
}
|
||||
count := "COUNT(*)"
|
||||
if group {
|
||||
where += ` AND kind='request'`
|
||||
count = "COUNT(DISTINCT ip)"
|
||||
}
|
||||
if err := s.db.QueryRow("SELECT "+count+" FROM traffic_events"+where, args...).Scan(&v.Total); err != nil {
|
||||
return v, err
|
||||
}
|
||||
paged := append(append([]any{}, args...), v.PageSize, page*v.PageSize)
|
||||
if group {
|
||||
rows, err := s.db.Query(`SELECT ip,MIN(country),COUNT(*),MAX(ts) FROM traffic_events`+where+` GROUP BY ip ORDER BY COUNT(*) DESC,ip LIMIT ? OFFSET ?`, paged...)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item TrafficIPDetail
|
||||
if err = rows.Scan(&item.IP, &item.Country, &item.Requests, &item.LastSeen); err != nil {
|
||||
return v, err
|
||||
}
|
||||
item.City, item.Region = lookupTrafficCity(item.IP)
|
||||
v.IPs = append(v.IPs, item)
|
||||
}
|
||||
return v, rows.Err()
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT seq,event_id,ts,kind,ip,domain,method,path,status,reason,country,agent FROM traffic_events`+where+` ORDER BY ts DESC,seq DESC LIMIT ? OFFSET ?`, paged...)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var e TrafficEvent
|
||||
if err = rows.Scan(&e.Sequence, &e.ID, &e.Time, &e.Kind, &e.IP, &e.Domain, &e.Method, &e.Path, &e.Status, &e.Reason, &e.Country, &e.Agent); err != nil {
|
||||
return v, err
|
||||
}
|
||||
e.Monitoring = isMonitoringRequest(e, settings.MonitoringRules)
|
||||
e.City, e.Region = lookupTrafficCity(e.IP)
|
||||
v.Events = append(v.Events, e)
|
||||
}
|
||||
return v, rows.Err()
|
||||
}
|
||||
func cityName(names map[string]string) string {
|
||||
for _, lang := range []string{"ru", "en"} {
|
||||
if s := strings.TrimSpace(names[lang]); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
109
traffic_history_test.go
Normal file
109
traffic_history_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTrafficHistoryFullPaginationAndSnapshot(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
if _, err := s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
batch := TrafficBatch{}
|
||||
for i := 0; i < 137; i++ {
|
||||
batch.Events = append(batch.Events, TrafficEvent{ID: fmt.Sprint(i), Time: now - 60, Kind: "request", IP: "8.8.8.8", Domain: "example.test"})
|
||||
}
|
||||
batch.Events = append(batch.Events, TrafficEvent{ID: "old-alert", Time: now - 3600, Kind: "alert", IP: "1.1.1.1", Domain: "other.test"})
|
||||
if err := s.SaveTraffic(a.ID, batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := s.TrafficHistory("", "request", "", "", "", false, false, false, 0, now, 0)
|
||||
if err != nil || first.Total != 137 || len(first.Events) != 50 {
|
||||
t.Fatalf("first: %+v %v", first, err)
|
||||
}
|
||||
if err := s.SaveTraffic(a.ID, TrafficBatch{Events: []TrafficEvent{{ID: "new", Time: now - 60, Kind: "request", IP: "8.8.8.8"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[int64]bool{}
|
||||
for page := 0; page < 3; page++ {
|
||||
h, err := s.TrafficHistory("", "request", "", "", "", false, false, false, page, first.Snapshot, first.MaxSequence)
|
||||
if err != nil || h.Total != 137 {
|
||||
t.Fatalf("page %d: %v total %d", page, err, h.Total)
|
||||
}
|
||||
for _, e := range h.Events {
|
||||
if seen[e.Sequence] {
|
||||
t.Fatal("duplicate page event")
|
||||
}
|
||||
seen[e.Sequence] = true
|
||||
}
|
||||
}
|
||||
if len(seen) != 137 {
|
||||
t.Fatalf("missing history: %d", len(seen))
|
||||
}
|
||||
h, err := s.TrafficHistory("other.test", "alert", "", "", "", false, false, false, 0, now, 0)
|
||||
if err != nil || h.Total != 1 || h.Events[0].ID != "old-alert" {
|
||||
t.Fatalf("old alert missing: %+v %v", h, err)
|
||||
}
|
||||
h, err = s.TrafficHistory("", "alert", "", "", "request", false, false, false, 0, now, 0)
|
||||
if err != nil || h.Total != 0 {
|
||||
t.Fatal("metric must respect global kind filter")
|
||||
}
|
||||
h, err = s.TrafficHistory("example.test", "", "", "", "", false, false, true, 0, now, 0)
|
||||
if err != nil || h.Total != 1 || len(h.IPs) != 1 || h.IPs[0].Requests != 137 {
|
||||
t.Fatalf("IP summary %+v %v", h, err)
|
||||
}
|
||||
h, err = s.TrafficHistory("", "", "ZZ", "", "", false, false, false, 0, now, 0)
|
||||
if err != nil || h.Total != 0 {
|
||||
t.Fatal("country filter ignored")
|
||||
}
|
||||
h, err = s.TrafficHistory("", "", "", "1.1.1.1", "", false, false, false, 0, now, 0)
|
||||
if err != nil || h.Total != 1 {
|
||||
t.Fatal("IP filter ignored")
|
||||
}
|
||||
}
|
||||
func TestCityNamesAndLocalAddresses(t *testing.T) {
|
||||
if cityName(map[string]string{"ru": "Москва", "en": "Moscow"}) != "Москва" || cityName(map[string]string{"en": "Amsterdam"}) != "Amsterdam" {
|
||||
t.Fatal("city localization")
|
||||
}
|
||||
for _, ip := range []string{"192.168.0.10", "127.0.0.1", "::1", "fe80::1", "invalid"} {
|
||||
c, r := lookupTrafficCity(ip)
|
||||
if c != "" || r != "" {
|
||||
t.Fatal("local IP geolocated")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficHistoryMonitoringAndAgentIsolation(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, MonitoringRules: []MonitoringRule{{IP: "192.168.0.10"}}})
|
||||
now := time.Now().Unix()
|
||||
batch := TrafficBatch{Events: []TrafficEvent{{ID: "check", Time: now, Kind: "request", IP: "192.168.0.10"}, {ID: "alert", Time: now, Kind: "alert", IP: "192.168.0.10"}, {ID: "public", Time: now, Kind: "request", IP: "8.8.8.8"}}}
|
||||
if err := s.SaveTraffic(a.ID, batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _ := s.CreateAgentEnrollmentToken()
|
||||
other, err := s.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "other", VMID: 103})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.SaveTraffic(other.ID, batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
external, hide bool
|
||||
want int64
|
||||
}{{false, false, 3}, {false, true, 2}, {true, false, 1}, {true, true, 1}} {
|
||||
h, err := s.TrafficHistory("", "", "", "", "", tc.external, tc.hide, false, 0, now, 0)
|
||||
if err != nil || h.Total != tc.want {
|
||||
t.Fatalf("filters %+v total=%d err=%v", tc, h.Total, err)
|
||||
}
|
||||
for _, e := range h.Events {
|
||||
if e.Agent != a.ID {
|
||||
t.Fatal("foreign agent leaked")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
traffic_map_cities.go
Normal file
71
traffic_map_cities.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type TrafficMapCity struct {
|
||||
TrafficCityLocation
|
||||
Country string `json:"country"`
|
||||
Requests int64 `json:"requests"`
|
||||
UniqueIPs int64 `json:"uniqueIps"`
|
||||
Alerts int64 `json:"alerts"`
|
||||
Decisions int64 `json:"decisions"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
}
|
||||
|
||||
func (s *Store) trafficMapCities(where string, args []any) ([]TrafficMapCity, bool, error) {
|
||||
rows, err := s.db.Query(`SELECT ip,country,SUM(kind='request'),SUM(kind='alert'),SUM(kind='decision'),MAX(ts) FROM traffic_events`+where+` GROUP BY ip,country`, args...)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
groups := map[string]*TrafficMapCity{}
|
||||
for rows.Next() {
|
||||
var ip, country string
|
||||
var requests, alerts, decisions, last int64
|
||||
if err = rows.Scan(&ip, &country, &requests, &alerts, &decisions, &last); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
g := lookupTrafficCityLocation(ip)
|
||||
if !g.Located {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%s/%s/%s/%.5f/%.5f", country, g.Region, g.City, g.Latitude, g.Longitude)
|
||||
c := groups[key]
|
||||
if c == nil {
|
||||
c = &TrafficMapCity{TrafficCityLocation: g, Country: country}
|
||||
groups[key] = c
|
||||
}
|
||||
c.Requests += requests
|
||||
c.Alerts += alerts
|
||||
c.Decisions += decisions
|
||||
if requests > 0 {
|
||||
c.UniqueIPs++
|
||||
}
|
||||
if last > c.LastSeen {
|
||||
c.LastSeen = last
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
result := make([]TrafficMapCity, 0, len(groups))
|
||||
for _, c := range groups {
|
||||
result = append(result, *c)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
a, b := result[i], result[j]
|
||||
x, y := a.Requests+a.Alerts+a.Decisions, b.Requests+b.Alerts+b.Decisions
|
||||
if x != y {
|
||||
return x > y
|
||||
}
|
||||
return fmt.Sprint(a.Country, a.Region, a.City, a.Latitude, a.Longitude) < fmt.Sprint(b.Country, b.Region, b.City, b.Latitude, b.Longitude)
|
||||
})
|
||||
limited := len(result) > 500
|
||||
if limited {
|
||||
result = result[:500]
|
||||
}
|
||||
return result, limited, nil
|
||||
}
|
||||
76
traffic_monitoring.go
Normal file
76
traffic_monitoring.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Domain/path restrictions describe HTTP probes only. An IP-only rule also
|
||||
// matches conntrack initiators; a destination match never hides other clients.
|
||||
type MonitoringRule struct {
|
||||
IP string `json:"ip"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
PathPrefix string `json:"pathPrefix,omitempty"`
|
||||
}
|
||||
|
||||
func validateMonitoringRules(rules []MonitoringRule) ([]MonitoringRule, error) {
|
||||
if len(rules) > 32 {
|
||||
return nil, fmt.Errorf("Не более 32 правил мониторинга")
|
||||
}
|
||||
result := make([]MonitoringRule, 0, len(rules))
|
||||
for i, r := range rules {
|
||||
ip := net.ParseIP(strings.TrimSpace(r.IP))
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("Правило %d: нужен точный IPv4/IPv6-адрес", i+1)
|
||||
}
|
||||
r.IP = ip.String()
|
||||
r.Domain = strings.ToLower(strings.TrimSpace(r.Domain))
|
||||
r.PathPrefix = strings.TrimSpace(r.PathPrefix)
|
||||
if len(r.Domain) > 253 || strings.ContainsAny(r.Domain, " /|\t\r\n") || len(r.PathPrefix) > 256 || strings.ContainsAny(r.PathPrefix, "|\r\n") || (r.PathPrefix != "" && !strings.HasPrefix(r.PathPrefix, "/")) {
|
||||
return nil, fmt.Errorf("Правило %d: проверьте домен и префикс пути (начинается с /)", i+1)
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func isMonitoringRequest(e TrafficEvent, rules []MonitoringRule) bool {
|
||||
if e.Kind != "request" {
|
||||
return false
|
||||
}
|
||||
for _, r := range rules {
|
||||
if e.IP == r.IP && (r.Domain == "" || strings.EqualFold(e.Domain, r.Domain)) && strings.HasPrefix(e.Path, r.PathPrefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func isMonitoringFlow(f NetworkFlow, rules []MonitoringRule) bool {
|
||||
for _, r := range rules {
|
||||
if net.ParseIP(f.Source).Equal(net.ParseIP(r.IP)) && r.Domain == "" && r.PathPrefix == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func monitoringSQL(rules []MonitoringRule) (string, []any) {
|
||||
var parts []string
|
||||
var args []any
|
||||
for _, r := range rules {
|
||||
p := "(ip=?"
|
||||
args = append(args, r.IP)
|
||||
if r.Domain != "" {
|
||||
p += " AND lower(domain)=?"
|
||||
args = append(args, r.Domain)
|
||||
}
|
||||
if r.PathPrefix != "" {
|
||||
p += " AND substr(path,1,length(?))=?"
|
||||
args = append(args, r.PathPrefix, r.PathPrefix)
|
||||
}
|
||||
parts = append(parts, p+")")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return " AND NOT (kind='request' AND (" + strings.Join(parts, " OR ") + "))", args
|
||||
}
|
||||
140
traffic_monitoring_test.go
Normal file
140
traffic_monitoring_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMonitoringRulesValidation(t *testing.T) {
|
||||
rules, err := validateMonitoringRules([]MonitoringRule{{IP: "2001:db8:0:0::1", Domain: "EXAMPLE.org", PathPrefix: "/health"}})
|
||||
if err != nil || rules[0].IP != "2001:db8::1" || rules[0].Domain != "example.org" {
|
||||
t.Fatalf("normalization: %+v %v", rules, err)
|
||||
}
|
||||
for _, r := range []MonitoringRule{{IP: "192.168.0.0/24"}, {IP: "8.8.8.8", Domain: "https://example.org"}, {IP: "8.8.8.8", PathPrefix: "health"}} {
|
||||
if _, err := validateMonitoringRules([]MonitoringRule{r}); err == nil {
|
||||
t.Fatalf("bad rule accepted: %+v", r)
|
||||
}
|
||||
}
|
||||
if _, err := validateMonitoringRules(make([]MonitoringRule, 33)); err == nil {
|
||||
t.Fatal("unbounded rules")
|
||||
}
|
||||
}
|
||||
func TestMonitoringFiltersKeepHistoryAndSecurityEvents(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
rules := []MonitoringRule{{IP: "192.168.0.10"}, {IP: "8.8.8.8", Domain: "example.org", PathPrefix: "/health"}}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, MonitoringRules: rules})
|
||||
now := time.Now().Unix()
|
||||
batch := TrafficBatch{Events: []TrafficEvent{
|
||||
{ID: "probe", Time: now, Kind: "request", IP: "192.168.0.10", Domain: "other.org", Path: "/"},
|
||||
{ID: "health", Time: now, Kind: "request", IP: "8.8.8.8", Domain: "EXAMPLE.org", Path: "/health/live"},
|
||||
{ID: "visitor", Time: now, Kind: "request", IP: "8.8.8.8", Domain: "example.org", Path: "/login"},
|
||||
{ID: "other-domain", Time: now, Kind: "request", IP: "8.8.8.8", Domain: "other.org", Path: "/health"},
|
||||
{ID: "alert", Time: now, Kind: "alert", IP: "192.168.0.10"},
|
||||
{ID: "ban", Time: now, Kind: "decision", IP: "192.168.0.10"},
|
||||
}}
|
||||
if err := s.SaveTraffic(a.ID, batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, err := s.TrafficView("", "", false)
|
||||
if err != nil || all.Requests != 4 {
|
||||
t.Fatalf("all: %+v %v", all, err)
|
||||
}
|
||||
tagged := 0
|
||||
for _, e := range all.Events {
|
||||
if e.Monitoring {
|
||||
tagged++
|
||||
}
|
||||
}
|
||||
if tagged != 2 {
|
||||
t.Fatalf("tags %d", tagged)
|
||||
}
|
||||
filtered, err := s.TrafficView("", "", false, true)
|
||||
if err != nil || filtered.Requests != 2 || filtered.Alerts != 1 || filtered.Decisions != 1 || len(filtered.Events) != 4 {
|
||||
t.Fatalf("filter: %+v %v", filtered, err)
|
||||
}
|
||||
if len(filtered.Series) != 1 || filtered.Series[0].Count != 2 {
|
||||
t.Fatalf("graph leaks probes: %+v", filtered.Series)
|
||||
}
|
||||
var count int
|
||||
s.db.QueryRow("SELECT COUNT(*) FROM traffic_events").Scan(&count)
|
||||
if count != 6 {
|
||||
t.Fatal("history deleted")
|
||||
}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, MonitoringRules: []MonitoringRule{}})
|
||||
filtered, err = s.TrafficView("", "", false, true)
|
||||
if err != nil || filtered.Requests != 4 {
|
||||
t.Fatal("cleared rules not applied retroactively")
|
||||
}
|
||||
}
|
||||
func TestMonitoringPrefixIsLiteralAndDefaultCanBeCleared(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
if len(s.TrafficSettings().MonitoringRules) != 1 {
|
||||
t.Fatal("missing suggested rule")
|
||||
}
|
||||
rules := []MonitoringRule{{IP: "8.8.8.8", PathPrefix: "/health_%"}}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, MonitoringRules: rules})
|
||||
s.SaveTraffic(a.ID, TrafficBatch{Events: []TrafficEvent{{ID: "x", Time: time.Now().Unix(), Kind: "request", IP: "8.8.8.8", Path: "/health_ok"}}})
|
||||
v, err := s.TrafficView("", "", false, true)
|
||||
if err != nil || v.Requests != 1 {
|
||||
t.Fatal("prefix used as wildcard")
|
||||
}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID})
|
||||
if len(s.TrafficSettings().MonitoringRules) != 0 {
|
||||
t.Fatal("cannot clear default")
|
||||
}
|
||||
}
|
||||
func TestNetworkMapAndMonitoringSourceOnly(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
rules := []MonitoringRule{{IP: "192.168.0.10"}, {IP: "8.8.8.8", Domain: "example.org"}}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, NetworkEnabled: true, AgentID: a.ID, MonitoringRules: rules})
|
||||
incoming, _ := parseNetworkFlow(networkNATLine)
|
||||
probe := incoming
|
||||
probe.Source = "192.168.0.10"
|
||||
outgoing := incoming
|
||||
outgoing.Source = "192.168.0.12"
|
||||
outgoing.Destination = "1.1.1.1"
|
||||
outgoing.Protocol = "udp"
|
||||
local := incoming
|
||||
local.Source = "192.168.0.15"
|
||||
local.Destination = "192.168.0.10"
|
||||
local.Protocol = "icmpv6"
|
||||
s.SaveNetwork(a.ID, NetworkSnapshot{At: time.Now().Unix(), Flows: []NetworkFlow{incoming, probe, outgoing, local}})
|
||||
snapshot, err := s.ReadNetworkSnapshot(a.ID, rules, true)
|
||||
if err != nil || len(snapshot.Flows) != 3 || snapshot.MonitoringHidden != 1 {
|
||||
t.Fatalf("source-only: %+v %v", snapshot, err)
|
||||
}
|
||||
mapped := makeNetworkMap(snapshot, false)
|
||||
if mapped.TCP != 1 || mapped.UDP != 1 || mapped.ICMP != 1 || mapped.Unmapped != 1 || len(mapped.Points) != 2 {
|
||||
t.Fatalf("map: %+v", mapped)
|
||||
}
|
||||
for _, p := range mapped.Points {
|
||||
if p.Protocol == "udp" && p.Direction != "out" {
|
||||
t.Fatal("outbound direction lost")
|
||||
}
|
||||
if p.Protocol == "tcp" && p.Direction != "in" {
|
||||
t.Fatal("inbound direction lost")
|
||||
}
|
||||
}
|
||||
mapped = makeNetworkMap(snapshot, true)
|
||||
if mapped.ICMP != 0 {
|
||||
t.Fatal("LAN flow leaked")
|
||||
}
|
||||
view, err := s.TrafficView("example.org", "alert", false, true)
|
||||
if err != nil || view.Network == nil || view.Network.TCP != 1 {
|
||||
t.Fatal("HTTP filters incorrectly hide network layer")
|
||||
}
|
||||
encoded, _ := json.Marshal(view)
|
||||
if len(encoded) == 0 {
|
||||
t.Fatal("map not serializable")
|
||||
}
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID})
|
||||
view, _ = s.TrafficView("", "", false)
|
||||
if view.Network != nil {
|
||||
t.Fatal("disabled network leaked")
|
||||
}
|
||||
v6 := NetworkFlow{Source: "2001:db8:0:0::1"}
|
||||
if !isMonitoringFlow(v6, []MonitoringRule{{IP: "2001:db8::1"}}) {
|
||||
t.Fatal("IPv6 representation mismatch")
|
||||
}
|
||||
}
|
||||
107
traffic_network_map.go
Normal file
107
traffic_network_map.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type NetworkMapPoint struct {
|
||||
Country string `json:"country"`
|
||||
Protocol string `json:"protocol"`
|
||||
Direction string `json:"direction"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
type NetworkMapView struct {
|
||||
At int64 `json:"at"`
|
||||
ReceivedAt int64 `json:"receivedAt"`
|
||||
TCP int `json:"tcp"`
|
||||
UDP int `json:"udp"`
|
||||
ICMP int `json:"icmp"`
|
||||
Unmapped int `json:"unmapped"`
|
||||
MonitoringHidden int `json:"monitoringHidden"`
|
||||
Limited bool `json:"limited"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Points []NetworkMapPoint `json:"points"`
|
||||
}
|
||||
|
||||
func (s *Store) ReadNetworkSnapshot(agent string, rules []MonitoringRule, hide bool) (NetworkSnapshot, error) {
|
||||
var raw, name string
|
||||
err := s.db.QueryRow(`SELECT n.snapshot,a.name FROM network_sources n JOIN agents a ON a.id=n.agent WHERE n.agent=?`, agent).Scan(&raw, &name)
|
||||
if err == sql.ErrNoRows {
|
||||
return NetworkSnapshot{Flows: []NetworkFlow{}, Error: "Ожидание агента v0.21.0 или новее"}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return NetworkSnapshot{}, err
|
||||
}
|
||||
var snapshot NetworkSnapshot
|
||||
if err = json.Unmarshal([]byte(raw), &snapshot); err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
snapshot.AgentName = name
|
||||
flows := make([]NetworkFlow, 0, len(snapshot.Flows))
|
||||
for _, f := range snapshot.Flows {
|
||||
f.Monitoring = isMonitoringFlow(f, rules)
|
||||
if hide && f.Monitoring {
|
||||
snapshot.MonitoringHidden++
|
||||
continue
|
||||
}
|
||||
flows = append(flows, f)
|
||||
}
|
||||
snapshot.Flows = flows
|
||||
return snapshot, nil
|
||||
}
|
||||
func makeNetworkMap(snapshot NetworkSnapshot, external bool) NetworkMapView {
|
||||
v := NetworkMapView{At: snapshot.At, ReceivedAt: snapshot.ReceivedAt, Limited: snapshot.Limited, Error: snapshot.Error, MonitoringHidden: snapshot.MonitoringHidden, Points: []NetworkMapPoint{}}
|
||||
if snapshot.ParseErrors > 0 {
|
||||
v.Error += fmt.Sprintf(" Нераспознанных строк: %d", snapshot.ParseErrors)
|
||||
}
|
||||
points := map[string]*NetworkMapPoint{}
|
||||
for _, f := range snapshot.Flows {
|
||||
if external && !f.External {
|
||||
continue
|
||||
}
|
||||
protocol := f.Protocol
|
||||
if protocol == "icmpv6" {
|
||||
protocol = "icmp"
|
||||
}
|
||||
switch protocol {
|
||||
case "tcp":
|
||||
v.TCP++
|
||||
case "udp":
|
||||
v.UDP++
|
||||
case "icmp":
|
||||
v.ICMP++
|
||||
}
|
||||
country, direction := f.Country, "in"
|
||||
if !networkExternalIP(f.Source) {
|
||||
if networkExternalIP(f.Destination) {
|
||||
country, direction = f.DestinationCountry, "out"
|
||||
} else {
|
||||
v.Unmapped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
if country == "" || country == "LAN" {
|
||||
v.Unmapped++
|
||||
continue
|
||||
}
|
||||
key := country + "/" + protocol + "/" + direction
|
||||
if points[key] == nil {
|
||||
points[key] = &NetworkMapPoint{Country: country, Protocol: protocol, Direction: direction}
|
||||
}
|
||||
points[key].Count++
|
||||
}
|
||||
for _, p := range points {
|
||||
v.Points = append(v.Points, *p)
|
||||
}
|
||||
sort.Slice(v.Points, func(i, j int) bool {
|
||||
a, b := v.Points[i], v.Points[j]
|
||||
if a.Count != b.Count {
|
||||
return a.Count > b.Count
|
||||
}
|
||||
return a.Country+a.Protocol+a.Direction < b.Country+b.Protocol+b.Direction
|
||||
})
|
||||
return v
|
||||
}
|
||||
@@ -15,7 +15,7 @@ func TestTrafficPreview(t *testing.T) {
|
||||
t.Skip("manual visual preview")
|
||||
}
|
||||
s, a := trafficFixture(t)
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, NetworkEnabled: true, AgentID: a.ID, ServerCountry: "RU"})
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, NetworkEnabled: true, MonitoringRules: []MonitoringRule{{IP: "192.168.0.10"}}, AgentID: a.ID, ServerCountry: "RU"})
|
||||
mux := http.NewServeMux()
|
||||
trafficRoutes(mux, s)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -54,6 +54,20 @@ func TestTrafficPreview(t *testing.T) {
|
||||
if k%5 == 0 && f.Protocol == "tcp" {
|
||||
f.State = "SYN_SENT"
|
||||
}
|
||||
if k%7 == 0 {
|
||||
f.Protocol = "icmp"
|
||||
f.State = "TRACKED"
|
||||
f.SourcePort = 0
|
||||
f.DestinationPort = 0
|
||||
f.ReplySourcePort = 0
|
||||
f.ReplyDestinationPort = 0
|
||||
}
|
||||
if k == 11 {
|
||||
f.Source = "192.168.0.12"
|
||||
f.Destination = "1.1.1.1"
|
||||
f.ReplySource = "1.1.1.1"
|
||||
f.ReplyDestination = "192.168.0.12"
|
||||
}
|
||||
flows = append(flows, f)
|
||||
}
|
||||
s.SaveNetwork(a.ID, NetworkSnapshot{At: time.Now().Unix(), Flows: flows, FirewallAt: time.Now().Unix(), Firewall: []FirewallCounter{{Name: "DEMO ip/crowdsec/drop #3", Packets: 12345, Bytes: 654321}}})
|
||||
|
||||
@@ -9,18 +9,27 @@ import (
|
||||
)
|
||||
|
||||
type TrafficSettings struct {
|
||||
NetworkEnabled bool `json:"networkEnabled"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AgentID string `json:"agentId"`
|
||||
ServerCountry string `json:"serverCountry"`
|
||||
MonitoringRules []MonitoringRule `json:"monitoringRules"`
|
||||
NetworkEnabled bool `json:"networkEnabled"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AgentID string `json:"agentId"`
|
||||
ServerCountry string `json:"serverCountry"`
|
||||
}
|
||||
|
||||
func (s *Store) TrafficSettings() TrafficSettings {
|
||||
var c TrafficSettings
|
||||
s.loadJSONSetting("traffic", &c)
|
||||
if c.MonitoringRules == nil {
|
||||
c.MonitoringRules = []MonitoringRule{{IP: "192.168.0.10"}}
|
||||
}
|
||||
return c
|
||||
}
|
||||
func (s *Store) SaveTrafficSettings(c TrafficSettings) (TrafficSettings, error) {
|
||||
rules, err := validateMonitoringRules(c.MonitoringRules)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.MonitoringRules = rules
|
||||
c.AgentID = strings.TrimSpace(c.AgentID)
|
||||
c.ServerCountry = strings.ToUpper(strings.TrimSpace(c.ServerCountry))
|
||||
if c.Enabled {
|
||||
@@ -54,7 +63,7 @@ func trafficSettingsRoute(mux *http.ServeMux, s *Store) {
|
||||
writeJSON(w, s.TrafficSettings(), nil)
|
||||
case "PUT":
|
||||
var c TrafficSettings
|
||||
if json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&c) != nil {
|
||||
if json.NewDecoder(io.LimitReader(r.Body, 32<<10)).Decode(&c) != nil {
|
||||
http.Error(w, "Некорректные настройки", 400)
|
||||
return
|
||||
}
|
||||
|
||||
11
web.go
11
web.go
@@ -887,7 +887,10 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
||||
<label class="field"><span>Агент-источник</span><select id="trafficAgent"><option value="">Выберите агент</option></select></label>
|
||||
<label class="field"><span>Страна сервера на карте (необязательно)</span><select id="trafficCountry"><option value="">Не задана</option></select></label>
|
||||
<p class="settings-note">Сбор выполняется только выбранным агентом: access-логи /opt/npm/data/logs и локальный cscli. Изменить путь можно через DASHBOARD_NPM_LOG_DIR в окружении службы агента. При выключении сбор прекращается и ссылка на страницу скрывается. События карты не создают общих алертов и писем. История: до 24 часов и 200 000 событий.</p>
|
||||
<label class="field"><span>Правила мониторинга: IP | домен | префикс пути</span><textarea id="monitoringRules" rows="4" placeholder="192.168.0.10" style="width:100%;font:12px monospace;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:10px"></textarea></label>
|
||||
<p class="settings-note">Одно правило на строку, до 32. Домен и путь необязательны; внутри правила условия объединяются. IP без уточнений помечает HTTP-проверки и сетевые потоки этого инициатора. Домен/путь применяются только к HTTP. Скрытие — фильтр просмотра: история сохраняется, срабатывания и решения CrowdSec не скрываются. Пример: 192.168.0.10 | example.org | /health</p>
|
||||
<button class="save-settings" id="saveTrafficSettings">Сохранить</button><span id="trafficSettingsStatus" role="status"></span>
|
||||
<div class="settings-group"><button class="theme-option" id="installCityDatabase">Установить базу городов (~58 МБ)</button><div class="settings-note" id="cityDatabaseStatus">DB-IP City Lite: локальная приблизительная геолокация, без отправки IP внешним сервисам. Устанавливается на сервер Dashboard.</div></div>
|
||||
</div></div>
|
||||
<div class="settings-body settings-panel" data-settings-panel="updates" hidden>
|
||||
<div class="detail-grid">
|
||||
@@ -1032,14 +1035,16 @@ document.querySelectorAll('[data-settings-tab]').forEach(tab=>tab.onclick=()=>{d
|
||||
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('networkEnabled').checked=!!settings.networkEnabled;el('trafficAgent').innerHTML='<option value="">Выберите агент</option>'+(agents||[]).map(a=>'<option value="'+safe(a.id)+'">'+safe(a.name)+' · VMID '+a.vmid+' · '+safe(a.version)+(a.online?' · онлайн':' · офлайн')+'</option>').join('');el('trafficAgent').value=settings.agentId;
|
||||
el('monitoringRules').value=(settings.monitoringRules||[]).map(r=>[r.ip,r.domain||'',r.pathPrefix||''].join(' | ').replace(/(?: \| )+$/,'')).join('\n');el('trafficEnabled').checked=settings.enabled;el('networkEnabled').checked=!!settings.networkEnabled;el('trafficAgent').innerHTML='<option value="">Выберите агент</option>'+(agents||[]).map(a=>'<option value="'+safe(a.id)+'">'+safe(a.name)+' · VMID '+a.vmid+' · '+safe(a.version)+(a.online?' · онлайн':' · офлайн')+'</option>').join('');el('trafficAgent').value=settings.agentId;
|
||||
el('trafficCountry').innerHTML='<option value="">Не задана</option>'+world.filter(c=>c.code&&c.code!=='-99').sort((a,b)=>a.name.localeCompare(b.name,'ru')).map(c=>'<option value="'+safe(c.code)+'">'+safe(c.name)+'</option>').join('');el('trafficCountry').value=settings.serverCountry;el('trafficSettingsStatus').textContent='';
|
||||
}catch(e){el('trafficSettingsStatus').textContent='Не удалось загрузить настройки';}}
|
||||
el('trafficSettingsTab').addEventListener('click',loadTrafficSettings);
|
||||
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,networkEnabled:el('networkEnabled').checked,agentId:el('trafficAgent').value,serverCountry:el('trafficCountry').value})});if(!response.ok)throw Error(await response.text());el('trafficLink').hidden=!el('trafficEnabled').checked;el('trafficSettingsStatus').textContent='Сохранено. Агент применит настройки при следующем отчёте.';}catch(e){el('trafficSettingsStatus').textContent=e.message||'Ошибка сохранения';}finally{button.disabled=false;}};
|
||||
fetch('/api/settings/traffic/city-database').then(r=>r.ok?r.json():null).then(d=>{if(!d)return;const b=el('installCityDatabase');if(d.ready){b.disabled=true;b.textContent='База городов установлена'}if(d.message)el('cityDatabaseStatus').textContent=d.message}).catch(()=>{});
|
||||
el('installCityDatabase').onclick=async()=>{const b=el('installCityDatabase'),status=el('cityDatabaseStatus');b.disabled=true;try{let r=await fetch('/api/settings/traffic/city-database',{method:'POST'});if(!r.ok)throw Error('Не удалось начать загрузку');let result=await r.json();status.textContent=result.message||'База уже установлена';while(result.running){await new Promise(resolve=>setTimeout(resolve,2000));r=await fetch('/api/settings/traffic/city-database');if(!r.ok)throw Error('Не удалось прочитать состояние');result=await r.json();status.textContent=result.message}b.textContent=result.ready?'База городов установлена':'Повторить установку базы';b.disabled=result.ready}catch(e){status.textContent=e.message;b.disabled=false}};
|
||||
el('saveTrafficSettings').onclick=async()=>{const button=el('saveTrafficSettings');button.disabled=true;try{const response=await fetch('/api/settings/traffic',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({monitoringRules:el('monitoringRules').value.split('\n').filter(line=>line.trim()).map(line=>{const parts=line.split('|').map(v=>v.trim());if(parts.length>3)throw Error('В правиле допускается три поля: IP | домен | путь');return {ip:parts[0],domain:parts[1]||'',pathPrefix:parts[2]||''}}),enabled:el('trafficEnabled').checked,networkEnabled:el('networkEnabled').checked,agentId:el('trafficAgent').value,serverCountry:el('trafficCountry').value})});if(!response.ok)throw Error(await response.text());el('trafficLink').hidden=!el('trafficEnabled').checked;el('trafficSettingsStatus').textContent='Сохранено. Фильтры применяются сразу, настройки сбора — при следующем отчёте агента.';}catch(e){el('trafficSettingsStatus').textContent=e.message||'Ошибка сохранения';}finally{button.disabled=false;}};
|
||||
if(location.hash==='#traffic-settings'){el('settingsDialog').showModal();el('trafficSettingsTab').click();}
|
||||
el('agentServerURL').value=location.origin;
|
||||
el('createAgentToken').onclick=async()=>{const button=el('createAgentToken'),server=el('agentServerURL').value.trim().replace(/\/$/,''),vmid=Number(el('agentVMID').value)||0;if(!/^https?:\/\//.test(server)){alert('Укажите полный адрес Dashboard, доступный из VM/LXC.');return}button.disabled=true;button.textContent='Создаём токен…';try{const response=await fetch('/api/agents/enrollment-token',{method:'POST'});if(!response.ok)throw new Error(await response.text());const value=await response.json(),quote=v=>"'"+String(v).replaceAll("'","'\"'\"'")+"'";el('agentInstallCommand').textContent='curl -fsSL https://git.myown.center/maxim/ProxmoxDash/raw/branch/main/scripts/install-agent.sh | DASHBOARD_URL='+quote(server)+' ENROLL_TOKEN='+quote(value.token)+' VMID='+quote(vmid)+' sh';el('agentInstallBlock').hidden=false}catch(e){alert('Не удалось создать токен: '+e.message)}finally{button.disabled=false;button.textContent='Создать команду установки или обновления'}};
|
||||
el('createAgentToken').onclick=async()=>{const button=el('createAgentToken'),server=el('agentServerURL').value.trim().replace(/\/$/,''),vmid=Number(el('agentVMID').value)||0;if(!/^https?:\/\//.test(server)){alert('Укажите полный адрес Dashboard, доступный из VM/LXC.');return}button.disabled=true;button.textContent='Создаём токен…';try{const response=await fetch('/api/agents/enrollment-token',{method:'POST'});if(!response.ok)throw new Error(await response.text());const value=await response.json(),quote=v=>"'"+String(v).replaceAll("'","'\"'\"'")+"'";el('agentInstallCommand').textContent='(command -v curl >/dev/null 2>&1 || apk add --no-cache curl ca-certificates) && curl -fsSL https://git.myown.center/maxim/ProxmoxDash/raw/branch/main/scripts/install-agent.sh | DASHBOARD_URL='+quote(server)+' ENROLL_TOKEN='+quote(value.token)+' VMID='+quote(vmid)+' sh';el('agentInstallBlock').hidden=false}catch(e){alert('Не удалось создать токен: '+e.message)}finally{button.disabled=false;button.textContent='Создать команду установки или обновления'}};
|
||||
el('agentSettingsList').onclick=async event=>{const detail=event.target.closest('[data-agent-detail]');if(detail){const agent=currentAgents.find(v=>v.id===detail.dataset.agentDetail);if(agent)openAgentDetail(agent);return}const button=event.target.closest('[data-agent-delete]');if(!button||!confirm('Удалить агент? Для повторного подключения потребуется новый токен.'))return;button.disabled=true;const response=await fetch('/api/agents?id='+encodeURIComponent(button.dataset.agentDelete),{method:'DELETE'});if(response.ok)button.closest('.disk-item').remove();else{button.disabled=false;alert(await response.text())}};
|
||||
async function loadAgentActions(){const response=await fetch('/api/agent-actions');if(!response.ok)return;const items=await response.json()||[];el('agentActionLog').innerHTML=items.length?items.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.agentName)+' · '+safe(v.action)+'</div><div class="disk-meta">'+safe(v.target||'без цели')+' · '+new Date(v.requestedAt*1000).toLocaleString('ru-RU')+'</div></div><span class="badge '+(v.status==='failed'?'bad':v.status==='completed'?'':'warn')+'">'+safe(v.status)+'</span></div>'+(v.result?'<div class="settings-note">'+safe(v.result)+'</div>':'')+'</article>').join(''):'<span class="empty">Действий пока не было.</span>'}loadAgentActions().catch(()=>{});
|
||||
async function loadThresholds(){const response=await fetch('/api/settings/alerts');if(!response.ok)return;alertSettings=await response.json();document.querySelectorAll('[data-threshold]').forEach(input=>input.value=alertSettings[input.dataset.threshold]??'')}
|
||||
|
||||
Reference in New Issue
Block a user