Files
op-packages/luci-app-secbox/htdocs/luci-static/resources/view/secbox/map.html
T

394 lines
9.7 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<title>SecBox Map</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<style>
html {
color-scheme: light dark;
}
html,
body {
height: 100%;
margin: 0;
}
body {
display: flex;
flex-direction: column;
}
#toolbar {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6em;
padding: 0.5em 0.8em;
box-sizing: border-box;
font-family: sans-serif;
font-size: 14px;
color: CanvasText;
background: Canvas;
border-bottom: 1px solid ButtonBorder;
}
#setSelect {
min-width: 16em;
max-width: 70vw;
padding: 0.25em;
}
#map {
flex: 1 1 auto;
min-height: 0;
width: 100%;
}
.title {
display: block;
text-align: center;
font-weight: bold;
}
.mono {
font-size: small;
font-family: monospace;
white-space: nowrap;
margin: 0.3em !important;
}
</style>
</head>
<body>
<div id="toolbar">
<label for="setSelect">Set:</label>
<select id="setSelect"></select>
</div>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
'use strict';
/* maximum display length of the free form GeoIP fields */
const MAX_IP_LEN = 38;
const MAX_AS_LEN = 38;
const MAX_CITY_LEN = 33;
/* zoom level applied when the view is centered on a single point */
const POINT_ZOOM = 6;
/* mapData key holding the geo data of the local uplink(s) */
const HOME_KEY = 'homeIP';
const HOME_STYLE = { color: '#378242', opacity: 1.0, fillColor: '#378242', fillOpacity: 0.5, radius: 6 };
const BLOCKED_STYLE = { color: '#C22121', opacity: 1.0, fillColor: '#C22121', fillOpacity: 0.5, radius: 3 };
/* initialize map, canvas rendering keeps the per Set marker volume cheap */
const map = L.map('map', {
zoom: 3,
minZoom: 2,
maxZoom: 18,
center: [50, 10],
worldCopyJump: true,
preferCanvas: true
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
/* feature groups, so redrawing is a single clearLayers() and getBounds() is available */
const homeLayer = L.featureGroup().addTo(map);
const blockedLayer = L.featureGroup().addTo(map);
const setSelect = document.getElementById('setSelect');
/* accept a coordinate only if it is a finite number in range */
function toLatLng(geo) {
const lat = Number(geo.lat);
const lon = Number(geo.lon);
if (geo.lat === null || geo.lat === undefined || geo.lat === '' ||
geo.lon === null || geo.lon === undefined || geo.lon === '')
return null;
if (!Number.isFinite(lat) || !Number.isFinite(lon))
return null;
if (lat < -90 || lat > 90 || lon < -180 || lon > 180)
return null;
return [lat, lon];
}
/* GeoIP lookups regularly return empty city/as fields, keep the entry anyway */
function fieldText(value, maxLength) {
const text = String(value === null || value === undefined ? '' : value).trim();
if (!text)
return 'n/a';
return text.length > maxLength ? text.slice(0, maxLength - 1) + '\u2026' : text;
}
/*
* Popups are built as DOM nodes, not as HTML strings, so the third party
* GeoIP fields cannot be interpreted as markup. Passing a function to
* bindPopup() defers this until the popup is actually opened.
*/
function buildPopup(title, entry) {
const content = document.createElement('div');
const heading = document.createElement('span');
const body = document.createElement('p');
const geo = entry.geo;
const latlng = entry.latlng;
const more = entry.count > 1 ? ' (+' + (entry.count - 1) + ' more)' : '';
const lines = [
'City: ' + fieldText(geo.city, MAX_CITY_LEN) + ' (' + fieldText(geo.countryCode, 8) + ')',
'Latitude : ' + latlng[0],
'Longitude: ' + latlng[1],
'IP: ' + fieldText(geo.query, MAX_IP_LEN) + more,
'AS: ' + fieldText(geo.as, MAX_AS_LEN)
];
heading.className = 'title';
heading.textContent = title;
body.className = 'mono';
lines.forEach(function (line, index) {
if (index)
body.appendChild(document.createElement('br'));
body.appendChild(document.createTextNode(line));
});
content.appendChild(heading);
content.appendChild(body);
return content;
}
function addMarker(layer, title, entry, style) {
L.circleMarker(entry.latlng, style)
.bindPopup(function () {
return buildPopup(title, entry);
})
.addTo(layer);
}
/*
* GeoIP resolves to city level, so several IPs of one Set regularly share a
* coordinate. Stacked markers are indistinguishable and only the topmost one
* can be opened, so collapse them into one marker and count the IPs behind it.
* Collapsing happens per Set, otherwise the Set that reaches a coordinate first
* would swallow the points of every later Set and make the counts depend on the
* order of ban_map.jsn.
*/
function addEntry(list, index, geo, latlng) {
const key = latlng[0] + ',' + latlng[1];
const known = index.get(key);
if (known) {
known.count++;
return;
}
const entry = { geo: geo, latlng: latlng, count: 1 };
index.set(key, entry);
list.push(entry);
}
/*
* mapData is an array of single key objects, the key being the SecBox Set name:
*
* [ {}, { "homeIP": {...} }, { "country_ir.v4": {...} }, ... ]
*/
function collectEntries(parsedData) {
const result = { home: [], sets: {} };
const homeIndex = new Map();
const setIndex = new Map();
parsedData.forEach(function (item) {
if (!item || typeof item !== 'object')
return;
Object.keys(item).forEach(function (setName) {
const geo = item[setName];
if (!geo || typeof geo !== 'object')
return;
const latlng = toLatLng(geo);
if (!latlng)
return;
if (setName === HOME_KEY) {
addEntry(result.home, homeIndex, geo, latlng);
return;
}
if (!result.sets[setName]) {
result.sets[setName] = [];
setIndex.set(setName, new Map());
}
addEntry(result.sets[setName], setIndex.get(setName), geo, latlng);
});
});
return result;
}
function countIPs(entries) {
let total = 0;
entries.forEach(function (entry) {
total += entry.count;
});
return total;
}
function setNames(sets) {
return Object.keys(sets).sort();
}
/*
* An empty selection renders all Sets, otherwise the selected one only.
* Every mapped location of a rendered Set is drawn, no cap is applied, so
* the result does not depend on how f_report() truncates set_elements.
*/
function renderBlocked(sets, selected) {
const names = selected ? [selected] : setNames(sets);
blockedLayer.clearLayers();
names.forEach(function (setName) {
const entries = sets[setName] || [];
entries.forEach(function (entry) {
addMarker(blockedLayer, 'blocked IP by ' + setName, entry, BLOCKED_STYLE);
});
});
/* the canvas renderer draws in insertion order, keep home on top */
homeLayer.eachLayer(function (marker) {
marker.bringToFront();
});
}
function fitLayer(layer) {
const layers = layer.getLayers();
if (!layers.length)
return false;
if (layers.length === 1)
map.setView(layers[0].getLatLng(), POINT_ZOOM);
else
map.fitBounds(layer.getBounds(), { maxZoom: POINT_ZOOM, padding: [30, 30] });
return true;
}
/* center on the local uplink(s), fall back to the blocked IPs */
function resetView() {
if (!fitLayer(homeLayer))
fitLayer(blockedLayer);
}
function addOption(value, label) {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
setSelect.appendChild(option);
}
function setNotice(label) {
setSelect.textContent = '';
addOption('', label);
setSelect.disabled = true;
}
function plural(count, noun) {
return count + ' ' + noun + (count === 1 ? '' : 's');
}
function optionLabel(name, entries) {
return name + ' (' + plural(countIPs(entries), 'IP') +
' at ' + plural(entries.length, 'location') + ')';
}
function fillSelect(sets) {
const names = setNames(sets);
let all = [];
names.forEach(function (setName) {
all = all.concat(sets[setName]);
});
if (!names.length) {
setNotice('No mappable Set entries');
return;
}
setSelect.textContent = '';
setSelect.disabled = false;
addOption('', optionLabel('All Sets', all));
names.forEach(function (setName) {
addOption(setName, optionLabel(setName, sets[setName]));
});
}
/* load the data handed over by the SecBox set report page */
const mapData = sessionStorage.getItem('mapData');
let parsedData = null;
if (mapData) {
try {
parsedData = JSON.parse(mapData);
} catch (error) {
console.error('SecBox: unable to parse mapData,', error);
}
}
if (!mapData) {
setNotice('No SecBox map data available');
} else if (!Array.isArray(parsedData)) {
setNotice('Unable to load SecBox map data');
} else {
const entries = collectEntries(parsedData);
entries.home.forEach(function (entry) {
addMarker(homeLayer, 'local IP', entry, HOME_STYLE);
});
fillSelect(entries.sets);
renderBlocked(entries.sets, '');
resetView();
setSelect.addEventListener('change', function () {
renderBlocked(entries.sets, setSelect.value);
if (!setSelect.value || !fitLayer(blockedLayer))
resetView();
});
}
</script>
</body>
</html>