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))}
PROXMOX / LIVE OBSERVATORY

Traffic space.

@@ -43,15 +44,18 @@ let world=[],countryMap=new Map(),view=null,kind='',paused=false,busy=false,puls const colors={request:'#5ed8ed',alert:'#ffbc70',decision:'#b7a0ff',tcp:'#b8ef72',udp:'#f69dde',icmp:'#ffd66d'},kindLabels={request:'HTTP-запрос',alert:'Срабатывание',decision:'Решение CrowdSec'}; const countryNames=new Intl.DisplayNames(['ru'],{type:'region'});const countryName=code=>code==='LAN'?'Локальная сеть':countryMap.get(code)?.name||(code&&/^[A-Z]{2}$/.test(code)?countryNames.of(code):'Не определена'); function project(lon,lat,w,h){const scale=Math.min(w/360,h/170);return [(w-360*scale)/2+(lon+180)*scale,(h-170*scale)/2+(85-lat)*scale]} -function mapBackground(w,h){const off=document.createElement('canvas');off.width=w;off.height=h;const ctx=off.getContext('2d'),mask=document.createElement('canvas');mask.width=w;mask.height=h;const m=mask.getContext('2d');m.fillStyle='#fff';world.forEach(c=>{const polygons=c.geometry.type==='Polygon'?[c.geometry.coordinates]:c.geometry.coordinates;polygons.forEach(poly=>{m.beginPath();poly.forEach(ring=>{ring.forEach(([lon,lat],i)=>{const [x,y]=project(lon,lat,w,h);if(i===0)m.moveTo(x,y);else m.lineTo(x,y)});m.closePath()});m.fill('evenodd')})});const pixels=m.getImageData(0,0,w,h).data;const step=Math.max(4,Math.round(w/210));ctx.fillStyle=isLight()?'#b6cbd4':'#324a5b';for(let y=0,row=0;y{const p=project(lon,lat,w,h);if(i)ctx.lineTo(...p);else ctx.moveTo(...p)});ctx.closePath()}ctx.fill('evenodd');ctx.stroke()}} +if(mapZoom>=2){ctx.font=(10/mapZoom)+'px system-ui';ctx.textAlign='center';ctx.fillStyle=isLight()?'#45616f':'#9fb7c6';const occupied=[];for(const c of world){const p=project(c.lon,c.lat,w,h),x=p[0]*mapZoom+mapX,y=p[1]*mapZoom+mapY;if(x<20||x>w-20||y<10||y>h-10)continue;const width=ctx.measureText(c.name).width*mapZoom;if(occupied.some(o=>Math.abs(x-o.x)<(width+o.w)/2+8&&Math.abs(y-o.y)<18))continue;ctx.fillText(c.name,p[0],p[1]+18/mapZoom);occupied.push({x,y,w:width})}}return off} +let baseMapKey=''; function networkVisible(){return !!view?.network&&el('networkOverlay').checked&&view.settings.networkEnabled} 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);ctx.save();ctx.translate(mapX,mapY);ctx.scale(mapZoom,mapZoom);if(baseMap)ctx.drawImage(baseMap,0,0);hitPoints=[];drawCountryHighlight(ctx,w,h); +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)),0,Math.PI*2);ctx.fill();hitPoints.push({x,y,text:country.name+' · '+num(c.count)+' событий'})}ctx.globalAlpha=1; - if(networkFresh()){for(const p of view.network.points){const c=countryMap.get(p.country);if(!c)continue;const [x,y]=project(c.lon,c.lat,w,h),radius=8+['tcp','udp','icmp'].indexOf(p.protocol)*3;ctx.strokeStyle=colors[p.protocol];ctx.lineWidth=1.4;ctx.globalAlpha=.8;ctx.beginPath();ctx.arc(x,y,radius,0,Math.PI*2);ctx.stroke();const text=p.protocol.toUpperCase()+' '+(p.direction==='out'?'← исходящие':'→ входящие')+': '+num(p.count)+' потоков';const existing=hitPoints.find(h=>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;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,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0} - ctx.globalAlpha=1;ctx.strokeStyle=colors.decision;ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(...end,7,0,Math.PI*2);ctx.stroke();ctx.fillStyle=isLight()?'#554496':'#e7dcff';ctx.beginPath();ctx.arc(...end,3,0,Math.PI*2);ctx.fill();ctx.font='11px system-ui';ctx.textAlign='center';ctx.fillText(destination?'NPM · '+destination.name:'NPM · геопозиция не задана',end[0],end[1]+22); + 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; + if(networkFresh()){for(const p of view.network.points){const c=countryMap.get(p.country);if(!c)continue;const [x,y]=project(c.lon,c.lat,w,h),radius=(8+['tcp','udp','icmp'].indexOf(p.protocol)*3)/mapZoom;ctx.strokeStyle=colors[p.protocol];ctx.lineWidth=1.4/mapZoom;ctx.globalAlpha=.8;ctx.beginPath();ctx.arc(x,y,radius,0,Math.PI*2);ctx.stroke();const text=p.protocol.toUpperCase()+' '+(p.direction==='out'?'← исходящие':'→ входящие')+': '+num(p.count)+' потоков';const existing=hitPoints.find(h=>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} + 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(); if(!document.hidden&&!paused)requestAnimationFrame(drawMap) @@ -77,10 +81,10 @@ function renderFeed(){if(!view)return;const rowHeight=innerHeight<=650&&innerWid el('feedPage').textContent=(feedPage+1)+' / '+pages;el('feedPrev').disabled=feedPage===0;el('feedNext').disabled=feedPage>=pages-1;el('feedMode').textContent=frozenEvents?'ИСТОРИЯ / '+items.length:'LIVE / '+items.length; } el('feedNext').onclick=()=>{if(!frozenEvents)frozenEvents=view.events.slice();feedPage++;renderFeed()};el('feedPrev').onclick=()=>{feedPage=Math.max(0,feedPage-1);renderFeed()};el('latest').onclick=()=>{frozenEvents=null;feedPage=0;renderFeed()}; -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)],['Время',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(''))} +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 событий доступны по страницам; нажмите строку, чтобы увидеть детали целиком.

'); +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 '
'+[['HTTP-запросы',s.requests],['Уникальные HTTP IP',s.uniqueIps],['Срабатывания',s.alerts],['Решения CrowdSec',s.decisions]].map(([k,v])=>'
'+k+''+num(v)+'
').join('')+'
'} function countryNetwork(code){if(!view?.network)return '';const points=view.network.points.filter(p=>p.country===code);return ''} function filterNote(){return ''} -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'):'нет за выбранный период')+'

'+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)+'
Срабатывания '+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()} const mapCanvas=el('world'); @@ -114,8 +118,13 @@ mapCanvas.onpointermove=e=>{if(!pointers.has(e.pointerId)){showCountryTip(e);ret 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.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('')+''+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()+'
'+title+''+num(view[field])+'

'+explanation+'

По странам

'+rows.map(c=>'').join('')+historyShell());startHistory({type,group:key==='unique'?'ip':''})}); +el('detailsBody').onclick=e=>{const b=e.target.closest('[data-country]');if(b)openCountry(b.dataset.country);const h=e.target.closest('[data-history-page]');if(h&&!historyBusy)loadHistory(Number(h.dataset.historyPage))}; +let historyQuery=null,historyVersion=0,historyBusy=false; +function historyShell(){return '

Загружаем историю…

'} +function startHistory(extra){historyVersion++;historyBusy=false;historyQuery={domain:el('domain').value,kind,external:el('external').checked?'1':'0',hideMonitoring:el('hideMonitoring').checked?'1':'0',snapshot:String(view.now),maxSequence:String(view.maxSequence),...extra};loadHistory(0)} +async function loadHistory(page){const version=historyVersion,query={...historyQuery,page:String(page)};historyBusy=true;try{const r=await fetch('/api/traffic/history?'+new URLSearchParams(query));if(!r.ok)throw Error('Не удалось загрузить историю: HTTP '+r.status);const d=await r.json();if(version!==historyVersion||!el('details').open||!el('historyPanel'))return;historyQuery.snapshot=String(d.snapshot);historyQuery.maxSequence=String(d.maxSequence);const pages=Math.max(1,Math.ceil(d.total/d.pageSize)),start=d.total?page*d.pageSize+1:0,end=Math.min(d.total,(page+1)*d.pageSize);let body=query.group==='ip'?(d.ips.length?'':'

IP не найдены.

'):eventList(d.events);el('historyPanel').innerHTML='

'+(query.group==='ip'?'Все уникальные HTTP IP':'История событий')+'

'+body+'
'+(page+1)+' / '+pages+'
'}catch(e){if(version===historyVersion&&el('historyPanel'))el('historyPanel').innerHTML='

'+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

+
DB-IP City Lite: локальная приблизительная геолокация, без отправки IP внешним сервисам. Устанавливается на сервер Dashboard.