diff --git a/README.md b/README.md
index a1055ba..a290914 100644
--- a/README.md
+++ b/README.md
@@ -561,3 +561,28 @@ 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, никогда не скрываются этим фильтром.
+Сетевые потоки по-прежнему хранятся только последним снимком, без истории.
diff --git a/assets/network.html b/assets/network.html
index e25ccec..5863915 100644
--- a/assets/network.html
+++ b/assets/network.html
@@ -1,13 +1,13 @@
Сетевая активность · Proxmox Dashboard
ОЖИДАНИЕ Подключение к агенту… Диагностика ⓘ
TCP · ESTABLISHED —
TCP · SYN_SENT / SYN_RECV —
UDP · отслеживаемые потоки —
ICMP / ICMPv6 · записи —
-Все протоколы TCP UDP ICMP / ICMPv6 Все порты Внешние IP
+Все протоколы TCP UDP ICMP / ICMPv6 Все порты Скрыть мониторинг Внешние IP
Потоки Статистика
Текущая таблица conntrack — Инициатор Назначение / NAT Протокол Состояние Байты → / ←
CrowdSec · firewall ⓘ —
Ожидание счётчиков
@@ -16,6 +16,7 @@
diff --git a/assets/traffic.html b/assets/traffic.html
index 31fc10b..d791236 100644
--- a/assets/traffic.html
+++ b/assets/traffic.html
@@ -1,64 +1,75 @@
Карта трафика · Proxmox Dashboard
◎ PROXMOX / LIVE OBSERVATORY
Traffic space. Подключение… ↖ Dashboard ⛶
-
+
Решения CrowdSec · 24 ч —
◇
Карта События Статистика
-Глобальная активность Источник… Подключение ⓘ
+Глобальная активность Источник… Подключение ⓘ
Ритм запросов / 60 мин — req/s
Подробности ×
diff --git a/network_flows.go b/network_flows.go
index 16d3532..ac41f42 100644
--- a/network_flows.go
+++ b/network_flows.go
@@ -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) {
diff --git a/traffic.go b/traffic.go
index 5440a13..84d9894 100644
--- a/traffic.go
+++ b/traffic.go
@@ -12,18 +12,19 @@ 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"`
+ 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,6 +52,7 @@ type TrafficCount struct {
}
type TrafficView struct {
+ Network *NetworkMapView `json:"network,omitempty"`
Events []TrafficEvent `json:"events"`
Sources []TrafficSource `json:"sources"`
Countries []TrafficCount `json:"countries"`
@@ -104,6 +106,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 +147,26 @@ 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()
+ 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
+ }
where := ` WHERE ts>=? AND agent=?`
args := []any{now - 86400, v.Settings.AgentID}
+ if hide {
+ clause, params := monitoringSQL(v.Settings.MonitoringRules)
+ where += clause
+ args = append(args, params...)
+ }
if external {
where += ` AND country!='LAN'`
}
@@ -175,6 +192,7 @@ func (s *Store) TrafficView(domain, kind string, external bool) (TrafficView, er
rows.Close()
return v, err
}
+ e.Monitoring = isMonitoringRequest(e, v.Settings.MonitoringRules)
v.Events = append(v.Events, e)
}
err = rows.Err()
@@ -263,7 +281,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) {
diff --git a/traffic_monitoring.go b/traffic_monitoring.go
new file mode 100644
index 0000000..1c8d393
--- /dev/null
+++ b/traffic_monitoring.go
@@ -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
+}
diff --git a/traffic_monitoring_test.go b/traffic_monitoring_test.go
new file mode 100644
index 0000000..58be431
--- /dev/null
+++ b/traffic_monitoring_test.go
@@ -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")
+ }
+}
diff --git a/traffic_network_map.go b/traffic_network_map.go
new file mode 100644
index 0000000..1ae5e9c
--- /dev/null
+++ b/traffic_network_map.go
@@ -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
+}
diff --git a/traffic_preview_test.go b/traffic_preview_test.go
index 995745a..0230d00 100644
--- a/traffic_preview_test.go
+++ b/traffic_preview_test.go
@@ -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}}})
diff --git a/traffic_settings.go b/traffic_settings.go
index 4a846d7..d3ccc51 100644
--- a/traffic_settings.go
+++ b/traffic_settings.go
@@ -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
}
diff --git a/web.go b/web.go
index e3e3dca..e0052b4 100644
--- a/web.go
+++ b/web.go
@@ -887,6 +887,8 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Агент-источник Выберите агент
Страна сервера на карте (необязательно) Не задана
Сбор выполняется только выбранным агентом: access-логи /opt/npm/data/logs и локальный cscli. Изменить путь можно через DASHBOARD_NPM_LOG_DIR в окружении службы агента. При выключении сбор прекращается и ссылка на страницу скрывается. События карты не создают общих алертов и писем. История: до 24 часов и 200 000 событий.
+ Правила мониторинга: IP | домен | префикс пути
+ Одно правило на строку, до 32. Домен и путь необязательны; внутри правила условия объединяются. IP без уточнений помечает HTTP-проверки и сетевые потоки этого инициатора. Домен/путь применяются только к HTTP. Скрытие — фильтр просмотра: история сохраняется, срабатывания и решения CrowdSec не скрываются. Пример: 192.168.0.10 | example.org | /health
Сохранить
@@ -1032,11 +1034,11 @@ 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='Выберите агент '+(agents||[]).map(a=>''+safe(a.name)+' · VMID '+a.vmid+' · '+safe(a.version)+(a.online?' · онлайн':' · офлайн')+' ').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='Выберите агент '+(agents||[]).map(a=>''+safe(a.name)+' · VMID '+a.vmid+' · '+safe(a.version)+(a.online?' · онлайн':' · офлайн')+' ').join('');el('trafficAgent').value=settings.agentId;
el('trafficCountry').innerHTML='Не задана '+world.filter(c=>c.code&&c.code!=='-99').sort((a,b)=>a.name.localeCompare(b.name,'ru')).map(c=>''+safe(c.name)+' ').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;}};
+ 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='Создать команду установки или обновления'}};