1116 lines
23 KiB
Python
1116 lines
23 KiB
Python
import html
|
|
import json
|
|
|
|
from reports.database import query_all, query_one
|
|
from reports.template import (
|
|
ReportContext,
|
|
card,
|
|
page_begin,
|
|
page_end,
|
|
page_header,
|
|
)
|
|
from reports.utils import (
|
|
format_datetime as format_report_datetime,
|
|
)
|
|
|
|
|
|
def esc(value):
|
|
|
|
if value is None:
|
|
return "—"
|
|
|
|
return html.escape(
|
|
str(value),
|
|
quote=True,
|
|
)
|
|
|
|
|
|
def format_datetime(value):
|
|
return format_report_datetime(
|
|
value,
|
|
"%d.%m.%y-%H.%M.%S"
|
|
)
|
|
|
|
|
|
def format_popup_datetime(value):
|
|
return format_report_datetime(
|
|
value,
|
|
"%d-%m-%Y-%H-%M-%S"
|
|
)
|
|
|
|
|
|
def load_report_interval(conn):
|
|
|
|
row = query_one(
|
|
conn,
|
|
"""
|
|
SELECT
|
|
MIN(start_time) AS first_time,
|
|
MAX(
|
|
COALESCE(
|
|
end_time,
|
|
start_time
|
|
)
|
|
) AS last_time
|
|
FROM capture_sessions
|
|
""",
|
|
)
|
|
|
|
if not row:
|
|
return (
|
|
None,
|
|
None,
|
|
)
|
|
|
|
return (
|
|
row["first_time"],
|
|
row["last_time"],
|
|
)
|
|
|
|
|
|
def load_access_points(conn):
|
|
|
|
rows = query_all(
|
|
conn,
|
|
"""
|
|
WITH ranked_observations AS (
|
|
SELECT
|
|
obs.access_point_id,
|
|
obs.latitude,
|
|
obs.longitude,
|
|
obs.observed_at,
|
|
obs.rssi,
|
|
obs.channel,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY obs.access_point_id
|
|
ORDER BY
|
|
obs.observed_at DESC,
|
|
obs.id DESC
|
|
) AS rn
|
|
FROM access_point_observations AS obs
|
|
WHERE
|
|
obs.latitude IS NOT NULL
|
|
AND obs.longitude IS NOT NULL
|
|
AND obs.latitude != 0
|
|
AND obs.longitude != 0
|
|
)
|
|
SELECT
|
|
ap.id,
|
|
ap.bssid,
|
|
ap.essid,
|
|
ap.vendor,
|
|
ap.has_handshake,
|
|
ap.has_pmkid,
|
|
ap.is_cracked,
|
|
obs.latitude,
|
|
obs.longitude,
|
|
obs.observed_at,
|
|
obs.rssi,
|
|
obs.channel,
|
|
(
|
|
SELECT c.password
|
|
FROM credentials AS c
|
|
WHERE c.access_point_id = ap.id
|
|
ORDER BY
|
|
c.created_at DESC,
|
|
c.id DESC
|
|
LIMIT 1
|
|
) AS password
|
|
FROM access_points AS ap
|
|
JOIN ranked_observations AS obs
|
|
ON obs.access_point_id = ap.id
|
|
AND obs.rn = 1
|
|
ORDER BY
|
|
obs.observed_at DESC,
|
|
ap.bssid
|
|
""",
|
|
)
|
|
|
|
return rows
|
|
|
|
|
|
def build_marker_js(observations):
|
|
|
|
ap_features = []
|
|
handshake_features = []
|
|
pmkid_features = []
|
|
password_features = []
|
|
|
|
for row in observations:
|
|
|
|
latitude = row["latitude"]
|
|
longitude = row["longitude"]
|
|
|
|
if (
|
|
latitude is None
|
|
or longitude is None
|
|
):
|
|
continue
|
|
|
|
ssid = (
|
|
row["essid"]
|
|
if row["essid"] is not None
|
|
and str(row["essid"]) != ""
|
|
else "—"
|
|
)
|
|
|
|
vendor = (
|
|
row["vendor"]
|
|
if row["vendor"] is not None
|
|
and str(row["vendor"]) != ""
|
|
else "—"
|
|
)
|
|
|
|
channel = (
|
|
row["channel"]
|
|
if row["channel"] is not None
|
|
else "—"
|
|
)
|
|
|
|
rssi = (
|
|
f"{row['rssi']} dBm"
|
|
if row["rssi"] is not None
|
|
else "—"
|
|
)
|
|
|
|
password = (
|
|
row["password"]
|
|
if row["password"] is not None
|
|
and str(row["password"]) != ""
|
|
else "—"
|
|
)
|
|
|
|
popup = (
|
|
f"<b>{esc(ssid)}</b>"
|
|
f"<br>MAC: {esc(row['bssid'])}"
|
|
f"<br>Vendor: {esc(vendor)}"
|
|
f"<br>Channel: {esc(channel)}"
|
|
f"<br>RSSI: {esc(rssi)}"
|
|
f"<br>Password: {esc(password)}"
|
|
f"<br>Date: "
|
|
f"{esc(format_popup_datetime(row['observed_at']))}"
|
|
f'<br><a href="/reports/access-points/{esc(row["bssid"])}">'
|
|
f"Подробнее</a>"
|
|
)
|
|
|
|
feature = {
|
|
"type": "Feature",
|
|
"geometry": {
|
|
"type": "Point",
|
|
"coordinates": [
|
|
float(longitude),
|
|
float(latitude),
|
|
],
|
|
},
|
|
"properties": {
|
|
"popup": popup,
|
|
},
|
|
}
|
|
|
|
ap_features.append(feature)
|
|
|
|
if row["has_handshake"]:
|
|
handshake_features.append(
|
|
feature
|
|
)
|
|
|
|
if row["has_pmkid"]:
|
|
pmkid_features.append(
|
|
feature
|
|
)
|
|
|
|
if row["is_cracked"] or row["password"]:
|
|
password_features.append(
|
|
feature
|
|
)
|
|
|
|
return (
|
|
"const apFeatures = "
|
|
+ json.dumps(
|
|
ap_features,
|
|
ensure_ascii=False,
|
|
)
|
|
+ ";\n"
|
|
"const handshakeFeatures = "
|
|
+ json.dumps(
|
|
handshake_features,
|
|
ensure_ascii=False,
|
|
)
|
|
+ ";\n"
|
|
"const pmkidFeatures = "
|
|
+ json.dumps(
|
|
pmkid_features,
|
|
ensure_ascii=False,
|
|
)
|
|
+ ";\n"
|
|
"const passwordFeatures = "
|
|
+ json.dumps(
|
|
password_features,
|
|
ensure_ascii=False,
|
|
)
|
|
+ ";\n"
|
|
)
|
|
|
|
|
|
def render_access_points_map(
|
|
observations,
|
|
context,
|
|
):
|
|
|
|
marker_js = build_marker_js(
|
|
observations
|
|
)
|
|
|
|
maplibre_css_url = context.asset_url(
|
|
"maplibre/maplibre-gl.css"
|
|
)
|
|
|
|
maplibre_js_url = context.asset_url(
|
|
"maplibre/maplibre-gl.js"
|
|
)
|
|
|
|
map_html = """
|
|
<div id="map"></div>
|
|
|
|
<link
|
|
rel="stylesheet"
|
|
href="__MAPLIBRE_CSS_URL__"
|
|
>
|
|
|
|
<script
|
|
src="__MAPLIBRE_JS_URL__">
|
|
</script>
|
|
|
|
<script>
|
|
|
|
__MARKER_JS__
|
|
|
|
// ======================================================
|
|
// MapLibre map
|
|
// ======================================================
|
|
|
|
const map = new maplibregl.Map({
|
|
|
|
container: "map",
|
|
|
|
style:
|
|
"http://127.0.0.1:8080/styles/server-map/style.json",
|
|
|
|
center: [
|
|
30.32,
|
|
59.93
|
|
],
|
|
|
|
zoom: 5,
|
|
|
|
attributionControl: true
|
|
|
|
});
|
|
|
|
|
|
// ======================================================
|
|
// Navigation
|
|
// ======================================================
|
|
|
|
map.addControl(
|
|
new maplibregl.NavigationControl(),
|
|
"top-right"
|
|
);
|
|
|
|
|
|
// ======================================================
|
|
// Scale
|
|
// ======================================================
|
|
|
|
map.addControl(
|
|
new maplibregl.ScaleControl({
|
|
maxWidth: 120,
|
|
unit: "metric"
|
|
})
|
|
);
|
|
|
|
|
|
// ======================================================
|
|
// Layer state
|
|
// ======================================================
|
|
|
|
const layerState = {
|
|
|
|
handshake: true,
|
|
password: true,
|
|
all: false
|
|
|
|
};
|
|
|
|
|
|
// ======================================================
|
|
// GeoJSON sources
|
|
// ======================================================
|
|
|
|
const createSource =
|
|
function(
|
|
id,
|
|
features
|
|
)
|
|
{
|
|
|
|
map.addSource(
|
|
id,
|
|
{
|
|
|
|
type: "geojson",
|
|
|
|
data: {
|
|
type: "FeatureCollection",
|
|
features: features
|
|
},
|
|
|
|
...(id === "aps"
|
|
? {
|
|
cluster: true,
|
|
clusterMaxZoom: 17,
|
|
clusterRadius: 50
|
|
}
|
|
: {})
|
|
|
|
}
|
|
);
|
|
|
|
};
|
|
|
|
|
|
// ======================================================
|
|
// Map load
|
|
// ======================================================
|
|
|
|
map.on(
|
|
"load",
|
|
function()
|
|
{
|
|
|
|
createSource(
|
|
"aps",
|
|
apFeatures
|
|
);
|
|
|
|
createSource(
|
|
"handshakes",
|
|
handshakeFeatures
|
|
);
|
|
|
|
createSource(
|
|
"pmkids",
|
|
pmkidFeatures
|
|
);
|
|
|
|
createSource(
|
|
"passwords",
|
|
passwordFeatures
|
|
);
|
|
|
|
|
|
// ==================================================
|
|
// All AP clusters
|
|
// ==================================================
|
|
|
|
map.addLayer({
|
|
|
|
id: "ap-clusters",
|
|
|
|
type: "circle",
|
|
|
|
source: "aps",
|
|
|
|
layout: {
|
|
visibility: "none"
|
|
},
|
|
|
|
paint: {
|
|
|
|
"circle-color": [
|
|
"step",
|
|
[
|
|
"get",
|
|
"point_count"
|
|
],
|
|
"#43A047",
|
|
10,
|
|
"#FDD835",
|
|
50,
|
|
"#FB8C00",
|
|
100,
|
|
"#E53935"
|
|
],
|
|
|
|
"circle-radius": [
|
|
"step",
|
|
[
|
|
"get",
|
|
"point_count"
|
|
],
|
|
18,
|
|
50,
|
|
22,
|
|
100,
|
|
28
|
|
],
|
|
|
|
"circle-stroke-color": "#ffffff",
|
|
|
|
"circle-stroke-width": 2
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
// ==================================================
|
|
// Cluster count
|
|
// ==================================================
|
|
|
|
map.addLayer({
|
|
|
|
id: "ap-cluster-count",
|
|
|
|
type: "symbol",
|
|
|
|
source: "aps",
|
|
|
|
layout: {
|
|
|
|
visibility: "none",
|
|
|
|
"text-field": [
|
|
"get",
|
|
"point_count_abbreviated"
|
|
],
|
|
|
|
"text-size": 12
|
|
|
|
},
|
|
|
|
paint: {
|
|
|
|
"text-color": "#ffffff"
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
// ==================================================
|
|
// All AP points
|
|
// ==================================================
|
|
|
|
map.addLayer({
|
|
|
|
id: "ap-points",
|
|
|
|
type: "circle",
|
|
|
|
source: "aps",
|
|
|
|
filter: [
|
|
"!",
|
|
[
|
|
"has",
|
|
"point_count"
|
|
]
|
|
],
|
|
|
|
layout: {
|
|
|
|
visibility: "none"
|
|
|
|
},
|
|
|
|
paint: {
|
|
|
|
"circle-radius": 7,
|
|
|
|
"circle-color": "#43A047",
|
|
|
|
"circle-stroke-color": "#ffffff",
|
|
|
|
"circle-stroke-width": 1.5
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
// ==================================================
|
|
// Handshake points
|
|
// ==================================================
|
|
|
|
map.addLayer({
|
|
|
|
id: "handshake-points",
|
|
|
|
type: "circle",
|
|
|
|
source: "handshakes",
|
|
|
|
layout: {
|
|
|
|
visibility:
|
|
layerState.handshake
|
|
? "visible"
|
|
: "none"
|
|
|
|
},
|
|
|
|
paint: {
|
|
|
|
"circle-radius": 7,
|
|
|
|
"circle-color": "#E5C07B",
|
|
|
|
"circle-stroke-color": "#ffffff",
|
|
|
|
"circle-stroke-width": 1.5
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
// ==================================================
|
|
// PMKID points
|
|
// ==================================================
|
|
|
|
map.addLayer({
|
|
|
|
id: "pmkid-points",
|
|
|
|
type: "circle",
|
|
|
|
source: "pmkids",
|
|
|
|
layout: {
|
|
|
|
visibility:
|
|
layerState.handshake
|
|
? "visible"
|
|
: "none"
|
|
|
|
},
|
|
|
|
paint: {
|
|
|
|
"circle-radius": 7,
|
|
|
|
"circle-color": "#FB8C00",
|
|
|
|
"circle-stroke-color": "#ffffff",
|
|
|
|
"circle-stroke-width": 1.5
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
// ==================================================
|
|
// Password points
|
|
// ==================================================
|
|
|
|
map.addLayer({
|
|
|
|
id: "password-points",
|
|
|
|
type: "circle",
|
|
|
|
source: "passwords",
|
|
|
|
layout: {
|
|
|
|
visibility:
|
|
layerState.password
|
|
? "visible"
|
|
: "none"
|
|
|
|
},
|
|
|
|
paint: {
|
|
|
|
"circle-radius": 7,
|
|
|
|
"circle-color": "#E53935",
|
|
|
|
"circle-stroke-color": "#ffffff",
|
|
|
|
"circle-stroke-width": 1.5
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
// ==================================================
|
|
// Popups
|
|
// ==================================================
|
|
|
|
const popupLayers = [
|
|
|
|
"ap-points",
|
|
|
|
"handshake-points",
|
|
|
|
"pmkid-points",
|
|
|
|
"password-points"
|
|
|
|
];
|
|
|
|
|
|
popupLayers.forEach(
|
|
function(layerId)
|
|
{
|
|
|
|
map.on(
|
|
"click",
|
|
layerId,
|
|
function(e)
|
|
{
|
|
|
|
if(
|
|
!e.features
|
|
||
|
|
!e.features.length
|
|
)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const feature =
|
|
e.features[0];
|
|
|
|
if(
|
|
!feature
|
|
||
|
|
!feature.properties
|
|
)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const popup =
|
|
feature.properties.popup
|
|
||
|
|
"<b>Access Point</b>";
|
|
|
|
new maplibregl.Popup()
|
|
.setLngLat(
|
|
feature.geometry.coordinates
|
|
)
|
|
.setHTML(
|
|
popup
|
|
)
|
|
.addTo(map);
|
|
|
|
}
|
|
);
|
|
|
|
|
|
map.on(
|
|
"mouseenter",
|
|
layerId,
|
|
function()
|
|
{
|
|
|
|
map.getCanvas().style.cursor =
|
|
"pointer";
|
|
|
|
}
|
|
);
|
|
|
|
|
|
map.on(
|
|
"mouseleave",
|
|
layerId,
|
|
function()
|
|
{
|
|
|
|
map.getCanvas().style.cursor =
|
|
"";
|
|
|
|
}
|
|
);
|
|
|
|
}
|
|
);
|
|
|
|
|
|
// ==================================================
|
|
// Cluster click
|
|
// ==================================================
|
|
|
|
map.on(
|
|
"click",
|
|
"ap-clusters",
|
|
function(e)
|
|
{
|
|
|
|
const features =
|
|
map.queryRenderedFeatures(
|
|
e.point,
|
|
{
|
|
layers: [
|
|
"ap-clusters"
|
|
]
|
|
}
|
|
);
|
|
|
|
if(
|
|
!features.length
|
|
)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const clusterId =
|
|
features[0].properties
|
|
.cluster_id;
|
|
|
|
map.getSource("aps")
|
|
.getClusterExpansionZoom(
|
|
clusterId,
|
|
function(
|
|
error,
|
|
zoom
|
|
)
|
|
{
|
|
|
|
if(error)
|
|
{
|
|
return;
|
|
}
|
|
|
|
map.easeTo({
|
|
|
|
center:
|
|
features[0]
|
|
.geometry
|
|
.coordinates,
|
|
|
|
zoom: zoom
|
|
|
|
});
|
|
|
|
}
|
|
);
|
|
|
|
}
|
|
);
|
|
|
|
|
|
map.on(
|
|
"mouseenter",
|
|
"ap-clusters",
|
|
function()
|
|
{
|
|
|
|
map.getCanvas().style.cursor =
|
|
"pointer";
|
|
|
|
}
|
|
);
|
|
|
|
|
|
map.on(
|
|
"mouseleave",
|
|
"ap-clusters",
|
|
function()
|
|
{
|
|
|
|
map.getCanvas().style.cursor =
|
|
"";
|
|
|
|
}
|
|
);
|
|
|
|
|
|
// ==================================================
|
|
// Layer visibility
|
|
// ==================================================
|
|
|
|
const setLayerVisibility =
|
|
function(
|
|
ids,
|
|
visible
|
|
)
|
|
{
|
|
|
|
ids.forEach(
|
|
function(id)
|
|
{
|
|
|
|
if(
|
|
map.getLayer(id)
|
|
)
|
|
{
|
|
|
|
map.setLayoutProperty(
|
|
id,
|
|
"visibility",
|
|
visible
|
|
? "visible"
|
|
: "none"
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
);
|
|
|
|
};
|
|
|
|
|
|
// ==================================================
|
|
// Layer switcher
|
|
// ==================================================
|
|
|
|
document.querySelectorAll(
|
|
".session-map-controls input[data-layer]"
|
|
).forEach(
|
|
function(input)
|
|
{
|
|
|
|
input.addEventListener(
|
|
"change",
|
|
function()
|
|
{
|
|
|
|
const layer =
|
|
input.dataset.layer;
|
|
|
|
layerState[layer] =
|
|
input.checked;
|
|
|
|
|
|
if(layer === "handshake")
|
|
{
|
|
|
|
setLayerVisibility(
|
|
[
|
|
"handshake-points",
|
|
"pmkid-points"
|
|
],
|
|
input.checked
|
|
);
|
|
|
|
}
|
|
|
|
|
|
if(layer === "password")
|
|
{
|
|
|
|
setLayerVisibility(
|
|
[
|
|
"password-points"
|
|
],
|
|
input.checked
|
|
);
|
|
|
|
}
|
|
|
|
|
|
if(layer === "all")
|
|
{
|
|
|
|
setLayerVisibility(
|
|
[
|
|
"ap-points",
|
|
"ap-clusters",
|
|
"ap-cluster-count"
|
|
],
|
|
input.checked
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
);
|
|
|
|
}
|
|
);
|
|
|
|
|
|
// ==================================================
|
|
// Initial map position
|
|
// ==================================================
|
|
|
|
if(apFeatures.length === 1)
|
|
{
|
|
|
|
map.jumpTo({
|
|
|
|
center:
|
|
apFeatures[0]
|
|
.geometry
|
|
.coordinates,
|
|
|
|
zoom: 15
|
|
|
|
});
|
|
|
|
}
|
|
else if(apFeatures.length > 1)
|
|
{
|
|
|
|
const bounds =
|
|
new maplibregl.LngLatBounds();
|
|
|
|
apFeatures.forEach(
|
|
function(feature)
|
|
{
|
|
|
|
bounds.extend(
|
|
feature.geometry.coordinates
|
|
);
|
|
|
|
}
|
|
);
|
|
|
|
map.fitBounds(
|
|
bounds,
|
|
{
|
|
padding: 50,
|
|
maxZoom: 15
|
|
}
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
);
|
|
|
|
|
|
// ======================================================
|
|
// Resize
|
|
// ======================================================
|
|
|
|
window.setTimeout(
|
|
function()
|
|
{
|
|
|
|
map.resize();
|
|
|
|
},
|
|
100
|
|
);
|
|
|
|
</script>
|
|
"""
|
|
|
|
map_html = map_html.replace(
|
|
"__MAPLIBRE_CSS_URL__",
|
|
esc(maplibre_css_url),
|
|
)
|
|
|
|
map_html = map_html.replace(
|
|
"__MAPLIBRE_JS_URL__",
|
|
esc(maplibre_js_url),
|
|
)
|
|
|
|
map_html = map_html.replace(
|
|
"__MARKER_JS__",
|
|
marker_js,
|
|
)
|
|
|
|
return map_html
|
|
|
|
|
|
def render_access_points_page(
|
|
conn,
|
|
context=None,
|
|
):
|
|
|
|
if context is None:
|
|
context = ReportContext(
|
|
mode="server"
|
|
)
|
|
|
|
first_time, last_time = (
|
|
load_report_interval(conn)
|
|
)
|
|
|
|
observations = load_access_points(
|
|
conn
|
|
)
|
|
|
|
html = page_begin(
|
|
"📡 Access Points",
|
|
active="access_points",
|
|
context=context,
|
|
)
|
|
|
|
html += f"""
|
|
<div class="page-description">
|
|
|
|
Time interval:
|
|
|
|
{esc(format_datetime(first_time))}
|
|
—
|
|
{esc(format_datetime(last_time))}
|
|
|
|
</div>
|
|
"""
|
|
|
|
html += card(
|
|
"""
|
|
<span class="session-map-title">
|
|
<span class="session-map-title-text">
|
|
🗺 Capture Map
|
|
</span>
|
|
|
|
<span class="session-map-controls">
|
|
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
data-layer="handshake"
|
|
checked
|
|
>
|
|
<span
|
|
class="map-layer-dot map-layer-dot-handshake"
|
|
></span>
|
|
Handshake / PMKID
|
|
</label>
|
|
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
data-layer="password"
|
|
checked
|
|
>
|
|
<span
|
|
class="map-layer-dot map-layer-dot-password"
|
|
></span>
|
|
Password
|
|
</label>
|
|
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
data-layer="all"
|
|
>
|
|
<span
|
|
class="map-layer-dot map-layer-dot-all"
|
|
></span>
|
|
All Access Points
|
|
</label>
|
|
|
|
</span>
|
|
</span>
|
|
""",
|
|
render_access_points_map(
|
|
observations,
|
|
context,
|
|
),
|
|
)
|
|
|
|
html += page_end()
|
|
|
|
return html |