4 Commits

11 changed files with 880 additions and 31 deletions

View File

@@ -631,3 +631,63 @@ 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, а не фактическому местоположению человека.

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -12,19 +12,22 @@ import (
)
type TrafficEvent struct {
Monitoring bool `json:"monitoring"`
ID string `json:"id"`
Sequence int64 `json:"sequence,omitempty"`
Time int64 `json:"time"`
Kind string `json:"kind"`
IP string `json:"ip"`
Domain string `json:"domain,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Status int `json:"status,omitempty"`
Reason string `json:"reason,omitempty"`
Country string `json:"country,omitempty"`
Agent string `json:"agent,omitempty"`
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 {
@@ -51,7 +54,21 @@ 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"`
@@ -160,8 +177,11 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring .
network := makeNetworkMap(snapshot, external)
v.Network = &network
}
where := ` WHERE ts>=? AND agent=?`
args := []any{now - 86400, v.Settings.AgentID}
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
@@ -193,6 +213,11 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring .
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()
@@ -222,6 +247,32 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring .
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
@@ -263,6 +314,8 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring .
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 {

216
traffic_city.go Normal file
View 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
View 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
View File

@@ -0,0 +1,44 @@
package main
import (
"fmt"
"testing"
"time"
)
func TestTrafficCountryDetailsUseHistoryAndFilters(t *testing.T) {
s, a := trafficFixture(t)
if _, err := s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, MonitoringRules: []MonitoringRule{{IP: "8.8.8.8"}}}); err != nil {
t.Fatal(err)
}
events := []TrafficEvent{}
for i := 0; i < 120; i++ {
events = append(events, TrafficEvent{ID: fmt.Sprint("http-", i), Time: time.Now().Unix(), Kind: "request", IP: "8.8.8.8", Domain: "example.test", Path: "/", Status: 200})
}
events = append(events, TrafficEvent{ID: "alert", Time: time.Now().Unix(), Kind: "alert", IP: "8.8.8.8", Domain: "example.test", Reason: "test"})
if err := s.SaveTraffic(a.ID, TrafficBatch{Events: events}); err != nil {
t.Fatal(err)
}
v, err := s.TrafficView("", "", false)
if err != nil {
t.Fatal(err)
}
if len(v.Events) != 100 || len(v.CountryDetails) != 1 {
t.Fatalf("events=%d countries=%+v", len(v.Events), v.CountryDetails)
}
d := v.CountryDetails[0]
if d.Requests != 120 || d.UniqueIPs != 1 || d.Alerts != 1 || d.LastSeen == 0 {
t.Fatalf("%+v", d)
}
v, err = s.TrafficView("example.test", "", false, true)
if err != nil {
t.Fatal(err)
}
if len(v.CountryDetails) != 1 || v.CountryDetails[0].Requests != 0 || v.CountryDetails[0].Alerts != 1 {
t.Fatalf("filter: %+v", v.CountryDetails)
}
v, err = s.TrafficView("other.test", "", false)
if err != nil || len(v.CountryDetails) != 0 {
t.Fatalf("domain: %+v %v", v.CountryDetails, err)
}
}

144
traffic_history.go Normal file
View 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
View 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
View 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
}

3
web.go
View File

@@ -890,6 +890,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<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">
@@ -1038,6 +1039,8 @@ async function loadTrafficSettings(){try{
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);
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;