diff --git a/README.md b/README.md index 7a0fef7..ee43581 100644 --- a/README.md +++ b/README.md @@ -678,3 +678,16 @@ DB-IP City Lite загружается сначала из вложения ре задать `GITEA_URL` в окружении сервиса Dashboard. База хранится отдельно от Git-истории и бинарника приложения; контрольная сумма проверяется независимо от источника. Официальный файл: https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz + +## Города на карте (v0.26.0) + +При установленной базе городов и масштабе от 200% на карте появляются города +с HTTP/CrowdSec-активностью. Маркеры используют приблизительные координаты DB-IP, +подписи не увеличиваются вместе с картой. Наведение показывает статистику, +нажатие открывает карточку города с итогами и ссылкой на всю историю страны. +HTTP/CrowdSec-импульсы также начинаются в городе, если он определён. + +Счётчики городов учитывают выбранного агента и фильтры за 24 часа. На карте +показываются до 500 наиболее активных городов; остальные события и IP без города +остаются в точках стран. TCP/UDP/ICMP пока отображаются на уровне стран. +Город может относиться к провайдеру/VPN, а не фактическому местоположению человека. diff --git a/assets/traffic.html b/assets/traffic.html index 957e669..65d051e 100644 --- a/assets/traffic.html +++ b/assets/traffic.html @@ -32,7 +32,7 @@ html[data-theme="light"] dialog,html[data-theme="light"] .tooltip,html[data-them
-
100%
HTTPСрабатываниеРешение
Без геолокации: —
+
100%
HTTPСрабатываниеРешение
Без геолокации: —

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

— req/s

География

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

Источники

ТОП 7 / IP
@@ -52,9 +52,11 @@ function networkVisible(){return !!view?.network&&el('networkOverlay').checked&& function networkFresh(){return networkVisible()&&view.network.receivedAt>0&&view.now+(Date.now()-lastPoll)/1000-view.network.receivedAt<25} function drawMap(now){const canvas=el('world'),w=Math.max(1,Math.floor(canvas.clientWidth)),h=Math.max(1,Math.floor(canvas.clientHeight)),dpr=Math.min(devicePixelRatio||1,2);if(canvas.width!==Math.round(w*dpr)||canvas.height!==Math.round(h*dpr)){canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr);baseMap=mapBackground(w,h)}const ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,w,h);const cacheKey=[w,h,dpr,mapZoom,mapX,mapY,isLight(),world.length].join(':');if(!baseMap||baseMapKey!==cacheKey){baseMap=mapBackground(w,h);baseMapKey=cacheKey}ctx.drawImage(baseMap,0,0,w,h);ctx.save();ctx.translate(mapX,mapY);ctx.scale(mapZoom,mapZoom);hitPoints=[];drawCountryHighlight(ctx,w,h); if(view){const destination=countryMap.get(view.settings.serverCountry),end=destination?project(destination.lon,destination.lat,w,h):[w*.82,h*.88]; - for(const c of view.countries){const country=countryMap.get(c.name);if(!country)continue;const [x,y]=project(country.lon,country.lat,w,h);ctx.fillStyle=colors.request;ctx.globalAlpha=.75;ctx.beginPath();ctx.arc(x,y,(2+Math.min(5,Math.log2(c.count+1)))/mapZoom,0,Math.PI*2);ctx.fill();hitPoints.push({x,y,text:country.name+' · '+num(c.count)+' событий'})}ctx.globalAlpha=1; + const cityLayer=mapZoom>=2&&view.cityReady,cityCounts=new Map();el("cityLayerNote").textContent=view.cityReady?(cityLayer?"Города: "+(view.cities||[]).length+(view.citiesLimited?" (топ 500)":""):"Города — от 200%") : "Города: нужна GeoIP-база"; + if(cityLayer){const labels=[];for(const c of view.cities||[]){const total=c.requests+c.alerts+c.decisions;cityCounts.set(c.country,(cityCounts.get(c.country)||0)+total);const [x,y]=project(c.longitude,c.latitude,w,h),sx=x*mapZoom+mapX,sy=y*mapZoom+mapY;if(sx<0||sx>w||sy<0||sy>h)continue;ctx.fillStyle=c.requests?colors.request:c.alerts?colors.alert:colors.decision;ctx.beginPath();ctx.arc(x,y,(3+Math.min(3,Math.log2(total+1)/2))/mapZoom,0,Math.PI*2);ctx.fill();hitPoints.push({x,y,city:c});ctx.font=(11/mapZoom)+'px system-ui';ctx.textAlign='left';const width=ctx.measureText(c.city).width*mapZoom;if(!labels.some(p=>Math.abs(sx-p.x)Math.hypot(h.x-x,h.y-y)<1);if(existing)existing.text+=' · '+text;else hitPoints.push({x,y,text:c.name+' · '+text})}ctx.globalAlpha=1} - pulses=pulses.filter(p=>now-p.start<3200&&(!p.network||networkFresh()));if(matchMedia('(prefers-reduced-motion: reduce)').matches)pulses=[];for(const p of pulses){const c=countryMap.get(p.country);if(!c)continue;const remote=project(c.lon,c.lat,w,h),start=p.direction==='out'?end:remote,finish=p.direction==='out'?remote:end,t=Math.min(1,(now-p.start)/2400),control=[(start[0]+finish[0])/2,(start[1]+finish[1])/2-Math.min(h*.3,Math.abs(start[0]-finish[0])*.3+30)],fade=Math.min(1,(3200-(now-p.start))/700);ctx.strokeStyle=colors[p.kind];ctx.globalAlpha=.4*fade;ctx.lineWidth=1/mapZoom;ctx.beginPath();ctx.moveTo(...start);ctx.quadraticCurveTo(...control,...finish);ctx.stroke();const x=(1-t)**2*start[0]+2*(1-t)*t*control[0]+t*t*finish[0],y=(1-t)**2*start[1]+2*(1-t)*t*control[1]+t*t*finish[1];ctx.fillStyle=colors[p.kind];ctx.globalAlpha=fade;ctx.shadowColor=colors[p.kind];ctx.shadowBlur=12;ctx.beginPath();ctx.arc(x,y,2.5/mapZoom,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0} + pulses=pulses.filter(p=>now-p.start<3200&&(!p.network||networkFresh()));if(matchMedia('(prefers-reduced-motion: reduce)').matches)pulses=[];for(const p of pulses){const c=countryMap.get(p.country);if(!c)continue;const remote=cityLayer&&p.location?.located?project(p.location.longitude,p.location.latitude,w,h):project(c.lon,c.lat,w,h),start=p.direction==='out'?end:remote,finish=p.direction==='out'?remote:end,t=Math.min(1,(now-p.start)/2400),control=[(start[0]+finish[0])/2,(start[1]+finish[1])/2-Math.min(h*.3,Math.abs(start[0]-finish[0])*.3+30)],fade=Math.min(1,(3200-(now-p.start))/700);ctx.strokeStyle=colors[p.kind];ctx.globalAlpha=.4*fade;ctx.lineWidth=1/mapZoom;ctx.beginPath();ctx.moveTo(...start);ctx.quadraticCurveTo(...control,...finish);ctx.stroke();const x=(1-t)**2*start[0]+2*(1-t)*t*control[0]+t*t*finish[0],y=(1-t)**2*start[1]+2*(1-t)*t*control[1]+t*t*finish[1];ctx.fillStyle=colors[p.kind];ctx.globalAlpha=fade;ctx.shadowColor=colors[p.kind];ctx.shadowBlur=12;ctx.beginPath();ctx.arc(x,y,2.5/mapZoom,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0} ctx.globalAlpha=1;ctx.strokeStyle=colors.decision;ctx.lineWidth=1.5/mapZoom;ctx.beginPath();ctx.arc(...end,7/mapZoom,0,Math.PI*2);ctx.stroke();ctx.fillStyle=isLight()?'#554496':'#e7dcff';ctx.beginPath();ctx.arc(...end,3/mapZoom,0,Math.PI*2);ctx.fill();ctx.font=(11/mapZoom)+'px system-ui';ctx.textAlign='center';ctx.fillText(destination?'NPM · '+destination.name:'NPM · геопозиция не задана',end[0],end[1]+22/mapZoom); } ctx.restore(); @@ -70,7 +72,7 @@ function render(data){view=data;el('networkLink').hidden=!data.settings.networkE const unmapped=data.countries.filter(c=>!countryMap.has(c.name)).reduce((n,c)=>n+c.count,0);el('unmapped').textContent='Локальные / без геолокации: '+num(unmapped); renderFeed(); if(networkFresh()&&data.network.receivedAt!==lastNetworkSnapshot){lastNetworkSnapshot=data.network.receivedAt;for(const p of data.network.points.slice(0,24)){pulses.push({country:p.country,kind:p.protocol,direction:p.direction,network:true,start:performance.now()})}} - for(const e of data.events){if(!seen.has(e.sequence)&&e.time>=data.now-15&&pulses.length<70)pulses.push({country:e.country,kind:e.kind,start:performance.now()});seen.add(e.sequence)}if(seen.size>5000)seen=new Set(data.events.map(e=>e.sequence));drawChart();if(paused)requestAnimationFrame(drawMap); + for(const e of data.events){if(!seen.has(e.sequence)&&e.time>=data.now-15&&pulses.length<70)pulses.push({country:e.country,location:e.location,kind:e.kind,start:performance.now()});seen.add(e.sequence)}if(seen.size>5000)seen=new Set(data.events.map(e=>e.sequence));drawChart();if(paused)requestAnimationFrame(drawMap); } function renderNetworkSummary(){el('networkSummary').hidden=!networkVisible();if(!networkVisible())return;const n=view.network,fresh=networkFresh();el('networkSummary').innerHTML=(fresh?'Сеть · ':'Сеть · нет свежего снимка · ')+['tcp','udp','icmp'].map(p=>''+p.toUpperCase()+' '+num(n[p])+'').join(' · ')+(n.error||n.limited?' · ⚠':'')+' ⓘ';el('networkSummary').title='Текущие потоки conntrack. Фильтры домена и HTTP/CrowdSec на них не влияют.'} el('networkSummary').onclick=()=>{const n=view.network;showDetails('Сетевая активность на карте','

Зелёный — TCP, розовый — UDP, жёлтый — ICMP/ICMPv6. Кольца показывают страны текущих потоков, импульс означает получение нового снимка (~5 с), а не новый запрос или пакет.

Входящий поток идёт к NPM, исходящий — от NPM. Если оба конца публичные, направление определяется по инициатору; это не трассировка маршрута.

HTTP-фильтры домена и типа события относятся только к HTTP/CrowdSec. «Только внешние IP» и «Скрыть мониторинг» применяются также к сетевому слою. Для сети используются только правила без домена/пути.

Снимок: '+(n.at?new Date(n.at*1000).toLocaleString('ru-RU'):'ещё не получен')+'. Локальные / без страны: '+num(n.unmapped)+'. Скрыто потоков мониторинга: '+num(n.monitoringHidden)+'.

'+safe(n.error||(n.limited?'Достигнут лимит 5 000 потоков':'При устаревании снимка сетевые кольца и импульсы скрываются.'))+'

Открыть таблицу TCP/UDP/ICMP ↗

')}; @@ -84,7 +86,7 @@ el('feedNext').onclick=()=>{if(!frozenEvents)frozenEvents=view.events.slice();fe function openEvent(target){const row=target.closest('[data-event]');if(!row)return;const e=feedVisible[Number(row.dataset.event)];if(!e)return;const fields=[['Источник',[e.ip,countryName(e.country),e.region,e.city].filter(Boolean).join(' · ')],['Время',new Date(e.time*1000).toLocaleString('ru-RU')],['Тип',kindLabels[e.kind]],['Мониторинг',e.monitoring?'Да — совпало с правилом':'Нет'],['Домен',e.domain||'Не указан'],['Детали',eventText(e)]];showDetails('Событие трафика',fields.map(([k,v])=>'
'+safe(k)+''+safe(v)+'
').join(''))} el('feed').onclick=e=>openEvent(e.target);el('feed').onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();openEvent(e.target)}}; el('sourceDetails').onclick=()=>showDetails('Состояние источника',statusMessages.map(m=>'

'+safe(m)+'

').join('')); -el('about').onclick=()=>showDetails('Как читать данные','

Голубой — HTTP-запросы, оранжевый — срабатывания CrowdSec, сиреневый — решения о ban или captcha.

Решение о бане не подтверждает конкретный отброшенный запрос. HTTP 401/403/502 сами по себе не означают блокировку CrowdSec.

Счётчики за 24 часа следуют выбранным фильтрам. Уникальный IP не равен человеку. Req/s — среднее за 60 секунд; график показывает запросы в минуту за последний час.

Точки на карте обозначают страны, а не точное местоположение людей. Локальные адреса остаются в ленте без страны. «Скрыть мониторинг» исключает из отображения HTTP-проверки по правилам в настройках; события CrowdSec сохраняются на экране. Исходная история не удаляется. В ленте показаны последние 100 событий; полная сохранённая история за 24 часа доступна в карточках и окнах стран; нажмите строку, чтобы увидеть детали целиком.

'); +el('about').onclick=()=>showDetails('Как читать данные','

Голубой — HTTP-запросы, оранжевый — срабатывания CrowdSec, сиреневый — решения о ban или captcha.

Решение о бане не подтверждает конкретный отброшенный запрос. HTTP 401/403/502 сами по себе не означают блокировку CrowdSec.

Счётчики за 24 часа следуют выбранным фильтрам. Уникальный IP не равен человеку. Req/s — среднее за 60 секунд; график показывает запросы в минуту за последний час.

При масштабе от 200% и установленной базе городов появляются города с HTTP/CrowdSec-активностью. Геолокация приблизительная. Сетевые потоки и события без города остаются на уровне стран. Показываются до 500 наиболее активных городов; остальные события учитываются в точках стран. Локальные адреса остаются в ленте без страны. «Скрыть мониторинг» исключает из отображения 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}} @@ -110,12 +112,14 @@ function countryNetwork(code){if(!view?.network)return '';const points=view.netw function filterNote(){return ''} 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 cityAt(x,y){if(mapZoom<2)return null;let nearest=null,distance=12;for(const p of hitPoints){if(!p.city)continue;const d=Math.hypot(x-(p.x*mapZoom+mapX),y-(p.y*mapZoom+mapY));if(d'+safe([countryName(c.country),c.region].filter(Boolean).join(' · '))+'

'+statGrid(c)+'

Последнее событие: '+new Date(c.lastSeen*1000).toLocaleString('ru-RU')+'

')} 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)+'
Срабатывания '+num(s.alerts)+' · решения '+num(s.decisions)+countryNetwork(c.code)+'24 ч · текущие фильтры · нажмите для деталей';tip.style.display='block';tip.style.left=Math.max(8,Math.min(innerWidth-tip.offsetWidth-8,e.clientX+14))+'px';tip.style.top=Math.max(8,Math.min(innerHeight-tip.offsetHeight-8,e.clientY+14))+'px';redrawMap()} +function showCountryTip(e){const r=el('world').getBoundingClientRect(),city=cityAt(e.clientX-r.left,e.clientY-r.top),c=city?countryMap.get(city.country):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=city||countryStats(c.code);tip.innerHTML=''+safe(city?city.city+' · '+c.name:c.name)+'HTTP '+num(s.requests)+' · IP '+num(s.uniqueIps)+'
Срабатывания '+num(s.alerts)+' · решения '+num(s.decisions)+(city?'':countryNetwork(c.code))+'24 ч · текущие фильтры · нажмите для деталей';tip.style.display='block';tip.style.left=Math.max(8,Math.min(innerWidth-tip.offsetWidth-8,e.clientX+14))+'px';tip.style.top=Math.max(8,Math.min(innerHeight-tip.offsetHeight-8,e.clientY+14))+'px';redrawMap()} const mapCanvas=el('world'); mapCanvas.onpointerdown=e=>{mapCanvas.setPointerCapture(e.pointerId);pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});drag={x:e.clientX,y:e.clientY,moved:pointers.size>1};hideCountryTip()}; mapCanvas.onpointermove=e=>{if(!pointers.has(e.pointerId)){showCountryTip(e);return}const old=pointers.get(e.pointerId),next={x:e.clientX,y:e.clientY};if(pointers.size===2){const other=[...pointers.entries()].find(([id])=>id!==e.pointerId)[1],before=Math.hypot(old.x-other.x,old.y-other.y),after=Math.hypot(next.x-other.x,next.y-other.y),r=mapCanvas.getBoundingClientRect();if(before>0)zoomMap(after/before,(next.x+other.x)/2-r.left,(next.y+other.y)/2-r.top);drag.moved=true}else{mapX+=next.x-old.x;mapY+=next.y-old.y;if(Math.hypot(next.x-drag.x,next.y-drag.y)>4)drag.moved=true;clampMap();redrawMap()}pointers.set(e.pointerId,next)}; -mapCanvas.onpointerup=e=>{const moved=drag?.moved; pointers.delete(e.pointerId);if(!moved&&pointers.size===0){const r=mapCanvas.getBoundingClientRect(),c=countryAt(e.clientX-r.left,e.clientY-r.top);if(c)openCountry(c.code)}if(!pointers.size)drag=null}; +mapCanvas.onpointerup=e=>{const moved=drag?.moved; pointers.delete(e.pointerId);if(!moved&&pointers.size===0){const r=mapCanvas.getBoundingClientRect(),c=countryAt(e.clientX-r.left,e.clientY-r.top);const city=cityAt(e.clientX-r.left,e.clientY-r.top);if(city)openCity(city);else if(c)openCountry(c.code)}if(!pointers.size)drag=null}; mapCanvas.onpointercancel=e=>{pointers.clear();drag=null;hideCountryTip()};mapCanvas.onpointerleave=()=>{hideCountryTip();redrawMap()}; window.addEventListener('resize',clampMap); 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()+'
'+title+''+num(view[field])+'

'+explanation+'

По странам

'+rows.map(c=>'').join('')+historyShell());startHistory({type,group:key==='unique'?'ip':''})}); diff --git a/traffic.go b/traffic.go index 183ca36..789008d 100644 --- a/traffic.go +++ b/traffic.go @@ -12,21 +12,22 @@ 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"` - 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"` + Location *TrafficCityLocation `json:"location,omitempty"` + City string `json:"city,omitempty"` + Region string `json:"region,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 { @@ -62,6 +63,9 @@ type TrafficCountryDetail struct { LastSeen int64 `json:"lastSeen"` } type TrafficView struct { + Cities []TrafficMapCity `json:"cities"` + CitiesLimited bool `json:"citiesLimited"` + CityReady bool `json:"cityReady"` MaxSequence int64 `json:"maxSequence"` CountryDetails []TrafficCountryDetail `json:"countryDetails"` @@ -209,7 +213,11 @@ 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) + g := lookupTrafficCityLocation(e.IP) + e.City, e.Region = g.City, g.Region + if g.Located { + e.Location = &g + } v.Events = append(v.Events, e) } err = rows.Err() @@ -257,6 +265,14 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring . if detailErr != nil { return v, detailErr } + v.CityReady = trafficCityReady() + if v.CityReady { + var cityErr error + v.Cities, v.CitiesLimited, cityErr = s.trafficMapCities(where, args) + if cityErr != nil { + return v, cityErr + } + } r, err := s.db.Query(`SELECT CAST((ts/60)*60 AS TEXT),COUNT(*) FROM traffic_events`+where+` AND kind='request' AND ts>=? GROUP BY ts/60 ORDER BY ts/60`, append(args, now-3600)...) if err != nil { return v, err diff --git a/traffic_city.go b/traffic_city.go index 64806eb..18c959c 100644 --- a/traffic_city.go +++ b/traffic_city.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "io" + "math" "net" "net/http" "os" @@ -26,6 +27,7 @@ var trafficCity struct { db *maxminddb.Reader running bool message string + cache map[string]TrafficCityLocation } func trafficCityPath() string { @@ -49,14 +51,34 @@ func trafficCityReady() bool { defer trafficCity.Unlock() return trafficCity.db != nil } + +type TrafficCityLocation struct { + City string `json:"city"` + Region string `json:"region,omitempty"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Located bool `json:"located"` +} + func lookupTrafficCity(text string) (string, string) { + g := lookupTrafficCityLocation(text) + return g.City, g.Region +} +func lookupTrafficCityLocation(text string) TrafficCityLocation { ip := net.ParseIP(text) if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || !ip.IsGlobalUnicast() || !trafficCityReady() { - return "", "" + return TrafficCityLocation{} } trafficCity.Lock() defer trafficCity.Unlock() + if g, ok := trafficCity.cache[text]; ok { + return g + } var r struct { + Location struct { + Latitude *float64 `maxminddb:"latitude"` + Longitude *float64 `maxminddb:"longitude"` + } `maxminddb:"location"` City struct { Names map[string]string `maxminddb:"names"` } `maxminddb:"city"` @@ -65,13 +87,24 @@ func lookupTrafficCity(text string) (string, string) { } `maxminddb:"subdivisions"` } if trafficCity.db.Lookup(ip, &r) != nil { - return "", "" + return TrafficCityLocation{} } region := "" if len(r.Subdivisions) > 0 { region = cityName(r.Subdivisions[0].Names) } - return cityName(r.City.Names), region + g := TrafficCityLocation{City: cityName(r.City.Names), Region: region} + if g.City != "" && r.Location.Latitude != nil && r.Location.Longitude != nil { + g.Latitude, g.Longitude = *r.Location.Latitude, *r.Location.Longitude + g.Located = !math.IsNaN(g.Latitude) && !math.IsNaN(g.Longitude) && math.Abs(g.Latitude) <= 90 && math.Abs(g.Longitude) <= 180 + } + if trafficCity.cache == nil { + trafficCity.cache = map[string]TrafficCityLocation{} + } + if len(trafficCity.cache) < 32768 { + trafficCity.cache[text] = g + } + return g } func trafficCityRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/settings/traffic/city-database", func(w http.ResponseWriter, r *http.Request) { @@ -178,5 +211,6 @@ func installTrafficCityFrom(source, expectedHash string) error { trafficCity.db.Close() } trafficCity.db = db + trafficCity.cache = nil return nil } diff --git a/traffic_city_test.go b/traffic_city_test.go index 2dc59fd..0eb8b79 100644 --- a/traffic_city_test.go +++ b/traffic_city_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "testing" + "time" ) func TestCityInstallRejectsBadDownload(t *testing.T) { @@ -46,6 +47,33 @@ func TestCityDatabaseDownloaded(t *testing.T) { if err := installTrafficCityFrom(server.URL, cityDatabaseSHA1); err != nil { t.Fatal(err) } + g := lookupTrafficCityLocation("20.194.96.114") + if !g.Located || g.Latitude < 30 || g.Latitude > 40 || g.Longitude < 120 || g.Longitude > 130 { + t.Fatalf("invalid Seoul coordinates: %+v", g) + } + s, a := trafficFixture(t) + s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID}) + now := time.Now().Unix() + if err := s.SaveTraffic(a.ID, TrafficBatch{Events: []TrafficEvent{ + {ID: "1", Time: now, Kind: "request", IP: "20.194.96.114", Domain: "a.test"}, + {ID: "2", Time: now, Kind: "request", IP: "20.194.96.114", Domain: "a.test"}, + {ID: "3", Time: now, Kind: "alert", IP: "20.194.96.114", Domain: "b.test"}, + {ID: "4", Time: now, Kind: "request", IP: "192.168.0.10"}, + }}); err != nil { + t.Fatal(err) + } + view, err := s.TrafficView("", "", false) + if err != nil || len(view.Cities) != 1 { + t.Fatalf("cities %+v err %v", view.Cities, err) + } + c := view.Cities[0] + if c.Requests != 2 || c.UniqueIPs != 1 || c.Alerts != 1 || c.City != g.City { + t.Fatalf("bad aggregation %+v", c) + } + filtered, err := s.TrafficView("b.test", "alert", false) + if err != nil || len(filtered.Cities) != 1 || filtered.Cities[0].Requests != 0 || filtered.Cities[0].Alerts != 1 { + t.Fatalf("city filter %+v %v", filtered.Cities, err) + } city, region := lookupTrafficCity("20.194.96.114") t.Logf("city=%q region=%q", city, region) if city == "" { diff --git a/traffic_map_cities.go b/traffic_map_cities.go new file mode 100644 index 0000000..246411d --- /dev/null +++ b/traffic_map_cities.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "sort" +) + +type TrafficMapCity struct { + TrafficCityLocation + Country string `json:"country"` + Requests int64 `json:"requests"` + UniqueIPs int64 `json:"uniqueIps"` + Alerts int64 `json:"alerts"` + Decisions int64 `json:"decisions"` + LastSeen int64 `json:"lastSeen"` +} + +func (s *Store) trafficMapCities(where string, args []any) ([]TrafficMapCity, bool, error) { + rows, err := s.db.Query(`SELECT ip,country,SUM(kind='request'),SUM(kind='alert'),SUM(kind='decision'),MAX(ts) FROM traffic_events`+where+` GROUP BY ip,country`, args...) + if err != nil { + return nil, false, err + } + defer rows.Close() + groups := map[string]*TrafficMapCity{} + for rows.Next() { + var ip, country string + var requests, alerts, decisions, last int64 + if err = rows.Scan(&ip, &country, &requests, &alerts, &decisions, &last); err != nil { + return nil, false, err + } + g := lookupTrafficCityLocation(ip) + if !g.Located { + continue + } + key := fmt.Sprintf("%s/%s/%s/%.5f/%.5f", country, g.Region, g.City, g.Latitude, g.Longitude) + c := groups[key] + if c == nil { + c = &TrafficMapCity{TrafficCityLocation: g, Country: country} + groups[key] = c + } + c.Requests += requests + c.Alerts += alerts + c.Decisions += decisions + if requests > 0 { + c.UniqueIPs++ + } + if last > c.LastSeen { + c.LastSeen = last + } + } + if err = rows.Err(); err != nil { + return nil, false, err + } + result := make([]TrafficMapCity, 0, len(groups)) + for _, c := range groups { + result = append(result, *c) + } + sort.Slice(result, func(i, j int) bool { + a, b := result[i], result[j] + x, y := a.Requests+a.Alerts+a.Decisions, b.Requests+b.Alerts+b.Decisions + if x != y { + return x > y + } + return fmt.Sprint(a.Country, a.Region, a.City, a.Latitude, a.Longitude) < fmt.Sprint(b.Country, b.Region, b.City, b.Latitude, b.Longitude) + }) + limited := len(result) > 500 + if limited { + result = result[:500] + } + return result, limited, nil +}