Update public source
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
python -m client.main %*
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo CrackLab client failed.
|
||||
pause
|
||||
)
|
||||
|
||||
endlocal
|
||||
@@ -0,0 +1 @@
|
||||
"""WiFi GPS Mapper CrackLab Windows client."""
|
||||
@@ -0,0 +1,793 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import ClientConfig, load_config
|
||||
from .hashcat import (
|
||||
HashcatError,
|
||||
run_show,
|
||||
run_step,
|
||||
)
|
||||
from .job_package import (
|
||||
ImportedJob,
|
||||
JobPackageError,
|
||||
JobPackageService,
|
||||
)
|
||||
from .client_logging import (
|
||||
configure_job_logging,
|
||||
configure_logging,
|
||||
)
|
||||
from .paths import ClientPaths
|
||||
from .result_package import (
|
||||
ResultPackageError,
|
||||
create_result_package,
|
||||
)
|
||||
from .state import (
|
||||
JOB_STATUS_COMPLETED,
|
||||
JOB_STATUS_FAILED,
|
||||
JOB_STATUS_PARTIAL,
|
||||
JOB_STATUS_RUNNING,
|
||||
STEP_STATUS_COMPLETED,
|
||||
STEP_STATUS_FAILED,
|
||||
STEP_STATUS_PARTIAL,
|
||||
STEP_STATUS_RUNNING,
|
||||
StateError,
|
||||
get_step_state,
|
||||
initialize_state,
|
||||
load_state,
|
||||
update_job_state,
|
||||
update_step_state,
|
||||
)
|
||||
|
||||
|
||||
class ClientError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class CrackLabClient:
|
||||
def __init__(
|
||||
self,
|
||||
script_dir: Path,
|
||||
) -> None:
|
||||
self.script_dir = script_dir.resolve()
|
||||
|
||||
self.config: ClientConfig = load_config(
|
||||
self.script_dir
|
||||
)
|
||||
|
||||
self.config.potfile_path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
self.paths = ClientPaths.from_root(
|
||||
self.config.client_root
|
||||
)
|
||||
self.paths.initialize()
|
||||
|
||||
self.logger = configure_logging(
|
||||
self.paths.logs / "client.log"
|
||||
)
|
||||
|
||||
self.package_service = JobPackageService(
|
||||
self.paths
|
||||
)
|
||||
|
||||
self.logger.info(
|
||||
"CrackLab client initialized"
|
||||
)
|
||||
self.logger.info(
|
||||
"Client root: %s",
|
||||
self.paths.root,
|
||||
)
|
||||
self.logger.info(
|
||||
"Hashcat: %s",
|
||||
self.config.hashcat_exe,
|
||||
)
|
||||
self.logger.info(
|
||||
"Potfile: %s",
|
||||
self.config.potfile_path,
|
||||
)
|
||||
self.logger.info(
|
||||
"poll_seconds=%s max_parallel_jobs=%s",
|
||||
self.config.poll_seconds,
|
||||
self.config.max_parallel_jobs,
|
||||
)
|
||||
|
||||
def import_jobs(self) -> list[ImportedJob]:
|
||||
imported: list[ImportedJob] = []
|
||||
|
||||
packages = sorted(
|
||||
self.paths.inbox.glob(
|
||||
"JOB-*.zip"
|
||||
)
|
||||
)
|
||||
|
||||
if not packages:
|
||||
self.logger.info(
|
||||
"Inbox is empty."
|
||||
)
|
||||
return imported
|
||||
|
||||
for package in packages:
|
||||
try:
|
||||
self.logger.info(
|
||||
"Importing Job package: %s",
|
||||
package.name,
|
||||
)
|
||||
|
||||
item = (
|
||||
self.package_service
|
||||
.import_package(package)
|
||||
)
|
||||
|
||||
imported.append(item)
|
||||
|
||||
if item.idempotent:
|
||||
self.logger.info(
|
||||
"Job package already imported: "
|
||||
"%s",
|
||||
item.job_id,
|
||||
)
|
||||
else:
|
||||
self.logger.info(
|
||||
"Job imported: %s",
|
||||
item.job_id,
|
||||
)
|
||||
|
||||
except JobPackageError as exc:
|
||||
self.logger.error(
|
||||
"Job package rejected: %s: %s",
|
||||
package.name,
|
||||
exc,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
self.logger.exception(
|
||||
"Unexpected error importing %s",
|
||||
package.name,
|
||||
)
|
||||
|
||||
return imported
|
||||
|
||||
def _load_job(
|
||||
self,
|
||||
job_dir: Path,
|
||||
) -> dict[str, Any]:
|
||||
path = job_dir / "job.json"
|
||||
|
||||
try:
|
||||
data = json.loads(
|
||||
path.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ClientError(
|
||||
f"Failed to read {path}: {exc}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ClientError(
|
||||
f"Invalid Job JSON: {path}"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def _ensure_state(
|
||||
self,
|
||||
job_dir: Path,
|
||||
job: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
path = job_dir / "state" / "client_state.json"
|
||||
|
||||
if not path.is_file():
|
||||
self.logger.info(
|
||||
"%s: creating client state",
|
||||
job["job_id"],
|
||||
)
|
||||
return initialize_state(
|
||||
job_dir,
|
||||
job,
|
||||
)
|
||||
|
||||
try:
|
||||
state = load_state(
|
||||
job_dir
|
||||
)
|
||||
except StateError:
|
||||
self.logger.warning(
|
||||
"%s: invalid state; rebuilding state",
|
||||
job["job_id"],
|
||||
)
|
||||
return initialize_state(
|
||||
job_dir,
|
||||
job,
|
||||
)
|
||||
|
||||
if state.get("job_id") != job["job_id"]:
|
||||
raise ClientError(
|
||||
"State job_id does not match job.json."
|
||||
)
|
||||
|
||||
expected_steps = {
|
||||
str(step["step_no"])
|
||||
for step in job["steps"]
|
||||
}
|
||||
|
||||
actual_steps = state.get(
|
||||
"steps",
|
||||
{},
|
||||
)
|
||||
|
||||
if not isinstance(actual_steps, dict):
|
||||
self.logger.warning(
|
||||
"%s: state has invalid steps; rebuilding",
|
||||
job["job_id"],
|
||||
)
|
||||
return initialize_state(
|
||||
job_dir,
|
||||
job,
|
||||
)
|
||||
|
||||
if set(actual_steps) != expected_steps:
|
||||
self.logger.warning(
|
||||
"%s: state steps do not match "
|
||||
"job.json; rebuilding",
|
||||
job["job_id"],
|
||||
)
|
||||
return initialize_state(
|
||||
job_dir,
|
||||
job,
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
def _step_result(
|
||||
self,
|
||||
job_dir: Path,
|
||||
step: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
state = get_step_state(
|
||||
job_dir,
|
||||
step["step_no"],
|
||||
)
|
||||
|
||||
return {
|
||||
"step_no": step["step_no"],
|
||||
"step_id": step["step_id"],
|
||||
"session_name": step["session_name"],
|
||||
"status": state["status"],
|
||||
"started_at": state.get(
|
||||
"started_at"
|
||||
),
|
||||
"completed_at": state.get(
|
||||
"completed_at"
|
||||
),
|
||||
"exit_code": state.get(
|
||||
"exit_code"
|
||||
),
|
||||
"restore_seen": bool(
|
||||
state.get("restore_seen", False)
|
||||
),
|
||||
"error": state.get("error"),
|
||||
}
|
||||
|
||||
def _run_step(
|
||||
self,
|
||||
job_dir: Path,
|
||||
job: dict[str, Any],
|
||||
step: dict[str, Any],
|
||||
job_logger: logging.Logger,
|
||||
) -> bool:
|
||||
step_no = step["step_no"]
|
||||
|
||||
state = get_step_state(
|
||||
job_dir,
|
||||
step_no,
|
||||
)
|
||||
|
||||
if (
|
||||
state.get("status")
|
||||
== STEP_STATUS_COMPLETED
|
||||
):
|
||||
job_logger.info(
|
||||
"Step %s already COMPLETED; skipping.",
|
||||
step_no,
|
||||
)
|
||||
return True
|
||||
|
||||
restore_file = (
|
||||
job_dir
|
||||
/ "state"
|
||||
/ f"step-{step_no:03d}.restore"
|
||||
)
|
||||
|
||||
restore_seen = restore_file.is_file()
|
||||
|
||||
mode = (
|
||||
"RESTORE"
|
||||
if restore_seen
|
||||
else "START"
|
||||
)
|
||||
|
||||
job_logger.info(
|
||||
"Step %s (%s) starting: %s "
|
||||
"session=%s",
|
||||
step_no,
|
||||
step["step_id"],
|
||||
mode,
|
||||
step["session_name"],
|
||||
)
|
||||
|
||||
if restore_seen:
|
||||
job_logger.warning(
|
||||
"Step %s has restore file; "
|
||||
"resuming existing Hashcat session.",
|
||||
step_no,
|
||||
)
|
||||
|
||||
update_step_state(
|
||||
job_dir,
|
||||
step_no,
|
||||
STEP_STATUS_RUNNING,
|
||||
restore_seen=restore_seen,
|
||||
started=True,
|
||||
)
|
||||
|
||||
log_path = (
|
||||
job_dir
|
||||
/ "logs"
|
||||
/ f"step-{step_no:03d}.log"
|
||||
)
|
||||
|
||||
try:
|
||||
result = run_step(
|
||||
self.config,
|
||||
job_dir,
|
||||
job,
|
||||
step,
|
||||
log_path,
|
||||
restore=restore_seen,
|
||||
)
|
||||
|
||||
except HashcatError as exc:
|
||||
current_restore = (
|
||||
restore_file.is_file()
|
||||
)
|
||||
|
||||
update_step_state(
|
||||
job_dir,
|
||||
step_no,
|
||||
(
|
||||
STEP_STATUS_PARTIAL
|
||||
if current_restore
|
||||
else STEP_STATUS_FAILED
|
||||
),
|
||||
exit_code=exc.exit_code,
|
||||
restore_seen=current_restore,
|
||||
error=str(exc),
|
||||
completed=True,
|
||||
)
|
||||
|
||||
if current_restore:
|
||||
job_logger.warning(
|
||||
"Step %s interrupted/partial: %s",
|
||||
step_no,
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
job_logger.error(
|
||||
"Step %s failed: %s",
|
||||
step_no,
|
||||
exc,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as exc:
|
||||
update_step_state(
|
||||
job_dir,
|
||||
step_no,
|
||||
STEP_STATUS_FAILED,
|
||||
restore_seen=restore_file.is_file(),
|
||||
error=str(exc),
|
||||
completed=True,
|
||||
)
|
||||
|
||||
job_logger.exception(
|
||||
"Unexpected Step %s error.",
|
||||
step_no,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
if result.restore_seen:
|
||||
update_step_state(
|
||||
job_dir,
|
||||
step_no,
|
||||
STEP_STATUS_PARTIAL,
|
||||
exit_code=result.exit_code,
|
||||
restore_seen=True,
|
||||
error=(
|
||||
"Hashcat finished while "
|
||||
"restore file still exists."
|
||||
),
|
||||
completed=True,
|
||||
)
|
||||
|
||||
job_logger.warning(
|
||||
"Step %s remains PARTIAL: "
|
||||
"restore file exists after Hashcat "
|
||||
"exit=%s.",
|
||||
step_no,
|
||||
result.exit_code,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
if result.exit_code not in (0, 1):
|
||||
update_step_state(
|
||||
job_dir,
|
||||
step_no,
|
||||
STEP_STATUS_FAILED,
|
||||
exit_code=result.exit_code,
|
||||
restore_seen=False,
|
||||
error=(
|
||||
f"Hashcat exited with "
|
||||
f"code {result.exit_code}."
|
||||
),
|
||||
completed=True,
|
||||
)
|
||||
|
||||
job_logger.error(
|
||||
"Step %s failed: Hashcat exit=%s.",
|
||||
step_no,
|
||||
result.exit_code,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
marker = (
|
||||
job_dir
|
||||
/ "state"
|
||||
/ f"step-{step_no:03d}.completed"
|
||||
)
|
||||
|
||||
marker.write_text(
|
||||
"COMPLETED\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
update_step_state(
|
||||
job_dir,
|
||||
step_no,
|
||||
STEP_STATUS_COMPLETED,
|
||||
exit_code=result.exit_code,
|
||||
restore_seen=False,
|
||||
error=None,
|
||||
completed=True,
|
||||
)
|
||||
|
||||
job_logger.info(
|
||||
"Step %s COMPLETED.",
|
||||
step_no,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def run_job(
|
||||
self,
|
||||
job_dir: Path,
|
||||
) -> bool:
|
||||
job = self._load_job(
|
||||
job_dir
|
||||
)
|
||||
|
||||
job_id = job["job_id"]
|
||||
|
||||
job_logger = configure_job_logging(
|
||||
job_dir / "logs" / "client.log",
|
||||
job_id,
|
||||
)
|
||||
|
||||
job_logger.info(
|
||||
"Job processing started."
|
||||
)
|
||||
job_logger.info(
|
||||
"method=%s version=%s hashes=%s "
|
||||
"handshakes=%s steps=%s",
|
||||
job["method_id"],
|
||||
job["method_version"],
|
||||
job["hash_count"],
|
||||
job["handshake_count"],
|
||||
job["step_count"],
|
||||
)
|
||||
|
||||
try:
|
||||
state = self._ensure_state(
|
||||
job_dir,
|
||||
job,
|
||||
)
|
||||
|
||||
if (
|
||||
state.get("status")
|
||||
== JOB_STATUS_COMPLETED
|
||||
):
|
||||
try:
|
||||
archived_package = (
|
||||
self.package_service.archive_job(
|
||||
job_dir.name
|
||||
)
|
||||
)
|
||||
except JobPackageError as exc:
|
||||
job_logger.error(
|
||||
"Job package archival failed: %s",
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
job_logger.exception(
|
||||
"Unexpected Job package archival failure."
|
||||
)
|
||||
return False
|
||||
|
||||
job_logger.info(
|
||||
"Job already COMPLETED; "
|
||||
"Job package finalized: %s",
|
||||
archived_package,
|
||||
)
|
||||
return True
|
||||
|
||||
update_job_state(
|
||||
job_dir,
|
||||
JOB_STATUS_RUNNING,
|
||||
)
|
||||
|
||||
all_completed = True
|
||||
|
||||
for step in job["steps"]:
|
||||
if not self._run_step(
|
||||
job_dir,
|
||||
job,
|
||||
step,
|
||||
job_logger,
|
||||
):
|
||||
all_completed = False
|
||||
break
|
||||
|
||||
if not all_completed:
|
||||
update_job_state(
|
||||
job_dir,
|
||||
JOB_STATUS_PARTIAL,
|
||||
)
|
||||
|
||||
job_logger.warning(
|
||||
"Job remains PARTIAL."
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
job_logger.info(
|
||||
"All steps completed; running Hashcat --show."
|
||||
)
|
||||
|
||||
show_path = (
|
||||
job_dir
|
||||
/ "results"
|
||||
/ "hashcat-show.txt"
|
||||
)
|
||||
|
||||
show_log = (
|
||||
job_dir
|
||||
/ "logs"
|
||||
/ "show.log"
|
||||
)
|
||||
|
||||
try:
|
||||
show_output = run_show(
|
||||
self.config,
|
||||
job_dir,
|
||||
job,
|
||||
show_path,
|
||||
show_log,
|
||||
)
|
||||
|
||||
job_logger.info(
|
||||
"Hashcat --show completed: "
|
||||
"%s bytes, %s lines.",
|
||||
show_path.stat().st_size,
|
||||
len(show_output.splitlines()),
|
||||
)
|
||||
|
||||
except HashcatError as exc:
|
||||
update_job_state(
|
||||
job_dir,
|
||||
JOB_STATUS_FAILED,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
job_logger.error(
|
||||
"Hashcat --show failed: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as exc:
|
||||
update_job_state(
|
||||
job_dir,
|
||||
JOB_STATUS_FAILED,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
job_logger.exception(
|
||||
"Unexpected --show error."
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
step_states = [
|
||||
self._step_result(
|
||||
job_dir,
|
||||
step,
|
||||
)
|
||||
for step in job["steps"]
|
||||
]
|
||||
|
||||
try:
|
||||
result_package = (
|
||||
create_result_package(
|
||||
job_dir,
|
||||
job,
|
||||
step_states,
|
||||
self.paths.outbox,
|
||||
logger=job_logger,
|
||||
)
|
||||
)
|
||||
|
||||
except ResultPackageError as exc:
|
||||
update_job_state(
|
||||
job_dir,
|
||||
JOB_STATUS_FAILED,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
job_logger.error(
|
||||
"Result package creation failed: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as exc:
|
||||
update_job_state(
|
||||
job_dir,
|
||||
JOB_STATUS_FAILED,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
job_logger.exception(
|
||||
"Unexpected result package error."
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
update_job_state(
|
||||
job_dir,
|
||||
JOB_STATUS_COMPLETED,
|
||||
)
|
||||
|
||||
try:
|
||||
archived_package = (
|
||||
self.package_service.archive_job(
|
||||
job_dir.name
|
||||
)
|
||||
)
|
||||
except JobPackageError as exc:
|
||||
job_logger.error(
|
||||
"Job package archival failed: %s",
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
job_logger.exception(
|
||||
"Unexpected Job package archival failure."
|
||||
)
|
||||
return False
|
||||
|
||||
job_logger.info(
|
||||
"Job COMPLETED."
|
||||
)
|
||||
job_logger.info(
|
||||
"Result package: %s",
|
||||
result_package,
|
||||
)
|
||||
job_logger.info(
|
||||
"Job package archived: %s",
|
||||
archived_package,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
update_job_state(
|
||||
job_dir,
|
||||
JOB_STATUS_FAILED,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
job_logger.error(
|
||||
"Job failed: %s",
|
||||
exc,
|
||||
)
|
||||
job_logger.debug(
|
||||
"%s",
|
||||
traceback.format_exc(),
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def run_once(self) -> list[str]:
|
||||
imported = self.import_jobs()
|
||||
|
||||
for item in imported:
|
||||
self.logger.info(
|
||||
"Queue item: %s idempotent=%s",
|
||||
item.job_id,
|
||||
item.idempotent,
|
||||
)
|
||||
|
||||
completed: list[str] = []
|
||||
|
||||
for job_dir in sorted(
|
||||
self.paths.jobs.glob("JOB-*")
|
||||
):
|
||||
if not job_dir.is_dir():
|
||||
continue
|
||||
|
||||
try:
|
||||
if self.run_job(
|
||||
job_dir
|
||||
):
|
||||
completed.append(
|
||||
job_dir.name
|
||||
)
|
||||
|
||||
except Exception:
|
||||
self.logger.exception(
|
||||
"Unhandled error processing %s",
|
||||
job_dir.name,
|
||||
)
|
||||
|
||||
return completed
|
||||
|
||||
def run_forever(self) -> None:
|
||||
self.logger.info(
|
||||
"Client loop started."
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
self.run_once()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.logger.info(
|
||||
"Client stopped by user."
|
||||
)
|
||||
return
|
||||
|
||||
except Exception:
|
||||
self.logger.exception(
|
||||
"Unhandled client loop error."
|
||||
)
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(
|
||||
self.config.poll_seconds
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LOGGER_NAME = "cracklab.client"
|
||||
|
||||
|
||||
class UTCFormatter(logging.Formatter):
|
||||
converter = staticmethod(
|
||||
lambda timestamp: datetime.fromtimestamp(
|
||||
timestamp,
|
||||
tz=timezone.utc,
|
||||
).timetuple()
|
||||
)
|
||||
|
||||
|
||||
def _configure(
|
||||
name: str,
|
||||
log_path: Path,
|
||||
) -> logging.Logger:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
handler = logging.FileHandler(
|
||||
log_path,
|
||||
mode="a",
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setFormatter(
|
||||
UTCFormatter(
|
||||
"%(asctime)sZ %(levelname)s %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||
)
|
||||
)
|
||||
|
||||
logger.addHandler(handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def configure_logging(log_path: Path) -> logging.Logger:
|
||||
return _configure(
|
||||
LOGGER_NAME,
|
||||
log_path,
|
||||
)
|
||||
|
||||
|
||||
def configure_job_logging(
|
||||
log_path: Path,
|
||||
job_id: str,
|
||||
) -> logging.Logger:
|
||||
return _configure(
|
||||
f"{LOGGER_NAME}.{job_id}",
|
||||
log_path,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"hashcat_exe": "F:\\hashcat\\hashcat.exe",
|
||||
"potfile_path": "F:\\cracklab\\potfile\\wifi-gps-mapper.potfile",
|
||||
"client_root": "F:\\cracklab\\client",
|
||||
"wordlists": {
|
||||
"insidePRO_WPA": "F:\\hashcat\\my-slovari\\insidePRO_WPA.txt",
|
||||
"BreachCompilation": "B:\\BIG_SLOVAR\\parser\\BreachCompilation.txt",
|
||||
"test": "F:\\cracklab\\test-artifacts\\wordlists\\integration-test.txt"
|
||||
},
|
||||
"poll_seconds": 3,
|
||||
"max_parallel_jobs": 1
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONFIG_NAME = "config.json"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClientConfig:
|
||||
hashcat_exe: Path
|
||||
potfile_path: Path
|
||||
client_root: Path
|
||||
wordlists: dict[str, Path]
|
||||
poll_seconds: int
|
||||
max_parallel_jobs: int
|
||||
|
||||
|
||||
def load_config(script_dir: Path) -> ClientConfig:
|
||||
path = script_dir / CONFIG_NAME
|
||||
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"Missing {path}. "
|
||||
"Copy config.example.json to config.json and edit it."
|
||||
)
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Client config must contain a JSON object.")
|
||||
|
||||
required = (
|
||||
"hashcat_exe",
|
||||
"potfile_path",
|
||||
"client_root",
|
||||
"wordlists",
|
||||
"poll_seconds",
|
||||
"max_parallel_jobs",
|
||||
)
|
||||
|
||||
missing = [name for name in required if name not in data]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"Missing required config fields: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
for name in (
|
||||
"hashcat_exe",
|
||||
"potfile_path",
|
||||
"client_root",
|
||||
):
|
||||
if not isinstance(data[name], str) or not data[name].strip():
|
||||
raise ValueError(
|
||||
f"Config field {name!r} must be a non-empty string."
|
||||
)
|
||||
|
||||
if not isinstance(data["wordlists"], dict) or not data["wordlists"]:
|
||||
raise ValueError(
|
||||
"Config field 'wordlists' must be a non-empty object."
|
||||
)
|
||||
|
||||
for name, path_value in data["wordlists"].items():
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError(
|
||||
"Each wordlists key must be a non-empty string."
|
||||
)
|
||||
|
||||
if not isinstance(path_value, str) or not path_value.strip():
|
||||
raise ValueError(
|
||||
f"Wordlist {name!r} path must be a non-empty string."
|
||||
)
|
||||
|
||||
for name in (
|
||||
"poll_seconds",
|
||||
"max_parallel_jobs",
|
||||
):
|
||||
value = data[name]
|
||||
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(
|
||||
f"Config field {name!r} must be an integer."
|
||||
)
|
||||
|
||||
if value <= 0:
|
||||
raise ValueError(
|
||||
f"Config field {name!r} must be greater than zero."
|
||||
)
|
||||
|
||||
return ClientConfig(
|
||||
hashcat_exe=Path(data["hashcat_exe"]).expanduser().resolve(),
|
||||
potfile_path=Path(data["potfile_path"]).expanduser().resolve(),
|
||||
client_root=Path(data["client_root"]).expanduser().resolve(),
|
||||
wordlists={
|
||||
name: Path(path_value).expanduser().resolve()
|
||||
for name, path_value in data["wordlists"].items()
|
||||
},
|
||||
poll_seconds=data["poll_seconds"],
|
||||
max_parallel_jobs=data["max_parallel_jobs"],
|
||||
)
|
||||
@@ -0,0 +1,673 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class HashcatError(RuntimeError):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
exit_code: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HashcatRunResult:
|
||||
exit_code: int
|
||||
restore_seen: bool
|
||||
|
||||
|
||||
def restore_path(
|
||||
job_dir: Path,
|
||||
step: dict,
|
||||
) -> Path:
|
||||
return (
|
||||
job_dir
|
||||
/ "state"
|
||||
/ f"step-{step['step_no']:03d}.restore"
|
||||
)
|
||||
|
||||
|
||||
def completed_marker(
|
||||
job_dir: Path,
|
||||
step: dict,
|
||||
) -> Path:
|
||||
return (
|
||||
job_dir
|
||||
/ "state"
|
||||
/ f"step-{step['step_no']:03d}.completed"
|
||||
)
|
||||
|
||||
|
||||
def _session_name(step: dict) -> str:
|
||||
session = step.get("session_name")
|
||||
|
||||
if not isinstance(session, str) or not session:
|
||||
raise HashcatError(
|
||||
f"Step {step.get('step_no')} has no session_name."
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
def _resolve_wordlist(
|
||||
config,
|
||||
resource: str,
|
||||
step_no,
|
||||
) -> Path:
|
||||
if not isinstance(resource, str) or not resource:
|
||||
raise HashcatError(
|
||||
f"Step {step_no} has invalid dictionary resource."
|
||||
)
|
||||
|
||||
prefix = "wordlist:"
|
||||
|
||||
if not resource.startswith(prefix):
|
||||
raise HashcatError(
|
||||
f"Step {step_no} has unsupported dictionary resource: "
|
||||
f"{resource}"
|
||||
)
|
||||
|
||||
name = resource[len(prefix):]
|
||||
|
||||
if not name or "/" in name or "\\" in name or name in (".", ".."):
|
||||
raise HashcatError(
|
||||
f"Step {step_no} has invalid dictionary resource: "
|
||||
f"{resource}"
|
||||
)
|
||||
|
||||
dictionary = config.wordlists.get(name)
|
||||
|
||||
if dictionary is None:
|
||||
raise HashcatError(
|
||||
f"Wordlist resource {resource!r} is not configured."
|
||||
)
|
||||
|
||||
dictionary = dictionary.resolve()
|
||||
|
||||
if not dictionary.is_file():
|
||||
raise HashcatError(
|
||||
f"Configured wordlist does not exist: {dictionary}"
|
||||
)
|
||||
|
||||
return dictionary
|
||||
|
||||
def build_start_command(
|
||||
config,
|
||||
job_dir: Path,
|
||||
job: dict,
|
||||
step: dict,
|
||||
) -> list[str]:
|
||||
exe = config.hashcat_exe.resolve()
|
||||
|
||||
if not exe.is_file():
|
||||
raise HashcatError(
|
||||
f"Hashcat executable does not exist: {exe}"
|
||||
)
|
||||
|
||||
hash_file = (
|
||||
job_dir
|
||||
/ "hashes"
|
||||
/ job["hash_file_name"]
|
||||
).resolve()
|
||||
|
||||
if not hash_file.is_file():
|
||||
raise HashcatError(
|
||||
f"Hash file does not exist: {hash_file}"
|
||||
)
|
||||
|
||||
restore = restore_path(
|
||||
job_dir,
|
||||
step,
|
||||
).resolve()
|
||||
|
||||
session = _session_name(step)
|
||||
|
||||
attack_mode = step.get("definition", {}).get(
|
||||
"attack_mode"
|
||||
)
|
||||
|
||||
if not isinstance(attack_mode, int):
|
||||
raise HashcatError(
|
||||
f"Step {step.get('step_no')} has invalid "
|
||||
"attack_mode."
|
||||
)
|
||||
|
||||
definition = step["definition"]
|
||||
|
||||
cmd = [
|
||||
str(exe),
|
||||
"-m",
|
||||
str(job["hash_mode"]),
|
||||
"-a",
|
||||
str(attack_mode),
|
||||
"--session",
|
||||
session,
|
||||
"--restore-file-path",
|
||||
str(restore),
|
||||
"--potfile-path",
|
||||
str(config.potfile_path.resolve()),
|
||||
"--logfile-disable",
|
||||
"-w",
|
||||
str(
|
||||
definition.get(
|
||||
"workload_profile",
|
||||
1,
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
if attack_mode in (0, 1, 6, 7):
|
||||
dictionary_definition = (
|
||||
definition.get(
|
||||
"dictionaries"
|
||||
)
|
||||
if attack_mode == 1
|
||||
else definition.get(
|
||||
"dictionary"
|
||||
)
|
||||
)
|
||||
|
||||
if attack_mode == 0:
|
||||
if not isinstance(
|
||||
dictionary_definition,
|
||||
dict,
|
||||
):
|
||||
raise HashcatError(
|
||||
f"Step {step.get('step_no')} "
|
||||
"attack mode 0 has no dictionary."
|
||||
)
|
||||
|
||||
resources = [
|
||||
dictionary_definition.get(
|
||||
"resource"
|
||||
)
|
||||
]
|
||||
|
||||
elif attack_mode == 1:
|
||||
if (
|
||||
not isinstance(
|
||||
dictionary_definition,
|
||||
list,
|
||||
)
|
||||
or len(dictionary_definition) != 2
|
||||
):
|
||||
raise HashcatError(
|
||||
f"Step {step.get('step_no')} "
|
||||
"attack mode 1 requires exactly "
|
||||
"two dictionaries."
|
||||
)
|
||||
|
||||
resources = [
|
||||
item.get("resource")
|
||||
if isinstance(item, dict)
|
||||
else None
|
||||
for item in dictionary_definition
|
||||
]
|
||||
|
||||
else:
|
||||
if not isinstance(
|
||||
dictionary_definition,
|
||||
dict,
|
||||
):
|
||||
raise HashcatError(
|
||||
f"Step {step.get('step_no')} "
|
||||
f"attack mode {attack_mode} "
|
||||
"has no dictionary."
|
||||
)
|
||||
|
||||
resources = [
|
||||
dictionary_definition.get(
|
||||
"resource"
|
||||
)
|
||||
]
|
||||
|
||||
dictionaries = [
|
||||
_resolve_wordlist(
|
||||
config,
|
||||
resource,
|
||||
step.get("step_no"),
|
||||
)
|
||||
for resource in resources
|
||||
]
|
||||
|
||||
induction = (
|
||||
job_dir
|
||||
/ "state"
|
||||
/ "induct"
|
||||
).resolve()
|
||||
|
||||
outfile_check = (
|
||||
job_dir
|
||||
/ "state"
|
||||
/ "outfiles"
|
||||
).resolve()
|
||||
|
||||
induction.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
outfile_check.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"--induction-dir",
|
||||
str(induction),
|
||||
"--outfile-check-dir",
|
||||
str(outfile_check),
|
||||
str(hash_file),
|
||||
]
|
||||
)
|
||||
|
||||
cmd.extend(
|
||||
str(dictionary)
|
||||
for dictionary in dictionaries
|
||||
)
|
||||
|
||||
if attack_mode in (6, 7):
|
||||
mask = definition.get("mask")
|
||||
|
||||
if not isinstance(mask, str) or not mask:
|
||||
raise HashcatError(
|
||||
f"Step {step.get('step_no')} "
|
||||
f"attack mode {attack_mode} has no mask."
|
||||
)
|
||||
|
||||
if attack_mode == 6:
|
||||
cmd.append(mask)
|
||||
else:
|
||||
cmd.insert(
|
||||
len(cmd) - len(dictionaries),
|
||||
mask,
|
||||
)
|
||||
|
||||
elif attack_mode == 3:
|
||||
mask = definition.get("mask")
|
||||
|
||||
if not isinstance(mask, str) or not mask:
|
||||
raise HashcatError(
|
||||
f"Step {step.get('step_no')} "
|
||||
"attack mode 3 has no mask."
|
||||
)
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
str(hash_file),
|
||||
mask,
|
||||
]
|
||||
)
|
||||
|
||||
else:
|
||||
raise HashcatError(
|
||||
f"Unsupported attack mode {attack_mode}. "
|
||||
"Use a structured adapter for new modes."
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
def build_restore_command(
|
||||
config,
|
||||
job_dir: Path,
|
||||
step: dict,
|
||||
) -> list[str]:
|
||||
exe = config.hashcat_exe.resolve()
|
||||
|
||||
if not exe.is_file():
|
||||
raise HashcatError(
|
||||
f"Hashcat executable does not exist: {exe}"
|
||||
)
|
||||
|
||||
restore = restore_path(
|
||||
job_dir,
|
||||
step,
|
||||
).resolve()
|
||||
|
||||
return [
|
||||
str(exe),
|
||||
"--session",
|
||||
_session_name(step),
|
||||
"--restore-file-path",
|
||||
str(restore),
|
||||
"--restore",
|
||||
]
|
||||
|
||||
|
||||
def build_show_command(
|
||||
config,
|
||||
job_dir: Path,
|
||||
job: dict,
|
||||
) -> list[str]:
|
||||
exe = config.hashcat_exe.resolve()
|
||||
|
||||
if not exe.is_file():
|
||||
raise HashcatError(
|
||||
f"Hashcat executable does not exist: {exe}"
|
||||
)
|
||||
|
||||
hash_file = (
|
||||
job_dir
|
||||
/ "hashes"
|
||||
/ job["hash_file_name"]
|
||||
).resolve()
|
||||
|
||||
if not hash_file.is_file():
|
||||
raise HashcatError(
|
||||
f"Hash file does not exist: {hash_file}"
|
||||
)
|
||||
|
||||
return [
|
||||
str(exe),
|
||||
"-m",
|
||||
str(job["hash_mode"]),
|
||||
"--potfile-path",
|
||||
str(config.potfile_path.resolve()),
|
||||
"--show",
|
||||
str(hash_file),
|
||||
]
|
||||
|
||||
|
||||
def command_text(cmd: list[str]) -> str:
|
||||
return " ".join(
|
||||
f'"{value}"' if " " in value else value
|
||||
for value in cmd
|
||||
)
|
||||
|
||||
|
||||
def run_hashcat(
|
||||
cmd: list[str],
|
||||
log_path: Path,
|
||||
*,
|
||||
on_output: Callable[[bytes], None] | None = None,
|
||||
) -> int:
|
||||
log_path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
if not cmd:
|
||||
raise HashcatError(
|
||||
"Hashcat command is empty."
|
||||
)
|
||||
|
||||
hashcat_dir = (
|
||||
Path(cmd[0])
|
||||
.resolve()
|
||||
.parent
|
||||
)
|
||||
|
||||
text = command_text(cmd)
|
||||
|
||||
with log_path.open(
|
||||
"ab",
|
||||
) as log:
|
||||
log.write(
|
||||
b"\n=== START ===\n"
|
||||
)
|
||||
log.write(
|
||||
text.encode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
)
|
||||
log.write(b"\n")
|
||||
log.flush()
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(hashcat_dir),
|
||||
stdin=None,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
bufsize=0,
|
||||
creationflags=getattr(
|
||||
subprocess,
|
||||
"CREATE_NEW_PROCESS_GROUP",
|
||||
0,
|
||||
),
|
||||
)
|
||||
except OSError as exc:
|
||||
log.write(
|
||||
(
|
||||
"\n=== PROCESS START ERROR ===\n"
|
||||
f"{exc}\n"
|
||||
).encode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
)
|
||||
raise HashcatError(
|
||||
f"Failed to start Hashcat: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
if process.stdout is None:
|
||||
raise HashcatError(
|
||||
"Hashcat stdout pipe was not created."
|
||||
)
|
||||
|
||||
while True:
|
||||
chunk = process.stdout.read(4096)
|
||||
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
log.write(chunk)
|
||||
log.flush()
|
||||
|
||||
if on_output is not None:
|
||||
on_output(chunk)
|
||||
|
||||
except BaseException:
|
||||
try:
|
||||
process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
raise
|
||||
|
||||
finally:
|
||||
if process.stdout is not None:
|
||||
process.stdout.close()
|
||||
|
||||
exit_code = process.wait()
|
||||
|
||||
log.write(
|
||||
(
|
||||
"\n=== END ===\n"
|
||||
f"exit_code={exit_code}\n"
|
||||
).encode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
)
|
||||
log.flush()
|
||||
|
||||
return exit_code
|
||||
|
||||
|
||||
def run_step(
|
||||
config,
|
||||
job_dir: Path,
|
||||
job: dict,
|
||||
step: dict,
|
||||
log_path: Path,
|
||||
*,
|
||||
restore: bool = False,
|
||||
) -> HashcatRunResult:
|
||||
restore_file = restore_path(
|
||||
job_dir,
|
||||
step,
|
||||
)
|
||||
|
||||
if restore:
|
||||
if not restore_file.is_file():
|
||||
raise HashcatError(
|
||||
f"Restore requested but restore file "
|
||||
f"does not exist: {restore_file}"
|
||||
)
|
||||
|
||||
cmd = build_restore_command(
|
||||
config,
|
||||
job_dir,
|
||||
step,
|
||||
)
|
||||
else:
|
||||
cmd = build_start_command(
|
||||
config,
|
||||
job_dir,
|
||||
job,
|
||||
step,
|
||||
)
|
||||
|
||||
exit_code = run_hashcat(
|
||||
cmd,
|
||||
log_path,
|
||||
on_output=lambda chunk: print(
|
||||
chunk.decode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
),
|
||||
end="",
|
||||
flush=True,
|
||||
),
|
||||
)
|
||||
|
||||
restore_seen = restore_file.is_file()
|
||||
|
||||
if exit_code not in (0, 1):
|
||||
raise HashcatError(
|
||||
f"Hashcat exited with unsupported "
|
||||
f"code {exit_code}.",
|
||||
exit_code=exit_code,
|
||||
)
|
||||
|
||||
return HashcatRunResult(
|
||||
exit_code=exit_code,
|
||||
restore_seen=restore_seen,
|
||||
)
|
||||
|
||||
|
||||
def run_show(
|
||||
config,
|
||||
job_dir: Path,
|
||||
job: dict,
|
||||
output_path: Path,
|
||||
log_path: Path,
|
||||
) -> str:
|
||||
cmd = build_show_command(
|
||||
config,
|
||||
job_dir,
|
||||
job,
|
||||
)
|
||||
|
||||
output_path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
log_path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
hashcat_dir = (
|
||||
Path(cmd[0])
|
||||
.resolve()
|
||||
.parent
|
||||
)
|
||||
|
||||
text = command_text(cmd)
|
||||
|
||||
with log_path.open(
|
||||
"ab",
|
||||
) as log:
|
||||
log.write(
|
||||
b"\n=== SHOW START ===\n"
|
||||
)
|
||||
log.write(
|
||||
text.encode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
)
|
||||
log.write(b"\n")
|
||||
log.flush()
|
||||
|
||||
try:
|
||||
process = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(hashcat_dir),
|
||||
stdin=None,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
check=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
log.write(
|
||||
(
|
||||
"\n=== SHOW START ERROR ===\n"
|
||||
f"{exc}\n"
|
||||
).encode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
)
|
||||
raise HashcatError(
|
||||
f"Failed to start Hashcat --show: {exc}"
|
||||
) from exc
|
||||
|
||||
output = process.stdout or b""
|
||||
|
||||
output_path.write_bytes(
|
||||
output
|
||||
)
|
||||
|
||||
log.write(output)
|
||||
log.write(
|
||||
(
|
||||
"\n=== SHOW END ===\n"
|
||||
f"exit_code={process.returncode}\n"
|
||||
).encode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
)
|
||||
log.flush()
|
||||
|
||||
if process.returncode not in (0, 1):
|
||||
raise HashcatError(
|
||||
f"Hashcat --show exited with "
|
||||
f"code {process.returncode}."
|
||||
)
|
||||
|
||||
return output.decode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
|
||||
with path.open("rb") as handle:
|
||||
while True:
|
||||
chunk = handle.read(1024 * 1024)
|
||||
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
digest.update(chunk)
|
||||
|
||||
return digest.hexdigest()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from .client import CrackLabClient
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="WiFi GPS Mapper CrackLab client"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--once",
|
||||
action="store_true",
|
||||
help="Import and process jobs once, then exit.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
script_dir = Path(
|
||||
__file__
|
||||
).resolve().parent
|
||||
|
||||
client = CrackLabClient(
|
||||
script_dir
|
||||
)
|
||||
|
||||
if args.once:
|
||||
client.run_once()
|
||||
return 0
|
||||
|
||||
client.run_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(
|
||||
main()
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CLIENT_DIRECTORIES = (
|
||||
"inbox",
|
||||
"pending",
|
||||
"jobs",
|
||||
"outbox",
|
||||
"logs",
|
||||
"archive",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClientPaths:
|
||||
root: Path
|
||||
inbox: Path
|
||||
pending: Path
|
||||
jobs: Path
|
||||
outbox: Path
|
||||
logs: Path
|
||||
archive: Path
|
||||
|
||||
@classmethod
|
||||
def from_root(cls, root: Path) -> "ClientPaths":
|
||||
root = root.resolve()
|
||||
|
||||
return cls(
|
||||
root=root,
|
||||
inbox=root / "inbox",
|
||||
pending=root / "pending",
|
||||
jobs=root / "jobs",
|
||||
outbox=root / "outbox",
|
||||
logs=root / "logs",
|
||||
archive=root / "archive",
|
||||
)
|
||||
|
||||
def initialize(self) -> None:
|
||||
for name in CLIENT_DIRECTORIES:
|
||||
(self.root / name).mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
@@ -0,0 +1,310 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ResultPackageError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return (
|
||||
datetime.now(timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
def _sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
|
||||
with path.open("rb") as handle:
|
||||
while True:
|
||||
chunk = handle.read(1024 * 1024)
|
||||
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
digest.update(chunk)
|
||||
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _json_bytes(data: dict[str, Any]) -> bytes:
|
||||
return (
|
||||
json.dumps(
|
||||
data,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _atomic_write(
|
||||
path: Path,
|
||||
data: bytes,
|
||||
) -> None:
|
||||
path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
fd, temporary = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=str(path.parent),
|
||||
)
|
||||
|
||||
temporary_path = Path(temporary)
|
||||
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
temporary_path.replace(path)
|
||||
except BaseException:
|
||||
temporary_path.unlink(
|
||||
missing_ok=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def _validate_result_steps(
|
||||
job: dict[str, Any],
|
||||
step_states: list[dict[str, Any]],
|
||||
) -> None:
|
||||
job_steps = job.get("steps")
|
||||
|
||||
if not isinstance(job_steps, list):
|
||||
raise ResultPackageError(
|
||||
"Job has no valid steps list."
|
||||
)
|
||||
|
||||
if len(job_steps) != len(step_states):
|
||||
raise ResultPackageError(
|
||||
"Result step count does not match Job step count."
|
||||
)
|
||||
|
||||
for job_step, result_step in zip(
|
||||
job_steps,
|
||||
step_states,
|
||||
):
|
||||
if (
|
||||
result_step.get("step_no")
|
||||
!= job_step.get("step_no")
|
||||
):
|
||||
raise ResultPackageError(
|
||||
"Result step_no does not match Job."
|
||||
)
|
||||
|
||||
if (
|
||||
result_step.get("step_id")
|
||||
!= job_step.get("step_id")
|
||||
):
|
||||
raise ResultPackageError(
|
||||
"Result step_id does not match Job."
|
||||
)
|
||||
|
||||
if (
|
||||
result_step.get("session_name")
|
||||
!= job_step.get("session_name")
|
||||
):
|
||||
raise ResultPackageError(
|
||||
"Result session_name does not match Job."
|
||||
)
|
||||
|
||||
if result_step.get("status") != "COMPLETED":
|
||||
raise ResultPackageError(
|
||||
f"Step {job_step.get('step_no')} "
|
||||
"is not COMPLETED."
|
||||
)
|
||||
|
||||
if result_step.get("exit_code") not in (0, 1):
|
||||
raise ResultPackageError(
|
||||
f"Step {job_step.get('step_no')} "
|
||||
"has unsupported exit_code."
|
||||
)
|
||||
|
||||
if result_step.get("restore_seen"):
|
||||
raise ResultPackageError(
|
||||
f"Step {job_step.get('step_no')} "
|
||||
"still has an active restore state."
|
||||
)
|
||||
|
||||
|
||||
def build_result(
|
||||
job: dict[str, Any],
|
||||
step_states: list[dict[str, Any]],
|
||||
*,
|
||||
completed_at: str | None = None,
|
||||
) -> tuple[dict[str, Any], bytes]:
|
||||
job_id = job.get("job_id")
|
||||
|
||||
if not isinstance(job_id, str) or not job_id:
|
||||
raise ResultPackageError(
|
||||
"Job has no valid job_id."
|
||||
)
|
||||
|
||||
method_id = job.get("method_id")
|
||||
method_version = job.get("method_version")
|
||||
|
||||
if not isinstance(method_id, str) or not method_id:
|
||||
raise ResultPackageError(
|
||||
"Job has no valid method_id."
|
||||
)
|
||||
|
||||
if (
|
||||
isinstance(method_version, bool)
|
||||
or not isinstance(method_version, int)
|
||||
):
|
||||
raise ResultPackageError(
|
||||
"Job has no valid method_version."
|
||||
)
|
||||
|
||||
_validate_result_steps(
|
||||
job,
|
||||
step_states,
|
||||
)
|
||||
|
||||
result = {
|
||||
"schema_version": 1,
|
||||
"job_id": job_id,
|
||||
"status": "COMPLETED",
|
||||
"completed_at": completed_at or utc_now(),
|
||||
"hash_file_sha256": job["hash_file_sha256"],
|
||||
"hash_count": job["hash_count"],
|
||||
"method": {
|
||||
"id": method_id,
|
||||
"version": method_version,
|
||||
},
|
||||
"steps": step_states,
|
||||
"show_file": "hashcat-show.txt",
|
||||
}
|
||||
|
||||
unsigned_bytes = _json_bytes(result)
|
||||
|
||||
result["result_sha256"] = _sha256_bytes(
|
||||
unsigned_bytes
|
||||
)
|
||||
|
||||
return result, _json_bytes(result)
|
||||
|
||||
|
||||
def create_result_package(
|
||||
job_dir: Path,
|
||||
job: dict[str, Any],
|
||||
step_states: list[dict[str, Any]],
|
||||
outbox: Path,
|
||||
*,
|
||||
logger=None,
|
||||
) -> Path:
|
||||
results_dir = job_dir / "results"
|
||||
|
||||
show_path = results_dir / "hashcat-show.txt"
|
||||
|
||||
if not show_path.is_file():
|
||||
raise ResultPackageError(
|
||||
f"Missing Hashcat show output: {show_path}"
|
||||
)
|
||||
|
||||
result, result_bytes = build_result(
|
||||
job,
|
||||
step_states,
|
||||
)
|
||||
|
||||
result_path = results_dir / "result.json"
|
||||
|
||||
_atomic_write(
|
||||
result_path,
|
||||
result_bytes,
|
||||
)
|
||||
|
||||
result_sha256 = _sha256_file(
|
||||
result_path
|
||||
)
|
||||
|
||||
if result_sha256 != _sha256_bytes(
|
||||
result_bytes
|
||||
):
|
||||
raise ResultPackageError(
|
||||
"Written result.json SHA-256 mismatch."
|
||||
)
|
||||
|
||||
outbox.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
job_id = job["job_id"]
|
||||
|
||||
destination = (
|
||||
outbox / f"RESULT-{job_dir.name}.zip"
|
||||
)
|
||||
|
||||
if destination.exists():
|
||||
raise ResultPackageError(
|
||||
f"Result package already exists: "
|
||||
f"{destination}"
|
||||
)
|
||||
|
||||
fd, temporary = tempfile.mkstemp(
|
||||
prefix=f".RESULT-{job_dir.name}.",
|
||||
suffix=".zip.tmp",
|
||||
dir=str(outbox),
|
||||
)
|
||||
|
||||
temporary_path = Path(temporary)
|
||||
|
||||
try:
|
||||
os.close(fd)
|
||||
|
||||
with zipfile.ZipFile(
|
||||
temporary_path,
|
||||
mode="w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
) as archive:
|
||||
archive.write(
|
||||
result_path,
|
||||
"result.json",
|
||||
)
|
||||
archive.write(
|
||||
show_path,
|
||||
"hashcat-show.txt",
|
||||
)
|
||||
|
||||
temporary_path.replace(
|
||||
destination
|
||||
)
|
||||
|
||||
except BaseException:
|
||||
temporary_path.unlink(
|
||||
missing_ok=True
|
||||
)
|
||||
raise
|
||||
|
||||
if logger is not None:
|
||||
logger.info(
|
||||
"Result package created: %s "
|
||||
"result_sha256=%s "
|
||||
"show_sha256=%s",
|
||||
destination.name,
|
||||
result["result_sha256"],
|
||||
_sha256_file(show_path),
|
||||
)
|
||||
|
||||
return destination
|
||||
@@ -0,0 +1,258 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
JOB_STATUS_QUEUED = "QUEUED"
|
||||
JOB_STATUS_RUNNING = "RUNNING"
|
||||
JOB_STATUS_COMPLETED = "COMPLETED"
|
||||
JOB_STATUS_PARTIAL = "PARTIAL"
|
||||
JOB_STATUS_FAILED = "FAILED"
|
||||
|
||||
STEP_STATUS_PENDING = "PENDING"
|
||||
STEP_STATUS_RUNNING = "RUNNING"
|
||||
STEP_STATUS_COMPLETED = "COMPLETED"
|
||||
STEP_STATUS_PARTIAL = "PARTIAL"
|
||||
STEP_STATUS_FAILED = "FAILED"
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return (
|
||||
datetime.now(timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
class StateError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _atomic_write_json(
|
||||
path: Path,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
temp_path = path.with_name(
|
||||
f".{path.name}.tmp"
|
||||
)
|
||||
|
||||
payload = json.dumps(
|
||||
data,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
) + "\n"
|
||||
|
||||
temp_path.write_text(
|
||||
payload,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
temp_path.replace(path)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(
|
||||
path.read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise StateError(
|
||||
f"Missing state file: {path}"
|
||||
) from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise StateError(
|
||||
f"Invalid JSON in state file {path}: {exc}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise StateError(
|
||||
f"State file must contain an object: {path}"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def state_path(job_dir: Path) -> Path:
|
||||
return job_dir / "state" / "client_state.json"
|
||||
|
||||
|
||||
def load_state(job_dir: Path) -> dict[str, Any]:
|
||||
return _load_json(
|
||||
state_path(job_dir)
|
||||
)
|
||||
|
||||
|
||||
def save_state(
|
||||
job_dir: Path,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
_atomic_write_json(
|
||||
state_path(job_dir),
|
||||
state,
|
||||
)
|
||||
|
||||
|
||||
def initialize_state(
|
||||
job_dir: Path,
|
||||
job: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
job_id = job.get("job_id")
|
||||
|
||||
if not isinstance(job_id, str) or not job_id:
|
||||
raise StateError(
|
||||
"Job is missing a valid job_id."
|
||||
)
|
||||
|
||||
steps = job.get("steps")
|
||||
|
||||
if not isinstance(steps, list) or not steps:
|
||||
raise StateError(
|
||||
f"Job {job_id} has no steps."
|
||||
)
|
||||
|
||||
now = utc_now()
|
||||
|
||||
state: dict[str, Any] = {
|
||||
"job_id": job_id,
|
||||
"status": JOB_STATUS_QUEUED,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"steps": {},
|
||||
}
|
||||
|
||||
for step in steps:
|
||||
step_no = step.get("step_no")
|
||||
|
||||
if not isinstance(step_no, int):
|
||||
raise StateError(
|
||||
f"Job {job_id} contains invalid step_no."
|
||||
)
|
||||
|
||||
state["steps"][str(step_no)] = {
|
||||
"step_no": step_no,
|
||||
"step_id": step.get("step_id"),
|
||||
"session_name": step.get("session_name"),
|
||||
"status": STEP_STATUS_PENDING,
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"exit_code": None,
|
||||
"restore_seen": False,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
save_state(
|
||||
job_dir,
|
||||
state,
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def update_job_state(
|
||||
job_dir: Path,
|
||||
status: str,
|
||||
*,
|
||||
error: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
state = load_state(job_dir)
|
||||
|
||||
state["status"] = status
|
||||
state["updated_at"] = utc_now()
|
||||
|
||||
if error is not None:
|
||||
state["error"] = error
|
||||
elif status != JOB_STATUS_FAILED:
|
||||
state.pop("error", None)
|
||||
|
||||
save_state(
|
||||
job_dir,
|
||||
state,
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def update_step_state(
|
||||
job_dir: Path,
|
||||
step_no: int,
|
||||
status: str,
|
||||
*,
|
||||
exit_code: int | None = None,
|
||||
restore_seen: bool | None = None,
|
||||
error: str | None = None,
|
||||
started: bool = False,
|
||||
completed: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
state = load_state(job_dir)
|
||||
|
||||
key = str(step_no)
|
||||
steps = state.get("steps")
|
||||
|
||||
if not isinstance(steps, dict) or key not in steps:
|
||||
raise StateError(
|
||||
f"Unknown step {step_no}."
|
||||
)
|
||||
|
||||
step_state = steps[key]
|
||||
|
||||
step_state["status"] = status
|
||||
|
||||
if started:
|
||||
step_state["started_at"] = utc_now()
|
||||
|
||||
if completed:
|
||||
step_state["completed_at"] = utc_now()
|
||||
|
||||
if exit_code is not None:
|
||||
step_state["exit_code"] = exit_code
|
||||
|
||||
if restore_seen is not None:
|
||||
step_state["restore_seen"] = restore_seen
|
||||
|
||||
if error is not None:
|
||||
step_state["error"] = error
|
||||
elif status != STEP_STATUS_FAILED:
|
||||
step_state["error"] = None
|
||||
|
||||
state["updated_at"] = utc_now()
|
||||
|
||||
save_state(
|
||||
job_dir,
|
||||
state,
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def get_step_state(
|
||||
job_dir: Path,
|
||||
step_no: int,
|
||||
) -> dict[str, Any]:
|
||||
state = load_state(job_dir)
|
||||
|
||||
steps = state.get("steps")
|
||||
|
||||
if not isinstance(steps, dict):
|
||||
raise StateError(
|
||||
f"Invalid steps state in {job_dir}."
|
||||
)
|
||||
|
||||
step = steps.get(str(step_no))
|
||||
|
||||
if not isinstance(step, dict):
|
||||
raise StateError(
|
||||
f"Unknown step {step_no}."
|
||||
)
|
||||
|
||||
return step
|
||||
@@ -0,0 +1,12 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo CrackLab GUI is not implemented yet.
|
||||
echo The client is currently available through client.bat.
|
||||
echo.
|
||||
|
||||
pause
|
||||
|
||||
endlocal
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
DEFAULT_DATABASE_PATH = PROJECT_ROOT / "database" / "wifi.sqlite3"
|
||||
|
||||
|
||||
def connect_database(path: Path | None = None) -> sqlite3.Connection:
|
||||
database_path = path or DEFAULT_DATABASE_PATH
|
||||
|
||||
if not database_path.is_file():
|
||||
raise FileNotFoundError(f"Database not found: {database_path}")
|
||||
|
||||
conn = sqlite3.connect(database_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
return conn
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from .connection import DEFAULT_DATABASE_PATH, connect_database
|
||||
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).resolve().parents[1] / "migrations"
|
||||
|
||||
|
||||
def _migration_checksum(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _ensure_migrations_table(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cracklab_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _load_applied_migrations(
|
||||
conn: sqlite3.Connection,
|
||||
) -> dict[str, tuple[str, str]]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, checksum, applied_at
|
||||
FROM cracklab_migrations
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
migration_id: (checksum, applied_at)
|
||||
for migration_id, checksum, applied_at in rows
|
||||
}
|
||||
|
||||
|
||||
def _migration_files(migrations_dir: Path) -> list[Path]:
|
||||
return sorted(
|
||||
path
|
||||
for path in migrations_dir.glob("*.sql")
|
||||
if path.is_file()
|
||||
)
|
||||
|
||||
|
||||
def migrate_database(
|
||||
path: Path | None = None,
|
||||
migrations_dir: Path | None = None,
|
||||
) -> list[str]:
|
||||
conn = connect_database(path)
|
||||
|
||||
try:
|
||||
_ensure_migrations_table(conn)
|
||||
conn.commit()
|
||||
|
||||
applied = _load_applied_migrations(conn)
|
||||
migration_directory = migrations_dir or MIGRATIONS_DIR
|
||||
migrations = _migration_files(migration_directory)
|
||||
newly_applied: list[str] = []
|
||||
|
||||
for migration_path in migrations:
|
||||
migration_id = migration_path.name
|
||||
checksum = _migration_checksum(migration_path)
|
||||
|
||||
if migration_id in applied:
|
||||
stored_checksum, _ = applied[migration_id]
|
||||
|
||||
if stored_checksum != checksum:
|
||||
raise RuntimeError(
|
||||
f"Migration checksum mismatch: {migration_id}"
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
sql = migration_path.read_text(encoding="utf-8")
|
||||
|
||||
transaction_sql = f"""
|
||||
BEGIN;
|
||||
{sql}
|
||||
INSERT INTO cracklab_migrations (
|
||||
id,
|
||||
checksum,
|
||||
applied_at
|
||||
)
|
||||
VALUES (
|
||||
'{migration_id.replace("'", "''")}',
|
||||
'{checksum}',
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
);
|
||||
COMMIT;
|
||||
"""
|
||||
|
||||
try:
|
||||
conn.executescript(transaction_sql)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
applied[migration_id] = (
|
||||
checksum,
|
||||
"",
|
||||
)
|
||||
newly_applied.append(migration_id)
|
||||
|
||||
return newly_applied
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
applied = migrate_database(DEFAULT_DATABASE_PATH)
|
||||
|
||||
for migration_id in applied:
|
||||
print(migration_id)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,248 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_methods (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_method_versions (
|
||||
method_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
definition_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (method_id, version),
|
||||
FOREIGN KEY (method_id)
|
||||
REFERENCES crack_methods(id),
|
||||
CHECK (version > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT,
|
||||
metadata_json TEXT,
|
||||
CHECK (status IN ('ACTIVE', 'DISABLED'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
method_id TEXT NOT NULL,
|
||||
method_version INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
queued_at TEXT,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
cancelled_at TEXT,
|
||||
imported_at TEXT,
|
||||
client_id TEXT,
|
||||
hash_file_name TEXT NOT NULL,
|
||||
hash_file_sha256 TEXT NOT NULL,
|
||||
hash_count INTEGER NOT NULL,
|
||||
step_count INTEGER NOT NULL,
|
||||
result_sha256 TEXT,
|
||||
error TEXT,
|
||||
FOREIGN KEY (method_id, method_version)
|
||||
REFERENCES crack_method_versions(method_id, version),
|
||||
FOREIGN KEY (client_id)
|
||||
REFERENCES crack_clients(id),
|
||||
CHECK (
|
||||
status IN (
|
||||
'CREATED',
|
||||
'QUEUED',
|
||||
'RUNNING',
|
||||
'PARTIAL',
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
'CANCELLED'
|
||||
)
|
||||
),
|
||||
CHECK (hash_count >= 0),
|
||||
CHECK (step_count > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_job_handshakes (
|
||||
job_id TEXT NOT NULL,
|
||||
handshake_id INTEGER NOT NULL,
|
||||
hash22000 TEXT NOT NULL,
|
||||
access_point_id INTEGER,
|
||||
PRIMARY KEY (job_id, handshake_id),
|
||||
FOREIGN KEY (job_id)
|
||||
REFERENCES crack_jobs(id),
|
||||
FOREIGN KEY (handshake_id)
|
||||
REFERENCES handshakes(id),
|
||||
FOREIGN KEY (access_point_id)
|
||||
REFERENCES access_points(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_job_steps (
|
||||
job_id TEXT NOT NULL,
|
||||
step_no INTEGER NOT NULL,
|
||||
step_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
session_name TEXT NOT NULL,
|
||||
definition_json TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
exit_code INTEGER,
|
||||
restore_seen INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
PRIMARY KEY (job_id, step_no),
|
||||
UNIQUE (job_id, step_id),
|
||||
FOREIGN KEY (job_id)
|
||||
REFERENCES crack_jobs(id),
|
||||
CHECK (step_no > 0),
|
||||
CHECK (
|
||||
status IN (
|
||||
'PENDING',
|
||||
'RUNNING',
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
'SKIPPED'
|
||||
)
|
||||
),
|
||||
CHECK (restore_seen IN (0, 1))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_reservations (
|
||||
id TEXT PRIMARY KEY,
|
||||
handshake_id INTEGER NOT NULL,
|
||||
method_id TEXT NOT NULL,
|
||||
method_version INTEGER NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
client_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
released_at TEXT,
|
||||
FOREIGN KEY (handshake_id)
|
||||
REFERENCES handshakes(id),
|
||||
FOREIGN KEY (method_id, method_version)
|
||||
REFERENCES crack_method_versions(method_id, version),
|
||||
FOREIGN KEY (job_id)
|
||||
REFERENCES crack_jobs(id),
|
||||
FOREIGN KEY (client_id)
|
||||
REFERENCES crack_clients(id),
|
||||
CHECK (
|
||||
status IN (
|
||||
'ACTIVE',
|
||||
'RELEASED',
|
||||
'EXPIRED',
|
||||
'CANCELLED'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_attempts (
|
||||
handshake_id INTEGER NOT NULL,
|
||||
method_id TEXT NOT NULL,
|
||||
method_version INTEGER NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
PRIMARY KEY (handshake_id, method_id, method_version),
|
||||
FOREIGN KEY (handshake_id)
|
||||
REFERENCES handshakes(id),
|
||||
FOREIGN KEY (method_id, method_version)
|
||||
REFERENCES crack_method_versions(method_id, version),
|
||||
FOREIGN KEY (job_id)
|
||||
REFERENCES crack_jobs(id),
|
||||
CHECK (status = 'COMPLETED')
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_results (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL,
|
||||
imported_at TEXT NOT NULL,
|
||||
FOREIGN KEY (job_id)
|
||||
REFERENCES crack_jobs(id),
|
||||
CHECK (
|
||||
status IN (
|
||||
'COMPLETED',
|
||||
'PARTIAL',
|
||||
'FAILED',
|
||||
'CANCELLED'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
job_id TEXT,
|
||||
client_id TEXT,
|
||||
handshake_id INTEGER,
|
||||
method_id TEXT,
|
||||
method_version INTEGER,
|
||||
details_json TEXT,
|
||||
FOREIGN KEY (job_id)
|
||||
REFERENCES crack_jobs(id),
|
||||
FOREIGN KEY (client_id)
|
||||
REFERENCES crack_clients(id),
|
||||
FOREIGN KEY (handshake_id)
|
||||
REFERENCES handshakes(id),
|
||||
FOREIGN KEY (method_id, method_version)
|
||||
REFERENCES crack_method_versions(method_id, version)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_crack_reservations_active
|
||||
ON crack_reservations(handshake_id, method_id, method_version)
|
||||
WHERE status = 'ACTIVE';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_method_versions_method
|
||||
ON crack_method_versions(method_id, version);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_jobs_status
|
||||
ON crack_jobs(status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_jobs_method_status
|
||||
ON crack_jobs(method_id, method_version, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_jobs_client_status
|
||||
ON crack_jobs(client_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_job_handshakes_handshake
|
||||
ON crack_job_handshakes(handshake_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_job_handshakes_job
|
||||
ON crack_job_handshakes(job_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_job_steps_status
|
||||
ON crack_job_steps(job_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_reservations_handshake
|
||||
ON crack_reservations(handshake_id, method_id, method_version);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_reservations_job
|
||||
ON crack_reservations(job_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_reservations_client
|
||||
ON crack_reservations(client_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_attempts_method
|
||||
ON crack_attempts(method_id, method_version, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_attempts_job
|
||||
ON crack_attempts(job_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_attempts_handshake
|
||||
ON crack_attempts(handshake_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_audit_job
|
||||
ON crack_audit_log(job_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_audit_client
|
||||
ON crack_audit_log(client_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_audit_handshake
|
||||
ON crack_audit_log(handshake_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_audit_event
|
||||
ON crack_audit_log(event_type, created_at);
|
||||
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class AttemptsRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def create_attempt(
|
||||
self,
|
||||
handshake_id: int,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
job_id: str,
|
||||
completed_at: str | None = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_attempts (
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
status,
|
||||
completed_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
"COMPLETED",
|
||||
completed_at or self._now(),
|
||||
),
|
||||
)
|
||||
|
||||
def get_attempt(
|
||||
self,
|
||||
handshake_id: int,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
status,
|
||||
completed_at
|
||||
FROM crack_attempts
|
||||
WHERE handshake_id = ?
|
||||
AND method_id = ?
|
||||
AND method_version = ?
|
||||
""",
|
||||
(
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
def has_completed(
|
||||
self,
|
||||
handshake_id: int,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
) -> bool:
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM crack_attempts
|
||||
WHERE handshake_id = ?
|
||||
AND method_id = ?
|
||||
AND method_version = ?
|
||||
AND status = 'COMPLETED'
|
||||
""",
|
||||
(
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
return row is not None
|
||||
|
||||
def list_handshake_attempts(
|
||||
self,
|
||||
handshake_id: int,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
status,
|
||||
completed_at
|
||||
FROM crack_attempts
|
||||
WHERE handshake_id = ?
|
||||
ORDER BY method_id, method_version
|
||||
""",
|
||||
(handshake_id,),
|
||||
).fetchall()
|
||||
|
||||
def list_job_attempts(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
status,
|
||||
completed_at
|
||||
FROM crack_attempts
|
||||
WHERE job_id = ?
|
||||
ORDER BY handshake_id, method_id, method_version
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
|
||||
def list_method_attempts(
|
||||
self,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
status,
|
||||
completed_at
|
||||
FROM crack_attempts
|
||||
WHERE method_id = ?
|
||||
AND method_version = ?
|
||||
ORDER BY handshake_id
|
||||
""",
|
||||
(
|
||||
method_id,
|
||||
method_version,
|
||||
),
|
||||
).fetchall()
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AuditRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def create_event(
|
||||
self,
|
||||
event_type: str,
|
||||
job_id: str | None = None,
|
||||
client_id: str | None = None,
|
||||
handshake_id: int | None = None,
|
||||
method_id: str | None = None,
|
||||
method_version: int | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
created_at: str | None = None,
|
||||
) -> int:
|
||||
cursor = self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_audit_log (
|
||||
created_at,
|
||||
event_type,
|
||||
job_id,
|
||||
client_id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
details_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
created_at or self._now(),
|
||||
event_type,
|
||||
job_id,
|
||||
client_id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
(
|
||||
json.dumps(
|
||||
details,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
if details is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def get_event(
|
||||
self,
|
||||
event_id: int,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
event_type,
|
||||
job_id,
|
||||
client_id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
details_json
|
||||
FROM crack_audit_log
|
||||
WHERE id = ?
|
||||
""",
|
||||
(event_id,),
|
||||
).fetchone()
|
||||
|
||||
def list_events(
|
||||
self,
|
||||
event_type: str | None = None,
|
||||
job_id: str | None = None,
|
||||
client_id: str | None = None,
|
||||
handshake_id: int | None = None,
|
||||
method_id: str | None = None,
|
||||
method_version: int | None = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
event_type,
|
||||
job_id,
|
||||
client_id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
details_json
|
||||
FROM crack_audit_log
|
||||
WHERE 1 = 1
|
||||
"""
|
||||
params: list[Any] = []
|
||||
|
||||
if event_type is not None:
|
||||
query += "\n AND event_type = ?"
|
||||
params.append(event_type)
|
||||
|
||||
if job_id is not None:
|
||||
query += "\n AND job_id = ?"
|
||||
params.append(job_id)
|
||||
|
||||
if client_id is not None:
|
||||
query += "\n AND client_id = ?"
|
||||
params.append(client_id)
|
||||
|
||||
if handshake_id is not None:
|
||||
query += "\n AND handshake_id = ?"
|
||||
params.append(handshake_id)
|
||||
|
||||
if method_id is not None:
|
||||
query += "\n AND method_id = ?"
|
||||
params.append(method_id)
|
||||
|
||||
if method_version is not None:
|
||||
query += "\n AND method_version = ?"
|
||||
params.append(method_version)
|
||||
|
||||
query += "\n ORDER BY created_at DESC, id DESC"
|
||||
|
||||
return self._conn.execute(query, params).fetchall()
|
||||
|
||||
@staticmethod
|
||||
def decode_details(row: sqlite3.Row) -> dict[str, Any] | None:
|
||||
if row["details_json"] is None:
|
||||
return None
|
||||
|
||||
return json.loads(row["details_json"])
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ClientsRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def create_client(
|
||||
self,
|
||||
client_id: str,
|
||||
name: str,
|
||||
token_hash: str | None = None,
|
||||
status: str = "ACTIVE",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
metadata_json = (
|
||||
json.dumps(
|
||||
metadata,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
if metadata is not None
|
||||
else None
|
||||
)
|
||||
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_clients (
|
||||
id,
|
||||
name,
|
||||
token_hash,
|
||||
status,
|
||||
created_at,
|
||||
last_seen_at,
|
||||
metadata_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
client_id,
|
||||
name,
|
||||
token_hash,
|
||||
status,
|
||||
self._now(),
|
||||
None,
|
||||
metadata_json,
|
||||
),
|
||||
)
|
||||
|
||||
def get_client(self, client_id: str) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
token_hash,
|
||||
status,
|
||||
created_at,
|
||||
last_seen_at,
|
||||
metadata_json
|
||||
FROM crack_clients
|
||||
WHERE id = ?
|
||||
""",
|
||||
(client_id,),
|
||||
).fetchone()
|
||||
|
||||
def list_clients(self) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
token_hash,
|
||||
status,
|
||||
created_at,
|
||||
last_seen_at,
|
||||
metadata_json
|
||||
FROM crack_clients
|
||||
ORDER BY id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
def update_last_seen(self, client_id: str) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE crack_clients
|
||||
SET last_seen_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
self._now(),
|
||||
client_id,
|
||||
),
|
||||
)
|
||||
|
||||
def update_status(self, client_id: str, status: str) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE crack_clients
|
||||
SET status = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
status,
|
||||
client_id,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class CredentialsRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def get_credential(
|
||||
self,
|
||||
access_point_id: int,
|
||||
handshake_id: int,
|
||||
password: str,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
source,
|
||||
created_at,
|
||||
verified
|
||||
FROM credentials
|
||||
WHERE access_point_id = ?
|
||||
AND handshake_id = ?
|
||||
AND password = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
def create_hashcat_credential(
|
||||
self,
|
||||
access_point_id: int,
|
||||
handshake_id: int,
|
||||
password: str,
|
||||
) -> int:
|
||||
cursor = self._conn.execute(
|
||||
"""
|
||||
INSERT INTO credentials (
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
source,
|
||||
created_at,
|
||||
verified
|
||||
)
|
||||
VALUES (?, ?, ?, 'hashcat', ?, 0)
|
||||
""",
|
||||
(
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
self._now(),
|
||||
),
|
||||
)
|
||||
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE access_points
|
||||
SET is_cracked = 1
|
||||
WHERE id = ?
|
||||
""",
|
||||
(access_point_id,),
|
||||
)
|
||||
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def mark_access_point_cracked(
|
||||
self,
|
||||
access_point_id: int,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE access_points
|
||||
SET is_cracked = 1
|
||||
WHERE id = ?
|
||||
""",
|
||||
(access_point_id,),
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
|
||||
class HandshakesRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def list_eligible(
|
||||
self,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
limit: int,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
h.id AS handshake_id,
|
||||
h.hash22000,
|
||||
h.access_point_id,
|
||||
h.captured_at
|
||||
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
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM crack_attempts AS a
|
||||
WHERE a.handshake_id = h.id
|
||||
AND a.method_id = ?
|
||||
AND a.method_version = ?
|
||||
AND a.status = 'COMPLETED'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM crack_reservations AS r
|
||||
WHERE r.handshake_id = h.id
|
||||
AND r.method_id = ?
|
||||
AND r.method_version = ?
|
||||
AND r.status = 'ACTIVE'
|
||||
)
|
||||
ORDER BY h.id
|
||||
LIMIT ?
|
||||
""",
|
||||
(
|
||||
method_id,
|
||||
method_version,
|
||||
method_id,
|
||||
method_version,
|
||||
limit,
|
||||
),
|
||||
).fetchall()
|
||||
@@ -0,0 +1,452 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class JobsRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def create_job(
|
||||
self,
|
||||
job_id: str,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
status: str,
|
||||
hash_file_name: str,
|
||||
hash_file_sha256: str,
|
||||
hash_count: int,
|
||||
step_count: int,
|
||||
client_id: str | None = None,
|
||||
) -> str:
|
||||
created_at = self._now()
|
||||
queued_at = created_at if status == "QUEUED" else None
|
||||
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_jobs (
|
||||
id,
|
||||
method_id,
|
||||
method_version,
|
||||
status,
|
||||
created_at,
|
||||
queued_at,
|
||||
started_at,
|
||||
completed_at,
|
||||
cancelled_at,
|
||||
imported_at,
|
||||
client_id,
|
||||
hash_file_name,
|
||||
hash_file_sha256,
|
||||
hash_count,
|
||||
step_count,
|
||||
result_sha256,
|
||||
error
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
method_id,
|
||||
method_version,
|
||||
status,
|
||||
created_at,
|
||||
queued_at,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
client_id,
|
||||
hash_file_name,
|
||||
hash_file_sha256,
|
||||
hash_count,
|
||||
step_count,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
return created_at
|
||||
|
||||
def get_job(self, job_id: str) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
method_id,
|
||||
method_version,
|
||||
status,
|
||||
created_at,
|
||||
queued_at,
|
||||
started_at,
|
||||
completed_at,
|
||||
cancelled_at,
|
||||
imported_at,
|
||||
client_id,
|
||||
hash_file_name,
|
||||
hash_file_sha256,
|
||||
hash_count,
|
||||
step_count,
|
||||
result_sha256,
|
||||
error
|
||||
FROM crack_jobs
|
||||
WHERE id = ?
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
|
||||
def list_jobs(
|
||||
self,
|
||||
status: str | None = None,
|
||||
method_id: str | None = None,
|
||||
client_id: str | None = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
method_id,
|
||||
method_version,
|
||||
status,
|
||||
created_at,
|
||||
queued_at,
|
||||
started_at,
|
||||
completed_at,
|
||||
cancelled_at,
|
||||
imported_at,
|
||||
client_id,
|
||||
hash_file_name,
|
||||
hash_file_sha256,
|
||||
hash_count,
|
||||
step_count,
|
||||
result_sha256,
|
||||
error
|
||||
FROM crack_jobs
|
||||
WHERE 1 = 1
|
||||
"""
|
||||
params: list[Any] = []
|
||||
|
||||
if status is not None:
|
||||
query += "\n AND status = ?"
|
||||
params.append(status)
|
||||
|
||||
if method_id is not None:
|
||||
query += "\n AND method_id = ?"
|
||||
params.append(method_id)
|
||||
|
||||
if client_id is not None:
|
||||
query += "\n AND client_id = ?"
|
||||
params.append(client_id)
|
||||
|
||||
query += "\n ORDER BY created_at DESC, id DESC"
|
||||
|
||||
return self._conn.execute(query, params).fetchall()
|
||||
|
||||
def update_job_status(
|
||||
self,
|
||||
job_id: str,
|
||||
status: str,
|
||||
) -> None:
|
||||
now = self._now()
|
||||
|
||||
timestamp_column = {
|
||||
"QUEUED": "queued_at",
|
||||
"RUNNING": "started_at",
|
||||
"COMPLETED": "completed_at",
|
||||
"CANCELLED": "cancelled_at",
|
||||
}.get(status)
|
||||
|
||||
if timestamp_column is None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE crack_jobs
|
||||
SET status = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
status,
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
self._conn.execute(
|
||||
f"""
|
||||
UPDATE crack_jobs
|
||||
SET status = ?,
|
||||
{timestamp_column} = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
status,
|
||||
now,
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
|
||||
def assign_client(
|
||||
self,
|
||||
job_id: str,
|
||||
client_id: str | None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE crack_jobs
|
||||
SET client_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
client_id,
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
|
||||
def update_job_result(
|
||||
self,
|
||||
job_id: str,
|
||||
result_sha256: str | None = None,
|
||||
error: str | None = None,
|
||||
imported_at: str | None = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE crack_jobs
|
||||
SET result_sha256 = ?,
|
||||
error = ?,
|
||||
imported_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
result_sha256,
|
||||
error,
|
||||
imported_at,
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
|
||||
def add_job_handshake(
|
||||
self,
|
||||
job_id: str,
|
||||
handshake_id: int,
|
||||
hash22000: str,
|
||||
access_point_id: int | None = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_job_handshakes (
|
||||
job_id,
|
||||
handshake_id,
|
||||
hash22000,
|
||||
access_point_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
handshake_id,
|
||||
hash22000,
|
||||
access_point_id,
|
||||
),
|
||||
)
|
||||
|
||||
def get_job_handshake(
|
||||
self,
|
||||
job_id: str,
|
||||
handshake_id: int,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
job_id,
|
||||
handshake_id,
|
||||
hash22000,
|
||||
access_point_id
|
||||
FROM crack_job_handshakes
|
||||
WHERE job_id = ?
|
||||
AND handshake_id = ?
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
handshake_id,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
def list_job_handshakes(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
job_id,
|
||||
handshake_id,
|
||||
hash22000,
|
||||
access_point_id
|
||||
FROM crack_job_handshakes
|
||||
WHERE job_id = ?
|
||||
ORDER BY handshake_id
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
|
||||
def create_job_step(
|
||||
self,
|
||||
job_id: str,
|
||||
step_no: int,
|
||||
step_id: str,
|
||||
status: str,
|
||||
session_name: str,
|
||||
definition: dict[str, Any],
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_job_steps (
|
||||
job_id,
|
||||
step_no,
|
||||
step_id,
|
||||
status,
|
||||
session_name,
|
||||
definition_json,
|
||||
started_at,
|
||||
completed_at,
|
||||
exit_code,
|
||||
restore_seen,
|
||||
error
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
step_no,
|
||||
step_id,
|
||||
status,
|
||||
session_name,
|
||||
json.dumps(
|
||||
definition,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
def get_job_step(
|
||||
self,
|
||||
job_id: str,
|
||||
step_no: int,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
job_id,
|
||||
step_no,
|
||||
step_id,
|
||||
status,
|
||||
session_name,
|
||||
definition_json,
|
||||
started_at,
|
||||
completed_at,
|
||||
exit_code,
|
||||
restore_seen,
|
||||
error
|
||||
FROM crack_job_steps
|
||||
WHERE job_id = ?
|
||||
AND step_no = ?
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
step_no,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
def list_job_steps(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
job_id,
|
||||
step_no,
|
||||
step_id,
|
||||
status,
|
||||
session_name,
|
||||
definition_json,
|
||||
started_at,
|
||||
completed_at,
|
||||
exit_code,
|
||||
restore_seen,
|
||||
error
|
||||
FROM crack_job_steps
|
||||
WHERE job_id = ?
|
||||
ORDER BY step_no
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
|
||||
def update_job_step(
|
||||
self,
|
||||
job_id: str,
|
||||
step_no: int,
|
||||
status: str | None = None,
|
||||
started_at: str | None = None,
|
||||
completed_at: str | None = None,
|
||||
exit_code: int | None = None,
|
||||
restore_seen: bool | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
fields: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if status is not None:
|
||||
fields.append("status = ?")
|
||||
params.append(status)
|
||||
|
||||
if started_at is not None:
|
||||
fields.append("started_at = ?")
|
||||
params.append(started_at)
|
||||
|
||||
if completed_at is not None:
|
||||
fields.append("completed_at = ?")
|
||||
params.append(completed_at)
|
||||
|
||||
if exit_code is not None:
|
||||
fields.append("exit_code = ?")
|
||||
params.append(exit_code)
|
||||
|
||||
if restore_seen is not None:
|
||||
fields.append("restore_seen = ?")
|
||||
params.append(int(restore_seen))
|
||||
|
||||
if error is not None:
|
||||
fields.append("error = ?")
|
||||
params.append(error)
|
||||
|
||||
if not fields:
|
||||
return
|
||||
|
||||
params.extend((job_id, step_no))
|
||||
|
||||
self._conn.execute(
|
||||
f"""
|
||||
UPDATE crack_job_steps
|
||||
SET {", ".join(fields)}
|
||||
WHERE job_id = ?
|
||||
AND step_no = ?
|
||||
""",
|
||||
params,
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class MethodsRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def create_method(
|
||||
self,
|
||||
method_id: str,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_methods (
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
method_id,
|
||||
name,
|
||||
description,
|
||||
self._now(),
|
||||
),
|
||||
)
|
||||
|
||||
def get_method(self, method_id: str) -> sqlite3.Row | None:
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
created_at
|
||||
FROM crack_methods
|
||||
WHERE id = ?
|
||||
""",
|
||||
(method_id,),
|
||||
).fetchone()
|
||||
|
||||
return row
|
||||
|
||||
def list_methods(self) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
created_at
|
||||
FROM crack_methods
|
||||
ORDER BY id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
def create_method_version(
|
||||
self,
|
||||
method_id: str,
|
||||
version: int,
|
||||
definition: dict[str, Any],
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_method_versions (
|
||||
method_id,
|
||||
version,
|
||||
definition_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
method_id,
|
||||
version,
|
||||
json.dumps(
|
||||
definition,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
),
|
||||
self._now(),
|
||||
),
|
||||
)
|
||||
|
||||
def get_method_version(
|
||||
self,
|
||||
method_id: str,
|
||||
version: int,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
method_id,
|
||||
version,
|
||||
definition_json,
|
||||
created_at
|
||||
FROM crack_method_versions
|
||||
WHERE method_id = ?
|
||||
AND version = ?
|
||||
""",
|
||||
(method_id, version),
|
||||
).fetchone()
|
||||
|
||||
def list_method_versions(
|
||||
self,
|
||||
method_id: str,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
method_id,
|
||||
version,
|
||||
definition_json,
|
||||
created_at
|
||||
FROM crack_method_versions
|
||||
WHERE method_id = ?
|
||||
ORDER BY version
|
||||
""",
|
||||
(method_id,),
|
||||
).fetchall()
|
||||
|
||||
def get_next_method_version(self, method_id: str) -> int:
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
SELECT COALESCE(MAX(version), 0) + 1
|
||||
FROM crack_method_versions
|
||||
WHERE method_id = ?
|
||||
""",
|
||||
(method_id,),
|
||||
).fetchone()
|
||||
|
||||
return int(row[0])
|
||||
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class ReservationsRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def create_reservation(
|
||||
self,
|
||||
reservation_id: str,
|
||||
handshake_id: int,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
job_id: str,
|
||||
client_id: str | None = None,
|
||||
expires_at: str | None = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_reservations (
|
||||
id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
client_id,
|
||||
status,
|
||||
created_at,
|
||||
expires_at,
|
||||
released_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
reservation_id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
client_id,
|
||||
"ACTIVE",
|
||||
self._now(),
|
||||
expires_at,
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
def get_reservation(
|
||||
self,
|
||||
reservation_id: str,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
client_id,
|
||||
status,
|
||||
created_at,
|
||||
expires_at,
|
||||
released_at
|
||||
FROM crack_reservations
|
||||
WHERE id = ?
|
||||
""",
|
||||
(reservation_id,),
|
||||
).fetchone()
|
||||
|
||||
def get_active_reservation(
|
||||
self,
|
||||
handshake_id: int,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
client_id,
|
||||
status,
|
||||
created_at,
|
||||
expires_at,
|
||||
released_at
|
||||
FROM crack_reservations
|
||||
WHERE handshake_id = ?
|
||||
AND method_id = ?
|
||||
AND method_version = ?
|
||||
AND status = 'ACTIVE'
|
||||
""",
|
||||
(
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
def list_job_reservations(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
client_id,
|
||||
status,
|
||||
created_at,
|
||||
expires_at,
|
||||
released_at
|
||||
FROM crack_reservations
|
||||
WHERE job_id = ?
|
||||
ORDER BY created_at, id
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
|
||||
def list_client_reservations(
|
||||
self,
|
||||
client_id: str,
|
||||
status: str | None = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
client_id,
|
||||
status,
|
||||
created_at,
|
||||
expires_at,
|
||||
released_at
|
||||
FROM crack_reservations
|
||||
WHERE client_id = ?
|
||||
"""
|
||||
params: list[object] = [client_id]
|
||||
|
||||
if status is not None:
|
||||
query += "\n AND status = ?"
|
||||
params.append(status)
|
||||
|
||||
query += "\n ORDER BY created_at, id"
|
||||
|
||||
return self._conn.execute(query, params).fetchall()
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
reservation_id: str,
|
||||
status: str,
|
||||
released_at: str | None = None,
|
||||
) -> None:
|
||||
if released_at is None and status in {
|
||||
"RELEASED",
|
||||
"EXPIRED",
|
||||
"CANCELLED",
|
||||
}:
|
||||
released_at = self._now()
|
||||
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE crack_reservations
|
||||
SET status = ?,
|
||||
released_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
status,
|
||||
released_at,
|
||||
reservation_id,
|
||||
),
|
||||
)
|
||||
|
||||
def update_expires_at(
|
||||
self,
|
||||
reservation_id: str,
|
||||
expires_at: str | None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE crack_reservations
|
||||
SET expires_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
expires_at,
|
||||
reservation_id,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ResultsRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def create_result(
|
||||
self,
|
||||
job_id: str,
|
||||
status: str,
|
||||
result: dict[str, Any],
|
||||
imported_at: str | None = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_results (
|
||||
job_id,
|
||||
status,
|
||||
result_json,
|
||||
imported_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
status,
|
||||
json.dumps(
|
||||
result,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
),
|
||||
imported_at or self._now(),
|
||||
),
|
||||
)
|
||||
|
||||
def get_result(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
job_id,
|
||||
status,
|
||||
result_json,
|
||||
imported_at
|
||||
FROM crack_results
|
||||
WHERE job_id = ?
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
|
||||
def list_results(
|
||||
self,
|
||||
status: str | None = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
query = """
|
||||
SELECT
|
||||
job_id,
|
||||
status,
|
||||
result_json,
|
||||
imported_at
|
||||
FROM crack_results
|
||||
WHERE 1 = 1
|
||||
"""
|
||||
params: list[Any] = []
|
||||
|
||||
if status is not None:
|
||||
query += "\n AND status = ?"
|
||||
params.append(status)
|
||||
|
||||
query += "\n ORDER BY imported_at DESC, job_id DESC"
|
||||
|
||||
return self._conn.execute(query, params).fetchall()
|
||||
|
||||
@staticmethod
|
||||
def decode_result(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return json.loads(row["result_json"])
|
||||
@@ -0,0 +1,12 @@
|
||||
from .job_issuance import JobIssuanceService, IssuedJob
|
||||
from .job_result import JobResultError, JobResultService
|
||||
from .methods import MethodService, MethodServiceError
|
||||
|
||||
__all__ = [
|
||||
"IssuedJob",
|
||||
"JobIssuanceService",
|
||||
"JobResultError",
|
||||
"JobResultService",
|
||||
"MethodService",
|
||||
"MethodServiceError",
|
||||
]
|
||||
@@ -0,0 +1,348 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import yaml
|
||||
|
||||
from cracklab.server.repositories.jobs import JobsRepository
|
||||
from cracklab.server.repositories.methods import MethodsRepository
|
||||
from cracklab.server.services.method_definition import (
|
||||
validate_method_definition,
|
||||
)
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
CONFIG_FILE = PROJECT_ROOT / "config" / "config.yaml"
|
||||
|
||||
|
||||
class JobExportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class JobExportService:
|
||||
def __init__(
|
||||
self,
|
||||
conn,
|
||||
timezone_name: str | None = None,
|
||||
) -> None:
|
||||
self._conn = conn
|
||||
self._jobs = JobsRepository(conn)
|
||||
self._methods = MethodsRepository(conn)
|
||||
self._timezone_name = (
|
||||
timezone_name
|
||||
if timezone_name is not None
|
||||
else self._load_timezone()
|
||||
)
|
||||
|
||||
try:
|
||||
self._timezone = ZoneInfo(self._timezone_name)
|
||||
except Exception as exc:
|
||||
raise JobExportError(
|
||||
f"Invalid timezone: {self._timezone_name}"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _load_timezone() -> str:
|
||||
if not CONFIG_FILE.is_file():
|
||||
raise JobExportError(
|
||||
f"Configuration file not found: {CONFIG_FILE}"
|
||||
)
|
||||
|
||||
try:
|
||||
with CONFIG_FILE.open(
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as fp:
|
||||
config = yaml.safe_load(fp) or {}
|
||||
except Exception as exc:
|
||||
raise JobExportError(
|
||||
f"Failed to load configuration: {CONFIG_FILE}"
|
||||
) from exc
|
||||
|
||||
timezone_name = config.get(
|
||||
"timezone",
|
||||
"Europe/Moscow",
|
||||
)
|
||||
|
||||
if not isinstance(timezone_name, str) or not timezone_name:
|
||||
raise JobExportError(
|
||||
"Configuration timezone must be a non-empty string"
|
||||
)
|
||||
|
||||
return timezone_name
|
||||
|
||||
@staticmethod
|
||||
def _parse_utc(text: str) -> datetime:
|
||||
try:
|
||||
value = datetime.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise JobExportError(
|
||||
f"Invalid job created_at: {text}"
|
||||
) from exc
|
||||
|
||||
if value.tzinfo is None:
|
||||
raise JobExportError(
|
||||
f"Job timestamp is not timezone-aware: {text}"
|
||||
)
|
||||
|
||||
value = value.astimezone(timezone.utc)
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _unique_hash_content(rows) -> tuple[str, int]:
|
||||
unique_hashes: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for row in rows:
|
||||
hash22000 = str(row["hash22000"]).strip()
|
||||
|
||||
if not hash22000:
|
||||
raise JobExportError(
|
||||
"Job contains an empty hash22000 value"
|
||||
)
|
||||
|
||||
if hash22000 in seen:
|
||||
continue
|
||||
|
||||
seen.add(hash22000)
|
||||
unique_hashes.append(hash22000)
|
||||
|
||||
content = "\n".join(unique_hashes) + "\n"
|
||||
|
||||
return content, len(unique_hashes)
|
||||
|
||||
@staticmethod
|
||||
def _sha256_text(content: str) -> str:
|
||||
return hashlib.sha256(
|
||||
content.encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _json_bytes(data: dict) -> bytes:
|
||||
return (
|
||||
json.dumps(
|
||||
data,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
def export_job(
|
||||
self,
|
||||
job_id: str,
|
||||
output_dir: str | Path,
|
||||
) -> Path:
|
||||
job = self._jobs.get_job(job_id)
|
||||
|
||||
if job is None:
|
||||
raise JobExportError(
|
||||
f"Job not found: {job_id}"
|
||||
)
|
||||
|
||||
method_version = self._methods.get_method_version(
|
||||
str(job["method_id"]),
|
||||
int(job["method_version"]),
|
||||
)
|
||||
|
||||
if method_version is None:
|
||||
raise JobExportError(
|
||||
"Method version not found: "
|
||||
f"{job['method_id']} v{job['method_version']}"
|
||||
)
|
||||
|
||||
try:
|
||||
method_definition = json.loads(
|
||||
method_version["definition_json"]
|
||||
)
|
||||
validate_method_definition(
|
||||
method_definition
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError, ValueError) as exc:
|
||||
raise JobExportError(
|
||||
"Invalid method version definition: "
|
||||
f"{job['method_id']} v{job['method_version']}"
|
||||
) from exc
|
||||
|
||||
hash_mode = int(
|
||||
method_definition["hash_mode"]
|
||||
)
|
||||
|
||||
handshake_rows = self._jobs.list_job_handshakes(
|
||||
job_id
|
||||
)
|
||||
step_rows = self._jobs.list_job_steps(
|
||||
job_id
|
||||
)
|
||||
|
||||
hash_content, hash_count = self._unique_hash_content(
|
||||
handshake_rows
|
||||
)
|
||||
|
||||
hash_sha256 = self._sha256_text(
|
||||
hash_content
|
||||
)
|
||||
|
||||
if hash_sha256 != job["hash_file_sha256"]:
|
||||
raise JobExportError(
|
||||
"Job hash snapshot SHA-256 mismatch: "
|
||||
f"expected {job['hash_file_sha256']}, "
|
||||
f"calculated {hash_sha256}"
|
||||
)
|
||||
|
||||
if hash_count != int(job["hash_count"]):
|
||||
raise JobExportError(
|
||||
"Job hash count mismatch: "
|
||||
f"expected {job['hash_count']}, "
|
||||
f"calculated {hash_count}"
|
||||
)
|
||||
|
||||
if len(handshake_rows) == 0:
|
||||
raise JobExportError(
|
||||
f"Job contains no handshake snapshot: {job_id}"
|
||||
)
|
||||
|
||||
if len(step_rows) != int(job["step_count"]):
|
||||
raise JobExportError(
|
||||
"Job step count mismatch: "
|
||||
f"expected {job['step_count']}, "
|
||||
f"calculated {len(step_rows)}"
|
||||
)
|
||||
|
||||
created_at = self._parse_utc(
|
||||
str(job["created_at"])
|
||||
)
|
||||
|
||||
local_created_at = created_at.astimezone(
|
||||
self._timezone
|
||||
)
|
||||
|
||||
zip_filename = (
|
||||
"JOB-"
|
||||
+ local_created_at.strftime(
|
||||
"%d-%m-%Y_%H%M%S"
|
||||
)
|
||||
+ "-"
|
||||
+ str(job["id"]).removeprefix("JOB-")
|
||||
+ ".zip"
|
||||
)
|
||||
|
||||
job_json = {
|
||||
"job_id": str(job["id"]),
|
||||
"created_at": str(job["created_at"]),
|
||||
"method_id": str(job["method_id"]),
|
||||
"method_version": int(job["method_version"]),
|
||||
"hash_mode": hash_mode,
|
||||
"hash_file_name": str(job["hash_file_name"]),
|
||||
"hash_file_sha256": str(job["hash_file_sha256"]),
|
||||
"hash_count": int(job["hash_count"]),
|
||||
"handshake_count": len(handshake_rows),
|
||||
"step_count": int(job["step_count"]),
|
||||
"handshakes": [
|
||||
{
|
||||
"handshake_id": int(row["handshake_id"]),
|
||||
"hash22000": str(row["hash22000"]),
|
||||
"access_point_id": (
|
||||
int(row["access_point_id"])
|
||||
if row["access_point_id"] is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
for row in handshake_rows
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"step_no": int(row["step_no"]),
|
||||
"step_id": str(row["step_id"]),
|
||||
"session_name": str(row["session_name"]),
|
||||
"definition": json.loads(
|
||||
row["definition_json"]
|
||||
),
|
||||
}
|
||||
for row in step_rows
|
||||
],
|
||||
}
|
||||
|
||||
job_bytes = self._json_bytes(job_json)
|
||||
hash_bytes = hash_content.encode("utf-8")
|
||||
|
||||
manifest_files = [
|
||||
{
|
||||
"path": "job.json",
|
||||
"size": len(job_bytes),
|
||||
"sha256": hashlib.sha256(
|
||||
job_bytes
|
||||
).hexdigest(),
|
||||
},
|
||||
{
|
||||
"path": f"hashes/{job['hash_file_name']}",
|
||||
"size": len(hash_bytes),
|
||||
"sha256": hashlib.sha256(
|
||||
hash_bytes
|
||||
).hexdigest(),
|
||||
},
|
||||
]
|
||||
|
||||
manifest = {
|
||||
"format_version": 1,
|
||||
"job_id": str(job["id"]),
|
||||
"created_at": str(job["created_at"]),
|
||||
"zip_filename": zip_filename,
|
||||
"files": manifest_files,
|
||||
}
|
||||
|
||||
manifest_bytes = self._json_bytes(
|
||||
manifest
|
||||
)
|
||||
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
final_path = output_path / zip_filename
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
suffix=".zip",
|
||||
dir=output_path,
|
||||
delete=False,
|
||||
) as tmp:
|
||||
temporary_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(
|
||||
temporary_path,
|
||||
mode="w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
) as archive:
|
||||
archive.writestr(
|
||||
"job.json",
|
||||
job_bytes,
|
||||
)
|
||||
archive.writestr(
|
||||
f"hashes/{job['hash_file_name']}",
|
||||
hash_bytes,
|
||||
)
|
||||
archive.writestr(
|
||||
"manifest.json",
|
||||
manifest_bytes,
|
||||
)
|
||||
|
||||
temporary_path.replace(final_path)
|
||||
|
||||
except Exception:
|
||||
temporary_path.unlink(
|
||||
missing_ok=True
|
||||
)
|
||||
raise
|
||||
|
||||
return final_path
|
||||
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from cracklab.server.repositories.audit import AuditRepository
|
||||
from cracklab.server.repositories.handshakes import HandshakesRepository
|
||||
from cracklab.server.repositories.jobs import JobsRepository
|
||||
from cracklab.server.repositories.methods import MethodsRepository
|
||||
from cracklab.server.repositories.reservations import ReservationsRepository
|
||||
from cracklab.server.services.method_definition import (
|
||||
MethodDefinitionError,
|
||||
validate_method_definition,
|
||||
)
|
||||
|
||||
|
||||
class JobIssuanceError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssuedJob:
|
||||
job_id: str
|
||||
method_id: str
|
||||
method_version: int
|
||||
status: str
|
||||
created_at: str
|
||||
hash_file_name: str
|
||||
hash_file_sha256: str
|
||||
hash_count: int
|
||||
handshake_count: int
|
||||
step_count: int
|
||||
hash_content: str
|
||||
handshake_ids: tuple[int, ...]
|
||||
steps: tuple[dict[str, Any], ...]
|
||||
|
||||
|
||||
class JobIssuanceService:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
self._methods = MethodsRepository(conn)
|
||||
self._handshakes = HandshakesRepository(conn)
|
||||
self._jobs = JobsRepository(conn)
|
||||
self._reservations = ReservationsRepository(conn)
|
||||
self._audit = AuditRepository(conn)
|
||||
|
||||
def issue_job(
|
||||
self,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
limit: int,
|
||||
client_id: str | None = None,
|
||||
) -> IssuedJob | None:
|
||||
if limit <= 0:
|
||||
raise ValueError("limit must be greater than zero")
|
||||
|
||||
method_version_row = self._methods.get_method_version(
|
||||
method_id,
|
||||
method_version,
|
||||
)
|
||||
if method_version_row is None:
|
||||
raise JobIssuanceError(
|
||||
f"Method version not found: {method_id} v{method_version}"
|
||||
)
|
||||
|
||||
definition = self._decode_method_definition(
|
||||
method_version_row["definition_json"],
|
||||
)
|
||||
steps = self._build_steps(
|
||||
method_id,
|
||||
method_version,
|
||||
definition,
|
||||
)
|
||||
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
|
||||
try:
|
||||
handshake_rows = self._handshakes.list_eligible(
|
||||
method_id,
|
||||
method_version,
|
||||
limit,
|
||||
)
|
||||
|
||||
if not handshake_rows:
|
||||
self._conn.rollback()
|
||||
return None
|
||||
|
||||
job_id = f"JOB-{uuid.uuid4().hex[:12].upper()}"
|
||||
hash_file_name = f"{job_id}.22000"
|
||||
|
||||
handshake_ids = tuple(
|
||||
int(row["handshake_id"])
|
||||
for row in handshake_rows
|
||||
)
|
||||
|
||||
unique_hashes: list[str] = []
|
||||
seen_hashes: set[str] = set()
|
||||
|
||||
for row in handshake_rows:
|
||||
hash22000 = str(row["hash22000"]).strip()
|
||||
|
||||
if hash22000 not in seen_hashes:
|
||||
seen_hashes.add(hash22000)
|
||||
unique_hashes.append(hash22000)
|
||||
|
||||
hash_content = "\n".join(unique_hashes) + "\n"
|
||||
hash_file_sha256 = hashlib.sha256(
|
||||
hash_content.encode("utf-8"),
|
||||
).hexdigest()
|
||||
|
||||
created_at = self._jobs.create_job(
|
||||
job_id=job_id,
|
||||
method_id=method_id,
|
||||
method_version=method_version,
|
||||
status="QUEUED",
|
||||
hash_file_name=hash_file_name,
|
||||
hash_file_sha256=hash_file_sha256,
|
||||
hash_count=len(unique_hashes),
|
||||
step_count=len(steps),
|
||||
client_id=client_id,
|
||||
)
|
||||
|
||||
for row in handshake_rows:
|
||||
self._jobs.add_job_handshake(
|
||||
job_id=job_id,
|
||||
handshake_id=int(row["handshake_id"]),
|
||||
hash22000=str(row["hash22000"]).strip(),
|
||||
access_point_id=(
|
||||
int(row["access_point_id"])
|
||||
if row["access_point_id"] is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
self._reservations.create_reservation(
|
||||
reservation_id=uuid.uuid4().hex,
|
||||
handshake_id=int(row["handshake_id"]),
|
||||
method_id=method_id,
|
||||
method_version=method_version,
|
||||
job_id=job_id,
|
||||
client_id=client_id,
|
||||
)
|
||||
|
||||
for step_no, step in enumerate(steps, start=1):
|
||||
self._jobs.create_job_step(
|
||||
job_id=job_id,
|
||||
step_no=step_no,
|
||||
step_id=step["step_id"],
|
||||
status="PENDING",
|
||||
session_name=self._session_name(
|
||||
job_id,
|
||||
step["step_id"],
|
||||
),
|
||||
definition=step,
|
||||
)
|
||||
|
||||
self._audit.create_event(
|
||||
event_type="JOB_ISSUED",
|
||||
job_id=job_id,
|
||||
client_id=client_id,
|
||||
method_id=method_id,
|
||||
method_version=method_version,
|
||||
details={
|
||||
"handshake_count": len(handshake_rows),
|
||||
"hash_count": len(unique_hashes),
|
||||
"step_count": len(steps),
|
||||
"hash_file_name": hash_file_name,
|
||||
},
|
||||
)
|
||||
|
||||
self._conn.commit()
|
||||
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
|
||||
return IssuedJob(
|
||||
job_id=job_id,
|
||||
method_id=method_id,
|
||||
method_version=method_version,
|
||||
status="QUEUED",
|
||||
created_at=created_at,
|
||||
hash_file_name=hash_file_name,
|
||||
hash_file_sha256=hash_file_sha256,
|
||||
hash_count=len(unique_hashes),
|
||||
handshake_count=len(handshake_rows),
|
||||
step_count=len(steps),
|
||||
hash_content=hash_content,
|
||||
handshake_ids=handshake_ids,
|
||||
steps=tuple(steps),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _decode_method_definition(
|
||||
definition_json: str,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
definition = json.loads(definition_json)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise JobIssuanceError(
|
||||
"Method version contains invalid definition JSON"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
return validate_method_definition(definition)
|
||||
except MethodDefinitionError as exc:
|
||||
raise JobIssuanceError(str(exc)) from exc
|
||||
|
||||
@staticmethod
|
||||
def _build_steps(
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
definition: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
del method_id
|
||||
del method_version
|
||||
|
||||
source_steps = definition["steps"]
|
||||
result: list[dict[str, Any]] = []
|
||||
seen_step_ids: set[str] = set()
|
||||
|
||||
for step in source_steps:
|
||||
if not isinstance(step, dict):
|
||||
raise JobIssuanceError(
|
||||
"Each method step must be a JSON object"
|
||||
)
|
||||
|
||||
step_id = step.get("step_id")
|
||||
|
||||
if not isinstance(step_id, str) or not step_id:
|
||||
raise JobIssuanceError(
|
||||
"Each method step must have a non-empty step_id"
|
||||
)
|
||||
|
||||
if step_id in seen_step_ids:
|
||||
raise JobIssuanceError(
|
||||
f"Duplicate step_id: {step_id}"
|
||||
)
|
||||
|
||||
seen_step_ids.add(step_id)
|
||||
|
||||
attack_mode = step.get("attack_mode")
|
||||
|
||||
if not isinstance(attack_mode, int) or attack_mode < 0:
|
||||
raise JobIssuanceError(
|
||||
f"Invalid attack_mode for step: {step_id}"
|
||||
)
|
||||
|
||||
result.append(dict(step))
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _session_name(
|
||||
job_id: str,
|
||||
step_id: str,
|
||||
) -> str:
|
||||
return f"cl_{job_id[:12]}_{step_id}"
|
||||
@@ -0,0 +1,740 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cracklab.server.repositories.attempts import AttemptsRepository
|
||||
from cracklab.server.repositories.audit import AuditRepository
|
||||
from cracklab.server.repositories.credentials import CredentialsRepository
|
||||
from cracklab.server.repositories.jobs import JobsRepository
|
||||
from cracklab.server.repositories.reservations import ReservationsRepository
|
||||
from cracklab.server.repositories.results import ResultsRepository
|
||||
|
||||
|
||||
class JobResultError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class JobResultService:
|
||||
def __init__(self, conn) -> None:
|
||||
self._conn = conn
|
||||
self._attempts = AttemptsRepository(conn)
|
||||
self._audit = AuditRepository(conn)
|
||||
self._credentials = CredentialsRepository(conn)
|
||||
self._jobs = JobsRepository(conn)
|
||||
self._reservations = ReservationsRepository(conn)
|
||||
self._results = ResultsRepository(conn)
|
||||
|
||||
def import_result(self, package_path: Path | str) -> dict[str, Any]:
|
||||
package = Path(package_path)
|
||||
|
||||
if not package.is_file():
|
||||
raise JobResultError(f"Result package not found: {package}")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="cracklab-result-") as tmp:
|
||||
root = Path(tmp)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(package, "r") as zf:
|
||||
self._safe_extract(zf, root)
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise JobResultError(
|
||||
f"Invalid result package: {package}"
|
||||
) from exc
|
||||
|
||||
result_path, show_path = self._find_result_files(root)
|
||||
result = self._read_json(result_path)
|
||||
|
||||
self._validate_result_sha256(result)
|
||||
|
||||
job_id = result.get("job_id")
|
||||
if not isinstance(job_id, str) or not job_id:
|
||||
raise JobResultError("Result is missing job_id")
|
||||
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
|
||||
try:
|
||||
job = self._jobs.get_job(job_id)
|
||||
|
||||
if job is None:
|
||||
raise JobResultError(f"Unknown Job: {job_id}")
|
||||
|
||||
idempotent = self._check_idempotency(job, result)
|
||||
|
||||
if idempotent:
|
||||
self._conn.rollback()
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"status": "ALREADY_IMPORTED",
|
||||
"result_sha256": result["result_sha256"],
|
||||
}
|
||||
|
||||
self._validate_job_contract(job, result)
|
||||
|
||||
job_handshakes = self._load_job_handshakes(job_id)
|
||||
|
||||
unique_hashes = {
|
||||
row["hash22000"]
|
||||
for row in job_handshakes
|
||||
}
|
||||
|
||||
if len(unique_hashes) != job["hash_count"]:
|
||||
raise JobResultError(
|
||||
"Job hash_count does not match unique "
|
||||
"crack_job_handshakes hash22000 values"
|
||||
)
|
||||
|
||||
found = self._parse_show_file(
|
||||
show_path,
|
||||
job_handshakes,
|
||||
)
|
||||
|
||||
credential_stats = self._import_credentials(
|
||||
found,
|
||||
job_handshakes,
|
||||
)
|
||||
|
||||
attempt_count = self._register_attempts(
|
||||
job,
|
||||
result,
|
||||
job_handshakes,
|
||||
)
|
||||
|
||||
self._update_steps(job_id, result["steps"])
|
||||
|
||||
released = self._release_reservations(job_id)
|
||||
|
||||
imported_at = self._now()
|
||||
|
||||
self._jobs.update_job_status(
|
||||
job_id,
|
||||
"COMPLETED",
|
||||
)
|
||||
|
||||
self._jobs.update_job_result(
|
||||
job_id,
|
||||
result_sha256=result["result_sha256"],
|
||||
imported_at=imported_at,
|
||||
)
|
||||
|
||||
self._results.create_result(
|
||||
job_id,
|
||||
"COMPLETED",
|
||||
result,
|
||||
imported_at=imported_at,
|
||||
)
|
||||
|
||||
self._audit.create_event(
|
||||
event_type="JOB_RESULT_IMPORTED",
|
||||
job_id=job_id,
|
||||
client_id=job["client_id"],
|
||||
method_id=job["method_id"],
|
||||
method_version=job["method_version"],
|
||||
details={
|
||||
"result_sha256": result["result_sha256"],
|
||||
"credentials_imported": credential_stats["imported"],
|
||||
"credentials_already_present": (
|
||||
credential_stats["already_present"]
|
||||
),
|
||||
"attempts_completed": attempt_count,
|
||||
"reservations_released": released,
|
||||
},
|
||||
)
|
||||
|
||||
self._conn.commit()
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"status": "COMPLETED",
|
||||
"result_sha256": result["result_sha256"],
|
||||
"credentials_imported": credential_stats["imported"],
|
||||
"credentials_already_present": (
|
||||
credential_stats["already_present"]
|
||||
),
|
||||
"attempts_completed": attempt_count,
|
||||
"reservations_released": released,
|
||||
}
|
||||
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat(
|
||||
timespec="milliseconds"
|
||||
).replace("+00:00", "Z")
|
||||
|
||||
@staticmethod
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise JobResultError(
|
||||
f"Invalid result.json: {path}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(value, dict):
|
||||
raise JobResultError("result.json must contain a JSON object")
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _safe_extract(
|
||||
zf: zipfile.ZipFile,
|
||||
destination: Path,
|
||||
) -> None:
|
||||
destination = destination.resolve()
|
||||
|
||||
for member in zf.infolist():
|
||||
target = (destination / member.filename).resolve()
|
||||
|
||||
try:
|
||||
target.relative_to(destination)
|
||||
except ValueError as exc:
|
||||
raise JobResultError(
|
||||
f"Unsafe path in result package: {member.filename}"
|
||||
) from exc
|
||||
|
||||
if member.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with zf.open(member) as src, target.open("wb") as dst:
|
||||
while True:
|
||||
chunk = src.read(1024 * 1024)
|
||||
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
dst.write(chunk)
|
||||
|
||||
@staticmethod
|
||||
def _find_result_files(
|
||||
root: Path,
|
||||
) -> tuple[Path, Path]:
|
||||
manifests = list(root.rglob("result.json"))
|
||||
|
||||
if len(manifests) != 1:
|
||||
raise JobResultError(
|
||||
"Result package must contain exactly one result.json"
|
||||
)
|
||||
|
||||
show_files = list(root.rglob("hashcat-show.txt"))
|
||||
|
||||
if len(show_files) != 1:
|
||||
raise JobResultError(
|
||||
"Result package must contain exactly one hashcat-show.txt"
|
||||
)
|
||||
|
||||
return manifests[0], show_files[0]
|
||||
|
||||
@staticmethod
|
||||
def _validate_result_sha256(result: dict[str, Any]) -> None:
|
||||
expected = result.get("result_sha256")
|
||||
|
||||
if not isinstance(expected, str) or not expected:
|
||||
raise JobResultError(
|
||||
"Result is missing result_sha256"
|
||||
)
|
||||
|
||||
payload = dict(result)
|
||||
payload.pop("result_sha256", None)
|
||||
|
||||
payload_bytes = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
).encode("utf-8")
|
||||
|
||||
actual = hashlib.sha256(payload_bytes).hexdigest()
|
||||
|
||||
if actual != expected:
|
||||
raise JobResultError(
|
||||
"Invalid result_sha256: "
|
||||
f"expected {expected}, calculated {actual}"
|
||||
)
|
||||
|
||||
def _check_idempotency(
|
||||
self,
|
||||
job,
|
||||
result: dict[str, Any],
|
||||
) -> bool:
|
||||
result_sha256 = result["result_sha256"]
|
||||
|
||||
stored_job_sha = job["result_sha256"]
|
||||
stored_result = self._results.get_result(job["id"])
|
||||
|
||||
if stored_job_sha is not None:
|
||||
if stored_job_sha != result_sha256:
|
||||
raise JobResultError(
|
||||
f"Job {job['id']} already has a different result_sha256"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
if stored_result is not None:
|
||||
stored_payload = self._results.decode_result(stored_result)
|
||||
stored_sha = stored_payload.get("result_sha256")
|
||||
|
||||
if stored_sha != result_sha256:
|
||||
raise JobResultError(
|
||||
f"Job {job['id']} already has a different imported result"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
if job["imported_at"] is not None:
|
||||
raise JobResultError(
|
||||
f"Job {job['id']} is marked imported without a stored result"
|
||||
)
|
||||
|
||||
if job["status"] == "COMPLETED":
|
||||
raise JobResultError(
|
||||
f"Job {job['id']} is already COMPLETED without an imported result"
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def _validate_job_contract(
|
||||
self,
|
||||
job,
|
||||
result: dict[str, Any],
|
||||
) -> None:
|
||||
if result.get("schema_version") != 1:
|
||||
raise JobResultError(
|
||||
"Unsupported result schema_version: "
|
||||
f"{result.get('schema_version')!r}"
|
||||
)
|
||||
|
||||
if result.get("job_id") != job["id"]:
|
||||
raise JobResultError(
|
||||
"Result Job ID does not match the database Job"
|
||||
)
|
||||
|
||||
if result.get("status") != "COMPLETED":
|
||||
raise JobResultError(
|
||||
"Only COMPLETED results can be imported"
|
||||
)
|
||||
|
||||
if result.get("hash_file_sha256") != job["hash_file_sha256"]:
|
||||
raise JobResultError(
|
||||
"Result hash_file_sha256 does not match the Job"
|
||||
)
|
||||
|
||||
if result.get("hash_count") != job["hash_count"]:
|
||||
raise JobResultError(
|
||||
"Result hash_count does not match the Job"
|
||||
)
|
||||
|
||||
method = result.get("method")
|
||||
|
||||
if not isinstance(method, dict):
|
||||
raise JobResultError(
|
||||
"Result is missing method object"
|
||||
)
|
||||
|
||||
if method.get("id") != job["method_id"]:
|
||||
raise JobResultError(
|
||||
"Result method id does not match the Job"
|
||||
)
|
||||
|
||||
if method.get("version") != job["method_version"]:
|
||||
raise JobResultError(
|
||||
"Result method version does not match the Job"
|
||||
)
|
||||
|
||||
completed_at = result.get("completed_at")
|
||||
|
||||
if not isinstance(completed_at, str) or not completed_at:
|
||||
raise JobResultError(
|
||||
"Result is missing completed_at"
|
||||
)
|
||||
|
||||
result_steps = result.get("steps")
|
||||
|
||||
if not isinstance(result_steps, list):
|
||||
raise JobResultError(
|
||||
"Result is missing steps list"
|
||||
)
|
||||
|
||||
if len(result_steps) != job["step_count"]:
|
||||
raise JobResultError(
|
||||
"Result step count does not match the Job"
|
||||
)
|
||||
|
||||
db_steps = self._jobs.list_job_steps(job["id"])
|
||||
|
||||
if len(db_steps) != job["step_count"]:
|
||||
raise JobResultError(
|
||||
"Database Job step count does not match crack_job_steps"
|
||||
)
|
||||
|
||||
db_by_no = {
|
||||
row["step_no"]: row
|
||||
for row in db_steps
|
||||
}
|
||||
|
||||
seen_step_numbers: set[int] = set()
|
||||
|
||||
for step in result_steps:
|
||||
if not isinstance(step, dict):
|
||||
raise JobResultError(
|
||||
"Each result step must be an object"
|
||||
)
|
||||
|
||||
step_no = step.get("step_no")
|
||||
|
||||
if not isinstance(step_no, int):
|
||||
raise JobResultError(
|
||||
f"Invalid result step_no: {step_no!r}"
|
||||
)
|
||||
|
||||
if step_no in seen_step_numbers:
|
||||
raise JobResultError(
|
||||
f"Duplicate result step_no: {step_no}"
|
||||
)
|
||||
|
||||
seen_step_numbers.add(step_no)
|
||||
|
||||
db_step = db_by_no.get(step_no)
|
||||
|
||||
if db_step is None:
|
||||
raise JobResultError(
|
||||
f"Result contains unknown step_no: {step_no}"
|
||||
)
|
||||
|
||||
if step.get("step_id") != db_step["step_id"]:
|
||||
raise JobResultError(
|
||||
f"Step {step_no}: step_id does not match the Job"
|
||||
)
|
||||
|
||||
if step.get("session") != db_step["session_name"]:
|
||||
raise JobResultError(
|
||||
f"Step {step_no}: session does not match the Job"
|
||||
)
|
||||
|
||||
if step.get("status") != "COMPLETED":
|
||||
raise JobResultError(
|
||||
f"Step {step_no}: result status must be COMPLETED"
|
||||
)
|
||||
|
||||
def _load_job_handshakes(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = self._jobs.list_job_handshakes(job_id)
|
||||
|
||||
if not rows:
|
||||
raise JobResultError(
|
||||
f"Job {job_id} contains no handshakes"
|
||||
)
|
||||
|
||||
result = []
|
||||
|
||||
for row in rows:
|
||||
result.append(
|
||||
{
|
||||
"handshake_id": row["handshake_id"],
|
||||
"hash22000": row["hash22000"],
|
||||
"access_point_id": row["access_point_id"],
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _normalize_mac(value: str) -> str:
|
||||
normalized = value.strip().lower().replace(":", "").replace("-", "")
|
||||
|
||||
if len(normalized) != 12:
|
||||
raise JobResultError(
|
||||
f"Invalid MAC address: {value!r}"
|
||||
)
|
||||
|
||||
try:
|
||||
int(normalized, 16)
|
||||
except ValueError as exc:
|
||||
raise JobResultError(
|
||||
f"Invalid MAC address: {value!r}"
|
||||
) from exc
|
||||
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _hash_identity(
|
||||
cls,
|
||||
hash22000: str,
|
||||
) -> tuple[str, str, str]:
|
||||
parts = hash22000.split("*")
|
||||
|
||||
if len(parts) < 6 or parts[0] != "WPA":
|
||||
raise JobResultError(
|
||||
f"Unsupported hash22000 format: {hash22000}"
|
||||
)
|
||||
|
||||
if parts[1] not in {"01", "02"}:
|
||||
raise JobResultError(
|
||||
f"Unsupported WPA hash type: {parts[1]}"
|
||||
)
|
||||
|
||||
hash_value = parts[2].strip().lower()
|
||||
ap_mac = cls._normalize_mac(parts[3])
|
||||
client_mac = cls._normalize_mac(parts[4])
|
||||
|
||||
if not hash_value:
|
||||
raise JobResultError(
|
||||
f"Invalid hash22000 hash value: {hash22000}"
|
||||
)
|
||||
|
||||
return (
|
||||
hash_value,
|
||||
ap_mac,
|
||||
client_mac,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _parse_show_identity(
|
||||
cls,
|
||||
line: str,
|
||||
) -> tuple[str, str, str, str]:
|
||||
fields = line.split(":", 4)
|
||||
|
||||
if len(fields) != 5:
|
||||
raise JobResultError(
|
||||
f"Invalid Hashcat --show line: {line}"
|
||||
)
|
||||
|
||||
hash_value = fields[0].strip().lower()
|
||||
ap_mac = cls._normalize_mac(fields[1])
|
||||
client_mac = cls._normalize_mac(fields[2])
|
||||
password = fields[4]
|
||||
|
||||
if not hash_value:
|
||||
raise JobResultError(
|
||||
f"Invalid Hashcat --show hash: {line}"
|
||||
)
|
||||
|
||||
return (
|
||||
hash_value,
|
||||
ap_mac,
|
||||
client_mac,
|
||||
password,
|
||||
)
|
||||
|
||||
def _parse_show_file(
|
||||
self,
|
||||
path: Path,
|
||||
job_handshakes: list[dict[str, Any]],
|
||||
) -> list[tuple[str, str]]:
|
||||
identity_to_hashes: dict[
|
||||
tuple[str, str, str],
|
||||
list[str],
|
||||
] = defaultdict(list)
|
||||
|
||||
for row in job_handshakes:
|
||||
identity = self._hash_identity(row["hash22000"])
|
||||
hashes = identity_to_hashes[identity]
|
||||
|
||||
if row["hash22000"] not in hashes:
|
||||
hashes.append(row["hash22000"])
|
||||
|
||||
found: list[tuple[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
for line_no, raw_line in enumerate(
|
||||
path.read_text(encoding="utf-8").splitlines(),
|
||||
start=1,
|
||||
):
|
||||
line = raw_line.strip()
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
hash_value, ap_mac, client_mac, password = (
|
||||
self._parse_show_identity(line)
|
||||
)
|
||||
|
||||
identity = (
|
||||
hash_value,
|
||||
ap_mac,
|
||||
client_mac,
|
||||
)
|
||||
|
||||
hashes = identity_to_hashes.get(identity)
|
||||
|
||||
if not hashes:
|
||||
raise JobResultError(
|
||||
"Result contains an unknown Hashcat --show line "
|
||||
f"{line_no}: {line}"
|
||||
)
|
||||
|
||||
for hash22000 in hashes:
|
||||
item = (hash22000, password)
|
||||
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
found.append(item)
|
||||
|
||||
return found
|
||||
|
||||
def _import_credentials(
|
||||
self,
|
||||
found: list[tuple[str, str]],
|
||||
job_handshakes: list[dict[str, Any]],
|
||||
) -> dict[str, int]:
|
||||
by_hash: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
|
||||
for row in job_handshakes:
|
||||
by_hash[row["hash22000"]].append(row)
|
||||
|
||||
imported = 0
|
||||
already_present = 0
|
||||
|
||||
for hash22000, password in found:
|
||||
mappings = by_hash.get(hash22000)
|
||||
|
||||
if not mappings:
|
||||
raise JobResultError(
|
||||
"Hashcat --show contains a hash that is not part "
|
||||
f"of the Job: {hash22000}"
|
||||
)
|
||||
|
||||
for mapping in mappings:
|
||||
handshake_id = mapping["handshake_id"]
|
||||
access_point_id = mapping["access_point_id"]
|
||||
|
||||
if access_point_id is None:
|
||||
raise JobResultError(
|
||||
"Cannot import credential for handshake "
|
||||
f"{handshake_id}: access_point_id is NULL"
|
||||
)
|
||||
|
||||
existing = self._credentials.get_credential(
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
)
|
||||
|
||||
if existing is not None:
|
||||
already_present += 1
|
||||
self._credentials.mark_access_point_cracked(
|
||||
access_point_id,
|
||||
)
|
||||
continue
|
||||
|
||||
self._credentials.create_hashcat_credential(
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
)
|
||||
imported += 1
|
||||
|
||||
return {
|
||||
"imported": imported,
|
||||
"already_present": already_present,
|
||||
}
|
||||
|
||||
def _register_attempts(
|
||||
self,
|
||||
job,
|
||||
result: dict[str, Any],
|
||||
job_handshakes: list[dict[str, Any]],
|
||||
) -> int:
|
||||
if result["status"] != "COMPLETED":
|
||||
return 0
|
||||
|
||||
completed_at = result["completed_at"]
|
||||
count = 0
|
||||
|
||||
for row in job_handshakes:
|
||||
existing = self._attempts.get_attempt(
|
||||
row["handshake_id"],
|
||||
job["method_id"],
|
||||
job["method_version"],
|
||||
)
|
||||
|
||||
if existing is not None:
|
||||
if (
|
||||
existing["job_id"] != job["id"]
|
||||
or existing["status"] != "COMPLETED"
|
||||
):
|
||||
raise JobResultError(
|
||||
"Handshake "
|
||||
f"{row['handshake_id']} already has a "
|
||||
"conflicting attempt for this method/version"
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
self._attempts.create_attempt(
|
||||
handshake_id=row["handshake_id"],
|
||||
method_id=job["method_id"],
|
||||
method_version=job["method_version"],
|
||||
job_id=job["id"],
|
||||
completed_at=completed_at,
|
||||
)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def _update_steps(
|
||||
self,
|
||||
job_id: str,
|
||||
result_steps: list[dict[str, Any]],
|
||||
) -> None:
|
||||
for step in result_steps:
|
||||
step_no = step["step_no"]
|
||||
|
||||
self._jobs.update_job_step(
|
||||
job_id=job_id,
|
||||
step_no=step_no,
|
||||
status="COMPLETED",
|
||||
started_at=step.get("started_at"),
|
||||
completed_at=step.get("completed_at"),
|
||||
exit_code=step.get("exit_code"),
|
||||
restore_seen=(
|
||||
1 if step.get("restore_seen") else 0
|
||||
),
|
||||
error=step.get("error"),
|
||||
)
|
||||
|
||||
updated = self._jobs.get_job_step(
|
||||
job_id,
|
||||
step_no,
|
||||
)
|
||||
|
||||
if updated is None:
|
||||
raise JobResultError(
|
||||
f"Job step disappeared during result import: "
|
||||
f"{step_no}"
|
||||
)
|
||||
|
||||
def _release_reservations(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> int:
|
||||
released = 0
|
||||
|
||||
for reservation in self._reservations.list_job_reservations(
|
||||
job_id
|
||||
):
|
||||
if reservation["status"] != "ACTIVE":
|
||||
continue
|
||||
|
||||
self._reservations.update_status(
|
||||
reservation["id"],
|
||||
"RELEASED",
|
||||
)
|
||||
released += 1
|
||||
|
||||
return released
|
||||
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
class MethodDefinitionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
_RESOURCE_RE = re.compile(r"^[a-z][a-z0-9_-]*:[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
_STEP_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
_MASK_CHARSET_SIZES = {
|
||||
"?l": 26,
|
||||
"?u": 26,
|
||||
"?d": 10,
|
||||
"?s": 33,
|
||||
"?a": 95,
|
||||
"?b": 256,
|
||||
}
|
||||
_MASK_MIN_LENGTH = 8
|
||||
|
||||
|
||||
def validate_method_definition(
|
||||
definition: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(definition, dict):
|
||||
raise MethodDefinitionError(
|
||||
"Method definition must be a JSON object"
|
||||
)
|
||||
|
||||
if definition.get("schema_version") != 1:
|
||||
raise MethodDefinitionError(
|
||||
"Unsupported method definition schema version"
|
||||
)
|
||||
|
||||
hash_mode = definition.get("hash_mode")
|
||||
|
||||
if not isinstance(hash_mode, int) or isinstance(hash_mode, bool):
|
||||
raise MethodDefinitionError(
|
||||
"Method definition hash_mode must be a non-negative integer"
|
||||
)
|
||||
|
||||
if hash_mode < 0:
|
||||
raise MethodDefinitionError(
|
||||
"Method definition hash_mode must be a non-negative integer"
|
||||
)
|
||||
|
||||
steps = definition.get("steps")
|
||||
|
||||
if not isinstance(steps, list) or not steps:
|
||||
raise MethodDefinitionError(
|
||||
"Method definition must contain a non-empty steps list"
|
||||
)
|
||||
|
||||
seen_step_ids: set[str] = set()
|
||||
|
||||
for step in steps:
|
||||
_validate_step(step, seen_step_ids)
|
||||
|
||||
return definition
|
||||
|
||||
|
||||
def _validate_step(
|
||||
step: Any,
|
||||
seen_step_ids: set[str],
|
||||
) -> None:
|
||||
if not isinstance(step, dict):
|
||||
raise MethodDefinitionError(
|
||||
"Each method step must be a JSON object"
|
||||
)
|
||||
|
||||
step_id = step.get("step_id")
|
||||
|
||||
if not isinstance(step_id, str) or not _STEP_ID_RE.fullmatch(step_id):
|
||||
raise MethodDefinitionError(
|
||||
"Each method step must have a valid step_id"
|
||||
)
|
||||
|
||||
if step_id in seen_step_ids:
|
||||
raise MethodDefinitionError(
|
||||
f"Duplicate step_id: {step_id}"
|
||||
)
|
||||
|
||||
seen_step_ids.add(step_id)
|
||||
|
||||
attack_mode = step.get("attack_mode")
|
||||
|
||||
if not isinstance(attack_mode, int) or isinstance(attack_mode, bool):
|
||||
raise MethodDefinitionError(
|
||||
f"Invalid attack_mode for step: {step_id}"
|
||||
)
|
||||
|
||||
if attack_mode < 0:
|
||||
raise MethodDefinitionError(
|
||||
f"Invalid attack_mode for step: {step_id}"
|
||||
)
|
||||
|
||||
if attack_mode == 0:
|
||||
_require_dictionary(step, step_id)
|
||||
|
||||
elif attack_mode == 1:
|
||||
_require_dictionaries(step, step_id)
|
||||
|
||||
elif attack_mode == 3:
|
||||
_require_mask(
|
||||
step,
|
||||
step_id,
|
||||
minimum_length=_MASK_MIN_LENGTH,
|
||||
)
|
||||
|
||||
elif attack_mode == 6:
|
||||
_require_dictionary(step, step_id)
|
||||
_require_mask(
|
||||
step,
|
||||
step_id,
|
||||
minimum_length=_MASK_MIN_LENGTH,
|
||||
)
|
||||
|
||||
elif attack_mode == 7:
|
||||
_require_mask(
|
||||
step,
|
||||
step_id,
|
||||
minimum_length=_MASK_MIN_LENGTH,
|
||||
)
|
||||
_require_dictionary(step, step_id)
|
||||
|
||||
if "rules" in step:
|
||||
_validate_resource_object(
|
||||
step["rules"],
|
||||
"rules",
|
||||
step_id,
|
||||
)
|
||||
|
||||
|
||||
def _require_dictionary(
|
||||
step: dict[str, Any],
|
||||
step_id: str,
|
||||
) -> None:
|
||||
if "dictionary" not in step:
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} requires dictionary"
|
||||
)
|
||||
|
||||
_validate_resource_object(
|
||||
step["dictionary"],
|
||||
"dictionary",
|
||||
step_id,
|
||||
)
|
||||
|
||||
|
||||
def _require_dictionaries(
|
||||
step: dict[str, Any],
|
||||
step_id: str,
|
||||
) -> None:
|
||||
dictionaries = step.get("dictionaries")
|
||||
|
||||
if not isinstance(dictionaries, list) or len(dictionaries) != 2:
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} requires exactly two dictionaries"
|
||||
)
|
||||
|
||||
for dictionary in dictionaries:
|
||||
_validate_resource_object(
|
||||
dictionary,
|
||||
"dictionary",
|
||||
step_id,
|
||||
)
|
||||
|
||||
|
||||
def _require_mask(
|
||||
step: dict[str, Any],
|
||||
step_id: str,
|
||||
minimum_length: int | None = None,
|
||||
) -> None:
|
||||
mask = step.get("mask")
|
||||
|
||||
if not isinstance(mask, str) or not mask:
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} requires a non-empty mask"
|
||||
)
|
||||
|
||||
length = 0
|
||||
index = 0
|
||||
|
||||
while index < len(mask):
|
||||
if mask[index] != "?":
|
||||
length += 1
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if index + 1 >= len(mask):
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} mask contains a lone '?' "
|
||||
f"at position {index + 1}"
|
||||
)
|
||||
|
||||
token = mask[index:index + 2]
|
||||
|
||||
if token == "??":
|
||||
length += 1
|
||||
index += 2
|
||||
continue
|
||||
|
||||
if token not in _MASK_CHARSET_SIZES:
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} contains unsupported mask token "
|
||||
f"{token!r} at position {index + 1}"
|
||||
)
|
||||
|
||||
length += 1
|
||||
index += 2
|
||||
|
||||
if (
|
||||
minimum_length is not None
|
||||
and length < minimum_length
|
||||
):
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} mask must produce at least "
|
||||
f"{minimum_length} characters; got {length}"
|
||||
)
|
||||
|
||||
def _validate_resource_object(
|
||||
value: Any,
|
||||
resource_type: str,
|
||||
step_id: str,
|
||||
) -> None:
|
||||
if not isinstance(value, dict):
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} {resource_type} must be an object"
|
||||
)
|
||||
|
||||
resource = value.get("resource")
|
||||
|
||||
if not isinstance(resource, str) or not _RESOURCE_RE.fullmatch(resource):
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} {resource_type}.resource must be "
|
||||
"a valid logical resource ID"
|
||||
)
|
||||
|
||||
expected_prefix = (
|
||||
"wordlist:"
|
||||
if resource_type == "dictionary"
|
||||
else f"{resource_type}:"
|
||||
)
|
||||
|
||||
if not resource.startswith(expected_prefix):
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} {resource_type}.resource must start "
|
||||
f"with {expected_prefix!r}"
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from cracklab.server.repositories.methods import MethodsRepository
|
||||
from cracklab.server.services.method_definition import (
|
||||
MethodDefinitionError,
|
||||
validate_method_definition,
|
||||
)
|
||||
|
||||
|
||||
class MethodServiceError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class MethodService:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._methods = MethodsRepository(conn)
|
||||
|
||||
def create_method(
|
||||
self,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
method_id: str | None = None,
|
||||
) -> str:
|
||||
if not name or not name.strip():
|
||||
raise MethodServiceError("Method name must not be empty")
|
||||
|
||||
if method_id is None:
|
||||
method_id = f"MTH-{uuid.uuid4().hex}"
|
||||
|
||||
if not method_id:
|
||||
raise MethodServiceError("Method ID must not be empty")
|
||||
|
||||
self._methods.create_method(
|
||||
method_id=method_id,
|
||||
name=name.strip(),
|
||||
description=description.strip() if description else None,
|
||||
)
|
||||
|
||||
return method_id
|
||||
|
||||
def create_method_version(
|
||||
self,
|
||||
method_id: str,
|
||||
version: int,
|
||||
definition: dict[str, Any],
|
||||
) -> None:
|
||||
if version <= 0:
|
||||
raise MethodServiceError(
|
||||
"Method version must be greater than zero"
|
||||
)
|
||||
|
||||
try:
|
||||
validate_method_definition(definition)
|
||||
except MethodDefinitionError as exc:
|
||||
raise MethodServiceError(str(exc)) from exc
|
||||
|
||||
self._methods.create_method_version(
|
||||
method_id=method_id,
|
||||
version=version,
|
||||
definition=definition,
|
||||
)
|
||||
|
||||
def create_next_method_version(
|
||||
self,
|
||||
method_id: str,
|
||||
definition: dict[str, Any],
|
||||
) -> int:
|
||||
try:
|
||||
validate_method_definition(definition)
|
||||
except MethodDefinitionError as exc:
|
||||
raise MethodServiceError(str(exc)) from exc
|
||||
|
||||
version = self._methods.get_next_method_version(method_id)
|
||||
|
||||
self._methods.create_method_version(
|
||||
method_id=method_id,
|
||||
version=version,
|
||||
definition=definition,
|
||||
)
|
||||
|
||||
return version
|
||||
|
||||
def get_method(self, method_id: str) -> sqlite3.Row | None:
|
||||
return self._methods.get_method(method_id)
|
||||
|
||||
def list_methods(self) -> list[sqlite3.Row]:
|
||||
return self._methods.list_methods()
|
||||
|
||||
def get_method_version(
|
||||
self,
|
||||
method_id: str,
|
||||
version: int,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._methods.get_method_version(
|
||||
method_id,
|
||||
version,
|
||||
)
|
||||
|
||||
def list_method_versions(
|
||||
self,
|
||||
method_id: str,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._methods.list_method_versions(method_id)
|
||||
|
||||
def get_latest_method_version(
|
||||
self,
|
||||
method_id: str,
|
||||
) -> sqlite3.Row | None:
|
||||
versions = self._methods.list_method_versions(method_id)
|
||||
|
||||
if not versions:
|
||||
return None
|
||||
|
||||
return versions[-1]
|
||||
@@ -0,0 +1,914 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from cracklab.server.services.methods import MethodService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StockMethod:
|
||||
method_id: str
|
||||
name: str
|
||||
description: str
|
||||
definition: dict[str, Any] | None
|
||||
|
||||
|
||||
STOCK_METHODS = (
|
||||
StockMethod(
|
||||
method_id="mobile_oper_spb_plus_79",
|
||||
name="MobileOperSpb+79",
|
||||
description="Перебор мобильных номеров Санкт-Петербурга с префиксом +79.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"+791100?d?d?d?d?d",
|
||||
"+791101?d?d?d?d?d",
|
||||
"+791102?d?d?d?d?d",
|
||||
"+791103?d?d?d?d?d",
|
||||
"+791108?d?d?d?d?d",
|
||||
"+791109?d?d?d?d?d",
|
||||
"+791110?d?d?d?d?d",
|
||||
"+791111?d?d?d?d?d",
|
||||
"+791112?d?d?d?d?d",
|
||||
"+791117?d?d?d?d?d",
|
||||
"+791119?d?d?d?d?d",
|
||||
"+79811?d?d?d?d?d?d",
|
||||
"+79817?d?d?d?d?d?d",
|
||||
"+79818?d?d?d?d?d?d",
|
||||
"+79819?d?d?d?d?d?d",
|
||||
"+798986?d?d?d?d?d",
|
||||
"+798686?d?d?d?d?d",
|
||||
"+798687?d?d?d?d?d",
|
||||
"+798688?d?d?d?d?d",
|
||||
"+792118?d?d?d?d?d",
|
||||
"+792119?d?d?d?d?d",
|
||||
"+79213?d?d?d?d?d?d",
|
||||
"+79214?d?d?d?d?d?d",
|
||||
"+79215?d?d?d?d?d?d",
|
||||
"+79216?d?d?d?d?d?d",
|
||||
"+79217?d?d?d?d?d?d",
|
||||
"+79218?d?d?d?d?d?d",
|
||||
"+79219?d?d?d?d?d?d",
|
||||
"+793110?d?d?d?d?d",
|
||||
"+793111?d?d?d?d?d",
|
||||
"+793112?d?d?d?d?d",
|
||||
"+793113?d?d?d?d?d",
|
||||
"+79312?d?d?d?d?d?d",
|
||||
"+79313?d?d?d?d?d?d",
|
||||
"+79315?d?d?d?d?d?d",
|
||||
"+79319?d?d?d?d?d?d",
|
||||
"+79291?d?d?d?d?d?d",
|
||||
"+799923?d?d?d?d?d",
|
||||
"+799924?d?d?d?d?d",
|
||||
"+799925?d?d?d?d?d",
|
||||
"+7963241?d?d?d?d",
|
||||
"+7963242?d?d?d?d",
|
||||
"+7963243?d?d?d?d",
|
||||
"+7963244?d?d?d?d",
|
||||
"+7963245?d?d?d?d",
|
||||
"+7963246?d?d?d?d",
|
||||
"+7963247?d?d?d?d",
|
||||
"+7963248?d?d?d?d",
|
||||
"+7963249?d?d?d?d",
|
||||
"+796330?d?d?d?d?d",
|
||||
"+796331?d?d?d?d?d",
|
||||
"+796332?d?d?d?d?d",
|
||||
"+796334?d?d?d?d?d",
|
||||
"+79650?d?d?d?d?d?d",
|
||||
"+79651?d?d?d?d?d?d",
|
||||
"+79652?d?d?d?d?d?d",
|
||||
"+79653?d?d?d?d?d?d",
|
||||
"+79654?d?d?d?d?d?d",
|
||||
"+79655?d?d?d?d?d?d",
|
||||
"+79656?d?d?d?d?d?d",
|
||||
"+79657?d?d?d?d?d?d",
|
||||
"+79658?d?d?d?d?d?d",
|
||||
"+79659?d?d?d?d?d?d",
|
||||
"+790520?d?d?d?d?d",
|
||||
"+790521?d?d?d?d?d",
|
||||
"+790522?d?d?d?d?d",
|
||||
"+790523?d?d?d?d?d",
|
||||
"+790524?d?d?d?d?d",
|
||||
"+790525?d?d?d?d?d",
|
||||
"+790526?d?d?d?d?d",
|
||||
"+790527?d?d?d?d?d",
|
||||
"+790528?d?d?d?d?d",
|
||||
"+790624?d?d?d?d?d",
|
||||
"+790625?d?d?d?d?d",
|
||||
"+790626?d?d?d?d?d",
|
||||
"+790627?d?d?d?d?d",
|
||||
"+790958?d?d?d?d?d",
|
||||
"+790959?d?d?d?d?d",
|
||||
"+796023?d?d?d?d?d",
|
||||
"+796024?d?d?d?d?d",
|
||||
"+796025?d?d?d?d?d",
|
||||
"+796026?d?d?d?d?d",
|
||||
"+796027?d?d?d?d?d",
|
||||
"+796028?d?d?d?d?d",
|
||||
"+796434?d?d?d?d?d",
|
||||
"+79500?d?d?d?d?d?d",
|
||||
"+79502?d?d?d?d?d?d",
|
||||
"+79516?d?d?d?d?d?d",
|
||||
"+79517?d?d?d?d?d?d",
|
||||
"+79518?d?d?d?d?d?d",
|
||||
"+79522?d?d?d?d?d?d",
|
||||
"+79523?d?d?d?d?d?d",
|
||||
"+79531?d?d?d?d?d?d",
|
||||
"+79533?d?d?d?d?d?d",
|
||||
"+790433?d?d?d?d?d",
|
||||
"+790451?d?d?d?d?d",
|
||||
"+790455?d?d?d?d?d",
|
||||
"+790460?d?d?d?d?d",
|
||||
"+790461?d?d?d?d?d",
|
||||
"+790462?d?d?d?d?d",
|
||||
"+790463?d?d?d?d?d",
|
||||
"+790464?d?d?d?d?d",
|
||||
"+799100?d?d?d?d?d",
|
||||
"+799101?d?d?d?d?d",
|
||||
"+799102?d?d?d?d?d",
|
||||
"+790062?d?d?d?d?d",
|
||||
"+790063?d?d?d?d?d",
|
||||
"+790064?d?d?d?d?d",
|
||||
"+790065?d?d?d?d?d",
|
||||
"+799900?d?d?d?d?d",
|
||||
"+799901?d?d?d?d?d",
|
||||
"+799902?d?d?d?d?d",
|
||||
"+799903?d?d?d?d?d",
|
||||
"+799904?d?d?d?d?d",
|
||||
"+799909?d?d?d?d?d",
|
||||
"+79991?d?d?d?d?d?d",
|
||||
"+79992?d?d?d?d?d?d",
|
||||
"+799322?d?d?d?d?d",
|
||||
"+799343?d?d?d?d?d",
|
||||
"+799344?d?d?d?d?d",
|
||||
"+799555?d?d?d?d?d",
|
||||
"+799559?d?d?d?d?d",
|
||||
"+799560?d?d?d?d?d",
|
||||
"+799590?d?d?d?d?d",
|
||||
"+795840?d?d?d?d?d",
|
||||
"+795856?d?d?d?d?d",
|
||||
"+799511?d?d?d?d?d",
|
||||
"+799512?d?d?d?d?d",
|
||||
"+7999944?d?d?d?d",
|
||||
"+7999945?d?d?d?d",
|
||||
"+7999946?d?d?d?d",
|
||||
"+7999947?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mobile_oper_spb_89",
|
||||
name="MobileOperSpb_89",
|
||||
description="Перебор мобильных номеров Санкт-Петербурга с префиксом 89.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"891100?d?d?d?d?d",
|
||||
"891101?d?d?d?d?d",
|
||||
"891102?d?d?d?d?d",
|
||||
"891103?d?d?d?d?d",
|
||||
"891108?d?d?d?d?d",
|
||||
"891109?d?d?d?d?d",
|
||||
"891110?d?d?d?d?d",
|
||||
"891111?d?d?d?d?d",
|
||||
"891112?d?d?d?d?d",
|
||||
"891117?d?d?d?d?d",
|
||||
"891119?d?d?d?d?d",
|
||||
"89811?d?d?d?d?d?d",
|
||||
"89817?d?d?d?d?d?d",
|
||||
"89818?d?d?d?d?d?d",
|
||||
"89819?d?d?d?d?d?d",
|
||||
"898986?d?d?d?d?d",
|
||||
"898686?d?d?d?d?d",
|
||||
"898687?d?d?d?d?d",
|
||||
"898688?d?d?d?d?d",
|
||||
"892118?d?d?d?d?d",
|
||||
"892119?d?d?d?d?d",
|
||||
"89213?d?d?d?d?d?d",
|
||||
"89214?d?d?d?d?d?d",
|
||||
"89215?d?d?d?d?d?d",
|
||||
"89216?d?d?d?d?d?d",
|
||||
"89217?d?d?d?d?d?d",
|
||||
"89218?d?d?d?d?d?d",
|
||||
"89219?d?d?d?d?d?d",
|
||||
"893110?d?d?d?d?d",
|
||||
"893111?d?d?d?d?d",
|
||||
"893112?d?d?d?d?d",
|
||||
"893113?d?d?d?d?d",
|
||||
"89312?d?d?d?d?d?d",
|
||||
"89313?d?d?d?d?d?d",
|
||||
"89315?d?d?d?d?d?d",
|
||||
"89319?d?d?d?d?d?d",
|
||||
"89291?d?d?d?d?d?d",
|
||||
"899923?d?d?d?d?d",
|
||||
"899924?d?d?d?d?d",
|
||||
"899925?d?d?d?d?d",
|
||||
"8963241?d?d?d?d",
|
||||
"8963242?d?d?d?d",
|
||||
"8963243?d?d?d?d",
|
||||
"8963244?d?d?d?d",
|
||||
"8963245?d?d?d?d",
|
||||
"8963246?d?d?d?d",
|
||||
"8963247?d?d?d?d",
|
||||
"8963248?d?d?d?d",
|
||||
"8963249?d?d?d?d",
|
||||
"896330?d?d?d?d?d",
|
||||
"896331?d?d?d?d?d",
|
||||
"896332?d?d?d?d?d",
|
||||
"896334?d?d?d?d?d",
|
||||
"89650?d?d?d?d?d?d",
|
||||
"89651?d?d?d?d?d?d",
|
||||
"89652?d?d?d?d?d?d",
|
||||
"89653?d?d?d?d?d?d",
|
||||
"89654?d?d?d?d?d?d",
|
||||
"89655?d?d?d?d?d?d",
|
||||
"89656?d?d?d?d?d?d",
|
||||
"89657?d?d?d?d?d?d",
|
||||
"89658?d?d?d?d?d?d",
|
||||
"89659?d?d?d?d?d?d",
|
||||
"890520?d?d?d?d?d",
|
||||
"890521?d?d?d?d?d",
|
||||
"890522?d?d?d?d?d",
|
||||
"890523?d?d?d?d?d",
|
||||
"890524?d?d?d?d?d",
|
||||
"890525?d?d?d?d?d",
|
||||
"890526?d?d?d?d?d",
|
||||
"890527?d?d?d?d?d",
|
||||
"890528?d?d?d?d?d",
|
||||
"890624?d?d?d?d?d",
|
||||
"890625?d?d?d?d?d",
|
||||
"890626?d?d?d?d?d",
|
||||
"890627?d?d?d?d?d",
|
||||
"890958?d?d?d?d?d",
|
||||
"890959?d?d?d?d?d",
|
||||
"896023?d?d?d?d?d",
|
||||
"896024?d?d?d?d?d",
|
||||
"896025?d?d?d?d?d",
|
||||
"896026?d?d?d?d?d",
|
||||
"896027?d?d?d?d?d",
|
||||
"896028?d?d?d?d?d",
|
||||
"896434?d?d?d?d?d",
|
||||
"89500?d?d?d?d?d?d",
|
||||
"89502?d?d?d?d?d?d",
|
||||
"89516?d?d?d?d?d?d",
|
||||
"89517?d?d?d?d?d?d",
|
||||
"89518?d?d?d?d?d?d",
|
||||
"89522?d?d?d?d?d?d",
|
||||
"89523?d?d?d?d?d?d",
|
||||
"89531?d?d?d?d?d?d",
|
||||
"89533?d?d?d?d?d?d",
|
||||
"890433?d?d?d?d?d",
|
||||
"890451?d?d?d?d?d",
|
||||
"890455?d?d?d?d?d",
|
||||
"890460?d?d?d?d?d",
|
||||
"890461?d?d?d?d?d",
|
||||
"890462?d?d?d?d?d",
|
||||
"890463?d?d?d?d?d",
|
||||
"890464?d?d?d?d?d",
|
||||
"899100?d?d?d?d?d",
|
||||
"899101?d?d?d?d?d",
|
||||
"899102?d?d?d?d?d",
|
||||
"890062?d?d?d?d?d",
|
||||
"890063?d?d?d?d?d",
|
||||
"890064?d?d?d?d?d",
|
||||
"890065?d?d?d?d?d",
|
||||
"899900?d?d?d?d?d",
|
||||
"899901?d?d?d?d?d",
|
||||
"899902?d?d?d?d?d",
|
||||
"899903?d?d?d?d?d",
|
||||
"899904?d?d?d?d?d",
|
||||
"899909?d?d?d?d?d",
|
||||
"89991?d?d?d?d?d?d",
|
||||
"89992?d?d?d?d?d?d",
|
||||
"899322?d?d?d?d?d",
|
||||
"899343?d?d?d?d?d",
|
||||
"899344?d?d?d?d?d",
|
||||
"899555?d?d?d?d?d",
|
||||
"899559?d?d?d?d?d",
|
||||
"899560?d?d?d?d?d",
|
||||
"899590?d?d?d?d?d",
|
||||
"895840?d?d?d?d?d",
|
||||
"895856?d?d?d?d?d",
|
||||
"899511?d?d?d?d?d",
|
||||
"899512?d?d?d?d?d",
|
||||
"8999944?d?d?d?d",
|
||||
"8999945?d?d?d?d",
|
||||
"8999946?d?d?d?d",
|
||||
"8999947?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mobile_oper_spb_79",
|
||||
name="MobileOperSpb_79",
|
||||
description="Перебор мобильных номеров Санкт-Петербурга без знака +.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"791100?d?d?d?d?d",
|
||||
"791101?d?d?d?d?d",
|
||||
"791102?d?d?d?d?d",
|
||||
"791103?d?d?d?d?d",
|
||||
"791108?d?d?d?d?d",
|
||||
"791109?d?d?d?d?d",
|
||||
"791110?d?d?d?d?d",
|
||||
"791111?d?d?d?d?d",
|
||||
"791112?d?d?d?d?d",
|
||||
"791117?d?d?d?d?d",
|
||||
"791119?d?d?d?d?d",
|
||||
"79811?d?d?d?d?d?d",
|
||||
"79817?d?d?d?d?d?d",
|
||||
"79818?d?d?d?d?d?d",
|
||||
"79819?d?d?d?d?d?d",
|
||||
"798986?d?d?d?d?d",
|
||||
"798686?d?d?d?d?d",
|
||||
"798687?d?d?d?d?d",
|
||||
"798688?d?d?d?d?d",
|
||||
"792118?d?d?d?d?d",
|
||||
"792119?d?d?d?d?d",
|
||||
"79213?d?d?d?d?d?d",
|
||||
"79214?d?d?d?d?d?d",
|
||||
"79215?d?d?d?d?d?d",
|
||||
"79216?d?d?d?d?d?d",
|
||||
"79217?d?d?d?d?d?d",
|
||||
"79218?d?d?d?d?d?d",
|
||||
"79219?d?d?d?d?d?d",
|
||||
"793110?d?d?d?d?d",
|
||||
"793111?d?d?d?d?d",
|
||||
"793112?d?d?d?d?d",
|
||||
"793113?d?d?d?d?d",
|
||||
"79312?d?d?d?d?d?d",
|
||||
"79313?d?d?d?d?d?d",
|
||||
"79315?d?d?d?d?d?d",
|
||||
"79319?d?d?d?d?d?d",
|
||||
"79291?d?d?d?d?d?d",
|
||||
"799923?d?d?d?d?d",
|
||||
"799924?d?d?d?d?d",
|
||||
"799925?d?d?d?d?d",
|
||||
"7963241?d?d?d?d",
|
||||
"7963242?d?d?d?d",
|
||||
"7963243?d?d?d?d",
|
||||
"7963244?d?d?d?d",
|
||||
"7963245?d?d?d?d",
|
||||
"7963246?d?d?d?d",
|
||||
"7963247?d?d?d?d",
|
||||
"7963248?d?d?d?d",
|
||||
"7963249?d?d?d?d",
|
||||
"796330?d?d?d?d?d",
|
||||
"796331?d?d?d?d?d",
|
||||
"796332?d?d?d?d?d",
|
||||
"796334?d?d?d?d?d",
|
||||
"79650?d?d?d?d?d?d",
|
||||
"79651?d?d?d?d?d?d",
|
||||
"79652?d?d?d?d?d?d",
|
||||
"79653?d?d?d?d?d?d",
|
||||
"79654?d?d?d?d?d?d",
|
||||
"79655?d?d?d?d?d?d",
|
||||
"79656?d?d?d?d?d?d",
|
||||
"79657?d?d?d?d?d?d",
|
||||
"79658?d?d?d?d?d?d",
|
||||
"79659?d?d?d?d?d?d",
|
||||
"790520?d?d?d?d?d",
|
||||
"790521?d?d?d?d?d",
|
||||
"790522?d?d?d?d?d",
|
||||
"790523?d?d?d?d?d",
|
||||
"790524?d?d?d?d?d",
|
||||
"790525?d?d?d?d?d",
|
||||
"790526?d?d?d?d?d",
|
||||
"790527?d?d?d?d?d",
|
||||
"790528?d?d?d?d?d",
|
||||
"790624?d?d?d?d?d",
|
||||
"790625?d?d?d?d?d",
|
||||
"790626?d?d?d?d?d",
|
||||
"790627?d?d?d?d?d",
|
||||
"790958?d?d?d?d?d",
|
||||
"790959?d?d?d?d?d",
|
||||
"796023?d?d?d?d?d",
|
||||
"796024?d?d?d?d?d",
|
||||
"796025?d?d?d?d?d",
|
||||
"796026?d?d?d?d?d",
|
||||
"796027?d?d?d?d?d",
|
||||
"796028?d?d?d?d?d",
|
||||
"796434?d?d?d?d?d",
|
||||
"79500?d?d?d?d?d?d",
|
||||
"79502?d?d?d?d?d?d",
|
||||
"79516?d?d?d?d?d?d",
|
||||
"79517?d?d?d?d?d?d",
|
||||
"79518?d?d?d?d?d?d",
|
||||
"79522?d?d?d?d?d?d",
|
||||
"79523?d?d?d?d?d?d",
|
||||
"79531?d?d?d?d?d?d",
|
||||
"79533?d?d?d?d?d?d",
|
||||
"790433?d?d?d?d?d",
|
||||
"790451?d?d?d?d?d",
|
||||
"790455?d?d?d?d?d",
|
||||
"790460?d?d?d?d?d",
|
||||
"790461?d?d?d?d?d",
|
||||
"790462?d?d?d?d?d",
|
||||
"790463?d?d?d?d?d",
|
||||
"790464?d?d?d?d?d",
|
||||
"799100?d?d?d?d?d",
|
||||
"799101?d?d?d?d?d",
|
||||
"799102?d?d?d?d?d",
|
||||
"790062?d?d?d?d?d",
|
||||
"790063?d?d?d?d?d",
|
||||
"790064?d?d?d?d?d",
|
||||
"790065?d?d?d?d?d",
|
||||
"799900?d?d?d?d?d",
|
||||
"799901?d?d?d?d?d",
|
||||
"799902?d?d?d?d?d",
|
||||
"799903?d?d?d?d?d",
|
||||
"799904?d?d?d?d?d",
|
||||
"799909?d?d?d?d?d",
|
||||
"79991?d?d?d?d?d?d",
|
||||
"79992?d?d?d?d?d?d",
|
||||
"799322?d?d?d?d?d",
|
||||
"799343?d?d?d?d?d",
|
||||
"799344?d?d?d?d?d",
|
||||
"799555?d?d?d?d?d",
|
||||
"799559?d?d?d?d?d",
|
||||
"799560?d?d?d?d?d",
|
||||
"799590?d?d?d?d?d",
|
||||
"795840?d?d?d?d?d",
|
||||
"795856?d?d?d?d?d",
|
||||
"799511?d?d?d?d?d",
|
||||
"799512?d?d?d?d?d",
|
||||
"7999944?d?d?d?d",
|
||||
"7999945?d?d?d?d",
|
||||
"7999946?d?d?d?d",
|
||||
"7999947?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mobile_oper_spb_9",
|
||||
name="MobileOperSpb_9",
|
||||
description="Перебор мобильных номеров Санкт-Петербурга без 7 и знака +.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"91100?d?d?d?d?d",
|
||||
"91101?d?d?d?d?d",
|
||||
"91102?d?d?d?d?d",
|
||||
"91103?d?d?d?d?d",
|
||||
"91108?d?d?d?d?d",
|
||||
"91109?d?d?d?d?d",
|
||||
"91110?d?d?d?d?d",
|
||||
"91111?d?d?d?d?d",
|
||||
"91112?d?d?d?d?d",
|
||||
"91117?d?d?d?d?d",
|
||||
"91119?d?d?d?d?d",
|
||||
"9811?d?d?d?d?d?d",
|
||||
"9817?d?d?d?d?d?d",
|
||||
"9818?d?d?d?d?d?d",
|
||||
"9819?d?d?d?d?d?d",
|
||||
"98986?d?d?d?d?d",
|
||||
"98686?d?d?d?d?d",
|
||||
"98687?d?d?d?d?d",
|
||||
"98688?d?d?d?d?d",
|
||||
"92118?d?d?d?d?d",
|
||||
"92119?d?d?d?d?d",
|
||||
"9213?d?d?d?d?d?d",
|
||||
"9214?d?d?d?d?d?d",
|
||||
"9215?d?d?d?d?d?d",
|
||||
"9216?d?d?d?d?d?d",
|
||||
"9217?d?d?d?d?d?d",
|
||||
"9218?d?d?d?d?d?d",
|
||||
"9219?d?d?d?d?d?d",
|
||||
"93110?d?d?d?d?d",
|
||||
"93111?d?d?d?d?d",
|
||||
"93112?d?d?d?d?d",
|
||||
"93113?d?d?d?d?d",
|
||||
"9312?d?d?d?d?d?d",
|
||||
"9313?d?d?d?d?d?d",
|
||||
"9315?d?d?d?d?d?d",
|
||||
"9319?d?d?d?d?d?d",
|
||||
"9291?d?d?d?d?d?d",
|
||||
"99923?d?d?d?d?d",
|
||||
"99924?d?d?d?d?d",
|
||||
"99925?d?d?d?d?d",
|
||||
"963241?d?d?d?d",
|
||||
"963242?d?d?d?d",
|
||||
"963243?d?d?d?d",
|
||||
"963244?d?d?d?d",
|
||||
"963245?d?d?d?d",
|
||||
"963246?d?d?d?d",
|
||||
"963247?d?d?d?d",
|
||||
"963248?d?d?d?d",
|
||||
"963249?d?d?d?d",
|
||||
"96330?d?d?d?d?d",
|
||||
"96331?d?d?d?d?d",
|
||||
"96332?d?d?d?d?d",
|
||||
"96334?d?d?d?d?d",
|
||||
"9650?d?d?d?d?d?d",
|
||||
"9651?d?d?d?d?d?d",
|
||||
"9652?d?d?d?d?d?d",
|
||||
"9653?d?d?d?d?d?d",
|
||||
"9654?d?d?d?d?d?d",
|
||||
"9655?d?d?d?d?d?d",
|
||||
"9656?d?d?d?d?d?d",
|
||||
"9657?d?d?d?d?d?d",
|
||||
"9658?d?d?d?d?d?d",
|
||||
"9659?d?d?d?d?d?d",
|
||||
"90520?d?d?d?d?d",
|
||||
"90521?d?d?d?d?d",
|
||||
"90522?d?d?d?d?d",
|
||||
"90523?d?d?d?d?d",
|
||||
"90524?d?d?d?d?d",
|
||||
"90525?d?d?d?d?d",
|
||||
"90526?d?d?d?d?d",
|
||||
"90527?d?d?d?d?d",
|
||||
"90528?d?d?d?d?d",
|
||||
"90624?d?d?d?d?d",
|
||||
"90625?d?d?d?d?d",
|
||||
"90626?d?d?d?d?d",
|
||||
"90627?d?d?d?d?d",
|
||||
"90958?d?d?d?d?d",
|
||||
"90959?d?d?d?d?d",
|
||||
"96023?d?d?d?d?d",
|
||||
"96024?d?d?d?d?d",
|
||||
"96025?d?d?d?d?d",
|
||||
"96026?d?d?d?d?d",
|
||||
"96027?d?d?d?d?d",
|
||||
"96028?d?d?d?d?d",
|
||||
"96434?d?d?d?d?d",
|
||||
"9500?d?d?d?d?d?d",
|
||||
"9502?d?d?d?d?d?d",
|
||||
"9516?d?d?d?d?d?d",
|
||||
"9517?d?d?d?d?d?d",
|
||||
"9518?d?d?d?d?d?d",
|
||||
"9522?d?d?d?d?d?d",
|
||||
"9523?d?d?d?d?d?d",
|
||||
"9531?d?d?d?d?d?d",
|
||||
"9533?d?d?d?d?d?d",
|
||||
"90433?d?d?d?d?d",
|
||||
"90451?d?d?d?d?d",
|
||||
"90455?d?d?d?d?d",
|
||||
"90460?d?d?d?d?d",
|
||||
"90461?d?d?d?d?d",
|
||||
"90462?d?d?d?d?d",
|
||||
"90463?d?d?d?d?d",
|
||||
"90464?d?d?d?d?d",
|
||||
"99100?d?d?d?d?d",
|
||||
"99101?d?d?d?d?d",
|
||||
"99102?d?d?d?d?d",
|
||||
"90062?d?d?d?d?d",
|
||||
"90063?d?d?d?d?d",
|
||||
"90064?d?d?d?d?d",
|
||||
"90065?d?d?d?d?d",
|
||||
"99900?d?d?d?d?d",
|
||||
"99901?d?d?d?d?d",
|
||||
"99902?d?d?d?d?d",
|
||||
"99903?d?d?d?d?d",
|
||||
"99904?d?d?d?d?d",
|
||||
"99909?d?d?d?d?d",
|
||||
"9991?d?d?d?d?d?d",
|
||||
"9992?d?d?d?d?d?d",
|
||||
"99322?d?d?d?d?d",
|
||||
"99343?d?d?d?d?d",
|
||||
"99344?d?d?d?d?d",
|
||||
"99555?d?d?d?d?d",
|
||||
"99559?d?d?d?d?d",
|
||||
"99560?d?d?d?d?d",
|
||||
"99590?d?d?d?d?d",
|
||||
"95840?d?d?d?d?d",
|
||||
"95856?d?d?d?d?d",
|
||||
"99511?d?d?d?d?d",
|
||||
"99512?d?d?d?d?d",
|
||||
"999944?d?d?d?d",
|
||||
"999945?d?d?d?d",
|
||||
"999946?d?d?d?d",
|
||||
"999947?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mobile_oper_plus_79",
|
||||
name="MobileOper+79",
|
||||
description="Перебор мобильных номеров с префиксом +79.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"+7911?d?d?d?d?d?d?d",
|
||||
"+7904?d?d?d?d?d?d?d",
|
||||
"+7931?d?d?d?d?d?d?d",
|
||||
"+7921?d?d?d?d?d?d?d",
|
||||
"+7999?d?d?d?d?d?d?d",
|
||||
"+7905?d?d?d?d?d?d?d",
|
||||
"+7951?d?d?d?d?d?d?d",
|
||||
"+7952?d?d?d?d?d?d?d",
|
||||
"+7953?d?d?d?d?d?d?d",
|
||||
"+7901?d?d?d?d?d?d?d",
|
||||
"+7903?d?d?d?d?d?d?d",
|
||||
"+7950?d?d?d?d?d?d?d",
|
||||
"+7906?d?d?d?d?d?d?d",
|
||||
"+7909?d?d?d?d?d?d?d",
|
||||
"+7929?d?d?d?d?d?d?d",
|
||||
"+7960?d?d?d?d?d?d?d",
|
||||
"+7961?d?d?d?d?d?d?d",
|
||||
"+7962?d?d?d?d?d?d?d",
|
||||
"+7963?d?d?d?d?d?d?d",
|
||||
"+7965?d?d?d?d?d?d?d",
|
||||
"+7966?d?d?d?d?d?d?d",
|
||||
"+7981?d?d?d?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mobile_oper_89",
|
||||
name="MobileOper_89",
|
||||
description="Перебор мобильных номеров с префиксом 89.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"8911?d?d?d?d?d?d?d",
|
||||
"8904?d?d?d?d?d?d?d",
|
||||
"8931?d?d?d?d?d?d?d",
|
||||
"8921?d?d?d?d?d?d?d",
|
||||
"8999?d?d?d?d?d?d?d",
|
||||
"8905?d?d?d?d?d?d?d",
|
||||
"8951?d?d?d?d?d?d?d",
|
||||
"8952?d?d?d?d?d?d?d",
|
||||
"8953?d?d?d?d?d?d?d",
|
||||
"8901?d?d?d?d?d?d?d",
|
||||
"8903?d?d?d?d?d?d?d",
|
||||
"8950?d?d?d?d?d?d?d",
|
||||
"8906?d?d?d?d?d?d?d",
|
||||
"8909?d?d?d?d?d?d?d",
|
||||
"8929?d?d?d?d?d?d?d",
|
||||
"8960?d?d?d?d?d?d?d",
|
||||
"8961?d?d?d?d?d?d?d",
|
||||
"8962?d?d?d?d?d?d?d",
|
||||
"8963?d?d?d?d?d?d?d",
|
||||
"8965?d?d?d?d?d?d?d",
|
||||
"8966?d?d?d?d?d?d?d",
|
||||
"8981?d?d?d?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mobile_oper_79",
|
||||
name="MobileOper_79",
|
||||
description="Перебор мобильных номеров без знака +.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"7911?d?d?d?d?d?d?d",
|
||||
"7904?d?d?d?d?d?d?d",
|
||||
"7931?d?d?d?d?d?d?d",
|
||||
"7921?d?d?d?d?d?d?d",
|
||||
"7999?d?d?d?d?d?d?d",
|
||||
"7905?d?d?d?d?d?d?d",
|
||||
"7951?d?d?d?d?d?d?d",
|
||||
"7952?d?d?d?d?d?d?d",
|
||||
"7953?d?d?d?d?d?d?d",
|
||||
"7901?d?d?d?d?d?d?d",
|
||||
"7903?d?d?d?d?d?d?d",
|
||||
"7950?d?d?d?d?d?d?d",
|
||||
"7906?d?d?d?d?d?d?d",
|
||||
"7909?d?d?d?d?d?d?d",
|
||||
"7929?d?d?d?d?d?d?d",
|
||||
"7960?d?d?d?d?d?d?d",
|
||||
"7961?d?d?d?d?d?d?d",
|
||||
"7962?d?d?d?d?d?d?d",
|
||||
"7963?d?d?d?d?d?d?d",
|
||||
"7965?d?d?d?d?d?d?d",
|
||||
"7966?d?d?d?d?d?d?d",
|
||||
"7981?d?d?d?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mobile_oper_9",
|
||||
name="MobileOper_9",
|
||||
description="Перебор мобильных номеров без 7 и знака +.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"911?d?d?d?d?d?d?d",
|
||||
"904?d?d?d?d?d?d?d",
|
||||
"931?d?d?d?d?d?d?d",
|
||||
"921?d?d?d?d?d?d?d",
|
||||
"999?d?d?d?d?d?d?d",
|
||||
"905?d?d?d?d?d?d?d",
|
||||
"951?d?d?d?d?d?d?d",
|
||||
"952?d?d?d?d?d?d?d",
|
||||
"953?d?d?d?d?d?d?d",
|
||||
"901?d?d?d?d?d?d?d",
|
||||
"903?d?d?d?d?d?d?d",
|
||||
"950?d?d?d?d?d?d?d",
|
||||
"906?d?d?d?d?d?d?d",
|
||||
"909?d?d?d?d?d?d?d",
|
||||
"929?d?d?d?d?d?d?d",
|
||||
"960?d?d?d?d?d?d?d",
|
||||
"961?d?d?d?d?d?d?d",
|
||||
"962?d?d?d?d?d?d?d",
|
||||
"963?d?d?d?d?d?d?d",
|
||||
"965?d?d?d?d?d?d?d",
|
||||
"966?d?d?d?d?d?d?d",
|
||||
"981?d?d?d?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mask_8_digits",
|
||||
name="8-digital",
|
||||
description="Перебор восьмизначных числовых паролей.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": "mask-001",
|
||||
"attack_mode": 3,
|
||||
"mask": "?d?d?d?d?d?d?d?d",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="breachcompilation",
|
||||
name="BreachCompilation",
|
||||
description="Перебор по словарю BreachCompilation.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": "dictionary-001",
|
||||
"attack_mode": 0,
|
||||
"dictionary": {
|
||||
"resource": "wordlist:breachcompilation",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="insidepro_wpa",
|
||||
name="insidePRO_WPA",
|
||||
description="Перебор по словарю insidePRO_WPA.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": "dictionary-001",
|
||||
"attack_mode": 0,
|
||||
"dictionary": {
|
||||
"resource": "wordlist:insidepro-wpa",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class StockMethodSeeder:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
self._methods = MethodService(conn)
|
||||
|
||||
def seed(self) -> None:
|
||||
with self._conn:
|
||||
for stock_method in STOCK_METHODS:
|
||||
if stock_method.definition is None:
|
||||
continue
|
||||
|
||||
existing_method = self._methods.get_method(
|
||||
stock_method.method_id
|
||||
)
|
||||
|
||||
if existing_method is None:
|
||||
self._methods.create_method(
|
||||
method_id=stock_method.method_id,
|
||||
name=stock_method.name,
|
||||
description=stock_method.description,
|
||||
)
|
||||
else:
|
||||
if existing_method["name"] != stock_method.name:
|
||||
raise ValueError(
|
||||
"Stock method ID is already used with a "
|
||||
f"different name: {stock_method.method_id}"
|
||||
)
|
||||
|
||||
existing_version = self._methods.get_method_version(
|
||||
stock_method.method_id,
|
||||
1,
|
||||
)
|
||||
|
||||
if existing_version is None:
|
||||
self._methods.create_method_version(
|
||||
method_id=stock_method.method_id,
|
||||
version=1,
|
||||
definition=stock_method.definition,
|
||||
)
|
||||
elif existing_version["definition_json"] != (
|
||||
self._canonical_json(stock_method.definition)
|
||||
):
|
||||
raise ValueError(
|
||||
"Stock method version 1 already exists with "
|
||||
f"different definition: {stock_method.method_id}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _canonical_json(definition: dict[str, Any]) -> str:
|
||||
import json
|
||||
|
||||
return json.dumps(
|
||||
definition,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
cracklab_bp = Blueprint(
|
||||
"cracklab",
|
||||
__name__,
|
||||
url_prefix="/cracklab",
|
||||
template_folder="templates",
|
||||
static_folder="static",
|
||||
static_url_path="/static",
|
||||
)
|
||||
|
||||
|
||||
from cracklab.server.web import routes # noqa: E402,F401
|
||||
@@ -0,0 +1,408 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
|
||||
from flask import redirect, render_template, request, url_for
|
||||
|
||||
from cracklab.server.db.connection import connect_database
|
||||
from cracklab.server.repositories.clients import ClientsRepository
|
||||
from cracklab.server.repositories.jobs import JobsRepository
|
||||
from cracklab.server.repositories.methods import MethodsRepository
|
||||
from cracklab.server.repositories.results import ResultsRepository
|
||||
from cracklab.server.services.methods import MethodService, MethodServiceError
|
||||
|
||||
from cracklab.server.web import cracklab_bp
|
||||
|
||||
|
||||
@cracklab_bp.route("/")
|
||||
def dashboard():
|
||||
conn = connect_database()
|
||||
|
||||
try:
|
||||
methods = MethodsRepository(conn).list_methods()
|
||||
jobs = JobsRepository(conn).list_jobs()
|
||||
clients = ClientsRepository(conn).list_clients()
|
||||
results = ResultsRepository(conn).list_results()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
job_statuses = Counter(
|
||||
row["status"]
|
||||
for row in jobs
|
||||
)
|
||||
|
||||
recent_jobs = jobs[:10]
|
||||
|
||||
method_names = {
|
||||
row["id"]: row["name"]
|
||||
for row in methods
|
||||
}
|
||||
|
||||
client_names = {
|
||||
row["id"]: row["name"]
|
||||
for row in clients
|
||||
}
|
||||
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
active_page="dashboard",
|
||||
methods_count=len(methods),
|
||||
jobs_count=len(jobs),
|
||||
clients_count=len(clients),
|
||||
results_count=len(results),
|
||||
active_jobs_count=sum(
|
||||
job_statuses.get(status, 0)
|
||||
for status in (
|
||||
"CREATED",
|
||||
"QUEUED",
|
||||
"RUNNING",
|
||||
"PARTIAL",
|
||||
)
|
||||
),
|
||||
job_statuses=job_statuses,
|
||||
recent_jobs=recent_jobs,
|
||||
method_names=method_names,
|
||||
client_names=client_names,
|
||||
)
|
||||
|
||||
|
||||
@cracklab_bp.route("/jobs/")
|
||||
def jobs_page():
|
||||
return _placeholder(
|
||||
"Jobs",
|
||||
"Job management will be implemented here.",
|
||||
"jobs",
|
||||
)
|
||||
|
||||
|
||||
@cracklab_bp.route("/methods/")
|
||||
def methods_page():
|
||||
conn = connect_database()
|
||||
|
||||
try:
|
||||
service = MethodService(conn)
|
||||
methods = service.list_methods()
|
||||
|
||||
method_rows = []
|
||||
|
||||
for method in methods:
|
||||
versions = service.list_method_versions(method["id"])
|
||||
|
||||
latest = versions[-1] if versions else None
|
||||
|
||||
steps_count = 0
|
||||
|
||||
if latest is not None:
|
||||
try:
|
||||
definition = json.loads(
|
||||
latest["definition_json"]
|
||||
)
|
||||
steps_count = len(
|
||||
definition.get("steps", [])
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
steps_count = 0
|
||||
|
||||
method_rows.append(
|
||||
{
|
||||
"id": method["id"],
|
||||
"name": method["name"],
|
||||
"description": method["description"],
|
||||
"created_at": method["created_at"],
|
||||
"version": (
|
||||
latest["version"]
|
||||
if latest is not None
|
||||
else None
|
||||
),
|
||||
"steps_count": steps_count,
|
||||
}
|
||||
)
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return render_template(
|
||||
"methods.html",
|
||||
active_page="methods",
|
||||
methods=method_rows,
|
||||
)
|
||||
|
||||
|
||||
@cracklab_bp.route("/methods/create/", methods=["GET", "POST"])
|
||||
def method_create_page():
|
||||
error = None
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
name = request.form.get("name", "").strip()
|
||||
description = request.form.get(
|
||||
"description",
|
||||
"",
|
||||
).strip()
|
||||
|
||||
hash_mode_raw = request.form.get(
|
||||
"hash_mode",
|
||||
"22000",
|
||||
).strip()
|
||||
|
||||
if not name:
|
||||
raise MethodServiceError(
|
||||
"Введите название метода."
|
||||
)
|
||||
|
||||
if hash_mode_raw != "22000":
|
||||
raise MethodServiceError(
|
||||
"Сейчас поддерживается только hash mode 22000."
|
||||
)
|
||||
|
||||
hash_mode = 22000
|
||||
|
||||
steps = _build_steps_from_form()
|
||||
|
||||
definition = {
|
||||
"schema_version": 1,
|
||||
"hash_mode": hash_mode,
|
||||
"steps": steps,
|
||||
}
|
||||
|
||||
conn = connect_database()
|
||||
|
||||
try:
|
||||
service = MethodService(conn)
|
||||
|
||||
with conn:
|
||||
method_id = service.create_method(
|
||||
name=name,
|
||||
description=description or None,
|
||||
)
|
||||
|
||||
service.create_method_version(
|
||||
method_id=method_id,
|
||||
version=1,
|
||||
definition=definition,
|
||||
)
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return redirect(
|
||||
url_for(
|
||||
"cracklab.methods_page",
|
||||
)
|
||||
)
|
||||
|
||||
except (MethodServiceError, ValueError) as exc:
|
||||
error = str(exc)
|
||||
|
||||
return render_template(
|
||||
"method_create.html",
|
||||
active_page="methods",
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def _build_steps_from_form() -> list[dict]:
|
||||
step_count_raw = request.form.get(
|
||||
"step_count",
|
||||
"1",
|
||||
).strip()
|
||||
|
||||
try:
|
||||
step_count = int(step_count_raw)
|
||||
except ValueError as exc:
|
||||
raise MethodServiceError(
|
||||
"Некорректное количество шагов."
|
||||
) from exc
|
||||
|
||||
if step_count < 1:
|
||||
raise MethodServiceError(
|
||||
"Метод должен содержать хотя бы один шаг."
|
||||
)
|
||||
|
||||
steps = []
|
||||
|
||||
for index in range(1, step_count + 1):
|
||||
prefix = f"step_{index}"
|
||||
|
||||
attack_mode_raw = request.form.get(
|
||||
f"{prefix}_attack_mode",
|
||||
"",
|
||||
).strip()
|
||||
|
||||
if not attack_mode_raw:
|
||||
raise MethodServiceError(
|
||||
f"Для шага {index} не выбран attack mode."
|
||||
)
|
||||
|
||||
try:
|
||||
attack_mode = int(attack_mode_raw)
|
||||
except ValueError as exc:
|
||||
raise MethodServiceError(
|
||||
f"Некорректный attack mode в шаге {index}."
|
||||
) from exc
|
||||
|
||||
if attack_mode not in (0, 1, 3, 6, 7):
|
||||
raise MethodServiceError(
|
||||
f"Attack mode {attack_mode} "
|
||||
f"не поддерживается клиентом CrackLab."
|
||||
)
|
||||
|
||||
step = {
|
||||
"step_id": f"step-{index:03d}",
|
||||
"attack_mode": attack_mode,
|
||||
}
|
||||
|
||||
workload_raw = request.form.get(
|
||||
f"{prefix}_workload_profile",
|
||||
"2",
|
||||
).strip()
|
||||
|
||||
if workload_raw:
|
||||
try:
|
||||
workload_profile = int(workload_raw)
|
||||
except ValueError as exc:
|
||||
raise MethodServiceError(
|
||||
f"Некорректный workload profile "
|
||||
f"в шаге {index}."
|
||||
) from exc
|
||||
|
||||
if workload_profile not in (1, 2, 3, 4):
|
||||
raise MethodServiceError(
|
||||
f"Workload profile в шаге {index} "
|
||||
"должен быть от 1 до 4."
|
||||
)
|
||||
|
||||
step["workload_profile"] = workload_profile
|
||||
|
||||
if attack_mode == 0:
|
||||
step["dictionary"] = {
|
||||
"resource": _get_dictionary(
|
||||
prefix,
|
||||
"dictionary",
|
||||
index,
|
||||
),
|
||||
}
|
||||
|
||||
elif attack_mode == 1:
|
||||
step["dictionaries"] = [
|
||||
{
|
||||
"resource": _get_dictionary(
|
||||
prefix,
|
||||
"dictionary_1",
|
||||
index,
|
||||
),
|
||||
},
|
||||
{
|
||||
"resource": _get_dictionary(
|
||||
prefix,
|
||||
"dictionary_2",
|
||||
index,
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
elif attack_mode == 3:
|
||||
step["mask"] = _get_mask(
|
||||
prefix,
|
||||
index,
|
||||
)
|
||||
|
||||
elif attack_mode == 6:
|
||||
step["dictionary"] = {
|
||||
"resource": _get_dictionary(
|
||||
prefix,
|
||||
"dictionary",
|
||||
index,
|
||||
),
|
||||
}
|
||||
step["mask"] = _get_mask(
|
||||
prefix,
|
||||
index,
|
||||
)
|
||||
|
||||
elif attack_mode == 7:
|
||||
step["mask"] = _get_mask(
|
||||
prefix,
|
||||
index,
|
||||
)
|
||||
step["dictionary"] = {
|
||||
"resource": _get_dictionary(
|
||||
prefix,
|
||||
"dictionary",
|
||||
index,
|
||||
),
|
||||
}
|
||||
|
||||
steps.append(step)
|
||||
|
||||
return steps
|
||||
|
||||
|
||||
def _get_dictionary(
|
||||
prefix: str,
|
||||
field: str,
|
||||
index: int,
|
||||
) -> str:
|
||||
value = request.form.get(
|
||||
f"{prefix}_{field}",
|
||||
"",
|
||||
).strip()
|
||||
|
||||
if not value:
|
||||
raise MethodServiceError(
|
||||
f"Для шага {index} необходимо указать словарь."
|
||||
)
|
||||
|
||||
if not value.startswith("wordlist:"):
|
||||
value = f"wordlist:{value}"
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _get_mask(
|
||||
prefix: str,
|
||||
index: int,
|
||||
) -> str:
|
||||
value = request.form.get(
|
||||
f"{prefix}_mask",
|
||||
"",
|
||||
).strip()
|
||||
|
||||
if not value:
|
||||
raise MethodServiceError(
|
||||
f"Для шага {index} необходимо указать маску."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@cracklab_bp.route("/clients/")
|
||||
def clients_page():
|
||||
return _placeholder(
|
||||
"Clients",
|
||||
"Client management will be implemented here.",
|
||||
"clients",
|
||||
)
|
||||
|
||||
|
||||
@cracklab_bp.route("/results/")
|
||||
def results_page():
|
||||
return _placeholder(
|
||||
"Results",
|
||||
"Result history will be implemented here.",
|
||||
"results",
|
||||
)
|
||||
|
||||
|
||||
def _placeholder(
|
||||
title: str,
|
||||
description: str,
|
||||
active_page: str,
|
||||
):
|
||||
return render_template(
|
||||
"placeholder.html",
|
||||
active_page=active_page,
|
||||
title=title,
|
||||
description=description,
|
||||
)
|
||||
@@ -0,0 +1,785 @@
|
||||
@font-face {
|
||||
font-family: "Bender";
|
||||
|
||||
src:
|
||||
url("fonts/Bender.woff2") format("woff2"),
|
||||
url("fonts/Bender.woff") format("woff");
|
||||
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: "Bender";
|
||||
|
||||
src:
|
||||
url("fonts/Bender-Bold.woff2") format("woff2"),
|
||||
url("fonts/Bender-Bold.woff") format("woff");
|
||||
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: "Bender";
|
||||
|
||||
src:
|
||||
url("fonts/Bender-Light.woff2") format("woff2"),
|
||||
url("fonts/Bender-Light.woff") format("woff");
|
||||
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
||||
:root {
|
||||
--bg: #0f141a;
|
||||
--surface: #171e26;
|
||||
--surface-light: #1c252f;
|
||||
--border: #2b3743;
|
||||
|
||||
--text: #e6edf3;
|
||||
--muted: #8b98a6;
|
||||
|
||||
--green: #45d483;
|
||||
--yellow: #e5c07b;
|
||||
--red: #ef6262;
|
||||
--accent: #69a7ff;
|
||||
|
||||
--terminal: #0a0f14;
|
||||
}
|
||||
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
min-height: 100vh;
|
||||
|
||||
font-family:
|
||||
"Bender",
|
||||
Arial,
|
||||
Helvetica,
|
||||
sans-serif;
|
||||
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
a:hover {
|
||||
color: #9bc4ff;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
z-index: 1000;
|
||||
|
||||
width: 100%;
|
||||
|
||||
background: rgba(15, 20, 26, 0.96);
|
||||
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
|
||||
.cracklab-header-inner {
|
||||
width: 100%;
|
||||
|
||||
max-width: 1800px;
|
||||
|
||||
margin: 0 auto;
|
||||
|
||||
min-height: 68px;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 36px;
|
||||
|
||||
padding: 0 30px;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-brand {
|
||||
flex: 0 0 auto;
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
color: var(--text);
|
||||
|
||||
text-decoration: none;
|
||||
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-brand:hover {
|
||||
color: var(--text);
|
||||
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-brand-title {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-brand-subtitle {
|
||||
margin-top: 3px;
|
||||
|
||||
color: var(--muted);
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
|
||||
letter-spacing: 0.7px;
|
||||
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-nav {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 6px;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
overflow-x: auto;
|
||||
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-nav a {
|
||||
display: inline-flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
min-height: 38px;
|
||||
|
||||
padding: 0 14px;
|
||||
|
||||
border-radius: 7px;
|
||||
|
||||
color: var(--muted);
|
||||
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
|
||||
letter-spacing: 0.7px;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-nav a:hover {
|
||||
color: var(--text);
|
||||
|
||||
background: var(--surface-light);
|
||||
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-nav a.active {
|
||||
color: var(--text);
|
||||
|
||||
background: var(--surface-light);
|
||||
|
||||
box-shadow:
|
||||
inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
|
||||
|
||||
.cracklab-content {
|
||||
width: 100%;
|
||||
|
||||
max-width: 1800px;
|
||||
|
||||
margin: 0 auto;
|
||||
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
|
||||
.page-title {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
|
||||
.page-description {
|
||||
color: var(--muted);
|
||||
|
||||
margin-bottom: 30px;
|
||||
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.methods-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.methods-page-header {
|
||||
display: flex;
|
||||
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
|
||||
gap: 24px;
|
||||
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
|
||||
.methods-page-header .page-description {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
.methods-button,
|
||||
.methods-button-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
min-height: 42px;
|
||||
padding: 10px 18px;
|
||||
|
||||
border: 2px outset ButtonFace;
|
||||
border-radius: 7px;
|
||||
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.methods-button {
|
||||
background: var(--accent);
|
||||
color: #0f141a;
|
||||
}
|
||||
|
||||
.methods-button:hover {
|
||||
background: #8abaff;
|
||||
color: #0f141a;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.methods-button:active {
|
||||
border-style: inset;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.methods-button-secondary {
|
||||
background: var(--surface-light);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.methods-button-secondary:hover {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.methods-button-secondary:active {
|
||||
border-style: inset;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
|
||||
.methods-list {
|
||||
display: grid;
|
||||
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
|
||||
.method-card {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
gap: 30px;
|
||||
|
||||
padding: 20px 22px;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
|
||||
box-shadow:
|
||||
0 2px 10px rgba(0, 0, 0, 0.20);
|
||||
}
|
||||
|
||||
|
||||
.method-card-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
.method-card-title {
|
||||
margin-bottom: 7px;
|
||||
|
||||
color: var(--text);
|
||||
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.method-card-description {
|
||||
margin-bottom: 9px;
|
||||
|
||||
color: var(--muted);
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
.method-card-id {
|
||||
color: var(--muted);
|
||||
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.method-card-meta {
|
||||
display: flex;
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
|
||||
.method-meta-item {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
|
||||
.method-meta-item span {
|
||||
display: block;
|
||||
|
||||
margin-bottom: 5px;
|
||||
|
||||
color: var(--muted);
|
||||
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.method-meta-item strong {
|
||||
color: var(--text);
|
||||
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.method-empty {
|
||||
padding: 50px 30px;
|
||||
|
||||
text-align: center;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
|
||||
.method-empty-title {
|
||||
margin-bottom: 10px;
|
||||
|
||||
color: var(--text);
|
||||
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.method-empty-description {
|
||||
max-width: 520px;
|
||||
|
||||
margin: 0 auto 24px;
|
||||
|
||||
color: var(--muted);
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
|
||||
grid-template-columns:
|
||||
repeat(
|
||||
auto-fit,
|
||||
minmax(180px, 1fr)
|
||||
);
|
||||
|
||||
gap: 18px;
|
||||
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
|
||||
border: 1px solid var(--border);
|
||||
|
||||
border-radius: 10px;
|
||||
|
||||
padding: 18px;
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.stat-value {
|
||||
margin-bottom: 8px;
|
||||
|
||||
color: var(--accent);
|
||||
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.stat-title {
|
||||
color: var(--muted);
|
||||
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
|
||||
grid-template-columns:
|
||||
minmax(260px, 0.35fr)
|
||||
minmax(500px, 1fr);
|
||||
|
||||
gap: 24px;
|
||||
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
|
||||
border: 1px solid var(--border);
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
padding: 22px;
|
||||
|
||||
margin-bottom: 24px;
|
||||
|
||||
box-shadow:
|
||||
0 2px 10px rgba(0, 0, 0, 0.20);
|
||||
}
|
||||
|
||||
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
|
||||
color: var(--text);
|
||||
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
|
||||
max-height: 700px;
|
||||
|
||||
border: 1px solid var(--border);
|
||||
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
|
||||
th {
|
||||
background: var(--surface-light);
|
||||
|
||||
color: var(--text);
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
text-align: left;
|
||||
|
||||
padding: 12px;
|
||||
|
||||
border-bottom: 2px solid var(--border);
|
||||
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
vertical-align: top;
|
||||
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
|
||||
tbody tr:hover {
|
||||
background: rgba(105, 167, 255, 0.05);
|
||||
}
|
||||
|
||||
|
||||
.status-list {
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: space-between;
|
||||
|
||||
gap: 12px;
|
||||
|
||||
padding-bottom: 10px;
|
||||
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
|
||||
.status-row:last-child {
|
||||
padding-bottom: 0;
|
||||
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
min-height: 24px;
|
||||
|
||||
padding: 0 9px;
|
||||
|
||||
border-radius: 6px;
|
||||
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
background: var(--surface-light);
|
||||
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
|
||||
.badge-created,
|
||||
.badge-queued {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
|
||||
.badge-running,
|
||||
.badge-partial {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
.badge-completed {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
|
||||
.badge-failed,
|
||||
.badge-cancelled {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
|
||||
.empty-state {
|
||||
padding: 35px 20px;
|
||||
|
||||
color: var(--muted);
|
||||
|
||||
text-align: center;
|
||||
|
||||
border: 1px dashed var(--border);
|
||||
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
||||
.cracklab-header-inner {
|
||||
align-items: flex-start;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 12px;
|
||||
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-nav {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.methods-page-header {
|
||||
align-items: stretch;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
.methods-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.method-card {
|
||||
align-items: stretch;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
|
||||
.method-card-meta {
|
||||
width: 100%;
|
||||
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
||||
.cracklab-content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
|
||||
.card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
>
|
||||
|
||||
<title>
|
||||
{% block title %}
|
||||
CrackLab
|
||||
{% endblock %}
|
||||
</title>
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('cracklab.static', filename='cracklab.css') }}"
|
||||
>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<header class="cracklab-header">
|
||||
|
||||
<div class="cracklab-header-inner">
|
||||
|
||||
<a
|
||||
class="cracklab-brand"
|
||||
href="{{ url_for('cracklab.dashboard') }}"
|
||||
>
|
||||
<span class="cracklab-brand-title">
|
||||
WiFi GPS Mapper
|
||||
</span>
|
||||
|
||||
<span class="cracklab-brand-subtitle">
|
||||
CrackLab
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="cracklab-nav">
|
||||
|
||||
<a
|
||||
class="{% if active_page == 'dashboard' %}active{% endif %}"
|
||||
href="{{ url_for('cracklab.dashboard') }}"
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="{% if active_page == 'jobs' %}active{% endif %}"
|
||||
href="{{ url_for('cracklab.jobs_page') }}"
|
||||
>
|
||||
Jobs
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="{% if active_page == 'methods' %}active{% endif %}"
|
||||
href="{{ url_for('cracklab.methods_page') }}"
|
||||
>
|
||||
Methods
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="{% if active_page == 'clients' %}active{% endif %}"
|
||||
href="{{ url_for('cracklab.clients_page') }}"
|
||||
>
|
||||
Clients
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="{% if active_page == 'results' %}active{% endif %}"
|
||||
href="{{ url_for('cracklab.results_page') }}"
|
||||
>
|
||||
Results
|
||||
</a>
|
||||
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<main class="cracklab-content">
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
|
||||
</main>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,215 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
CrackLab — Dashboard
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1 class="page-title">
|
||||
CrackLab
|
||||
</h1>
|
||||
|
||||
<div class="page-description">
|
||||
Job and cracking coverage management.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="stats-grid">
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{{ methods_count }}
|
||||
</div>
|
||||
|
||||
<div class="stat-title">
|
||||
Methods
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{{ jobs_count }}
|
||||
</div>
|
||||
|
||||
<div class="stat-title">
|
||||
Jobs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{{ active_jobs_count }}
|
||||
</div>
|
||||
|
||||
<div class="stat-title">
|
||||
Active Jobs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{{ clients_count }}
|
||||
</div>
|
||||
|
||||
<div class="stat-title">
|
||||
Clients
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{{ results_count }}
|
||||
</div>
|
||||
|
||||
<div class="stat-title">
|
||||
Results
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="dashboard-grid">
|
||||
|
||||
<section class="card">
|
||||
|
||||
<h2>
|
||||
Job Status
|
||||
</h2>
|
||||
|
||||
<div class="status-list">
|
||||
|
||||
{% for status in (
|
||||
"CREATED",
|
||||
"QUEUED",
|
||||
"RUNNING",
|
||||
"PARTIAL",
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"CANCELLED"
|
||||
) %}
|
||||
|
||||
<div class="status-row">
|
||||
|
||||
<span>
|
||||
<span class="badge badge-{{ status|lower }}">
|
||||
{{ status }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<strong>
|
||||
{{ job_statuses.get(status, 0) }}
|
||||
</strong>
|
||||
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
<section class="card">
|
||||
|
||||
<h2>
|
||||
Recent Jobs
|
||||
</h2>
|
||||
|
||||
{% if recent_jobs %}
|
||||
|
||||
<div class="table-container">
|
||||
|
||||
<table>
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Method</th>
|
||||
<th>Status</th>
|
||||
<th>Hashes</th>
|
||||
<th>Client</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
{% for job in recent_jobs %}
|
||||
|
||||
<tr>
|
||||
|
||||
<td>
|
||||
<a
|
||||
href="{{ url_for(
|
||||
'cracklab.jobs_page'
|
||||
) }}"
|
||||
>
|
||||
{{ job["id"] }}
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{{ method_names.get(
|
||||
job["method_id"],
|
||||
job["method_id"]
|
||||
) }}
|
||||
<span class="muted">
|
||||
v{{ job["method_version"] }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-{{
|
||||
job["status"]|lower
|
||||
}}"
|
||||
>
|
||||
{{ job["status"] }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{{ job["hash_count"] }}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if job["client_id"] %}
|
||||
{{ client_names.get(
|
||||
job["client_id"],
|
||||
job["client_id"]
|
||||
) }}
|
||||
{% else %}
|
||||
<span class="muted">
|
||||
—
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
||||
<div class="empty-state">
|
||||
No jobs have been issued yet.
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
CrackLab — Methods
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="methods-page">
|
||||
|
||||
<div class="methods-page-header">
|
||||
|
||||
<div>
|
||||
<h1 class="page-title">
|
||||
Methods
|
||||
</h1>
|
||||
|
||||
<div class="page-description">
|
||||
Рабочие методы перебора, сохранённые в базе данных.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if methods %}
|
||||
<a
|
||||
class="methods-button"
|
||||
href="{{ url_for('cracklab.method_create_page') }}"
|
||||
>
|
||||
+ Создать метод
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{% if methods %}
|
||||
|
||||
<section class="methods-list">
|
||||
|
||||
{% for method in methods %}
|
||||
|
||||
<article class="method-card">
|
||||
|
||||
<div class="method-card-main">
|
||||
|
||||
<div class="method-card-title">
|
||||
{{ method.name }}
|
||||
</div>
|
||||
|
||||
{% if method.description %}
|
||||
<div class="method-card-description">
|
||||
{{ method.description }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="method-card-id">
|
||||
{{ method.id }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="method-card-meta">
|
||||
|
||||
<div class="method-meta-item">
|
||||
<span>Version</span>
|
||||
|
||||
<strong>
|
||||
{% if method.version %}
|
||||
{{ method.version }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="method-meta-item">
|
||||
<span>Steps</span>
|
||||
|
||||
<strong>
|
||||
{{ method.steps_count }}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="method-meta-item">
|
||||
<span>Created</span>
|
||||
|
||||
<strong>
|
||||
{{ method.created_at }}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</article>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</section>
|
||||
|
||||
{% else %}
|
||||
|
||||
<section class="method-empty">
|
||||
|
||||
<div class="method-empty-title">
|
||||
Методов пока нет
|
||||
</div>
|
||||
|
||||
<div class="method-empty-description">
|
||||
Создайте первый метод, чтобы начать
|
||||
выдавать задания CrackLab.
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="methods-button"
|
||||
href="{{ url_for('cracklab.method_create_page') }}"
|
||||
>
|
||||
+ Создать метод
|
||||
</a>
|
||||
|
||||
</section>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
CrackLab — {{ title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1 class="page-title">
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<div class="page-description">
|
||||
{{ description }}
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
|
||||
<div class="empty-state">
|
||||
This section is under development.
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
+2611
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
+365
@@ -0,0 +1,365 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import yaml
|
||||
|
||||
|
||||
class Config:
|
||||
|
||||
def __init__(self):
|
||||
|
||||
# Путь к конфигурационному файлу
|
||||
config_file = (
|
||||
Path(__file__).parent.parent
|
||||
/
|
||||
"config"
|
||||
/
|
||||
"config.yaml"
|
||||
)
|
||||
|
||||
|
||||
# Загрузка YAML
|
||||
with open(
|
||||
config_file,
|
||||
"r"
|
||||
) as file:
|
||||
|
||||
self.cfg = yaml.safe_load(file)
|
||||
|
||||
self._interface = None
|
||||
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# GPS
|
||||
# -----------------------------
|
||||
|
||||
@property
|
||||
def gps_source(self):
|
||||
gps = self.cfg["gps"]
|
||||
|
||||
return gps.get(
|
||||
"source",
|
||||
"gpsrelay"
|
||||
)
|
||||
|
||||
@property
|
||||
def gpsrelay_host(self):
|
||||
gps = self.cfg["gps"]
|
||||
|
||||
if "gpsrelay" in gps:
|
||||
return gps["gpsrelay"]["host"]
|
||||
|
||||
return gps["host"]
|
||||
|
||||
@property
|
||||
def gpsrelay_port(self):
|
||||
gps = self.cfg["gps"]
|
||||
|
||||
if "gpsrelay" in gps:
|
||||
return gps["gpsrelay"]["port"]
|
||||
|
||||
return gps["port"]
|
||||
|
||||
@property
|
||||
def gnss_device(self):
|
||||
gnss = self.cfg["gps"]["gnss"]
|
||||
handler = gnss["handler"]
|
||||
|
||||
return gnss[handler]["device"]
|
||||
|
||||
@property
|
||||
def gnss_at_device(self):
|
||||
return self.cfg["gps"]["gnss"]["modem"]["at_device"]
|
||||
|
||||
@property
|
||||
def gnss_handler(self):
|
||||
return self.cfg["gps"]["gnss"]["handler"]
|
||||
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Database
|
||||
# -----------------------------
|
||||
|
||||
@property
|
||||
def database(self):
|
||||
|
||||
return self.cfg["database"]["file"]
|
||||
|
||||
@property
|
||||
def vendor_database(self):
|
||||
|
||||
return (
|
||||
Path(__file__).parent.parent
|
||||
/
|
||||
self.cfg["vendor"]["database"]
|
||||
)
|
||||
|
||||
@property
|
||||
def vendor_sources(self):
|
||||
|
||||
return self.cfg["vendor"]["sources"]
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# WiFi
|
||||
# -----------------------------
|
||||
|
||||
@property
|
||||
def interface(self):
|
||||
|
||||
if self._interface is None:
|
||||
|
||||
raise RuntimeError(
|
||||
"WiFi interface is not selected."
|
||||
)
|
||||
|
||||
return self._interface
|
||||
|
||||
def select_interface(self):
|
||||
|
||||
result = subprocess.run(
|
||||
|
||||
[
|
||||
"sudo",
|
||||
"-n",
|
||||
"iw",
|
||||
"dev"
|
||||
],
|
||||
|
||||
capture_output=True,
|
||||
|
||||
text=True
|
||||
|
||||
)
|
||||
|
||||
interfaces = []
|
||||
|
||||
current = None
|
||||
|
||||
for line in result.stdout.splitlines():
|
||||
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith("Interface"):
|
||||
|
||||
current = line.split()[1]
|
||||
|
||||
elif (
|
||||
line.startswith("type")
|
||||
and
|
||||
current
|
||||
):
|
||||
|
||||
if "monitor" in line or "managed" in line:
|
||||
|
||||
interfaces.append(
|
||||
current
|
||||
)
|
||||
|
||||
current = None
|
||||
|
||||
|
||||
if not interfaces:
|
||||
|
||||
raise RuntimeError(
|
||||
"No WiFi interface found."
|
||||
)
|
||||
|
||||
|
||||
iwlist = subprocess.run(
|
||||
|
||||
[
|
||||
"sudo",
|
||||
"-n",
|
||||
"iw",
|
||||
"list"
|
||||
],
|
||||
|
||||
capture_output=True,
|
||||
|
||||
text=True
|
||||
|
||||
).stdout
|
||||
|
||||
|
||||
if "monitor" not in iwlist.lower():
|
||||
|
||||
raise RuntimeError(
|
||||
"No adapter supports monitor mode."
|
||||
)
|
||||
|
||||
|
||||
import os
|
||||
|
||||
requested_interface = os.environ.get(
|
||||
"WIFI_INTERFACE"
|
||||
)
|
||||
|
||||
if requested_interface:
|
||||
|
||||
if requested_interface in interfaces:
|
||||
|
||||
self._interface = (
|
||||
requested_interface
|
||||
)
|
||||
|
||||
return self._interface
|
||||
|
||||
raise RuntimeError(
|
||||
f"WiFi interface '{requested_interface}' "
|
||||
"is not available."
|
||||
)
|
||||
|
||||
|
||||
if len(interfaces) == 1:
|
||||
|
||||
self._interface = interfaces[0]
|
||||
|
||||
return self._interface
|
||||
|
||||
|
||||
print()
|
||||
|
||||
print(
|
||||
"Available WiFi interfaces:"
|
||||
)
|
||||
|
||||
print()
|
||||
|
||||
for index, iface in enumerate(
|
||||
interfaces,
|
||||
start=1
|
||||
):
|
||||
|
||||
print(
|
||||
f"{index}) {iface}"
|
||||
)
|
||||
|
||||
print()
|
||||
|
||||
while True:
|
||||
|
||||
try:
|
||||
|
||||
value = int(
|
||||
input(
|
||||
"Select interface: "
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
1
|
||||
<= value
|
||||
<= len(interfaces)
|
||||
):
|
||||
|
||||
self._interface = (
|
||||
interfaces[value - 1]
|
||||
)
|
||||
|
||||
return self._interface
|
||||
|
||||
except Exception:
|
||||
|
||||
pass
|
||||
|
||||
print(
|
||||
"Invalid selection."
|
||||
)
|
||||
|
||||
@property
|
||||
def wifi_enable_2_4ghz(self):
|
||||
|
||||
return self.cfg["wifi"]["enable_2_4ghz"]
|
||||
|
||||
|
||||
@property
|
||||
def wifi_channels_2_4ghz(self):
|
||||
|
||||
return self.cfg["wifi"]["channels_2_4ghz"]
|
||||
|
||||
|
||||
@property
|
||||
def wifi_enable_5ghz(self):
|
||||
|
||||
return self.cfg["wifi"]["enable_5ghz"]
|
||||
|
||||
|
||||
@property
|
||||
def wifi_channels_5ghz(self):
|
||||
|
||||
return self.cfg["wifi"]["channels_5ghz"]
|
||||
|
||||
|
||||
@property
|
||||
def wifi_enable_6ghz(self):
|
||||
|
||||
return self.cfg["wifi"]["enable_6ghz"]
|
||||
|
||||
|
||||
@property
|
||||
def wifi_channels_6ghz(self):
|
||||
|
||||
return self.cfg["wifi"]["channels_6ghz"]
|
||||
|
||||
|
||||
@property
|
||||
def wifi_filtermode(self):
|
||||
|
||||
return self.cfg["wifi"]["filtermode"]
|
||||
|
||||
|
||||
@property
|
||||
def wifi_extra_args(self):
|
||||
|
||||
return self.cfg["wifi"]["extra_args"]
|
||||
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Tools
|
||||
# -----------------------------
|
||||
|
||||
@property
|
||||
def hcxdumptool(self):
|
||||
|
||||
return (
|
||||
Path(__file__).parent.parent
|
||||
/
|
||||
"bin"
|
||||
/
|
||||
"hcxdumptool"
|
||||
)
|
||||
|
||||
|
||||
@property
|
||||
def hcxpcapngtool(self):
|
||||
|
||||
return (
|
||||
Path(__file__).parent.parent
|
||||
/
|
||||
"bin"
|
||||
/
|
||||
"hcxpcapngtool"
|
||||
)
|
||||
|
||||
# -----------------------------
|
||||
# Capture
|
||||
# -----------------------------
|
||||
|
||||
@property
|
||||
def capture_directory(self):
|
||||
|
||||
return self.cfg["captures"]["directory"]
|
||||
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Status
|
||||
# -----------------------------
|
||||
|
||||
@property
|
||||
def status_interval(self):
|
||||
|
||||
return self.cfg["status"]["interval"]
|
||||
+1973
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,314 @@
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
import sqlite3
|
||||
import csv
|
||||
|
||||
from vendor_import import VendorImporter
|
||||
|
||||
class EnvironmentChecker:
|
||||
"""
|
||||
Проверка окружения проекта.
|
||||
|
||||
Проверяются:
|
||||
|
||||
- наличие необходимых каталогов;
|
||||
- наличие бинарных файлов;
|
||||
- права на выполнение;
|
||||
- версия hcxdumptool;
|
||||
- наличие базы производителей.
|
||||
|
||||
Основная база проекта не проверяется,
|
||||
так как она может быть создана автоматически
|
||||
при первом запуске.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config
|
||||
):
|
||||
|
||||
self.config = config
|
||||
|
||||
self.log = logging.getLogger(
|
||||
"environment"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Главная проверка
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def check(self):
|
||||
|
||||
self.log.info(
|
||||
"Checking application environment..."
|
||||
)
|
||||
|
||||
|
||||
self.check_binary(
|
||||
self.config.hcxdumptool,
|
||||
"hcxdumptool"
|
||||
)
|
||||
|
||||
|
||||
self.check_binary(
|
||||
self.config.hcxpcapngtool,
|
||||
"hcxpcapngtool"
|
||||
)
|
||||
|
||||
|
||||
self.check_hcxdumptool_version()
|
||||
|
||||
|
||||
self.check_vendor_database()
|
||||
|
||||
|
||||
self.log.info(
|
||||
"Environment OK"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Проверка бинарника
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def check_binary(
|
||||
self,
|
||||
path,
|
||||
name
|
||||
):
|
||||
|
||||
if not path.exists():
|
||||
|
||||
raise RuntimeError(
|
||||
|
||||
f"{name} not found:\n\n{path}"
|
||||
|
||||
)
|
||||
|
||||
|
||||
if not path.is_file():
|
||||
|
||||
raise RuntimeError(
|
||||
|
||||
f"{name} is not a file:\n\n{path}"
|
||||
|
||||
)
|
||||
|
||||
|
||||
if not os.access(
|
||||
path,
|
||||
os.X_OK
|
||||
):
|
||||
|
||||
raise RuntimeError(
|
||||
|
||||
f"{name} is not executable:\n\n"
|
||||
f"{path}\n\n"
|
||||
f"Run:\n"
|
||||
f"chmod +x {path}"
|
||||
|
||||
)
|
||||
|
||||
|
||||
self.log.info(
|
||||
f"{name}: OK"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Проверка версии hcxdumptool
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def check_hcxdumptool_version(self):
|
||||
|
||||
result = subprocess.run(
|
||||
|
||||
[
|
||||
str(
|
||||
self.config.hcxdumptool
|
||||
),
|
||||
"--help"
|
||||
],
|
||||
|
||||
capture_output=True,
|
||||
|
||||
text=True
|
||||
|
||||
)
|
||||
|
||||
|
||||
output = (
|
||||
result.stdout
|
||||
+
|
||||
result.stderr
|
||||
)
|
||||
|
||||
|
||||
if "7." not in output:
|
||||
|
||||
raise RuntimeError(
|
||||
|
||||
"Unsupported hcxdumptool version.\n"
|
||||
"Version 7.x is required."
|
||||
|
||||
)
|
||||
|
||||
|
||||
self.log.info(
|
||||
"hcxdumptool version: OK"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Проверка базы производителей
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def check_vendor_database(self):
|
||||
|
||||
database = self.config.vendor_database
|
||||
|
||||
rebuild = False
|
||||
|
||||
if not database.exists():
|
||||
|
||||
self.log.warning(
|
||||
"Vendor database not found"
|
||||
)
|
||||
|
||||
rebuild = True
|
||||
|
||||
elif database.stat().st_size == 0:
|
||||
|
||||
self.log.warning(
|
||||
"Vendor database is empty"
|
||||
)
|
||||
|
||||
rebuild = True
|
||||
|
||||
else:
|
||||
|
||||
try:
|
||||
|
||||
connection = sqlite3.connect(
|
||||
database
|
||||
)
|
||||
|
||||
cursor = connection.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' "
|
||||
"AND name='oui'"
|
||||
)
|
||||
|
||||
if cursor.fetchone() is None:
|
||||
|
||||
raise RuntimeError(
|
||||
"vendors table not found"
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
|
||||
"SELECT COUNT(*) FROM oui"
|
||||
|
||||
)
|
||||
|
||||
records = cursor.fetchone()[0]
|
||||
|
||||
if records < 50000:
|
||||
|
||||
raise RuntimeError(
|
||||
|
||||
f"Only {records} records"
|
||||
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
|
||||
"SELECT value "
|
||||
"FROM metadata "
|
||||
"WHERE key='import_status'"
|
||||
|
||||
)
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
if (
|
||||
|
||||
row is None
|
||||
|
||||
or
|
||||
|
||||
row[0] != "ok"
|
||||
|
||||
):
|
||||
|
||||
raise RuntimeError(
|
||||
|
||||
"Invalid import_status"
|
||||
|
||||
)
|
||||
|
||||
connection.close()
|
||||
|
||||
self.log.info(
|
||||
|
||||
f"Vendor database: OK ({records} records)"
|
||||
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
except KeyboardInterrupt:
|
||||
|
||||
self.log.warning(
|
||||
"Download cancelled by user"
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
except Exception as error:
|
||||
|
||||
self.log.warning(
|
||||
|
||||
f"Vendor database is invalid: {error}"
|
||||
|
||||
)
|
||||
|
||||
rebuild = True
|
||||
|
||||
if rebuild:
|
||||
|
||||
database.unlink(
|
||||
missing_ok=True
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"Creating vendor database..."
|
||||
)
|
||||
|
||||
importer = VendorImporter(
|
||||
self.config
|
||||
)
|
||||
|
||||
importer.bootstrap()
|
||||
|
||||
if not database.exists():
|
||||
|
||||
raise RuntimeError(
|
||||
|
||||
"Unable to create vendor database."
|
||||
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"Vendor database created"
|
||||
)
|
||||
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
import socket
|
||||
import threading
|
||||
import select
|
||||
import os
|
||||
import termios
|
||||
import logging
|
||||
import subprocess
|
||||
import pynmea2
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from models import GPSData
|
||||
|
||||
|
||||
class GPSRelaySource:
|
||||
|
||||
def __init__(self, host, port):
|
||||
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
self.socket = None
|
||||
|
||||
|
||||
def open(self):
|
||||
|
||||
self.socket = socket.create_connection(
|
||||
(
|
||||
self.host,
|
||||
self.port
|
||||
)
|
||||
)
|
||||
|
||||
return self.socket.makefile()
|
||||
|
||||
|
||||
def close(self):
|
||||
|
||||
if self.socket:
|
||||
|
||||
self.socket.close()
|
||||
|
||||
|
||||
class GpsdSource:
|
||||
|
||||
def __init__(self, host="127.0.0.1", port=2947):
|
||||
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
self.socket = None
|
||||
|
||||
|
||||
def open(self):
|
||||
|
||||
self.socket = socket.create_connection(
|
||||
(
|
||||
self.host,
|
||||
self.port
|
||||
)
|
||||
)
|
||||
|
||||
file = self.socket.makefile()
|
||||
|
||||
file.readline()
|
||||
|
||||
self.socket.sendall(
|
||||
b'?WATCH={"enable":true,"json":false,"nmea":true};\n'
|
||||
)
|
||||
|
||||
return file
|
||||
|
||||
|
||||
def close(self):
|
||||
|
||||
if self.socket:
|
||||
|
||||
self.socket.close()
|
||||
|
||||
|
||||
class SerialSource:
|
||||
|
||||
def __init__(self, device):
|
||||
|
||||
self.device = device
|
||||
|
||||
self.fd = None
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
|
||||
def open(self):
|
||||
|
||||
self.fd = os.open(
|
||||
self.device,
|
||||
os.O_RDWR | os.O_NOCTTY
|
||||
)
|
||||
|
||||
attributes = termios.tcgetattr(
|
||||
self.fd
|
||||
)
|
||||
|
||||
attributes[0] = 0
|
||||
attributes[1] = 0
|
||||
attributes[2] = (
|
||||
termios.CLOCAL |
|
||||
termios.CREAD |
|
||||
termios.CS8
|
||||
)
|
||||
attributes[3] = 0
|
||||
|
||||
attributes[4] = termios.B9600
|
||||
attributes[5] = termios.B9600
|
||||
|
||||
attributes[6][
|
||||
termios.VMIN
|
||||
] = 0
|
||||
|
||||
attributes[6][
|
||||
termios.VTIME
|
||||
] = 5
|
||||
|
||||
termios.tcsetattr(
|
||||
self.fd,
|
||||
termios.TCSANOW,
|
||||
attributes
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
def readline(self):
|
||||
|
||||
with self.lock:
|
||||
|
||||
buffer = b""
|
||||
|
||||
while True:
|
||||
|
||||
ready, _, _ = select.select(
|
||||
[self.fd],
|
||||
[],
|
||||
[],
|
||||
0.5
|
||||
)
|
||||
|
||||
if not ready:
|
||||
return ""
|
||||
|
||||
data = os.read(
|
||||
self.fd,
|
||||
1
|
||||
)
|
||||
|
||||
if not data:
|
||||
return ""
|
||||
|
||||
buffer += data
|
||||
|
||||
if data == b"\n":
|
||||
return buffer.decode(
|
||||
errors="replace"
|
||||
)
|
||||
|
||||
|
||||
def close(self):
|
||||
|
||||
with self.lock:
|
||||
|
||||
if self.fd is not None:
|
||||
|
||||
os.close(
|
||||
self.fd
|
||||
)
|
||||
|
||||
self.fd = None
|
||||
|
||||
class ModemGNSSSource:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device,
|
||||
at_device
|
||||
):
|
||||
|
||||
self.device = device
|
||||
self.at_device = at_device
|
||||
|
||||
self.serial = SerialSource(
|
||||
device
|
||||
)
|
||||
|
||||
|
||||
def send_at(self, command):
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"sudo",
|
||||
"-n",
|
||||
"atinout",
|
||||
"-",
|
||||
self.at_device,
|
||||
"-"
|
||||
],
|
||||
input=command + "\r\n",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
|
||||
raise RuntimeError(
|
||||
"AT command failed: "
|
||||
+ result.stderr.strip()
|
||||
)
|
||||
|
||||
return result.stdout
|
||||
|
||||
|
||||
def ensure_gnss_enabled(self):
|
||||
|
||||
response = self.send_at(
|
||||
"AT+QGPS?"
|
||||
)
|
||||
|
||||
if "+QGPS: 1" not in response:
|
||||
|
||||
self.send_at(
|
||||
"AT+QGPS=1"
|
||||
)
|
||||
|
||||
response = self.send_at(
|
||||
"AT+QGPS?"
|
||||
)
|
||||
|
||||
if "+QGPS: 1" not in response:
|
||||
|
||||
raise RuntimeError(
|
||||
"GNSS did not start."
|
||||
)
|
||||
|
||||
|
||||
def open(self):
|
||||
|
||||
self.ensure_gnss_enabled()
|
||||
|
||||
return self.serial.open()
|
||||
|
||||
|
||||
def readline(self):
|
||||
|
||||
return self.serial.readline()
|
||||
|
||||
|
||||
def close(self):
|
||||
|
||||
self.serial.close()
|
||||
|
||||
|
||||
def create_gps_source(cfg):
|
||||
|
||||
if cfg.gps_source == "gpsrelay":
|
||||
|
||||
return GPSRelaySource(
|
||||
cfg.gpsrelay_host,
|
||||
cfg.gpsrelay_port
|
||||
)
|
||||
|
||||
if cfg.gps_source == "gnss":
|
||||
|
||||
if cfg.gnss_handler == "gpsd":
|
||||
|
||||
return GpsdSource()
|
||||
|
||||
if cfg.gnss_handler == "modem":
|
||||
|
||||
return ModemGNSSSource(
|
||||
cfg.gnss_device,
|
||||
cfg.gnss_at_device
|
||||
)
|
||||
|
||||
if cfg.gnss_handler == "direct":
|
||||
|
||||
return SerialSource(
|
||||
cfg.gnss_device
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"Unknown GNSS handler: {cfg.gnss_handler}"
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"Unknown GPS source: {cfg.gps_source}"
|
||||
)
|
||||
|
||||
|
||||
def parse_nmea(
|
||||
line,
|
||||
received
|
||||
):
|
||||
|
||||
if not line.startswith("$"):
|
||||
|
||||
return None
|
||||
|
||||
try:
|
||||
msg = pynmea2.parse(
|
||||
line
|
||||
)
|
||||
|
||||
except Exception:
|
||||
|
||||
return None
|
||||
|
||||
message_type = getattr(
|
||||
msg,
|
||||
"sentence_type",
|
||||
""
|
||||
)
|
||||
|
||||
if message_type == "RMC":
|
||||
|
||||
if msg.status != "A":
|
||||
return None
|
||||
|
||||
speed = float(
|
||||
msg.spd_over_grnd or 0
|
||||
)
|
||||
|
||||
elif message_type == "GNS":
|
||||
|
||||
mode = getattr(
|
||||
msg,
|
||||
"mode_indicator",
|
||||
""
|
||||
)
|
||||
|
||||
if not mode or mode[0] == "N":
|
||||
return None
|
||||
|
||||
speed = 0
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
try:
|
||||
|
||||
return GPSData(
|
||||
latitude=msg.latitude,
|
||||
longitude=msg.longitude,
|
||||
speed=speed,
|
||||
timestamp=received,
|
||||
valid=True
|
||||
)
|
||||
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class GPSManager:
|
||||
|
||||
def __init__(self, source, session):
|
||||
|
||||
self.source = source
|
||||
|
||||
self.session = session
|
||||
|
||||
self.current = None
|
||||
|
||||
self.running = False
|
||||
|
||||
self.thread = None
|
||||
|
||||
self.log = logging.getLogger("gps")
|
||||
|
||||
|
||||
def start(self):
|
||||
|
||||
self.running = True
|
||||
|
||||
self.thread = threading.Thread(
|
||||
target=self.worker,
|
||||
daemon=True
|
||||
)
|
||||
|
||||
self.thread.start()
|
||||
|
||||
|
||||
def parse_nmea(
|
||||
self,
|
||||
line,
|
||||
received
|
||||
):
|
||||
|
||||
gps = parse_nmea(
|
||||
line,
|
||||
received
|
||||
)
|
||||
|
||||
if gps is None:
|
||||
return
|
||||
|
||||
self.current = gps
|
||||
self.session.update_gps(
|
||||
gps
|
||||
)
|
||||
|
||||
|
||||
def worker(self):
|
||||
|
||||
file = None
|
||||
|
||||
try:
|
||||
|
||||
file = self.source.open()
|
||||
|
||||
self.log.info(
|
||||
"GPS connection established"
|
||||
)
|
||||
|
||||
with open(
|
||||
self.session.gps_file,
|
||||
"a"
|
||||
) as gps_log:
|
||||
|
||||
while self.running:
|
||||
|
||||
if hasattr(
|
||||
self.source,
|
||||
"readline"
|
||||
):
|
||||
|
||||
line = self.source.readline()
|
||||
|
||||
else:
|
||||
|
||||
line = file.readline()
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
received = datetime.now(
|
||||
timezone.utc
|
||||
)
|
||||
|
||||
gps_log.write(
|
||||
f"{received.isoformat().replace('+00:00', 'Z')};{line}"
|
||||
)
|
||||
|
||||
gps_log.flush()
|
||||
|
||||
self.session.add_nmea()
|
||||
|
||||
self.parse_nmea(
|
||||
line,
|
||||
received
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
|
||||
self.log.error(
|
||||
f"GPS error: {error}"
|
||||
)
|
||||
|
||||
finally:
|
||||
|
||||
self.source.close()
|
||||
|
||||
|
||||
def get(self):
|
||||
|
||||
return self.current
|
||||
|
||||
|
||||
def stop(self):
|
||||
|
||||
self.running = False
|
||||
|
||||
self.source.close()
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
import threading
|
||||
import time
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from gps import create_gps_source, parse_nmea
|
||||
|
||||
|
||||
class GPSTestService:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cfg,
|
||||
callback=None
|
||||
):
|
||||
|
||||
self.cfg = cfg
|
||||
self.callback = callback
|
||||
|
||||
self.thread = None
|
||||
self.stop_event = threading.Event()
|
||||
|
||||
self.running = False
|
||||
self.state = "READY"
|
||||
|
||||
self.nmea_received = False
|
||||
self.fix_received = False
|
||||
|
||||
self.nmea_count = 0
|
||||
self.fix_count = 0
|
||||
|
||||
self.latitude = None
|
||||
self.longitude = None
|
||||
|
||||
self.error = None
|
||||
|
||||
|
||||
def emit(self, message):
|
||||
|
||||
if self.callback:
|
||||
self.callback(message)
|
||||
|
||||
|
||||
def start(self):
|
||||
|
||||
if (
|
||||
self.thread is not None
|
||||
and
|
||||
self.thread.is_alive()
|
||||
):
|
||||
return False
|
||||
|
||||
self.stop_event.clear()
|
||||
|
||||
self.running = True
|
||||
self.state = "STARTING"
|
||||
self.nmea_received = False
|
||||
self.fix_received = False
|
||||
self.nmea_count = 0
|
||||
self.fix_count = 0
|
||||
self.latitude = None
|
||||
self.longitude = None
|
||||
self.error = None
|
||||
|
||||
self.thread = threading.Thread(
|
||||
target=self.worker,
|
||||
daemon=True
|
||||
)
|
||||
|
||||
self.thread.start()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def stop(self):
|
||||
|
||||
self.stop_event.set()
|
||||
|
||||
|
||||
def worker(self):
|
||||
|
||||
source = None
|
||||
file = None
|
||||
|
||||
try:
|
||||
|
||||
self.emit(
|
||||
"[GPS TEST] Starting GPS test "
|
||||
"from saved configuration."
|
||||
)
|
||||
|
||||
source = create_gps_source(
|
||||
self.cfg
|
||||
)
|
||||
|
||||
self.emit(
|
||||
"[GPS TEST] Connecting to GPS source..."
|
||||
)
|
||||
|
||||
file = source.open()
|
||||
|
||||
self.state = "CONNECTED"
|
||||
|
||||
self.emit(
|
||||
"[GPS TEST] GPS source connected. "
|
||||
"Waiting for NMEA..."
|
||||
)
|
||||
|
||||
last_no_fix_message = 0
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
|
||||
if hasattr(
|
||||
source,
|
||||
"readline"
|
||||
):
|
||||
|
||||
line = source.readline()
|
||||
|
||||
else:
|
||||
|
||||
line = file.readline()
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
self.nmea_count += 1
|
||||
self.nmea_received = True
|
||||
|
||||
if self.state == "CONNECTED":
|
||||
|
||||
self.state = "NMEA"
|
||||
|
||||
self.emit(
|
||||
"[GPS TEST] NMEA received. "
|
||||
"Waiting for valid FIX..."
|
||||
)
|
||||
|
||||
received = datetime.now(
|
||||
timezone.utc
|
||||
)
|
||||
|
||||
gps = parse_nmea(
|
||||
line,
|
||||
received
|
||||
)
|
||||
|
||||
if gps is not None:
|
||||
|
||||
first_fix = not self.fix_received
|
||||
|
||||
self.fix_count += 1
|
||||
self.fix_received = True
|
||||
self.state = "FIX"
|
||||
self.latitude = gps.latitude
|
||||
self.longitude = gps.longitude
|
||||
|
||||
self.emit(
|
||||
"[GPS TEST] FIX received: "
|
||||
f"lat={gps.latitude:.6f} "
|
||||
f"lon={gps.longitude:.6f}"
|
||||
)
|
||||
|
||||
if first_fix:
|
||||
|
||||
self.emit(
|
||||
"[GPS TEST] GPS is ready "
|
||||
"for scanning."
|
||||
)
|
||||
|
||||
elif self.fix_count % 10 == 0:
|
||||
|
||||
self.emit(
|
||||
"[GPS TEST] GPS is ready "
|
||||
"for scanning. "
|
||||
f"FIX: lat={gps.latitude:.6f} "
|
||||
f"lon={gps.longitude:.6f}"
|
||||
)
|
||||
|
||||
elif (
|
||||
self.nmea_received
|
||||
and
|
||||
not self.fix_received
|
||||
and
|
||||
self.nmea_count % 10 == 0
|
||||
and
|
||||
time.monotonic() - last_no_fix_message >= 5
|
||||
):
|
||||
|
||||
last_no_fix_message = (
|
||||
time.monotonic()
|
||||
)
|
||||
|
||||
self.emit(
|
||||
"[GPS TEST] GPS is not ready: "
|
||||
"NMEA received, but no valid FIX yet. "
|
||||
f"NMEA: {self.nmea_count}"
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
|
||||
self.state = "ERROR"
|
||||
self.error = str(error)
|
||||
|
||||
self.emit(
|
||||
f"[GPS TEST] GPS source error: {error}"
|
||||
)
|
||||
|
||||
finally:
|
||||
|
||||
if source is not None:
|
||||
|
||||
try:
|
||||
source.close()
|
||||
|
||||
except Exception as error:
|
||||
|
||||
self.emit(
|
||||
f"[GPS TEST] Close error: {error}"
|
||||
)
|
||||
|
||||
self.running = False
|
||||
|
||||
if self.state != "ERROR":
|
||||
|
||||
self.state = "STOPPED"
|
||||
|
||||
self.emit(
|
||||
"[GPS TEST] Test stopped."
|
||||
)
|
||||
|
||||
|
||||
def run_gps_test(cfg):
|
||||
|
||||
def output(message):
|
||||
|
||||
print(
|
||||
message,
|
||||
flush=True
|
||||
)
|
||||
|
||||
service = GPSTestService(
|
||||
cfg,
|
||||
callback=output
|
||||
)
|
||||
|
||||
service.start()
|
||||
|
||||
try:
|
||||
|
||||
while service.running:
|
||||
|
||||
time.sleep(0.2)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
|
||||
print(
|
||||
"[GPS TEST] Test interrupted by user.",
|
||||
flush=True
|
||||
)
|
||||
|
||||
if service.fix_received:
|
||||
|
||||
print(
|
||||
"[GPS TEST] Result: "
|
||||
"GPS is ready for scanning.",
|
||||
flush=True
|
||||
)
|
||||
|
||||
print(
|
||||
"[GPS TEST] Last position: "
|
||||
f"lat={service.latitude:.6f} "
|
||||
f"lon={service.longitude:.6f}",
|
||||
flush=True
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
print(
|
||||
"[GPS TEST] Result: "
|
||||
"GPS is not ready for scanning.",
|
||||
flush=True
|
||||
)
|
||||
|
||||
service.stop()
|
||||
|
||||
while service.running:
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
return 0 if service.fix_received else 1
|
||||
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
||||
def setup_logger():
|
||||
|
||||
|
||||
Path(
|
||||
"logs"
|
||||
).mkdir(
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
|
||||
|
||||
level=logging.INFO,
|
||||
|
||||
|
||||
format=(
|
||||
|
||||
"%(asctime)s "
|
||||
"[%(levelname)s] "
|
||||
"%(name)s: "
|
||||
"%(message)s"
|
||||
|
||||
),
|
||||
|
||||
|
||||
handlers=[
|
||||
|
||||
|
||||
logging.FileHandler(
|
||||
"logs/project.log"
|
||||
),
|
||||
|
||||
|
||||
logging.StreamHandler()
|
||||
|
||||
]
|
||||
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
return logging.getLogger(
|
||||
"project-wifi"
|
||||
)
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
|
||||
|
||||
from config import Config
|
||||
from database import Database
|
||||
from gps import GPSManager, create_gps_source
|
||||
from gps_test import run_gps_test
|
||||
from logger import setup_logger
|
||||
from network import NetworkManagerControl
|
||||
from wifi import WiFiManager
|
||||
from session import Session
|
||||
from environment import EnvironmentChecker
|
||||
from sudo import SudoChecker
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Инициализация
|
||||
# ============================================================
|
||||
|
||||
|
||||
cfg = Config()
|
||||
|
||||
|
||||
if "--gps-test" in sys.argv:
|
||||
|
||||
sudo_checker = SudoChecker()
|
||||
|
||||
sudo_checker.check()
|
||||
|
||||
sys.exit(
|
||||
run_gps_test(
|
||||
cfg
|
||||
)
|
||||
)
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
logger.info(
|
||||
"Project started"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Проверка окружения
|
||||
# ============================================================
|
||||
|
||||
try:
|
||||
|
||||
environment = EnvironmentChecker(
|
||||
cfg
|
||||
)
|
||||
|
||||
environment.check()
|
||||
|
||||
sudo_checker = SudoChecker()
|
||||
|
||||
sudo_checker.check()
|
||||
|
||||
cfg.select_interface()
|
||||
|
||||
logger.info(
|
||||
f"WiFi interface: {cfg.interface}"
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
|
||||
logger.info(
|
||||
"Startup cancelled"
|
||||
)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
# ============================================================
|
||||
# Network
|
||||
# ============================================================
|
||||
|
||||
|
||||
network = NetworkManagerControl(
|
||||
cfg.interface
|
||||
)
|
||||
|
||||
wifi = None
|
||||
|
||||
gps = None
|
||||
|
||||
session = None
|
||||
|
||||
db = None
|
||||
|
||||
parser = None
|
||||
|
||||
session_id = None
|
||||
|
||||
network_prepared = False
|
||||
|
||||
shutdown_requested = False
|
||||
|
||||
scanner_state = "READY"
|
||||
|
||||
def set_scanner_state(state):
|
||||
|
||||
global scanner_state
|
||||
|
||||
if scanner_state == state:
|
||||
return
|
||||
|
||||
scanner_state = state
|
||||
|
||||
print(
|
||||
f"ScannerState: {state}",
|
||||
flush=True
|
||||
)
|
||||
|
||||
|
||||
def shutdown(signum=None, frame=None):
|
||||
|
||||
global shutdown_requested
|
||||
global network_prepared
|
||||
|
||||
if shutdown_requested:
|
||||
return
|
||||
|
||||
shutdown_requested = True
|
||||
|
||||
set_scanner_state(
|
||||
"STOPPED"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Shutdown requested"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
|
||||
if wifi:
|
||||
|
||||
wifi.stop()
|
||||
|
||||
|
||||
except Exception as error:
|
||||
|
||||
logger.error(
|
||||
f"WiFi stop error: {error}"
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
if parser and session:
|
||||
|
||||
parser.parse(
|
||||
session,
|
||||
session_id
|
||||
)
|
||||
|
||||
|
||||
except Exception as error:
|
||||
|
||||
logger.error(
|
||||
f"Parser error: {error}"
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
if gps:
|
||||
|
||||
gps.stop()
|
||||
|
||||
|
||||
except Exception as error:
|
||||
|
||||
logger.error(
|
||||
f"GPS stop error: {error}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
try:
|
||||
|
||||
if (db and session_id is not None):
|
||||
|
||||
db.flush_observations()
|
||||
|
||||
db.finalize_session(
|
||||
session_id
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
except Exception as error:
|
||||
|
||||
logger.error(
|
||||
f"Database finalize error: {error}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
try:
|
||||
|
||||
if network_prepared:
|
||||
|
||||
network.restore()
|
||||
|
||||
|
||||
except Exception as error:
|
||||
|
||||
logger.error(
|
||||
f"Network restore error: {error}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
try:
|
||||
|
||||
if db:
|
||||
|
||||
db.close()
|
||||
|
||||
|
||||
except Exception:
|
||||
|
||||
pass
|
||||
|
||||
|
||||
|
||||
logger.info(
|
||||
"Project stopped"
|
||||
)
|
||||
|
||||
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
signal.signal(
|
||||
signal.SIGINT,
|
||||
shutdown
|
||||
)
|
||||
|
||||
signal.signal(
|
||||
signal.SIGTERM,
|
||||
shutdown
|
||||
)
|
||||
|
||||
from parser import CaptureParser
|
||||
|
||||
# ============================================================
|
||||
# Подготовка оборудования
|
||||
# ============================================================
|
||||
|
||||
|
||||
try:
|
||||
|
||||
network_prepared = True
|
||||
|
||||
network.prepare()
|
||||
|
||||
# ============================================================
|
||||
# Сессия
|
||||
# ============================================================
|
||||
|
||||
session = Session(
|
||||
cfg.capture_directory
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Session directory: {session.path}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Capture file: {session.capture_file}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"GPS track: {session.gps_file}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Database
|
||||
# ============================================================
|
||||
|
||||
db = Database(
|
||||
cfg.database
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"SQLite initialized"
|
||||
)
|
||||
|
||||
parser = CaptureParser(
|
||||
cfg,
|
||||
db
|
||||
)
|
||||
|
||||
session_id = db.create_session(
|
||||
str(session.capture_file)
|
||||
)
|
||||
|
||||
session.id = session_id
|
||||
|
||||
wifi = WiFiManager(
|
||||
cfg,
|
||||
session,
|
||||
cfg.interface
|
||||
)
|
||||
|
||||
gps_source = create_gps_source(
|
||||
cfg
|
||||
)
|
||||
|
||||
gps = GPSManager(
|
||||
gps_source,
|
||||
session
|
||||
)
|
||||
|
||||
wifi.start()
|
||||
|
||||
gps.start()
|
||||
|
||||
set_scanner_state(
|
||||
"ACTIVE"
|
||||
)
|
||||
|
||||
|
||||
except KeyboardInterrupt:
|
||||
|
||||
logger.info(
|
||||
"Startup cancelled"
|
||||
)
|
||||
|
||||
shutdown()
|
||||
|
||||
except Exception as error:
|
||||
|
||||
logger.error(
|
||||
f"Startup error: {error}"
|
||||
)
|
||||
|
||||
shutdown()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Главный цикл
|
||||
# ============================================================
|
||||
|
||||
|
||||
last_status = 0
|
||||
|
||||
last_capture_size = 0
|
||||
|
||||
capture_stall_count = 0
|
||||
|
||||
last_sudo_keepalive = time.time()
|
||||
|
||||
sudo_keepalive_interval = 60
|
||||
|
||||
|
||||
while True:
|
||||
|
||||
|
||||
now = time.time()
|
||||
|
||||
if (
|
||||
now - last_sudo_keepalive
|
||||
>= sudo_keepalive_interval
|
||||
):
|
||||
|
||||
if sudo_checker.refresh():
|
||||
|
||||
logger.debug(
|
||||
"Sudo keepalive: OK"
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
logger.warning(
|
||||
"WARNING: sudo keepalive failed"
|
||||
)
|
||||
|
||||
last_sudo_keepalive = now
|
||||
|
||||
|
||||
if wifi and wifi.process:
|
||||
|
||||
return_code = wifi.process.poll()
|
||||
|
||||
if return_code is not None:
|
||||
|
||||
if return_code == 0:
|
||||
|
||||
logger.info(
|
||||
"hcxdumptool finished"
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
logger.error(
|
||||
f"hcxdumptool exited with code {return_code}"
|
||||
)
|
||||
|
||||
shutdown()
|
||||
|
||||
|
||||
|
||||
if ( now - last_status >= cfg.status_interval ):
|
||||
capture_bytes = 0
|
||||
try:
|
||||
capture_bytes = ( session.capture_file.stat().st_size )
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
#
|
||||
# контроль роста capture.pcapng
|
||||
#
|
||||
if last_capture_size:
|
||||
if capture_bytes <= last_capture_size:
|
||||
capture_stall_count += 1
|
||||
else:
|
||||
capture_stall_count = 0
|
||||
|
||||
last_capture_size = capture_bytes
|
||||
capture_size = ( capture_bytes / 1024 )
|
||||
capture_text = ( f"{capture_size:.1f}KB")
|
||||
|
||||
|
||||
|
||||
pid = "-"
|
||||
|
||||
if wifi and wifi.process:
|
||||
|
||||
pid = wifi.process.pid
|
||||
|
||||
gps_data = session.current_gps
|
||||
|
||||
|
||||
|
||||
if gps_data:
|
||||
|
||||
|
||||
logger.info(
|
||||
|
||||
"Status: "
|
||||
f"elapsed={session.elapsed()}s "
|
||||
f"nmea={session.nmea_messages} "
|
||||
f"gps={session.gps_positions} "
|
||||
f"lat={gps_data.latitude:.6f} "
|
||||
f"lon={gps_data.longitude:.6f} "
|
||||
f"capture={capture_text} "
|
||||
f"pid={pid}"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
else:
|
||||
|
||||
|
||||
logger.info(
|
||||
|
||||
"Status: "
|
||||
f"elapsed={session.elapsed()}s "
|
||||
f"nmea={session.nmea_messages} "
|
||||
f"gps={session.gps_positions} "
|
||||
f"capture={capture_text} "
|
||||
f"pid={pid} "
|
||||
"gps=no signal"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
last_status = now
|
||||
|
||||
if capture_stall_count >= 3:
|
||||
|
||||
logger.warning(
|
||||
"WARNING: capture file is not growing"
|
||||
)
|
||||
|
||||
set_scanner_state(
|
||||
"WARNING"
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
set_scanner_state(
|
||||
"ACTIVE"
|
||||
)
|
||||
|
||||
time.sleep(1)
|
||||
@@ -0,0 +1,11 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPSData:
|
||||
latitude: float
|
||||
longitude: float
|
||||
speed: float
|
||||
timestamp: datetime
|
||||
valid: bool
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
|
||||
class NetworkManagerControl:
|
||||
|
||||
|
||||
def __init__(self, interface):
|
||||
|
||||
self.interface = interface
|
||||
|
||||
self.log = logging.getLogger(
|
||||
"network"
|
||||
)
|
||||
|
||||
|
||||
self.restore_networkmanager = False
|
||||
|
||||
self.restore_wpa = False
|
||||
|
||||
|
||||
|
||||
def _run(self, command):
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"sudo",
|
||||
"-n"
|
||||
]
|
||||
+
|
||||
command,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _is_active(self, service):
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
|
||||
[
|
||||
"systemctl",
|
||||
"is-active",
|
||||
service
|
||||
],
|
||||
|
||||
capture_output=True,
|
||||
|
||||
text=True
|
||||
|
||||
)
|
||||
|
||||
|
||||
return (
|
||||
result.stdout.strip()
|
||||
==
|
||||
"active"
|
||||
)
|
||||
|
||||
|
||||
|
||||
def prepare(self):
|
||||
|
||||
|
||||
self.restore_networkmanager = self._is_active(
|
||||
"NetworkManager"
|
||||
)
|
||||
|
||||
|
||||
self.restore_wpa = self._is_active(
|
||||
"wpa_supplicant"
|
||||
)
|
||||
|
||||
|
||||
|
||||
self.log.info(
|
||||
f"NetworkManager : {'running' if self.restore_networkmanager else 'stopped'}"
|
||||
)
|
||||
|
||||
|
||||
self.log.info(
|
||||
f"wpa_supplicant : {'running' if self.restore_wpa else 'stopped'}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
if self.restore_networkmanager:
|
||||
|
||||
|
||||
self._run(
|
||||
[
|
||||
"systemctl",
|
||||
"stop",
|
||||
"NetworkManager"
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
||||
if self.restore_wpa:
|
||||
|
||||
|
||||
self._run(
|
||||
[
|
||||
"systemctl",
|
||||
"stop",
|
||||
"wpa_supplicant"
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
||||
self.log.info(
|
||||
"Network services stopped"
|
||||
)
|
||||
|
||||
|
||||
|
||||
def restore(self):
|
||||
|
||||
self.log.info(
|
||||
"Restoring WiFi interface"
|
||||
)
|
||||
|
||||
self._run(
|
||||
[
|
||||
"ip",
|
||||
"link",
|
||||
"set",
|
||||
self.interface,
|
||||
"down"
|
||||
]
|
||||
)
|
||||
|
||||
self._run(
|
||||
[
|
||||
"iw",
|
||||
"dev",
|
||||
self.interface,
|
||||
"set",
|
||||
"type",
|
||||
"managed"
|
||||
]
|
||||
)
|
||||
|
||||
self._run(
|
||||
[
|
||||
"ip",
|
||||
"link",
|
||||
"set",
|
||||
self.interface,
|
||||
"up"
|
||||
]
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"WiFi interface restored to managed mode"
|
||||
)
|
||||
|
||||
|
||||
if self.restore_wpa:
|
||||
|
||||
self._run(
|
||||
[
|
||||
"systemctl",
|
||||
"start",
|
||||
"wpa_supplicant"
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
if self.restore_networkmanager:
|
||||
|
||||
self._run(
|
||||
[
|
||||
"systemctl",
|
||||
"start",
|
||||
"NetworkManager"
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
if self.restore_networkmanager:
|
||||
|
||||
self._run(
|
||||
[
|
||||
"nmcli",
|
||||
"device",
|
||||
"set",
|
||||
self.interface,
|
||||
"managed",
|
||||
"yes"
|
||||
]
|
||||
)
|
||||
|
||||
self._run(
|
||||
[
|
||||
"nmcli",
|
||||
"device",
|
||||
"connect",
|
||||
self.interface
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
self.log.info(
|
||||
"Network services restored"
|
||||
)
|
||||
@@ -0,0 +1,229 @@
|
||||
import csv
|
||||
import logging
|
||||
|
||||
|
||||
class OUIImporter:
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resolver,
|
||||
files
|
||||
):
|
||||
|
||||
self.logger = logging.getLogger(
|
||||
"oui-import"
|
||||
)
|
||||
|
||||
self.resolver = resolver
|
||||
|
||||
self.files = files
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Импорт IEEE
|
||||
# =====================================================
|
||||
|
||||
def import_database(self):
|
||||
|
||||
|
||||
count = 0
|
||||
|
||||
skipped = 0
|
||||
|
||||
|
||||
stats = {
|
||||
|
||||
"MA-L": 0,
|
||||
|
||||
"MA-M": 0,
|
||||
|
||||
"MA-S": 0
|
||||
|
||||
}
|
||||
|
||||
|
||||
for filename in self.files:
|
||||
|
||||
|
||||
if not filename.exists():
|
||||
|
||||
self.logger.warning(
|
||||
f"{filename.name} not found"
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
|
||||
self.logger.info(
|
||||
f"Importing {filename.name}"
|
||||
)
|
||||
|
||||
|
||||
with open(
|
||||
|
||||
filename,
|
||||
|
||||
"r",
|
||||
|
||||
encoding="utf-8",
|
||||
|
||||
errors="ignore"
|
||||
|
||||
) as file:
|
||||
|
||||
|
||||
reader = csv.DictReader(
|
||||
file
|
||||
)
|
||||
|
||||
|
||||
for row in reader:
|
||||
|
||||
|
||||
registry = row.get(
|
||||
"Registry"
|
||||
)
|
||||
|
||||
assignment = row.get(
|
||||
"Assignment"
|
||||
)
|
||||
|
||||
organization = row.get(
|
||||
"Organization Name"
|
||||
)
|
||||
|
||||
|
||||
if not registry:
|
||||
|
||||
continue
|
||||
|
||||
|
||||
if registry not in stats:
|
||||
|
||||
continue
|
||||
|
||||
|
||||
if not assignment:
|
||||
|
||||
skipped += 1
|
||||
|
||||
continue
|
||||
|
||||
|
||||
if not organization:
|
||||
|
||||
skipped += 1
|
||||
|
||||
continue
|
||||
|
||||
|
||||
assignment = (
|
||||
|
||||
assignment
|
||||
|
||||
.replace(
|
||||
"-",
|
||||
""
|
||||
)
|
||||
|
||||
.replace(
|
||||
":",
|
||||
""
|
||||
)
|
||||
|
||||
.replace(
|
||||
".",
|
||||
""
|
||||
)
|
||||
|
||||
.strip()
|
||||
|
||||
.upper()
|
||||
|
||||
)
|
||||
|
||||
|
||||
if not all(
|
||||
|
||||
char in "0123456789ABCDEF"
|
||||
|
||||
for char in assignment
|
||||
|
||||
):
|
||||
|
||||
skipped += 1
|
||||
|
||||
continue
|
||||
|
||||
|
||||
if len(
|
||||
assignment
|
||||
) not in (
|
||||
|
||||
6,
|
||||
|
||||
7,
|
||||
|
||||
9
|
||||
|
||||
):
|
||||
|
||||
skipped += 1
|
||||
|
||||
continue
|
||||
|
||||
|
||||
result = self.resolver.add_vendor(
|
||||
|
||||
assignment,
|
||||
|
||||
organization.strip(),
|
||||
|
||||
registry
|
||||
|
||||
)
|
||||
|
||||
|
||||
if result:
|
||||
|
||||
count += 1
|
||||
|
||||
stats[
|
||||
registry
|
||||
] += 1
|
||||
|
||||
else:
|
||||
|
||||
skipped += 1
|
||||
|
||||
|
||||
self.resolver.commit()
|
||||
|
||||
|
||||
total = self.resolver.count()
|
||||
|
||||
|
||||
self.logger.info(
|
||||
f"Imported: {count}"
|
||||
)
|
||||
|
||||
self.logger.info(
|
||||
f"Skipped: {skipped}"
|
||||
)
|
||||
|
||||
self.logger.info(
|
||||
f"Database records: {total}"
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
"imported": count,
|
||||
|
||||
"skipped": skipped,
|
||||
|
||||
"total": total,
|
||||
|
||||
"stats": stats
|
||||
|
||||
}
|
||||
+1199
File diff suppressed because it is too large
Load Diff
+278
@@ -0,0 +1,278 @@
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
||||
class Session:
|
||||
"""
|
||||
Управление текущей сессией захвата.
|
||||
|
||||
Одна сессия содержит:
|
||||
|
||||
- pcapng захват
|
||||
- GPS трек
|
||||
- результаты парсинга
|
||||
- hashcat файлы
|
||||
|
||||
Все данные одной поездки находятся
|
||||
внутри одного каталога.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
directory
|
||||
):
|
||||
|
||||
|
||||
#
|
||||
# Время запуска
|
||||
#
|
||||
self.start_time = datetime.now(
|
||||
timezone.utc
|
||||
)
|
||||
|
||||
|
||||
|
||||
#
|
||||
# Каталог сессии
|
||||
#
|
||||
name = self.start_time.strftime(
|
||||
"%Y%m%d_%H%M%S"
|
||||
)
|
||||
|
||||
|
||||
self.path = (
|
||||
Path(directory) / name
|
||||
)
|
||||
|
||||
|
||||
self.path.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Основной захват
|
||||
# =====================================================
|
||||
|
||||
|
||||
self.capture_file = (
|
||||
self.path /
|
||||
"capture.pcapng"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# =====================================================
|
||||
# GPS
|
||||
# =====================================================
|
||||
|
||||
|
||||
self.gps_file = (
|
||||
self.path /
|
||||
"gps_track.nmea"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Результаты hcxpcapngtool
|
||||
# =====================================================
|
||||
|
||||
|
||||
#
|
||||
# Список точек доступа
|
||||
#
|
||||
self.access_points_file = (
|
||||
self.path /
|
||||
"access_points.csv"
|
||||
)
|
||||
|
||||
|
||||
|
||||
#
|
||||
# Hashcat формат
|
||||
#
|
||||
# WPA-PBKDF2-PMKID+EAPOL
|
||||
#
|
||||
self.handshakes_file = (
|
||||
self.path /
|
||||
"handshakes.22000"
|
||||
)
|
||||
|
||||
|
||||
|
||||
#
|
||||
# ESSID список
|
||||
#
|
||||
self.essid_file = (
|
||||
self.path /
|
||||
"essid.txt"
|
||||
)
|
||||
|
||||
|
||||
|
||||
#
|
||||
# Identity список
|
||||
#
|
||||
self.identity_file = (
|
||||
self.path /
|
||||
"identities.txt"
|
||||
)
|
||||
|
||||
|
||||
|
||||
#
|
||||
# Лог работы парсера
|
||||
#
|
||||
self.parser_log = (
|
||||
self.path /
|
||||
"parser.log"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Статистика текущей сессии
|
||||
# =====================================================
|
||||
|
||||
|
||||
#
|
||||
# Все NMEA сообщения
|
||||
#
|
||||
self.nmea_messages = 0
|
||||
|
||||
|
||||
|
||||
#
|
||||
# Валидные GPS позиции
|
||||
#
|
||||
self.gps_positions = 0
|
||||
|
||||
|
||||
|
||||
#
|
||||
# Последняя координата
|
||||
#
|
||||
self.current_gps = None
|
||||
|
||||
@classmethod
|
||||
def from_existing(
|
||||
cls,
|
||||
path
|
||||
):
|
||||
|
||||
"""
|
||||
Открывает уже существующую
|
||||
сессию из каталога captures.
|
||||
"""
|
||||
|
||||
obj = cls.__new__(cls)
|
||||
|
||||
obj.start_time = datetime.now(
|
||||
timezone.utc
|
||||
)
|
||||
|
||||
obj.path = Path(path)
|
||||
|
||||
obj.capture_file = (
|
||||
obj.path /
|
||||
"capture.pcapng"
|
||||
)
|
||||
|
||||
obj.gps_file = (
|
||||
obj.path /
|
||||
"gps_track.nmea"
|
||||
)
|
||||
|
||||
obj.access_points_file = (
|
||||
obj.path /
|
||||
"access_points.csv"
|
||||
)
|
||||
|
||||
obj.handshakes_file = (
|
||||
obj.path /
|
||||
"handshakes.22000"
|
||||
)
|
||||
|
||||
obj.essid_file = (
|
||||
obj.path /
|
||||
"essid.txt"
|
||||
)
|
||||
|
||||
obj.identity_file = (
|
||||
obj.path /
|
||||
"identities.txt"
|
||||
)
|
||||
|
||||
obj.parser_log = (
|
||||
obj.path /
|
||||
"parser.log"
|
||||
)
|
||||
|
||||
obj.nmea_messages = 0
|
||||
|
||||
obj.gps_positions = 0
|
||||
|
||||
obj.current_gps = None
|
||||
|
||||
return obj
|
||||
|
||||
# =========================================================
|
||||
# Время работы
|
||||
# =========================================================
|
||||
|
||||
|
||||
def elapsed(
|
||||
self
|
||||
):
|
||||
|
||||
|
||||
return int(
|
||||
(
|
||||
datetime.now(
|
||||
timezone.utc
|
||||
)
|
||||
-
|
||||
self.start_time
|
||||
).total_seconds()
|
||||
)
|
||||
|
||||
|
||||
|
||||
# =========================================================
|
||||
# GPS статистика
|
||||
# =========================================================
|
||||
|
||||
|
||||
def add_nmea(self):
|
||||
|
||||
"""
|
||||
Увеличить счетчик
|
||||
полученных NMEA сообщений.
|
||||
"""
|
||||
|
||||
|
||||
self.nmea_messages += 1
|
||||
|
||||
|
||||
|
||||
def update_gps(
|
||||
self,
|
||||
gps
|
||||
):
|
||||
|
||||
"""
|
||||
Обновить последнюю GPS координату.
|
||||
"""
|
||||
|
||||
|
||||
self.current_gps = gps
|
||||
|
||||
|
||||
self.gps_positions += 1
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
class SudoChecker:
|
||||
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self.log = logging.getLogger(
|
||||
"sudo"
|
||||
)
|
||||
|
||||
|
||||
def check(self):
|
||||
|
||||
self.log.info(
|
||||
"Checking sudo..."
|
||||
)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"sudo",
|
||||
"-n",
|
||||
"-v"
|
||||
]
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"sudo",
|
||||
"-v"
|
||||
]
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
|
||||
self.log.error(
|
||||
"Sudo authentication cancelled."
|
||||
)
|
||||
|
||||
raise SystemExit(1)
|
||||
|
||||
if result.returncode != 0:
|
||||
|
||||
self.log.error(
|
||||
"Sudo authentication is not available."
|
||||
)
|
||||
|
||||
raise SystemExit(1)
|
||||
|
||||
self.log.info(
|
||||
"Sudo: OK"
|
||||
)
|
||||
|
||||
|
||||
def refresh(self):
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"sudo",
|
||||
"-n",
|
||||
"-v"
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
except Exception:
|
||||
|
||||
return False
|
||||
|
||||
return result.returncode == 0
|
||||
+704
@@ -0,0 +1,704 @@
|
||||
import sqlite3
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class VendorResolver:
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
database
|
||||
):
|
||||
|
||||
self.db_path = Path(database)
|
||||
|
||||
|
||||
self.db_path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
|
||||
self.conn = sqlite3.connect(
|
||||
self.db_path
|
||||
)
|
||||
|
||||
|
||||
self.create_tables()
|
||||
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Создание структуры
|
||||
# =====================================================
|
||||
|
||||
def create_tables(self):
|
||||
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS oui
|
||||
(
|
||||
|
||||
prefix TEXT PRIMARY KEY,
|
||||
|
||||
vendor TEXT NOT NULL,
|
||||
|
||||
confidence TEXT NOT NULL,
|
||||
|
||||
prefix_length INTEGER NOT NULL
|
||||
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS metadata
|
||||
(
|
||||
|
||||
key TEXT PRIMARY KEY,
|
||||
|
||||
value TEXT NOT NULL
|
||||
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS metadata
|
||||
(
|
||||
|
||||
key TEXT PRIMARY KEY,
|
||||
|
||||
value TEXT
|
||||
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_prefix
|
||||
|
||||
ON oui(prefix)
|
||||
"""
|
||||
)
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS metadata
|
||||
(
|
||||
|
||||
key TEXT PRIMARY KEY,
|
||||
|
||||
value TEXT
|
||||
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Определение производителя
|
||||
# =====================================================
|
||||
|
||||
def lookup(
|
||||
self,
|
||||
mac
|
||||
):
|
||||
|
||||
if not mac:
|
||||
|
||||
return None
|
||||
|
||||
|
||||
mac = self.normalize(
|
||||
mac
|
||||
)
|
||||
|
||||
|
||||
for length in (
|
||||
9,
|
||||
7,
|
||||
6
|
||||
):
|
||||
|
||||
prefix = mac[:length]
|
||||
|
||||
|
||||
cursor = self.conn.execute(
|
||||
"""
|
||||
SELECT vendor
|
||||
|
||||
FROM oui
|
||||
|
||||
WHERE prefix=?
|
||||
|
||||
""",
|
||||
(
|
||||
prefix,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
|
||||
if row:
|
||||
|
||||
return row[0]
|
||||
|
||||
|
||||
return "Unknown"
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Добавление производителя
|
||||
# =====================================================
|
||||
|
||||
def add_vendor(
|
||||
self,
|
||||
prefix,
|
||||
vendor,
|
||||
confidence="IEEE"
|
||||
):
|
||||
|
||||
prefix = self.normalize(prefix)
|
||||
|
||||
|
||||
if not prefix:
|
||||
|
||||
return False
|
||||
|
||||
|
||||
prefix_length = len(prefix)
|
||||
|
||||
|
||||
if prefix_length not in (6,7,9):
|
||||
|
||||
return False
|
||||
|
||||
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO oui
|
||||
(
|
||||
prefix,
|
||||
vendor,
|
||||
confidence,
|
||||
prefix_length
|
||||
)
|
||||
|
||||
VALUES (?,?,?,?)
|
||||
|
||||
""",
|
||||
|
||||
(
|
||||
prefix,
|
||||
vendor,
|
||||
confidence,
|
||||
prefix_length
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Сохранение изменений
|
||||
# =====================================================
|
||||
|
||||
def commit(self):
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Количество записей
|
||||
# =====================================================
|
||||
|
||||
def count(self):
|
||||
|
||||
cursor = self.conn.execute(
|
||||
"SELECT COUNT(*) FROM oui"
|
||||
)
|
||||
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
# =====================================================
|
||||
# Metadata
|
||||
# =====================================================
|
||||
|
||||
def get_metadata(
|
||||
self,
|
||||
key,
|
||||
default=None
|
||||
):
|
||||
|
||||
cursor = self.conn.execute(
|
||||
|
||||
"""
|
||||
SELECT value
|
||||
|
||||
FROM metadata
|
||||
|
||||
WHERE key=?
|
||||
|
||||
""",
|
||||
|
||||
(
|
||||
key,
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
|
||||
if row:
|
||||
|
||||
return row[0]
|
||||
|
||||
|
||||
return default
|
||||
|
||||
|
||||
|
||||
def set_metadata(
|
||||
self,
|
||||
key,
|
||||
value
|
||||
):
|
||||
|
||||
self.conn.execute(
|
||||
|
||||
"""
|
||||
INSERT OR REPLACE INTO metadata
|
||||
(
|
||||
key,
|
||||
value
|
||||
)
|
||||
|
||||
VALUES (?,?)
|
||||
|
||||
""",
|
||||
|
||||
(
|
||||
key,
|
||||
str(value)
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# Получить значение metadata
|
||||
# =====================================================
|
||||
|
||||
def get_metadata(
|
||||
self,
|
||||
key
|
||||
):
|
||||
|
||||
cursor = self.conn.execute(
|
||||
"""
|
||||
SELECT value
|
||||
|
||||
FROM metadata
|
||||
|
||||
WHERE key=?
|
||||
|
||||
""",
|
||||
(
|
||||
key,
|
||||
)
|
||||
)
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
|
||||
return row[0]
|
||||
|
||||
return None
|
||||
|
||||
# =====================================================
|
||||
# Сохранение metadata
|
||||
# =====================================================
|
||||
|
||||
def set_metadata(
|
||||
self,
|
||||
key,
|
||||
value
|
||||
):
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO metadata
|
||||
(
|
||||
key,
|
||||
value
|
||||
)
|
||||
|
||||
VALUES (?,?)
|
||||
""",
|
||||
(
|
||||
key,
|
||||
str(value)
|
||||
)
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# Записать значение metadata
|
||||
# =====================================================
|
||||
|
||||
def set_metadata(
|
||||
self,
|
||||
key,
|
||||
value
|
||||
):
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO metadata
|
||||
(
|
||||
key,
|
||||
value
|
||||
)
|
||||
|
||||
VALUES
|
||||
(
|
||||
?,
|
||||
?
|
||||
)
|
||||
""",
|
||||
(
|
||||
key,
|
||||
str(value)
|
||||
)
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# Удаление metadata
|
||||
# =====================================================
|
||||
|
||||
def delete_metadata(
|
||||
self,
|
||||
key
|
||||
):
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
DELETE
|
||||
|
||||
FROM metadata
|
||||
|
||||
WHERE key=?
|
||||
""",
|
||||
(
|
||||
key,
|
||||
)
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# Есть ли база производителей
|
||||
# =====================================================
|
||||
|
||||
def has_database(self):
|
||||
|
||||
cursor = self.conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM oui
|
||||
"""
|
||||
)
|
||||
|
||||
return cursor.fetchone()[0] > 0
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Требуется обновление
|
||||
# =====================================================
|
||||
|
||||
def update_available(self):
|
||||
|
||||
value = self.get_metadata(
|
||||
"update_available"
|
||||
)
|
||||
|
||||
return value == "1"
|
||||
|
||||
# =====================================================
|
||||
# Получение параметра metadata
|
||||
# =====================================================
|
||||
|
||||
def get_metadata(
|
||||
|
||||
self,
|
||||
|
||||
key
|
||||
|
||||
):
|
||||
|
||||
|
||||
cursor = self.conn.execute(
|
||||
|
||||
"""
|
||||
SELECT value
|
||||
|
||||
FROM metadata
|
||||
|
||||
WHERE key=?
|
||||
|
||||
""",
|
||||
|
||||
(
|
||||
key,
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
|
||||
if row:
|
||||
|
||||
return row[0]
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Запись параметра metadata
|
||||
# =====================================================
|
||||
|
||||
def set_metadata(
|
||||
|
||||
self,
|
||||
|
||||
key,
|
||||
|
||||
value
|
||||
|
||||
):
|
||||
|
||||
|
||||
self.conn.execute(
|
||||
|
||||
"""
|
||||
INSERT OR REPLACE INTO metadata
|
||||
|
||||
(
|
||||
|
||||
key,
|
||||
|
||||
value
|
||||
|
||||
)
|
||||
|
||||
VALUES
|
||||
|
||||
(
|
||||
|
||||
?,
|
||||
|
||||
?
|
||||
|
||||
)
|
||||
|
||||
""",
|
||||
|
||||
(
|
||||
|
||||
key,
|
||||
|
||||
str(value)
|
||||
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Получение всех параметров metadata
|
||||
# =====================================================
|
||||
|
||||
def get_all_metadata(
|
||||
|
||||
self
|
||||
|
||||
):
|
||||
|
||||
|
||||
cursor = self.conn.execute(
|
||||
|
||||
"""
|
||||
SELECT
|
||||
|
||||
key,
|
||||
|
||||
value
|
||||
|
||||
FROM metadata
|
||||
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
result = {}
|
||||
|
||||
|
||||
for row in cursor.fetchall():
|
||||
|
||||
result[
|
||||
row[0]
|
||||
] = row[1]
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Удаление параметра metadata
|
||||
# =====================================================
|
||||
|
||||
def delete_metadata(
|
||||
|
||||
self,
|
||||
|
||||
key
|
||||
|
||||
):
|
||||
|
||||
|
||||
self.conn.execute(
|
||||
|
||||
"""
|
||||
DELETE
|
||||
|
||||
FROM metadata
|
||||
|
||||
WHERE key=?
|
||||
|
||||
""",
|
||||
|
||||
(
|
||||
|
||||
key,
|
||||
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# Проверка metadata
|
||||
# =====================================================
|
||||
|
||||
def has_metadata(
|
||||
self,
|
||||
key
|
||||
):
|
||||
|
||||
cursor = self.conn.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
|
||||
FROM metadata
|
||||
|
||||
WHERE key=?
|
||||
|
||||
LIMIT 1
|
||||
""",
|
||||
(
|
||||
key,
|
||||
)
|
||||
)
|
||||
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
# =====================================================
|
||||
# Приведение MAC
|
||||
# =====================================================
|
||||
|
||||
def normalize(
|
||||
self,
|
||||
mac
|
||||
):
|
||||
|
||||
if not mac:
|
||||
|
||||
return None
|
||||
|
||||
|
||||
mac = mac.replace(
|
||||
":",
|
||||
""
|
||||
)
|
||||
|
||||
mac = mac.replace(
|
||||
"-",
|
||||
""
|
||||
)
|
||||
|
||||
mac = mac.replace(
|
||||
".",
|
||||
""
|
||||
)
|
||||
|
||||
|
||||
mac = mac.upper()
|
||||
|
||||
|
||||
return mac
|
||||
|
||||
# =====================================================
|
||||
# Получение metadata
|
||||
# =====================================================
|
||||
|
||||
def get_metadata(
|
||||
self,
|
||||
key,
|
||||
default=None
|
||||
):
|
||||
|
||||
cursor = self.conn.execute(
|
||||
"""
|
||||
SELECT value
|
||||
|
||||
FROM metadata
|
||||
|
||||
WHERE key=?
|
||||
""",
|
||||
(
|
||||
key,
|
||||
)
|
||||
)
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
|
||||
return row[0]
|
||||
|
||||
return default
|
||||
|
||||
# =====================================================
|
||||
# Закрытие
|
||||
# =====================================================
|
||||
|
||||
def close(self):
|
||||
|
||||
self.conn.close()
|
||||
@@ -0,0 +1,314 @@
|
||||
import logging
|
||||
import urllib.request
|
||||
import datetime
|
||||
import time
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from vendor import VendorResolver
|
||||
from oui_import import OUIImporter
|
||||
|
||||
|
||||
PROJECT_ROOT = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
)
|
||||
|
||||
|
||||
DATABASE_DIR = (
|
||||
PROJECT_ROOT
|
||||
/
|
||||
"database"
|
||||
)
|
||||
|
||||
|
||||
OUI_DATABASE = (
|
||||
DATABASE_DIR
|
||||
/
|
||||
"oui.sqlite"
|
||||
)
|
||||
|
||||
|
||||
HTTP_HEADERS = {
|
||||
|
||||
"User-Agent":
|
||||
|
||||
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||
|
||||
"AppleWebKit/537.36 "
|
||||
|
||||
"(KHTML, like Gecko) "
|
||||
|
||||
"Chrome/138.0 Safari/537.36"
|
||||
|
||||
}
|
||||
|
||||
|
||||
DOWNLOAD_RETRIES = 3
|
||||
|
||||
DOWNLOAD_TIMEOUT = 60
|
||||
|
||||
CSV_FILES = {
|
||||
|
||||
"oui.csv": "oui",
|
||||
|
||||
"mam.csv": "mam",
|
||||
|
||||
"oui36.csv": "oui36",
|
||||
|
||||
}
|
||||
|
||||
|
||||
class VendorImporter:
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config
|
||||
):
|
||||
|
||||
self.config = config
|
||||
|
||||
self.log = logging.getLogger(
|
||||
"vendor_import"
|
||||
)
|
||||
|
||||
DATABASE_DIR.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Загрузка IEEE CSV
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def download_csv_files(self):
|
||||
|
||||
for filename, key in CSV_FILES.items():
|
||||
|
||||
url = self.config.vendor_sources[key]
|
||||
|
||||
destination = (
|
||||
DATABASE_DIR
|
||||
/
|
||||
filename
|
||||
)
|
||||
|
||||
success = False
|
||||
|
||||
for attempt in range(
|
||||
1,
|
||||
DOWNLOAD_RETRIES + 1
|
||||
):
|
||||
|
||||
self.log.info(
|
||||
|
||||
f"Downloading {filename} "
|
||||
|
||||
f"(attempt {attempt}/{DOWNLOAD_RETRIES})"
|
||||
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers=HTTP_HEADERS
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(
|
||||
request,
|
||||
timeout=DOWNLOAD_TIMEOUT
|
||||
) as response:
|
||||
|
||||
if response.status != 200:
|
||||
|
||||
raise RuntimeError(
|
||||
f"HTTP {response.status}"
|
||||
)
|
||||
|
||||
data = response.read()
|
||||
|
||||
if len(data) < 1000:
|
||||
|
||||
raise RuntimeError(
|
||||
"Downloaded file is too small"
|
||||
)
|
||||
|
||||
text = data.decode(
|
||||
"utf-8",
|
||||
errors="ignore"
|
||||
)
|
||||
|
||||
if "Assignment" not in text:
|
||||
|
||||
raise RuntimeError(
|
||||
"Invalid IEEE CSV"
|
||||
)
|
||||
|
||||
destination.write_bytes(
|
||||
data
|
||||
)
|
||||
|
||||
size = (
|
||||
destination.stat().st_size
|
||||
/ 1024
|
||||
/ 1024
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
|
||||
f"{filename} downloaded "
|
||||
|
||||
f"({size:.2f} MB)"
|
||||
|
||||
)
|
||||
|
||||
success = True
|
||||
|
||||
break
|
||||
|
||||
except KeyboardInterrupt:
|
||||
|
||||
self.log.info(
|
||||
"CSV download cancelled"
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
except Exception as error:
|
||||
|
||||
self.log.warning(
|
||||
f"{filename}: {error}"
|
||||
)
|
||||
|
||||
if destination.exists():
|
||||
|
||||
destination.unlink()
|
||||
|
||||
if attempt < DOWNLOAD_RETRIES:
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
if not success:
|
||||
|
||||
raise RuntimeError(
|
||||
f"Unable to download {filename}"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Полное создание IEEE базы
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def bootstrap(self):
|
||||
|
||||
OUI_DATABASE.unlink(
|
||||
missing_ok=True
|
||||
)
|
||||
|
||||
self.download_csv_files()
|
||||
|
||||
resolver = VendorResolver(
|
||||
OUI_DATABASE
|
||||
)
|
||||
|
||||
importer = OUIImporter(
|
||||
|
||||
resolver,
|
||||
|
||||
[
|
||||
DATABASE_DIR
|
||||
/
|
||||
filename
|
||||
|
||||
for filename
|
||||
|
||||
in CSV_FILES
|
||||
]
|
||||
|
||||
)
|
||||
|
||||
result = importer.import_database()
|
||||
|
||||
total = result["total"]
|
||||
|
||||
stats = result["stats"]
|
||||
|
||||
if (
|
||||
|
||||
stats["MA-L"] < 35000
|
||||
|
||||
or
|
||||
|
||||
stats["MA-M"] < 5000
|
||||
|
||||
or
|
||||
|
||||
stats["MA-S"] < 5000
|
||||
|
||||
):
|
||||
|
||||
resolver.close()
|
||||
|
||||
OUI_DATABASE.unlink(
|
||||
missing_ok=True
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
|
||||
"IEEE database verification failed.\n\n"
|
||||
|
||||
f"MA-L: {stats['MA-L']}\n"
|
||||
|
||||
f"MA-M: {stats['MA-M']}\n"
|
||||
|
||||
f"MA-S: {stats['MA-S']}"
|
||||
|
||||
)
|
||||
|
||||
resolver.set_metadata(
|
||||
|
||||
"last_import",
|
||||
datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
).isoformat().replace(
|
||||
"+00:00",
|
||||
"Z"
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
resolver.set_metadata(
|
||||
|
||||
"records",
|
||||
|
||||
total
|
||||
|
||||
)
|
||||
|
||||
resolver.set_metadata(
|
||||
|
||||
"schema_version",
|
||||
|
||||
1
|
||||
|
||||
)
|
||||
|
||||
resolver.set_metadata(
|
||||
|
||||
"import_status",
|
||||
|
||||
"ok"
|
||||
|
||||
)
|
||||
|
||||
resolver.commit()
|
||||
|
||||
resolver.close()
|
||||
|
||||
self.log.info(
|
||||
|
||||
"IEEE database created: "
|
||||
|
||||
f"{total} records"
|
||||
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,373 @@
|
||||
import logging
|
||||
import urllib.request
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from vendor import VendorResolver
|
||||
from vendor_import import (
|
||||
VendorImporter,
|
||||
CSV_FILES
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(
|
||||
"vendor-update"
|
||||
)
|
||||
|
||||
|
||||
class VendorUpdater:
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cfg
|
||||
):
|
||||
|
||||
self.cfg = cfg
|
||||
|
||||
|
||||
self.project_root = (
|
||||
|
||||
Path(__file__)
|
||||
|
||||
.resolve()
|
||||
|
||||
.parent
|
||||
|
||||
.parent
|
||||
|
||||
)
|
||||
|
||||
|
||||
self.database = (
|
||||
|
||||
self.project_root
|
||||
|
||||
/ "database"
|
||||
|
||||
/ "oui.sqlite"
|
||||
|
||||
)
|
||||
|
||||
|
||||
self.csv_files = [
|
||||
|
||||
self.project_root
|
||||
/ "database"
|
||||
/ filename
|
||||
|
||||
for filename
|
||||
|
||||
in CSV_FILES
|
||||
|
||||
]
|
||||
|
||||
# =====================================================
|
||||
# Проверка CSV
|
||||
# =====================================================
|
||||
|
||||
def csv_exists(
|
||||
self
|
||||
):
|
||||
|
||||
for file in self.csv_files:
|
||||
|
||||
if not file.exists():
|
||||
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# =====================================================
|
||||
# Проверка базы
|
||||
# =====================================================
|
||||
|
||||
def database_exists(
|
||||
self
|
||||
):
|
||||
|
||||
return self.database.exists()
|
||||
|
||||
# =====================================================
|
||||
# Проверка metadata
|
||||
# =====================================================
|
||||
|
||||
def metadata_exists(
|
||||
self
|
||||
):
|
||||
|
||||
resolver = VendorResolver(
|
||||
self.database
|
||||
)
|
||||
|
||||
|
||||
result = resolver.has_metadata(
|
||||
"last_import"
|
||||
)
|
||||
|
||||
|
||||
resolver.close()
|
||||
|
||||
return result
|
||||
|
||||
# =====================================================
|
||||
# Создание базы IEEE
|
||||
# =====================================================
|
||||
|
||||
def create_database(
|
||||
self
|
||||
):
|
||||
|
||||
logger.info(
|
||||
"Creating IEEE OUI database..."
|
||||
)
|
||||
|
||||
|
||||
importer = VendorImporter(
|
||||
|
||||
self.cfg
|
||||
|
||||
)
|
||||
|
||||
|
||||
importer.import_database()
|
||||
|
||||
|
||||
logger.info(
|
||||
"IEEE database created"
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# Проверка IEEE базы
|
||||
# =====================================================
|
||||
|
||||
def check(
|
||||
self
|
||||
):
|
||||
|
||||
logger.info(
|
||||
"Checking IEEE vendor database..."
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# База уже существует
|
||||
#
|
||||
|
||||
if self.database_exists():
|
||||
|
||||
if self.database.stat().st_size < 10000:
|
||||
|
||||
logger.warning(
|
||||
"IEEE database corrupted"
|
||||
)
|
||||
|
||||
self.database.unlink()
|
||||
|
||||
self.check()
|
||||
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"IEEE database found"
|
||||
)
|
||||
|
||||
|
||||
if self.metadata_exists():
|
||||
|
||||
logger.info(
|
||||
"IEEE metadata found"
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
|
||||
logger.warning(
|
||||
"IEEE metadata missing"
|
||||
)
|
||||
|
||||
|
||||
resolver = VendorResolver(
|
||||
self.database
|
||||
)
|
||||
|
||||
if resolver.count() == 0:
|
||||
|
||||
resolver.close()
|
||||
|
||||
logger.warning(
|
||||
"IEEE database is empty"
|
||||
)
|
||||
|
||||
self.database.unlink()
|
||||
|
||||
self.check()
|
||||
|
||||
return
|
||||
|
||||
resolver.set_metadata(
|
||||
"last_import",
|
||||
"unknown"
|
||||
)
|
||||
|
||||
|
||||
resolver.commit()
|
||||
|
||||
resolver.close()
|
||||
|
||||
|
||||
logger.info(
|
||||
"IEEE metadata restored"
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
|
||||
#
|
||||
# Базы нет
|
||||
#
|
||||
|
||||
logger.warning(
|
||||
"IEEE database not found"
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Проверяем CSV
|
||||
#
|
||||
|
||||
if not self.csv_exists():
|
||||
|
||||
logger.warning(
|
||||
"IEEE CSV files not found"
|
||||
)
|
||||
|
||||
self.download_csv()
|
||||
|
||||
|
||||
logger.info(
|
||||
"IEEE CSV files found"
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Создаем базу
|
||||
#
|
||||
|
||||
self.create_database()
|
||||
|
||||
# =====================================================
|
||||
# Загрузка IEEE CSV
|
||||
# =====================================================
|
||||
|
||||
def download_csv(
|
||||
self
|
||||
):
|
||||
|
||||
|
||||
logger.info(
|
||||
"Downloading IEEE CSV files..."
|
||||
)
|
||||
|
||||
self.database.parent.mkdir(
|
||||
|
||||
parents=True,
|
||||
|
||||
exist_ok=True
|
||||
|
||||
)
|
||||
|
||||
downloads = [
|
||||
|
||||
(
|
||||
self.cfg.vendor_sources[key],
|
||||
|
||||
self.project_root
|
||||
/
|
||||
"database"
|
||||
/
|
||||
filename
|
||||
)
|
||||
|
||||
for filename, key
|
||||
|
||||
in CSV_FILES.items()
|
||||
|
||||
]
|
||||
|
||||
|
||||
for url, filename in downloads:
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Downloading {filename.name}"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
|
||||
urllib.request.urlretrieve(
|
||||
|
||||
url,
|
||||
|
||||
filename
|
||||
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
|
||||
logger.error(
|
||||
|
||||
f"Download failed: {filename.name}"
|
||||
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
|
||||
str(error)
|
||||
|
||||
)
|
||||
|
||||
|
||||
if not filename.exists():
|
||||
|
||||
raise RuntimeError(
|
||||
|
||||
f"{filename.name} download failed"
|
||||
|
||||
)
|
||||
|
||||
|
||||
if filename.stat().st_size < 1000:
|
||||
|
||||
raise RuntimeError(
|
||||
|
||||
f"{filename.name} is too small"
|
||||
|
||||
)
|
||||
|
||||
|
||||
size = (
|
||||
|
||||
filename.stat().st_size
|
||||
|
||||
/ 1024
|
||||
|
||||
/ 1024
|
||||
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
|
||||
f"{filename.name} downloaded "
|
||||
|
||||
f"({size:.2f} MB)"
|
||||
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
"IEEE CSV download finished"
|
||||
)
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import logging
|
||||
import subprocess
|
||||
import signal
|
||||
import shlex
|
||||
|
||||
|
||||
class WiFiManager:
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config,
|
||||
session,
|
||||
interface
|
||||
):
|
||||
|
||||
self.config = config
|
||||
|
||||
self.session = session
|
||||
|
||||
self.interface = interface
|
||||
|
||||
self.process = None
|
||||
|
||||
self.log = logging.getLogger(
|
||||
"wifi"
|
||||
)
|
||||
|
||||
|
||||
cmd = [
|
||||
"sudo",
|
||||
"-n",
|
||||
str(self.config.hcxdumptool),
|
||||
"-i",
|
||||
self.interface,
|
||||
"-w",
|
||||
str(self.session.capture_file),
|
||||
]
|
||||
|
||||
|
||||
|
||||
#
|
||||
# Каналы WiFi
|
||||
#
|
||||
# Каналы собираются отдельно для каждого
|
||||
# включённого диапазона.
|
||||
#
|
||||
# a - диапазон 2.4 GHz
|
||||
# b - диапазон 5 GHz
|
||||
# c - диапазон 6 GHz
|
||||
#
|
||||
|
||||
channels = []
|
||||
|
||||
|
||||
if self.config.wifi_enable_2_4ghz:
|
||||
|
||||
if self.config.wifi_channels_2_4ghz:
|
||||
|
||||
channels.append(
|
||||
self.config.wifi_channels_2_4ghz
|
||||
)
|
||||
|
||||
|
||||
if self.config.wifi_enable_5ghz:
|
||||
|
||||
if self.config.wifi_channels_5ghz:
|
||||
|
||||
channels.append(
|
||||
self.config.wifi_channels_5ghz
|
||||
)
|
||||
|
||||
|
||||
if self.config.wifi_enable_6ghz:
|
||||
|
||||
if self.config.wifi_channels_6ghz:
|
||||
|
||||
channels.append(
|
||||
self.config.wifi_channels_6ghz
|
||||
)
|
||||
|
||||
|
||||
if channels:
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"-c",
|
||||
",".join(channels)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
||||
#
|
||||
# Дополнительные параметры
|
||||
#
|
||||
# Берутся из config.yaml
|
||||
#
|
||||
# Позволяют добавлять новые ключи
|
||||
# без изменения программы
|
||||
#
|
||||
|
||||
if self.config.wifi_extra_args:
|
||||
|
||||
|
||||
cmd.extend(
|
||||
shlex.split(
|
||||
self.config.wifi_extra_args
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
self.cmd = cmd
|
||||
|
||||
|
||||
def start(self):
|
||||
|
||||
if self.process is not None:
|
||||
|
||||
return
|
||||
|
||||
self.log.info(
|
||||
"Starting hcxdumptool"
|
||||
)
|
||||
|
||||
self.log.debug(
|
||||
"Command: %s",
|
||||
" ".join(self.cmd)
|
||||
)
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
self.cmd
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"hcxdumptool started, pid=%s",
|
||||
self.process.pid
|
||||
)
|
||||
|
||||
|
||||
def stop(self):
|
||||
|
||||
|
||||
if not self.process:
|
||||
|
||||
return
|
||||
|
||||
|
||||
|
||||
self.log.info(
|
||||
"Stopping hcxdumptool"
|
||||
)
|
||||
|
||||
|
||||
|
||||
self.process.send_signal(
|
||||
signal.SIGTERM
|
||||
)
|
||||
|
||||
|
||||
|
||||
try:
|
||||
|
||||
self.process.wait(
|
||||
timeout=10
|
||||
)
|
||||
|
||||
|
||||
self.log.info(
|
||||
|
||||
"hcxdumptool stopped with code %s",
|
||||
|
||||
self.process.returncode
|
||||
|
||||
)
|
||||
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
|
||||
|
||||
self.log.warning(
|
||||
"hcxdumptool did not stop, killing..."
|
||||
)
|
||||
|
||||
|
||||
self.process.kill()
|
||||
|
||||
self.process.wait(
|
||||
timeout=5
|
||||
)
|
||||
|
||||
|
||||
self.process = None
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user