diff --git a/README.md b/README.md index f6e46f2..3974783 100644 --- a/README.md +++ b/README.md @@ -645,7 +645,23 @@ Dashboard. Кнопка солнца/луны меняет тему; выбор Четыре карточки статистики открывают окна с итогом, объяснением метрики, разбивкой по странам и примерами событий. Счётчики стран считаются сервером -по всей доступной истории за 24 часа с текущими фильтрами. Примеры — до 30 -совпадений из последних 100 событий, что явно отмечено в интерфейсе. Открытое +по всей доступной истории за 24 часа с текущими фильтрами. Начиная с v0.25.0 +история загружается целиком по страницам, без ограничения последними 100 событиями. Открытое окно фиксирует данные на момент открытия; временная отметка показана внутри. Сетевые потоки остаются отдельным текущим снимком, не суточной статистикой. + +## Карта и полная история (v0.25.0) + +Карта использует векторные границы стран: при зуме 100–600% они перерисовываются, +а маркеры и подписи сохраняют размер. Это обзорная карта стран, не уличная карта. +Карточки и окна стран загружают всю сохранённую историю за последние 24 часа +по 50 записей; фильтры учитываются сервером. В карточке IP показаны все уникальные +HTTP IP с количеством запросов. Переходы между страницами фиксируют снимок истории. + +Для городов откройте настройки карты и нажмите «Установить базу городов». +Dashboard скачает DB-IP City Lite 2026-10 (~58 МБ сжатая, ~121 МБ на диске), +проверит контрольную сумму и сохранит `dbip-city.mmdb` рядом с `DASHBOARD_DB_PATH`. +IP посетителей не отправляются во внешние сервисы. Город и регион отображаются +в истории и списке IP; местоположение приблизительное и может относиться к VPN +или провайдеру. Для части IP город неизвестен. База устанавливается отдельно от +релиза, автоматически каждый месяц не обновляется. diff --git a/assets/NOTICE.md b/assets/NOTICE.md index cd34bcb..b7a1ee0 100644 --- a/assets/NOTICE.md +++ b/assets/NOTICE.md @@ -12,3 +12,9 @@ https://creativecommons.org/licenses/by/4.0/ https://db-ip.com/db/download/ip-to-country-lite The database is unmodified. The traffic page displays the required DB-IP link. Countries are approximate; plotted positions are country labels, not exact IP locations. + +Optional DB-IP City Lite October 2026 is downloaded separately through Settings. +Source: https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz +License: CC BY 4.0, https://creativecommons.org/licenses/by/4.0/ +https://db-ip.com/db/download/ip-to-city-lite +The database is unmodified; city and region are approximate. diff --git a/assets/traffic.html b/assets/traffic.html index 08c55bf..bd0254d 100644 --- a/assets/traffic.html +++ b/assets/traffic.html @@ -26,6 +26,7 @@ html[data-theme="light"] .metric i{background:#e2f2e7}html[data-theme="light"] . html[data-theme="light"] .section-label,html[data-theme="light"] .event .path,html[data-theme="light"] dialog p{color:var(--text)}html[data-theme="light"] .event .meta{color:var(--muted)}html[data-theme="light"] .event{border-color:var(--line)}html[data-theme="light"] .event:hover{background:#eaf1f4} html[data-theme="light"] dialog,html[data-theme="light"] .tooltip,html[data-theme="light"] #networkSummary{background:#fff;color:var(--text);border-color:var(--line);box-shadow:0 12px 40px #4e64752b}html[data-theme="light"] dialog::backdrop{background:#1e2d3759}html[data-theme="light"] .monitoring-tag{color:var(--green)} @media(max-width:760px){header nav{gap:5px}#themeToggle{padding:5px}.map-controls{bottom:3px;right:3px}.map-controls button{padding:4px 8px}} +.history-pager{display:flex;align-items:center;justify-content:space-between;gap:12px;position:sticky;bottom:-22px;background:var(--panel);padding:12px 0}dialog{width:min(760px,calc(100vw - 30px))}
'+safe(m)+'
').join('')); -el('about').onclick=()=>showDetails('Как читать данные','Голубой — HTTP-запросы, оранжевый — срабатывания CrowdSec, сиреневый — решения о ban или captcha.
Решение о бане не подтверждает конкретный отброшенный запрос. HTTP 401/403/502 сами по себе не означают блокировку CrowdSec.
Счётчики за 24 часа следуют выбранным фильтрам. Уникальный IP не равен человеку. Req/s — среднее за 60 секунд; график показывает запросы в минуту за последний час.
Точки на карте обозначают страны, а не точное местоположение людей. Локальные адреса остаются в ленте без страны. «Скрыть мониторинг» исключает из отображения HTTP-проверки по правилам в настройках; события CrowdSec сохраняются на экране. Исходная история не удаляется. Последние 100 событий доступны по страницам; нажмите строку, чтобы увидеть детали целиком.
'); +el('about').onclick=()=>showDetails('Как читать данные','Голубой — HTTP-запросы, оранжевый — срабатывания CrowdSec, сиреневый — решения о ban или captcha.
Решение о бане не подтверждает конкретный отброшенный запрос. HTTP 401/403/502 сами по себе не означают блокировку CrowdSec.
Счётчики за 24 часа следуют выбранным фильтрам. Уникальный IP не равен человеку. Req/s — среднее за 60 секунд; график показывает запросы в минуту за последний час.
Точки на карте обозначают страны, а не точное местоположение людей. Локальные адреса остаются в ленте без страны. «Скрыть мониторинг» исключает из отображения HTTP-проверки по правилам в настройках; события CrowdSec сохраняются на экране. Исходная история не удаляется. В ленте показаны последние 100 событий; полная сохранённая история за 24 часа доступна в карточках и окнах стран; нажмите строку, чтобы увидеть детали целиком.
'); el('closeDetails').onclick=()=>el('details').close();el('details').onclick=e=>{if(e.target===el('details'))el('details').close()}; document.querySelectorAll('[data-mobile-tab]').forEach(b=>b.onclick=()=>{document.body.dataset.mobile=b.dataset.mobileTab;document.querySelectorAll('[data-mobile-tab]').forEach(t=>t.classList.toggle('active',t===b));drawChart();renderFeed()}); async function poll(force=false){if((paused&&!force)||busy||document.hidden)return;busy=true;try{const q=new URLSearchParams({domain:el('domain').value,kind,hideMonitoring:el('hideMonitoring').checked?'1':'0',external:el('external').checked?'1':'0'}),r=await fetch('/api/traffic?'+q);if(!r.ok){if(r.status===404){view=null;pulses=[];location.replace('/');return;}throw Error(r.status===404?'Карта отключена':'Не удалось обновить данные')}const data=await r.json();lastPoll=Date.now();render(data)}catch(e){el('live').textContent='НЕТ ОБНОВЛЕНИЙ';el('live').classList.add('stale');statusMessages=[e.message];el('status').textContent=e.message;el('status').classList.add('error')}finally{busy=false}} @@ -104,8 +108,8 @@ function countryStats(code){return (view?.countryDetails||[]).find(c=>c.country= function statGrid(s){return 'Сетевой снимок'+(networkFresh()?'':' · устарел / слой выключен')+': '+(points.length?points.map(p=>p.protocol.toUpperCase()+' '+(p.direction==='out'?'исходящие':'входящие')+' '+num(p.count)).join(' · '):'нет потоков этой страны')+'
'} function filterNote(){return 'Последние 24 часа · текущие фильтры · снимок на '+new Date(view.now*1000).toLocaleTimeString('ru-RU')+'. '+(view.limited?'История ограничена лимитом хранения.':'')+'
'} -function eventList(events){return events.length?'В последних 100 событиях подходящих записей нет.
'} -function openCountry(code){if(!view)return;const c=countryStats(code);showDetails(countryName(code),filterNote()+statGrid(c)+countryNetwork(code)+'Последнее событие: '+(c.lastSeen?new Date(c.lastSeen*1000).toLocaleString('ru-RU'):'нет за выбранный период')+'
Геолокация IP приблизительная. Ниже — до 30 совпадений из последних 100 событий.
'+eventList(view.events.filter(e=>e.country===code)))} +function eventList(events){return events.length?'В выбранном периоде записей нет.
'} +function openCountry(code){if(!view)return;const c=countryStats(code);showDetails(countryName(code),filterNote()+statGrid(c)+countryNetwork(code)+'Последнее событие: '+(c.lastSeen?new Date(c.lastSeen*1000).toLocaleString('ru-RU'):'нет за выбранный период')+'
'+historyShell());startHistory({country:code})} function hideCountryTip(){el('tooltip').style.display='none';hoverCountry=null} function showCountryTip(e){const r=el('world').getBoundingClientRect(),c=countryAt(e.clientX-r.left,e.clientY-r.top);hoverCountry=c||null;const tip=el('tooltip');if(!c){tip.style.display='none';redrawMap();return}const s=countryStats(c.code);tip.innerHTML=''+safe(c.name)+'HTTP '+num(s.requests)+' · IP '+num(s.uniqueIps)+''+explanation+'
Примеры: до 30 совпадений из последних 100 событий, не полный журнал.
'+eventList(view.events.filter(e=>e.kind===type)))}); -el('detailsBody').onclick=e=>{const b=e.target.closest('[data-country]');if(b)openCountry(b.dataset.country)}; +document.querySelectorAll('[data-metric]').forEach(b=>b.onclick=()=>{if(!view)return;const key=b.dataset.metric,title={unique:'Уникальные HTTP IP',requests:'HTTP-запросы',alerts:'Срабатывания CrowdSec',decisions:'Решения CrowdSec'}[key],field=key==='unique'?'uniqueIps':key,type={unique:'request',requests:'request',alerts:'alert',decisions:'decision'}[key],rows=(view.countryDetails||[]).filter(c=>c[field]>0).sort((a,b)=>b[field]-a[field]);const explanation={unique:'Уникальный IP не равен человеку. Учитываются IP HTTP-запросов.',requests:'Количество HTTP-запросов. Ошибки HTTP сами по себе не доказывают атаку.',alerts:'Срабатывания сценариев CrowdSec. Одно срабатывание может объединять несколько запросов.',decisions:'Решения CrowdSec, например ban или captcha. Это не число отброшенных пакетов и не список активных банов.'}[key];showDetails(title,filterNote()+''+explanation+'
Загружаем историю…
IP не найдены.
'):eventList(d.events);el('historyPanel').innerHTML=''+num(start)+'–'+num(end)+' из '+num(d.total)+' · последние 24 часа на момент открытия. '+(d.cityReady?'Город/регион приблизительны; отсутствие названия означает, что база их не определила.':'Для городов установите базу GeoIP в настройках карты.')+'
'+body+''+safe(e.message)+'
'}finally{if(version===historyVersion)historyBusy=false}} +el('details').addEventListener('close',()=>{historyVersion++;historyBusy=false;historyQuery=null}); applyTrafficTheme(document.documentElement.dataset.theme||'dark');clampMap(); (async()=>{try{const r=await fetch('/api/traffic/world');if(!r.ok)throw Error();world=await r.json();countryMap=new Map(world.map(c=>[c.code,c]));requestAnimationFrame(drawMap);await poll();setInterval(poll,2000)}catch(e){el('status').textContent='Не удалось загрузить карту. Обновите страницу.'}})();setInterval(()=>{el('clock').textContent=new Date().toLocaleTimeString('ru-RU')},1000); diff --git a/traffic.go b/traffic.go index c761777..183ca36 100644 --- a/traffic.go +++ b/traffic.go @@ -12,6 +12,8 @@ import ( ) type TrafficEvent struct { + City string `json:"city,omitempty"` + Region string `json:"region,omitempty"` Monitoring bool `json:"monitoring"` ID string `json:"id"` Sequence int64 `json:"sequence,omitempty"` @@ -60,6 +62,7 @@ type TrafficCountryDetail struct { LastSeen int64 `json:"lastSeen"` } type TrafficView struct { + MaxSequence int64 `json:"maxSequence"` CountryDetails []TrafficCountryDetail `json:"countryDetails"` Network *NetworkMapView `json:"network,omitempty"` @@ -170,8 +173,11 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring . network := makeNetworkMap(snapshot, external) v.Network = &network } - where := ` WHERE ts>=? AND agent=?` - args := []any{now - 86400, v.Settings.AgentID} + if err := s.db.QueryRow(`SELECT COALESCE(MAX(seq),0) FROM traffic_events WHERE agent=?`, v.Settings.AgentID).Scan(&v.MaxSequence); err != nil { + return v, err + } + where := ` WHERE ts>=? AND agent=? AND ts<=? AND seq<=?` + args := []any{now - 86400, v.Settings.AgentID, now, v.MaxSequence} if hide { clause, params := monitoringSQL(v.Settings.MonitoringRules) where += clause @@ -203,6 +209,7 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring . return v, err } e.Monitoring = isMonitoringRequest(e, v.Settings.MonitoringRules) + e.City, e.Region = lookupTrafficCity(e.IP) v.Events = append(v.Events, e) } err = rows.Err() @@ -291,6 +298,8 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring . func trafficRoutes(mux *http.ServeMux, s *Store) { trafficSettingsRoute(mux, s) + s.trafficHistoryRoute(mux) + trafficCityRoutes(mux) networkRoutes(mux, s) mux.HandleFunc("/traffic", func(w http.ResponseWriter, r *http.Request) { if !s.TrafficSettings().Enabled { diff --git a/traffic_city.go b/traffic_city.go new file mode 100644 index 0000000..5d37dad --- /dev/null +++ b/traffic_city.go @@ -0,0 +1,166 @@ +package main + +import ( + "compress/gzip" + "crypto/sha1" + "encoding/hex" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/oschwald/maxminddb-golang" +) + +const cityDownloadURL = "https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz" +const cityDatabaseSHA1 = "df17bd24390108ed1d24dcb7718dc60f6b9abd4f" + +var trafficCity struct { + sync.Mutex + once sync.Once + db *maxminddb.Reader + running bool + message string +} + +func trafficCityPath() string { + db := os.Getenv("DASHBOARD_DB_PATH") + if db == "" { + db = defaultDatabasePath + } + return filepath.Join(filepath.Dir(db), "dbip-city.mmdb") +} +func trafficCityReady() bool { + trafficCity.once.Do(func() { + trafficCity.Lock() + defer trafficCity.Unlock() + trafficCity.db, _ = maxminddb.Open(trafficCityPath()) + if trafficCity.db != nil && !strings.Contains(strings.ToLower(trafficCity.db.Metadata.DatabaseType), "city") { + trafficCity.db.Close() + trafficCity.db = nil + } + }) + trafficCity.Lock() + defer trafficCity.Unlock() + return trafficCity.db != nil +} +func lookupTrafficCity(text string) (string, string) { + ip := net.ParseIP(text) + if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || !ip.IsGlobalUnicast() || !trafficCityReady() { + return "", "" + } + trafficCity.Lock() + defer trafficCity.Unlock() + var r struct { + City struct { + Names map[string]string `maxminddb:"names"` + } `maxminddb:"city"` + Subdivisions []struct { + Names map[string]string `maxminddb:"names"` + } `maxminddb:"subdivisions"` + } + if trafficCity.db.Lookup(ip, &r) != nil { + return "", "" + } + region := "" + if len(r.Subdivisions) > 0 { + region = cityName(r.Subdivisions[0].Names) + } + return cityName(r.City.Names), region +} +func trafficCityRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/settings/traffic/city-database", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" && r.Method != "POST" { + w.WriteHeader(405) + return + } + trafficCityReady() + trafficCity.Lock() + if r.Method == "POST" && !trafficCity.running && trafficCity.db == nil { + trafficCity.running = true + trafficCity.message = "Загрузка базы городов (около 58 МБ)…" + go func() { + err := installTrafficCity() + trafficCity.Lock() + defer trafficCity.Unlock() + trafficCity.running = false + if err != nil { + trafficCity.message = "Не удалось установить базу: " + err.Error() + } else { + trafficCity.message = "База городов DB-IP City Lite 2026-10 установлена" + } + }() + } + status := map[string]any{"ready": trafficCity.db != nil, "running": trafficCity.running, "message": trafficCity.message, "date": "2026-10"} + trafficCity.Unlock() + writeJSON(w, status, nil) + }) +} +func installTrafficCity() error { return installTrafficCityFrom(cityDownloadURL, cityDatabaseSHA1) } +func installTrafficCityFrom(source, expectedHash string) error { + client := &http.Client{Timeout: 15 * time.Minute} + response, err := client.Get(source) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != 200 { + return fmt.Errorf("HTTP %d", response.StatusCode) + } + reader, err := gzip.NewReader(io.LimitReader(response.Body, 70<<20)) + if err != nil { + return err + } + defer reader.Close() + path := trafficCityPath() + if err = os.MkdirAll(filepath.Dir(path), 0750); err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(path), ".city-*.mmdb") + if err != nil { + return err + } + defer os.Remove(file.Name()) + defer file.Close() + hash := sha1.New() + n, err := io.Copy(io.MultiWriter(file, hash), io.LimitReader(reader, (160<<20)+1)) + if err != nil { + return err + } + if n > 160<<20 { + return fmt.Errorf("база превышает лимит размера") + } + if hex.EncodeToString(hash.Sum(nil)) != expectedHash { + return fmt.Errorf("контрольная сумма базы не совпала") + } + if err = file.Sync(); err != nil { + return err + } + if err = file.Close(); err != nil { + return err + } + db, err := maxminddb.Open(file.Name()) + if err != nil { + return err + } + if !strings.Contains(strings.ToLower(db.Metadata.DatabaseType), "city") { + db.Close() + return fmt.Errorf("неверный тип GeoIP-базы") + } + if err = os.Rename(file.Name(), path); err != nil { + db.Close() + return err + } + trafficCity.Lock() + defer trafficCity.Unlock() + if trafficCity.db != nil { + trafficCity.db.Close() + } + trafficCity.db = db + return nil +} diff --git a/traffic_city_test.go b/traffic_city_test.go new file mode 100644 index 0000000..2dc59fd --- /dev/null +++ b/traffic_city_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "compress/gzip" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestCityInstallRejectsBadDownload(t *testing.T) { + dir := t.TempDir() + t.Setenv("DASHBOARD_DB_PATH", filepath.Join(dir, "dashboard.db")) + existing := []byte("existing database") + if err := os.WriteFile(trafficCityPath(), existing, 0600); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + g := gzip.NewWriter(w) + g.Write([]byte("corrupt database")) + g.Close() + })) + defer server.Close() + if err := installTrafficCityFrom(server.URL, cityDatabaseSHA1); err == nil { + t.Fatal("bad checksum accepted") + } + got, err := os.ReadFile(trafficCityPath()) + if err != nil || string(got) != string(existing) { + t.Fatal("existing database replaced on failure") + } + files, _ := filepath.Glob(filepath.Join(dir, ".city-*")) + if len(files) != 0 { + t.Fatal("partial file left behind") + } +} +func TestCityDatabaseDownloaded(t *testing.T) { + source := os.Getenv("DASHBOARD_CITY_TEST_GZIP") + if source == "" { + t.Skip("optional official database integration test") + } + t.Setenv("DASHBOARD_DB_PATH", filepath.Join(t.TempDir(), "dashboard.db")) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, source) })) + defer server.Close() + trafficCityReady() + if err := installTrafficCityFrom(server.URL, cityDatabaseSHA1); err != nil { + t.Fatal(err) + } + city, region := lookupTrafficCity("20.194.96.114") + t.Logf("city=%q region=%q", city, region) + if city == "" { + t.Fatal("public IP city not decoded") + } +} diff --git a/traffic_history.go b/traffic_history.go new file mode 100644 index 0000000..d649993 --- /dev/null +++ b/traffic_history.go @@ -0,0 +1,144 @@ +package main + +import ( + "net/http" + "strconv" + "strings" + "time" +) + +type TrafficIPDetail struct { + IP string `json:"ip"` + Country string `json:"country"` + Requests int64 `json:"requests"` + LastSeen int64 `json:"lastSeen"` + City string `json:"city,omitempty"` + Region string `json:"region,omitempty"` +} +type TrafficHistory struct { + Events []TrafficEvent `json:"events"` + IPs []TrafficIPDetail `json:"ips"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Snapshot int64 `json:"snapshot"` + MaxSequence int64 `json:"maxSequence"` + CityReady bool `json:"cityReady"` +} + +func (s *Store) trafficHistoryRoute(mux *http.ServeMux) { + mux.HandleFunc("/api/traffic/history", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + w.WriteHeader(405) + return + } + if !s.TrafficSettings().Enabled { + http.Error(w, "Карта отключена", 404) + return + } + q := r.URL.Query() + page, _ := strconv.Atoi(q.Get("page")) + snapshot, _ := strconv.ParseInt(q.Get("snapshot"), 10, 64) + maxSeq, _ := strconv.ParseInt(q.Get("maxSequence"), 10, 64) + if q.Get("maxSequence") == "0" { + maxSeq = -1 + } + if page < 0 || page > 10000 { + http.Error(w, "Invalid page", 400) + return + } + kind := q.Get("kind") + metricType := q.Get("type") + if metricType != "" && metricType != "request" && metricType != "alert" && metricType != "decision" { + http.Error(w, "Invalid type", 400) + return + } + if kind != "" && kind != "request" && kind != "alert" && kind != "decision" { + http.Error(w, "Invalid kind", 400) + return + } + result, err := s.TrafficHistory(q.Get("domain"), kind, q.Get("country"), q.Get("ip"), q.Get("type"), q.Get("external") == "1", q.Get("hideMonitoring") == "1", q.Get("group") == "ip", page, snapshot, maxSeq) + writeJSON(w, result, err) + }) +} +func (s *Store) TrafficHistory(domain, kind, country, ip, metricType string, external, hide, group bool, page int, snapshot, maxSeq int64) (TrafficHistory, error) { + now := time.Now().Unix() + if snapshot <= 0 || snapshot > now { + snapshot = now + } + if snapshot < now-86400 { + snapshot = now + } + settings := s.TrafficSettings() + v := TrafficHistory{Events: []TrafficEvent{}, IPs: []TrafficIPDetail{}, Page: page, PageSize: 50, Snapshot: snapshot, CityReady: trafficCityReady()} + if maxSeq == 0 { + if err := s.db.QueryRow(`SELECT COALESCE(MAX(seq),0) FROM traffic_events WHERE agent=?`, settings.AgentID).Scan(&maxSeq); err != nil { + return v, err + } + } + v.MaxSequence = maxSeq + where := ` WHERE agent=? AND ts>=? AND ts<=? AND seq<=?` + args := []any{settings.AgentID, snapshot - 86400, snapshot, maxSeq} + if hide { + clause, values := monitoringSQL(settings.MonitoringRules) + where += clause + args = append(args, values...) + } + if external { + where += ` AND country!='LAN'` + } + for _, filter := range []struct{ column, value string }{{"domain", domain}, {"kind", kind}, {"kind", metricType}, {"country", country}, {"ip", ip}} { + if filter.value != "" { + where += " AND " + filter.column + "=?" + args = append(args, filter.value) + } + } + count := "COUNT(*)" + if group { + where += ` AND kind='request'` + count = "COUNT(DISTINCT ip)" + } + if err := s.db.QueryRow("SELECT "+count+" FROM traffic_events"+where, args...).Scan(&v.Total); err != nil { + return v, err + } + paged := append(append([]any{}, args...), v.PageSize, page*v.PageSize) + if group { + rows, err := s.db.Query(`SELECT ip,MIN(country),COUNT(*),MAX(ts) FROM traffic_events`+where+` GROUP BY ip ORDER BY COUNT(*) DESC,ip LIMIT ? OFFSET ?`, paged...) + if err != nil { + return v, err + } + defer rows.Close() + for rows.Next() { + var item TrafficIPDetail + if err = rows.Scan(&item.IP, &item.Country, &item.Requests, &item.LastSeen); err != nil { + return v, err + } + item.City, item.Region = lookupTrafficCity(item.IP) + v.IPs = append(v.IPs, item) + } + return v, rows.Err() + } + rows, err := s.db.Query(`SELECT seq,event_id,ts,kind,ip,domain,method,path,status,reason,country,agent FROM traffic_events`+where+` ORDER BY ts DESC,seq DESC LIMIT ? OFFSET ?`, paged...) + if err != nil { + return v, err + } + defer rows.Close() + for rows.Next() { + var e TrafficEvent + if err = rows.Scan(&e.Sequence, &e.ID, &e.Time, &e.Kind, &e.IP, &e.Domain, &e.Method, &e.Path, &e.Status, &e.Reason, &e.Country, &e.Agent); err != nil { + return v, err + } + e.Monitoring = isMonitoringRequest(e, settings.MonitoringRules) + e.City, e.Region = lookupTrafficCity(e.IP) + v.Events = append(v.Events, e) + } + return v, rows.Err() +} +func cityName(names map[string]string) string { + for _, lang := range []string{"ru", "en"} { + if s := strings.TrimSpace(names[lang]); s != "" { + return s + } + } + return "" +} diff --git a/traffic_history_test.go b/traffic_history_test.go new file mode 100644 index 0000000..01dc019 --- /dev/null +++ b/traffic_history_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "fmt" + "testing" + "time" +) + +func TestTrafficHistoryFullPaginationAndSnapshot(t *testing.T) { + s, a := trafficFixture(t) + if _, err := s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID}); err != nil { + t.Fatal(err) + } + now := time.Now().Unix() + batch := TrafficBatch{} + for i := 0; i < 137; i++ { + batch.Events = append(batch.Events, TrafficEvent{ID: fmt.Sprint(i), Time: now - 60, Kind: "request", IP: "8.8.8.8", Domain: "example.test"}) + } + batch.Events = append(batch.Events, TrafficEvent{ID: "old-alert", Time: now - 3600, Kind: "alert", IP: "1.1.1.1", Domain: "other.test"}) + if err := s.SaveTraffic(a.ID, batch); err != nil { + t.Fatal(err) + } + first, err := s.TrafficHistory("", "request", "", "", "", false, false, false, 0, now, 0) + if err != nil || first.Total != 137 || len(first.Events) != 50 { + t.Fatalf("first: %+v %v", first, err) + } + if err := s.SaveTraffic(a.ID, TrafficBatch{Events: []TrafficEvent{{ID: "new", Time: now - 60, Kind: "request", IP: "8.8.8.8"}}}); err != nil { + t.Fatal(err) + } + seen := map[int64]bool{} + for page := 0; page < 3; page++ { + h, err := s.TrafficHistory("", "request", "", "", "", false, false, false, page, first.Snapshot, first.MaxSequence) + if err != nil || h.Total != 137 { + t.Fatalf("page %d: %v total %d", page, err, h.Total) + } + for _, e := range h.Events { + if seen[e.Sequence] { + t.Fatal("duplicate page event") + } + seen[e.Sequence] = true + } + } + if len(seen) != 137 { + t.Fatalf("missing history: %d", len(seen)) + } + h, err := s.TrafficHistory("other.test", "alert", "", "", "", false, false, false, 0, now, 0) + if err != nil || h.Total != 1 || h.Events[0].ID != "old-alert" { + t.Fatalf("old alert missing: %+v %v", h, err) + } + h, err = s.TrafficHistory("", "alert", "", "", "request", false, false, false, 0, now, 0) + if err != nil || h.Total != 0 { + t.Fatal("metric must respect global kind filter") + } + h, err = s.TrafficHistory("example.test", "", "", "", "", false, false, true, 0, now, 0) + if err != nil || h.Total != 1 || len(h.IPs) != 1 || h.IPs[0].Requests != 137 { + t.Fatalf("IP summary %+v %v", h, err) + } + h, err = s.TrafficHistory("", "", "ZZ", "", "", false, false, false, 0, now, 0) + if err != nil || h.Total != 0 { + t.Fatal("country filter ignored") + } + h, err = s.TrafficHistory("", "", "", "1.1.1.1", "", false, false, false, 0, now, 0) + if err != nil || h.Total != 1 { + t.Fatal("IP filter ignored") + } +} +func TestCityNamesAndLocalAddresses(t *testing.T) { + if cityName(map[string]string{"ru": "Москва", "en": "Moscow"}) != "Москва" || cityName(map[string]string{"en": "Amsterdam"}) != "Amsterdam" { + t.Fatal("city localization") + } + for _, ip := range []string{"192.168.0.10", "127.0.0.1", "::1", "fe80::1", "invalid"} { + c, r := lookupTrafficCity(ip) + if c != "" || r != "" { + t.Fatal("local IP geolocated") + } + } +} + +func TestTrafficHistoryMonitoringAndAgentIsolation(t *testing.T) { + s, a := trafficFixture(t) + s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, MonitoringRules: []MonitoringRule{{IP: "192.168.0.10"}}}) + now := time.Now().Unix() + batch := TrafficBatch{Events: []TrafficEvent{{ID: "check", Time: now, Kind: "request", IP: "192.168.0.10"}, {ID: "alert", Time: now, Kind: "alert", IP: "192.168.0.10"}, {ID: "public", Time: now, Kind: "request", IP: "8.8.8.8"}}} + if err := s.SaveTraffic(a.ID, batch); err != nil { + t.Fatal(err) + } + token, _ := s.CreateAgentEnrollmentToken() + other, err := s.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "other", VMID: 103}) + if err != nil { + t.Fatal(err) + } + if err = s.SaveTraffic(other.ID, batch); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + external, hide bool + want int64 + }{{false, false, 3}, {false, true, 2}, {true, false, 1}, {true, true, 1}} { + h, err := s.TrafficHistory("", "", "", "", "", tc.external, tc.hide, false, 0, now, 0) + if err != nil || h.Total != tc.want { + t.Fatalf("filters %+v total=%d err=%v", tc, h.Total, err) + } + for _, e := range h.Events { + if e.Agent != a.ID { + t.Fatal("foreign agent leaked") + } + } + } +} diff --git a/web.go b/web.go index 5ab83d1..e7b2bea 100644 --- a/web.go +++ b/web.go @@ -890,6 +890,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}Одно правило на строку, до 32. Домен и путь необязательны; внутри правила условия объединяются. IP без уточнений помечает HTTP-проверки и сетевые потоки этого инициатора. Домен/путь применяются только к HTTP. Скрытие — фильтр просмотра: история сохраняется, срабатывания и решения CrowdSec не скрываются. Пример: 192.168.0.10 | example.org | /health
+