Update public source

This commit is contained in:
2026-10-06 21:08:12 +03:00
commit f231cc5b1f
102 changed files with 48249 additions and 0 deletions
+1
View File
@@ -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
+93
View File
@@ -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
)
+856
View File
@@ -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 = "&lt;hidden&gt;"
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
+244
View File
@@ -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()
+834
View File
@@ -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
+903
View File
@@ -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" &gt; 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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1019
View File
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
+370
View File
@@ -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()
+690
View File
@@ -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>"
+395
View File
@@ -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
+440
View File
@@ -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
)
+900
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff