Automatically install missing conntrack for enabled network collector
This commit is contained in:
17
README.md
17
README.md
@@ -516,12 +516,19 @@ DASHBOARD_TRAFFIC_PREVIEW=1 go test -run '^TestTrafficPreview$' -timeout 30m
|
|||||||
отключает оба сборщика. Сетевой режим по умолчанию выключен и не создаёт
|
отключает оба сборщика. Сетевой режим по умолчанию выключен и не создаёт
|
||||||
общих алертов/писем.
|
общих алертов/писем.
|
||||||
|
|
||||||
Для Debian/Ubuntu на хосте агента (не внутри контейнера NPM):
|
На Debian/Ubuntu агент **сам проверяет и устанавливает conntrack**, когда
|
||||||
|
включён сетевой режим. Выполняются `apt-get update` и установка только
|
||||||
|
`conntrack` с зависимостями (`--no-install-recommends --no-remove`), без
|
||||||
|
обновления всей системы. Установка выполняется от root через отдельную
|
||||||
|
systemd-службу, как существующие операции обновления агента, и не требует
|
||||||
|
отключать его read-only sandbox. Начатая пакетная операция завершается даже
|
||||||
|
при выключении сбора. Ход/ошибка видны в диагностике; повтор после ошибки —
|
||||||
|
не чаще раза в 30 минут работы процесса. После перезапуска агента проверка
|
||||||
|
выполняется заново. Если conntrack уже установлен, apt-get не запускается.
|
||||||
|
|
||||||
```sh
|
На других системах, без root или systemd/apt-get, выводится инструкция для
|
||||||
sudo apt-get update
|
ручной установки. Для счётчиков CrowdSec требуется доступный `nft`; он
|
||||||
sudo apt-get install -y conntrack nftables
|
обычно уже установлен вместе с nftables-bouncer и автоматически не ставится.
|
||||||
```
|
|
||||||
|
|
||||||
Агенту нужны root/CAP_NET_ADMIN в сетевом namespace хоста. Установщик агента
|
Агенту нужны root/CAP_NET_ADMIN в сетевом namespace хоста. Установщик агента
|
||||||
запускает службу от root. Чтение: `/proc/net/nf_conntrack`, если доступен,
|
запускает службу от root. Чтение: `/proc/net/nf_conntrack`, если доступен,
|
||||||
|
|||||||
@@ -223,7 +223,12 @@ func startAgentNetwork(config agentConfig) func() {
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
snapshot := collectNetwork(ctx)
|
snapshot := NetworkSnapshot{At: time.Now().Unix(), Flows: []NetworkFlow{}}
|
||||||
|
if message := ensureNetworkDependency(); message != "" {
|
||||||
|
snapshot.Error = message
|
||||||
|
} else {
|
||||||
|
snapshot = collectNetwork(ctx)
|
||||||
|
}
|
||||||
if time.Since(lastFirewall) >= 30*time.Second {
|
if time.Since(lastFirewall) >= 30*time.Second {
|
||||||
lastFirewall = time.Now()
|
lastFirewall = time.Now()
|
||||||
raw, err := networkCommand(ctx, "nft", "-j", "list", "ruleset")
|
raw, err := networkCommand(ctx, "nft", "-j", "list", "ruleset")
|
||||||
|
|||||||
89
agent_network_setup.go
Normal file
89
agent_network_setup.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Package work runs in a transient systemd unit, as do existing agent update
|
||||||
|
// actions. It must not inherit ProtectSystem=strict from the reporting agent.
|
||||||
|
// A started package transaction is allowed to finish if collection is disabled.
|
||||||
|
type networkDependencyState struct {
|
||||||
|
sync.Mutex
|
||||||
|
running bool
|
||||||
|
nextAttempt time.Time
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
var networkDependency networkDependencyState
|
||||||
|
|
||||||
|
func (d *networkDependencyState) ensure(now time.Time, present bool, install func() error) string {
|
||||||
|
d.Lock()
|
||||||
|
defer d.Unlock()
|
||||||
|
if present {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if d.running {
|
||||||
|
return "Автоустановка conntrack… Сбор начнётся после установки пакета."
|
||||||
|
}
|
||||||
|
if now.Before(d.nextAttempt) {
|
||||||
|
return d.message + " Повторная попытка не раньше " + d.nextAttempt.Format("15:04:05") + " (время агента)."
|
||||||
|
}
|
||||||
|
d.running = true
|
||||||
|
d.nextAttempt = now.Add(30 * time.Minute)
|
||||||
|
go func() {
|
||||||
|
err := install()
|
||||||
|
d.Lock()
|
||||||
|
defer d.Unlock()
|
||||||
|
d.running = false
|
||||||
|
if err != nil {
|
||||||
|
d.message = "Не удалось установить conntrack: " + trafficText(err.Error(), 400)
|
||||||
|
} else {
|
||||||
|
d.message = "Установка завершена; ожидается появление conntrack в PATH"
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return "Автоустановка conntrack… Сбор начнётся после установки пакета."
|
||||||
|
}
|
||||||
|
func ensureNetworkDependency() string {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
return "Сбор conntrack поддерживается только на Linux"
|
||||||
|
}
|
||||||
|
_, err := exec.LookPath("conntrack")
|
||||||
|
return networkDependency.ensure(time.Now(), err == nil, installNetworkDependency)
|
||||||
|
}
|
||||||
|
func installNetworkDependency() error {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
return fmt.Errorf("для автоустановки нужны права root")
|
||||||
|
}
|
||||||
|
apt, err := exec.LookPath("apt-get")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("автоустановка поддерживается на Debian/Ubuntu с apt-get; установите conntrack вручную")
|
||||||
|
}
|
||||||
|
run := func(suffix string, args ...string) error {
|
||||||
|
out := runPrivilegedTransient(10*time.Minute, suffix, apt, args...)
|
||||||
|
if strings.HasPrefix(out, "ERROR: ") {
|
||||||
|
return fmt.Errorf("%s", explainSystemUpdateError(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return installConntrackPackage(run, func() bool { _, err := exec.LookPath("conntrack"); return err == nil })
|
||||||
|
}
|
||||||
|
func installConntrackPackage(run func(string, ...string) error, present func() bool) error {
|
||||||
|
// No upgrade, autoremove, firewall changes, or runtime sysctl changes.
|
||||||
|
options := []string{"-o", "DPkg::Lock::Timeout=30", "-o", "Acquire::Retries=1", "-o", "Acquire::http::Timeout=20", "-o", "Acquire::https::Timeout=20"}
|
||||||
|
if err := run("conntrack-lists", append(append([]string{}, options...), "update")...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := run("conntrack-install", append(append([]string{}, options...), "-y", "--no-install-recommends", "--no-remove", "install", "conntrack")...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !present() {
|
||||||
|
return fmt.Errorf("apt-get завершился, но conntrack не найден в PATH")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ function render(){if(!snapshot)return;const data=frozen||snapshot,items=filterFl
|
|||||||
}
|
}
|
||||||
function openFlow(target){const row=target.closest('[data-flow]');if(!row)return;const f=visible[+row.dataset.flow];if(!f)return;show('Поток '+f.protocol.toUpperCase(),fields([['Инициатор',endpoint(f.source,f.sourcePort)+' · '+country(f.country)],['Исходное назначение',endpoint(f.destination,f.destinationPort)],['Обратное направление (NAT)',endpoint(f.replySource,f.replySourcePort)+' → '+endpoint(f.replyDestination,f.replyDestinationPort)],['Состояние / зона',f.state+' / '+(f.zone||'0')],['Инициатор → назначение',f.accounting?bytes(f.bytes)+' · '+num(f.packets)+' пакетов':'Учёт пакетов/байтов не включён для этого потока'],['Ответное направление',f.accounting?bytes(f.replyBytes)+' · '+num(f.replyPackets)+' пакетов':'Нет данных'],['Время снимка',new Date((frozen||snapshot).at*1000).toLocaleString('ru-RU')]]))}
|
function openFlow(target){const row=target.closest('[data-flow]');if(!row)return;const f=visible[+row.dataset.flow];if(!f)return;show('Поток '+f.protocol.toUpperCase(),fields([['Инициатор',endpoint(f.source,f.sourcePort)+' · '+country(f.country)],['Исходное назначение',endpoint(f.destination,f.destinationPort)],['Обратное направление (NAT)',endpoint(f.replySource,f.replySourcePort)+' → '+endpoint(f.replyDestination,f.replyDestinationPort)],['Состояние / зона',f.state+' / '+(f.zone||'0')],['Инициатор → назначение',f.accounting?bytes(f.bytes)+' · '+num(f.packets)+' пакетов':'Учёт пакетов/байтов не включён для этого потока'],['Ответное направление',f.accounting?bytes(f.replyBytes)+' · '+num(f.replyPackets)+' пакетов':'Нет данных'],['Время снимка',new Date((frozen||snapshot).at*1000).toLocaleString('ru-RU')]]))}
|
||||||
el('rows').onclick=e=>openFlow(e.target);el('rows').onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();openFlow(e.target)}};el('close').onclick=()=>el('dialog').close();
|
el('rows').onclick=e=>openFlow(e.target);el('rows').onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();openFlow(e.target)}};el('close').onclick=()=>el('dialog').close();
|
||||||
el('statusDetails').onclick=()=>show('Диагностика агента',fields([['Сбор',snapshot?.error||'Ошибок conntrack не сообщено'],['Связь',networkError||'Последний ответ получен'],['Получен сервером',snapshot?.receivedAt?new Date(snapshot.receivedAt*1000).toLocaleString('ru-RU'):'Ещё не получен'],['Ограничения',snapshot?.limited?'Достигнут лимит 5 000 записей; статистика частичная':'До 5 000 записей в снимке'],['Нераспознанные строки',num(snapshot?.parseErrors)],['Firewall',snapshot?.firewallError||'Поддерживаемые счётчики получены']])+ '<p>Для Debian/Ubuntu установите на агенте пакет <code>conntrack</code>. Службе нужны root/CAP_NET_ADMIN. Для firewall нужен <code>nft</code>. При выключенном учёте conntrack байты недоступны; sysctl и правила автоматически не изменяются.</p>');
|
el('statusDetails').onclick=()=>show('Диагностика агента',fields([['Сбор',snapshot?.error||'Ошибок conntrack не сообщено'],['Связь',networkError||'Последний ответ получен'],['Получен сервером',snapshot?.receivedAt?new Date(snapshot.receivedAt*1000).toLocaleString('ru-RU'):'Ещё не получен'],['Ограничения',snapshot?.limited?'Достигнут лимит 5 000 записей; статистика частичная':'До 5 000 записей в снимке'],['Нераспознанные строки',num(snapshot?.parseErrors)],['Firewall',snapshot?.firewallError||'Поддерживаемые счётчики получены']])+ '<p>На Debian/Ubuntu агент автоматически устанавливает отсутствующий <code>conntrack</code> через apt-get и systemd. Службе нужны root; для чтения таблицы — root/CAP_NET_ADMIN. Ошибка установки повторяется не чаще раза в 30 минут. Для firewall нужен <code>nft</code>. При выключенном учёте conntrack байты недоступны; sysctl и правила автоматически не изменяются.</p>');
|
||||||
el('firewallDetails').onclick=()=>show('Счётчики блокировок',fields((snapshot?.firewall||[]).map(c=>[c.name,num(c.packets)+' пакетов · '+bytes(c.bytes)]))+'<p>'+safe(snapshot?.firewallError||'Накопительные счётчики правил CrowdSec непосредственно перед drop/reject. Сбрасываются при пересоздании правил. Не зависят от фильтров списка потоков; не показывают количество людей или HTTP-запросов.')+'</p>');
|
el('firewallDetails').onclick=()=>show('Счётчики блокировок',fields((snapshot?.firewall||[]).map(c=>[c.name,num(c.packets)+' пакетов · '+bytes(c.bytes)]))+'<p>'+safe(snapshot?.firewallError||'Накопительные счётчики правил CrowdSec непосредственно перед drop/reject. Сбрасываются при пересоздании правил. Не зависят от фильтров списка потоков; не показывают количество людей или HTTP-запросов.')+'</p>');
|
||||||
el('about').onclick=()=>show('Как читать сетевые данные','<p>Это текущие отслеживаемые потоки TCP, UDP, ICMP и ICMPv6, а не журнал всех пакетов. Снимки делаются примерно раз в 5 секунд: короткие потоки могут быть пропущены. TIME_WAIT и другие состояния закрытия остаются в таблице до истечения таймера.</p><p>Инициатор и назначение — исходное направление conntrack. Обратная пара адресов показывает трансляцию NAT, включая Docker. Соединение NPM с upstream — отдельный поток; оно не считается ещё одним посетителем.</p><p>UDP ASSURED — отметка conntrack, не TCP-соединение. Порт 443 сам по себе не доказывает HTTPS или QUIC. SYN_SENT/SYN_RECV не означают атаку.</p><p>Байты и пакеты накоплены за жизнь конкретного потока, если учёт был включён при его создании. Длительность соединения и точный поток новых подключений по этим снимкам не вычисляются.</p><p>Фильтры влияют на карточки и топы. Firewall показывает отдельные накопительные счётчики без фильтров. Приватные и локальные IP не геолокируются. Геолокация: <a href="https://db-ip.com">DB-IP</a>, CC BY 4.0.</p>');
|
el('about').onclick=()=>show('Как читать сетевые данные','<p>Это текущие отслеживаемые потоки TCP, UDP, ICMP и ICMPv6, а не журнал всех пакетов. Снимки делаются примерно раз в 5 секунд: короткие потоки могут быть пропущены. TIME_WAIT и другие состояния закрытия остаются в таблице до истечения таймера.</p><p>Инициатор и назначение — исходное направление conntrack. Обратная пара адресов показывает трансляцию NAT, включая Docker. Соединение NPM с upstream — отдельный поток; оно не считается ещё одним посетителем.</p><p>UDP ASSURED — отметка conntrack, не TCP-соединение. Порт 443 сам по себе не доказывает HTTPS или QUIC. SYN_SENT/SYN_RECV не означают атаку.</p><p>Байты и пакеты накоплены за жизнь конкретного потока, если учёт был включён при его создании. Длительность соединения и точный поток новых подключений по этим снимкам не вычисляются.</p><p>Фильтры влияют на карточки и топы. Firewall показывает отдельные накопительные счётчики без фильтров. Приватные и локальные IP не геолокируются. Геолокация: <a href="https://db-ip.com">DB-IP</a>, CC BY 4.0.</p>');
|
||||||
function reset(){frozen=null;page=0;render()};['protocol','port','external'].forEach(id=>el(id).onchange=reset);el('search').oninput=reset;el('next').onclick=()=>{if(!frozen)frozen=snapshot;page++;render()};el('prev').onclick=()=>{page--;render()};el('resume').onclick=reset;el('pause').onclick=()=>{paused=!paused;el('pause').textContent=paused?'Продолжить':'Пауза';render();if(!paused)poll()};document.querySelectorAll('[data-tab]').forEach(b=>{if(b.tagName==='BUTTON')b.onclick=()=>{document.body.dataset.tab=b.dataset.tab;document.querySelectorAll('#tabs button').forEach(t=>t.classList.toggle('active',t===b));render()}});window.addEventListener('resize',render);
|
function reset(){frozen=null;page=0;render()};['protocol','port','external'].forEach(id=>el(id).onchange=reset);el('search').oninput=reset;el('next').onclick=()=>{if(!frozen)frozen=snapshot;page++;render()};el('prev').onclick=()=>{page--;render()};el('resume').onclick=reset;el('pause').onclick=()=>{paused=!paused;el('pause').textContent=paused?'Продолжить':'Пауза';render();if(!paused)poll()};document.querySelectorAll('[data-tab]').forEach(b=>{if(b.tagName==='BUTTON')b.onclick=()=>{document.body.dataset.tab=b.dataset.tab;document.querySelectorAll('#tabs button').forEach(t=>t.classList.toggle('active',t===b));render()}});window.addEventListener('resize',render);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -135,3 +136,54 @@ func TestNetworkExternalAddresses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConntrackInstallerOnlyInstallsRequestedPackage(t *testing.T) {
|
||||||
|
var calls [][]string
|
||||||
|
err := installConntrackPackage(func(name string, args ...string) error {
|
||||||
|
calls = append(calls, append([]string{name}, args...))
|
||||||
|
return nil
|
||||||
|
}, func() bool { return true })
|
||||||
|
if err != nil || len(calls) != 2 {
|
||||||
|
t.Fatalf("installer: %v %v", calls, err)
|
||||||
|
}
|
||||||
|
if calls[0][len(calls[0])-1] != "update" || strings.Join(calls[1][len(calls[1])-5:], " ") != "-y --no-install-recommends --no-remove install conntrack" {
|
||||||
|
t.Fatalf("unexpected apt operation: %v", calls)
|
||||||
|
}
|
||||||
|
calls = nil
|
||||||
|
err = installConntrackPackage(func(name string, args ...string) error { calls = append(calls, args); return fmt.Errorf("apt locked") }, func() bool { return true })
|
||||||
|
if err == nil || len(calls) != 1 {
|
||||||
|
t.Fatal("continued after failed apt update")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestConntrackDependencySingleInstallAndBackoff(t *testing.T) {
|
||||||
|
var d networkDependencyState
|
||||||
|
now := time.Now()
|
||||||
|
started := make(chan struct{})
|
||||||
|
finish := make(chan struct{})
|
||||||
|
installer := func() error { close(started); <-finish; return fmt.Errorf("offline") }
|
||||||
|
if got := d.ensure(now, true, installer); got != "" {
|
||||||
|
t.Fatal("installed tool should be skipped")
|
||||||
|
}
|
||||||
|
d.ensure(now, false, installer)
|
||||||
|
<-started
|
||||||
|
// A second call must not start a second installer (which would panic closing started).
|
||||||
|
d.ensure(now, false, installer)
|
||||||
|
close(finish)
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for {
|
||||||
|
d.Lock()
|
||||||
|
running := d.running
|
||||||
|
d.Unlock()
|
||||||
|
if !running {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("installer stuck")
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
got := d.ensure(now.Add(time.Minute), false, installer)
|
||||||
|
if !strings.Contains(got, "offline") || !strings.Contains(got, "Повторная") {
|
||||||
|
t.Fatalf("backoff missing: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
2
web.go
2
web.go
@@ -883,7 +883,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
|||||||
<div class="settings-group"><div class="label">Карта трафика · Nginx Proxy Manager + CrowdSec</div>
|
<div class="settings-group"><div class="label">Карта трафика · Nginx Proxy Manager + CrowdSec</div>
|
||||||
<label class="threshold-item"><span>Включить страницу и сбор трафика</span><input type="checkbox" id="trafficEnabled"></label>
|
<label class="threshold-item"><span>Включить страницу и сбор трафика</span><input type="checkbox" id="trafficEnabled"></label>
|
||||||
<label class="threshold-item"><span>TCP/UDP/ICMP и счётчики блокировок (агент v0.21.0+)</span><input type="checkbox" id="networkEnabled"></label>
|
<label class="threshold-item"><span>TCP/UDP/ICMP и счётчики блокировок (агент v0.21.0+)</span><input type="checkbox" id="networkEnabled"></label>
|
||||||
<p class="settings-note">Сетевой режим: текущая таблица conntrack, обновление ~5 с. На агенте нужен пакет conntrack и root/CAP_NET_ADMIN; nft — для счётчиков CrowdSec. Настройки firewall и ядра автоматически не меняются. Короткие потоки между снимками могут быть пропущены.</p>
|
<p class="settings-note">Сетевой режим: текущая таблица conntrack, обновление ~5 с. Агент сам установит отсутствующий conntrack через apt-get на Debian/Ubuntu (root + systemd). nft нужен для счётчиков CrowdSec. Настройки firewall и ядра автоматически не меняются. Короткие потоки между снимками могут быть пропущены.</p>
|
||||||
<label class="field"><span>Агент-источник</span><select id="trafficAgent"><option value="">Выберите агент</option></select></label>
|
<label class="field"><span>Агент-источник</span><select id="trafficAgent"><option value="">Выберите агент</option></select></label>
|
||||||
<label class="field"><span>Страна сервера на карте (необязательно)</span><select id="trafficCountry"><option value="">Не задана</option></select></label>
|
<label class="field"><span>Страна сервера на карте (необязательно)</span><select id="trafficCountry"><option value="">Не задана</option></select></label>
|
||||||
<p class="settings-note">Сбор выполняется только выбранным агентом: access-логи /opt/npm/data/logs и локальный cscli. Изменить путь можно через DASHBOARD_NPM_LOG_DIR в окружении службы агента. При выключении сбор прекращается и ссылка на страницу скрывается. События карты не создают общих алертов и писем. История: до 24 часов и 200 000 событий.</p>
|
<p class="settings-note">Сбор выполняется только выбранным агентом: access-логи /opt/npm/data/logs и локальный cscli. Изменить путь можно через DASHBOARD_NPM_LOG_DIR в окружении службы агента. При выключении сбор прекращается и ссылка на страницу скрывается. События карты не создают общих алертов и писем. История: до 24 часов и 200 000 событий.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user