Update public source

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