diff --git a/README.md b/README.md
index cbac19a..44d3a4d 100644
--- a/README.md
+++ b/README.md
@@ -507,3 +507,50 @@ GeoIP работает локально: DB-IP Country Lite за октябрь
DASHBOARD_TRAFFIC_PREVIEW=1 go test -run '^TestTrafficPreview$' -timeout 30m
# http://127.0.0.1:19105/traffic
```
+
+## Сетевая активность TCP/UDP/ICMP (v0.21.0)
+
+В **Настройки → Карта трафика** включите дополнительный сбор TCP/UDP/ICMP.
+Он выполняется только выбранным агентом v0.21.0+; на странице карты появится
+кнопка **TCP / UDP**, открывающая `/network`. Основной выключатель карты
+отключает оба сборщика. Сетевой режим по умолчанию выключен и не создаёт
+общих алертов/писем.
+
+Для Debian/Ubuntu на хосте агента (не внутри контейнера NPM):
+
+```sh
+sudo apt-get update
+sudo apt-get install -y conntrack nftables
+```
+
+Агенту нужны root/CAP_NET_ADMIN в сетевом namespace хоста. Установщик агента
+запускает службу от root. Чтение: `/proc/net/nf_conntrack`, если доступен,
+иначе `conntrack -L -f ipv4/ipv6 -o extended`. Ничего устанавливать на хост
+самого Dashboard не требуется. Firewall, NAT и sysctl сборщик не меняет.
+
+Страница показывает состояния TCP, UDP/ICMP-потоки, обе пары адресов до/после
+NAT (в том числе Docker), локальную GeoIP, топ инициаторов и портов, фильтры,
+поиск и детали потока. Список листается по страницам; переход на следующую
+фиксирует снимок, «К свежим» возвращает live. Для телефона — вкладки.
+
+Это **снимки таблицы примерно каждые 5 секунд**, не полный журнал соединений:
+короткие потоки между снимками могут быть пропущены. До 5 000 записей на
+снимок, 8 MiB вывода команды и таймаут 4 секунды; ограничения/ошибки видны в
+диагностике. Сохраняется только последний снимок каждого агента. Инициатор
+не обязательно внешний клиент: NPM → upstream — отдельный поток. Порт не
+доказывает протокол приложения; UDP ASSURED не означает TCP handshake.
+
+Байты/пакеты доступны только при включённом учёте conntrack для конкретных
+потоков. Это накопленные значения за жизнь потока, а не трафик за 24 часа.
+При отсутствии учёта интерфейс показывает «нет учёта». При необходимости
+администратор может включить `net.netfilter.nf_conntrack_acct=1`; изменение
+применяется к новым потокам и автоматически не выполняется.
+
+Firewall читается раз в 30 секунд командой `nft -j list ruleset`. Поддержаны
+анонимные счётчики непосредственно перед финальным `drop/reject` в таблице
+или цепочке с `crowdsec` в имени. Остальные счётчики не выдаются за блокировки.
+Это сумма пакетов/байтов выбранных правил с момента их создания, независимо
+от фильтров потоков; при пересоздании правил она может уменьшиться.
+Для iptables/ipset-only, именованных счётчиков и правил без счётчиков выводится
+«недоступно», а не ноль. Отброшенный пакет до подтверждения conntrack может
+отсутствовать в таблице потоков. Содержимое пакетов не перехватывается.
diff --git a/agent.go b/agent.go
index de38f9f..edc57ee 100644
--- a/agent.go
+++ b/agent.go
@@ -145,6 +145,7 @@ type AgentUpdateManifest struct {
type agentReportResponse struct {
Accepted bool `json:"accepted"`
+ NetworkEnabled bool `json:"networkEnabled"`
TrafficEnabled bool `json:"trafficEnabled"`
Update *AgentUpdateManifest `json:"update,omitempty"`
Commands []AgentCommand `json:"commands,omitempty"`
@@ -340,6 +341,14 @@ func runGuestAgent(opts agentOptions) error {
return err
}
}
+ var networkStop func()
+ stopNetwork := func() {
+ if networkStop != nil {
+ networkStop()
+ networkStop = nil
+ }
+ }
+ defer stopNetwork()
var trafficStop func()
stopTraffic := func() {
if trafficStop != nil {
@@ -367,6 +376,7 @@ func runGuestAgent(opts agentOptions) error {
fresh, enrollmentErr := enrollRemote(config, opts.EnrollmentToken)
if enrollmentErr == nil {
stopTraffic()
+ stopNetwork()
config = fresh
if saveErr := saveAgentConfig(opts.ConfigPath, config); saveErr != nil {
return saveErr
@@ -383,6 +393,11 @@ func runGuestAgent(opts agentOptions) error {
if response.StatusCode == http.StatusOK {
var result agentReportResponse
if json.Unmarshal(responseData, &result) == nil {
+ if !result.NetworkEnabled {
+ stopNetwork()
+ } else if networkStop == nil {
+ networkStop = startAgentNetwork(config)
+ }
if !result.TrafficEnabled {
stopTraffic()
} else if trafficStop == nil {
diff --git a/agent_network.go b/agent_network.go
new file mode 100644
index 0000000..e11fc4d
--- /dev/null
+++ b/agent_network.go
@@ -0,0 +1,266 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Bounded command output and deadline keep large conntrack tables from exhausting the agent.
+type networkBuffer struct {
+ bytes.Buffer
+ limit int
+}
+
+func (b *networkBuffer) Write(p []byte) (int, error) {
+ if len(p) > b.limit-b.Len() {
+ return 0, errors.New("output limit exceeded")
+ }
+ return b.Buffer.Write(p)
+}
+func networkCommand(ctx context.Context, name string, args ...string) ([]byte, error) {
+ ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, name, args...)
+ out := &networkBuffer{limit: 8 << 20}
+ stderr := &networkBuffer{limit: 4096}
+ cmd.Stdout = out
+ cmd.Stderr = stderr
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("%s: %v %s", name, err, trafficText(stderr.String(), 240))
+ }
+ return out.Bytes(), nil
+}
+func parseNetworkFlow(line string) (NetworkFlow, bool) {
+ var f NetworkFlow
+ fields := strings.Fields(line)
+ protoIndex := -1
+ for i, v := range fields {
+ if v == "tcp" || v == "udp" || v == "icmp" || v == "icmpv6" {
+ f.Protocol = v
+ protoIndex = i
+ break
+ }
+ }
+ if protoIndex < 0 {
+ return f, false
+ }
+ values := map[string][]string{}
+ for _, v := range fields[protoIndex+1:] {
+ if k, val, ok := strings.Cut(v, "="); ok {
+ values[k] = append(values[k], val)
+ }
+ }
+ get := func(k string, i int) string {
+ if len(values[k]) > i {
+ return values[k][i]
+ }
+ return ""
+ }
+ integer := func(k string, i int) int { n, _ := strconv.Atoi(get(k, i)); return n }
+ number := func(k string, i int) uint64 { n, _ := strconv.ParseUint(get(k, i), 10, 64); return n }
+ f.Source = get("src", 0)
+ f.Destination = get("dst", 0)
+ f.ReplySource = get("src", 1)
+ f.ReplyDestination = get("dst", 1)
+ if net.ParseIP(f.Source) == nil || net.ParseIP(f.Destination) == nil || net.ParseIP(f.ReplySource) == nil || net.ParseIP(f.ReplyDestination) == nil {
+ return f, false
+ }
+ f.SourcePort = integer("sport", 0)
+ f.DestinationPort = integer("dport", 0)
+ f.ReplySourcePort = integer("sport", 1)
+ f.ReplyDestinationPort = integer("dport", 1)
+ f.State = "TRACKED"
+ if f.Protocol == "tcp" && len(fields) > protoIndex+3 && !strings.Contains(fields[protoIndex+3], "=") {
+ f.State = fields[protoIndex+3]
+ }
+ if strings.Contains(line, "[UNREPLIED]") {
+ f.State = "UNREPLIED"
+ if f.Protocol == "tcp" && len(fields) > protoIndex+3 {
+ f.State = fields[protoIndex+3]
+ }
+ }
+ if f.Protocol == "udp" && strings.Contains(line, "[ASSURED]") {
+ f.State = "ASSURED"
+ }
+ f.Accounting = len(values["bytes"]) == 2 && len(values["packets"]) == 2
+ f.Bytes = number("bytes", 0)
+ f.ReplyBytes = number("bytes", 1)
+ f.Packets = number("packets", 0)
+ f.ReplyPackets = number("packets", 1)
+ f.Zone = get("zone", 0)
+ return f, true
+}
+func appendNetworkFlows(snapshot *NetworkSnapshot, raw []byte) {
+ for _, line := range strings.Split(string(raw), "\n") {
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ f, ok := parseNetworkFlow(line)
+ if !ok {
+ if strings.Contains(line, "src=") {
+ snapshot.ParseErrors++
+ }
+ continue
+ }
+ if len(snapshot.Flows) >= 5000 {
+ snapshot.Limited = true
+ continue
+ }
+ snapshot.Flows = append(snapshot.Flows, f)
+ }
+}
+
+// Only counters immediately before a terminal drop/reject in a CrowdSec table/chain
+// are reported as blocked packets. General chain and set counters are not summed.
+func parseFirewallCounters(raw []byte) ([]FirewallCounter, error) {
+ var doc struct {
+ NFTables []struct {
+ Rule *struct {
+ Family string `json:"family"`
+ Table string `json:"table"`
+ Chain string `json:"chain"`
+ Handle int `json:"handle"`
+ Expr []map[string]json.RawMessage `json:"expr"`
+ } `json:"rule"`
+ } `json:"nftables"`
+ }
+ if err := json.Unmarshal(raw, &doc); err != nil {
+ return nil, err
+ }
+ result := []FirewallCounter{}
+ for _, item := range doc.NFTables {
+ r := item.Rule
+ if r == nil || !strings.Contains(strings.ToLower(r.Table+" "+r.Chain), "crowdsec") {
+ continue
+ }
+ if len(r.Expr) < 2 {
+ continue
+ }
+ last := r.Expr[len(r.Expr)-1]
+ _, drop := last["drop"]
+ _, reject := last["reject"]
+ if !drop && !reject {
+ continue
+ }
+ counter, ok := r.Expr[len(r.Expr)-2]["counter"]
+ if !ok {
+ continue
+ }
+ var c struct {
+ Packets uint64 `json:"packets"`
+ Bytes uint64 `json:"bytes"`
+ }
+ if json.Unmarshal(counter, &c) != nil || len(counter) == 0 || counter[0] != '{' {
+ continue
+ }
+ result = append(result, FirewallCounter{Name: fmt.Sprintf("%s/%s/%s #%d", r.Family, r.Table, r.Chain, r.Handle), Packets: c.Packets, Bytes: c.Bytes})
+ if len(result) == 128 {
+ break
+ }
+ }
+ return result, nil
+}
+func collectNetwork(ctx context.Context) NetworkSnapshot {
+ s := NetworkSnapshot{At: time.Now().Unix(), Flows: []NetworkFlow{}}
+ if runtime.GOOS != "linux" {
+ s.Error = "Сбор conntrack поддерживается только на Linux"
+ return s
+ }
+ // Proc contains both address families when available. Modern kernels often expose only netlink.
+ for _, path := range []string{"/proc/net/nf_conntrack", "/proc/net/ip_conntrack"} {
+ f, err := os.Open(path)
+ if err != nil {
+ continue
+ }
+ raw, err := io.ReadAll(io.LimitReader(f, (8<<20)+1))
+ f.Close()
+ if err != nil {
+ s.Error = err.Error()
+ return s
+ }
+ if len(raw) > 8<<20 {
+ s.Error = "Таблица conntrack превышает лимит чтения 8 MiB"
+ return s
+ }
+ appendNetworkFlows(&s, raw)
+ return s
+ }
+ for _, family := range []string{"ipv4", "ipv6"} {
+ raw, err := networkCommand(ctx, "conntrack", "-L", "-f", family, "-o", "extended")
+ if err != nil {
+ s.Error += "Conntrack " + family + ": нужен пакет conntrack и права root/CAP_NET_ADMIN. " + err.Error() + "; "
+ continue
+ }
+ appendNetworkFlows(&s, raw)
+ }
+ return s
+}
+func startAgentNetwork(config agentConfig) func() {
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ client := &http.Client{Timeout: 10 * time.Second}
+ ticker := time.NewTicker(5 * time.Second)
+ defer ticker.Stop()
+ var firewall []FirewallCounter
+ var firewallAt int64
+ var firewallErr string
+ lastFirewall := time.Time{}
+ for {
+ if ctx.Err() != nil {
+ return
+ }
+ snapshot := collectNetwork(ctx)
+ if time.Since(lastFirewall) >= 30*time.Second {
+ lastFirewall = time.Now()
+ raw, err := networkCommand(ctx, "nft", "-j", "list", "ruleset")
+ firewall = nil
+ firewallAt = 0
+ firewallErr = ""
+ if err == nil {
+ firewall, err = parseFirewallCounters(raw)
+ }
+ if err != nil {
+ firewallErr = "Счётчики nftables недоступны: " + err.Error()
+ } else if len(firewall) == 0 {
+ firewallErr = "В правилах CrowdSec нет поддерживаемых счётчиков перед drop/reject. Это не означает отсутствие блокировок."
+ } else {
+ firewallAt = time.Now().Unix()
+ }
+ }
+ snapshot.Firewall = firewall
+ snapshot.FirewallAt = firewallAt
+ snapshot.FirewallError = firewallErr
+ data, _ := json.Marshal(snapshot)
+ req, err := http.NewRequestWithContext(ctx, "POST", config.Server+"/api/agent/network", bytes.NewReader(data))
+ if err != nil {
+ return
+ }
+ req.Header.Set("Authorization", "Bearer "+config.ID+"."+config.Secret)
+ req.Header.Set("Content-Type", "application/json")
+ if res, err := client.Do(req); err == nil {
+ io.Copy(io.Discard, io.LimitReader(res.Body, 4096))
+ res.Body.Close()
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ }
+ }
+ }()
+ return func() { cancel(); <-done }
+}
diff --git a/assets/network.html b/assets/network.html
new file mode 100644
index 0000000..8d4e519
--- /dev/null
+++ b/assets/network.html
@@ -0,0 +1,39 @@
+
Сетевая активность · Proxmox Dashboard
+
+ОЖИДАНИЕ Подключение к агенту… Диагностика ⓘ
+TCP · ESTABLISHED —
TCP · SYN_SENT / SYN_RECV —
UDP · отслеживаемые потоки —
ICMP / ICMPv6 · записи —
+Все протоколы TCP UDP ICMP / ICMPv6 Все порты Внешние IP
+Потоки Статистика
+
Текущая таблица conntrack — Инициатор Назначение / NAT Протокол Состояние Байты → / ←
+
CrowdSec · firewall ⓘ —
Ожидание счётчиков
+
+
×
+
diff --git a/assets/traffic.html b/assets/traffic.html
index 74cf241..31fc10b 100644
--- a/assets/traffic.html
+++ b/assets/traffic.html
@@ -10,7 +10,7 @@
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
◎ PROXMOX / LIVE OBSERVATORY
Traffic space. Подключение… ↖ Dashboard ⛶
-
+
Решения CrowdSec · 24 ч —
◇
Карта События Статистика
Глобальная активность Источник… Подключение ⓘ
@@ -35,7 +35,7 @@ function drawMap(now){const canvas=el('world'),w=Math.max(1,Math.floor(canvas.cl
}
function ranking(id,items,label){el(id).innerHTML=items.length?items.slice(0,7).map((c,i)=>''+String(i+1).padStart(2,'0')+' '+safe(label(c.name))+''+num(c.count)+'
').join(''):'Пока нет событий
'}
function drawChart(){const c=el('chart'),w=c.clientWidth,h=Math.max(35,c.clientHeight),dpr=devicePixelRatio||1;c.width=w*dpr;c.height=h*dpr;const ctx=c.getContext('2d');ctx.scale(dpr,dpr);if(!view)return;const buckets=new Map(view.series.map(v=>[Number(v.name),v.count])),end=Math.floor(view.now/60)*60,values=Array.from({length:60},(_,i)=>buckets.get(end-(59-i)*60)||0),max=Math.max(1,...values);ctx.strokeStyle='#26314b';ctx.beginPath();ctx.moveTo(0,h-18);ctx.lineTo(w,h-18);ctx.stroke();ctx.fillStyle='#55c9ff';values.forEach((v,i)=>{const x=i*w/60,height=v/max*(h-32);ctx.fillRect(x,h-18-height,Math.max(1,w/60-3),height)});ctx.fillStyle='#8490ab';ctx.font='10px system-ui';ctx.fillText('−60 мин',0,h-2);ctx.textAlign='right';ctx.fillText('сейчас',w,h-2);ctx.fillText('макс. '+num(max)+'/мин',w,12)}
-function render(data){view=data;el('unique').textContent=num(data.uniqueIps);el('requests').textContent=num(data.requests);el('alerts').textContent=num(data.alerts);el('decisions').textContent=num(data.decisions);el('qps').textContent=Number(data.qps).toFixed(2);el('geoDate').textContent=data.geoDate;ranking('countries',data.countries,countryName);ranking('ips',data.ips,v=>v);
+function render(data){view=data;el('networkLink').hidden=!data.settings.networkEnabled;el('unique').textContent=num(data.uniqueIps);el('requests').textContent=num(data.requests);el('alerts').textContent=num(data.alerts);el('decisions').textContent=num(data.decisions);el('qps').textContent=Number(data.qps).toFixed(2);el('geoDate').textContent=data.geoDate;ranking('countries',data.countries,countryName);ranking('ips',data.ips,v=>v);
const src=data.sources[0],fresh=src&&data.now-src.lastSeen<20;el('live').textContent=paused?'ПАУЗА':fresh?'● В ЭФИРЕ':'ОЖИДАНИЕ АГЕНТА';el('live').classList.toggle('stale',!fresh||paused);el('sourceName').textContent=src?src.name:'Источник ещё не прислал данные';
const messages=[];if(!src)messages.push('Выбранный агент ещё не прислал данные. Нужен агент v0.20.0 или новее.');else{messages.push(src.name+' · '+src.status.files+' логов · сбор с '+new Date(src.status.startedAt*1000).toLocaleString('ru-RU'));if(!fresh)messages.push('Источник не обновляется; показана сохранённая история');if(src.status.logError)messages.push(src.status.logError);if(src.status.crowdsecError)messages.push('CrowdSec: '+src.status.crowdsecError);if(src.status.backlogBytes)messages.push('Ожидает чтения: '+num(src.status.backlogBytes)+' байт');if(src.status.crowdsecLimited)messages.push('CrowdSec: получены последние 50 срабатываний; более ранняя история может быть неполной');if(src.status.parseErrors)messages.push('Нераспознанных строк: '+src.status.parseErrors)}if(!data.geoReady)messages.push('База GeoIP недоступна');if(data.limited)messages.push('Достигнут лимит 200 000 событий: часть суточной истории удалена');statusMessages=messages;el('status').textContent=fresh?'● на связи':'● нет связи';el('sourceDetails').title=messages.join(' · ');
el('status').classList.toggle('error',!fresh||!!src?.status.logError||!!src?.status.crowdsecError||data.limited);
diff --git a/network_flows.go b/network_flows.go
new file mode 100644
index 0000000..16d3532
--- /dev/null
+++ b/network_flows.go
@@ -0,0 +1,170 @@
+package main
+
+import (
+ "database/sql"
+ _ "embed"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "time"
+)
+
+//go:embed assets/network.html
+var networkHTML string
+
+// Original and reply tuples describe one conntrack entry, not two connections.
+type NetworkFlow struct {
+ External bool `json:"external"`
+ Protocol string `json:"protocol"`
+ Source string `json:"source"`
+ Destination string `json:"destination"`
+ SourcePort int `json:"sourcePort"`
+ DestinationPort int `json:"destinationPort"`
+ ReplySource string `json:"replySource"`
+ ReplyDestination string `json:"replyDestination"`
+ ReplySourcePort int `json:"replySourcePort"`
+ ReplyDestinationPort int `json:"replyDestinationPort"`
+ State string `json:"state"`
+ Country string `json:"country"`
+ DestinationCountry string `json:"destinationCountry"`
+ Packets uint64 `json:"packets"`
+ ReplyPackets uint64 `json:"replyPackets"`
+ Bytes uint64 `json:"bytes"`
+ ReplyBytes uint64 `json:"replyBytes"`
+ Accounting bool `json:"accounting"`
+ Zone string `json:"zone,omitempty"`
+}
+type FirewallCounter struct {
+ Name string `json:"name"`
+ Packets uint64 `json:"packets"`
+ 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"`
+}
+
+func networkExternalIP(value string) bool {
+ ip := net.ParseIP(value)
+ if ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
+ return false
+ }
+ if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
+ return false
+ }
+ return true
+}
+func (s *Store) SaveNetwork(agent string, snapshot NetworkSnapshot) error {
+ if len(snapshot.Flows) > 5000 || len(snapshot.Firewall) > 128 {
+ return errors.New("network snapshot too large")
+ }
+ if snapshot.At < time.Now().Unix()-120 || snapshot.At > time.Now().Unix()+60 {
+ return errors.New("invalid snapshot time")
+ }
+ for i := range snapshot.Flows {
+ f := &snapshot.Flows[i]
+ if net.ParseIP(f.Source) == nil || net.ParseIP(f.Destination) == nil || net.ParseIP(f.ReplySource) == nil || net.ParseIP(f.ReplyDestination) == nil {
+ return errors.New("invalid network address")
+ }
+ for _, p := range []int{f.SourcePort, f.DestinationPort, f.ReplySourcePort, f.ReplyDestinationPort} {
+ if p < 0 || p > 65535 {
+ return errors.New("invalid network port")
+ }
+ }
+ if f.Protocol != "tcp" && f.Protocol != "udp" && f.Protocol != "icmp" && f.Protocol != "icmpv6" {
+ return errors.New("invalid protocol")
+ }
+ f.State = trafficText(f.State, 32)
+ f.Zone = trafficText(f.Zone, 16)
+ f.External = networkExternalIP(f.Source) || networkExternalIP(f.Destination)
+ f.Country = lookupTrafficCountry(f.Source)
+ f.DestinationCountry = lookupTrafficCountry(f.Destination)
+ }
+ snapshot.Error = trafficText(snapshot.Error, 600)
+ snapshot.FirewallError = trafficText(snapshot.FirewallError, 600)
+ for i := range snapshot.Firewall {
+ snapshot.Firewall[i].Name = trafficText(snapshot.Firewall[i].Name, 200)
+ }
+ snapshot.ReceivedAt = time.Now().Unix()
+ snapshot.AgentName = ""
+ raw, err := json.Marshal(snapshot)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.Exec(`INSERT INTO network_sources(agent,snapshot) VALUES(?,?) ON CONFLICT(agent) DO UPDATE SET snapshot=excluded.snapshot`, agent, string(raw))
+ return err
+}
+func networkRoutes(mux *http.ServeMux, s *Store) {
+ enabled := func() bool { c := s.TrafficSettings(); return c.Enabled && c.NetworkEnabled }
+ mux.HandleFunc("/network", func(w http.ResponseWriter, r *http.Request) {
+ if !enabled() {
+ http.Error(w, "Включите TCP/UDP в настройках карты трафика", 404)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(networkHTML))
+ })
+ mux.HandleFunc("/api/network", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" {
+ w.WriteHeader(405)
+ return
+ }
+ if !enabled() {
+ 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
+ }
+ 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) {
+ if r.Method != "POST" {
+ w.WriteHeader(405)
+ return
+ }
+ id, secret, ok := agentCredentials(r)
+ if !ok || !s.AuthenticateAgent(id, secret) {
+ http.Error(w, "Unauthorized", 401)
+ return
+ }
+ c := s.TrafficSettings()
+ if !c.Enabled || !c.NetworkEnabled || c.AgentID != id {
+ http.Error(w, "Источник отключён", 403)
+ return
+ }
+ var snapshot NetworkSnapshot
+ if json.NewDecoder(io.LimitReader(r.Body, 4<<20)).Decode(&snapshot) != nil {
+ http.Error(w, "Invalid snapshot", 400)
+ return
+ }
+ if err := s.SaveNetwork(id, snapshot); err != nil {
+ http.Error(w, err.Error(), 400)
+ return
+ }
+ writeJSON(w, map[string]bool{"accepted": true}, nil)
+ })
+}
diff --git a/network_flows_test.go b/network_flows_test.go
new file mode 100644
index 0000000..31ff4d0
--- /dev/null
+++ b/network_flows_test.go
@@ -0,0 +1,137 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+const networkNATLine = "ipv4 2 tcp 6 431999 ESTABLISHED src=8.8.8.8 dst=192.168.0.12 sport=51000 dport=443 packets=12 bytes=1500 src=172.18.0.2 dst=8.8.8.8 sport=443 dport=51000 packets=16 bytes=2500 [ASSURED] mark=0 zone=2 use=1"
+
+func TestNetworkConntrackTuples(t *testing.T) {
+ f, ok := parseNetworkFlow(networkNATLine)
+ if !ok || f.Source != "8.8.8.8" || f.Destination != "192.168.0.12" || f.ReplySource != "172.18.0.2" || f.SourcePort != 51000 || f.ReplySourcePort != 443 || f.State != "ESTABLISHED" || !f.Accounting || f.Bytes != 1500 || f.ReplyBytes != 2500 || f.Zone != "2" {
+ t.Fatalf("NAT tuple corrupted: %+v", f)
+ }
+ udp, ok := parseNetworkFlow("ipv6 10 udp 17 29 src=2001:4860:4860::8888 dst=fd00::2 sport=51000 dport=53 [UNREPLIED] src=fd00::2 dst=2001:4860:4860::8888 sport=53 dport=51000 mark=0 use=1")
+ if !ok || udp.State != "UNREPLIED" || udp.Accounting || udp.Protocol != "udp" {
+ t.Fatalf("UDP: %+v", udp)
+ }
+ icmp, ok := parseNetworkFlow("icmp 1 29 src=8.8.8.8 dst=192.168.0.12 type=8 code=0 id=3 src=192.168.0.12 dst=8.8.8.8 type=0 code=0 id=3 use=1")
+ if !ok || icmp.SourcePort != 0 || icmp.State != "TRACKED" {
+ t.Fatalf("ICMP: %+v", icmp)
+ }
+ if _, ok := parseNetworkFlow("tcp 6 20 SYN_SENT src=not-an-ip dst=192.168.0.12"); ok {
+ t.Fatal("invalid accepted")
+ }
+}
+func TestNetworkSnapshotLimit(t *testing.T) {
+ var s NetworkSnapshot
+ appendNetworkFlows(&s, []byte(strings.Repeat(networkNATLine+"\n", 5001)))
+ if len(s.Flows) != 5000 || !s.Limited {
+ t.Fatalf("limit not enforced %d", len(s.Flows))
+ }
+ b := networkBuffer{limit: 2}
+ if _, err := b.Write([]byte("123")); err == nil || b.Len() != 0 {
+ t.Fatal("output unbounded")
+ }
+}
+func TestFirewallOnlyConfirmedDropCounters(t *testing.T) {
+ raw := `{"nftables":[{"rule":{"family":"ip","table":"crowdsec","chain":"crowdsec-chain","handle":3,"expr":[{"match":{}},{"counter":{"packets":10,"bytes":1000}},{"drop":null}]}},{"rule":{"table":"crowdsec","expr":[{"counter":{"packets":99}},{"accept":null}]}},{"rule":{"table":"other","expr":[{"counter":{"packets":99}},{"drop":null}]}},{"rule":{"table":"crowdsec","expr":[{"counter":{"packets":99}},{"match":{}},{"drop":null}]}},{"rule":{"table":"crowdsec","expr":[{"counter":"named-counter"},{"drop":null}]}}]}`
+ counters, err := parseFirewallCounters([]byte(raw))
+ if err != nil || len(counters) != 1 || counters[0].Packets != 10 || counters[0].Bytes != 1000 {
+ t.Fatalf("invented blocks: %+v %v", counters, err)
+ }
+ if _, err := parseFirewallCounters([]byte("invalid")); err == nil {
+ t.Fatal("invalid nft JSON accepted")
+ }
+}
+func TestNetworkRouteAuthorizationAndSourceSelection(t *testing.T) {
+ s, a := trafficFixture(t)
+ s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID})
+ mux := http.NewServeMux()
+ trafficRoutes(mux, s)
+ flow, _ := parseNetworkFlow(networkNATLine)
+ snapshot := NetworkSnapshot{At: time.Now().Unix(), Flows: []NetworkFlow{flow}}
+ raw, _ := json.Marshal(snapshot)
+ post := func(token string) int {
+ r := httptest.NewRequest("POST", "/api/agent/network", bytes.NewReader(raw))
+ r.Header.Set("Authorization", "Bearer "+token)
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, r)
+ return w.Code
+ }
+ if got := post(a.ID + "." + a.Secret); got != 403 {
+ t.Fatalf("disabled accepts data: %d", got)
+ }
+ s.SaveTrafficSettings(TrafficSettings{Enabled: true, NetworkEnabled: true, AgentID: a.ID})
+ if got := post("wrong.token"); got != 401 {
+ t.Fatalf("unauthenticated: %d", got)
+ }
+ if got := post(a.ID + "." + a.Secret); got != 200 {
+ t.Fatalf("valid: %d", got)
+ }
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/network", nil))
+ var got NetworkSnapshot
+ json.Unmarshal(w.Body.Bytes(), &got)
+ if w.Code != 200 || len(got.Flows) != 1 || got.Flows[0].Country == "" || got.ReceivedAt == 0 {
+ t.Fatalf("bad snapshot: %s", w.Body.String())
+ }
+ token, _ := s.CreateAgentEnrollmentToken()
+ other, _ := s.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "other"})
+ s.SaveTrafficSettings(TrafficSettings{Enabled: true, NetworkEnabled: true, AgentID: other.ID})
+ if code := post(a.ID + "." + a.Secret); code != 403 {
+ t.Fatalf("old source accepted: %d", code)
+ }
+ w = httptest.NewRecorder()
+ mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/network", nil))
+ json.Unmarshal(w.Body.Bytes(), &got)
+ if len(got.Flows) != 0 {
+ t.Fatal("old source data leaked")
+ }
+ s.SaveTrafficSettings(TrafficSettings{Enabled: false, NetworkEnabled: true, AgentID: a.ID})
+ for _, path := range []string{"/network", "/api/network"} {
+ w = httptest.NewRecorder()
+ mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
+ if w.Code != 404 {
+ t.Fatalf("disabled page: %s", path)
+ }
+ }
+}
+func TestNetworkRejectBadSnapshots(t *testing.T) {
+ s, a := trafficFixture(t)
+ if err := s.SaveNetwork(a.ID, NetworkSnapshot{At: time.Now().Unix() - 300}); err == nil {
+ t.Fatal("stale timestamp")
+ }
+ f, _ := parseNetworkFlow(networkNATLine)
+ f.SourcePort = 99999
+ if err := s.SaveNetwork(a.ID, NetworkSnapshot{At: time.Now().Unix(), Flows: []NetworkFlow{f}}); err == nil {
+ t.Fatal("invalid port")
+ }
+}
+func TestNetworkCommandCancellation(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err := networkCommand(ctx, "conntrack", "-L"); err == nil {
+ t.Fatal("cancelled command ran")
+ }
+}
+
+func TestNetworkExternalAddresses(t *testing.T) {
+ for _, ip := range []string{"192.168.1.1", "127.0.0.1", "100.64.0.1", "224.0.0.1", "ff02::1", "fe80::1", "fd00::1", "::"} {
+ if networkExternalIP(ip) {
+ t.Fatalf("not external: %s", ip)
+ }
+ }
+ for _, ip := range []string{"8.8.8.8", "2001:4860:4860::8888"} {
+ if !networkExternalIP(ip) {
+ t.Fatalf("external: %s", ip)
+ }
+ }
+}
diff --git a/traffic.go b/traffic.go
index bdac5b2..5440a13 100644
--- a/traffic.go
+++ b/traffic.go
@@ -76,6 +76,7 @@ const trafficSchema = `CREATE TABLE IF NOT EXISTS traffic_events (
country TEXT NOT NULL, UNIQUE(agent,event_id));
CREATE INDEX IF NOT EXISTS traffic_ts ON traffic_events(ts);
CREATE INDEX IF NOT EXISTS traffic_filter ON traffic_events(domain,kind,ts);
+ CREATE TABLE IF NOT EXISTS network_sources(agent TEXT PRIMARY KEY, snapshot TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS traffic_sources(agent TEXT PRIMARY KEY, last_seen INTEGER NOT NULL, status_json TEXT NOT NULL);`
func (s *Store) SaveTraffic(agent string, batch TrafficBatch) error {
@@ -244,6 +245,7 @@ func (s *Store) TrafficView(domain, kind string, external bool) (TrafficView, er
func trafficRoutes(mux *http.ServeMux, s *Store) {
trafficSettingsRoute(mux, s)
+ networkRoutes(mux, s)
mux.HandleFunc("/traffic", func(w http.ResponseWriter, r *http.Request) {
if !s.TrafficSettings().Enabled {
http.Error(w, "Карта трафика отключена. Включите её в настройках Dashboard.", 404)
diff --git a/traffic_preview_test.go b/traffic_preview_test.go
index 0d7960b..995745a 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, AgentID: a.ID, ServerCountry: "RU"})
+ s.SaveTrafficSettings(TrafficSettings{Enabled: true, NetworkEnabled: true, AgentID: a.ID, ServerCountry: "RU"})
mux := http.NewServeMux()
trafficRoutes(mux, s)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
@@ -38,11 +38,35 @@ func TestTrafficPreview(t *testing.T) {
}
events = append(events, TrafficEvent{ID: fmt.Sprintf("preview-%d-%d", i, j), Time: time.Now().Unix(), Kind: kind, IP: ip, Domain: "demo.example", Method: "GET", Path: "/demo", Status: 200, Reason: "ДЕМО · crowdsecurity/http-probing"})
}
+ flows := []NetworkFlow{}
+ for k := 0; k < 32; k++ {
+ f, _ := parseNetworkFlow(networkNATLine)
+ f.SourcePort = 51000 + k
+ f.ReplyDestinationPort = f.SourcePort
+ f.Source = []string{"8.8.8.8", "1.1.1.1", "20.194.96.114", "192.168.0.10", "51.158.145.102", "2001:4860:4860::8888"}[k%6]
+ f.ReplyDestination = f.Source
+ if k%3 == 0 {
+ f.Protocol = "udp"
+ f.State = "ASSURED"
+ f.DestinationPort = 53
+ f.ReplySourcePort = 53
+ }
+ if k%5 == 0 && f.Protocol == "tcp" {
+ f.State = "SYN_SENT"
+ }
+ 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}}})
s.SaveTraffic(a.ID, TrafficBatch{Events: events, Files: 22, StartedAt: time.Now().Add(-time.Hour).Unix(), CrowdSecAt: time.Now().Unix()})
time.Sleep(3 * time.Second)
}
}()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/network" {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(strings.Replace(networkHTML, "PROXMOX / NETWORK OBSERVATORY", "ДЕМО / ИСКУССТВЕННЫЕ ДАННЫЕ", 1)))
+ return
+ }
if r.URL.Path == "/traffic" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(strings.Replace(trafficHTML, "PROXMOX / LIVE OBSERVATORY", "ДЕМО / ИСКУССТВЕННЫЕ ДАННЫЕ", 1)))
diff --git a/traffic_settings.go b/traffic_settings.go
index 6f557c5..4a846d7 100644
--- a/traffic_settings.go
+++ b/traffic_settings.go
@@ -9,9 +9,10 @@ import (
)
type TrafficSettings struct {
- Enabled bool `json:"enabled"`
- AgentID string `json:"agentId"`
- ServerCountry string `json:"serverCountry"`
+ NetworkEnabled bool `json:"networkEnabled"`
+ Enabled bool `json:"enabled"`
+ AgentID string `json:"agentId"`
+ ServerCountry string `json:"serverCountry"`
}
func (s *Store) TrafficSettings() TrafficSettings {
diff --git a/web.go b/web.go
index 3f562c0..bcfd83c 100644
--- a/web.go
+++ b/web.go
@@ -284,7 +284,7 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
return
}
trafficSettings := store.TrafficSettings()
- result := agentReportResponse{Accepted: true, TrafficEnabled: trafficSettings.Enabled && trafficSettings.AgentID == id}
+ result := agentReportResponse{Accepted: true, NetworkEnabled: trafficSettings.Enabled && trafficSettings.NetworkEnabled && trafficSettings.AgentID == id, TrafficEnabled: trafficSettings.Enabled && trafficSettings.AgentID == id}
result.Commands, _ = store.DispatchAgentActions(id)
if report.Version != version && version != "dev" {
manifest, _, manifestErr := currentAgentUpdate()
@@ -882,6 +882,8 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Карта трафика · Nginx Proxy Manager + CrowdSec
Включить страницу и сбор трафика
+
TCP/UDP/ICMP и счётчики блокировок (агент v0.21.0+)
+
Сетевой режим: текущая таблица conntrack, обновление ~5 с. На агенте нужен пакет conntrack и root/CAP_NET_ADMIN; nft — для счётчиков CrowdSec. Настройки firewall и ядра автоматически не меняются. Короткие потоки между снимками могут быть пропущены.
Агент-источник Выберите агент
Страна сервера на карте (необязательно) Не задана
Сбор выполняется только выбранным агентом: access-логи /opt/npm/data/logs и локальный cscli. Изменить путь можно через DASHBOARD_NPM_LOG_DIR в окружении службы агента. При выключении сбор прекращается и ссылка на страницу скрывается. События карты не создают общих алертов и писем. История: до 24 часов и 200 000 событий.
@@ -1030,11 +1032,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('trafficAgent').innerHTML='
Выберите агент '+(agents||[]).map(a=>'
'+safe(a.name)+' · VMID '+a.vmid+' · '+safe(a.version)+(a.online?' · онлайн':' · офлайн')+' ').join('');el('trafficAgent').value=settings.agentId;
+ 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,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({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='Создать команду установки или обновления'}};