From 6a940772f0a4c3b4ea59cc61b652726070619c84 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 9 Oct 2026 22:59:32 +0300 Subject: [PATCH] Add network activity map overlay and configurable monitoring filters --- README.md | 25 +++++++ assets/network.html | 19 ++--- assets/traffic.html | 37 ++++++---- network_flows.go | 38 ++++------ traffic.go | 46 ++++++++---- traffic_monitoring.go | 76 ++++++++++++++++++++ traffic_monitoring_test.go | 140 +++++++++++++++++++++++++++++++++++++ traffic_network_map.go | 107 ++++++++++++++++++++++++++++ traffic_preview_test.go | 16 ++++- traffic_settings.go | 19 +++-- web.go | 6 +- 11 files changed, 462 insertions(+), 67 deletions(-) create mode 100644 traffic_monitoring.go create mode 100644 traffic_monitoring_test.go create mode 100644 traffic_network_map.go 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
PROXMOX / NETWORK OBSERVATORY

Сетевая активность.

ОЖИДАНИЕПодключение к агенту…
TCP · ESTABLISHED—
TCP · SYN_SENT / SYN_RECV—
UDP · отслеживаемые потоки—
ICMP / ICMPv6 · записи—
-
+

Текущая таблица conntrack

—
ИнициаторНазначение / NATПротоколСостояниеБайты → / ←
Ожидание первого снимка…
—
@@ -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.

-
+
Уникальные IP · 24 ч—
HTTP-запросы · 24 ч—
Срабатывания · 24 ч—
Решения CrowdSec · 24 ч—
-
HTTPСрабатываниеРешение
Без геолокации: —
+
HTTPСрабатываниеРешение
Без геолокации: —

Ритм запросов / 60 мин

— req/s

География

ТОП 7 / события

Источники

ТОП 7 / IP

Подробности

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 событий.

+ +

Одно правило на строку, до 32. Домен и путь необязательны; внутри правила условия объединяются. IP без уточнений помечает HTTP-проверки и сетевые потоки этого инициатора. Домен/путь применяются только к HTTP. Скрытие — фильтр просмотра: история сохраняется, срабатывания и решения CrowdSec не скрываются. Пример: 192.168.0.10 | example.org | /health