- Full Spanish interface (all UI text, popups, charts, tables) - Dark and light mode support - Disclaimer banner: no data logged, public European service - Footer: Servicio ofrecido por Cloud Host (cloudhost.es) - Docker: single container (Redis + DataServer + AttackMapServer) - Remote T-Pot support via ELASTICSEARCH_URL env var (direct or SSH tunnel) - Based on telekom-security/t-pot-attack-map (Apache 2.0) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1539 lines
55 KiB
JavaScript
1539 lines
55 KiB
JavaScript
// Global WebSocket connection state variables
|
|
let webSocket = null;
|
|
let reconnectAttempts = 0;
|
|
let reconnectDelay = 60000; // Fixed 60 second delay
|
|
let heartbeatInterval = null;
|
|
let isReconnecting = false;
|
|
let connectionHealthCheck = null;
|
|
|
|
// Page visibility and connection health tracking
|
|
let isPageVisible = !document.hidden;
|
|
let isWakingUp = false; // Flag to suppress animation burst on tab wake
|
|
window.lastWebSocketMessageTime = Date.now(); // For connection health (heartbeat)
|
|
window.lastValidDataTime = Date.now(); // For "Connected" vs "Idle" state tracking
|
|
|
|
// Global connection flag for dashboard synchronization
|
|
window.webSocketConnected = false;
|
|
|
|
// FIX: Leaflet Grid Lines / Tile Gaps
|
|
// Override the internal _initTile method to force tiles to be 1px larger
|
|
// This creates a 1px overlap that hides the sub-pixel rendering gaps in Chrome/Edge
|
|
// We exclude Firefox (Gecko) because it handles rendering differently and this fix causes artifacts there
|
|
(function(){
|
|
if (L.Browser.gecko) return; // Skip for Firefox
|
|
|
|
var originalInitTile = L.GridLayer.prototype._initTile;
|
|
L.GridLayer.include({
|
|
_initTile: function (tile) {
|
|
originalInitTile.call(this, tile);
|
|
var tileSize = this.getTileSize();
|
|
tile.style.width = tileSize.x + 1 + 'px';
|
|
tile.style.height = tileSize.y + 1 + 'px';
|
|
}
|
|
});
|
|
})();
|
|
|
|
// Map theme support
|
|
var mapLayers = {
|
|
dark: L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
attribution: '<a href="https://www.openstreetmap.org/copyright">© OpenStreetMap</a> <a href="https://carto.com/attributions">© CARTO</a>',
|
|
detectRetina: true,
|
|
subdomains: 'abcd',
|
|
minZoom: 2,
|
|
maxZoom: 8,
|
|
tileSize: 256
|
|
}),
|
|
light: L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
|
attribution: '<a href="https://www.openstreetmap.org/copyright">© OpenStreetMap</a> <a href="https://carto.com/attributions">© CARTO</a>',
|
|
detectRetina: true,
|
|
subdomains: 'abcd',
|
|
minZoom: 2,
|
|
maxZoom: 8,
|
|
tileSize: 256
|
|
})
|
|
};
|
|
|
|
// Get current theme
|
|
var currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
|
|
var base = mapLayers[currentTheme];
|
|
|
|
// Check if map container is already initialized
|
|
if (window.map) {
|
|
window.map.remove();
|
|
}
|
|
|
|
var map = L.map('map', {
|
|
layers: [base],
|
|
tap: false, // ref https://github.com/Leaflet/Leaflet/issues/7255
|
|
center: new L.LatLng(0, 0),
|
|
trackResize: true,
|
|
worldCopyJump: true,
|
|
minZoom: 2,
|
|
maxZoom: 8,
|
|
zoom: 3,
|
|
zoomSnap: 0.2, // Allow fractional zoom levels
|
|
zoomDelta: 0.2, // Match zoomSnap
|
|
fullscreenControl: true,
|
|
fullscreenControlOptions: {
|
|
title:"Fullscreen Mode",
|
|
titleCancel:"Exit Fullscreen Mode"
|
|
}
|
|
});
|
|
|
|
// Make map globally accessible
|
|
window.map = map;
|
|
|
|
// Enhanced attack map with modern dashboard integration
|
|
|
|
// Make map globally accessible
|
|
window.map = map;
|
|
|
|
// Enhanced marker clustering
|
|
var circles = new L.LayerGroup();
|
|
var markers = new L.LayerGroup();
|
|
var attackLines = new L.LayerGroup();
|
|
|
|
map.addLayer(circles);
|
|
map.addLayer(markers);
|
|
map.addLayer(attackLines);
|
|
|
|
// Cache restoration function for map markers
|
|
window.processRestoredAttack = function(event) {
|
|
console.log('[MAP-RESTORE] Processing restored attack:', event);
|
|
|
|
// Skip if event doesn't have required data
|
|
if (!event.source_ip || !event.destination_ip) {
|
|
console.log('[MAP-RESTORE] Skipping event - missing IP data');
|
|
return;
|
|
}
|
|
|
|
// Create a simplified message object from cached event
|
|
const restoredMsg = {
|
|
// Source (attacker) data
|
|
country: event.country || 'Unknown',
|
|
iso_code: event.country_code || 'XX',
|
|
src_ip: event.source_ip || event.ip,
|
|
ip_rep: event.ip_rep || event.reputation || event.ip_reputation || 'Unknown',
|
|
color: event.color || getProtocolColor(event.protocol),
|
|
|
|
// Destination (honeypot) data - use original WebSocket field names
|
|
dst_country_name: event.dst_country_name || event.destination_country || 'Local',
|
|
dst_iso_code: event.dst_iso_code || event.destination_country_code || 'XX',
|
|
dst_ip: event.destination_ip,
|
|
tpot_hostname: event.tpot_hostname || event.honeypot || 'honeypot',
|
|
honeypot: event.honeypot,
|
|
protocol: event.protocol,
|
|
dst_port: event.destination_port || event.port,
|
|
|
|
// Coordinates (if available in cached data)
|
|
src_lat: event.source_lat,
|
|
src_long: event.source_lng || event.source_long,
|
|
dst_lat: event.destination_lat,
|
|
dst_long: event.destination_lng || event.destination_long
|
|
};
|
|
|
|
// If we have coordinates in the cached data, use them directly
|
|
if (restoredMsg.src_lat && restoredMsg.src_long && restoredMsg.dst_lat && restoredMsg.dst_long) {
|
|
const srcLatLng = new L.LatLng(restoredMsg.src_lat, restoredMsg.src_long);
|
|
const dstLatLng = new L.LatLng(restoredMsg.dst_lat, restoredMsg.dst_long);
|
|
|
|
restoreMarkerData(restoredMsg, srcLatLng, dstLatLng, event);
|
|
} else {
|
|
// Fallback: get coordinates from country/location data
|
|
Promise.all([
|
|
getCoordinates(restoredMsg.country, restoredMsg.iso_code),
|
|
getCoordinates(restoredMsg.dst_country_name, restoredMsg.dst_iso_code)
|
|
]).then(([srcCoords, dstCoords]) => {
|
|
if (srcCoords && dstCoords) {
|
|
const srcLatLng = new L.LatLng(srcCoords.lat, srcCoords.lng);
|
|
const dstLatLng = new L.LatLng(dstCoords.lat, dstCoords.lng);
|
|
|
|
restoreMarkerData(restoredMsg, srcLatLng, dstLatLng, event);
|
|
}
|
|
}).catch(error => {
|
|
console.log('[MAP-RESTORE] Error getting coordinates:', error);
|
|
});
|
|
}
|
|
};
|
|
|
|
// Helper function to restore marker data and add visual elements
|
|
function restoreMarkerData(restoredMsg, srcLatLng, dstLatLng, originalEvent) {
|
|
const srcKey = srcLatLng.lat + "," + srcLatLng.lng;
|
|
const dstKey = dstLatLng.lat + "," + dstLatLng.lng;
|
|
|
|
// Initialize or update circleAttackData for source location
|
|
if (!circleAttackData[srcKey]) {
|
|
circleAttackData[srcKey] = {
|
|
country: restoredMsg.country,
|
|
iso_code: restoredMsg.iso_code,
|
|
attacks: [],
|
|
totalAttacks: 0,
|
|
ips: {},
|
|
firstSeen: new Date(originalEvent.timestamp),
|
|
lastSeen: new Date(originalEvent.timestamp),
|
|
lastProtocol: restoredMsg.protocol,
|
|
lastColor: restoredMsg.color
|
|
};
|
|
} else {
|
|
// Update protocol tracking for restored attacks
|
|
// For restoration, we want to preserve the latest protocol/color from actual restore order
|
|
circleAttackData[srcKey].lastProtocol = restoredMsg.protocol;
|
|
circleAttackData[srcKey].lastColor = restoredMsg.color;
|
|
circleAttackData[srcKey].lastSeen = new Date(originalEvent.timestamp);
|
|
}
|
|
|
|
// Initialize IP data if needed
|
|
if (!circleAttackData[srcKey].ips[restoredMsg.src_ip]) {
|
|
circleAttackData[srcKey].ips[restoredMsg.src_ip] = {
|
|
src_ip: restoredMsg.src_ip,
|
|
ip_rep: restoredMsg.ip_rep,
|
|
attacks: [],
|
|
firstSeen: new Date(originalEvent.timestamp),
|
|
lastSeen: new Date(originalEvent.timestamp)
|
|
};
|
|
} else {
|
|
// Update reputation if new data is provided
|
|
if (restoredMsg.ip_rep) {
|
|
circleAttackData[srcKey].ips[restoredMsg.src_ip].ip_rep = restoredMsg.ip_rep;
|
|
}
|
|
}
|
|
|
|
// Add attack data to source location
|
|
const attackData = {
|
|
protocol: restoredMsg.protocol,
|
|
port: restoredMsg.dst_port,
|
|
timestamp: new Date(originalEvent.timestamp),
|
|
src_ip: restoredMsg.src_ip
|
|
};
|
|
|
|
circleAttackData[srcKey].attacks.push(attackData);
|
|
circleAttackData[srcKey].totalAttacks++;
|
|
circleAttackData[srcKey].lastSeen = new Date(originalEvent.timestamp);
|
|
circleAttackData[srcKey].ips[restoredMsg.src_ip].attacks.push(attackData);
|
|
circleAttackData[srcKey].ips[restoredMsg.src_ip].lastSeen = new Date(originalEvent.timestamp);
|
|
|
|
// Initialize or update markerAttackData for destination (honeypot)
|
|
if (!markerAttackData[dstKey]) {
|
|
markerAttackData[dstKey] = {
|
|
country: restoredMsg.dst_country_name,
|
|
iso_code: restoredMsg.dst_iso_code,
|
|
dst_ip: restoredMsg.dst_ip,
|
|
hostname: restoredMsg.tpot_hostname,
|
|
attacks: [],
|
|
totalAttacks: 0,
|
|
uniqueAttackers: new Set(),
|
|
protocolStats: {},
|
|
firstSeen: new Date(originalEvent.timestamp),
|
|
lastUpdate: new Date(originalEvent.timestamp)
|
|
};
|
|
}
|
|
|
|
// Add attack to honeypot data
|
|
markerAttackData[dstKey].attacks.push({
|
|
src_ip: restoredMsg.src_ip,
|
|
protocol: restoredMsg.protocol,
|
|
port: restoredMsg.dst_port,
|
|
timestamp: new Date(originalEvent.timestamp)
|
|
});
|
|
markerAttackData[dstKey].totalAttacks++;
|
|
markerAttackData[dstKey].uniqueAttackers.add(restoredMsg.src_ip);
|
|
markerAttackData[dstKey].protocolStats[restoredMsg.protocol] =
|
|
(markerAttackData[dstKey].protocolStats[restoredMsg.protocol] || 0) + 1;
|
|
markerAttackData[dstKey].lastUpdate = new Date(originalEvent.timestamp);
|
|
|
|
// Keep only last 50 attacks per location for performance
|
|
if (markerAttackData[dstKey].attacks.length > 50) {
|
|
markerAttackData[dstKey].attacks = markerAttackData[dstKey].attacks.slice(-50);
|
|
}
|
|
if (circleAttackData[srcKey].attacks.length > 50) {
|
|
circleAttackData[srcKey].attacks = circleAttackData[srcKey].attacks.slice(-50);
|
|
}
|
|
|
|
// Add visual elements (circle for attacker and marker for honeypot)
|
|
addCircle(restoredMsg.country, restoredMsg.iso_code, restoredMsg.src_ip,
|
|
restoredMsg.ip_rep, restoredMsg.color, srcLatLng, restoredMsg.protocol);
|
|
addMarker(restoredMsg.dst_country_name, restoredMsg.dst_iso_code,
|
|
restoredMsg.dst_ip, restoredMsg.tpot_hostname, dstLatLng);
|
|
}
|
|
|
|
// Helper function to get protocol color (matches existing logic)
|
|
function getProtocolColor(protocol) {
|
|
// Use the same color mapping as the dashboard for consistency
|
|
const colors = {
|
|
'CHARGEN': '#4CAF50',
|
|
'FTP-DATA': '#F44336',
|
|
'FTP': '#FF5722',
|
|
'SSH': '#FF9800',
|
|
'TELNET': '#FFC107',
|
|
'SMTP': '#8BC34A',
|
|
'WINS': '#009688',
|
|
'DNS': '#00BCD4',
|
|
'DHCP': '#03A9F4',
|
|
'TFTP': '#2196F3',
|
|
'HTTP': '#3F51B5',
|
|
'DICOM': '#9C27B0',
|
|
'POP3': '#E91E63',
|
|
'NTP': '#795548',
|
|
'RPC': '#607D8B',
|
|
'IMAP': '#9E9E9E',
|
|
'SNMP': '#FF6B35',
|
|
'LDAP': '#FF8E53',
|
|
'HTTPS': '#0080FF',
|
|
'SMB': '#BF00FF',
|
|
'SMTPS': '#80FF00',
|
|
'EMAIL': '#00FF80',
|
|
'IPMI': '#00FFFF',
|
|
'IPP': '#8000FF',
|
|
'IMAPS': '#FF0080',
|
|
'POP3S': '#80FF80',
|
|
'NFS': '#FF8080',
|
|
'SOCKS': '#8080FF',
|
|
'SQL': '#00FF00',
|
|
'ORACLE': '#FFFF00',
|
|
'PPTP': '#FF00FF',
|
|
'MQTT': '#00FF40',
|
|
'SSDP': '#40FF00',
|
|
'IEC104': '#FF4000',
|
|
'HL7': '#4000FF',
|
|
'MYSQL': '#00FF00',
|
|
'RDP': '#FF0060',
|
|
'IPSEC': '#60FF00',
|
|
'SIP': '#FFCCFF',
|
|
'POSTGRESQL': '#00CCFF',
|
|
'ADB': '#FFCCCC',
|
|
'VNC': '#0000FF',
|
|
'REDIS': '#CC00FF',
|
|
'IRC': '#FFCC00',
|
|
'JETDIRECT': '#8000FF',
|
|
'ELASTICSEARCH': '#FF8000',
|
|
'INDUSTRIAL': '#80FF40',
|
|
'MEMCACHED': '#40FF80',
|
|
'MONGODB': '#FF4080',
|
|
'SCADA': '#8040FF',
|
|
'OTHER': '#78909C'
|
|
};
|
|
|
|
// Normalize the protocol like the dashboard does
|
|
function normalizeProtocol(protocol) {
|
|
if (!protocol) return 'OTHER';
|
|
|
|
// Check if protocol is a numeric string (port number) - convert to OTHER
|
|
if (/^\d+$/.test(protocol.toString())) {
|
|
return 'OTHER';
|
|
}
|
|
|
|
// List of known protocols to check against
|
|
const knownProtocols = [
|
|
'CHARGEN', 'FTP-DATA', 'FTP', 'SSH', 'TELNET', 'SMTP', 'WINS', 'DNS', 'DHCP', 'TFTP',
|
|
'HTTP', 'DICOM', 'POP3', 'NTP', 'RPC', 'IMAP', 'SNMP', 'LDAP', 'HTTPS', 'SMB',
|
|
'SMTPS', 'EMAIL', 'IPMI', 'IPP', 'IMAPS', 'POP3S', 'NFS', 'SOCKS', 'SQL', 'ORACLE',
|
|
'PPTP', 'MQTT', 'SSDP', 'IEC104', 'HL7', 'MYSQL', 'RDP', 'IPSEC', 'SIP', 'POSTGRESQL',
|
|
'ADB', 'VNC', 'REDIS', 'IRC', 'JETDIRECT', 'ELASTICSEARCH', 'INDUSTRIAL', 'MEMCACHED',
|
|
'MONGODB', 'SCADA'
|
|
];
|
|
|
|
const protocolUpper = protocol.toUpperCase();
|
|
|
|
// If protocol is not in the known list, use "OTHER"
|
|
if (!knownProtocols.includes(protocolUpper)) {
|
|
return 'OTHER';
|
|
}
|
|
|
|
return protocolUpper;
|
|
}
|
|
|
|
const normalizedProtocol = normalizeProtocol(protocol);
|
|
|
|
// Return color for the normalized protocol
|
|
return colors[normalizedProtocol] || colors['OTHER'];
|
|
}
|
|
|
|
// Use Leaflet's built-in SVG renderer to handle zoom/pan and event bubbling correctly
|
|
var svgRenderer = L.svg({ clickable: true }).addTo(map);
|
|
|
|
// Select the SVG element and append a group for D3 animations
|
|
// We use a group to keep our elements separate from Leaflet's internal layers
|
|
var svg = d3.select(svgRenderer._container).append("g").attr("class", "d3-overlay");
|
|
|
|
// Ensure the SVG container doesn't block map interactions
|
|
// Leaflet usually handles this, but we enforce it to fix the Firefox panning issue
|
|
d3.select(svgRenderer._container).style("pointer-events", "none");
|
|
|
|
// Clear animations on zoom start to prevent coordinate desync
|
|
// D3 elements don't automatically re-project during zoom, so we clear them
|
|
map.on("zoomstart", function() {
|
|
svg.selectAll("*").remove();
|
|
});
|
|
|
|
// No need for manual translateSVG or moveend listener as Leaflet handles the SVG renderer
|
|
|
|
function calcMidpoint(x1, y1, x2, y2, bend) {
|
|
if(y2<y1 && x2<x1) {
|
|
var tmpy = y2;
|
|
var tmpx = x2;
|
|
x2 = x1;
|
|
y2 = y1;
|
|
x1 = tmpx;
|
|
y1 = tmpy;
|
|
}
|
|
else if(y2<y1) {
|
|
y1 = y2 + (y2=y1, 0);
|
|
}
|
|
else if(x2<x1) {
|
|
x1 = x2 + (x2=x1, 0);
|
|
}
|
|
|
|
var radian = Math.atan(-((y2-y1)/(x2-x1)));
|
|
var r = Math.sqrt(x2-x1) + Math.sqrt(y2-y1);
|
|
var m1 = (x1+x2)/2;
|
|
var m2 = (y1+y2)/2;
|
|
|
|
var min = 2.5, max = 7.5;
|
|
var arcIntensity = parseFloat((Math.random() * (max - min) + min).toFixed(2));
|
|
|
|
if (bend === true) {
|
|
var a = Math.floor(m1 - r * arcIntensity * Math.sin(radian));
|
|
var b = Math.floor(m2 - r * arcIntensity * Math.cos(radian));
|
|
} else {
|
|
var a = Math.floor(m1 + r * arcIntensity * Math.sin(radian));
|
|
var b = Math.floor(m2 + r * arcIntensity * Math.cos(radian));
|
|
}
|
|
|
|
return {"x":a, "y":b};
|
|
}
|
|
|
|
function translateAlong(path) {
|
|
var l = path.getTotalLength();
|
|
return function(i) {
|
|
return function(t) {
|
|
// Put in try/catch because sometimes floating point is stupid..
|
|
try {
|
|
var p = path.getPointAtLength(t*l);
|
|
return "translate(" + p.x + "," + p.y + ")";
|
|
} catch(err){
|
|
console.log("Caught exception.");
|
|
return "ERROR";
|
|
}
|
|
};
|
|
};
|
|
}
|
|
|
|
function handleParticle(color, srcPoint) {
|
|
// Skip animation if tab is not visible OR if we are in the "waking up" grace period
|
|
// This prevents the explosion of buffered animations when returning to the tab
|
|
if (document.hidden || isWakingUp) return;
|
|
|
|
var i = 0;
|
|
var x = srcPoint['x'];
|
|
var y = srcPoint['y'];
|
|
|
|
svg.append('circle')
|
|
.attr('cx', x)
|
|
.attr('cy', y)
|
|
.attr('r', 0)
|
|
.style('fill', 'none')
|
|
.style('stroke', color)
|
|
.style('stroke-opacity', 1)
|
|
.style('stroke-width', 3)
|
|
.transition()
|
|
.duration(700)
|
|
.ease(d3.easeCircleIn)
|
|
// Circle radius source animation
|
|
.attr('r', 50)
|
|
.style('stroke-opacity', 0)
|
|
.remove();
|
|
}
|
|
|
|
function handleTraffic(color, srcPoint, hqPoint) {
|
|
// Skip animation if tab is not visible OR if we are in the "waking up" grace period
|
|
// This prevents the explosion of buffered animations when returning to the tab
|
|
if (document.hidden || isWakingUp) return;
|
|
|
|
var fromX = srcPoint['x'];
|
|
var fromY = srcPoint['y'];
|
|
var toX = hqPoint['x'];
|
|
var toY = hqPoint['y'];
|
|
var bendArray = [true, false];
|
|
var bend = bendArray[Math.floor(Math.random() * bendArray.length)];
|
|
|
|
var lineData = [srcPoint, calcMidpoint(fromX, fromY, toX, toY, bend), hqPoint]
|
|
var lineFunction = d3.line()
|
|
.curve(d3.curveBasis)
|
|
.x(function(d) {return d.x;})
|
|
.y(function(d) {return d.y;});
|
|
|
|
var lineGraph = svg.append('path')
|
|
.attr('d', lineFunction(lineData))
|
|
.attr('opacity', 0.8)
|
|
.attr('stroke', color)
|
|
.attr('stroke-width', 2)
|
|
.attr('fill', 'none');
|
|
|
|
var circleRadius = 6
|
|
|
|
// Circle follows the line
|
|
var dot = svg.append('circle')
|
|
.attr('r', circleRadius)
|
|
.attr('fill', color)
|
|
.transition()
|
|
.duration(700)
|
|
.ease(d3.easeCircleIn)
|
|
.attrTween('transform', translateAlong(lineGraph.node()))
|
|
.on('end', function() {
|
|
d3.select(this)
|
|
.attr('fill', 'none')
|
|
.attr('stroke', color)
|
|
.attr('stroke-width', 3)
|
|
.transition()
|
|
.duration(700)
|
|
.ease(d3.easeCircleIn)
|
|
// Circle radius destination animation
|
|
.attr('r', 50)
|
|
.style('stroke-opacity', 0)
|
|
.remove();
|
|
});
|
|
|
|
var length = lineGraph.node().getTotalLength();
|
|
lineGraph.attr('stroke-dasharray', length + ' ' + length)
|
|
.attr('stroke-dashoffset', length)
|
|
.transition()
|
|
.duration(700)
|
|
.ease(d3.easeCircleIn)
|
|
.attr('stroke-dashoffset', 0)
|
|
.on('end', function() {
|
|
d3.select(this)
|
|
.transition()
|
|
.duration(700)
|
|
.style('opacity', 0)
|
|
.remove();
|
|
});
|
|
}
|
|
|
|
var circlesObject = {};
|
|
// Store attack data for each circle for enhanced tooltips
|
|
var circleAttackData = {};
|
|
|
|
function addCircle(country, iso_code, src_ip, ip_rep, color, srcLatLng, protocol) {
|
|
circleCount = circles.getLayers().length;
|
|
circleArray = circles.getLayers();
|
|
|
|
// Only allow 200 circles to be on the map at a time
|
|
if (circleCount >= 200) {
|
|
// Find the key with the oldest lastSeen time
|
|
let oldestKey = null;
|
|
let oldestTime = new Date(); // Start with current time, anything older will be smaller
|
|
|
|
// Only iterate over keys that actually exist on the map to avoid ghost entries
|
|
const validKeys = Object.keys(circlesObject);
|
|
|
|
for (const key of validKeys) {
|
|
const data = circleAttackData[key];
|
|
if (data && data.lastSeen < oldestTime) {
|
|
oldestTime = data.lastSeen;
|
|
oldestKey = key;
|
|
}
|
|
}
|
|
|
|
if (oldestKey) {
|
|
circles.removeLayer(circlesObject[oldestKey]);
|
|
delete circlesObject[oldestKey];
|
|
delete circleAttackData[oldestKey];
|
|
} else {
|
|
// Fallback if something goes wrong
|
|
const layerToRemove = circleArray[0];
|
|
circles.removeLayer(layerToRemove);
|
|
|
|
// Try to find and clean up the key for this layer
|
|
for (const [key, layer] of Object.entries(circlesObject)) {
|
|
if (layer === layerToRemove) {
|
|
delete circlesObject[key];
|
|
delete circleAttackData[key];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var key = srcLatLng.lat + "," + srcLatLng.lng;
|
|
|
|
// Check if circle exists and needs color update
|
|
if (circlesObject[key]) {
|
|
// Circle exists - check if protocol/color has changed
|
|
const existingCircle = circlesObject[key];
|
|
const currentColor = existingCircle.options.color;
|
|
|
|
// If color changed, update the circle
|
|
if (currentColor !== color) {
|
|
console.log(`[CIRCLE-UPDATE] Updating circle color at ${key} from ${currentColor} to ${color} (protocol: ${protocol})`);
|
|
|
|
// Update circle style
|
|
existingCircle.setStyle({
|
|
color: color,
|
|
fillColor: color,
|
|
fillOpacity: 0.2
|
|
});
|
|
|
|
// Update protocol tracking in attack data
|
|
if (circleAttackData[key]) {
|
|
circleAttackData[key].lastProtocol = protocol;
|
|
circleAttackData[key].lastColor = color;
|
|
circleAttackData[key].lastSeen = new Date();
|
|
}
|
|
}
|
|
|
|
// Update IP data if needed
|
|
if (circleAttackData[key] && circleAttackData[key].ips[src_ip]) {
|
|
// Update reputation if new data is provided
|
|
if (ip_rep) {
|
|
circleAttackData[key].ips[src_ip].ip_rep = ip_rep;
|
|
}
|
|
}
|
|
|
|
return; // Circle exists and has been updated if needed
|
|
}
|
|
|
|
// Create new circle if it doesn't exist
|
|
// Attack data should already be created in Traffic handler
|
|
// If for some reason it doesn't exist, create it (fallback)
|
|
if (!circleAttackData[key]) {
|
|
circleAttackData[key] = {
|
|
country: country,
|
|
iso_code: iso_code,
|
|
location_key: key,
|
|
attacks: [],
|
|
firstSeen: new Date(),
|
|
lastSeen: new Date(),
|
|
lastProtocol: protocol,
|
|
lastColor: color,
|
|
ips: {}
|
|
};
|
|
} else {
|
|
// Update protocol tracking for existing data
|
|
circleAttackData[key].lastProtocol = protocol;
|
|
circleAttackData[key].lastColor = color;
|
|
circleAttackData[key].lastSeen = new Date();
|
|
}
|
|
|
|
// Ensure IP data exists (fallback)
|
|
if (!circleAttackData[key].ips[src_ip]) {
|
|
circleAttackData[key].ips[src_ip] = {
|
|
src_ip: src_ip,
|
|
ip_rep: ip_rep,
|
|
attacks: [],
|
|
firstSeen: new Date(),
|
|
lastSeen: new Date()
|
|
};
|
|
} else {
|
|
// Update reputation if new data is provided
|
|
if (ip_rep) {
|
|
circleAttackData[key].ips[src_ip].ip_rep = ip_rep;
|
|
}
|
|
}
|
|
|
|
var circle = L.circle(srcLatLng, 50000, {
|
|
color: color,
|
|
fillColor: color,
|
|
fillOpacity: 0.2
|
|
});
|
|
|
|
// Enhanced popup with modern styling
|
|
var popupContent = createAttackerPopup(circleAttackData[key]);
|
|
circle.bindPopup(popupContent, {
|
|
maxWidth: 350,
|
|
className: 'modern-popup attacker-popup'
|
|
});
|
|
|
|
// Add click event for enhanced interaction
|
|
circle.on('click', function(e) {
|
|
// Update popup content with latest data
|
|
var updatedContent = createAttackerPopup(circleAttackData[key]);
|
|
circle.setPopupContent(updatedContent);
|
|
});
|
|
|
|
circlesObject[key] = circle.addTo(circles);
|
|
}
|
|
|
|
var markersObject = {};
|
|
// Store attack data for each marker for enhanced tooltips
|
|
var markerAttackData = {};
|
|
|
|
function addMarker(dst_country_name, dst_iso_code, dst_ip, tpot_hostname, dstLatLng) {
|
|
// Validate parameters
|
|
if (!dstLatLng || !dstLatLng.lat || !dstLatLng.lng) {
|
|
return;
|
|
}
|
|
|
|
markerCount = markers.getLayers().length;
|
|
markerArray = markers.getLayers();
|
|
|
|
// Only allow 200 markers to be on the map at a time
|
|
if (markerCount >= 200) {
|
|
// Find the key with the oldest lastUpdate time
|
|
let oldestKey = null;
|
|
let oldestTime = new Date();
|
|
|
|
// Only iterate over keys that actually exist on the map to avoid ghost entries
|
|
const validKeys = Object.keys(markersObject);
|
|
|
|
for (const key of validKeys) {
|
|
const data = markerAttackData[key];
|
|
if (data && data.lastUpdate < oldestTime) {
|
|
oldestTime = data.lastUpdate;
|
|
oldestKey = key;
|
|
}
|
|
}
|
|
|
|
if (oldestKey) {
|
|
markers.removeLayer(markersObject[oldestKey]);
|
|
delete markersObject[oldestKey];
|
|
delete markerAttackData[oldestKey];
|
|
} else {
|
|
// Fallback
|
|
markers.removeLayer(markerArray[0]);
|
|
// Reset objects if we can't track properly (original behavior fallback)
|
|
markersObject = {};
|
|
markerAttackData = {};
|
|
}
|
|
}
|
|
|
|
var key = dstLatLng.lat + "," + dstLatLng.lng;
|
|
// Only draw marker if its coordinates are not already present in markersObject
|
|
if (!markersObject[key]) {
|
|
// Attack data should already be created in Traffic handler
|
|
// If for some reason it doesn't exist, create it (fallback)
|
|
if (!markerAttackData[key]) {
|
|
markerAttackData[key] = {
|
|
country: dst_country_name,
|
|
iso_code: dst_iso_code,
|
|
dst_ip: dst_ip,
|
|
hostname: tpot_hostname,
|
|
attacks: [],
|
|
totalAttacks: 0,
|
|
uniqueAttackers: new Set(),
|
|
protocolStats: {},
|
|
firstSeen: new Date(),
|
|
lastUpdate: new Date()
|
|
};
|
|
}
|
|
|
|
var marker = L.marker(dstLatLng, {
|
|
icon: L.icon({
|
|
iconUrl: 'static/images/honeypot-marker.svg',
|
|
iconSize: [48, 48], // Match original square size
|
|
iconAnchor: [24, 40], // Adjusted anchor to fix hovering (was 48)
|
|
popupAnchor: [0, -48], // Match original popup position
|
|
className: 'honeypot-marker'
|
|
}),
|
|
});
|
|
|
|
// Enhanced popup with modern styling
|
|
var popupContent = createHoneypotPopup(markerAttackData[key]);
|
|
marker.bindPopup(popupContent, {
|
|
maxWidth: 400,
|
|
className: 'modern-popup honeypot-popup'
|
|
});
|
|
|
|
// Add click event for enhanced interaction
|
|
marker.on('click', function(e) {
|
|
// Update popup content with latest data
|
|
var updatedContent = createHoneypotPopup(markerAttackData[key]);
|
|
marker.setPopupContent(updatedContent);
|
|
});
|
|
|
|
markersObject[key] = marker.addTo(markers);
|
|
}
|
|
}
|
|
|
|
function handleStats(msg) {
|
|
const last = ["last_1m", "last_1h", "last_24h"];
|
|
|
|
// Check if message contains any stats data
|
|
const hasData = last.some(key => msg[key] !== undefined && msg[key] !== null);
|
|
|
|
if (!hasData) {
|
|
// If message is empty (backend failed to fetch stats), just return
|
|
// We don't want to spam the console with warnings every 10 seconds
|
|
console.log('[WARNING] Stats message contains no valid data:', msg);
|
|
return;
|
|
}
|
|
|
|
// Valid data received - update timestamp for connection status
|
|
console.log('[STATS] Valid stats data received, updating last valid timestamp.');
|
|
window.lastValidDataTime = Date.now();
|
|
|
|
last.forEach(function(i) {
|
|
const element = document.getElementById(i);
|
|
if (element) {
|
|
const oldValue = element.textContent;
|
|
const newValue = msg[i];
|
|
|
|
// Check if newValue exists and is not undefined
|
|
if (newValue !== undefined && newValue !== null) {
|
|
// Only animate if value actually changed
|
|
if (oldValue !== newValue.toString()) {
|
|
element.textContent = newValue;
|
|
element.setAttribute('data-updated', 'true');
|
|
|
|
// Remove animation class after animation completes
|
|
setTimeout(() => {
|
|
element.removeAttribute('data-updated');
|
|
}, 600);
|
|
}
|
|
} else {
|
|
console.warn('[WARNING] Stats value is undefined for:', i, 'in message:', msg);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
// WEBSOCKET STUFF
|
|
|
|
// Helper function to format reputation with line breaks for multi-word values
|
|
function formatReputation(reputation) {
|
|
if (!reputation) return 'Unknown';
|
|
|
|
// Add line break if the value contains multiple words (space separated)
|
|
const words = reputation.trim().split(/\s+/);
|
|
if (words.length > 1) {
|
|
return words.join('<br>');
|
|
}
|
|
|
|
return reputation;
|
|
}
|
|
|
|
// Modern popup creation functions
|
|
function createAttackerPopup(attackerData) {
|
|
// Validate attackerData structure
|
|
if (!attackerData || typeof attackerData !== 'object') {
|
|
console.error('[ERROR] Invalid attackerData:', attackerData);
|
|
const errorDiv = document.createElement('div');
|
|
errorDiv.className = 'popup-content';
|
|
const errorRow = document.createElement('div');
|
|
errorRow.className = 'info-row';
|
|
errorRow.textContent = 'Error: Invalid data';
|
|
errorDiv.appendChild(errorRow);
|
|
return errorDiv;
|
|
}
|
|
|
|
// Ensure required fields exist with defaults
|
|
if (!attackerData.firstSeen) attackerData.firstSeen = new Date();
|
|
if (!attackerData.lastSeen) attackerData.lastSeen = new Date();
|
|
if (!attackerData.attacks) attackerData.attacks = [];
|
|
if (!attackerData.ips) attackerData.ips = {};
|
|
if (!attackerData.country) attackerData.country = 'Unknown';
|
|
if (!attackerData.iso_code) attackerData.iso_code = 'XX';
|
|
|
|
const now = new Date();
|
|
const firstSeenAgo = formatTimeAgo(attackerData.firstSeen);
|
|
const lastSeenAgo = formatTimeAgo(attackerData.lastSeen);
|
|
|
|
// Get list of unique IPs at this location
|
|
const ips = Object.keys(attackerData.ips);
|
|
const totalAttacks = attackerData.attacks.length;
|
|
|
|
// Get protocol stats from all attacks
|
|
const protocolCounts = {};
|
|
attackerData.attacks.forEach(attack => {
|
|
protocolCounts[attack.protocol] = (protocolCounts[attack.protocol] || 0) + 1;
|
|
});
|
|
|
|
const topProtocol = Object.keys(protocolCounts).reduce((a, b) =>
|
|
protocolCounts[a] > protocolCounts[b] ? a : b, 'N/A');
|
|
|
|
const container = document.createElement('div');
|
|
|
|
// Header
|
|
const header = document.createElement('div');
|
|
header.className = 'popup-header';
|
|
|
|
const flagImg = document.createElement('img');
|
|
flagImg.src = `static/flags/${attackerData.iso_code}.svg`;
|
|
flagImg.width = 64;
|
|
flagImg.height = 44;
|
|
flagImg.className = 'flag-icon';
|
|
header.appendChild(flagImg);
|
|
|
|
const titleDiv = document.createElement('div');
|
|
titleDiv.className = 'popup-title';
|
|
|
|
const h4 = document.createElement('h4');
|
|
|
|
const subtitle = document.createElement('span');
|
|
subtitle.className = 'popup-subtitle';
|
|
subtitle.textContent = attackerData.country;
|
|
|
|
titleDiv.appendChild(h4);
|
|
titleDiv.appendChild(subtitle);
|
|
header.appendChild(titleDiv);
|
|
container.appendChild(header);
|
|
|
|
const content = document.createElement('div');
|
|
content.className = 'popup-content';
|
|
container.appendChild(content);
|
|
|
|
// Helper to create info row
|
|
function createInfoRow(label, value, valueClass = '') {
|
|
const row = document.createElement('div');
|
|
row.className = 'info-row';
|
|
|
|
const labelSpan = document.createElement('span');
|
|
labelSpan.className = 'info-label';
|
|
labelSpan.textContent = label;
|
|
|
|
const valueSpan = document.createElement('span');
|
|
valueSpan.className = 'info-value ' + valueClass;
|
|
|
|
if (value instanceof Node) {
|
|
valueSpan.appendChild(value);
|
|
} else {
|
|
valueSpan.textContent = value;
|
|
}
|
|
|
|
row.appendChild(labelSpan);
|
|
row.appendChild(valueSpan);
|
|
return row;
|
|
}
|
|
|
|
if (ips.length === 1) {
|
|
// Single IP
|
|
h4.textContent = 'Origen del Atacante';
|
|
const ipData = attackerData.ips[ips[0]];
|
|
|
|
if (!ipData) {
|
|
console.error('[ERROR] IP data is missing for:', ips[0]);
|
|
const err = document.createElement('div');
|
|
err.className = 'info-row';
|
|
err.textContent = 'Error: IP data corrupted';
|
|
content.appendChild(err);
|
|
return container;
|
|
}
|
|
|
|
// Defaults
|
|
if (!ipData.src_ip) ipData.src_ip = ips[0] || 'Unknown';
|
|
if (ipData.ip_rep === undefined || ipData.ip_rep === null) ipData.ip_rep = 'Unknown';
|
|
|
|
content.appendChild(createInfoRow('IP Origen:', ipData.src_ip));
|
|
|
|
// Handle reputation with safe line breaks
|
|
const repFragment = document.createDocumentFragment();
|
|
const words = (ipData.ip_rep || 'Unknown').trim().split(/\s+/);
|
|
words.forEach((word, index) => {
|
|
if (index > 0) repFragment.appendChild(document.createElement('br'));
|
|
repFragment.appendChild(document.createTextNode(word));
|
|
});
|
|
content.appendChild(createInfoRow('Reputación:', repFragment, getReputationClass(ipData.ip_rep)));
|
|
|
|
content.appendChild(createInfoRow('Total Ataques:', ipData.attacks.length));
|
|
|
|
// Protocol Badge
|
|
const protoRow = document.createElement('div');
|
|
protoRow.className = 'info-row';
|
|
const protoLabel = document.createElement('span');
|
|
protoLabel.className = 'info-label';
|
|
protoLabel.textContent = 'Protocolo Principal:';
|
|
const protoBadge = document.createElement('span');
|
|
protoBadge.className = `protocol-badge protocol-${topProtocol.toLowerCase()}`;
|
|
protoBadge.textContent = topProtocol;
|
|
protoRow.appendChild(protoLabel);
|
|
protoRow.appendChild(protoBadge);
|
|
content.appendChild(protoRow);
|
|
|
|
content.appendChild(createInfoRow('Primera vez visto:', formatTimeAgo(ipData.firstSeen || new Date())));
|
|
content.appendChild(createInfoRow('Última vez visto:', formatTimeAgo(ipData.lastSeen || new Date())));
|
|
|
|
} else {
|
|
// Multiple IPs
|
|
h4.textContent = 'Múltiples Atacantes';
|
|
|
|
const sortedIps = ips.map(ip => {
|
|
const ipData = attackerData.ips[ip];
|
|
if (!ipData || !ipData.attacks) return { ip: ip, attackCount: 0 };
|
|
return { ip: ip, attackCount: ipData.attacks.length };
|
|
}).sort((a, b) => b.attackCount - a.attackCount);
|
|
|
|
const topIps = sortedIps.slice(0, 3);
|
|
|
|
content.appendChild(createInfoRow('Total IPs:', ips.length));
|
|
content.appendChild(createInfoRow('Total Ataques:', totalAttacks));
|
|
|
|
// Protocol Badge
|
|
const protoRow = document.createElement('div');
|
|
protoRow.className = 'info-row';
|
|
const protoLabel = document.createElement('span');
|
|
protoLabel.className = 'info-label';
|
|
protoLabel.textContent = 'Protocolo Principal:';
|
|
const protoBadge = document.createElement('span');
|
|
protoBadge.className = `protocol-badge protocol-${topProtocol.toLowerCase()}`;
|
|
protoBadge.textContent = topProtocol;
|
|
protoRow.appendChild(protoLabel);
|
|
protoRow.appendChild(protoBadge);
|
|
content.appendChild(protoRow);
|
|
|
|
// Top Source IPs Section
|
|
const section = document.createElement('div');
|
|
section.className = 'info-section';
|
|
const sectionLabel = document.createElement('span');
|
|
sectionLabel.className = 'section-label';
|
|
sectionLabel.textContent = 'IPs Origen Principales:';
|
|
section.appendChild(sectionLabel);
|
|
|
|
topIps.forEach(ipInfo => {
|
|
const detail = document.createElement('div');
|
|
detail.className = 'ip-detail';
|
|
const ipAddr = document.createElement('span');
|
|
ipAddr.className = 'ip-address';
|
|
ipAddr.textContent = ipInfo.ip;
|
|
const ipCount = document.createElement('span');
|
|
ipCount.className = 'ip-count';
|
|
ipCount.textContent = `${ipInfo.attackCount} ataques`;
|
|
detail.appendChild(ipAddr);
|
|
detail.appendChild(ipCount);
|
|
section.appendChild(detail);
|
|
});
|
|
|
|
if (ips.length > 3) {
|
|
const more = document.createElement('div');
|
|
more.className = 'ip-detail more-ips';
|
|
more.textContent = `... y ${ips.length - 3} más`;
|
|
section.appendChild(more);
|
|
}
|
|
content.appendChild(section);
|
|
|
|
content.appendChild(createInfoRow('Primera vez visto:', firstSeenAgo));
|
|
content.appendChild(createInfoRow('Última vez visto:', lastSeenAgo));
|
|
}
|
|
|
|
return container;
|
|
}
|
|
|
|
function createHoneypotPopup(honeypotData) {
|
|
const now = new Date();
|
|
const lastUpdateAgo = formatTimeAgo(honeypotData.lastUpdate);
|
|
|
|
// Get top 3 protocols
|
|
const sortedProtocols = Object.entries(honeypotData.protocolStats)
|
|
.sort(([,a], [,b]) => b - a)
|
|
.slice(0, 3);
|
|
|
|
const container = document.createElement('div');
|
|
|
|
// Header
|
|
const header = document.createElement('div');
|
|
header.className = 'popup-header';
|
|
|
|
const flagImg = document.createElement('img');
|
|
flagImg.src = `static/flags/${honeypotData.iso_code}.svg`;
|
|
flagImg.width = 64;
|
|
flagImg.height = 44;
|
|
flagImg.className = 'flag-icon';
|
|
header.appendChild(flagImg);
|
|
|
|
const titleDiv = document.createElement('div');
|
|
titleDiv.className = 'popup-title';
|
|
|
|
const h4 = document.createElement('h4');
|
|
h4.textContent = 'Honeypot T-Pot';
|
|
|
|
const subtitle = document.createElement('span');
|
|
subtitle.className = 'popup-subtitle';
|
|
subtitle.textContent = honeypotData.country;
|
|
|
|
titleDiv.appendChild(h4);
|
|
titleDiv.appendChild(subtitle);
|
|
header.appendChild(titleDiv);
|
|
container.appendChild(header);
|
|
|
|
const content = document.createElement('div');
|
|
content.className = 'popup-content';
|
|
container.appendChild(content);
|
|
|
|
// Helper to create info row
|
|
function createInfoRow(label, value) {
|
|
const row = document.createElement('div');
|
|
row.className = 'info-row';
|
|
|
|
const labelSpan = document.createElement('span');
|
|
labelSpan.className = 'info-label';
|
|
labelSpan.textContent = label;
|
|
|
|
const valueSpan = document.createElement('span');
|
|
valueSpan.className = 'info-value';
|
|
valueSpan.textContent = value;
|
|
|
|
row.appendChild(labelSpan);
|
|
row.appendChild(valueSpan);
|
|
return row;
|
|
}
|
|
|
|
content.appendChild(createInfoRow('Hostname:', honeypotData.hostname));
|
|
content.appendChild(createInfoRow('IP Destino:', honeypotData.dst_ip));
|
|
content.appendChild(createInfoRow('Total Ataques:', honeypotData.totalAttacks));
|
|
content.appendChild(createInfoRow('Atacantes Únicos:', honeypotData.uniqueAttackers.size));
|
|
|
|
if (sortedProtocols.length > 0) {
|
|
const section = document.createElement('div');
|
|
section.className = 'info-section';
|
|
const sectionLabel = document.createElement('span');
|
|
sectionLabel.className = 'section-label';
|
|
sectionLabel.textContent = 'Protocolos Principales:';
|
|
section.appendChild(sectionLabel);
|
|
|
|
sortedProtocols.forEach(([protocol, count]) => {
|
|
const stat = document.createElement('div');
|
|
stat.className = 'protocol-stat';
|
|
|
|
const badge = document.createElement('span');
|
|
badge.className = `protocol-badge protocol-${protocol.toLowerCase()}`;
|
|
badge.textContent = protocol;
|
|
|
|
const countSpan = document.createElement('span');
|
|
countSpan.className = 'protocol-count';
|
|
countSpan.textContent = count;
|
|
|
|
stat.appendChild(badge);
|
|
stat.appendChild(countSpan);
|
|
section.appendChild(stat);
|
|
});
|
|
content.appendChild(section);
|
|
}
|
|
|
|
content.appendChild(createInfoRow('Última Actualización:', lastUpdateAgo));
|
|
|
|
return container;
|
|
}
|
|
|
|
function formatTimeAgo(date) {
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMins / 60);
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
|
|
if (diffMins < 1) return 'Ahora mismo';
|
|
if (diffMins < 60) return `hace ${diffMins}m`;
|
|
if (diffHours < 24) return `hace ${diffHours}h`;
|
|
return `hace ${diffDays}d`;
|
|
}
|
|
|
|
function getReputationClass(reputation) {
|
|
if (reputation === 'MALICIOUS') return 'reputation-malicious';
|
|
if (reputation === 'SUSPICIOUS') return 'reputation-suspicious';
|
|
return 'reputation-clean';
|
|
}
|
|
|
|
const messageHandlers = {
|
|
Traffic: (msg) => {
|
|
// Valid data received - update timestamp for connection status
|
|
window.lastValidDataTime = Date.now();
|
|
|
|
var srcLatLng = new L.LatLng(msg.src_lat, msg.src_long);
|
|
var dstLatLng = new L.LatLng(msg.dst_lat, msg.dst_long);
|
|
var dstPoint = map.latLngToLayerPoint(dstLatLng);
|
|
var srcPoint = map.latLngToLayerPoint(srcLatLng);
|
|
|
|
// Store attack data for tooltips
|
|
var srcKey = srcLatLng.lat + "," + srcLatLng.lng;
|
|
var dstKey = dstLatLng.lat + "," + dstLatLng.lng;
|
|
|
|
// Pre-create attacker data structure if needed
|
|
if (!circleAttackData[srcKey]) {
|
|
circleAttackData[srcKey] = {
|
|
country: msg.country,
|
|
iso_code: msg.iso_code,
|
|
location_key: srcKey,
|
|
attacks: [],
|
|
firstSeen: new Date(),
|
|
lastSeen: new Date(),
|
|
lastProtocol: msg.protocol,
|
|
lastColor: msg.color,
|
|
// Track multiple IPs at the same location
|
|
ips: {}
|
|
};
|
|
} else {
|
|
// Update protocol tracking for existing location
|
|
circleAttackData[srcKey].lastProtocol = msg.protocol;
|
|
circleAttackData[srcKey].lastColor = msg.color;
|
|
circleAttackData[srcKey].lastSeen = new Date();
|
|
}
|
|
|
|
// Initialize IP-specific data if this is a new IP at this location
|
|
if (!circleAttackData[srcKey].ips[msg.src_ip]) {
|
|
circleAttackData[srcKey].ips[msg.src_ip] = {
|
|
src_ip: msg.src_ip,
|
|
ip_rep: msg.ip_rep,
|
|
attacks: [],
|
|
firstSeen: new Date(),
|
|
lastSeen: new Date()
|
|
};
|
|
}
|
|
|
|
// Pre-create honeypot data structure if needed
|
|
if (!markerAttackData[dstKey]) {
|
|
markerAttackData[dstKey] = {
|
|
country: msg.dst_country_name,
|
|
iso_code: msg.dst_iso_code,
|
|
dst_ip: msg.dst_ip,
|
|
hostname: msg.tpot_hostname,
|
|
attacks: [],
|
|
totalAttacks: 0,
|
|
uniqueAttackers: new Set(),
|
|
protocolStats: {},
|
|
firstSeen: new Date(),
|
|
lastUpdate: new Date()
|
|
};
|
|
}
|
|
|
|
Promise.all([
|
|
addCircle(msg.country, msg.iso_code, msg.src_ip, msg.ip_rep, msg.color, srcLatLng, msg.protocol),
|
|
addMarker(msg.dst_country_name, msg.dst_iso_code, msg.dst_ip, msg.tpot_hostname, dstLatLng),
|
|
handleParticle(msg.color, srcPoint),
|
|
handleTraffic(msg.color, srcPoint, dstPoint, srcLatLng)
|
|
]).then(() => {
|
|
// Add attack data AFTER visual elements are created/updated
|
|
const attackData = {
|
|
protocol: msg.protocol,
|
|
port: msg.dst_port,
|
|
honeypot: msg.honeypot,
|
|
timestamp: new Date(),
|
|
src_ip: msg.src_ip
|
|
};
|
|
|
|
// Add to overall location attacks
|
|
circleAttackData[srcKey].attacks.push(attackData);
|
|
circleAttackData[srcKey].lastSeen = new Date();
|
|
|
|
// Add to IP-specific attacks
|
|
circleAttackData[srcKey].ips[msg.src_ip].attacks.push(attackData);
|
|
circleAttackData[srcKey].ips[msg.src_ip].lastSeen = new Date();
|
|
|
|
// Add attack to honeypot data
|
|
markerAttackData[dstKey].attacks.push({
|
|
src_ip: msg.src_ip,
|
|
protocol: msg.protocol,
|
|
port: msg.dst_port,
|
|
timestamp: new Date()
|
|
});
|
|
markerAttackData[dstKey].totalAttacks++;
|
|
markerAttackData[dstKey].uniqueAttackers.add(msg.src_ip);
|
|
markerAttackData[dstKey].protocolStats[msg.protocol] =
|
|
(markerAttackData[dstKey].protocolStats[msg.protocol] || 0) + 1;
|
|
markerAttackData[dstKey].lastUpdate = new Date();
|
|
|
|
// Keep only last 50 attacks per honeypot for performance
|
|
if (markerAttackData[dstKey].attacks.length > 50) {
|
|
markerAttackData[dstKey].attacks = markerAttackData[dstKey].attacks.slice(-50);
|
|
}
|
|
});
|
|
|
|
// Send to dashboard for Live Feed processing with correct field mapping
|
|
if (window.attackMapDashboard) {
|
|
const attackData = {
|
|
ip: msg.src_ip,
|
|
source_ip: msg.src_ip,
|
|
src_ip: msg.src_ip,
|
|
ip_rep: msg.ip_rep,
|
|
tpot_hostname: msg.tpot_hostname,
|
|
color: msg.color,
|
|
country: msg.country,
|
|
country_code: msg.iso_code,
|
|
iso_code: msg.iso_code,
|
|
protocol: msg.protocol,
|
|
honeypot: msg.honeypot, // Use honeypot field from message, not tpot_hostname
|
|
port: msg.dst_port,
|
|
dst_port: msg.dst_port,
|
|
destination_ip: msg.dst_ip,
|
|
destination_port: msg.dst_port,
|
|
// Add honeypot location data for proper flag restoration
|
|
dst_country_name: msg.dst_country_name,
|
|
dst_iso_code: msg.dst_iso_code,
|
|
destination_country: msg.dst_country_name, // Alternative field name
|
|
destination_country_code: msg.dst_iso_code, // Alternative field name
|
|
// Add coordinate data for map restoration
|
|
source_lat: msg.src_lat,
|
|
source_lng: msg.src_long,
|
|
destination_lat: msg.dst_lat,
|
|
destination_lng: msg.dst_long,
|
|
timestamp: Date.now(),
|
|
event_time: msg.event_time
|
|
};
|
|
|
|
// Send to live feed
|
|
window.attackMapDashboard.addAttackEvent(attackData);
|
|
|
|
// Send to honeypot performance tracking
|
|
window.attackMapDashboard.processAttackForDashboard(attackData);
|
|
}
|
|
},
|
|
Stats: (msg) => {
|
|
handleStats(msg);
|
|
},
|
|
};
|
|
|
|
// Enhanced WebSocket handling with dashboard integration
|
|
function connectWebSocket() {
|
|
// Prevent multiple connection attempts
|
|
if (isReconnecting) {
|
|
console.log('[INFO] Connection attempt already in progress');
|
|
return;
|
|
}
|
|
|
|
// Close existing connection if it exists to prevent resource leaks
|
|
if (window.webSocket) {
|
|
try {
|
|
console.log('[INFO] Cleaning up existing WebSocket before reconnection');
|
|
window.webSocket.close();
|
|
} catch (e) {
|
|
console.log('[WARN] Error closing existing WebSocket:', e);
|
|
}
|
|
}
|
|
|
|
isReconnecting = true;
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const WS_HOST = protocol + '//' + window.location.host + '/websocket';
|
|
|
|
// Update status to connecting when attempting connection
|
|
if (window.attackMapDashboard) {
|
|
window.attackMapDashboard.updateConnectionStatus('connecting');
|
|
}
|
|
|
|
// Make WebSocket globally accessible for dashboard monitoring
|
|
window.webSocket = webSocket = new WebSocket(WS_HOST);
|
|
|
|
webSocket.onopen = function () {
|
|
// Reset reconnection tracking
|
|
isReconnecting = false;
|
|
reconnectAttempts = 0;
|
|
|
|
// Reset last message time to prevent immediate timeout on reconnection
|
|
window.lastWebSocketMessageTime = Date.now();
|
|
window.lastValidDataTime = Date.now(); // Reset valid data timer
|
|
|
|
// Set global connection flag immediately
|
|
window.webSocketConnected = true;
|
|
|
|
// Start heartbeat to monitor connection health
|
|
startHeartbeat();
|
|
|
|
// Update connection status in dashboard with better retry logic
|
|
function updateStatusWithRetry(attempts = 0) {
|
|
const maxAttempts = 10; // Try for up to 5 seconds
|
|
|
|
if (window.attackMapDashboard) {
|
|
window.attackMapDashboard.updateConnectionStatus('connected');
|
|
console.log('[*] WebSocket connection status updated to connected');
|
|
} else if (attempts < maxAttempts) {
|
|
// Dashboard not ready yet, retry with exponential backoff
|
|
const delay = Math.min(100 + (attempts * 100), 1000); // 100ms to 1000ms
|
|
setTimeout(() => updateStatusWithRetry(attempts + 1), delay);
|
|
} else {
|
|
console.log('[WARNING] Dashboard not available after retries, but flag is set');
|
|
}
|
|
}
|
|
|
|
updateStatusWithRetry();
|
|
console.log('[*] WebSocket connection established.');
|
|
};
|
|
|
|
webSocket.onclose = function (event) {
|
|
// Stop heartbeat when connection closes
|
|
stopHeartbeat();
|
|
|
|
// Clear the WebSocket connected flag
|
|
window.webSocketConnected = false;
|
|
|
|
var reason = "Unknown error reason?";
|
|
if (event.code == 1000) reason = "[ ] Endpoint terminating connection: Normal closure";
|
|
else if(event.code == 1001) reason = "[ ] Endpoint terminating connection: Endpoint is \"going away\"";
|
|
else if(event.code == 1002) reason = "[ ] Endpoint terminating connection: Protocol error";
|
|
else if(event.code == 1003) reason = "[ ] Endpoint terminating connection: Unknown data";
|
|
else if(event.code == 1004) reason = "[ ] Endpoint terminating connection: Reserved";
|
|
else if(event.code == 1005) reason = "[ ] Endpoint terminating connection: No status code";
|
|
else if(event.code == 1006) reason = "[ ] Endpoint terminating connection: Connection closed abnormally";
|
|
else if(event.code == 1007) reason = "[ ] Endpoint terminating connection: Message was not consistent with the type of the message";
|
|
else if(event.code == 1008) reason = "[ ] Endpoint terminating connection: Message \"violates policy\"";
|
|
else if(event.code == 1009) reason = "[ ] Endpoint terminating connection: Message is too big";
|
|
else if(event.code == 1010) reason = "[ ] Endpoint terminating connection: Client failed to negotiate ("+event.reason+")";
|
|
else if(event.code == 1011) reason = "[ ] Endpoint terminating connection: Server encountered an unexpected condition";
|
|
else if(event.code == 1015) reason = "[ ] Endpoint terminating connection: Connection closed due TLS handshake failure";
|
|
else reason = "[ ] Endpoint terminating connection; Unknown reason";
|
|
|
|
// Update dashboard connection status
|
|
if (window.attackMapDashboard) {
|
|
window.attackMapDashboard.updateConnectionStatus('disconnected');
|
|
}
|
|
|
|
console.log(reason);
|
|
|
|
// Always attempt to reconnect if not a clean closure (or even if it is, depending on requirements, but usually 1000 is manual)
|
|
// User requirement: "Every 60 seconds a reconnection attempt should be made"
|
|
if (event.code !== 1000) {
|
|
const delay = reconnectDelay;
|
|
console.log(`[INFO] Connection lost. Attempting reconnection in ${delay}ms`);
|
|
|
|
setTimeout(() => {
|
|
reconnectAttempts++;
|
|
isReconnecting = false; // Reset flag to allow new connection attempt
|
|
connectWebSocket();
|
|
}, delay);
|
|
} else {
|
|
isReconnecting = false;
|
|
console.log('[INFO] Connection closed normally. No auto-reconnect.');
|
|
}
|
|
};
|
|
|
|
webSocket.onerror = function (error) {
|
|
console.log('[ERROR] WebSocket error:', error);
|
|
// Stop heartbeat on error
|
|
stopHeartbeat();
|
|
// Update status to disconnected on error
|
|
if (window.attackMapDashboard) {
|
|
window.attackMapDashboard.updateConnectionStatus('disconnected');
|
|
}
|
|
};
|
|
|
|
webSocket.onmessage = function (e) {
|
|
try {
|
|
// Update last message time for connection health monitoring
|
|
window.lastWebSocketMessageTime = Date.now();
|
|
|
|
var msg = JSON.parse(e.data);
|
|
|
|
let handler = messageHandlers[msg.type];
|
|
if (handler) {
|
|
handler(msg);
|
|
} else {
|
|
console.warn('[WARNING] No handler found for message type:', msg.type);
|
|
}
|
|
|
|
// Let dashboard handle its own processing through messageHandlers
|
|
// Removed duplicate addAttackEvent call to prevent double entries
|
|
|
|
} catch (error) {
|
|
console.error('[ERROR] Failed to parse WebSocket message:', error);
|
|
console.log('[ERROR] Raw message data:', e.data);
|
|
}
|
|
};
|
|
}
|
|
|
|
// Heartbeat functions to monitor connection health
|
|
function startHeartbeat() {
|
|
stopHeartbeat(); // Clear any existing heartbeat
|
|
|
|
heartbeatInterval = setInterval(() => {
|
|
const now = Date.now();
|
|
const timeSinceLastMessage = now - window.lastWebSocketMessageTime;
|
|
|
|
// Log warning if no messages for extended time, but do NOT force close
|
|
// This allows for "Idle" state
|
|
if (timeSinceLastMessage > 60000) {
|
|
console.log('[INFO] No messages received for 1 minute. Connection state should be Idle.');
|
|
}
|
|
}, 30000); // Check every 30 seconds
|
|
}
|
|
|
|
function stopHeartbeat() {
|
|
if (heartbeatInterval) {
|
|
clearInterval(heartbeatInterval);
|
|
heartbeatInterval = null;
|
|
}
|
|
}
|
|
|
|
// Enhanced function to check connection health
|
|
function checkConnectionHealth() {
|
|
if (!webSocket || webSocket.readyState !== WebSocket.OPEN) {
|
|
console.log('[INFO] WebSocket not connected, attempting to reconnect...');
|
|
if (window.attackMapDashboard) {
|
|
window.attackMapDashboard.updateConnectionStatus('disconnected');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Simple check: Is the socket technically open?
|
|
if (!webSocket || webSocket.readyState !== WebSocket.OPEN) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Initialize connection when DOM is ready
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
connectWebSocket();
|
|
});
|
|
|
|
// Map theme update function
|
|
function updateMapTheme(theme) {
|
|
if (!window.map || !mapLayers[theme]) return;
|
|
|
|
// Remove current layer
|
|
window.map.eachLayer(function(layer) {
|
|
if (layer._url && layer._url.includes('basemaps.cartocdn.com')) {
|
|
window.map.removeLayer(layer);
|
|
}
|
|
});
|
|
|
|
// Add new theme layer
|
|
mapLayers[theme].addTo(window.map);
|
|
}
|
|
|
|
// Listen for theme changes
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const observer = new MutationObserver(function(mutations) {
|
|
mutations.forEach(function(mutation) {
|
|
if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') {
|
|
const newTheme = document.documentElement.getAttribute('data-theme');
|
|
updateMapTheme(newTheme);
|
|
}
|
|
});
|
|
});
|
|
|
|
observer.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ['data-theme']
|
|
});
|
|
|
|
// Add page visibility change handler
|
|
document.addEventListener('visibilitychange', function() {
|
|
isPageVisible = !document.hidden;
|
|
|
|
if (isPageVisible) {
|
|
// Set waking up flag to suppress animation burst from buffered messages
|
|
isWakingUp = true;
|
|
setTimeout(() => {
|
|
isWakingUp = false;
|
|
}, 2000); // 2 second grace period
|
|
|
|
// Clean up any stuck D3 animations from background throttling
|
|
if (typeof svg !== 'undefined' && svg) {
|
|
svg.selectAll("*").remove();
|
|
}
|
|
|
|
// Check connection health and reconnect if needed
|
|
if (!checkConnectionHealth()) {
|
|
console.log('Connection lost while backgrounded, reconnecting...');
|
|
isReconnecting = false;
|
|
connectWebSocket();
|
|
}
|
|
} else {
|
|
// Page hidden - background operation mode
|
|
}
|
|
});
|
|
|
|
// Start connection health monitoring
|
|
// Removed aggressive health check as per new logic:
|
|
// - Connected: Data < 30s
|
|
// - Idle: No Data > 30s (but socket open)
|
|
// - Disconnected: Socket Closed
|
|
/*
|
|
function startConnectionHealthCheck() {
|
|
if (connectionHealthCheck) clearInterval(connectionHealthCheck);
|
|
|
|
connectionHealthCheck = setInterval(() => {
|
|
// ... removed ...
|
|
}, 30000);
|
|
}
|
|
|
|
startConnectionHealthCheck();
|
|
*/
|
|
});
|