Files
2026-10-06 21:08:12 +03:00

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