Update public source
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Конфигурация HTML Report.
|
||||
|
||||
Все пути генератора отчётов находятся здесь.
|
||||
|
||||
Это позволяет не использовать "магические" строки
|
||||
по всему проекту.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Пути проекта
|
||||
# ============================================================
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
DATABASE_FILE = PROJECT_ROOT / "database" / "wifi.sqlite3"
|
||||
|
||||
CAPTURES_DIR = PROJECT_ROOT / "captures"
|
||||
|
||||
CONFIG_FILE = PROJECT_ROOT / "config" / "config.yaml"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Загрузка конфигурации
|
||||
# ============================================================
|
||||
|
||||
if CONFIG_FILE.exists():
|
||||
|
||||
with CONFIG_FILE.open(
|
||||
"r",
|
||||
encoding="utf-8"
|
||||
) as fp:
|
||||
|
||||
CONFIG = yaml.safe_load(fp) or {}
|
||||
|
||||
else:
|
||||
|
||||
CONFIG = {}
|
||||
|
||||
REPORTS_CONFIG = CONFIG.get(
|
||||
"reports",
|
||||
{}
|
||||
)
|
||||
|
||||
RECENT_SESSIONS_LIMIT = REPORTS_CONFIG.get(
|
||||
"recent_sessions_limit",
|
||||
10
|
||||
)
|
||||
|
||||
TIMEZONE = CONFIG.get(
|
||||
"timezone",
|
||||
"Europe/Moscow"
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Каталог отчёта
|
||||
# ============================================================
|
||||
|
||||
OUTPUT_DIR = PROJECT_ROOT / "reports" / "output"
|
||||
|
||||
SESSIONS_DIR = OUTPUT_DIR / "sessions"
|
||||
|
||||
INDEX_FILE = OUTPUT_DIR / "index.html"
|
||||
|
||||
ACCESS_POINTS_FILE = OUTPUT_DIR / "access_points.html"
|
||||
|
||||
|
||||
# Страница всех поездок
|
||||
|
||||
CAPTURE_SESSIONS_FILE = OUTPUT_DIR / "capture_sessions.html"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Создание каталогов
|
||||
# ============================================================
|
||||
|
||||
OUTPUT_DIR.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
SESSIONS_DIR.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
@@ -0,0 +1,856 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Dashboard HTML report.
|
||||
|
||||
Главная страница отчета.
|
||||
|
||||
Содержит только обзорную информацию.
|
||||
|
||||
Без карты.
|
||||
|
||||
Без полного списка точек доступа.
|
||||
|
||||
Вся детальная информация находится
|
||||
на страницах отдельных поездок.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .config import (
|
||||
RECENT_SESSIONS_LIMIT,
|
||||
)
|
||||
|
||||
from .utils import (
|
||||
format_datetime,
|
||||
)
|
||||
|
||||
from .template import (
|
||||
|
||||
page_begin,
|
||||
|
||||
page_end,
|
||||
|
||||
page_header,
|
||||
|
||||
card,
|
||||
|
||||
stat,
|
||||
|
||||
stats_grid,
|
||||
|
||||
)
|
||||
|
||||
from .statistics import (
|
||||
|
||||
database_statistics,
|
||||
|
||||
top_vendors,
|
||||
|
||||
top_essid,
|
||||
|
||||
channel_statistics,
|
||||
|
||||
encryption_statistics,
|
||||
|
||||
)
|
||||
|
||||
from .sessions import (
|
||||
|
||||
recent_sessions,
|
||||
|
||||
all_sessions,
|
||||
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Helpers
|
||||
# ============================================================
|
||||
|
||||
def format_session_folder(capture_file):
|
||||
if not capture_file:
|
||||
return "-"
|
||||
|
||||
folder = Path(capture_file).parent.name
|
||||
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
dt = datetime.strptime(
|
||||
folder,
|
||||
"%Y%m%d_%H%M%S"
|
||||
).replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
|
||||
return format_datetime(
|
||||
dt.isoformat()
|
||||
)
|
||||
|
||||
except ValueError:
|
||||
return folder
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Database statistics
|
||||
# ============================================================
|
||||
|
||||
def statistics_block(conn):
|
||||
|
||||
data = database_statistics(conn)
|
||||
|
||||
items = [
|
||||
|
||||
stat(
|
||||
data["access_points"],
|
||||
"Access Points"
|
||||
),
|
||||
|
||||
stat(
|
||||
data["access_points_without_coordinates"],
|
||||
"AP without Coordinates"
|
||||
),
|
||||
|
||||
stat(
|
||||
data["sessions"],
|
||||
"Capture Sessions"
|
||||
),
|
||||
|
||||
stat(
|
||||
data["vendors"],
|
||||
"Vendors"
|
||||
),
|
||||
|
||||
stat(
|
||||
data["handshakes"],
|
||||
"Handshakes"
|
||||
),
|
||||
|
||||
stat(
|
||||
data["credentials"],
|
||||
"Handshake Cracked"
|
||||
),
|
||||
|
||||
stat(
|
||||
data["pmkid"],
|
||||
"PMKID"
|
||||
),
|
||||
|
||||
stat(
|
||||
data["cracked"],
|
||||
"Cracked AP"
|
||||
),
|
||||
|
||||
stat(
|
||||
data["database_size"],
|
||||
"Database"
|
||||
),
|
||||
|
||||
]
|
||||
|
||||
return card(
|
||||
|
||||
"📊 Database Overview",
|
||||
|
||||
stats_grid(items)
|
||||
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Information
|
||||
# ============================================================
|
||||
|
||||
def information_block(conn):
|
||||
|
||||
data = database_statistics(conn)
|
||||
|
||||
html = f"""
|
||||
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Database size</b></td>
|
||||
|
||||
<td>{data["database_size"]}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Captures size</b></td>
|
||||
|
||||
<td>{data["captures_size"]}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Capture files</b></td>
|
||||
|
||||
<td>{data["capture_files"]}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>First access point</b></td>
|
||||
|
||||
<td>{format_datetime(data["first_seen"])}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Last access point</b></td>
|
||||
|
||||
<td>{format_datetime(data["last_seen"])}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
"""
|
||||
|
||||
return card(
|
||||
|
||||
"ℹ️ Database Information",
|
||||
|
||||
html,
|
||||
|
||||
)
|
||||
# ============================================================
|
||||
# Top Vendors
|
||||
# ============================================================
|
||||
|
||||
def vendors_block(conn):
|
||||
|
||||
rows = top_vendors(
|
||||
conn,
|
||||
limit=15
|
||||
)
|
||||
|
||||
html = """
|
||||
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Vendor</th>
|
||||
|
||||
<th>Devices</th>
|
||||
|
||||
</tr>
|
||||
|
||||
"""
|
||||
|
||||
for vendor, total in rows:
|
||||
|
||||
if not vendor:
|
||||
|
||||
vendor = "-"
|
||||
|
||||
html += f"""
|
||||
|
||||
<tr>
|
||||
|
||||
<td>{vendor}</td>
|
||||
|
||||
<td>{total}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
"""
|
||||
|
||||
html += """
|
||||
|
||||
</table>
|
||||
|
||||
"""
|
||||
|
||||
return card(
|
||||
|
||||
"🏭 Top Vendors",
|
||||
|
||||
html,
|
||||
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Top ESSID
|
||||
# ============================================================
|
||||
|
||||
def essid_block(conn):
|
||||
|
||||
rows = top_essid(
|
||||
conn,
|
||||
limit=20
|
||||
)
|
||||
|
||||
html = """
|
||||
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>ESSID</th>
|
||||
|
||||
<th>Count</th>
|
||||
|
||||
</tr>
|
||||
|
||||
"""
|
||||
|
||||
for essid, total in rows:
|
||||
|
||||
if not essid:
|
||||
|
||||
essid = "<hidden>"
|
||||
|
||||
html += f"""
|
||||
|
||||
<tr>
|
||||
|
||||
<td>{essid}</td>
|
||||
|
||||
<td>{total}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
"""
|
||||
|
||||
html += """
|
||||
|
||||
</table>
|
||||
|
||||
"""
|
||||
|
||||
return card(
|
||||
|
||||
"📶 Most Common ESSID",
|
||||
|
||||
html,
|
||||
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Channels
|
||||
# ============================================================
|
||||
|
||||
def channels_block(conn):
|
||||
|
||||
rows = channel_statistics(
|
||||
conn
|
||||
)
|
||||
|
||||
html = """
|
||||
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Channel</th>
|
||||
|
||||
<th>Access Points</th>
|
||||
|
||||
</tr>
|
||||
|
||||
"""
|
||||
|
||||
for channel, total in rows:
|
||||
|
||||
html += f"""
|
||||
|
||||
<tr>
|
||||
|
||||
<td>{channel}</td>
|
||||
|
||||
<td>{total}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
"""
|
||||
|
||||
html += """
|
||||
|
||||
</table>
|
||||
|
||||
"""
|
||||
|
||||
return card(
|
||||
|
||||
"📡 Channel Statistics",
|
||||
|
||||
html,
|
||||
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Encryption
|
||||
# ============================================================
|
||||
|
||||
def encryption_block(conn):
|
||||
|
||||
rows = encryption_statistics(
|
||||
conn
|
||||
)
|
||||
|
||||
html = """
|
||||
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Encryption</th>
|
||||
|
||||
<th>Networks</th>
|
||||
|
||||
</tr>
|
||||
|
||||
"""
|
||||
|
||||
for encryption, total in rows:
|
||||
|
||||
if not encryption:
|
||||
|
||||
encryption = "-"
|
||||
|
||||
html += f"""
|
||||
|
||||
<tr>
|
||||
|
||||
<td>{encryption}</td>
|
||||
|
||||
<td>{total}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
"""
|
||||
|
||||
html += """
|
||||
|
||||
</table>
|
||||
|
||||
"""
|
||||
|
||||
return card(
|
||||
|
||||
"🔐 Encryption",
|
||||
|
||||
html,
|
||||
|
||||
)
|
||||
# ============================================================
|
||||
# Session Card
|
||||
# ============================================================
|
||||
|
||||
def session_card(
|
||||
session,
|
||||
context=None,
|
||||
):
|
||||
|
||||
session_url = context.session_url(
|
||||
session["id"]
|
||||
)
|
||||
|
||||
return f"""
|
||||
|
||||
<div class="card">
|
||||
|
||||
<h3>
|
||||
|
||||
🚗 Session {format_datetime(session.get("trip_start"))}
|
||||
|
||||
</h3>
|
||||
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Trip start</b></td>
|
||||
|
||||
<td>{format_datetime(session.get("trip_start"))}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Trip end</b></td>
|
||||
|
||||
<td>{format_datetime(session.get("trip_end"))}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Duration</b></td>
|
||||
|
||||
<td>{session.get("duration","-")}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Imported</b></td>
|
||||
|
||||
<td>{format_datetime(session.get("start_time"))}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Session folder</b></td>
|
||||
|
||||
<td>{format_session_folder(session.get("capture_file"))}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Access Points</b></td>
|
||||
|
||||
<td>{session.get("access_points","-")}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Coordinates Restored</b></td>
|
||||
|
||||
<td>{session.get("coordinates_restored","-")}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>AP without Coordinates</b></td>
|
||||
|
||||
<td>{session.get("ap_without_coordinates","-")}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Handshake AP</b></td>
|
||||
|
||||
<td>{session.get("handshakes","-")}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Handshake Records</b></td>
|
||||
|
||||
<td>{session.get("handshake_records","-")}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>PMKID AP</b></td>
|
||||
|
||||
<td>{session.get("pmkid","-")}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Cracked AP</b></td>
|
||||
|
||||
<td>{session.get("cracked","-")}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<td><b>Directory size</b></td>
|
||||
|
||||
<td>{session.get("directory_size","-")}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<p>
|
||||
|
||||
<a
|
||||
|
||||
class="button"
|
||||
draggable="false"
|
||||
|
||||
href="{session_url}">
|
||||
|
||||
📄 Open Session Report
|
||||
|
||||
</a>
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Recent Sessions
|
||||
# ============================================================
|
||||
|
||||
def sessions_block(
|
||||
conn,
|
||||
context=None,
|
||||
):
|
||||
|
||||
sessions = recent_sessions(
|
||||
|
||||
conn,
|
||||
|
||||
limit=RECENT_SESSIONS_LIMIT,
|
||||
|
||||
)
|
||||
|
||||
if not sessions:
|
||||
|
||||
return card(
|
||||
|
||||
"🚗 Recent Capture Sessions",
|
||||
|
||||
"""
|
||||
|
||||
<p>
|
||||
|
||||
Capture sessions not found.
|
||||
|
||||
</p>
|
||||
|
||||
"""
|
||||
|
||||
)
|
||||
|
||||
cards = []
|
||||
|
||||
for session in sessions:
|
||||
|
||||
cards.append(
|
||||
|
||||
session_card(
|
||||
|
||||
session,
|
||||
context,
|
||||
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
capture_sessions_url = (
|
||||
context.capture_sessions_url()
|
||||
)
|
||||
|
||||
html = """
|
||||
|
||||
<div class="report-grid">
|
||||
|
||||
"""
|
||||
|
||||
html += "\n".join(cards)
|
||||
|
||||
html += f"""
|
||||
|
||||
</div>
|
||||
|
||||
<p class="report-action">
|
||||
|
||||
<a
|
||||
class="button"
|
||||
draggable="false"
|
||||
href="{capture_sessions_url}">
|
||||
|
||||
Show all sessions
|
||||
|
||||
</a>
|
||||
|
||||
</p>
|
||||
|
||||
"""
|
||||
|
||||
return card(
|
||||
|
||||
"🚗 Recent Capture Sessions",
|
||||
|
||||
html,
|
||||
|
||||
)
|
||||
# ============================================================
|
||||
# Dashboard
|
||||
# ============================================================
|
||||
|
||||
def generate_dashboard(
|
||||
conn,
|
||||
context=None,
|
||||
):
|
||||
|
||||
html = page_begin(
|
||||
|
||||
title="WiFi GPS Mapper",
|
||||
|
||||
description=(
|
||||
"Database overview"
|
||||
),
|
||||
|
||||
active="dashboard",
|
||||
|
||||
context=context,
|
||||
|
||||
)
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Database statistics
|
||||
# --------------------------------------------------------
|
||||
|
||||
html += statistics_block(
|
||||
conn
|
||||
)
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Information
|
||||
# --------------------------------------------------------
|
||||
|
||||
html += information_block(
|
||||
conn
|
||||
)
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Vendors / ESSID
|
||||
# --------------------------------------------------------
|
||||
|
||||
html += """
|
||||
|
||||
<div class="report-grid">
|
||||
|
||||
"""
|
||||
|
||||
html += vendors_block(
|
||||
conn
|
||||
)
|
||||
|
||||
html += essid_block(
|
||||
conn
|
||||
)
|
||||
|
||||
html += """
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Channels / Encryption
|
||||
# --------------------------------------------------------
|
||||
|
||||
html += """
|
||||
|
||||
<div class="report-grid">
|
||||
|
||||
"""
|
||||
|
||||
html += channels_block(
|
||||
conn
|
||||
)
|
||||
|
||||
html += encryption_block(
|
||||
conn
|
||||
)
|
||||
|
||||
html += """
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Sessions
|
||||
# --------------------------------------------------------
|
||||
|
||||
html += sessions_block(
|
||||
conn,
|
||||
context,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Footer
|
||||
# --------------------------------------------------------
|
||||
|
||||
html += page_end()
|
||||
|
||||
return html
|
||||
|
||||
# ============================================================
|
||||
# All Capture Sessions
|
||||
# ============================================================
|
||||
|
||||
def generate_capture_sessions(
|
||||
conn,
|
||||
context=None,
|
||||
):
|
||||
|
||||
sessions = all_sessions(
|
||||
conn
|
||||
)
|
||||
|
||||
html = page_begin(
|
||||
|
||||
title="Capture Sessions",
|
||||
|
||||
description="All capture sessions",
|
||||
|
||||
active="capture_sessions",
|
||||
|
||||
context=context,
|
||||
|
||||
)
|
||||
|
||||
html += page_header(
|
||||
|
||||
"🚗 Capture Sessions",
|
||||
|
||||
f"""
|
||||
|
||||
Total sessions:
|
||||
|
||||
<b>{len(sessions)}</b>
|
||||
|
||||
"""
|
||||
)
|
||||
|
||||
cards = []
|
||||
|
||||
for session in sessions:
|
||||
|
||||
cards.append(
|
||||
session_card(
|
||||
session,
|
||||
context,
|
||||
)
|
||||
)
|
||||
|
||||
html += """
|
||||
|
||||
<div class="report-grid">
|
||||
|
||||
"""
|
||||
|
||||
html += "\n".join(cards)
|
||||
|
||||
html += """
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
html += page_end()
|
||||
|
||||
return html
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Работа с базой данных SQLite.
|
||||
|
||||
Данный модуль является единственной точкой доступа
|
||||
к базе данных для HTML Report.
|
||||
|
||||
Никакие другие модули не должны напрямую вызывать
|
||||
sqlite3.connect().
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .config import DATABASE_FILE
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Подключение
|
||||
# ============================================================
|
||||
|
||||
|
||||
def connect(database_file=None):
|
||||
"""
|
||||
Открыть соединение с SQLite.
|
||||
"""
|
||||
|
||||
if database_file is None:
|
||||
database_file = DATABASE_FILE
|
||||
|
||||
database_file = Path(database_file)
|
||||
|
||||
if not database_file.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Database not found:\n{database_file}"
|
||||
)
|
||||
|
||||
conn = sqlite3.connect(database_file)
|
||||
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Выполнение SQL
|
||||
# ============================================================
|
||||
|
||||
|
||||
def execute(conn, sql, parameters=None):
|
||||
"""
|
||||
Выполнить SQL запрос.
|
||||
"""
|
||||
|
||||
if parameters is None:
|
||||
parameters = ()
|
||||
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
sql,
|
||||
parameters
|
||||
)
|
||||
|
||||
return cursor
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Один объект
|
||||
# ============================================================
|
||||
|
||||
|
||||
def query_one(conn, sql, parameters=None):
|
||||
"""
|
||||
Вернуть одну запись.
|
||||
"""
|
||||
|
||||
cursor = execute(
|
||||
conn,
|
||||
sql,
|
||||
parameters
|
||||
)
|
||||
|
||||
return cursor.fetchone()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Все записи
|
||||
# ============================================================
|
||||
|
||||
|
||||
def query_all(conn, sql, parameters=None):
|
||||
"""
|
||||
Вернуть список записей.
|
||||
"""
|
||||
|
||||
cursor = execute(
|
||||
conn,
|
||||
sql,
|
||||
parameters
|
||||
)
|
||||
|
||||
return cursor.fetchall()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Одно значение
|
||||
# ============================================================
|
||||
|
||||
|
||||
def query_value(conn, sql, parameters=None, default=None):
|
||||
"""
|
||||
Вернуть первое поле первой записи.
|
||||
"""
|
||||
|
||||
row = query_one(
|
||||
conn,
|
||||
sql,
|
||||
parameters
|
||||
)
|
||||
|
||||
if row is None:
|
||||
return default
|
||||
|
||||
if len(row) == 0:
|
||||
return default
|
||||
|
||||
return row[0]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Проверка существования таблицы
|
||||
# ============================================================
|
||||
|
||||
|
||||
def table_exists(conn, table_name):
|
||||
"""
|
||||
Проверить существование таблицы.
|
||||
"""
|
||||
|
||||
row = query_one(
|
||||
conn,
|
||||
"""
|
||||
SELECT
|
||||
name
|
||||
FROM sqlite_master
|
||||
WHERE type='table'
|
||||
AND name=?
|
||||
""",
|
||||
(
|
||||
table_name,
|
||||
)
|
||||
)
|
||||
|
||||
return row is not None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Количество строк
|
||||
# ============================================================
|
||||
|
||||
|
||||
def count_rows(conn, table_name):
|
||||
"""
|
||||
Вернуть количество строк таблицы.
|
||||
"""
|
||||
|
||||
if not table_exists(
|
||||
conn,
|
||||
table_name
|
||||
):
|
||||
return 0
|
||||
|
||||
cursor = execute(
|
||||
conn,
|
||||
f"""
|
||||
SELECT COUNT(*)
|
||||
FROM {table_name}
|
||||
"""
|
||||
)
|
||||
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Список таблиц
|
||||
# ============================================================
|
||||
|
||||
|
||||
def list_tables(conn):
|
||||
"""
|
||||
Вернуть список таблиц базы.
|
||||
"""
|
||||
|
||||
rows = query_all(
|
||||
conn,
|
||||
"""
|
||||
SELECT
|
||||
name
|
||||
FROM sqlite_master
|
||||
WHERE type='table'
|
||||
ORDER BY name
|
||||
"""
|
||||
)
|
||||
|
||||
return [
|
||||
row["name"]
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Структура таблицы
|
||||
# ============================================================
|
||||
|
||||
|
||||
def table_info(conn, table_name):
|
||||
"""
|
||||
Вернуть описание таблицы.
|
||||
"""
|
||||
|
||||
cursor = execute(
|
||||
conn,
|
||||
f"PRAGMA table_info({table_name})"
|
||||
)
|
||||
|
||||
return cursor.fetchall()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Закрытие соединения
|
||||
# ============================================================
|
||||
|
||||
|
||||
def close(conn):
|
||||
"""
|
||||
Закрыть соединение.
|
||||
"""
|
||||
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -0,0 +1,834 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Общая бизнес-логика Hashcat.
|
||||
|
||||
Этот модуль используется одновременно:
|
||||
• серверным Reports;
|
||||
• CLI tools/hashcat.py.
|
||||
|
||||
Модуль не открывает соединение с SQLite самостоятельно.
|
||||
|
||||
Соединение передаётся вызывающим кодом и обслуживается
|
||||
существующим reports.database.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Constants
|
||||
# ============================================================
|
||||
|
||||
MAC_PATTERN = re.compile(
|
||||
r"^[0-9a-fA-F]{12}$"
|
||||
)
|
||||
|
||||
SUPPORTED_HANDSHAKE_TYPES = {
|
||||
"WPA*01",
|
||||
"WPA*02",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Export filename
|
||||
# ============================================================
|
||||
|
||||
def sanitize_export_query(
|
||||
query,
|
||||
):
|
||||
value = str(
|
||||
query or ""
|
||||
).strip().lower()
|
||||
|
||||
value = re.sub(
|
||||
r"[^a-z0-9]+",
|
||||
"-",
|
||||
value
|
||||
)
|
||||
|
||||
value = value.strip(
|
||||
"-"
|
||||
)
|
||||
|
||||
if not value:
|
||||
value = "all"
|
||||
|
||||
return value[
|
||||
:80
|
||||
]
|
||||
|
||||
|
||||
def build_export_filename(
|
||||
export_type,
|
||||
query="",
|
||||
):
|
||||
timestamp = datetime.now(
|
||||
timezone.utc
|
||||
).strftime(
|
||||
"%Y%m%d-%H%M%S"
|
||||
)
|
||||
|
||||
if export_type == "all":
|
||||
|
||||
return (
|
||||
"wifi-gps-mapper-all-"
|
||||
f"{timestamp}.hc22000"
|
||||
)
|
||||
|
||||
if export_type == "search":
|
||||
|
||||
safe_query = sanitize_export_query(
|
||||
query
|
||||
)
|
||||
|
||||
return (
|
||||
"wifi-gps-mapper-search-"
|
||||
f"{safe_query}-"
|
||||
f"{timestamp}.hc22000"
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"Unknown export type: {export_type}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Hash export
|
||||
# ============================================================
|
||||
|
||||
def _build_hash_export_from_rows(
|
||||
rows,
|
||||
):
|
||||
hashes = [
|
||||
str(
|
||||
row["hash22000"]
|
||||
)
|
||||
for row in rows
|
||||
if row["hash22000"] is not None
|
||||
and str(
|
||||
row["hash22000"]
|
||||
).strip()
|
||||
]
|
||||
|
||||
if not hashes:
|
||||
return ""
|
||||
|
||||
return (
|
||||
"\n".join(
|
||||
hashes
|
||||
)
|
||||
+
|
||||
"\n"
|
||||
)
|
||||
|
||||
|
||||
def get_all_hashes(
|
||||
conn,
|
||||
):
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
h.hash22000
|
||||
FROM handshakes AS h
|
||||
WHERE
|
||||
h.hash22000 IS NOT NULL
|
||||
AND TRIM(h.hash22000) != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM credentials AS c
|
||||
WHERE c.handshake_id = h.id
|
||||
)
|
||||
ORDER BY
|
||||
h.hash22000
|
||||
"""
|
||||
)
|
||||
|
||||
return cursor.fetchall()
|
||||
|
||||
|
||||
def build_all_hash_export(
|
||||
conn,
|
||||
):
|
||||
rows = get_all_hashes(
|
||||
conn
|
||||
)
|
||||
|
||||
return _build_hash_export_from_rows(
|
||||
rows
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Database statistics
|
||||
# ============================================================
|
||||
|
||||
def get_export_statistics(
|
||||
conn,
|
||||
):
|
||||
rows = get_all_hashes(
|
||||
conn
|
||||
)
|
||||
|
||||
statistics = {
|
||||
"exported_hashes": len(rows),
|
||||
"wpa01": 0,
|
||||
"wpa02": 0,
|
||||
}
|
||||
|
||||
for row in rows:
|
||||
|
||||
hash_value = str(
|
||||
row["hash22000"]
|
||||
)
|
||||
|
||||
if hash_value.startswith(
|
||||
"WPA*01*"
|
||||
):
|
||||
statistics["wpa01"] += 1
|
||||
|
||||
elif hash_value.startswith(
|
||||
"WPA*02*"
|
||||
):
|
||||
statistics["wpa02"] += 1
|
||||
|
||||
return statistics
|
||||
|
||||
def get_database_statistics(
|
||||
conn,
|
||||
):
|
||||
statistics = {}
|
||||
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM access_points
|
||||
"""
|
||||
)
|
||||
|
||||
statistics["access_points"] = cursor.fetchone()[0]
|
||||
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM handshakes
|
||||
"""
|
||||
)
|
||||
|
||||
statistics["handshakes"] = cursor.fetchone()[0]
|
||||
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM handshakes
|
||||
WHERE
|
||||
hash22000 IS NOT NULL
|
||||
AND TRIM(hash22000) != ''
|
||||
"""
|
||||
)
|
||||
|
||||
statistics["hashes"] = cursor.fetchone()[0]
|
||||
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT hash22000)
|
||||
FROM handshakes
|
||||
WHERE
|
||||
hash22000 IS NOT NULL
|
||||
AND TRIM(hash22000) != ''
|
||||
"""
|
||||
)
|
||||
|
||||
statistics["unique_hashes"] = cursor.fetchone()[0]
|
||||
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM credentials
|
||||
"""
|
||||
)
|
||||
|
||||
statistics["credentials"] = cursor.fetchone()[0]
|
||||
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT c.access_point_id)
|
||||
FROM credentials AS c
|
||||
JOIN handshakes AS h
|
||||
ON h.id = c.handshake_id
|
||||
"""
|
||||
)
|
||||
|
||||
statistics["cracked_access_points"] = cursor.fetchone()[0]
|
||||
|
||||
return statistics
|
||||
|
||||
# ============================================================
|
||||
# Hashcat --show parser
|
||||
# ============================================================
|
||||
|
||||
def _normalize_mac(
|
||||
value,
|
||||
):
|
||||
value = str(
|
||||
value or ""
|
||||
).strip().lower()
|
||||
|
||||
value = value.replace(
|
||||
":",
|
||||
""
|
||||
)
|
||||
|
||||
value = value.replace(
|
||||
"-",
|
||||
""
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
def _normalize_hashcat_essid(
|
||||
value,
|
||||
):
|
||||
value = str(
|
||||
value or ""
|
||||
).strip()
|
||||
|
||||
if (
|
||||
value.startswith("$HEX[")
|
||||
and value.endswith("]")
|
||||
):
|
||||
encoded = value[5:-1]
|
||||
|
||||
if not encoded:
|
||||
return ""
|
||||
|
||||
if (
|
||||
len(encoded) % 2 != 0
|
||||
or re.fullmatch(
|
||||
r"[0-9a-fA-F]+",
|
||||
encoded
|
||||
) is None
|
||||
):
|
||||
raise ValueError(
|
||||
"ESSID $HEX value must be hexadecimal."
|
||||
)
|
||||
|
||||
return bytes.fromhex(
|
||||
encoded
|
||||
).hex()
|
||||
|
||||
return value.encode(
|
||||
"utf-8"
|
||||
).hex()
|
||||
|
||||
def _parse_handshake_hash(
|
||||
value,
|
||||
):
|
||||
value = str(
|
||||
value or ""
|
||||
).strip()
|
||||
|
||||
if not value:
|
||||
raise ValueError(
|
||||
"HASH is empty."
|
||||
)
|
||||
|
||||
parts = value.split(
|
||||
"*"
|
||||
)
|
||||
|
||||
if len(parts) < 6:
|
||||
raise ValueError(
|
||||
"HASH is not a valid WPA hash."
|
||||
)
|
||||
|
||||
handshake_type = (
|
||||
f"{parts[0]}*{parts[1]}"
|
||||
)
|
||||
|
||||
if handshake_type not in SUPPORTED_HANDSHAKE_TYPES:
|
||||
raise ValueError(
|
||||
"Unsupported handshake type: "
|
||||
f"{handshake_type}."
|
||||
)
|
||||
|
||||
hash_value = parts[2].strip().lower()
|
||||
ap_bssid = _normalize_mac(
|
||||
parts[3]
|
||||
)
|
||||
client_mac = _normalize_mac(
|
||||
parts[4]
|
||||
)
|
||||
essid = parts[5].strip().lower()
|
||||
|
||||
if not hash_value:
|
||||
raise ValueError(
|
||||
"HASH value is empty."
|
||||
)
|
||||
|
||||
if (
|
||||
len(hash_value) != 32
|
||||
or re.fullmatch(
|
||||
r"[0-9a-f]{32}",
|
||||
hash_value
|
||||
) is None
|
||||
):
|
||||
raise ValueError(
|
||||
"HASH value must be 32 hexadecimal characters."
|
||||
)
|
||||
|
||||
if not MAC_PATTERN.fullmatch(
|
||||
ap_bssid
|
||||
):
|
||||
raise ValueError(
|
||||
"AP_BSSID is not a valid MAC address."
|
||||
)
|
||||
|
||||
if not MAC_PATTERN.fullmatch(
|
||||
client_mac
|
||||
):
|
||||
raise ValueError(
|
||||
"CLIENT_MAC is not a valid MAC address."
|
||||
)
|
||||
|
||||
if essid and (
|
||||
len(essid) % 2 != 0
|
||||
or re.fullmatch(
|
||||
r"[0-9a-fA-F]+",
|
||||
essid
|
||||
) is None
|
||||
):
|
||||
raise ValueError(
|
||||
"ESSID must be hexadecimal."
|
||||
)
|
||||
|
||||
return {
|
||||
"handshake_type": handshake_type,
|
||||
"hash": hash_value.lower(),
|
||||
"ap_bssid": ap_bssid,
|
||||
"client_mac": client_mac,
|
||||
"essid": essid,
|
||||
}
|
||||
|
||||
|
||||
def parse_hashcat_show_line(
|
||||
line,
|
||||
line_number,
|
||||
):
|
||||
raw = str(
|
||||
line
|
||||
).rstrip(
|
||||
"\r\n"
|
||||
)
|
||||
|
||||
if not raw.strip():
|
||||
raise ValueError(
|
||||
"Empty line."
|
||||
)
|
||||
|
||||
parts = raw.split(
|
||||
":",
|
||||
4
|
||||
)
|
||||
|
||||
if len(parts) != 5:
|
||||
raise ValueError(
|
||||
"Expected format: "
|
||||
"HASH:AP_BSSID:CLIENT_MAC:ESSID:PASSWORD."
|
||||
)
|
||||
|
||||
hash_value = parts[0].strip().lower()
|
||||
ap_bssid = _normalize_mac(
|
||||
parts[1]
|
||||
)
|
||||
client_mac = _normalize_mac(
|
||||
parts[2]
|
||||
)
|
||||
essid = parts[3].strip()
|
||||
password = parts[4]
|
||||
|
||||
if not hash_value:
|
||||
raise ValueError(
|
||||
"HASH is empty."
|
||||
)
|
||||
|
||||
if (
|
||||
len(hash_value) != 32
|
||||
or re.fullmatch(
|
||||
r"[0-9a-f]{32}",
|
||||
hash_value
|
||||
) is None
|
||||
):
|
||||
raise ValueError(
|
||||
"HASH value must be 32 hexadecimal characters."
|
||||
)
|
||||
|
||||
if not MAC_PATTERN.fullmatch(
|
||||
ap_bssid
|
||||
):
|
||||
raise ValueError(
|
||||
"AP_BSSID is not a valid MAC address."
|
||||
)
|
||||
|
||||
if not MAC_PATTERN.fullmatch(
|
||||
client_mac
|
||||
):
|
||||
raise ValueError(
|
||||
"CLIENT_MAC is not a valid MAC address."
|
||||
)
|
||||
|
||||
essid_hex = _normalize_hashcat_essid(
|
||||
essid
|
||||
)
|
||||
|
||||
return {
|
||||
"line_number": line_number,
|
||||
"hash22000": hash_value,
|
||||
"hash": hash_value,
|
||||
"ap_bssid": ap_bssid,
|
||||
"client_mac": client_mac,
|
||||
"essid": essid_hex,
|
||||
"password": password,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Handshake matching
|
||||
# ============================================================
|
||||
|
||||
def find_matching_handshake(
|
||||
conn,
|
||||
parsed,
|
||||
):
|
||||
from reports.database import query_all
|
||||
|
||||
rows = query_all(
|
||||
conn,
|
||||
"""
|
||||
SELECT
|
||||
h.id,
|
||||
h.access_point_id,
|
||||
h.hash22000
|
||||
FROM handshakes AS h
|
||||
WHERE
|
||||
h.hash22000 IS NOT NULL
|
||||
AND TRIM(h.hash22000) != ''
|
||||
ORDER BY
|
||||
h.id
|
||||
"""
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
|
||||
try:
|
||||
handshake = _parse_handshake_hash(
|
||||
row["hash22000"]
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if (
|
||||
handshake["hash"]
|
||||
== parsed["hash"]
|
||||
and handshake["ap_bssid"]
|
||||
== parsed["ap_bssid"]
|
||||
and handshake["client_mac"]
|
||||
== parsed["client_mac"]
|
||||
):
|
||||
return row
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Credential operations
|
||||
# ============================================================
|
||||
|
||||
def credential_exists(
|
||||
conn,
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
):
|
||||
from reports.database import query_one
|
||||
|
||||
row = query_one(
|
||||
conn,
|
||||
"""
|
||||
SELECT id
|
||||
FROM credentials
|
||||
WHERE
|
||||
access_point_id = ?
|
||||
AND handshake_id = ?
|
||||
AND password = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
)
|
||||
)
|
||||
|
||||
return row is not None
|
||||
|
||||
|
||||
def add_credential(
|
||||
conn,
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
):
|
||||
from reports.database import execute
|
||||
|
||||
now = datetime.now(
|
||||
timezone.utc
|
||||
).isoformat().replace(
|
||||
"+00:00",
|
||||
"Z"
|
||||
)
|
||||
|
||||
cursor = execute(
|
||||
conn,
|
||||
"""
|
||||
INSERT INTO credentials
|
||||
(
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
source,
|
||||
created_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?
|
||||
)
|
||||
""",
|
||||
(
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
"hashcat",
|
||||
now,
|
||||
)
|
||||
)
|
||||
|
||||
execute(
|
||||
conn,
|
||||
"""
|
||||
UPDATE access_points
|
||||
SET
|
||||
is_cracked = 1
|
||||
WHERE
|
||||
id = ?
|
||||
""",
|
||||
(
|
||||
access_point_id,
|
||||
)
|
||||
)
|
||||
|
||||
return cursor.lastrowid
|
||||
|
||||
# ============================================================
|
||||
# Import validation
|
||||
# ============================================================
|
||||
|
||||
def validate_hashcat_show_file(
|
||||
conn,
|
||||
lines,
|
||||
):
|
||||
statistics = {
|
||||
"total_lines": 0,
|
||||
"valid_lines": 0,
|
||||
"invalid_lines": 0,
|
||||
"matched_handshakes": 0,
|
||||
"missing_handshakes": 0,
|
||||
"new_credentials": 0,
|
||||
"duplicate_credentials": 0,
|
||||
"already_existing": 0,
|
||||
"errors": [],
|
||||
"missing": [],
|
||||
"items": [],
|
||||
}
|
||||
|
||||
seen = set()
|
||||
seen_credentials = set()
|
||||
|
||||
for line_number, line in enumerate(
|
||||
lines,
|
||||
start=1
|
||||
):
|
||||
statistics["total_lines"] += 1
|
||||
|
||||
try:
|
||||
parsed = parse_hashcat_show_line(
|
||||
line,
|
||||
line_number
|
||||
)
|
||||
|
||||
key = (
|
||||
parsed["hash"],
|
||||
parsed["ap_bssid"],
|
||||
parsed["client_mac"],
|
||||
parsed["essid"],
|
||||
parsed["password"],
|
||||
)
|
||||
|
||||
if key in seen:
|
||||
raise ValueError(
|
||||
"Duplicate line in input file."
|
||||
)
|
||||
|
||||
seen.add(
|
||||
key
|
||||
)
|
||||
|
||||
handshake = find_matching_handshake(
|
||||
conn,
|
||||
parsed
|
||||
)
|
||||
|
||||
if handshake is None:
|
||||
statistics["missing_handshakes"] += 1
|
||||
|
||||
statistics["missing"].append(
|
||||
{
|
||||
"line": line_number,
|
||||
"hash":
|
||||
parsed["hash"],
|
||||
"ap_bssid":
|
||||
parsed["ap_bssid"],
|
||||
"client_mac":
|
||||
parsed["client_mac"],
|
||||
"essid":
|
||||
parsed["essid"],
|
||||
"password":
|
||||
parsed["password"],
|
||||
}
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
statistics["matched_handshakes"] += 1
|
||||
|
||||
credential_key = (
|
||||
handshake["access_point_id"],
|
||||
handshake["id"],
|
||||
parsed["password"],
|
||||
)
|
||||
|
||||
if credential_key in seen_credentials:
|
||||
|
||||
statistics["duplicate_credentials"] += 1
|
||||
status = "Duplicate credential"
|
||||
|
||||
else:
|
||||
|
||||
seen_credentials.add(
|
||||
credential_key
|
||||
)
|
||||
|
||||
exists = credential_exists(
|
||||
conn,
|
||||
handshake["access_point_id"],
|
||||
handshake["id"],
|
||||
parsed["password"]
|
||||
)
|
||||
|
||||
if exists:
|
||||
statistics["already_existing"] += 1
|
||||
status = "Already exists"
|
||||
|
||||
else:
|
||||
statistics["new_credentials"] += 1
|
||||
status = "New credential"
|
||||
|
||||
statistics["valid_lines"] += 1
|
||||
|
||||
statistics["items"].append(
|
||||
{
|
||||
"line": line_number,
|
||||
"access_point_id":
|
||||
handshake["access_point_id"],
|
||||
"handshake_id":
|
||||
handshake["id"],
|
||||
"password":
|
||||
parsed["password"],
|
||||
"status": status,
|
||||
}
|
||||
)
|
||||
|
||||
except ValueError as error:
|
||||
|
||||
statistics["invalid_lines"] += 1
|
||||
|
||||
statistics["errors"].append(
|
||||
{
|
||||
"line": line_number,
|
||||
"reason": str(
|
||||
error
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return statistics
|
||||
|
||||
# ============================================================
|
||||
# Atomic import
|
||||
# ============================================================
|
||||
|
||||
def import_hashcat_show(
|
||||
conn,
|
||||
lines,
|
||||
):
|
||||
lines = list(
|
||||
lines
|
||||
)
|
||||
|
||||
validation = validate_hashcat_show_file(
|
||||
conn,
|
||||
lines
|
||||
)
|
||||
|
||||
if validation["errors"]:
|
||||
|
||||
return {
|
||||
**validation,
|
||||
"committed": False,
|
||||
"result": "Rejected",
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
for item in validation["items"]:
|
||||
|
||||
if item["status"] != "New credential":
|
||||
continue
|
||||
|
||||
add_credential(
|
||||
conn,
|
||||
item["access_point_id"],
|
||||
item["handshake_id"],
|
||||
item["password"]
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
**validation,
|
||||
"committed": True,
|
||||
"result": "Imported",
|
||||
}
|
||||
|
||||
except Exception:
|
||||
|
||||
conn.rollback()
|
||||
|
||||
raise
|
||||
@@ -0,0 +1,903 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Server-side Hashcat report page.
|
||||
"""
|
||||
|
||||
from html import escape
|
||||
|
||||
from reports.hashcat import (
|
||||
get_database_statistics,
|
||||
get_export_statistics,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Hashcat page
|
||||
# ============================================================
|
||||
|
||||
def render_hashcat_page(
|
||||
conn,
|
||||
context=None,
|
||||
message=None,
|
||||
import_result=None,
|
||||
):
|
||||
from reports.template import (
|
||||
page_begin,
|
||||
page_end,
|
||||
)
|
||||
|
||||
if context is None:
|
||||
from reports.template import (
|
||||
ReportContext,
|
||||
)
|
||||
|
||||
context = ReportContext(
|
||||
mode="server"
|
||||
)
|
||||
|
||||
export_statistics = get_export_statistics(
|
||||
conn
|
||||
)
|
||||
|
||||
database_statistics = get_database_statistics(
|
||||
conn
|
||||
)
|
||||
|
||||
html = page_begin(
|
||||
"Hashcat",
|
||||
(
|
||||
"Export WPA handshakes for Hashcat "
|
||||
"and import passwords returned by "
|
||||
"hashcat --show."
|
||||
),
|
||||
"hashcat",
|
||||
context
|
||||
)
|
||||
|
||||
if message:
|
||||
|
||||
message_type = escape(
|
||||
str(
|
||||
message.get(
|
||||
"type",
|
||||
"info"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
message_text = escape(
|
||||
str(
|
||||
message.get(
|
||||
"text",
|
||||
""
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
html += f"""
|
||||
<section class="card">
|
||||
|
||||
<div class="info {message_type}">
|
||||
|
||||
{message_text}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
"""
|
||||
|
||||
# ========================================================
|
||||
# Export
|
||||
# ========================================================
|
||||
|
||||
html += """
|
||||
<section class="card hashcat-card">
|
||||
|
||||
<h2>
|
||||
Export
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
Export WPA handshake hashes from the database
|
||||
for Hashcat.
|
||||
</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{exported_hashes}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Hashes to export
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{wpa01}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
WPA*01
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{wpa02}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
WPA*02
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p>
|
||||
The generated file contains the original WPA*01
|
||||
and WPA*02 handshake lines stored in the database.
|
||||
</p>
|
||||
|
||||
<a
|
||||
class="button"
|
||||
draggable="false"
|
||||
href="/reports/hashcat/export"
|
||||
>
|
||||
Export all hashes
|
||||
</a>
|
||||
|
||||
</section>
|
||||
""".format(
|
||||
exported_hashes=export_statistics[
|
||||
"exported_hashes"
|
||||
],
|
||||
wpa01=export_statistics[
|
||||
"wpa01"
|
||||
],
|
||||
wpa02=export_statistics[
|
||||
"wpa02"
|
||||
],
|
||||
)
|
||||
|
||||
# ========================================================
|
||||
# Import
|
||||
# ========================================================
|
||||
|
||||
html += """
|
||||
<section class="card hashcat-card">
|
||||
|
||||
<h2>
|
||||
Import Hashcat results
|
||||
</h2>
|
||||
|
||||
<div class="info">
|
||||
|
||||
<strong>Требуемый файл:</strong>
|
||||
|
||||
текстовый результат команды
|
||||
<code>hashcat --show</code>.
|
||||
|
||||
Не загружайте напрямую файл Hashcat
|
||||
<code>.potfile</code>.
|
||||
|
||||
</div>
|
||||
|
||||
<h3>
|
||||
Как создать файл для импорта
|
||||
</h3>
|
||||
|
||||
<p>
|
||||
После завершения подбора паролей Hashcat
|
||||
выполните <code>hashcat --show</code> с теми же
|
||||
параметрами Hashcat и сохраните текстовый
|
||||
результат в файл.
|
||||
</p>
|
||||
|
||||
<pre class="hashcat-command">hashcat -m 22000 --show --potfile-path "ПУТЬ_К_ВАШЕМУ_wifi-gps-mapper.potfile" "ПУТЬ_К_ФАЙЛУ_wifi-gps-mapper-all-20260915-002541.hc22000" > wifi-gps-mapper-result.txt</pre>
|
||||
|
||||
<p>
|
||||
Имя и расширение файла не имеют значения.
|
||||
Проверяется только его содержимое.
|
||||
</p>
|
||||
|
||||
<h3>
|
||||
Ожидаемый формат
|
||||
</h3>
|
||||
|
||||
<pre class="hashcat-format">HASH:AP_BSSID:CLIENT_MAC:ESSID:PASSWORD</pre>
|
||||
|
||||
<p>
|
||||
Каждая строка проверяется по историческим
|
||||
данным рукопожатий: HASH, BSSID точки доступа,
|
||||
MAC-адрес клиента и ESSID.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Если хотя бы одна строка имеет неверный формат,
|
||||
весь импорт отклоняется. Если для результата
|
||||
не найдено соответствующее рукопожатие, такая
|
||||
строка будет пропущена, а остальные результаты
|
||||
могут быть импортированы.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Уже существующие credentials и повторяющиеся
|
||||
результаты не создают новых записей. Они будут
|
||||
показаны отдельно в статистике импорта.
|
||||
</p>
|
||||
|
||||
<form
|
||||
id="hashcat-import-form"
|
||||
method="post"
|
||||
action="/reports/hashcat/import"
|
||||
enctype="multipart/form-data"
|
||||
>
|
||||
|
||||
<div class="hashcat-file-row">
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="hashcat-file"
|
||||
name="hashcat_file"
|
||||
required
|
||||
>
|
||||
|
||||
<label
|
||||
for="hashcat-file"
|
||||
class="button"
|
||||
>
|
||||
Browse
|
||||
</label>
|
||||
|
||||
<span
|
||||
class="hashcat-file-name"
|
||||
id="hashcat-file-name"
|
||||
>
|
||||
Файл не выбран
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="button"
|
||||
id="hashcat-import-button"
|
||||
disabled
|
||||
>
|
||||
Import results
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="hashcat-preflight"
|
||||
class="hashcat-preflight"
|
||||
hidden
|
||||
>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</section>
|
||||
"""
|
||||
|
||||
html += """
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
const form = document.getElementById(
|
||||
"hashcat-import-form"
|
||||
);
|
||||
|
||||
const fileInput = document.getElementById(
|
||||
"hashcat-file"
|
||||
);
|
||||
|
||||
const fileName = document.getElementById(
|
||||
"hashcat-file-name"
|
||||
);
|
||||
|
||||
const importButton = document.getElementById(
|
||||
"hashcat-import-button"
|
||||
);
|
||||
|
||||
const preflight = document.getElementById(
|
||||
"hashcat-preflight"
|
||||
);
|
||||
|
||||
if (
|
||||
!form
|
||||
|| !fileInput
|
||||
|| !fileName
|
||||
|| !importButton
|
||||
|| !preflight
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
function escapeHtml(
|
||||
value
|
||||
) {
|
||||
const element = document.createElement(
|
||||
"div"
|
||||
);
|
||||
|
||||
element.textContent = String(
|
||||
value
|
||||
);
|
||||
|
||||
return element.innerHTML;
|
||||
}
|
||||
|
||||
function renderPreflight(
|
||||
result
|
||||
) {
|
||||
const valid = result.valid === true;
|
||||
|
||||
const message = valid
|
||||
? "Файл прошёл проверку. Импорт разрешён."
|
||||
: (
|
||||
result.error
|
||||
|| "Файл не прошёл проверку."
|
||||
);
|
||||
|
||||
preflight.innerHTML = `
|
||||
<section class="info ${valid ? "success" : "error"}">
|
||||
${escapeHtml(message)}
|
||||
</section>
|
||||
|
||||
<div class="stats-grid">
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
${result.total_lines ?? 0}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Total lines
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
${result.valid_lines ?? 0}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Valid lines
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
${result.invalid_lines ?? 0}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Invalid lines
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
${result.matched_handshakes ?? 0}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Matched handshakes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
${result.missing_handshakes ?? 0}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Missing handshakes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
${result.new_credentials ?? 0}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
New credentials
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
${result.already_existing ?? 0}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Already exists
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (
|
||||
Array.isArray(result.missing)
|
||||
&& result.missing.length > 0
|
||||
) {
|
||||
let missingHtml = `
|
||||
<h3>
|
||||
Skipped results
|
||||
</h3>
|
||||
|
||||
<div class="table-container">
|
||||
|
||||
<table>
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th>Hash</th>
|
||||
<th>AP BSSID</th>
|
||||
<th>Client MAC</th>
|
||||
<th>ESSID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
for (
|
||||
const item of result.missing
|
||||
) {
|
||||
missingHtml += `
|
||||
<tr>
|
||||
<td>
|
||||
${escapeHtml(item.line)}
|
||||
</td>
|
||||
<td>
|
||||
${escapeHtml(item.hash)}
|
||||
</td>
|
||||
<td>
|
||||
${escapeHtml(item.ap_bssid)}
|
||||
</td>
|
||||
<td>
|
||||
${escapeHtml(item.client_mac)}
|
||||
</td>
|
||||
<td>
|
||||
${escapeHtml(item.essid)}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
missingHtml += `
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
preflight.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
missingHtml
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(result.errors)
|
||||
&& result.errors.length > 0
|
||||
) {
|
||||
let errorsHtml = `
|
||||
<h3>
|
||||
Errors
|
||||
</h3>
|
||||
|
||||
<div class="table-container">
|
||||
|
||||
<table>
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
for (
|
||||
const error of result.errors
|
||||
) {
|
||||
errorsHtml += `
|
||||
<tr>
|
||||
<td>
|
||||
${escapeHtml(error.line)}
|
||||
</td>
|
||||
<td>
|
||||
${escapeHtml(error.reason)}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
errorsHtml += `
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
preflight.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
errorsHtml
|
||||
);
|
||||
}
|
||||
|
||||
preflight.hidden = false;
|
||||
const newCredentials =
|
||||
result.new_credentials ?? 0;
|
||||
|
||||
importButton.disabled =
|
||||
!valid
|
||||
|| newCredentials === 0;
|
||||
|
||||
if (valid && newCredentials > 0) {
|
||||
importButton.textContent =
|
||||
"Import "
|
||||
+ newCredentials
|
||||
+ " new credentials";
|
||||
} else if (valid) {
|
||||
importButton.textContent =
|
||||
"Nothing new to import";
|
||||
} else {
|
||||
importButton.textContent =
|
||||
"Import results";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fileInput.addEventListener(
|
||||
"change",
|
||||
async function () {
|
||||
|
||||
importButton.disabled = true;
|
||||
preflight.hidden = true;
|
||||
preflight.innerHTML = "";
|
||||
|
||||
if (
|
||||
!fileInput.files
|
||||
|| fileInput.files.length === 0
|
||||
) {
|
||||
fileName.textContent =
|
||||
"Файл не выбран";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileInput.files[0];
|
||||
|
||||
fileName.textContent =
|
||||
file.name;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append(
|
||||
"hashcat_file",
|
||||
file
|
||||
);
|
||||
|
||||
preflight.innerHTML = `
|
||||
<section class="info">
|
||||
Проверка файла...
|
||||
</section>
|
||||
`;
|
||||
|
||||
preflight.hidden = false;
|
||||
|
||||
try {
|
||||
|
||||
const response = await fetch(
|
||||
"/reports/hashcat/preflight",
|
||||
{
|
||||
method: "POST",
|
||||
body: formData
|
||||
}
|
||||
);
|
||||
|
||||
const result =
|
||||
await response.json();
|
||||
|
||||
renderPreflight(
|
||||
result
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
|
||||
renderPreflight(
|
||||
{
|
||||
valid: false,
|
||||
error:
|
||||
"Не удалось выполнить "
|
||||
+ "проверку файла."
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
# ========================================================
|
||||
# Import statistics
|
||||
# ========================================================
|
||||
|
||||
if import_result is not None:
|
||||
|
||||
html += """
|
||||
<section class="card hashcat-card">
|
||||
|
||||
<h2>
|
||||
Import statistics
|
||||
</h2>
|
||||
|
||||
<div class="stats-grid">
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{total_lines}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Total lines
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{valid_lines}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Valid lines
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{invalid_lines}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Invalid lines
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{matched_handshakes}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Matched handshakes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{missing_handshakes}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Missing handshakes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{new_credentials}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
New credentials
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{duplicate_credentials}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Duplicate credentials
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{already_existing}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Already exists
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<h3>
|
||||
Result
|
||||
</h3>
|
||||
|
||||
<p>
|
||||
<strong>
|
||||
{result}
|
||||
</strong>
|
||||
</p>
|
||||
""".format(
|
||||
total_lines=import_result[
|
||||
"total_lines"
|
||||
],
|
||||
valid_lines=import_result[
|
||||
"valid_lines"
|
||||
],
|
||||
invalid_lines=import_result[
|
||||
"invalid_lines"
|
||||
],
|
||||
matched_handshakes=import_result[
|
||||
"matched_handshakes"
|
||||
],
|
||||
missing_handshakes=import_result[
|
||||
"missing_handshakes"
|
||||
],
|
||||
new_credentials=import_result[
|
||||
"new_credentials"
|
||||
],
|
||||
duplicate_credentials=import_result[
|
||||
"duplicate_credentials"
|
||||
],
|
||||
already_existing=import_result[
|
||||
"already_existing"
|
||||
],
|
||||
result=escape(
|
||||
str(
|
||||
import_result[
|
||||
"result"
|
||||
]
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
errors = import_result[
|
||||
"errors"
|
||||
]
|
||||
|
||||
if errors:
|
||||
|
||||
html += """
|
||||
<h3>
|
||||
Errors
|
||||
</h3>
|
||||
|
||||
<div class="table-container">
|
||||
|
||||
<table>
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
"""
|
||||
|
||||
for error in errors:
|
||||
|
||||
html += """
|
||||
<tr>
|
||||
<td>
|
||||
{line}
|
||||
</td>
|
||||
<td>
|
||||
{reason}
|
||||
</td>
|
||||
</tr>
|
||||
""".format(
|
||||
line=error[
|
||||
"line"
|
||||
],
|
||||
reason=escape(
|
||||
str(
|
||||
error[
|
||||
"reason"
|
||||
]
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
html += """
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
"""
|
||||
|
||||
html += """
|
||||
</section>
|
||||
"""
|
||||
|
||||
# ========================================================
|
||||
# Database state
|
||||
# ========================================================
|
||||
|
||||
html += """
|
||||
<section class="card hashcat-card">
|
||||
|
||||
<h2>
|
||||
Database
|
||||
</h2>
|
||||
|
||||
<div class="stats-grid">
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{access_points}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Access points
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{handshakes}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Handshakes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{unique_hashes}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Unique hashes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{credentials}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Handshake Cracked
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{cracked_access_points}
|
||||
</div>
|
||||
<div class="stat-label">
|
||||
Cracked access points
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
""".format(
|
||||
access_points=database_statistics[
|
||||
"access_points"
|
||||
],
|
||||
handshakes=database_statistics[
|
||||
"handshakes"
|
||||
],
|
||||
unique_hashes=database_statistics[
|
||||
"unique_hashes"
|
||||
],
|
||||
credentials=database_statistics[
|
||||
"credentials"
|
||||
],
|
||||
cracked_access_points=database_statistics[
|
||||
"cracked_access_points"
|
||||
],
|
||||
)
|
||||
|
||||
html += page_end()
|
||||
|
||||
return html
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
WiFi GPS Mapper
|
||||
|
||||
HTML Report Generator
|
||||
|
||||
Запуск:
|
||||
|
||||
python reports/main.py
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import shutil
|
||||
|
||||
from reports.database import connect
|
||||
|
||||
from reports.config import DATABASE_FILE
|
||||
|
||||
from reports.template import (
|
||||
ReportContext,
|
||||
)
|
||||
|
||||
from reports.dashboard import (
|
||||
generate_dashboard,
|
||||
generate_capture_sessions,
|
||||
)
|
||||
|
||||
from reports.session_page import (
|
||||
build_all_session_pages,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Output directory
|
||||
# ============================================================
|
||||
|
||||
OUTPUT_DIR = (
|
||||
Path(__file__).parent
|
||||
/ "output"
|
||||
)
|
||||
|
||||
OUTPUT_DIR.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Helpers
|
||||
# ============================================================
|
||||
|
||||
def save_text(
|
||||
|
||||
filename: Path,
|
||||
|
||||
text: str,
|
||||
|
||||
):
|
||||
|
||||
filename.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
filename.write_text(
|
||||
|
||||
text,
|
||||
|
||||
encoding="utf-8",
|
||||
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Copy static resources
|
||||
# ============================================================
|
||||
|
||||
def copy_static():
|
||||
|
||||
source = (
|
||||
Path(__file__).parent
|
||||
/ "static"
|
||||
)
|
||||
|
||||
destination = (
|
||||
OUTPUT_DIR
|
||||
/ "static"
|
||||
)
|
||||
|
||||
if destination.exists():
|
||||
|
||||
shutil.rmtree(
|
||||
destination
|
||||
)
|
||||
|
||||
if source.exists():
|
||||
|
||||
shutil.copytree(
|
||||
source,
|
||||
destination
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Build report
|
||||
# ============================================================
|
||||
|
||||
def build_report():
|
||||
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
print(" WiFi GPS Mapper")
|
||||
|
||||
print(" HTML Report Generator")
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
print()
|
||||
|
||||
if not DATABASE_FILE.exists():
|
||||
|
||||
raise FileNotFoundError(
|
||||
|
||||
f"Database not found:\n{DATABASE_FILE}"
|
||||
|
||||
)
|
||||
|
||||
print("Opening database...")
|
||||
|
||||
conn = connect(DATABASE_FILE)
|
||||
|
||||
context = ReportContext(
|
||||
"static"
|
||||
)
|
||||
|
||||
print("Copying static resources...")
|
||||
|
||||
copy_static()
|
||||
|
||||
try:
|
||||
|
||||
#
|
||||
# Dashboard
|
||||
#
|
||||
|
||||
print("Generating dashboard...")
|
||||
|
||||
dashboard = generate_dashboard(
|
||||
conn,
|
||||
context,
|
||||
)
|
||||
|
||||
save_text(
|
||||
|
||||
OUTPUT_DIR / "index.html",
|
||||
|
||||
dashboard,
|
||||
|
||||
)
|
||||
|
||||
#
|
||||
# Capture Sessions
|
||||
#
|
||||
|
||||
print("Generating capture sessions page...")
|
||||
|
||||
capture_sessions = generate_capture_sessions(
|
||||
conn,
|
||||
context,
|
||||
)
|
||||
|
||||
save_text(
|
||||
|
||||
OUTPUT_DIR / "capture_sessions.html",
|
||||
|
||||
capture_sessions,
|
||||
|
||||
)
|
||||
|
||||
#
|
||||
# Sessions
|
||||
#
|
||||
|
||||
print("Generating session pages...")
|
||||
|
||||
generated = build_all_session_pages(
|
||||
conn,
|
||||
context,
|
||||
)
|
||||
|
||||
print()
|
||||
|
||||
print(
|
||||
|
||||
f"Generated {len(generated)} session pages."
|
||||
|
||||
)
|
||||
|
||||
finally:
|
||||
|
||||
conn.close()
|
||||
|
||||
print()
|
||||
|
||||
print("Done.")
|
||||
|
||||
print()
|
||||
|
||||
print(
|
||||
|
||||
"Dashboard:"
|
||||
|
||||
)
|
||||
|
||||
print(
|
||||
|
||||
OUTPUT_DIR / "index.html"
|
||||
|
||||
)
|
||||
|
||||
print()
|
||||
|
||||
print(
|
||||
|
||||
"Output directory:"
|
||||
|
||||
)
|
||||
|
||||
print(
|
||||
|
||||
OUTPUT_DIR.resolve()
|
||||
|
||||
)
|
||||
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
print()
|
||||
|
||||
# ============================================================
|
||||
# MAIN
|
||||
# ============================================================
|
||||
|
||||
def main():
|
||||
|
||||
build_report()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
main()
|
||||
|
||||
+2044
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1019
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Статистика базы данных WiFi GPS Mapper.
|
||||
|
||||
Этот модуль ничего не рисует.
|
||||
|
||||
Он только собирает информацию из SQLite и
|
||||
возвращает обычные словари Python.
|
||||
|
||||
Отображением занимается dashboard.py.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .config import (
|
||||
DATABASE_FILE,
|
||||
CAPTURES_DIR,
|
||||
)
|
||||
|
||||
from .database import (
|
||||
query_value,
|
||||
query_one,
|
||||
)
|
||||
|
||||
from .utils import (
|
||||
format_size,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Размер базы
|
||||
# ============================================================
|
||||
|
||||
def database_size():
|
||||
|
||||
if not DATABASE_FILE.exists():
|
||||
|
||||
return 0
|
||||
|
||||
return DATABASE_FILE.stat().st_size
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Размер каталога
|
||||
# ============================================================
|
||||
|
||||
def directory_size(path):
|
||||
|
||||
path = Path(path)
|
||||
|
||||
if not path.exists():
|
||||
|
||||
return 0
|
||||
|
||||
total = 0
|
||||
|
||||
for file in path.rglob("*"):
|
||||
|
||||
if file.is_file():
|
||||
|
||||
total += file.stat().st_size
|
||||
|
||||
return total
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Количество файлов
|
||||
# ============================================================
|
||||
|
||||
def count_files(path):
|
||||
|
||||
path = Path(path)
|
||||
|
||||
if not path.exists():
|
||||
|
||||
return 0
|
||||
|
||||
total = 0
|
||||
|
||||
for file in path.rglob("*"):
|
||||
|
||||
if file.is_file():
|
||||
|
||||
total += 1
|
||||
|
||||
return total
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Общая статистика
|
||||
# ============================================================
|
||||
|
||||
def database_statistics(conn):
|
||||
|
||||
statistics = {}
|
||||
|
||||
statistics["access_points"] = query_value(
|
||||
conn,
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM access_points
|
||||
""",
|
||||
default=0
|
||||
)
|
||||
|
||||
statistics["access_points_without_coordinates"] = query_value(
|
||||
conn,
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM access_points
|
||||
WHERE
|
||||
last_latitude IS NULL
|
||||
OR last_longitude IS NULL
|
||||
""",
|
||||
default=0
|
||||
)
|
||||
|
||||
statistics["vendors"] = query_value(
|
||||
conn,
|
||||
"""
|
||||
SELECT COUNT(DISTINCT vendor)
|
||||
FROM access_points
|
||||
WHERE vendor IS NOT NULL
|
||||
AND vendor!=''
|
||||
""",
|
||||
default=0
|
||||
)
|
||||
|
||||
statistics["sessions"] = query_value(
|
||||
conn,
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM capture_sessions
|
||||
""",
|
||||
default=0
|
||||
)
|
||||
|
||||
statistics["handshakes"] = query_value(
|
||||
conn,
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM handshakes
|
||||
""",
|
||||
default=0
|
||||
)
|
||||
|
||||
statistics["credentials"] = query_value(
|
||||
conn,
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM credentials
|
||||
""",
|
||||
default=0
|
||||
)
|
||||
|
||||
statistics["pmkid"] = query_value(
|
||||
conn,
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM access_points
|
||||
WHERE has_pmkid=1
|
||||
""",
|
||||
default=0
|
||||
)
|
||||
|
||||
statistics["cracked"] = query_value(
|
||||
conn,
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM access_points
|
||||
WHERE is_cracked=1
|
||||
""",
|
||||
default=0
|
||||
)
|
||||
|
||||
statistics["capture_files"] = count_files(
|
||||
CAPTURES_DIR
|
||||
)
|
||||
|
||||
statistics["database_size"] = format_size(
|
||||
database_size()
|
||||
)
|
||||
|
||||
statistics["captures_size"] = format_size(
|
||||
directory_size(
|
||||
CAPTURES_DIR
|
||||
)
|
||||
)
|
||||
|
||||
row = query_one(
|
||||
conn,
|
||||
"""
|
||||
SELECT
|
||||
|
||||
MIN(first_seen),
|
||||
|
||||
MAX(last_seen)
|
||||
|
||||
FROM access_points
|
||||
"""
|
||||
)
|
||||
|
||||
if row:
|
||||
|
||||
statistics["first_seen"] = row[0]
|
||||
|
||||
statistics["last_seen"] = row[1]
|
||||
|
||||
else:
|
||||
|
||||
statistics["first_seen"] = None
|
||||
|
||||
statistics["last_seen"] = None
|
||||
|
||||
return statistics
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Статистика производителей
|
||||
# ============================================================
|
||||
|
||||
def top_vendors(
|
||||
conn,
|
||||
limit=20
|
||||
):
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
|
||||
vendor,
|
||||
|
||||
COUNT(*) AS devices
|
||||
|
||||
FROM access_points
|
||||
|
||||
WHERE vendor IS NOT NULL
|
||||
AND vendor!=''
|
||||
|
||||
GROUP BY vendor
|
||||
|
||||
ORDER BY devices DESC
|
||||
|
||||
LIMIT ?
|
||||
"""
|
||||
|
||||
return conn.execute(
|
||||
sql,
|
||||
(
|
||||
limit,
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Используемые каналы
|
||||
# ============================================================
|
||||
|
||||
def channel_statistics(conn):
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
|
||||
channel,
|
||||
|
||||
COUNT(*) AS total
|
||||
|
||||
FROM access_points
|
||||
|
||||
GROUP BY channel
|
||||
|
||||
ORDER BY channel
|
||||
"""
|
||||
|
||||
return conn.execute(sql).fetchall()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Типы шифрования
|
||||
# ============================================================
|
||||
|
||||
def encryption_statistics(conn):
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
|
||||
encryption,
|
||||
|
||||
COUNT(*) AS total
|
||||
|
||||
FROM access_points
|
||||
|
||||
GROUP BY encryption
|
||||
|
||||
ORDER BY total DESC
|
||||
"""
|
||||
|
||||
return conn.execute(sql).fetchall()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Самые часто встречающиеся ESSID
|
||||
# ============================================================
|
||||
|
||||
def top_essid(
|
||||
conn,
|
||||
limit=25
|
||||
):
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
|
||||
essid,
|
||||
|
||||
COUNT(*) AS total
|
||||
|
||||
FROM access_points
|
||||
|
||||
WHERE essid IS NOT NULL
|
||||
AND essid!=''
|
||||
|
||||
GROUP BY essid
|
||||
|
||||
ORDER BY total DESC
|
||||
|
||||
LIMIT ?
|
||||
"""
|
||||
|
||||
return conn.execute(
|
||||
sql,
|
||||
(
|
||||
limit,
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Самые сильные сигналы
|
||||
# ============================================================
|
||||
|
||||
def strongest_access_points(
|
||||
conn,
|
||||
limit=20
|
||||
):
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
|
||||
bssid,
|
||||
|
||||
essid,
|
||||
|
||||
vendor,
|
||||
|
||||
last_rssi
|
||||
|
||||
FROM access_points
|
||||
|
||||
ORDER BY last_rssi DESC
|
||||
|
||||
LIMIT ?
|
||||
"""
|
||||
|
||||
return conn.execute(
|
||||
sql,
|
||||
(
|
||||
limit,
|
||||
)
|
||||
).fetchall()
|
||||
@@ -0,0 +1,690 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
HTML шаблон WiFi GPS Mapper Report.
|
||||
|
||||
Все страницы используют единый шаблон.
|
||||
|
||||
В дальнейшем здесь будет располагаться:
|
||||
|
||||
• CSS
|
||||
• JavaScript
|
||||
• Навигация
|
||||
• Каркас страниц
|
||||
• Footer
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from reports.utils import (
|
||||
format_datetime as format_report_datetime,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Report context
|
||||
# ============================================================
|
||||
|
||||
class ReportContext:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode="static",
|
||||
page="root",
|
||||
):
|
||||
|
||||
if mode not in (
|
||||
"static",
|
||||
"server",
|
||||
):
|
||||
raise ValueError(
|
||||
f"Unknown report mode: {mode}"
|
||||
)
|
||||
|
||||
if page not in (
|
||||
"root",
|
||||
"session",
|
||||
"trips",
|
||||
):
|
||||
raise ValueError(
|
||||
f"Unknown report page: {page}"
|
||||
)
|
||||
|
||||
self.mode = mode
|
||||
self.page = page
|
||||
|
||||
def session_page(self):
|
||||
|
||||
return ReportContext(
|
||||
mode=self.mode,
|
||||
page="session",
|
||||
)
|
||||
|
||||
def home_url(self):
|
||||
|
||||
if self.mode == "server":
|
||||
return "/"
|
||||
|
||||
if self.page == "session":
|
||||
return "../index.html"
|
||||
|
||||
return "index.html"
|
||||
|
||||
|
||||
def dashboard_url(self):
|
||||
|
||||
if self.mode == "server":
|
||||
return "/reports/"
|
||||
|
||||
if self.page == "session":
|
||||
return "../index.html"
|
||||
|
||||
return "index.html"
|
||||
|
||||
def capture_sessions_url(self):
|
||||
|
||||
if self.mode == "server":
|
||||
return "/reports/sessions/"
|
||||
|
||||
if self.page == "session":
|
||||
return "../capture_sessions.html"
|
||||
|
||||
return "capture_sessions.html"
|
||||
|
||||
def trips_url(
|
||||
self,
|
||||
):
|
||||
|
||||
if self.mode == "server":
|
||||
return "/reports/trips/"
|
||||
|
||||
return "index.html"
|
||||
|
||||
def search_url(
|
||||
self,
|
||||
):
|
||||
if self.mode == "server":
|
||||
return "/reports/search/"
|
||||
|
||||
return "index.html"
|
||||
|
||||
|
||||
def session_url(
|
||||
self,
|
||||
session_id,
|
||||
):
|
||||
|
||||
if self.mode == "server":
|
||||
return (
|
||||
f"/reports/session/{session_id}"
|
||||
)
|
||||
|
||||
return (
|
||||
f"sessions/{session_id}.html"
|
||||
)
|
||||
|
||||
def asset_url(
|
||||
self,
|
||||
path,
|
||||
):
|
||||
|
||||
path = str(path).lstrip("/")
|
||||
|
||||
if self.mode == "server":
|
||||
return (
|
||||
f"/reports/static/{path}"
|
||||
)
|
||||
|
||||
if self.page == "session":
|
||||
return (
|
||||
f"../static/{path}"
|
||||
)
|
||||
|
||||
return (
|
||||
f"static/{path}"
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# HTML Header
|
||||
# ============================================================
|
||||
|
||||
def html_header(
|
||||
context=None,
|
||||
):
|
||||
|
||||
if context is None:
|
||||
context = ReportContext()
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
|
||||
<meta charset="utf-8">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<title>WiFi GPS Mapper Report</title>
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{context.asset_url('report.css')}"
|
||||
>
|
||||
|
||||
<script>
|
||||
|
||||
function filterTable(id,value)
|
||||
{{
|
||||
let table=document.getElementById(id);
|
||||
|
||||
if(table===null)
|
||||
return;
|
||||
|
||||
value=value.toLowerCase();
|
||||
|
||||
let rows=table.getElementsByTagName("tr");
|
||||
|
||||
for(let i=1;i<rows.length;i++)
|
||||
{{
|
||||
let text=rows[i].innerText.toLowerCase();
|
||||
|
||||
rows[i].style.display=
|
||||
text.includes(value)
|
||||
?
|
||||
""
|
||||
:
|
||||
"none";
|
||||
}}
|
||||
}}
|
||||
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
"""
|
||||
|
||||
HTML_END = """
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
"""
|
||||
|
||||
# ============================================================
|
||||
# Navigation
|
||||
# ============================================================
|
||||
|
||||
def navigation(
|
||||
active="dashboard",
|
||||
context=None,
|
||||
):
|
||||
|
||||
if context is None:
|
||||
context = ReportContext()
|
||||
|
||||
menu = [
|
||||
|
||||
(
|
||||
"dashboard",
|
||||
"Dashboard",
|
||||
context.dashboard_url(),
|
||||
),
|
||||
|
||||
(
|
||||
"capture_sessions",
|
||||
"Capture Sessions",
|
||||
context.capture_sessions_url(),
|
||||
),
|
||||
|
||||
]
|
||||
|
||||
if context.mode == "server":
|
||||
|
||||
menu.append(
|
||||
(
|
||||
"trips",
|
||||
"Trips",
|
||||
context.trips_url(),
|
||||
)
|
||||
)
|
||||
|
||||
if context.mode == "server":
|
||||
|
||||
menu.extend(
|
||||
[
|
||||
(
|
||||
"access_points",
|
||||
"Access Points",
|
||||
"/reports/access-points/",
|
||||
),
|
||||
|
||||
(
|
||||
"vendors",
|
||||
"Vendors",
|
||||
"/reports/vendors/",
|
||||
),
|
||||
|
||||
(
|
||||
"hashcat",
|
||||
"Hashes",
|
||||
"/reports/hashcat/",
|
||||
),
|
||||
|
||||
]
|
||||
)
|
||||
|
||||
menu.append(
|
||||
(
|
||||
"search",
|
||||
"Search",
|
||||
context.search_url(),
|
||||
)
|
||||
)
|
||||
|
||||
html = """
|
||||
<header class="report-header">
|
||||
|
||||
<div class="report-header-inner">
|
||||
|
||||
<a
|
||||
class="report-brand"
|
||||
href="{}"
|
||||
>
|
||||
<span class="report-brand-title">
|
||||
WiFi GPS Mapper
|
||||
</span>
|
||||
|
||||
<span class="report-brand-subtitle">
|
||||
Wireless network scanner
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="report-nav">
|
||||
|
||||
""".format(
|
||||
context.home_url()
|
||||
)
|
||||
|
||||
for key, title, url in menu:
|
||||
|
||||
css = ""
|
||||
|
||||
if key == active:
|
||||
css = "active"
|
||||
|
||||
html += f"""
|
||||
<a
|
||||
class="{css}"
|
||||
href="{url}">
|
||||
{title}
|
||||
</a>
|
||||
"""
|
||||
|
||||
html += """
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
</header>
|
||||
"""
|
||||
|
||||
return html
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Page begin
|
||||
# ============================================================
|
||||
|
||||
def page_begin(
|
||||
|
||||
title,
|
||||
|
||||
description="",
|
||||
|
||||
active="dashboard",
|
||||
|
||||
context=None,
|
||||
|
||||
):
|
||||
|
||||
if context is None:
|
||||
context = ReportContext()
|
||||
|
||||
html = html_header(
|
||||
context
|
||||
)
|
||||
|
||||
html += """
|
||||
|
||||
<div class="wrapper">
|
||||
|
||||
"""
|
||||
|
||||
html += navigation(
|
||||
active,
|
||||
context,
|
||||
)
|
||||
|
||||
html += f"""
|
||||
|
||||
<div class="content">
|
||||
|
||||
<h1 class="page-title">
|
||||
|
||||
{title}
|
||||
|
||||
</h1>
|
||||
|
||||
"""
|
||||
|
||||
if description:
|
||||
|
||||
html += f"""
|
||||
|
||||
<div class="page-description">
|
||||
|
||||
{description}
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
return html
|
||||
|
||||
# ============================================================
|
||||
# Page header
|
||||
# ============================================================
|
||||
|
||||
def page_header(
|
||||
|
||||
title,
|
||||
|
||||
description="",
|
||||
|
||||
):
|
||||
|
||||
html = f"""
|
||||
|
||||
<h1 class="page-title">
|
||||
|
||||
{title}
|
||||
|
||||
</h1>
|
||||
|
||||
"""
|
||||
|
||||
if description:
|
||||
|
||||
html += f"""
|
||||
|
||||
<div class="page-description">
|
||||
|
||||
{description}
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
return html
|
||||
|
||||
# ============================================================
|
||||
# Page end
|
||||
# ============================================================
|
||||
|
||||
def page_end():
|
||||
generated = format_report_datetime(
|
||||
datetime.now(
|
||||
timezone.utc
|
||||
).isoformat()
|
||||
)
|
||||
|
||||
|
||||
|
||||
return f"""
|
||||
|
||||
<div class="footer">
|
||||
|
||||
Generated:
|
||||
|
||||
<b>
|
||||
|
||||
{generated}
|
||||
|
||||
</b>
|
||||
|
||||
<br><br>
|
||||
|
||||
WiFi GPS Mapper Report
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{HTML_END}
|
||||
|
||||
"""
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Card
|
||||
# ============================================================
|
||||
|
||||
def card(
|
||||
|
||||
title,
|
||||
|
||||
body
|
||||
|
||||
):
|
||||
|
||||
return f"""
|
||||
|
||||
<div class="card">
|
||||
|
||||
<h2>
|
||||
|
||||
{title}
|
||||
|
||||
</h2>
|
||||
|
||||
{body}
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Info
|
||||
# ============================================================
|
||||
|
||||
def info(text):
|
||||
|
||||
return f"""
|
||||
|
||||
<div class="info">
|
||||
|
||||
{text}
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Badge
|
||||
# ============================================================
|
||||
|
||||
def badge(
|
||||
|
||||
text,
|
||||
|
||||
color="gray"
|
||||
|
||||
):
|
||||
|
||||
return f"""
|
||||
|
||||
<span class="badge badge-{color}">
|
||||
|
||||
{text}
|
||||
|
||||
</span>
|
||||
|
||||
"""
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Button
|
||||
# ============================================================
|
||||
|
||||
def button(
|
||||
|
||||
title,
|
||||
|
||||
url
|
||||
|
||||
):
|
||||
|
||||
return f"""
|
||||
|
||||
<a
|
||||
|
||||
class="button"
|
||||
draggable="false"
|
||||
href="{url}">
|
||||
|
||||
{title}
|
||||
|
||||
</a>
|
||||
|
||||
"""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Statistics card
|
||||
# ============================================================
|
||||
|
||||
def stat(
|
||||
|
||||
value,
|
||||
|
||||
title
|
||||
|
||||
):
|
||||
|
||||
return f"""
|
||||
|
||||
<div class="stat-card">
|
||||
|
||||
<div class="stat-value">
|
||||
|
||||
{value}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="stat-title">
|
||||
|
||||
{title}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Statistics grid
|
||||
# ============================================================
|
||||
|
||||
def stats_grid(items):
|
||||
|
||||
html = """
|
||||
|
||||
<div class="stats-grid">
|
||||
|
||||
"""
|
||||
|
||||
|
||||
|
||||
html += "".join(items)
|
||||
|
||||
|
||||
|
||||
html += """
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
|
||||
|
||||
return html
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Search field
|
||||
# ============================================================
|
||||
|
||||
def search_box(
|
||||
|
||||
table_id,
|
||||
|
||||
placeholder="Search..."
|
||||
|
||||
):
|
||||
|
||||
return f"""
|
||||
|
||||
<input
|
||||
|
||||
class="search"
|
||||
|
||||
type="text"
|
||||
|
||||
placeholder="{placeholder}"
|
||||
|
||||
onkeyup="filterTable('{table_id}',this.value)"
|
||||
|
||||
>
|
||||
|
||||
"""
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Table container
|
||||
# ============================================================
|
||||
|
||||
def table_container(content):
|
||||
|
||||
return f"""
|
||||
|
||||
<div class="table-container">
|
||||
|
||||
{content}
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Horizontal separator
|
||||
# ============================================================
|
||||
|
||||
def hr():
|
||||
|
||||
return "<hr>"
|
||||
@@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Server-side Trips report page for .wifitrip import/export.
|
||||
"""
|
||||
|
||||
from html import escape
|
||||
|
||||
from reports.template import (
|
||||
ReportContext,
|
||||
page_begin,
|
||||
page_end,
|
||||
)
|
||||
|
||||
|
||||
def _stat_card(label, value):
|
||||
return f"""
|
||||
<div class="stat-card">
|
||||
|
||||
<div class="stat-label">
|
||||
{escape(str(label))}
|
||||
</div>
|
||||
|
||||
<div class="stat-value">
|
||||
{escape(str(value))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _message(message):
|
||||
if not message:
|
||||
return ""
|
||||
|
||||
message_type = escape(
|
||||
str(message.get("type", "info"))
|
||||
)
|
||||
|
||||
message_text = escape(
|
||||
str(message.get("text", ""))
|
||||
)
|
||||
|
||||
return f"""
|
||||
<section class="card">
|
||||
|
||||
<div class="info {message_type}">
|
||||
{message_text}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
"""
|
||||
|
||||
|
||||
def _import_result(result):
|
||||
if not result:
|
||||
return ""
|
||||
|
||||
html = """
|
||||
<section class="card">
|
||||
|
||||
<h2>Import result</h2>
|
||||
|
||||
<div class="stats-grid">
|
||||
"""
|
||||
|
||||
html += _stat_card(
|
||||
"Sessions in file",
|
||||
result.get("sessions", 0),
|
||||
)
|
||||
|
||||
html += _stat_card(
|
||||
"Imported",
|
||||
result.get("imported", 0),
|
||||
)
|
||||
|
||||
html += _stat_card(
|
||||
"Duplicates",
|
||||
result.get("duplicates", 0),
|
||||
)
|
||||
|
||||
html += _stat_card(
|
||||
"Failed",
|
||||
result.get("failed", 0),
|
||||
)
|
||||
|
||||
html += """
|
||||
</div>
|
||||
"""
|
||||
|
||||
failed_sessions = result.get(
|
||||
"failed_sessions",
|
||||
[],
|
||||
)
|
||||
|
||||
if failed_sessions:
|
||||
|
||||
html += """
|
||||
<h3>Failed sessions</h3>
|
||||
|
||||
<div class="table-wrapper">
|
||||
|
||||
<table>
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session hash</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
"""
|
||||
|
||||
for item in failed_sessions:
|
||||
|
||||
html += f"""
|
||||
<tr>
|
||||
|
||||
<td>
|
||||
<code>
|
||||
{escape(str(item.get("session_hash", "")))}
|
||||
</code>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{escape(str(item.get("error", "")))}
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
"""
|
||||
|
||||
html += """
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
"""
|
||||
|
||||
html += """
|
||||
</section>
|
||||
"""
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def _validation_result(validation):
|
||||
if not validation:
|
||||
return ""
|
||||
|
||||
if not validation.get("valid"):
|
||||
|
||||
return f"""
|
||||
<section class="card">
|
||||
|
||||
<h2>Package validation</h2>
|
||||
|
||||
<div class="info error">
|
||||
{escape(
|
||||
str(
|
||||
validation.get(
|
||||
"error",
|
||||
"Package is invalid."
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
"""
|
||||
|
||||
return f"""
|
||||
<section class="card">
|
||||
|
||||
<h2>Package validation</h2>
|
||||
|
||||
<div class="info success">
|
||||
Package is valid and contains
|
||||
{escape(str(validation.get("sessions", 0)))}
|
||||
session(s).
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
|
||||
{_stat_card(
|
||||
"Sessions",
|
||||
validation.get("sessions", 0)
|
||||
)}
|
||||
|
||||
{_stat_card(
|
||||
"Access points",
|
||||
validation.get("access_points", 0)
|
||||
)}
|
||||
|
||||
{_stat_card(
|
||||
"GPS points",
|
||||
validation.get("gps_points", 0)
|
||||
)}
|
||||
|
||||
{_stat_card(
|
||||
"Observations",
|
||||
validation.get("observations", 0)
|
||||
)}
|
||||
|
||||
{_stat_card(
|
||||
"Handshakes",
|
||||
validation.get("handshakes", 0)
|
||||
)}
|
||||
|
||||
{_stat_card(
|
||||
"Handshake Cracked",
|
||||
validation.get("credentials", 0)
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
"""
|
||||
|
||||
|
||||
def render_trips_page(
|
||||
conn,
|
||||
context=None,
|
||||
message=None,
|
||||
import_result=None,
|
||||
validation=None,
|
||||
):
|
||||
if context is None:
|
||||
|
||||
context = ReportContext(
|
||||
mode="server",
|
||||
page="trips",
|
||||
)
|
||||
|
||||
session_count = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM capture_sessions
|
||||
"""
|
||||
).fetchone()["count"]
|
||||
|
||||
html = page_begin(
|
||||
"Trips",
|
||||
(
|
||||
"Export and import capture sessions "
|
||||
"as .wifitrip packages."
|
||||
),
|
||||
"trips",
|
||||
context,
|
||||
)
|
||||
|
||||
html += _message(
|
||||
message
|
||||
)
|
||||
|
||||
html += """
|
||||
<section class="card">
|
||||
|
||||
<h2>Export capture sessions</h2>
|
||||
|
||||
<p>
|
||||
Export all capture sessions into one
|
||||
<code>.wifitrip</code> package.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The package contains logical session data:
|
||||
GPS track, access-point observations,
|
||||
handshakes and credentials. Physical capture
|
||||
files are not included.
|
||||
</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
"""
|
||||
|
||||
html += _stat_card(
|
||||
"Sessions available",
|
||||
session_count,
|
||||
)
|
||||
|
||||
html += """
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="get"
|
||||
action="/reports/trips/export"
|
||||
>
|
||||
|
||||
<button type="submit" class="button">
|
||||
Export all sessions
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
</section>
|
||||
"""
|
||||
|
||||
html += """
|
||||
<section class="card">
|
||||
|
||||
<h2>Import capture sessions</h2>
|
||||
|
||||
<p>
|
||||
Import one <code>.wifitrip</code> package.
|
||||
The complete package is validated before
|
||||
any database changes are made.
|
||||
</p>
|
||||
|
||||
<form
|
||||
method="post"
|
||||
action="/reports/trips/import"
|
||||
enctype="multipart/form-data"
|
||||
>
|
||||
|
||||
<label for="wifitrip_file">
|
||||
.wifitrip file
|
||||
</label>
|
||||
|
||||
<div class="wifitrip-file-row">
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="wifitrip_file"
|
||||
name="wifitrip_file"
|
||||
accept=".wifitrip,application/zip"
|
||||
required
|
||||
>
|
||||
|
||||
<label
|
||||
for="wifitrip_file"
|
||||
class="button"
|
||||
>
|
||||
Browse
|
||||
</label>
|
||||
|
||||
<span
|
||||
class="wifitrip-file-name"
|
||||
id="wifitrip-file-name"
|
||||
>
|
||||
Файл не выбран
|
||||
</span>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
|
||||
<button type="submit" class="button">
|
||||
Import package
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const wifitripFileInput = document.getElementById(
|
||||
"wifitrip_file"
|
||||
);
|
||||
|
||||
const wifitripFileName = document.getElementById(
|
||||
"wifitrip-file-name"
|
||||
);
|
||||
|
||||
if (wifitripFileInput && wifitripFileName) {
|
||||
wifitripFileInput.addEventListener(
|
||||
"change",
|
||||
function () {
|
||||
if (this.files.length > 0) {
|
||||
wifitripFileName.textContent =
|
||||
this.files[0].name;
|
||||
} else {
|
||||
wifitripFileName.textContent =
|
||||
"Файл не выбран";
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
</section>
|
||||
"""
|
||||
|
||||
html += _validation_result(
|
||||
validation
|
||||
)
|
||||
|
||||
html += _import_result(
|
||||
import_result
|
||||
)
|
||||
|
||||
html += page_end()
|
||||
|
||||
return html
|
||||
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Общие вспомогательные функции HTML Report.
|
||||
|
||||
В этом модуле не должно быть SQL-запросов.
|
||||
|
||||
Только:
|
||||
|
||||
• форматирование
|
||||
• HTML escaping
|
||||
• работа с файлами
|
||||
• чтение CSV
|
||||
• чтение gps_track.nmea
|
||||
• вычисления
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import html
|
||||
import os
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .config import TIMEZONE
|
||||
|
||||
|
||||
# ============================================================
|
||||
# HTML
|
||||
# ============================================================
|
||||
|
||||
|
||||
def esc(value) -> str:
|
||||
"""
|
||||
Безопасный вывод значения в HTML.
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
return html.escape(str(value))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Значение по умолчанию
|
||||
# ============================================================
|
||||
|
||||
|
||||
def safe(value, default="-"):
|
||||
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
if value == "":
|
||||
return default
|
||||
|
||||
return value
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Размер файла
|
||||
# ============================================================
|
||||
|
||||
|
||||
def format_size(size):
|
||||
|
||||
if size is None:
|
||||
return "-"
|
||||
|
||||
size = float(size)
|
||||
|
||||
units = [
|
||||
"B",
|
||||
"KB",
|
||||
"MB",
|
||||
"GB",
|
||||
"TB"
|
||||
]
|
||||
|
||||
for unit in units:
|
||||
|
||||
if size < 1024:
|
||||
|
||||
return f"{size:.1f} {unit}"
|
||||
|
||||
size /= 1024
|
||||
|
||||
return f"{size:.1f} PB"
|
||||
|
||||
|
||||
def file_size(path):
|
||||
|
||||
path = Path(path)
|
||||
|
||||
if not path.exists():
|
||||
|
||||
return "-"
|
||||
|
||||
return format_size(
|
||||
path.stat().st_size
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Дата / время
|
||||
# ============================================================
|
||||
|
||||
|
||||
def parse_datetime(text):
|
||||
if not text:
|
||||
return None
|
||||
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
|
||||
#
|
||||
# Старые записи проекта хранят UTC
|
||||
# как naive datetime.
|
||||
#
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
|
||||
return dt
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def format_datetime(
|
||||
text,
|
||||
output_format="%d.%m.%Y %H:%M:%S"
|
||||
):
|
||||
dt = parse_datetime(text)
|
||||
|
||||
if dt is None:
|
||||
return safe(text)
|
||||
|
||||
display_timezone = ZoneInfo(TIMEZONE)
|
||||
|
||||
return dt.astimezone(
|
||||
display_timezone
|
||||
).strftime(
|
||||
output_format
|
||||
)
|
||||
|
||||
|
||||
def format_duration(start, end):
|
||||
|
||||
start = parse_datetime(start)
|
||||
|
||||
end = parse_datetime(end)
|
||||
|
||||
if start is None:
|
||||
|
||||
return "-"
|
||||
|
||||
if end is None:
|
||||
|
||||
return "-"
|
||||
|
||||
delta = end - start
|
||||
|
||||
seconds = int(
|
||||
delta.total_seconds()
|
||||
)
|
||||
|
||||
hours = seconds // 3600
|
||||
|
||||
minutes = (
|
||||
seconds % 3600
|
||||
) // 60
|
||||
|
||||
seconds = seconds % 60
|
||||
|
||||
return (
|
||||
f"{hours:02}:{minutes:02}:{seconds:02}"
|
||||
)
|
||||
|
||||
|
||||
# Совместимость со старым кодом
|
||||
duration = format_duration
|
||||
|
||||
# ============================================================
|
||||
# CSV
|
||||
# ============================================================
|
||||
|
||||
|
||||
def read_csv(filename):
|
||||
|
||||
filename = Path(filename)
|
||||
|
||||
if not filename.exists():
|
||||
|
||||
return []
|
||||
|
||||
with open(
|
||||
filename,
|
||||
newline="",
|
||||
encoding="utf-8",
|
||||
errors="ignore"
|
||||
) as f:
|
||||
|
||||
reader = csv.DictReader(f)
|
||||
|
||||
return list(reader)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Текстовый файл
|
||||
# ============================================================
|
||||
|
||||
|
||||
def read_lines(filename):
|
||||
|
||||
filename = Path(filename)
|
||||
|
||||
if not filename.exists():
|
||||
|
||||
return []
|
||||
|
||||
with open(
|
||||
filename,
|
||||
encoding="utf-8",
|
||||
errors="ignore"
|
||||
) as f:
|
||||
|
||||
return [
|
||||
line.rstrip()
|
||||
for line in f
|
||||
]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# gps_track.nmea
|
||||
# ============================================================
|
||||
|
||||
|
||||
def read_nmea(filename):
|
||||
|
||||
"""
|
||||
Читает gps_track.nmea.
|
||||
|
||||
Формат строки:
|
||||
|
||||
2026-07-16T12:47:51.938726;$GPRMC,...
|
||||
|
||||
Возвращает:
|
||||
|
||||
[
|
||||
(
|
||||
datetime,
|
||||
"$GPRMC,..."
|
||||
),
|
||||
...
|
||||
]
|
||||
"""
|
||||
|
||||
result = []
|
||||
|
||||
filename = Path(filename)
|
||||
|
||||
if not filename.exists():
|
||||
|
||||
return result
|
||||
|
||||
with open(
|
||||
filename,
|
||||
encoding="utf-8",
|
||||
errors="ignore"
|
||||
) as f:
|
||||
|
||||
for line in f:
|
||||
|
||||
line = line.strip()
|
||||
|
||||
if not line:
|
||||
|
||||
continue
|
||||
|
||||
if ";" not in line:
|
||||
|
||||
continue
|
||||
|
||||
timestamp, sentence = line.split(
|
||||
";",
|
||||
1
|
||||
)
|
||||
|
||||
dt = parse_datetime(
|
||||
timestamp
|
||||
)
|
||||
|
||||
if dt is None:
|
||||
|
||||
continue
|
||||
|
||||
result.append(
|
||||
(
|
||||
dt,
|
||||
sentence
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Каталог
|
||||
# ============================================================
|
||||
|
||||
|
||||
def list_directories(path):
|
||||
|
||||
path = Path(path)
|
||||
|
||||
if not path.exists():
|
||||
|
||||
return []
|
||||
|
||||
return sorted(
|
||||
|
||||
[
|
||||
item
|
||||
|
||||
for item in path.iterdir()
|
||||
|
||||
if item.is_dir()
|
||||
]
|
||||
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Список файлов
|
||||
# ============================================================
|
||||
|
||||
|
||||
def list_files(path):
|
||||
|
||||
path = Path(path)
|
||||
|
||||
if not path.exists():
|
||||
|
||||
return []
|
||||
|
||||
return sorted(
|
||||
|
||||
[
|
||||
item
|
||||
|
||||
for item in path.iterdir()
|
||||
|
||||
if item.is_file()
|
||||
]
|
||||
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Проверка существования
|
||||
# ============================================================
|
||||
|
||||
|
||||
def exists(path):
|
||||
|
||||
return Path(path).exists()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Имя файла
|
||||
# ============================================================
|
||||
|
||||
|
||||
def filename(path):
|
||||
|
||||
return Path(path).name
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Имя каталога
|
||||
# ============================================================
|
||||
|
||||
|
||||
def dirname(path):
|
||||
|
||||
return Path(path).parent.name
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Последнее изменение
|
||||
# ============================================================
|
||||
|
||||
|
||||
def modified(path):
|
||||
path = Path(path)
|
||||
|
||||
if not path.exists():
|
||||
return "-"
|
||||
|
||||
display_timezone = ZoneInfo(TIMEZONE)
|
||||
|
||||
return datetime.fromtimestamp(
|
||||
path.stat().st_mtime,
|
||||
timezone.utc,
|
||||
).astimezone(
|
||||
display_timezone
|
||||
).strftime(
|
||||
"%d.%m.%Y %H:%M:%S"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Количество строк файла
|
||||
# ============================================================
|
||||
|
||||
|
||||
def line_count(filename):
|
||||
|
||||
filename = Path(filename)
|
||||
|
||||
if not filename.exists():
|
||||
|
||||
return 0
|
||||
|
||||
with open(
|
||||
filename,
|
||||
encoding="utf-8",
|
||||
errors="ignore"
|
||||
) as f:
|
||||
|
||||
return sum(
|
||||
1
|
||||
for _ in f
|
||||
)
|
||||
@@ -0,0 +1,900 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Vendors report page.
|
||||
|
||||
Страница управления базой OUI.
|
||||
|
||||
Бизнес-логика находится в VendorService.
|
||||
Этот модуль отвечает только за HTML-представление.
|
||||
"""
|
||||
|
||||
from html import escape
|
||||
|
||||
from .statistics import (
|
||||
top_vendors,
|
||||
)
|
||||
|
||||
from .utils import (
|
||||
format_datetime as format_report_datetime,
|
||||
)
|
||||
|
||||
from .template import (
|
||||
page_begin,
|
||||
page_end,
|
||||
card,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Helpers
|
||||
# ============================================================
|
||||
|
||||
def format_size(size):
|
||||
if not size:
|
||||
return "-"
|
||||
|
||||
size = float(size)
|
||||
|
||||
units = (
|
||||
"B",
|
||||
"KB",
|
||||
"MB",
|
||||
"GB",
|
||||
)
|
||||
|
||||
for unit in units:
|
||||
|
||||
if size < 1024:
|
||||
return f"{size:.1f} {unit}"
|
||||
|
||||
size /= 1024
|
||||
|
||||
return f"{size:.1f} TB"
|
||||
|
||||
|
||||
def format_value(value):
|
||||
if value is None or value == "":
|
||||
return "-"
|
||||
|
||||
return escape(
|
||||
str(value)
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Database status
|
||||
# ============================================================
|
||||
|
||||
def database_status_block(status):
|
||||
if not status["exists"]:
|
||||
health = "Not found"
|
||||
|
||||
elif status["healthy"]:
|
||||
health = "Healthy"
|
||||
|
||||
else:
|
||||
health = "Error"
|
||||
|
||||
html = f"""
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
<th>Property</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Database</td>
|
||||
<td>{format_value(status["database"])}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td>{health}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Total entries</td>
|
||||
<td>{status["total"]}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>MA-L</td>
|
||||
<td>{status["ma_l"]}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>MA-M</td>
|
||||
<td>{status["ma_m"]}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>MA-S</td>
|
||||
<td>{status["ma_s"]}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Schema version</td>
|
||||
<td>{format_value(status["schema_version"])}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Last import</td>
|
||||
<td>{format_report_datetime(status["last_import"])}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Import status</td>
|
||||
<td>{format_value(status["import_status"])}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Size</td>
|
||||
<td>{format_size(status["size"])}</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
"""
|
||||
|
||||
return card(
|
||||
"🏭 OUI Database",
|
||||
html,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Top Vendors
|
||||
# ============================================================
|
||||
|
||||
def top_vendors_block(conn):
|
||||
rows = top_vendors(
|
||||
conn,
|
||||
limit=15
|
||||
)
|
||||
|
||||
html = """
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
<th>Vendor</th>
|
||||
<th>Devices</th>
|
||||
</tr>
|
||||
|
||||
"""
|
||||
|
||||
if not rows:
|
||||
|
||||
html += """
|
||||
<tr>
|
||||
<td colspan="2">No vendor data.</td>
|
||||
</tr>
|
||||
|
||||
"""
|
||||
|
||||
else:
|
||||
|
||||
for vendor, total in rows:
|
||||
|
||||
if not vendor:
|
||||
vendor = "-"
|
||||
|
||||
html += f"""
|
||||
<tr>
|
||||
<td>{escape(str(vendor))}</td>
|
||||
<td>{total}</td>
|
||||
</tr>
|
||||
|
||||
"""
|
||||
|
||||
html += """
|
||||
</table>
|
||||
"""
|
||||
|
||||
return card(
|
||||
"🏭 Top Vendors",
|
||||
html,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Vendors page
|
||||
# ============================================================
|
||||
|
||||
def update_block(
|
||||
service,
|
||||
preview=None,
|
||||
token=None,
|
||||
job=None
|
||||
):
|
||||
html = """
|
||||
"""
|
||||
|
||||
sources = service.sources
|
||||
mirrors = service.mirrors
|
||||
|
||||
source_html = """
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>URL</th>
|
||||
<th>Mirror</th>
|
||||
</tr>
|
||||
|
||||
"""
|
||||
|
||||
for name, url in sources.items():
|
||||
|
||||
mirror = mirrors.get(
|
||||
name
|
||||
)
|
||||
|
||||
source_html += f"""
|
||||
<tr>
|
||||
<td>{escape(str(name))}</td>
|
||||
<td>{escape(str(url))}</td>
|
||||
<td>{escape(str(mirror)) if mirror else "—"}</td>
|
||||
</tr>
|
||||
|
||||
"""
|
||||
|
||||
source_html += """
|
||||
</table>
|
||||
|
||||
"""
|
||||
|
||||
html += """
|
||||
<div class="report-grid">
|
||||
|
||||
"""
|
||||
|
||||
html += card(
|
||||
"🌐 Configured Sources",
|
||||
source_html,
|
||||
)
|
||||
|
||||
if preview is None:
|
||||
|
||||
if job is not None:
|
||||
|
||||
stage_labels = {
|
||||
"starting":
|
||||
"Запуск обновления",
|
||||
"downloading":
|
||||
"Загрузка источника",
|
||||
"validating":
|
||||
"Проверка источника",
|
||||
"fallback":
|
||||
"Переход на резервное зеркало",
|
||||
"building":
|
||||
"Построение базы OUI",
|
||||
"analyzing":
|
||||
"Анализ изменений",
|
||||
"ready":
|
||||
"Обновление готово",
|
||||
"error":
|
||||
"Ошибка обновления"
|
||||
}
|
||||
|
||||
stage = job.get(
|
||||
"stage"
|
||||
)
|
||||
|
||||
stage_label = stage_labels.get(
|
||||
stage,
|
||||
stage or "Неизвестный этап"
|
||||
)
|
||||
|
||||
source = job.get(
|
||||
"source"
|
||||
)
|
||||
|
||||
message = job.get(
|
||||
"message"
|
||||
)
|
||||
|
||||
error = job.get(
|
||||
"error"
|
||||
)
|
||||
|
||||
html += f"""
|
||||
<div class="card">
|
||||
|
||||
<h2>Обновление базы OUI</h2>
|
||||
|
||||
<p>
|
||||
<strong>Этап:</strong>
|
||||
<span id="vendor-update-stage">
|
||||
{escape(stage_label)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Источник:</strong>
|
||||
<span id="vendor-update-source">
|
||||
{escape(str(source)) if source else "—"}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span id="vendor-update-message">
|
||||
{escape(str(message)) if message else ""}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p id="vendor-update-error">
|
||||
{escape(str(error)) if error else ""}
|
||||
</p>
|
||||
|
||||
<script>
|
||||
(function(){{
|
||||
|
||||
const jobId = {job["id"]!r};
|
||||
|
||||
const stageLabels = {{
|
||||
"starting": "Запуск обновления",
|
||||
"downloading": "Загрузка источника",
|
||||
"validating": "Проверка источника",
|
||||
"fallback": "Переход на резервное зеркало",
|
||||
"building": "Построение базы OUI",
|
||||
"analyzing": "Анализ изменений",
|
||||
"ready": "Обновление готово",
|
||||
"error": "Ошибка обновления"
|
||||
}};
|
||||
|
||||
async function pollVendorUpdate(){{
|
||||
|
||||
try {{
|
||||
|
||||
const response = await fetch(
|
||||
"/reports/vendors/update/status/"
|
||||
+ encodeURIComponent(jobId),
|
||||
{{
|
||||
cache: "no-store"
|
||||
}}
|
||||
);
|
||||
|
||||
if (!response.ok) {{
|
||||
return;
|
||||
}}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const stage =
|
||||
document.getElementById(
|
||||
"vendor-update-stage"
|
||||
);
|
||||
|
||||
const source =
|
||||
document.getElementById(
|
||||
"vendor-update-source"
|
||||
);
|
||||
|
||||
const message =
|
||||
document.getElementById(
|
||||
"vendor-update-message"
|
||||
);
|
||||
|
||||
const error =
|
||||
document.getElementById(
|
||||
"vendor-update-error"
|
||||
);
|
||||
|
||||
if (stage) {{
|
||||
stage.textContent =
|
||||
stageLabels[data.stage]
|
||||
|| data.stage
|
||||
|| "Неизвестный этап";
|
||||
}}
|
||||
|
||||
if (source) {{
|
||||
source.textContent =
|
||||
data.source || "—";
|
||||
}}
|
||||
|
||||
if (message) {{
|
||||
message.textContent =
|
||||
data.message || "";
|
||||
}}
|
||||
|
||||
if (error) {{
|
||||
error.textContent =
|
||||
data.error || "";
|
||||
}}
|
||||
|
||||
if (
|
||||
data.status === "ready"
|
||||
&& data.preview_ready
|
||||
) {{
|
||||
|
||||
window.location.href =
|
||||
"/reports/vendors/?job_id="
|
||||
+ encodeURIComponent(jobId);
|
||||
|
||||
return;
|
||||
}}
|
||||
|
||||
if (
|
||||
data.status === "error"
|
||||
|| data.status === "missing"
|
||||
) {{
|
||||
return;
|
||||
}}
|
||||
|
||||
}} catch (error) {{
|
||||
|
||||
console.error(
|
||||
"Vendor update polling failed:",
|
||||
error
|
||||
);
|
||||
}}
|
||||
|
||||
window.setTimeout(
|
||||
pollVendorUpdate,
|
||||
1000
|
||||
);
|
||||
}}
|
||||
|
||||
pollVendorUpdate();
|
||||
|
||||
}})();
|
||||
</script>
|
||||
|
||||
"""
|
||||
|
||||
if job.get(
|
||||
"status"
|
||||
) == "running":
|
||||
|
||||
html += """
|
||||
<p>
|
||||
Обновление выполняется. Страница автоматически
|
||||
обновит результат после завершения.
|
||||
</p>
|
||||
"""
|
||||
|
||||
elif job.get(
|
||||
"status"
|
||||
) == "error":
|
||||
|
||||
html += """
|
||||
<form
|
||||
method="post"
|
||||
action="/reports/vendors/update"
|
||||
>
|
||||
|
||||
<div class="vendor-actions">
|
||||
|
||||
<button type="submit" class="button">
|
||||
Повторить обновление
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
"""
|
||||
|
||||
html += """
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
else:
|
||||
|
||||
html += """
|
||||
<div class="card">
|
||||
|
||||
<h2>Обновление базы OUI</h2>
|
||||
|
||||
<form
|
||||
method="post"
|
||||
action="/reports/vendors/update"
|
||||
>
|
||||
|
||||
<div class="vendor-actions">
|
||||
|
||||
<button type="submit" class="button">
|
||||
Проверить обновления
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
html += """
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
return html
|
||||
|
||||
oui = preview["oui"]
|
||||
build = preview["build"]
|
||||
ap = preview["ap"]
|
||||
|
||||
ap_total = (
|
||||
ap["unknown_to_vendor"]
|
||||
+ ap["vendor_to_vendor"]
|
||||
+ ap["vendor_to_unknown"]
|
||||
)
|
||||
|
||||
if oui["identical"]:
|
||||
|
||||
status_html = """
|
||||
<p>
|
||||
База OUI не изменилась. Обновление не требуется.
|
||||
</p>
|
||||
"""
|
||||
|
||||
elif ap_total == 0:
|
||||
|
||||
status_html = """
|
||||
<p>
|
||||
База OUI содержит изменения, но существующие записи
|
||||
AP не требуют изменения vendor.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Обновление базы OUI можно применить отдельно.
|
||||
Оно обновит справочник для будущих определений vendor.
|
||||
</p>
|
||||
"""
|
||||
|
||||
else:
|
||||
|
||||
status_html = """
|
||||
<p>
|
||||
База OUI содержит изменения.
|
||||
Сначала проверьте изменения в справочнике, затем при
|
||||
необходимости выберите синхронизацию vendor у существующих AP.
|
||||
</p>
|
||||
"""
|
||||
|
||||
change_html = f"""
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
<th>Параметр</th>
|
||||
<th>Значение</th>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Текущих записей</td>
|
||||
<td>{oui["current"]}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Записей в новой базе</td>
|
||||
<td>{oui["candidate"]}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Добавлено</td>
|
||||
<td>{oui["added"]}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Изменено</td>
|
||||
<td>{oui["changed"]}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Удалено</td>
|
||||
<td>{oui["removed"]}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Без изменений</td>
|
||||
<td>{oui["unchanged"]}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Импортировано</td>
|
||||
<td>{build["imported"]}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Пропущено</td>
|
||||
<td>{build["skipped"]}</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
"""
|
||||
|
||||
html += card(
|
||||
"🔄 Предварительный просмотр обновления",
|
||||
status_html + change_html,
|
||||
)
|
||||
|
||||
html += """
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
if oui["identical"]:
|
||||
|
||||
pass
|
||||
|
||||
else:
|
||||
|
||||
html += f"""
|
||||
<form
|
||||
method="post"
|
||||
action="/reports/vendors/apply"
|
||||
onsubmit="return confirm('Применить обновление базы OUI?');"
|
||||
>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="token"
|
||||
value="{escape(token)}"
|
||||
>
|
||||
|
||||
"""
|
||||
|
||||
if ap_total > 0:
|
||||
|
||||
html += """
|
||||
<div class="vendor-sync">
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>
|
||||
Синхронизация существующих AP
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
Следующие параметры не изменяют саму базу OUI.
|
||||
Они определяют, какие изменения vendor применить
|
||||
к уже существующим записям WiFi AP.
|
||||
</p>
|
||||
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
<th>Изменение vendor</th>
|
||||
<th>Количество</th>
|
||||
<th>Применить</th>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Unknown → Vendor</strong>
|
||||
|
||||
<details>
|
||||
<summary>Что это означает?</summary>
|
||||
<p>
|
||||
Заполнить поле vendor у существующих AP, для которых
|
||||
vendor сейчас неизвестен. Новое значение определяется
|
||||
по обновлённой базе OUI.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
</td>
|
||||
<td>""" + str(ap["unknown_to_vendor"]) + """</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="unknown_to_vendor"
|
||||
value="1"
|
||||
checked
|
||||
onchange="updateVendorApplyCount()"
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Vendor → другой Vendor</strong>
|
||||
|
||||
<details>
|
||||
<summary>Что это означает?</summary>
|
||||
<p>
|
||||
Заменить текущее значение vendor, если обновлённая
|
||||
база OUI определяет тот же AP как принадлежащий
|
||||
другому vendor.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
</td>
|
||||
<td>""" + str(ap["vendor_to_vendor"]) + """</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="vendor_to_vendor"
|
||||
value="1"
|
||||
onchange="updateVendorApplyCount()"
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Vendor → Unknown</strong>
|
||||
|
||||
<details>
|
||||
<summary>Что это означает?</summary>
|
||||
<p>
|
||||
Удалить текущее значение vendor, если обновлённая
|
||||
база OUI больше не позволяет определить vendor
|
||||
для данного AP.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
</td>
|
||||
<td>""" + str(ap["vendor_to_unknown"]) + """</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="vendor_to_unknown"
|
||||
value="1"
|
||||
onchange="updateVendorApplyCount()"
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<p>
|
||||
Выбрано изменений AP:
|
||||
<strong id="vendor-apply-count">""" + str(
|
||||
ap["unknown_to_vendor"]
|
||||
) + """</strong>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Синхронизация AP необязательна. Снятые категории
|
||||
не будут изменять существующие записи AP.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
else:
|
||||
|
||||
html += """
|
||||
<div class="vendor-sync vendor-sync-empty">
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>
|
||||
Синхронизация существующих AP
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
Существующие записи AP не требуют изменения vendor.
|
||||
Для этого обновления синхронизация AP недоступна.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
html += """
|
||||
<div class="vendor-actions">
|
||||
|
||||
<button type="submit" class="button">
|
||||
Применить обновление базы OUI
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
"""
|
||||
|
||||
if ap_total > 0:
|
||||
|
||||
html += f"""
|
||||
<script>
|
||||
function updateVendorApplyCount() {{
|
||||
|
||||
let total = 0;
|
||||
|
||||
const fields = [
|
||||
[
|
||||
"unknown_to_vendor",
|
||||
{ap["unknown_to_vendor"]}
|
||||
],
|
||||
[
|
||||
"vendor_to_vendor",
|
||||
{ap["vendor_to_vendor"]}
|
||||
],
|
||||
[
|
||||
"vendor_to_unknown",
|
||||
{ap["vendor_to_unknown"]}
|
||||
]
|
||||
];
|
||||
|
||||
for (const field of fields) {{
|
||||
|
||||
const checkbox = document.querySelector(
|
||||
'input[name="' + field[0] + '"]'
|
||||
);
|
||||
|
||||
if (checkbox && checkbox.checked) {{
|
||||
total += field[1];
|
||||
}}
|
||||
}}
|
||||
|
||||
document.getElementById(
|
||||
"vendor-apply-count"
|
||||
).textContent = total;
|
||||
}}
|
||||
</script>
|
||||
|
||||
"""
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def render_vendors_page(
|
||||
conn,
|
||||
service,
|
||||
preview=None,
|
||||
token=None,
|
||||
message=None,
|
||||
context=None,
|
||||
job=None
|
||||
):
|
||||
status = service.status()
|
||||
|
||||
html = page_begin(
|
||||
title="Vendors",
|
||||
description=(
|
||||
"Vendor database and OUI management"
|
||||
),
|
||||
active="vendors",
|
||||
context=context,
|
||||
)
|
||||
|
||||
if message:
|
||||
|
||||
html += f"""
|
||||
<div class="card">
|
||||
|
||||
<p>
|
||||
{escape(message["text"])}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
html += """
|
||||
<div class="report-grid">
|
||||
|
||||
"""
|
||||
|
||||
html += database_status_block(
|
||||
status
|
||||
)
|
||||
|
||||
html += top_vendors_block(
|
||||
conn
|
||||
)
|
||||
|
||||
html += """
|
||||
</div>
|
||||
|
||||
"""
|
||||
|
||||
html += update_block(
|
||||
service,
|
||||
preview,
|
||||
token,
|
||||
job
|
||||
)
|
||||
|
||||
html += page_end()
|
||||
|
||||
return html
|
||||
+2111
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user