Update public source
This commit is contained in:
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user